Shadow Receiver Plane Depth Bias in GLSL

This article explains how to compute receiver plane depth bias for shadow mapping using screen-space partial derivatives in GLSL. By analyzing the rate of change of shadow coordinates relative to screen pixels, this technique computes an exact per-pixel depth gradient across shadow map texels, substantially reducing shadow acne and peter-panning on surfaces angled steeply away from either the light or the camera.


The Underlying Problem

Standard slope-scale depth bias relies on geometric surface normals to estimate slope relative to light direction. However, this approximation fails to capture the true projected footprint of shadow map texels when sampled across perspective projections or during Percentage-Closer Filtering (PCF).

Receiver plane depth bias addresses this by calculating the partial derivatives of the light-space depth (\(z\)) with respect to light-space UV texture coordinates (\(\frac{\partial z}{\partial u}\) and \(\frac{\partial z}{\partial v}\)).

Mathematical Formulation

Given shadow-space coordinates \((u, v, z)\) calculated per fragment, GLSL provides the built-in screen-space derivative functions dFdx() and dFdy(). The chain rule relates screen-space depth changes to UV-space depth changes:

\[ \begin{aligned} \frac{\partial z}{\partial x_{\text{screen}}} &= \frac{\partial z}{\partial u} \frac{\partial u}{\partial x_{\text{screen}}} + \frac{\partial z}{\partial v} \frac{\partial v}{\partial x_{\text{screen}}} \\ \frac{\partial z}{\partial y_{\text{screen}}} &= \frac{\partial z}{\partial u} \frac{\partial u}{\partial y_{\text{screen}}} + \frac{\partial z}{\partial v} \frac{\partial v}{\partial y_{\text{screen}}} \end{aligned} \]

In matrix form:

\[ \begin{bmatrix} \frac{\partial z}{\partial x} \\ \frac{\partial z}{\partial y} \end{bmatrix} = \begin{bmatrix} \frac{\partial u}{\partial x} & \frac{\partial v}{\partial x} \\ \frac{\partial u}{\partial y} & \frac{\partial v}{\partial y} \end{bmatrix} \begin{bmatrix} \frac{\partial z}{\partial u} \\ \frac{\partial z}{\partial v} \end{bmatrix} \]

Inverting the \(2 \times 2\) Jacobian matrix yields the depth gradient in shadow-map texture space:

\[ \begin{bmatrix} \frac{\partial z}{\partial u} \\ \frac{\partial z}{\partial v} \end{bmatrix} = \frac{1}{\frac{\partial u}{\partial x}\frac{\partial v}{\partial y} - \frac{\partial v}{\partial x}\frac{\partial u}{\partial y}} \begin{bmatrix} \frac{\partial v}{\partial y} & -\frac{\partial v}{\partial x} \\ -\frac{\partial u}{\partial y} & \frac{\partial u}{\partial x} \end{bmatrix} \begin{bmatrix} \frac{\partial z}{\partial x} \\ \frac{\partial z}{\partial y} \end{bmatrix} \]

GLSL Implementation

The following function computes the depth gradient vector \(\left(\frac{\partial z}{\partial u}, \frac{\partial z}{\partial v}\right)\) from projective shadow coordinates:

vec2 computeReceiverPlaneDepthBias(vec3 shadowCoord) {
    // Screen-space partial derivatives of the shadow coordinate
    vec3 dShadowX = dFdx(shadowCoord);
    vec3 dShadowY = dFdy(shadowCoord);

    // Compute determinant of the Jacobian (du/dx * dv/dy - dv/dx * du/dy)
    float det = dShadowX.x * dShadowY.y - dShadowX.y * dShadowY.x;

    // Handle degenerate cases to prevent division by zero
    if (abs(det) < 1e-7) {
        return vec2(0.0);
    }

    float invDet = 1.0 / det;

    // Solve the linear system for dz/du and dz/dv
    float dzdu = invDet * ( dShadowY.y * dShadowX.z - dShadowX.y * dShadowY.z);
    float dzdv = invDet * (-dShadowY.x * dShadowX.z + dShadowX.x * dShadowY.z);

    return vec2(dzdu, dzdv);
}

Applying the Bias during Filtering

When sampling the shadow map—especially with PCF kernel offsets—the exact receiver plane bias for a given texel offset \((\Delta u, \Delta v)\) is computed via a dot product:

vec2 depthSlope = computeReceiverPlaneDepthBias(projShadowCoord.xyz);

// Compute depth offset for a specific PCF tap offset in UV space
vec2 texelOffset = vec2(offsetU, offsetV) * shadowMapTexelSize;
float receiverPlaneBias = dot(depthSlope, texelOffset);

// Total comparison depth for this tap
float biasedDepth = projShadowCoord.z + receiverPlaneBias;

For single-tap hardware comparisons or conservative single-value biasing, a maximum slope magnitude across a single texel width can be applied:

vec2 depthSlope = computeReceiverPlaneDepthBias(projShadowCoord.xyz);
float maxTexelBias = length(depthSlope * shadowMapTexelSize);

// Apply a constant base bias alongside the dynamic receiver bias
float finalBias = max(maxTexelBias, baseMinimumBias);

Key Considerations

  1. Hardware Derivatives & Coarse Shading: Screen-space derivatives rely on \(2 \times 2\) pixel quads. Along silhouette edges or triangle boundaries, dFdx and dFdy can produce discontinuities, which may require clamping the resulting gradient to an upper bound.
  2. Perspective Division: If perspective projection is used for the shadow caster (such as for spot lights), screen derivatives should be calculated on the post-perspective division coordinates projCoords = lightSpacePos.xyz / lightSpacePos.w.