Lab 5.2 — Deferred Shading & Tile-Based GPUs

Course 4 syllabus · Module 5 · Prev: « Lab 5.1 · Next: Lab 5.3 »

Goal

The architecture-comparison lab this course has been building toward. First, classic deferred shading: render geometry once into a G-buffer (normals, albedo, roughness/metallic — position reconstructed from depth, never stored), then shade in a second pass that reads the G-buffer and loops lights — the design that decouples scene complexity from lighting complexity and made hundreds of dynamic lights routine. Then the punchline this course owns two GPUs to deliver: on an immediate-mode GPU (the Linux desktop’s RTX 4090 — Ada Lovelace, discrete GDDR6X VRAM), the G-buffer is real DRAM traffic, written and read in full every frame — while on Apple’s TBDR GPUs, tile memory plus memoryless attachments (and, in Metal, programmable blending / tile shading) mean the same algorithm never touches DRAM with its G-buffer at all. Same code shape, different machine, an order-of-magnitude different bandwidth story — and both are measured, not asserted. This is the single best illustration in the course of why a rendering engineer must know the hardware under the API.

Prerequisites

  • Lab 5.1: multi-pass render-graph fluency; the shadow map can stay wired in or be toggled off for this lab’s measurements.
  • Lab 4.2 / Lab 4.4: the graph and the instanced scene.
  • Module 3’s forward lighting path (Lab 3.13.3) still building and runnable — it is the A side of this lab’s A/B.

Project & environment setup

  • New graph passes in engine/render/: gbuffer_pass and lighting_pass (plus an optional light_cull_pass compute stage). Shaders in shaders/ in both GLSL and MSL.
  • Extend the scene format with a light list; add a generator for the 100-point-light stress scene (lights on a grid or random walk over the Module 4 scene, small radii, animated so the win is visible).
  • A runtime toggle between the forward path and the deferred path, same scene, same camera — the A/B must be one keypress, or the comparison will not get made honestly.

Where results go:

Artifact Path
Notes, G-buffer layout worksheet, bandwidth reconciliation labs/lab-5-2/notes.md
Xcode GPU captures, RenderDoc/Nsight captures, screenshots labs/lab-5-2/captures/
Counter exports, HUD dumps for forward-vs-deferred and light sweeps labs/lab-5-2/benchmarks/

Background

The G-buffer. Geometry is rasterized once, writing per-pixel surface attributes to multiple render targets. A disciplined layout for this lab: normals octahedrally encoded into two components (worth a note in the worksheet — unit vectors are 2-DOF, storing three channels wastes bits), albedo in 8-bit sRGB-aware channels, roughness+metallic packed alongside. Position is never stored: it is reconstructed from the depth buffer and the inverse projection,

\[ \mathbf{q} = P^{-1}\begin{bmatrix} x_{ndc} \\ y_{ndc} \\ d \\ 1 \end{bmatrix}, \qquad \mathbf{p}_{\text{view}} = \frac{\mathbf{q}_{xyz}}{\mathbf{q}_w}, \]

with \((x_{ndc}, y_{ndc})\) from the fragment coordinate and \(d\) the sampled depth — derive the cheaper ray-times-linear-depth form in your notes and use whichever you can defend.

  • Why defer. Forward shading does lighting work per rasterized fragment per light: cost \(\mathcal{O}(F \cdot L)\) with overdraw inside \(F\). Deferred shades each visible pixel once per light that reaches it: geometry cost and lighting cost separate, and many small lights become cheap — the case the stress scene demonstrates. The classical costs: transparents don’t fit (no single surface per pixel — they stay on a forward path), MSAA becomes awkward (per-sample G-buffer or edge tricks), and material variety is squeezed through one G-buffer schema.
  • Light assignment. The naive light pass loops all lights per pixel. Two standard improvements, one of which you will implement: light volumes (rasterize a proxy sphere per light, shading only covered pixels — simple, but overlap-heavy) or tiled light culling (a compute pass bins lights into screen tiles, then the light pass loops only its tile’s list). The tiled path is this module’s first in-API compute pass — and worth saying once: CUDA (Module 1) was the classroom; compute shaders inside the graphics API are the production tool. The warps/bandwidth/shared-memory mental model transfers wholesale; only the spelling changes.
  • The TBDR truth. An immediate-mode GPU processes triangles in submission order and its render targets live in DRAM: every G-buffer byte is written to memory and read back in the light pass. A TBDR GPU (every Apple M-series) splits the screen into tiles, bins geometry per tile, and shades a tile entirely in on-chip tile memory, writing to DRAM only what the pass stores. Declare the G-buffer attachments memoryless (storageMode = .memoryless, load/store actions dontCare), keep producer and consumer in one render pass (programmable blending reading the framebuffer, or an explicit tile shader), and the G-buffer term of DRAM traffic essentially vanishes — only the final color (and depth if kept) is ever stored. In Vulkan the analogous machinery is subpasses with input attachments / local read, which MoltenVK maps with varying fidelity — the honest engineering answer on Apple hardware is the Metal-native one, which is exactly why the engine has a metal-cpp backend.
flowchart LR
  subgraph IMR["Immediate-mode (RTX 4090)"]
    G1[Geometry pass] -->|"G-buffer write → DRAM"| D[(DRAM)]
    D -->|"G-buffer read ← DRAM"| L1[Light pass]
  end
  subgraph TBDR["Apple TBDR (memoryless)"]
    G2[Geometry phase] -->|tile memory only| T[Tile]
    T --> L2[Light phase]
    L2 -->|"final color only"| D2[(DRAM)]
  end

flowchart LR
  subgraph IMR["Immediate-mode (RTX 4090)"]
    G1[Geometry pass] -->|"G-buffer write → DRAM"| D[(DRAM)]
    D -->|"G-buffer read ← DRAM"| L1[Light pass]
  end
  subgraph TBDR["Apple TBDR (memoryless)"]
    G2[Geometry phase] -->|tile memory only| T[Tile]
    T --> L2[Light phase]
    L2 -->|"final color only"| D2[(DRAM)]
  end

Tasks

The deferred renderer (both backends)

  1. Layout worksheet first. In notes.md, before any code: chosen G-buffer formats, bits per channel, bytes per pixel per target, total bytes per pixel — the arithmetic the Deliverable table asks for. Justify each format in one line.
  2. G-buffer pass. Add gbuffer_pass to the graph writing the MRT set + depth; verify contents with a debug view that can blit each target (and the octahedral-decoded normal) to screen. RenderDoc/Xcode texture views are the ground truth to compare against.
  3. Lighting pass. Full-screen pass reading the G-buffer, reconstructing position from depth, evaluating the Module 3 BRDF per light. Start with the all-lights loop; confirm image parity with the forward path on a matched scene (a small pixel-diff screenshot belongs in captures/).
  4. Light assignment. Implement one of light volumes or tiled compute culling — and write a paragraph defending the choice against the other (the discussion is graded content, in the self-imposed sense). Keep the naive loop behind a toggle as the baseline.

The Apple-GPU variant (Metal)

  1. Memoryless G-buffer. Restructure the Metal backend’s deferred path into a single render pass: G-buffer attachments memoryless, store actions dontCare, lighting reading them in-pass (programmable blending / imageblocks per the MbT tile-shader chapter). The image must be identical; only the counters change.

The measurement (both GPUs)

  1. Bandwidth, measured. On the Mac: Xcode GPU capture with counters — record the pass’s memory-traffic counters for the DRAM-backed variant vs. the memoryless variant. On the 4090: the same deferred frame in Nsight (or RenderDoc’s overlay for structure) — record VRAM read/write for the G-buffer passes. Export both into benchmarks/.
  2. The 100-light A/B. Forward path vs. deferred path on the stress scene, both GPUs: frame time as light count sweeps 1 → 100 (HUD timestamps, a handful of sample points is enough). Find the crossover.

Deliverable & expected results

  • Deferred rendering producing images matching the forward path on both backends; the Metal memoryless variant; debug views of every G-buffer channel; the light sweep recorded.
  • notes.md carrying the layout worksheet, the choice-defense paragraph, and the reconciliation.
Quantity Predicted Measured
G-buffer size at chosen formats & resolution bytes/pixel × pixel count — from the worksheet arithmetic, computed by hand for your native resolution
Per-frame G-buffer VRAM traffic, 4090 (naive: write + read once) ≥ size × 2, plus depth traffic — a hand lower-bound; overdraw pushes writes above it. The 4090’s raw bandwidth is enormous, so the observable is the counter reading and its fraction of capacity (from the device query / Nsight), not a struggling frame
DRAM traffic delta with memoryless on Apple the G-buffer term ~vanishes from the counters; residual traffic ≈ final color + depth + textures
Light-count crossover, forward vs. deferred qualitative: forward scales with (fragments × lights), deferred pays a fixed G-buffer cost then scales with covered pixels — expect a crossover at modest light counts for small-radius lights; reason it before measuring
Image parity forward vs. deferred indistinguishable on opaque geometry (diff near zero); transparents excluded by construction

Profiling & performance

This lab is its profiling section. Mac: Xcode GPU capture is primary — the counters view for memory traffic, the encoder/phase timeline to see the geometry and lighting phases, and (worth an annotated screenshot) evidence of the single-pass structure of the memoryless variant. The Linux box: Nsight Systems for the pass timeline and memory throughput, RenderDoc for structure and G-buffer inspection. Tracy stays on the CPU side to confirm the deferred path’s CPU cost is flat in light count while forward’s per-light uniform churn (if any) shows up. The in-engine timestamp HUD gives the sweep numbers; Module 6 (Lab 6.2/6.4) turns these counters into a formal methodology — here you take honest first readings.

Analysis & reconciliation

The centerpiece: a side-by-side table, 4090 vs. Mac, of measured G-buffer-attributable traffic against the hand-computed size, with the ratio explained (overdraw, compression, depth) — on the 4090, report the traffic both in bytes and as a fraction of its (device-queried) peak bandwidth, since the architectural point is which cost terms exist, not whether a desktop GPU visibly struggles. Then the paragraph the lab exists for: the same algorithm on two machines — state precisely which architectural feature (tile memory + binning) removes which term of the cost, and what that implies for how a cross-platform engine should structure its render graph on each. Reconcile the crossover row: did the measured crossover match your scaling argument, and which assumption (light radius, overdraw, fixed cost) was most wrong? Close by revisiting the forward-vs-deferred tradeoff list against your own evidence — which classical objection (transparents, MSAA, material variety) would actually bite in this engine first?

Going further

  • SSAO (LearnOpenGL theory article): a screen-space G-buffer consumer — implement the sampling loop as a new graph pass and revisit Course 1 §16’s take on it as a screen-space filter.
  • Vulkan subpasses / dynamic-rendering local read on MoltenVK: implement, and record how close it gets to the Metal-native counters — the portability-layer lesson in numbers.
  • Sketch (on paper, no code) a clustered (3-D) light-culling extension per the C&S clustered chapter, and note what breaks first at depth-heavy scenes with 2-D tiles.
  • Run the light sweep on the Pi 5 (V3DV — also a tiler) if it is on the bench, and see which half of the story it tells.