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