Exponential Shadow Maps in GLSL

This article provides a concise guide to the mathematical formulation and GLSL implementation of Exponential Shadow Maps (ESM). ESM allows for hardware-accelerated filtering and pre-filtering of shadow maps by replacing the standard non-linear shadow test with a smooth exponential approximation. Below, you will find the theoretical foundation of this technique along with direct GLSL shader implementations for both the shadow map generation and lighting evaluation stages.


Mathematical Foundation of ESM

Standard shadow mapping evaluates visibility using the Heaviside step function \(H(d - z)\), where \(d\) is the stored occluder depth from the light's point of view, and \(z\) is the receiver depth:

\[ V(z, d) = \begin{cases} 1 & \text{if } z \le d \\ 0 & \text{if } z > d \end{cases} \]

Because the step function is non-linear, linear filtering (such as bilinear interpolation or Gaussian blur) cannot be applied directly to depth maps without causing visual artifacts. ESM approximates the step function using an exponential decay function parameterized by a scaling factor \(c > 0\):

\[ V(z, d) \approx \min\left(1, e^{-c(z - d)}\right) = \min\left(1, e^{-cz} \cdot e^{cd}\right) \]

The factor \(c\) controls the steepness of the approximation. Higher values reduce light bleeding around edges but increase the risk of floating-point overflow.

Because the exponential property splits the term into \(e^{cd}\) and \(e^{-cz}\), spatial filtering can be distributed across occluder samples:

\[ \sum_i w_i V(z, d_i) \approx e^{-cz} \sum_i w_i e^{cd_i} \]

This allows the shadow map to store \(e^{cd}\) and be pre-filtered using standard linear blurring algorithms (e.g., Gaussian blur, MIP-mapping) prior to the lighting evaluation pass.


GLSL Implementation

ESM requires two rendering steps: generating the exponential depth map and sampling the filtered map during the lighting pass.

1. Shadow Map Generation Pass

In the fragment shader that writes to the shadow map, convert the fragment's depth to an exponential value. Linear depth in the range \([0, 1]\) should be used rather than non-linear projection depth.

// Shadow Map Fragment Shader
#version 330 core

in vec4 v_LightSpacePosition;
out float FragColor;

uniform float c; // ESM constant (typically between 40.0 and 80.0)

void main() {
    // Calculate normalized linear depth in [0, 1] range
    float depth = v_LightSpacePosition.z / v_LightSpacePosition.w;
    depth = depth * 0.5 + 0.5;

    // Store the exponential depth
    FragColor = exp(c * depth);
}

Note: The target shadow texture should use a 32-bit floating-point format (GL_R32F) to prevent arithmetic overflow from the exponential operator.

2. Lighting Pass Evaluation

In the lighting fragment shader, sample the pre-filtered shadow map, compute the exponential decay using the receiver depth, and clamp the visibility factor to \([0, 1]\).

// Lighting Fragment Shader
#version 330 core

in vec4 v_FragPosLightSpace;
out vec4 FinalColor;

uniform sampler2D shadowMap;
uniform float c; // Must match the constant used during the shadow pass

float computeESMShadow(vec4 fragPosLightSpace) {
    // Perform perspective division
    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
    projCoords = projCoords * 0.5 + 0.5;

    // Return fully lit if outside the light frustum
    if (projCoords.z > 1.0 || projCoords.x < 0.0 || projCoords.x > 1.0 || 
        projCoords.y < 0.0 || projCoords.y > 1.0) {
        return 1.0;
    }

    float receiverDepth = projCoords.z;
    
    // Sample the filtered exponential occluder depth (sum of w_i * exp(c * d_i))
    float filteredOccluderExp = texture(shadowMap, projCoords.xy).r;

    // Calculate visibility: exp(-c * z) * exp(c * d)
    float shadow = filteredOccluderExp * exp(-c * receiverDepth);

    // Clamp to [0, 1] to remove false darkening on fully lit areas
    return clamp(shadow, 0.0, 1.0);
}

void main() {
    float shadow = computeESMShadow(v_FragPosLightSpace);
    
    vec3 baseColor = vec3(0.8); // Example surface color
    vec3 litColor = baseColor * shadow;

    FinalColor = vec4(litColor, 1.0);
}

Avoiding Numerical Overflow (Log-Space Alternative)

When using lower precision targets (such as 16-bit floats) or very high \(c\) values, \(e^{cd}\) may exceed floating-point limits. In such cases, compute visibility using the equivalent natural logarithm representation:

\[ \ln(V) = \ln\left( \sum_i w_i e^{cd_i} \right) - cz \]

This is implemented during sampling as:

float shadow = clamp(exp(log(filteredOccluderExp) - c * receiverDepth), 0.0, 1.0);