Lab 5.1 — Shadow Mapping & Cascades
← Course 4 syllabus · Module 5 · Prev: « Lab 4.4 · Next: Lab 5.2 »
Goal
Shadows, built the way every shipping renderer builds them: as visibility from the light. A shadow map is a depth-only render of the scene from a second camera — which means the render graph from Lab 4.2 finally earns its keep: a new pass, a new attachment, a read-dependency into the main pass, and the graph handles the transitions. On top of that machinery this lab builds the full practical stack: the shadow test with hardware comparison samplers, the famous failure modes (shadow acne and peter-panning) and the bias toolbox that trades between them, PCF filtering, and finally cascaded shadow maps (CSM) for a sun over a large scene — frustum splitting, per-cascade tight projections, cascade selection, and stabilization against shimmer. The thesis to internalize: shadow-map aliasing is not a grab-bag of hacks, it is the sampling problem of Course 1 Section 8 — the map is a discretely sampled signal, the shadow test is a resampling of it, and every artifact in the gallery is undersampling wearing a costume.
Recommended reading
- Lengyel — the shadow-mapping and visibility-determination chapters (topic-level; confirm against the copy in hand) — in particular the treatment behind the perspective vs. projection aliasing distinction this lab’s analysis leans on.
- C&S — the shadow chapter (topic-level), for where shadow passes live in a render-graph engine and how cascades are scheduled.
- MbT — the shadow-mapping chapter (topic-level), the Metal-side telling of the same depth-only pass.
- LearnOpenGL — the shadow-mapping articles (basic shadow mapping, then the CSM article): the clearest free walk through acne, bias, PCF, and cascades, API-agnostic in substance.
- Course 1 Section 8 — sampling and aliasing; reread the sampling-theorem intuition before the analysis section asks you to apply it to a depth map.
- Course 1 Section 1 — the light’s view and projection are just another change of basis and another projection; nothing new, applied twice.
Prerequisites
- Lab 4.2: the render graph schedules passes and derives barriers/transitions from declared reads and writes.
- Lab 4.4: the instanced test scene with per-object bounds and CPU frustum culling — reused here to cull per cascade.
- Module 3 lighting in place (Lab 3.1 at minimum; Lab 3.3’s direct term if you shade with PBR) — the shadow factor multiplies a lighting term that must already exist.
Project & environment setup
- New engine work in
engine/render/: ashadow_pass(depth-only, N cascades) and shadow-sampling additions to the main pass’s shader interface. Shaders inshaders/as usual — GLSL→SPIR-V and MSL variants of the same logic. - The Module 4 scene (instanced meshes over a ground plane) is sufficient; add one low, grazing “sun” direction to the scene description so acne has somewhere to live.
- A debug toggle system (keyboard or ImGui if you added it) pays for itself this lab: bias values, PCF radius, cascade count, debug-color mode, and snapping on/off should all be flippable at runtime — the failure gallery is captured by turning fixes off.
Where results go:
| Artifact | Path |
|---|---|
| Notes, failure gallery, split-scheme comparison, predicted-vs-measured | labs/lab-5-1/notes.md |
| Screenshots (acne/bias/peter-pan series, cascade debug colors), shimmer video pair | labs/lab-5-1/captures/ |
| HUD/timestamp dumps for depth-only vs. main pass | labs/lab-5-1/benchmarks/ |
Background
The two-camera view. Render depth from the light: a view matrix looking along the light direction, an orthographic projection (for a directional light) fit around the visible scene. In the main pass, transform each shaded point into that light’s clip space, and compare its light-space depth \(d_r\) (receiver) against the stored map depth \(d_m\) at its projected texel:
\[ \text{lit}(\mathbf{p}) \;=\; \big[\, d_r(\mathbf{p}) \le d_m(\mathbf{p}) + b \,\big], \]
with \(b\) a bias. Hardware does this comparison for you: a comparison sampler (sampler2DShadow in GLSL, a sampler with MTLCompareFunction in Metal) returns the result of the compare — and with linear filtering enabled returns the filtered result over the 2×2 footprint, which is free 4-tap PCF.
- Acne vs. peter-panning. The map stores one depth per texel; every point shading within that texel compares against the same stored depth, so a slanted surface alternately self-shadows — acne, a moiré of stripes. Bias \(b\) pushes the comparison out of the noise, but too much detaches shadows from their casters — peter-panning. The toolbox, in escalating order: constant bias; slope-scaled bias \(b = b_0 + b_1 \tan\theta\) (\(\theta\) the angle between normal and light — hardware depth-bias units implement exactly this); normal-offset bias, moving the sample position along the geometric normal by a fraction of the texel’s world size; and front-face culling into the map (render back faces into the shadow map), which moves the acne to surfaces where it is hidden — each has a failure mode you will photograph.
- PCF. Percentage-closer filtering averages the comparison results (never the depths — averaging depths is meaningless) over a kernel of taps; penumbra width scales with kernel radius, cost scales linearly with tap count.
- Cascades. One map over a whole scene gives absurd texels-per-pixel near the camera. CSM splits the view frustum into \(N\) depth ranges and fits a tight light-space projection to each. The standard split blends logarithmic (theoretically optimal for perspective) and uniform schemes:
\[ z_i \;=\; \lambda\, z_n \left(\frac{z_f}{z_n}\right)^{i/N} + (1-\lambda)\left(z_n + \frac{i}{N}\,(z_f - z_n)\right), \qquad i = 1,\dots,N-1 . \]
- Aliasing taxonomy (Lengyel’s distinction, topic-level): perspective aliasing — shadow texels project to many screen pixels because the camera is close and the map is far-fit; cascades attack this. Projection aliasing — the surface is nearly parallel to the light rays, so one texel smears along it; no split scheme fixes this, only bias and filtering soften it. Naming which one you are looking at is half the debugging skill.
- Stabilization. A cascade projection that re-fits every frame makes shadow edges shimmer under camera motion — the map’s sample grid slides beneath the world. Fix by fitting each cascade to a bounding sphere of its frustum slice (rotation-invariant diameter) and snapping the projection origin to whole shadow-map texels, so the sample grid moves in texel-quantized steps.
Tasks
Engine (both backends)
- Depth-only pass in the graph. Add a
shadow_passproducing a depth-only attachment (32-bit float depth, 2048² to start) that the main pass declares as a sampled read. Verify in a frame capture that the graph inserted the depth-write → shader-read transition for you — that is the render graph earning its keep, in one screenshot. - Single-light shadow + the failure gallery. One directional light, one map, comparison-sampler test in the main pass. Then deliberately walk the failure ladder with the debug toggles, screenshotting each rung into
captures/: zero bias (acne), constant bias large enough to kill it (peter-panning visible at contact points), slope-scaled bias at a sane pair, normal-offset added, front-face culling into the map. Each screenshot gets one caption line innotes.mdnaming the artifact and the mechanism. - PCF comparison. Implement a selectable PCF kernel (1 hardware tap / 3×3 / 5×5, or a rotated-poisson variant if you prefer) and capture the same shadow edge under each; record the per-frame cost of each setting from the timestamp HUD.
- Cascaded shadow maps. 3–4 cascades: split the frustum with the blended scheme above (λ as a runtime slider), fit a tight per-cascade orthographic projection, cull the scene per cascade (Lab 4.4’s culling, run N times with different volumes), select the cascade per fragment, and add a debug-color mode tinting each cascade. Compare λ = 0 (uniform), λ = 1 (log), and your chosen blend with screenshots at matched viewpoints.
- Stabilize. Switch cascade fitting to bounding-sphere + texel snapping. Evidence: a short video (or A/B screenshot pair under small camera rotation) of a shadow edge with snapping off, then on — the shimmer must visibly die.
- Cross-cascade polish. Handle the cascade boundary: at minimum, verify the debug-color seam is where the math says; optionally blend a small overlap band between cascades.
Backend notes — Vulkan
- Depth format via the usual query ladder (
D32_SFLOATpreferred); sampler withcompareEnable+VK_COMPARE_OP_LESS_OR_EQUAL; hardware slope-scaled bias via the rasterizer depth-bias state (vkCmdSetDepthBiasif you kept it dynamic). Front-face culling into the map is one pipeline (or dynamic state) flip for the shadow pipeline. - The shadow pass has no fragment shader at all for opaque casters — check RenderDoc shows an empty fragment stage.
Backend notes — Metal
- Same logic in MSL: a
depth2d<float>sampled with a sampler whose compare function is set; depth bias viasetDepthBias(_:slopeScale:clamp:)on the render encoder. - The shadow map is not a candidate for memoryless storage — it is written in one pass and sampled in another, so it must live in device memory. Note this in
notes.mdnow; Lab 5.2 is about the attachments that can be memoryless, and the contrast is the lesson.
Deliverable & expected results
- The engine rendering the Module 4 scene with stable, filtered cascaded sun shadows on both backends; debug-color mode and all toggles working.
notes.mdwith the captioned failure gallery, the λ comparison, and the table below;captures/holding the gallery images and the shimmer-off/on evidence pair.
| Quantity | Predicted | Measured |
|---|---|---|
| World size of one shadow texel, per cascade | \(D_i / R\) for cascade diameter \(D_i\) (from the split scheme + frustum geometry) and map resolution \(R\) — tabulate all cascades by hand first | … |
| Acne at zero bias on the grazing-lit ground plane | guaranteed — same-texel self-comparison, worst where \(\tan\theta\) is large | … |
| Depth-only pass GPU cost vs. main pass | a small fraction — no fragment work, depth-write bound; expect it to scale with caster triangle count, not resolution of shading | … |
| PCF cost vs. tap count (1 → 9 → 25) | linear in taps in the shadowed-fragment term; wall-clock sublinear if texture-cache hits dominate | … |
| Shimmer after sphere-fit + texel snapping | edge motion quantized to whole texels — visually static under pure camera rotation | … |
Profiling & performance
Timestamp the shadow pass and the main pass separately with the in-engine HUD (Vulkan timestamp queries / Metal counter sample buffers — this HUD matures into a proper tool in Module 6, but the two numbers it gives here are already the ones that matter). On the Mac, one Xcode GPU capture of the frame: confirm the shadow encoder shows no fragment stage cost and read the two passes’ GPU times from the encoder timeline. On the Linux desktop (RTX 4090), the same frame in RenderDoc for correctness (pixel history on an acne texel is genuinely instructive) and Nsight Systems if you want the passes on a timeline. Tracy on the CPU side: per-cascade culling shows up as N visible zones — confirm the cost is N× Lab 4.4’s one-frustum cost, not worse.
Analysis & reconciliation
In notes.md: first, the by-hand cascade table — split depths from the λ-blend formula, per-cascade diameter, world-units-per-texel — against the measured debug-color boundaries on screen. Second, the sampling argument, in your own words: state what the shadow map’s spatial sampling rate is per cascade, what signal it is sampling, and why acne and edge crawl are exactly the aliasing Course 1 §8 predicts — then classify two artifacts from your gallery as perspective vs. projection aliasing and justify. Third, reconcile the PCF scaling row: if 25 taps did not cost ~25× the 1-tap term, explain where the time went (cache locality, shadowed-fragment fraction). File any unexplained pass-cost gap as a question for Lab 6.3’s deeper tooling.
Going further
- Cache-friendly cascades: skip re-rendering distant cascades every frame (round-robin update) and measure the saved GPU time against the visible staleness — a real engine tradeoff.
- Try a shadow atlas (all cascades in one texture, viewport-selected) vs. a texture array, and note what each backend makes easy.
- Read (paper-level, no implementation) about variance/moment shadow maps, and write one paragraph on what pre-filterable shadows would buy over PCF here.
- Point light: replace the sun with a cube-map shadow (six faces or a single-pass layered render) on one backend, and note what the render graph needed to express it.