How Does GLSL Shadow Sampler Depth Comparison Work?

In OpenGL Shading Language (GLSL), shadow samplers automate the depth-testing stage of shadow mapping directly in hardware during texture lookup. Instead of returning raw depth values for manual software comparison, shadow samplers like sampler2DShadow compare a reference depth coordinate against the depth recorded in the shadow map. This article explores the internal mechanics of shadow samplers, how hardware Percentage Closer Filtering (PCF) functions, and how to configure both host OpenGL and GLSL shaders to use this feature.

Understanding the Standard Depth Texture vs. Shadow Samplers

In a naive shadow mapping pipeline, depth information from the light’s point of view is written to a standard depth texture and accessed in shaders using a sampler2D. To determine visibility, the fragment shader executes two distinct operations: fetching the stored depth value via texture() and then performing an explicit conditional check (such as currentDepth > sampledDepth) to test if a fragment is occluded.

Shadow samplers replace this manual process. By declaring the texture uniform as a shadow type—such as sampler2DShadow or samplerCubeShadow—the GPU combines the texture fetch and the comparison operation into a single built-in hardware instruction.

The Role of the Reference Coordinate

When sampling from a standard texture, GLSL requires coordinates corresponding to the texture’s dimensions (e.g., \((u, v)\) for 2D textures). When sampling with a shadow sampler, an additional coordinate component is required:

The extra component, \(ref\_z\), represents the reference depth: the distance from the light source to the currently processed surface fragment. The GPU compares this reference value directly against the texel values stored in the shadow map.

OpenGL Host State Configuration

Shadow samplers do not function automatically without setting the appropriate texture comparison parameters on the OpenGL host side. Two specific texture parameters must be configured using glTexParameteri:

  1. GL_TEXTURE_COMPARE_MODE: Must be set to GL_COMPARE_REF_TO_TEXTURE. This signals the hardware to activate its internal comparison logic instead of returning raw depth data.
  2. GL_TEXTURE_COMPARE_FUNC: Defines the condition under which a fragment is considered "in light" (evaluates to \(1.0\)). Typically, this is set to GL_LEQUAL (Less than or Equal), meaning if \(ref\_z \le depth_{map}\), the fragment is visible to the light.

If GL_TEXTURE_COMPARE_MODE is left as GL_NONE (the default state), sampling a shadow sampler yields undefined behavior or compilation warnings depending on the GLSL version.

Hardware PCF and Bilinear Filtering

The most significant advantage of using shadow samplers over manual shader comparisons is hardware-level Percentage Closer Filtering (PCF).

When using standard sampler2D with bilinear filtering (GL_LINEAR), the GPU linearly interpolates between raw depth values. Interpolating depth values produces incorrect intermediate distances, causing severe visual artifacts along silhouette edges. Consequently, standard depth maps must rely on nearest-neighbor filtering (GL_NEAREST).

When GL_COMPARE_REF_TO_TEXTURE and GL_LINEAR are active simultaneously, the hardware changes how interpolation executes:

  1. The GPU reads the four nearest depth texels surrounding the sampling coordinate.
  2. It compares the reference depth \(ref\_z\) against each of the four texels individually, yielding four binary results (\(0.0\) or \(1.0\)).
  3. It performs standard bilinear interpolation across the four comparison results.

The resulting output from texture(shadowMap, vec3(uv, ref_z)) is a continuous floating-point value in the range \([0.0, 1.0]\). This value represents the percentage of the sampled area illuminated by the light, producing smoothly anti-aliased shadow edges with zero shader overhead.

GLSL Implementation Example

Integrating a 2D shadow sampler into a fragment shader requires minimal code:

#version 330 core

in vec4 FragPosLightSpace;

uniform sampler2DShadow shadowMap;

out vec4 FragColor;

float CalculateShadow(vec4 fragPosLightSpace)
{
    // Perform perspective divide to transform to Normalized Device Coordinates [-1, 1]
    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
    
    // Transform coordinates to the [0, 1] range for texture sampling
    projCoords = projCoords * 0.5 + 0.5;
    
    // Prevent shadowing outside the light frustum's far plane
    if (projCoords.z > 1.0)
    {
        return 1.0;
    }
    
    // Built-in shadow comparison:
    // projCoords.xy = UV coordinates
    // projCoords.z  = Reference depth to compare against the depth map
    float shadowFactor = texture(shadowMap, projCoords);
    
    return shadowFactor;
}

void main()
{
    float visibility = CalculateShadow(FragPosLightSpace);
    vec3 lightIntensity = vec3(1.0) * visibility;
    FragColor = vec4(lightIntensity, 1.0);
}

Shadow samplers provide an efficient, hardware-accelerated mechanism for depth testing and edge softening in modern graphics pipelines, eliminating boilerplate comparison logic and manual multi-tap filtering algorithms.