Cascaded Shadow Mapping Split Selection in GLSL
Cascaded Shadow Mapping (CSM) resolves perspective aliasing across large outdoor environments by dividing the view frustum into multiple depth ranges, each rendered into a separate shadow map. In a GLSL fragment shader, the core mechanism for selecting the appropriate shadow map split relies on measuring the fragment's distance from the camera in view space, evaluating this value against an array of predefined split distances, and using the resulting cascade index to sample the correct shadow map and transformation matrix.
Calculating View-Space Depth
Before selecting a cascade, the fragment shader needs a reliable
measurement of distance along the camera’s view axis. While
gl_FragCoord.z provides normalized device coordinate (NDC)
depth, it is non-linear and less intuitive to compare against linear
frustum splits. The standard approach uses the fragment's view-space
position, calculated either in the vertex shader and passed as an
interpolated varying, or reconstructed from the depth
buffer:
in vec3 vViewPosition; // Interpolated view-space position from vertex shaderIn standard OpenGL view space, the camera faces down the negative
Z-axis. Consequently, the linear distance along the view vector is
-vViewPosition.z (or
abs(vViewPosition.z)).
Split Distance Comparison
The CPU computes the split distances (often using logarithmic, uniform, or practical split schemes) and passes them to the fragment shader via a uniform array.
#define NUM_CASCADES 4
uniform float uCascadeSplits[NUM_CASCADES];
uniform mat4 uLightSpaceMatrices[NUM_CASCADES];
uniform sampler2DArray uShadowMapArray;To determine which cascade contains the fragment, the shader compares the fragment's linear depth against the cascade thresholds:
int selectCascade(float depth) {
int cascadeIndex = 0;
for (int i = 0; i < NUM_CASCADES - 1; ++i) {
if (depth > uCascadeSplits[i]) {
cascadeIndex = i + 1;
}
}
return cascadeIndex;
}Because NUM_CASCADES is typically small (3 to 5),
compilers easily unroll this loop.
Branchless Cascade Selection
On architectures where dynamic branching might introduce warp
divergence, selection can be implemented branchlessly using the GLSL
step function:
int selectCascadeBranchless(float depth) {
vec4 comparisons = step(vec4(uCascadeSplits[0], uCascadeSplits[1], uCascadeSplits[2], uCascadeSplits[3]), vec4(depth));
return int(dot(comparisons, vec4(1.0)));
}step(edge, x) returns 1.0 if
x >= edge and 0.0 otherwise. Taking the dot
product with a vector of ones sums the successful comparisons, directly
producing the target index.
Sampling the Selected Cascade
Once the cascade index is identified, the fragment is transformed
into the corresponding light space. Using a sampler2DArray
simplifies texture sampling because the cascade index directly
corresponds to the array slice:
float depth = -vViewPosition.z;
int layer = selectCascade(depth);
// Transform world position into light-space for the selected cascade
vec4 lightSpacePos = uLightSpaceMatrices[layer] * vec4(vWorldPosition, 1.0);
vec3 projCoords = (lightSpacePos.xyz / lightSpacePos.w) * 0.5 + 0.5;
// Sample the 2D texture array using projCoords.xy and the layer index
float currentDepth = projCoords.z;
float shadow = texture(uShadowMapArray, vec3(projCoords.xy, float(layer))).r < currentDepth ? 0.0 : 1.0;Cascade Boundary Blending
Hard boundaries between cascades can cause visible transitions where
shadow resolution shifts abruptly. To eliminate this artifact, determine
how close the fragment is to the active split threshold. If the fragment
lies within a small blend margin, sample both the current cascade and
the subsequent cascade, then linearly interpolate (mix) the
shadow values based on the distance through the boundary region.