Exponential Shadow Maps in GLSL: Light Attenuation
This article explains how a OpenGL Shading Language (GLSL) fragment shader calculates light attenuation across Exponential Shadow Map (ESM) boundaries. By replacing the discrete step function of standard shadow mapping with continuous exponential functions, ESM allows hardware-accelerated filtering (such as bilinear filtering or Gaussian blur) directly on the shadow texture. The following sections detail the mathematical foundation of ESM, how boundary transitions produce soft penumbrae, and the practical implementation of this attenuation calculation inside a GLSL shader.
The Mathematical Foundation of ESM
Standard shadow mapping compares the fragment's depth from the light's perspective (\(d\)) to the stored depth in the shadow map (\(z\)). This step function creates harsh, binary aliased edges that cannot be filtered using standard bilinear interpolation.
Exponential Shadow Maps approximate this step function by expressing shadow visibility as an exponential decay:
\[f(d, z) = \min(1.0, e^{-c(d - z)}) = \min(1.0, e^{c \cdot z} \cdot e^{-c \cdot d})\]
Where:
- \(d\) is the receiver depth (fragment distance from the light).
- \(z\) is the occluder depth stored in the shadow map.
- \(c\) is a user-defined positive constant controlling the sharpness of the transition and the rate of exponential decay.
Because \(e^{c \cdot z}\) depends solely on occluder depth, it can be computed during shadow map generation and pre-filtered across neighboring texels. The filtered value stored in the shadow texture, denoted as \(M\), represents a linear combination of exponential depth values:
\[M = \sum_{i} w_i e^{c \cdot z_i}\]
Calculating Attenuation Across Boundaries
When a fragment lies near a shadow boundary, the texture sample coordinates straddle both occluded and unoccluded light texels. Because the texture contains \(e^{c \cdot z}\) rather than linear depth \(z\), bilinear texture filtering smoothly blends the terms into an averaged occluder intensity \(M\).
In the fragment shader, light attenuation across the boundary is resolved in three steps:
Receiver Term Computation: The shader computes \(e^{-c \cdot d}\) using the current fragment's light-space depth \(d\).
Convolution Evaluation: The shader multiplies the sampled filtered occluder depth \(M\) by the receiver term:
\[\text{visibility} = M \cdot e^{-c \cdot d}\]
Clamping:
- When a fragment is fully in light (\(d \le z\)), \(e^{-c(d - z)} \ge 1.0\). The value is clamped to \(1.0\).
- When a fragment is partially occluded across a boundary, the filtered \(M\) yields a smooth transition between \(0.0\) and \(1.0\), simulating a penumbra.
- Deep in shadow (\(d \gg z\)), the value rapidly decays to \(0.0\).
GLSL Shader Implementation
Below is the GLSL fragment code demonstrating how the shadow factor is calculated and applied to lighting:
#version 330 core
in vec4 vLightSpacePos;
in vec3 vNormal;
in vec3 vWorldPos;
out vec4 FragColor;
uniform sampler2D shadowMap;
uniform float c; // ESM constant, typically between 40.0 and 80.0
float calculateESMAttenuation(vec4 lightSpacePos, float c)
{
// Perform perspective divide to obtain Normalized Device Coordinates (NDC)
vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w;
// Transform from [-1, 1] NDC to [0, 1] texture coordinates
projCoords = projCoords * 0.5 + 0.5;
// Return full illumination if outside the light projection 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;
}
// Sample the pre-filtered exponential occluder term (M)
float occluderExp = texture(shadowMap, projCoords.xy).r;
// Calculate the receiver exponential term
float receiverExp = exp(-c * projCoords.z);
// Compute the attenuated shadow factor and clamp to [0.0, 1.0]
float shadowFactor = clamp(occluderExp * receiverExp, 0.0, 1.0);
return shadowFactor;
}
void main()
{
float shadowAttenuation = calculateESMAttenuation(vLightSpacePos, c);
vec3 lightColor = vec3(1.0);
vec3 ambient = vec3(0.1);
// Apply shadow attenuation strictly to direct illumination components
vec3 directLighting = lightColor * shadowAttenuation;
vec3 finalColor = ambient + directLighting;
FragColor = vec4(finalColor, 1.0);
}Addressing Precision and Light Bleeding
Two critical factors govern attenuation quality at ESM boundaries:
- Exponent Constant (\(c\)): A value of \(c\) that is too low causes light bleeding, where occluders appear semi-transparent near edges because the exponential tail is too broad. A value that is too high sharpens edges to hard transitions and risks floating-point overflow when computing \(e^{c \cdot z}\).
- Texture Precision: Because \(e^{c \cdot z}\) grows rapidly, the shadow
map texture must use a floating-point format, typically
GL_R32F, to preserve precision along continuous boundaries and prevent banding artifacts.