Metal Tutorial 4 β Shadows
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()
}
}
}

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
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
Interactive: light position and shadow
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
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>β nottexture2d. Metalβs depth textures require this type to usecompare_funcsampling. 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.