Tutorial 4 β€” Shadows

Prerequisites

Make sure you have completed Tutorial 3 β€” Deferred Rendering.

Setting up the project

In MTMetalTutorialsApp.swift set MT4ContentView() and run:

@main
struct MetalTutorialsApp: App {
    var body: some Scene {
        WindowGroup {
            MT4ContentView()
        }
    }
}

Shadows result

New concepts in this tutorial

  • Shadow map β€” a depth texture rendered from the light’s point of view
  • Shadow pass β€” a new render pass before the GBuffer pass, depth-only, no fragment shader
  • Light-space transform β€” a second MVP matrix computed from the light position
  • PCF shadow sampling β€” comparing fragment depth against the shadow map with a compare_func
  • Ground plane β€” a procedural MDLMesh plane added to the scene

Files

File Purpose
MT4ContentView.swift Sets up Metal view and render loop
MT4DeferredMetalView.swift Subclass of MTKView, sets up Metal device, command queue, etc.
MT4DeferredRenderer.swift Three-pass renderer: shadow β†’ GBuffer β†’ lighting
MT4DeferredRendering.metal Shadow vertex shader + GBuffer with shadow test
MT4RenderTargets.h Adds MT4RenderTargetShadow = 4
MT4Uniforms.h Adds shadowModelViewProjectionMatrix to vertex uniforms

Three-pass render sequence

sequenceDiagram
    participant CB as "Command Buffer"
    participant Shadow as "Shadow Pass"
    participant GBuffer as "GBuffer Pass"
    participant Lighting as "Lighting Pass"

    CB->>Shadow: vertex_depth only β€” writes shadow map texture
    CB->>GBuffer: vertex_main + gbuffer_fragment β€” reads shadow map, writes GBuffer
    CB->>Lighting: full-screen quad β€” reads GBuffer, writes drawable

Interactive: light position and shadow

Drag left/right to move the light. Watch how the shadow direction and length change.

How shadow mapping works

Shadow mapping is a two-pass technique:

  1. Shadow pass β€” Render the scene from the light’s perspective. Only write depth (no color). The resulting depth texture is the shadow map.
  2. To accurately determine if a fragment is in shadow, transform its world position into light space, aligning the scene with the light’s perspective for consistent depth comparisons. Sample the shadow map at that location. If the fragment’s light-space depth is greater than the stored depth, something is closer to the light β†’ the fragment is in shadow.
Light's eye ──→ scene ┐──→ depth texture (shadow map)
                               β”‚
Camera's eye ┐──→ fragment β””β”€β”€β”€β”€β”˜ compare depths β†’ lit or shadowed

Shadow texture creation

The shadow map is a depth-only texture. It is recreated on every resize (same as the GBuffer):

let shadowDesc = MTLTextureDescriptor
    .texture2DDescriptor(pixelFormat: .depth32Float,
                         width: Int(size.width),
                         height: Int(size.height),
                         mipmapped: false)
shadowDesc.usage       = [.shaderRead, .renderTarget]
shadowDesc.storageMode = .private
_shadowTexture = device.makeTexture(descriptor: shadowDesc)!
_shadowTexture.label = "Shadow Depth Texture"

Note .depth32Float β€” 32 bits of precision is important for reducing shadow acne (self-shadowing artifacts).

The shadow pipeline

The shadow pass needs no fragment shader β€” we only care about depth values:

_shadowPSO = _buildPipeline(
    vertexFunctionName:   "MT4::vertex_depth",
    fragmentFunctionName: nil,           // ← no fragment shader
    label: "ShadowPSO"
) { descriptor in
    descriptor.colorAttachments[0]?.pixelFormat       = .invalid
    descriptor.depthAttachmentPixelFormat             = .depth32Float
    descriptor.stencilAttachmentPixelFormat           = .invalid
}

Setting fragmentFunctionName: nil and all color attachments to .invalid is how you tell Metal β€œdepth only.” This ensures that no color output is generated for this pass, focusing solely on the depth information needed for shadow mapping.

Light-space matrix

The key new addition in MT4Uniforms.h:

struct MT4VertexUniforms {
    matrix_float4x4 modelViewMatrix;
    matrix_float3x3 modelViewInverseTransposeMatrix;
    matrix_float4x4 modelViewProjectionMatrix;

    // NEW β€” transforms vertices into the light's clip space
    matrix_float4x4 shadowModelViewProjectionMatrix;
};

In Swift, this is computed in _buildUniforms:

// Camera is placed AT the light position, looking at scene center
let shadowViewMatrix = float4x4(
    origin: lightPosition, target: center, up: SIMD3<Float>(0, 1, 0))

// Narrow FOV β€” the light is far away and focused
let shadowProjectionMatrix = float4x4(
    perspectiveProjectionFov: Float.pi / 16,
    aspectRatio: 1, nearZ: 0.1, farZ: 10_000)

let shadowModelViewProjection = shadowProjectionMatrix * shadowViewMatrix * modelMatrix

Both the shadow pass and the GBuffer pass receive this matrix via MT4VertexUniforms.

Three-pass render loop

The render function now encodes three passes into one command buffer:

let commandBuffer = _commandQueue.makeCommandBuffer()!

// ── Pass 1: Shadow ────────────────────────────────────────────
_encodePass(into: commandBuffer, using: shadowPassDescriptor, label: "Shadow Pass") { enc in
    enc.setRenderPipelineState(_shadowPSO)
    enc.setDepthStencilState(_depthStencilState)
    enc.setVertexBytes(&uniforms.0, …, index: 1)
    _renderMeshes(enc)    // bunny only (not the plane β€” it can't cast on itself here)
}

// ── Pass 2: GBuffer ───────────────────────────────────────────
_encodePass(into: commandBuffer, using: gBufferPassDescriptor, label: "GBuffer Pass") { enc in
    enc.setRenderPipelineState(_gBufferPSO)
    enc.setDepthStencilState(_depthStencilState)
    enc.setFragmentTexture(_shadowTexture, index: Int(MT4RenderTargetShadow.rawValue))
    _renderMeshes(enc)    // bunny only (not the plane β€” it can't cast on itself here)
}

// ── Pass 3: Lighting ───────────────────────────────────────────
_encodePass(into: commandBuffer, using: lightingPassDescriptor, label: "Lighting Pass") { enc in
    enc.setRenderPipelineState(_lightingPSO)
    enc.setDepthStencilState(_depthStencilState)
    enc.setFragmentTexture(_gbufferTextures[0], index: 0) // diffuse texture
    enc.setFragmentTexture(_gbufferTextures[1], index: 1) // normal texture
    enc.setFragmentTexture(_shadowTexture, index: 2)      // shadow map
    _renderMeshes(enc)
}

commandBuffer.presentDrawable(drawable!)
commandBuffer.commit()

Metal shaders β€” MT4DeferredRendering.metal

Vertex shader

The vertex shader now outputs lightViewPosition in addition to the standard clip-space position. This is used by the GBuffer fragment shader to look up the shadow map:

struct VertexOut {
    float4 clipSpacePosition [[position]];
    float3 viewNormal;
    float4 viewPosition;
    float2 texCoords;
    float4 lightViewPosition;   // NEW: position in light's clip space
};

vertex VertexOut vertex_main(VertexIn vertexIn [[stage_in]],
                                    constant MT4VertexUniforms &uniforms [[buffer(1)]])
{
    VertexOut vertexOut;
    vertexOut.clipSpacePosition = uniforms.modelViewProjectionMatrix * float4(vertexIn.position, 1);
    vertexOut.viewNormal = uniforms.modelViewInverseTransposeMatrix * vertexIn.normal;
    vertexOut.viewPosition = uniforms.modelViewMatrix * float4(vertexIn.position, 1);
    vertexOut.lightViewPosition = uniforms.shadowModelViewProjectionMatrix * float4(vertexIn.position, 1);
    return vertexOut;
}

Shadow-only vertex shader

The shadow pass needs only depth β€” no normals, no color, just the clip-space position from the light’s perspective:

struct ShadowVertexIn {
    float3 position  [[attribute(0)]];
};

vertex float4
  vertex_depth(ShadowVertexIn in  [[ stage_in ]],
               constant MT4VertexUniforms &uniforms [[buffer(1)]])
{
  return uniforms.shadowModelViewProjectionMatrix * float4(in.position,1);
}

GBuffer fragment β€” shadow test

The GBuffer fragment shader projects lightViewPosition into the shadow map’s UV space, samples the depth, and dims the albedo if the fragment is occluded:

fragment GBuffer gbuffer_fragment(
                                         VertexOut fragmentIn [[stage_in]],
                                         depth2d<float> shadowTexture [[ texture(MT4RenderTargetShadow) ]],
                                         constant MT4FragmentUniforms &uniforms [[buffer(1)]]
                                         )
{
    float3 lightCoords = fragmentIn.lightViewPosition.xyz / fragmentIn.lightViewPosition.w;
    float2 lightScreenCoords = lightCoords.xy;
    lightScreenCoords = lightScreenCoords * 0.5 + 0.5;
    lightScreenCoords.y = 1 - lightScreenCoords.y; //invert y

    GBuffer out;

    if (lightScreenCoords.x < 0.0 || lightScreenCoords.x > 1.0 ||
        lightScreenCoords.y < 0.0 || lightScreenCoords.y > 1.0) {
        out.albedo = float4(1, 0, 0, 1);
    } else {
        constexpr sampler s(
          coord::normalized, filter::linear,
          address::clamp_to_edge,
          compare_func::less);

        float4 albedo = float4(1,1,1,1);

        float depthValue = shadowTexture.sample(s, lightScreenCoords);
        if (lightCoords.z > depthValue + 0.00001f) {
            albedo *= 0.4;   // in shadow β€” darken
        }
        // … fill GBuffer outputs …
    }
}

Note depth2d<float> β€” not texture2d. Metal’s depth textures require this type to use compare_func sampling. The small bias (+ 0.00001f) prevents shadow acne (self-shadowing artifacts from floating-point imprecision).


Next: Tutorial 5 β€” Tiled Rendering β€” eliminate GBuffer bandwidth on Apple Silicon with .memoryless textures and a merged render pass.