Slope-Scaled Depth Bias Formulation in GLSL

Slope-scaled depth bias is a dynamic biasing technique used in shadow mapping to prevent shadow acne while minimizing detachment artifacts like "peter-panning." By calculating the angle between a surface's geometric normal and the incoming light direction, shaders can scale the depth offset proportionally to the steepness of the slope relative to the light. This article covers the exact trigonometric derivation of slope-scaled bias, its numerical stability safeguards, and its practical implementation in OpenGL Shading Language (GLSL).

The Geometric Principle

Shadow acne occurs due to limited shadow map resolution: a single shadow map texel represents a finite planar area. When a surface is tilted relative to the light, the depth values across that single texel vary drastically.

Let \(\mathbf{n}\) be the normalized surface normal vector, and let \(\mathbf{l}\) be the normalized direction vector pointing toward the light source:

\[\cos \theta = \mathbf{n} \cdot \mathbf{l}\]

Here, \(\theta\) represents the angle of incidence. When the surface directly faces the light, \(\theta = 0\) and \(\cos \theta = 1\). As the surface tilts into grazing angles relative to the light rays, \(\theta \to \frac{\pi}{2}\) and \(\cos \theta \to 0\).

The rate of depth change across the surface with respect to the light plane is proportional to the tangent of the incidence angle:

\[\text{Slope} = \tan \theta = \frac{\sin \theta}{\cos \theta}\]

Using the trigonometric identity \(\sin \theta = \sqrt{1 - \cos^2 \theta}\), the slope can be computed entirely through dot products:

\[\tan \theta = \frac{\sqrt{1 - (\mathbf{n} \cdot \mathbf{l})^2}}{\mathbf{n} \cdot \mathbf{l}}\]

Mathematical Formulation of the Bias

The depth bias \(B\) applied to the shadow map comparison is expressed as a linear function of this slope factor, clamped within safe boundaries to avoid extreme detachment:

\[B = \operatorname{clamp}\left(B_{\text{constant}} + B_{\text{scale}} \cdot \tan \theta, \; B_{\text{min}}, \; B_{\text{max}}\right)\]

Where:

  • \(B_{\text{constant}}\) is the minimum constant bias for surfaces perpendicular to light rays.
  • \(B_{\text{scale}}\) is the scaling factor applied to the geometric slope.
  • \(B_{\text{min}}\) and \(B_{\text{max}}\) are boundary limits preventing floating-point precision issues at grazing angles (\(\theta \approx \frac{\pi}{2}\)).

Fast Linear Approximation

Because evaluating the square root and division of the exact tangent formula can occasionally produce instability when \(\mathbf{n} \cdot \mathbf{l} \to 0\), a commonly utilized linear approximation models the bias directly off the sine or the inverse cosine falloff:

\[B \approx \operatorname{mix}(B_{\text{max}}, B_{\text{min}}, \mathbf{n} \cdot \mathbf{l}) = B_{\text{max}} - (B_{\text{max}} - B_{\text{min}}) \cdot (\mathbf{n} \cdot \mathbf{l})\]

Alternatively:

\[B \approx \operatorname{max}\left(B_{\text{scale}} \cdot (1.0 - \mathbf{n} \cdot \mathbf{l}), \; B_{\text{min}}\right)\]

GLSL Implementation

Below are implementations for both the exact trigonometric slope-scale bias and the optimized linear approximation.

1. Exact Trigonometric Bias

float calculateSlopeBiasExact(vec3 normal, vec3 lightDir, float baseBias, float slopeFactor, float maxBias) {
    // Ensure inputs are normalized
    vec3 N = normalize(normal);
    vec3 L = normalize(lightDir);

    // Compute cos(theta) and clamp to avoid division by zero or backface calculations
    float cosTheta = clamp(dot(N, L), 0.001, 1.0);

    // tan(theta) = sin(theta) / cos(theta) = sqrt(1 - cos^2(theta)) / cos(theta)
    float sinTheta = sqrt(max(0.0, 1.0 - cosTheta * cosTheta));
    float tanTheta = sinTheta / cosTheta;

    // Calculate dynamic bias and clamp to acceptable upper limit
    float bias = baseBias + slopeFactor * tanTheta;
    return min(bias, maxBias);
}

2. Optimized Approximation

float calculateSlopeBiasFast(vec3 normal, vec3 lightDir, float minBias, float maxBias) {
    vec3 N = normalize(normal);
    vec3 L = normalize(lightDir);

    float cosTheta = clamp(dot(N, L), 0.0, 1.0);

    // Linear scaling based on surface tilt
    float bias = maxBias * (1.0 - cosTheta);

    return max(bias, minBias);
}

Application in Shadow Testing

The computed bias is subtracted from the projected light-space depth before comparing against the depth sampled from the shadow texture:

float currentDepth = lightSpacePosition.z / lightSpacePosition.w;
float bias = calculateSlopeBiasExact(vNormal, uLightDirection, 0.0005, 0.002, 0.01);

// Standard depth test
float shadow = (currentDepth - bias > sampledShadowDepth) ? 1.0 : 0.0;

This formulation adjusts continuously across curved geometry, maintaining close contact shadows on front-facing surfaces while extending depth tolerance on sharply angled triangles.