PCF Implementation with GLSL Shadow Samplers

Percentage-Closer Filtering (PCF) is a technique used in real-time rendering to produce soft shadow edges by filtering depth comparisons rather than the depth values themselves. In OpenGL and GLSL, PCF can be accelerated using dedicated shadow samplers (sampler2DShadow), which leverage GPU texture-filtering hardware to automatically compare fragment depths against the shadow map. This article explains how to configure texture parameters on the host, declare and sample shadow textures in GLSL, and implement multi-tap PCF kernels for smooth shadow boundaries.

1. Host-Side Texture Configuration

To use GLSL shadow samplers effectively, the depth texture on the CPU side must be configured to enable hardware depth comparisons. This is done by setting the texture comparison mode and comparison function using glTexParameteri:

glBindTexture(GL_TEXTURE_2D, depthTextureID);

// Enable depth comparison mode
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE);

// Set comparison operator (typically GL_LEQUAL or GL_LESS)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);

// Enable bilinear filtering to get automatic 2x2 hardware PCF
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

When GL_TEXTURE_COMPARE_MODE is set to GL_COMPARE_REF_TO_TEXTURE and minification/magnification filters are set to GL_LINEAR, the GPU performs a 2x2 bilinear percentage-closer filter directly on chip before returning the result to the shader.

2. GLSL Shadow Sampler Basics

In GLSL, shadow textures are declared using sampler2DShadow instead of a standard sampler2D. When querying a sampler2DShadow, the texture() function accepts a vec3 (or vec4 for projective textures) rather than a vec2:

  • uv.xy: The 2D texture coordinates in the [0, 1] range.
  • uv.z: The reference depth (the current fragment's depth from the light's perspective).

The sampler compares the reference depth with the values stored in the depth map. It returns a single float between 0.0 (fully in shadow) and 1.0 (fully lit), representing the filtered comparison result.

uniform sampler2DShadow shadowMap;

// Basic single-tap sample with hardware bilinear PCF
float shadow = texture(shadowMap, vec3(projCoords.xy, projCoords.z - bias));

3. Implementing a Multi-Tap PCF Kernel

While the hardware 2x2 filter softens aliasing, a larger kernel is often required to produce noticeable, visually pleasing penumbras. Because hardware shadow samplers already perform a 2x2 bilinear blend per fetch, taking a 4x4 or 3x3 loop of samples yields wider and smoother results than it would with standard samplers.

Below is a complete GLSL fragment shader implementation demonstrating an expanded PCF kernel:

#version 330 core

in vec4 FragPosLightSpace;
out vec4 FragColor;

uniform sampler2DShadow shadowMap;
uniform vec2 shadowMapTexelSize; // vec2(1.0 / width, 1.0 / height)

float calculateShadow(vec4 fragPosLightSpace, float bias)
{
    // Perform perspective divide
    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
    
    // Transform from [-1, 1] NDC to [0, 1] texture range
    projCoords = projCoords * 0.5 + 0.5;
    
    // Outside the light frustum far plane
    if (projCoords.z > 1.0)
        return 0.0;

    float shadowFactor = 0.0;
    
    // 3x3 sample kernel (effectively covers a wider area due to hardware bilinear interpolation)
    for (int x = -1; x <= 1; ++x)
    {
        for (int y = -1; y <= 1; ++y)
        {
            vec2 offset = vec2(x, y) * shadowMapTexelSize;
            
            // vec3(uv.x, uv.y, referenceDepth - bias)
            shadowFactor += texture(shadowMap, vec3(projCoords.xy + offset, projCoords.z - bias));
        }
    }
    
    // Average the samples
    shadowFactor /= 9.0;

    // Convert visibility factor to shadow amount (1.0 = fully lit, 0.0 = fully shadowed)
    return shadowFactor;
}

void main()
{
    float bias = 0.005;
    float visibility = calculateShadow(FragPosLightSpace, bias);
    
    vec3 lightColor = vec3(1.0);
    vec3 baseColor = vec3(0.8, 0.8, 0.8);
    
    // Apply calculated visibility to diffuse/specular components
    vec3 finalColor = baseColor * visibility;
    FragColor = vec4(finalColor, 1.0);
}

4. Key Advantages of This Approach

  1. Performance: Offloading the initial depth comparisons and bilinear blending to fixed-function texture hardware significantly reduces shader arithmetic instructions.
  2. Reduced Tap Count: Because each tap evaluates four depth samples via bilinear filtering, an \(N \times N\) tap loop using sampler2DShadow covers an \((N+1) \times (N+1)\) sample footprint with smoother transitions than a manual comparison loop using a standard sampler2D.
  3. Simplicity: GLSL handles both the depth test and linear interpolation in a single function call, keeping shader code concise.