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 shader

In 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.