Computing Logarithmic Shadow Cascade Splits in GLSL

Cascaded Shadow Maps (CSM) mitigate perspective aliasing by dividing the view frustum along the depth axis into separate partitions, each rendered to a dedicated depth texture. Because perspective projection compresses screen-space area relative to view depth non-linearly, uniform depth slicing severely under-allocates shadow texels near the near clipping plane while wasting fidelity on distant geometry. This article explains the mathematical derivation of optimal non-uniform logarithmic split distributions and demonstrates how to implement cascade depth evaluation and selection directly within GLSL shaders.

The Mathematics of Frustum Slicing

Perspective aliasing causes the world-space footprint of a screen pixel to grow proportionally with view-space depth \(z\). In a standard perspective view defined by a near clip plane \(n\) and far clip plane \(f\), divided into \(m\) cascades, linear uniform slicing computes the \(i\)-th partition boundary as:

\[Z_{i,\text{uniform}} = n + (f - n) \left( \frac{i}{m} \right)\]

This linear distribution fails to match the hyperbolic rate of perspective projection. To maintain an approximately constant ratio between world-space shadow texel size and screen-space pixel footprint across all cascades, boundaries must follow an exponential progression:

\[Z_{i,\text{log}} = n \left( \frac{f}{n} \right)^{\frac{i}{m}}\]

While a pure logarithmic distribution theoretically minimizes perspective aliasing, it concentrates cascades aggressively close to the camera, which can cause severe edge aliasing in moderately distant scenes. The standard industry solution is the practical split scheme (PSSM), a weighted combination governed by a parameter \(\lambda \in [0.0, 1.0]\):

\[Z_i = \lambda Z_{i,\text{log}} + (1 - \lambda) Z_{i,\text{uniform}}\]

A \(\lambda\) value between \(0.5\) and \(0.85\) generally provides the optimal trade-off between perspective error reduction and overall depth coverage.

Precomputing Splits vs. Runtime Evaluation

Although split plane distances can be precomputed on the CPU and passed via Uniform Buffer Objects (UBOs), calculating or transforming them in GLSL allows procedural materials, compute passes, or dynamic near/far updates to determine cascade layers dynamically.

The following GLSL snippet demonstrates calculating non-uniform split distances dynamically for an arbitrary cascade index \(i\):

// Computes the far plane distance for cascade index 'i' out of 'cascadeCount'
float computeCascadeSplit(int i, int cascadeCount, float nearPlane, float farPlane, float lambda) {
    float ratio = float(i) / float(cascadeCount);
    
    // Logarithmic split: ideal for perspective error minimization
    float logSplit = nearPlane * pow(farPlane / nearPlane, ratio);
    
    // Uniform split: linear distribution
    float uniformSplit = nearPlane + (farPlane - nearPlane) * ratio;
    
    // Practical split scheme interpolation
    return mix(uniformSplit, logSplit, lambda);
}

Cascade Selection in the Fragment Shader

In the main rendering pass, the fragment shader must identify which cascade slice covers the current fragment. Cascade determination should always use the fragment's linear view-space depth (\(z_v = -(\text{ViewMatrix} \times \text{WorldPos}).z\)) rather than non-linear window depth (gl_FragCoord.z).

When splits are supplied via a uniform array pre-populated using the logarithmic formula:

#version 450 core

layout(std140, binding = 0) uniform ShadowData {
    mat4 u_LightViewProjection[4];
    vec4 u_CascadeSplits; // Split depths in view space: x = slice 0, y = slice 1, etc.
    float u_CascadeCount;
};

in vec3 v_ViewPosition; // View-space position from vertex shader
out vec4 FragColor;

int getCascadeIndex(float linearDepth) {
    int layer = 0;
    for (int i = 0; i < int(u_CascadeCount) - 1; ++i) {
        if (linearDepth > u_CascadeSplits[i]) {
            layer = i + 1;
        }
    }
    return layer;
}

void main() {
    float depth = abs(v_ViewPosition.z);
    int cascadeIndex = getCascadeIndex(depth);

    // Map fragment to the corresponding cascade light-space transform
    vec4 lightSpacePos = u_LightViewProjection[cascadeIndex] * vec4(v_WorldPosition, 1.0);
    
    // Proceed with shadow map projection and depth comparison...
    FragColor = vec4(vec3(float(cascadeIndex) / u_CascadeCount), 1.0);
}

Direct Mathematical Selection Without Branching

When purely logarithmic distributions are used without a linear mix (\(\lambda = 1.0\)), the cascade index can be evaluated analytically in \(O(1)\) complexity without looping or branching over split arrays:

int getLogCascadeIndex(float linearDepth, float nearPlane, float farPlane, int cascadeCount) {
    // Clamp to valid range
    float clampedDepth = clamp(linearDepth, nearPlane, farPlane);
    
    // Invert the formula: Z_i = n * (f / n)^(i / m)
    float factor = log2(clampedDepth / nearPlane) / log2(farPlane / nearPlane);
    
    return clamp(int(floor(factor * float(cascadeCount))), 0, cascadeCount - 1);
}

This analytical inversion evaluates logarithmic bounds continuously, eliminating array lookups when constructing unified depth hierarchies across vast draw distances.