Blending Cascaded Shadow Map Splits in GLSL
Cascaded Shadow Maps (CSM) often suffer from noticeable seam artifacts and sudden resolution transitions where adjacent cascades meet. To eliminate these visual disruptions, developers use boundary blend band cross-fading, an algorithm that identifies fragments falling within a designated threshold near a cascade edge and smoothly interpolates the shadow values of adjacent cascades. This article details the mathematical formulation, implementation steps, and GLSL code required to achieve seamless cascade transitions.
The Boundary Blend Band Algorithm
The transition algorithm works by calculating a normalized blending weight \(\alpha \in [0, 1]\) inside a specified boundary zone. While a standard CSM implementation selects a single cascade index \(i\) based on view-space depth or coordinate bounds, the blend band algorithm samples both cascade \(i\) and cascade \(i+1\) whenever a fragment falls within the transition region, linearly interpolating between them.
The transition zone can be defined using two approaches:
- View-Space Depth Split Blending: Evaluates distance between the fragment's linear view depth and the cascade split plane.
- Normalized Device Coordinate (NDC) Edge Blending: Evaluates the proximity of the light-space projected coordinates to the texture boundaries (\([0, 1]\) or \([-1, 1]\)). Edge blending is generally superior because it accounts for the actual spatial coverage of the light frustum.
Step 1: Calculate the Boundary Blend Factor
In NDC edge blending, the light-space coordinate of cascade \(i\) ranges from \([0, 1]\) across \(X\) and \(Y\). The distance of the projected coordinate \((u, v)\) to the nearest edge is calculated as:
\[\text{edgeDist} = \min(\min(u, 1.0 - u), \min(v, 1.0 - v))\]
Given a predefined blend band width \(w \in (0, 0.5)\) (typically \(0.1\) for a 10% transition band), the blend weight \(\alpha\) is derived:
\[\alpha = \text{clamp}\left(1.0 - \frac{\text{edgeDist}}{w}, 0.0, 1.0\right)\]
When the coordinate is outside the margin, \(\alpha = 0.0\) (only cascade \(i\) is used). As it approaches the border, \(\alpha\) approaches \(1.0\) (transitioning to cascade \(i+1\)).
Step 2: Sample and Cross-Fade
- Determine the active cascade index \(i\) for the fragment.
- Compute the shadow factor \(S_i\) using Percentage-Closer Filtering (PCF) on cascade \(i\).
- Compute the blend factor \(\alpha\).
- If \(\alpha > 0.0\) and cascade
\(i+1\) exists:
- Project the world position into cascade \(i+1\).
- Sample shadow factor \(S_{i+1}\) from cascade \(i+1\).
- Compute final shadow: \(S = \text{mix}(S_i, S_{i+1}, \alpha)\).
- If \(\alpha = 0.0\) or \(i\) is the final cascade, \(S = S_i\).
GLSL Implementation
Below is a complete GLSL fragment shader implementation demonstrating boundary band edge blending across cascade splits:
#version 450 core
in vec3 v_WorldPos;
in vec3 v_Normal;
out vec4 FragColor;
layout(binding = 0) uniform sampler2DArrayShadow u_ShadowMapArray;
const int CASCADE_COUNT = 4;
const float BLEND_BAND_WIDTH = 0.1; // 10% boundary band
layout(std140, binding = 1) uniform LightMatrices {
mat4 u_LightViewProj[CASCADE_COUNT];
float u_CascadeEndDepths[CASCADE_COUNT];
};
float SampleShadow(int cascadeIndex, vec3 coords) {
return texture(u_ShadowMapArray, vec4(coords.xy, float(cascadeIndex), coords.z));
}
float CalculateBlendFactor(vec2 uv, float bandWidth) {
// Find shortest distance to boundary [0, 1]
vec2 edgeDist = min(uv, 1.0 - uv);
float minEdgeDist = min(edgeDist.x, edgeDist.y);
// Normalize factor: 0.0 inside safe area, 1.0 at edge
return clamp((bandWidth - minEdgeDist) / bandWidth, 0.0, 1.0);
}
float GetCascadedShadow(vec3 worldPos, int cascadeIndex) {
vec4 lightSpacePos = u_LightViewProj[cascadeIndex] * vec4(worldPos, 1.0);
vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w;
projCoords = projCoords * 0.5 + 0.5; // Map [-1, 1] to [0, 1]
float shadow = SampleShadow(cascadeIndex, projCoords);
if (cascadeIndex < CASCADE_COUNT - 1) {
float blendFactor = CalculateBlendFactor(projCoords.xy, BLEND_BAND_WIDTH);
if (blendFactor > 0.0) {
vec4 nextLightSpacePos = u_LightViewProj[cascadeIndex + 1] * vec4(worldPos, 1.0);
vec3 nextProjCoords = nextLightSpacePos.xyz / nextLightSpacePos.w;
nextProjCoords = nextProjCoords * 0.5 + 0.5;
float nextShadow = SampleShadow(cascadeIndex + 1, nextProjCoords);
shadow = mix(shadow, nextShadow, blendFactor);
}
}
return shadow;
}
void main() {
// Determine base cascade via linear depth comparison
float depth = gl_FragCoord.z / gl_FragCoord.w;
int cascadeIndex = CASCADE_COUNT - 1;
for (int i = 0; i < CASCADE_COUNT - 1; ++i) {
if (depth < u_CascadeEndDepths[i]) {
cascadeIndex = i;
break;
}
}
float shadowFactor = GetCascadedShadow(v_WorldPos, cascadeIndex);
FragColor = vec4(vec3(shadowFactor), 1.0);
}Performance Considerations
- Conditional Branching: The dynamic branch
if (blendFactor > 0.0)skips secondary shadow lookups for fragments located within the center of a cascade, preserving memory bandwidth across the majority of the screen. - Band Width Selection: A blend band width between
0.05and0.15offers the best balance between hiding resolution seams and minimizing pixels that require double sampling.