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.
Recommended reading
C&S — the deferred/clustered lighting chapters (topic-level; confirm against the copy in hand): G-buffer layout pragmatics and light-culling structure in a Vulkan engine.
MbT — the deferred-rendering and tile-shader chapters (topic-level): the same technique told natively for Apple GPUs, including the single-render-pass formulation.
Apple’s documentation — the Tile-Based Deferred Rendering architecture overview, the memoryless attachments / render-pass load-store-action pages, and the WWDC “Metal for Apple GPUs” family of sessions (TBDR, tile shading, GPU counters). Watch before implementing the Metal variant — the mental model is the deliverable.
LearnOpenGL — the deferred-shading articles for the API-agnostic theory pass (and the SSAO article for the Going-further section, which is a G-buffer consumer).
Course 1 Section 16 — the G-buffer is a stack of 2-D signals; the light pass is per-pixel function evaluation over them. sRGB/linear discipline from Lab 3.4 carries over unchanged.
Course 3 Module 2 — the bandwidth-and-caches machine model; this lab is that module’s DRAM-traffic argument replayed at GPU scale.
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.
Module 3’s forward lighting path (Lab 3.1–3.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.
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,
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)
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.
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.
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/).
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)
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)
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/.
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
≥ 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.