T.TAO
Back to Blog
/6 min read/Graphics Engine

Metal #10 Shadow

#ComputerGraphics#GraphicsEngine#Metal

This note covers shadow mapping in Metal, and the handful of traps that essentially everyone falls into.

The basic idea

Shadow mapping is one sentence: render the scene's depth from the light's point of view. Then during the main render, transform each fragment into light space and compare its depth against what the shadow map recorded β€” if it is further away, something else was in the way and the fragment is in shadow.

So it is a two-pass algorithm.

Pass 1: the depth pass

Only a depth attachment is needed, no colour:

Swiftlet shadowDescriptor = MTLRenderPassDescriptor()
shadowDescriptor.depthAttachment.texture     = shadowMap
shadowDescriptor.depthAttachment.loadAction  = .clear
shadowDescriptor.depthAttachment.storeAction = .store      // the main pass reads it
shadowDescriptor.depthAttachment.clearDepth  = 1.0

let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: shadowDescriptor)!
encoder.setRenderPipelineState(shadowPipelineState)        // vertex function only
encoder.setDepthStencilState(depthStencilState)

Note the store action must be .store here β€” this is exactly the "unless a later pass reads it" exception from the previous note.

The shadow pipeline can omit the fragment function entirely (fragmentFunction = nil), because we only want depth:

Swiftlet pd = MTLRenderPipelineDescriptor()
pd.vertexFunction   = library.makeFunction(name: "shadow_vertex")
pd.fragmentFunction = nil
pd.depthAttachmentPixelFormat = .depth32Float

That makes the depth pass considerably faster, and is why it costs so much less than the main pass.

The vertex function does one thing:

MSLvertex float4 shadow_vertex(VertexIn in [[stage_in]],
                            constant float4x4 &lightMVP [[buffer(11)]])
{
    return lightMVP * in.position;
}

lightMVP is the light's view-projection times the model matrix. Directional lights use an orthographic projection (parallel rays); point lights use perspective and need all six faces of a cube.

Pass 2: the comparison

In the main pass, the vertex function outputs an extra light-space position:

MSLout.shadowPosition = uniforms.lightViewProjection * posWS;

The fragment function divides, maps to [0,1] UVs, and samples:

MSLfloat3 proj = in.shadowPosition.xyz / in.shadowPosition.w;   // w is 1 for ortho, but keep the divide
float2 uv   = proj.xy * 0.5 + 0.5;
uv.y        = 1.0 - uv.y;                                    // Metal texture origin is top-left

float closest = shadowMap.sample(shadowSampler, uv).r;
float current = proj.z;                                      // Metal depth is already [0,1]
float shadow  = current > closest ? 0.0 : 1.0;

Two Metal-specific details: the texture y axis has to be flipped (NDC has its origin at the centre with y up; textures have theirs at the top-left with y down), and depth does not need remapping from [-1,1] to [0,1] β€” Metal's NDC depth is already [0,1], so the proj.z * 0.5 + 0.5 copied from an OpenGL tutorial is wrong here.

The shadow map sampler must use .clampToEdge, and anything outside the shadow map should be treated as not in shadow. Otherwise the entire world beyond the light's frustum goes black.

Shadow acne and Peter Panning

Run the code above as-is and you get stripes of self-shadowing across every surface β€” shadow acne.

The cause is finite shadow map resolution. One shadow texel covers a small patch of surface, and every point in that patch shares one recorded depth, while their actual depths vary continuously. Half of them therefore test as "further away than recorded" and are misclassified as shadowed.

The direct fix is a bias:

MSLfloat bias = 0.005;
float shadow = current - bias > closest ? 0.0 : 1.0;

But a constant bias treats the symptom. The more a surface is inclined, the larger the depth range a single texel spans, and the larger the bias it needs. So use a slope-scaled bias:

MSLfloat bias = max(0.05 * (1.0 - dot(n, l)), 0.005);

Push the bias too far and you get the other failure mode: Peter Panning, where an object visibly detaches from its shadow and appears to float. The name is from the story about the boy who lost his shadow.

The more thorough fix is to render back faces only in the depth pass:

Swiftencoder.setCullMode(.front)

Now the recorded depth is that of the object's far side, one object-thickness behind the lit surface, so self-shadowing disappears and no bias is needed. The catch is that this only works for closed, solid objects β€” a single-sided wall or a leaf card loses its shadow entirely.

In practice you combine the two: back-face rendering for closed geometry, slope-scaled bias for thin cards.

Hardware shadow comparison

That current > closest comparison can be done by the texture unit instead, and you get hardware PCF for free. Metal exposes this through a comparison sampler:

Swiftlet sd = MTLSamplerDescriptor()
sd.compareFunction = .lessEqual
sd.minFilter = .linear
sd.magFilter = .linear
shadowSampler = device.makeSamplerState(descriptor: sd)
MSLdepth2d<float> shadowMap [[texture(1)]];
sampler_comparison shadowSampler [[sampler(1)]];

float shadow = shadowMap.sample_compare(shadowSampler, uv, current - bias);

sample_compare performs four comparisons and bilinearly interpolates the comparison results, not the depths, returning a soft 0-to-1 edge directly. The order matters: interpolating depth and then comparing is wrong; comparing and then interpolating is right.

PCF: softening the edge

A single sample_compare softens by one pixel. For genuinely soft shadows you average several samples over a neighbourhood β€” PCF, percentage-closer filtering:

MSLfloat pcf(depth2d<float> map, sampler_comparison s, float2 uv, float ref) {
    float sum = 0.0;
    float texel = 1.0 / 2048.0;
    for (int y = -1; y <= 1; y++) {
        for (int x = -1; x <= 1; x++) {
            sum += map.sample_compare(s, uv + float2(x, y) * texel, ref);
        }
    }
    return sum / 9.0;
}

3Γ—3 is the usual compromise between quality and cost. For softer results, rather than enlarging the kernel, use Poisson disk sampling with a per-pixel rotation: fewer samples, and the error appears as noise instead of banding β€” and noise is something TAA removes for you.

Cascaded shadow maps

One shadow map covering the whole view frustum means terrible resolution up close: the grass at your feet gets the same texel density as a hillside two hundred metres away, which is plainly the wrong allocation.

Cascaded shadow maps (CSM) split the frustum into depth slices, each with its own shadow map. The nearest map covers a small region and therefore has high effective resolution.

Plain Text|--cascade 0--|-----cascade 1-----|----------cascade 2----------|
0            10m                 40m                          200m

The split distances are usually a blend of logarithmic and uniform (the practical split scheme), because a purely logarithmic split packs too many cascades up close.

The fragment picks a cascade by view-space depth:

MSLuint cascade = 0;
for (uint i = 0; i < cascadeCount - 1; i++) {
    if (viewDepth > cascadeSplits[i]) cascade = i + 1;
}
float shadow = pcf(shadowMaps, s, projectToCascade(posWS, cascade), ref);

Cascades bring two characteristic problems: visible seams (fixed by blending across a narrow band between cascades) and shadow swimming (shadow edges shimmer as the camera moves, because the light's orthographic bounds keep changing). The standard fix for the latter is to snap the light projection's centre to the shadow map's texel grid, so the sample points stay fixed in world space.

The next note covers deferred rendering.

In this series

Metal β†’
  1. 01Metal #0 Swift Review
  2. 02Metal #1 Initialization
  3. 03Metal #2 Rendering Pipeline
  4. 04Metal #3 Vertex Function
  5. 05Metal #4 Fragment Function
  6. 06Metal #5 Texture
  7. 07Metal #6 Navigation
  8. 08Metal #7 Lighting
  9. 09Metal #8 Materials
  10. 10Metal #9 Render Passes
  11. 11Metal #10 Shadow
  12. 12Metal #11 Deferred Rendering
  13. 13Metal #12 Particle System
  14. 14Metal #13 Tessellation
  15. 15Metal #14 Post-processing
  16. 16Metal #15 Reflection and Refraction
  17. 17Metal #16 Animation
  18. 18Metal #17 Ray Tracing (I) Rendering Algorithm
  19. 19Metal #18 Ray Tracing (II) Shadows and Lighting
  20. 20Metal #19 Ray Tracing (III) Performance Optimization
  21. 21Metal #21 [Appendix] Compute Shaders
  22. 22Metal #22 [Appendix] Metal in SwiftUI