PCSS Blocker Search Algorithm in GLSL

The blocker search algorithm is the foundational first step in Percentage-Closer Soft Shadows (PCSS), responsible for identifying occluding geometry between a surface point and an area light source to compute an average blocker depth. This average depth determines the size of the penumbra, enabling shadows to appear sharp near the contact point and progressively softer farther away. This article breaks down the mathematical mechanics of the blocker search step and details its direct implementation using OpenGL Shading Language (GLSL).

Before filtering the shadow map, PCSS must determine how wide the filter kernel should be. According to the principles of similar triangles, the penumbra width (\(w_{penumbra}\)) is proportional to the distance between the receiver and the blocker:

\[w_{penumbra} = \frac{d_{receiver} - d_{blocker}}{d_{blocker}} \cdot w_{light}\]

To find \(d_{blocker}\), the algorithm searches a neighborhood around the projected shadow map coordinate of the fragment. The radius of this search region (\(w_{search}\)) is dynamic and scales based on the receiver's distance from the light:

\[w_{search} = w_{light} \cdot \left( \frac{d_{receiver} - z_{near}}{d_{receiver}} \right)\]

Here, \(w_{light}\) represents the physical or normalized size of the light source, and \(z_{near}\) is the near clipping plane of the light's projection frustum.

Algorithmic Steps

  1. Calculate Search Window Size: Compute the UV search radius on the shadow map using the receiver's depth and light parameters.
  2. Sample the Shadow Map: Distribute sample points across the search window, typically using a distribution such as a Poisson disk or a Vogel disk to reduce structured aliasing.
  3. Depth Evaluation: Compare each sampled depth against the receiver's depth. If a sample is closer to the light than the receiver (accounting for depth bias), it is flagged as a blocker.
  4. Average Blocker Depth: Accumulate the depths of all detected blockers and divide by the total count of blockers found. If no blockers are found, the fragment is completely unshadowed, and subsequent PCF steps can be skipped.

GLSL Implementation

Below is a standard GLSL implementation of the blocker search routine:

#version 330 core

#define BLOCKER_SEARCH_SAMPLES 16

// Precomputed Poisson disk sample offsets normalized between [-1, 1]
const vec2 POISSON_DISK[16] = vec2[](
    vec2(-0.94201624, -0.39906216),
    vec2( 0.94558609, -0.76890725),
    vec2(-0.09418410, -0.92938870),
    vec2( 0.34495938,  0.29387760),
    vec2(-0.91588581,  0.45771432),
    vec2(-0.81544232, -0.87912464),
    vec2(-0.38277543,  0.27676845),
    vec2( 0.97484398,  0.75648377),
    vec2( 0.44323325, -0.97511554),
    vec2( 0.53742981, -0.47373420),
    vec2(-0.26496911, -0.41893023),
    vec2( 0.79197514,  0.19090188),
    vec2(-0.24188840,  0.99706507),
    vec2(-0.81409955,  0.91437590),
    vec2( 0.19984126,  0.78641367),
    vec2( 0.14383161, -0.14100790)
);

// Searches for occluders and returns the average blocker depth.
// Returns -1.0 if no blockers are present.
float FindBlocker(
    sampler2D shadowMap,
    vec2 shadowCoordsUV,
    float receiverDepth,
    float lightSizeUV,
    float lightZNear,
    float bias
) {
    // 1. Calculate search radius in texture space
    float searchRadius = lightSizeUV * (receiverDepth - lightZNear) / receiverDepth;

    float blockerSum = 0.0;
    int numBlockers = 0;

    // 2. Sample shadow map across the disk pattern
    for (int i = 0; i < BLOCKER_SEARCH_SAMPLES; ++i) {
        vec2 offset = POISSON_DISK[i] * searchRadius;
        float shadowMapDepth = texture(shadowMap, shadowCoordsUV + offset).r;

        // 3. Check if the sample occludes the receiver
        if (shadowMapDepth < receiverDepth - bias) {
            blockerSum += shadowMapDepth;
            numBlockers++;
        }
    }

    // Early exit if the fragment is fully illuminated
    if (numBlockers == 0) {
        return -1.0;
    }

    // 4. Return the average depth of all detected blockers
    return blockerSum / float(numBlockers);
}

Key Considerations for Optimization and Artifacts

  • Sampling Artifacts: Using fixed offsets can lead to repetitive banding. Applying a random rotation per-pixel using an interleaved gradient noise or blue noise texture decorrelates the pattern into high-frequency noise that is easier to denoise.
  • Early Out: When FindBlocker returns -1.0, the fragment is completely outside the penumbra and umbra. The shader can immediately output a shadow factor of 1.0 (fully lit), skipping the more expensive Percentage-Closer Filtering (PCF) step entirely.
  • Under-sampling Under Large Lights: If the light source is exceptionally wide, a small sample count (e.g., 16 samples) over an expanded search radius may miss small occluders, causing sudden flickering or noise along shadow borders.