Fixing Shadow Acne with Normal Offset Bias in GLSL
Normal offset depth bias is a shadow mapping technique designed to eradicate shadow acne while avoiding the detached shadows and light leaks known as "Peter Panning." By shifting the world-space surface position along its normal vector before projecting it into shadow map coordinates, this method keeps shadow receivers stable across varying surface slopes. This guide details the mathematical foundation of normal offset bias, why it outperforms traditional constant and slope-scale depth biases, and how to implement it directly within GLSL shaders.
The Problem with Standard Depth Biasing
Standard depth biasing adjusts the depth comparison value (\(z\)) directly along the light's projection axis:
\[\text{depth}_{\text{biased}} = \text{depth} - \text{bias}\]
While effective on flat surfaces perpendicular to the light, this approach fails on steep slopes and geometry with grazing light angles. Increasing a constant or slope-scaled depth bias pushes the shadow receiver too far down the light ray. This creates a visible gap between the shadow caster and the shadow boundary, detaching the shadow from the base of the object—a visual artifact called Peter Panning or light leaking.
Mathematical Formulation of Normal Offset Bias
Normal offset bias approaches the problem geometrically rather than analytically altering depth values. Instead of modifying the computed depth value after projection, it displaces the world-space position \(\mathbf{P}\) along the surface normal vector \(\mathbf{N}\) by a factor proportional to the shadow map's texel footprint:
\[\mathbf{P}_{\text{offset}} = \mathbf{P} + \mathbf{N} \cdot \text{offsetMagnitude}\]
Deriving the Offset Magnitude
The required displacement depends on two variables: the world-space size of a shadow map texel and the angle between the surface normal and the light direction.
Texel Footprint: To ensure that the displacement covers at least one shadow texel to avoid self-intersection, compute the world-space size of a texel (\(T_{\text{world}}\)). For an orthographic directional light: \[T_{\text{world}} = \frac{\text{Frustum Width}}{\text{Shadow Map Resolution}}\]
Angle Modulation: Surfaces angled sharply away from the light require a larger bias. The angle is derived using the dot product between the surface normal \(\mathbf{N}\) and the direction to the light \(\mathbf{L}\): \[\cos(\theta) = \text{clamp}(\mathbf{N} \cdot \mathbf{L}, 0.0, 1.0)\] \[\sin(\theta) = \sqrt{1.0 - \cos^2(\theta)}\]
The final offset position is:
\[\mathbf{P}_{\text{offset}} = \mathbf{P} + \mathbf{N} \cdot (T_{\text{world}} \cdot \text{biasScale} \cdot \sin(\theta))\]
Because the displacement moves outward along the normal, geometry maintains its physical contact at grazing angles, leaving shadow contact points grounded while completely mitigating acne.
GLSL Implementation
Below is the GLSL fragment shader implementation demonstrating how to apply the normal offset bias prior to shadow coordinate generation and depth comparison.
#version 450 core
in vec3 vWorldPosition;
in vec3 vNormal;
uniform sampler2DShadow uShadowMap;
uniform mat4 uLightViewProj;
uniform vec3 uLightDirection; // Normalized vector pointing toward the light
uniform float uShadowMapResolution;
uniform float uLightFrustumWidth;
uniform float uNormalBiasScale; // Typically tuned between 1.0 and 2.0
float CalculateShadow(vec3 worldPos, vec3 normal) {
vec3 N = normalize(normal);
vec3 L = normalize(uLightDirection);
// Calculate slope factor: sin(theta) = sqrt(1 - cos^2(theta))
float NdotL = clamp(dot(N, L), 0.0, 1.0);
float sinTheta = sqrt(1.0 - NdotL * NdotL);
// Compute shadow map texel size in world units
float texelSizeWorld = uLightFrustumWidth / uShadowMapResolution;
// Apply offset along the normal
float normalOffset = texelSizeWorld * uNormalBiasScale * sinTheta;
vec3 offsetWorldPos = worldPos + (N * normalOffset);
// Transform biased world position into light clip space
vec4 lightSpacePos = uLightViewProj * vec4(offsetWorldPos, 1.0);
vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w;
// Convert normalized device coordinates (NDC) to [0, 1] texture coordinates
projCoords = projCoords * 0.5 + 0.5;
// Outside the shadow frustum bounds
if (projCoords.z > 1.0 || projCoords.x < 0.0 || projCoords.x > 1.0 ||
projCoords.y < 0.0 || projCoords.y > 1.0) {
return 0.0;
}
// Hardware depth comparison using sampler2DShadow
// Returns 1.0 for fully lit, 0.0 for fully shadowed
float shadow = texture(uShadowMap, vec3(projCoords.xy, projCoords.z));
return shadow;
}
out vec4 FragColor;
void main() {
float shadowFactor = CalculateShadow(vWorldPosition, vNormal);
vec3 litColor = vec3(1.0) * shadowFactor;
FragColor = vec4(litColor, 1.0);
}Eliminating Light Leaks
Normal offset bias prevents light leaks through two mechanisms:
- Inward Ray Consistency: When looking from the light source, the surface appears geometrically thicker rather than pushed away. The shadow silhouette shifts outward slightly along object borders instead of shifting in the direction of the light rays.
- Tight Basal Contacts: At contact points (such as where a foot touches the floor), the floor's normal points upward and the caster's geometry remains unmoved down the light path. This ensures the ambient occlusion boundary and contact shadow line remain visually continuous.