Shadow mapping is one of the most common techniques for real-time shadows, but a naïve implementation produces hard, aliased edges. This post is a practical guide to improving shadow sampling quality. We start with a single raw sample, soften the edges with percentage-closer filtering (PCF), replace the PCF grid with a Vogel disc to cut down on banding, and finally build contact-hardening soft shadows that vary the penumbra with occluder distance. The code is written in Slang, but its syntax closely mirrors HLSL, so anyone working in HLSL or GLSL should be able to follow along. All techniques are implemented in my game engine Dodo.

Raw shadow sampling

Focusing primarily on the sampling, the simplest approach is to take a single sample of the shadow map.

float sampleShadowRaw(Texture2D shadowMap, 
                      SamplerState shadowSampler,
                      float3 projCoords,
                      float currentDepth, float bias)
{
    float occluderDepth = shadowMap.Sample(shadowSampler, projCoords.xy);
    return (currentDepth - bias > occluderDepth) ? 1.0f : 0.0f;
}

Using this method, we end up with jagged shadows shown in the image below. Raw sample

In the next section, we will use a technique to soften the shadow edges.


Percentage Closer Filtering (PCF)

The PCF algorithm, first described in 1987 by Reeves et al. in a paper called Rendering Antialiased Shadows with Depth Maps, solves this issue by taking multiple samples nearby and averaging the result.

float sampleShadowPCF(Texture2D shadowMap,
                      SamplerState shadowSampler,
                      float3 projCoords,
                      float currentDepth, float bias)
{
    uint w, h, layers;
    shadowMap.GetDimensions(w, h, layers);
    if (w == 0 || h == 0)
        return 0.0f;

    float2 texelSize = 1.0f / float2(w, h);

    int kernelRadius = 2;
    float shadow = 0.0f;
    int diameter = kernelRadius * 2 + 1;
    int numSamples = diameter*diameter;
    for (int x = -kernelRadius; x <= kernelRadius; x++)
    {
        for (int y = -kernelRadius; y <= kernelRadius; y++)
        {
            float2 offset = float2(x, y) * texelSize;
            float occluderDepth = shadowMap.Sample(shadowSampler, projCoords.xy + offset);
            shadow += (currentDepth - bias > occluderDepth) ? 1.0f : 0.0f;
        }
    }
    return shadow / float(numSamples);
}

PCF sample

A kernelRadius of 1 gives a 3×3 kernel (9 samples); 2 gives 5×5 (25 samples). This visibly softens the shadow edges, but two limitations remain. First, the shadow border is still pixelated even with a 5x5 kernel. Secondly, the softness is uniform: every edge gets the same blur regardless of distance to the caster. Try casting a shadow with your hand under a lamp. As you move your hand closer to or farther from the surface, the shadow softens.

The next section will address the first issue, and the subsequent section will solve the second issue.


Vogel Disc Sampling

In 1979, Helmut Vogel published A Better Way to Construct the Sunflower Head. Although not a paper on computer graphics, it has been popularized as a sampling technique because of its near-uniform packing and randomness.

It works by placing $n$ sample points in a unit disc, where each point $i$ sits at radius $\sqrt{(i + 0.5) / n}$ and angle $i \cdot \phi_g + \phi$. Here $\phi_g \approx 2.4$ radians is the golden angle, derived from the golden ratio. The golden angle ensures consecutive points are never clustered together, producing a near-uniform distribution over the disc area without the axis-aligned regularity of a grid.

vogel

We can sample each individual point with the Vogel disc function which takes the sample index, the number of total samples, and the offset $\phi$.

float2 vogelDiskSample(int sampleIndex, int sampleCount, float phi)
{
    const float goldenAngle = 2.399963f;
    float r = sqrt((float(sampleIndex) + 0.5f) / float(sampleCount));
    float theta = float(sampleIndex) * goldenAngle + phi;
    return float2(cos(theta), sin(theta)) * r;
}

In order to randomize the sampling, we must adjust the offset angle $\phi$ for each fragment. We construct a hashing function of the shadow-space coordinate that returns a number in $[0,2\pi]$.

    // Radius of the blur in terms of texels
    float2 filterRadiusUV = 2 * texelSize;

    // Per-fragment offset used in the vogel disc sampling
    const float phi = frac(sin(dot(projCoords.xy, float2(143.2f, 364.7f))) * 60158.2443f) * 6.28318f;
    const int pcfSamples = 25;
    float shadow = 0.0f;
    for (int i = 0; i < pcfSamples; i++)
    {
        float2 offset = vogelDiskSample(i, pcfSamples, phi) * filterRadiusUV;
        float occluderDepth = shadowMap.Sample(shadowSampler, projCoords.xy + offset);
        shadow += (currentDepth - bias > occluderDepth) ? 1.0f : 0.0f;
    }

    return shadow / float(pcfSamples);

With the same number of samples as the PCF implementation, we are able to achieve much higher quality shadows.

Vogel Sampling


Vogel-disc Filtered Soft Shadows (VDFSS)

All the sampling techniques so far have produced uniformly soft shadows. But in a realistic setting, the shadow sharpens when the caster is close to the receiver and softens farther away. You can see this in real life if you cast a shadow with your hand and adjust the distance to the shadowed area. Percentage-closer Soft Shadows, introduced by Fernando in 2005, achieves this by adjusting the PCF kernel radius from the estimated penumbra width rather than a fixed constant. We will follow Fernando’s steps but substitute PCF with Vogel disc filtering.

The algorithm is divided into three distinct steps. First, we do a so-called blocker search, which finds the depth of the casting geometry. The second step estimates the penumbra width. The third step filters the shadow based on the penumbra width.

To estimate the penumbra width, we need to estimate the average depth of the blocking geometry. We do this with 16 Vogel disc samples – although any number of samples would work, it is simply a matter of performance versus quality.

    float lightSize = 10.0f; // Bigger light means bigger penumbra
    const int blockerSamples = 16;
    float2 searchRadiusUV = lightSize * texelSize;
    float avgBlockerDepth = 0.0f;
    int blockerCount = 0;
    for (int i = 0; i < blockerSamples; i++)
    {
        float2 offset = vogelDiskSample(i, blockerSamples, phi) * searchRadiusUV;
        float occluderDepth = shadowMap.Sample(shadowSampler, projCoords.xy + offset);
        if (occluderDepth < currentDepth - bias)
        {
            avgBlockerDepth += occluderDepth;
            blockerCount++;
        }
    }

    // No blockers found means the fragment is fully lit
    if (blockerCount == 0)
        return 0.0f;

    avgBlockerDepth /= float(blockerCount);
    

Stage 2: Penumbra estimation

The penumbra formula is the following

\[w_\text{penumbra} = \frac{d_\text{receiver} - \bar{d}_\text{blocker}}{\bar{d}_\text{blocker}} \cdot w_\text{light}\]

In code this translates to:

float penumbraWidth = ((currentDepth - avgBlockerDepth) / avgBlockerDepth) * lightSize;

Stage 3: Variable-radius sampling

Finally we use penumbraWidth calculated in the previous step and compute the filter radius.

float2 filterRadiusUV = max(penumbraWidth, 2.0f) * texelSize;

The reason for the maximum is to prevent aliasing when the pixel is very close to the occluder.

Any number of samples can be used for the filter. More samples means less noise but worse performance. So again, it is a performance versus quality scenario. We choose 49 samples for our purposes.

const int pcfSamples = 49;
float shadow = 0.0f;
for (int i = 0; i < pcfSamples; i++)
{
    float2 offset = vogelDiskSample(i, pcfSamples, phi) * filterRadiusUV;
    float occluderDepth = shadowMap.Sample(shadowSampler, projCoords.xy + offset);
    shadow += (currentDepth - bias > occluderDepth) ? 1.0f : 0.0f;
}

return shadow / (pcfSamples);

Results

Vogel-disc Soft Shadows

The image above shows that the shadow sharpens where the occluder is close to the surface and softens as the shadow stretches away, producing a wide, blurred penumbra. Neither raw sampling, PCF, nor Vogel disc PCF can reproduce this, as all three apply a fixed kernel radius regardless of occluder distance.

The cost is roughly 65 shadow-map samples (16 for the blocker search, 49 for the filter stage). If performance is tight, blockerSamples and pcfSamples can be reduced. The blocker search tolerates fewer samples well, since it only needs a coarse depth estimate.


Further reading