Rendering Colored Shadows from Stained Glass in GLSL
Rendering translucent colored shadows—such as light filtering through stained glass—requires extending traditional depth-based shadow mapping to capture chromatic attenuation and light transmittance. This article details the multi-pass algorithm for simulating these shadows in GLSL, covering shadow map generation, light absorption using the Beer-Lambert law, and the final scene shading pass that projects colored light patterns onto receiving geometry.
Algorithm Overview
Traditional shadow mapping only stores the closest depth value to the light source, which turns any occluder into a completely black shadow. To simulate translucent colored shadows, the pipeline is divided into two phases:
- Light Space Shadow Pass: Render the scene from the
light's perspective into two separate buffers (or a multi-render
target):
- A traditional depth map storing opaque occluders.
- A color/transmittance map storing the filtered color and optical depth of translucent surfaces.
- Camera Lighting Pass: When shading standard scene geometry, test whether a point is occluded by opaque objects. If it is clear of opaque shadows, sample the transmittance texture to tint the incoming light before evaluating the local BRDF.
Step 1: Generating the Colored Shadow Map
For the stained glass object, the fragment shader in the light's pass needs to write the translucent color and the depth at which the glass exists. If the stained glass is a thin plane, you can simply output the stained glass texture color directly. For volumetric glass with thickness, use the Beer-Lambert law to compute absorption.
Stained Glass Fragment Shader (Light Pass)
#version 330 core
in vec2 vUV;
in float vDepth;
layout(location = 0) out vec4 FragColor;
uniform sampler2D uStainedGlassTexture;
uniform vec3 uExtinctionCoefficients; // Color absorption rates per RGB channel
uniform float uThickness; // Physical thickness of the glass
void main() {
vec4 glassBase = texture(uStainedGlassTexture, vUV);
// If fully transparent, discard fragment to avoid casting shadows
if (glassBase.a < 0.01) {
discard;
}
// Beer-Lambert transmission: T = exp(-extinction * distance)
vec3 absorption = uExtinctionCoefficients * (1.0 - glassBase.rgb);
vec3 transmittance = exp(-absorption * uThickness);
// Store transmitted light color and store depth in alpha
FragColor = vec4(transmittance * glassBase.rgb, gl_FragCoord.z);
}Step 2: Scene Lighting Pass
When rendering receiving surfaces (such as the floor beneath the stained glass), transform the fragment's world coordinates into the light's clip space. Then, compare depths against both the opaque depth map and the translucent shadow map.
Receiver Fragment Shader (Camera Pass)
#version 330 core
in vec3 vWorldPos;
in vec3 vNormal;
in vec4 vLightSpacePos;
out vec4 FragColor;
uniform sampler2D uOpaqueShadowMap; // Standard depth component texture
uniform sampler2D uTranslucentShadowMap; // Transmittance RGB + depth A
uniform vec3 uLightColor;
uniform vec3 uLightPos;
float CalculateOpaqueShadow(vec4 lightSpacePos, float bias) {
vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w;
projCoords = projCoords * 0.5 + 0.5;
if (projCoords.z > 1.0) return 0.0;
float closestDepth = texture(uOpaqueShadowMap, projCoords.xy).r;
float currentDepth = projCoords.z;
return (currentDepth - bias > closestDepth) ? 1.0 : 0.0;
}
vec3 CalculateTranslucentLight(vec4 lightSpacePos, float bias) {
vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w;
projCoords = projCoords * 0.5 + 0.5;
if (projCoords.z > 1.0) return uLightColor;
vec4 glassSample = texture(uTranslucentShadowMap, projCoords.xy);
float glassDepth = glassSample.a;
vec3 glassTransmittance = glassSample.rgb;
float currentDepth = projCoords.z;
// Check if the current surface is behind the stained glass
if (currentDepth - bias > glassDepth && glassDepth > 0.0) {
// Attenuate light color by the glass transmittance
return uLightColor * glassTransmittance;
}
// Fully illuminated by the light
return uLightColor;
}
void main() {
vec3 N = normalize(vNormal);
vec3 L = normalize(uLightPos - vWorldPos);
float NdotL = max(dot(N, L), 0.0);
float bias = max(0.005 * (1.0 - NdotL), 0.001);
// 1. Evaluate opaque shadow
float opaqueShadow = CalculateOpaqueShadow(vLightSpacePos, bias);
if (opaqueShadow > 0.5) {
// Fully occluded by opaque geometry
FragColor = vec4(vec3(0.0), 1.0);
return;
}
// 2. Evaluate filtered light through stained glass
vec3 filteredLight = CalculateTranslucentLight(vLightSpacePos, bias);
// 3. Compute final diffuse contribution
vec3 diffuse = filteredLight * NdotL;
FragColor = vec4(diffuse, 1.0);
}Step 3: Handling Overlapping Panes and Soft Filtering
To improve realism and eliminate rendering artifacts:
- Multiple Translucent Layers: If multiple stained glass objects overlap from the light's point of view, standard blending does not capture proper depth ordering without sorting. For advanced configurations, use order-independent transparency (OIT) structures, such as Linked Lists via GLSL shader storage buffer objects (SSBOs), to accumulate transmittance values along the light ray.
- Percentage Closer Filtering (PCF): Sample
uTranslucentShadowMapwith a multi-tap Poisson disk or standard kernel to produce soft edges on the projected colored shadows, matching the blur radius of typical penumbra techniques.