Shadows
In this tutorial we add shadows to the deferred renderer using shadow mapping β a technique that asks βis this point visible from the light?β before shading it:
- Allocate a depth-only shadow map texture (rendered from the lightβs point of view)
- Shadow pass: render the scene with no fragment shader, writing only depth
- Compute a light-space MVP matrix and pass it alongside the regular camera matrices
- GBuffer pass: transform each fragment to light space and compare depth against the shadow map
- Add a ground plane so the shadows have somewhere to land
Prerequisite: 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()
}
}
}
π Metal API in this tutorial
| Object | Docs | Scope | Role |
|---|---|---|---|
Depth-only MTLRenderPipelineState |
β | Scene lifetime | Pipeline with no fragment function; writes only depth |
depth2d<float> (MSL) |
β | Shader | Depth texture type in Metal Shading Language; supports compare_func sampling |
compare_func::less (MSL sampler) |
β | Shader | Hardware-accelerated depth comparison inside the texture sampler |
MTLSamplerDescriptor |
β | Pipeline setup | Configures filtering, addressing, and compare function for samplers |
π‘ New concepts in this tutorial
- Shadow map β a depth texture (
MTLTexturewith.depth32Floatpixel format) 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 hardware
compare_funcsampler - Ground plane β a procedural
MDLMeshplane 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
π Shadow mapping: step by step
π How shadow mapping works
Shadow mapping is a two-pass technique:
- Shadow pass β Render the scene from the lightβs perspective. Only write depth (no color). The resulting depth texture is the shadow map.
- 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
Before encoding any passes, we allocate the shadow map. Itβs a depth-only texture the same resolution as the framebuffer β we recreate it whenever the view resizes, just like the GBuffer.
Depth textures in Metal. A depth texture is an ordinary
MTLTexturewith a depth pixel format (.depth32Float,.depth16Unorm, etc.) and usage[.shaderRead, .renderTarget]. The.depth32Floatformat gives 32-bit IEEE float depth precision β important for reducing shadow acne (depth aliasing artifacts). On Apple Silicon, depth textures are stored in a compressed, hierarchical format in the tile memory during rasterization and are only resolved to DRAM when.storeis used β so a shadow-only pass that uses.storeto make the depth readable is still efficient because the hardware compresses it automatically.
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 has no fragment shader β we only care about depth, not color. Metal needs a dedicated pipeline that reflects this:
Depth-only render pipeline. Setting
fragmentFunctionName: nilon anMTLRenderPipelineDescriptorcreates a depth-only pipeline β the fragment stage is entirely skipped. This is significantly faster than running a no-op fragment shader because the GPU can rasterize and write depth without stalling on fragment execution. Additionally, setting all color attachments to.invalidpixel format tells Metal no color writes occur, allowing the GPUβs early-depth (TBIMR) optimisations to kick in even more aggressively. Always use depth-only pipelines for shadow passes.
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
To render the scene from the lightβs perspective we need a second MVP matrix β one where the camera sits at the light position. This is computed every frame alongside the regular camera matrices and passed to all three passes via the vertex uniforms.
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
With the shadow texture, pipeline, and light-space matrix ready, the render function encodes all three passes into a single command buffer. The GPU executes them in order β shadow, GBuffer, lighting β before presenting the frame.
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
Three shader changes from Tutorial 3: the vertex shader gains a lightViewPosition output, a new depth-only vertex shader handles the shadow pass, and the GBuffer fragment shader uses lightViewPosition to sample the shadow map and decide lit vs. shadowed.
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
Three things happen in those four lines that every beginner gets stuck on:
1. Perspective division (/ .w):
lightViewPosition is a 4D homogeneous coordinate (x, y, z, w) produced by the projection matrix. The w component encodes depth β it grows proportionally to how far the point is from the camera. Dividing xyz by w converts homogeneous clip space into 3D NDC (Normalized Device Coordinates): the result is in the range [-1, 1] on X and Y, and [0, 1] on Z. This division is what creates perspective: farther objects shrink toward the center. The GPU does this automatically for your primary camera position ([[position]]), but for the secondary light-space position you have to do it manually.
2. NDC β UV (* 0.5 + 0.5):
NDC X/Y range from -1 to +1. Texture UV coordinates range from 0 to 1. The mapping is:
UV = NDC * 0.5 + 0.5
β -1 maps to 0.0 (left/bottom edge of texture)
β 0 maps to 0.5 (center)
β +1 maps to 1.0 (right/top edge of texture)
3. Y-axis flip (y = 1 - y):
Metalβs NDC has Y = +1 at the top of the screen and Y = -1 at the bottom. Texture UV coordinates have V = 0 at the top and V = 1 at the bottom (following image file convention). So after the * 0.5 + 0.5 conversion, NDC top (+1 β 1.0) needs to become UV 0.0, and NDC bottom (-1 β 0.0) needs to become UV 1.0. The formula y = 1 - y flips the axis.
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 β¦
}
}
depth2d<float>vstexture2d<float>in MSL. In Metal Shading Language, depth textures must be typed asdepth2d<float>(nottexture2d<float>) to unlock the hardware depth-comparison path. When you specifycompare_func::lessin aconstexpr sampler, the GPU performs the depth test inside the texture unit β it reads the stored depth, compares with your provided reference value, and returns 0.0 or 1.0. This is the PCF (Percentage Closer Filtering) path: withfilter::linear, the GPU performs 4 compare-and-sample operations and blends the results, giving a soft shadow edge at no extra shader cost. Usingtexture2d<float>instead would return the raw float depth and force you to do the compare in shader code β losing the hardware filtering.The small bias (
+ 0.00001f) offsets the fragmentβs depth slightly above the stored value to prevent shadow acne β a self-shadowing artifact caused by floating-point rounding making a surface appear to shadow itself. The ideal bias value depends on the surface slope relative to the light; some implementations use a slope-scaled bias (gl_PolygonOffset-style) for better quality.