Fixing Shadow Shimmering with Texel Snapping
Shadow shimmering occurs in directional shadow mapping when the light camera translates continuously alongside the main camera, causing depth map texels to continuously shift over sub-texel distances and create jittering edges. Snapping the shadow projection matrix to discrete texel boundaries resolves this by restricting shadow camera movement to exact whole-texel increments in light space. This article details the mathematical principles behind texel snapping, how to stabilize the light view-projection matrix, and how to apply the resulting transformation within a GLSL shadow mapping pipeline.
Why Shadow Shimmering Happens
Directional light sources typically follow the player or camera using an orthographic projection volume. When the player moves, the shadow camera's position changes smoothly in continuous floating-point world space. Because the shadow map has a fixed resolution (for example, 2048x2048), geometry edges project onto fractional texel coordinates that change every frame. When sampled via percentage-closer filtering (PCF) or standard depth tests, shadow edges continuously wobble and flicker.
The Texel Snapping Algorithm
To prevent sub-texel movement, the shadow camera’s orthographic bounding box must move in discrete steps equal to the size of a single shadow map texel in world units.
The snapping process involves four steps, typically executed on the CPU before passing the combined light view-projection matrix to GLSL uniforms:
Calculate the Texel Size in World Units: Determine how many world units are covered by a single texel along the X and Y axes of the orthographic projection:
texelSize = (orthoRight - orthoLeft) / shadowMapResolution;If the projection is symmetrical,
(orthoSize * 2.0) / shadowMapResolutionrepresents the world-space dimensions of one texel.Transform the Look-At Center into Light Space: Compute the preliminary light view matrix looking at the target (e.g., the center of the camera frustum):
lightViewMatrix = lookAt(lightPosition, lightTarget, upVector);Transform the target point into light view space:
lightSpaceTarget = lightViewMatrix * vec4(targetWorldPosition, 1.0);Snap the Light-Space Coordinates: Round the light-space coordinates to the nearest texel increment:
snappedX = floor(lightSpaceTarget.x / texelSize) * texelSize; snappedY = floor(lightSpaceTarget.y / texelSize) * texelSize;Compute the difference between the snapped position and the original position:
offsetX = snappedX - lightSpaceTarget.x; offsetY = snappedY - lightSpaceTarget.y;Apply the Offset to the Projection Matrix: Adjust the orthographic bounds or directly offset the translation elements of the projection matrix:
orthoLeft += offsetX; orthoRight += offsetX; orthoBottom += offsetY; orthoTop += offsetY; lightProjectionMatrix = ortho(orthoLeft, orthoRight, orthoBottom, orthoTop, nearPlane, farPlane);
Alternatively, you can modify the light’s position directly in world
space by translating back the offset vector, or alter row/column
elements [3][0] and [3][1] of the generated
orthographic projection matrix.
Implementation Example
The following C++ pseudo-code demonstrates the complete stabilization process used to generate the uniform matrix:
// 1. Establish the shadow box bounds
float shadowExtent = 25.0f; // Half-width of the orthographic box
float resolution = 2048.0f;
float texelSize = (shadowExtent * 2.0f) / resolution;
// 2. Light View Matrix
glm::vec3 lightDir = glm::normalize(glm::vec3(0.5f, 1.0f, 0.2f));
glm::vec3 center = mainCamera.position;
glm::mat4 lightView = glm::lookAt(center + lightDir, center, glm::vec3(0.0f, 1.0f, 0.0f));
// 3. Snap center in light space
glm::vec4 shadowOrigin = glm::vec4(0.0f, 0.0f, 0.0f, 1.0f);
shadowOrigin = lightView * shadowOrigin;
shadowOrigin.x = std::round(shadowOrigin.x / texelSize) * texelSize;
shadowOrigin.y = std::round(shadowOrigin.y / texelSize) * texelSize;
// 4. Correct the light view matrix translation
glm::vec4 roundedOffset = shadowOrigin - (lightView * glm::vec4(0.0f, 0.0f, 0.0f, 1.0f));
glm::mat4 lightProjection = glm::ortho(-shadowExtent, shadowExtent, -shadowExtent, shadowExtent, nearPlane, farPlane);
// Shift projection to compensate for snapping
lightProjection[3][0] += roundedOffset.x;
lightProjection[3][1] += roundedOffset.y;
glm::mat4 lightSpaceMatrix = lightProjection * lightView;GLSL Usage
Once the matrix is snapped, the GLSL shaders require no manual rounding math. The uniform transformation matrix ensures all coordinates arrive at exact texel centers in the depth buffer.
Vertex Shader:
#version 450 core
layout(location = 0) in vec3 aPos;
uniform mat4 u_Model;
uniform mat4 u_LightSpaceMatrix;
out vec4 v_LightSpacePos;
void main()
{
gl_Position = u_Model * vec4(aPos, 1.0);
v_LightSpacePos = u_LightSpaceMatrix * gl_Position;
}Fragment Shader:
#version 450 core
in vec4 v_LightSpacePos;
out vec4 FragColor;
uniform sampler2DShadow u_ShadowMap;
float CalculateShadow(vec4 fragPosLightSpace)
{
// Perform perspective divide (normalized device coordinates)
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// Transform from [-1, 1] to [0, 1] texture coordinates
projCoords = projCoords * 0.5 + 0.5;
// Check outside bounds
if(projCoords.z > 1.0) return 0.0;
// Sample using hardware PCF with depth comparison
float shadow = texture(u_ShadowMap, vec3(projCoords.xy, projCoords.z - 0.002));
return 1.0 - shadow;
}
void main()
{
float shadow = CalculateShadow(v_LightSpacePos);
FragColor = vec4(vec3(1.0 - shadow), 1.0);
}Essential Considerations
- Light Direction Changes: Snapping only eliminates shimmering caused by camera translation. If the directional light rotates (e.g., dynamic day/night cycles), the shadow map will still shimmer because texel orientations rotate relative to world geometry.
- Aspect Ratio: Ensure that
shadowExtentapplies uniformly across both axes or thattexelSizeis calculated independently for \(X\) and \(Y\) if non-square orthographic frustums or non-square shadow maps are used. - Cascaded Shadow Maps (CSM): Apply this algorithm to each cascade individually, utilizing the corresponding orthographic extents and resolution of that specific cascade level.