Lab 5.4 — GPU-Driven Rendering: Culling & Indirect Draws

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

Goal

Move the decision of what to draw onto the GPU. In Lab 4.4 the CPU walked 10 000 objects, tested frustum planes, and rebuilt draw lists every frame — visible as a fat Tracy zone. This lab replaces that with the modern pipeline: per-object data lives in GPU buffers, a compute-shader culling pass tests every object in parallel and writes a compacted draw list, and the graphics queue consumes it via indirect draws (vkCmdDrawIndexedIndirect + count buffer on Vulkan, indirect command buffers on Metal) — the CPU never sees the visible set at all. Around that core, two load-bearing ideas: the compaction step is a scan — the reduction/scan ladder from Lab 1.3 returns as the production tool — and drawing from a GPU-resident list forces the bindless question: how does a shader reach any mesh’s textures without per-draw descriptor churn? Vulkan answers with descriptor indexing (feature-queried — and MoltenVK’s partial support makes this the module’s sharpest portability lesson); Metal answers natively with argument buffers. The C&S GPU-driven chapters are the backbone reference for the whole lab.

Prerequisites

  • Lab 4.4: the 10k-object scene, per-object bounds, and the CPU culling path — kept alive as the A side of the A/B and the correctness oracle.
  • Lab 5.3 complete (the engine-wide reverse-Z and the graph’s compute-pass support if you added it there).
  • Module 1’s scan mental model, honestly absorbed — this lab assumes it and does not re-teach it.

Project & environment setup

  • Engine work: engine/render/gpu_scene_* — the per-object GPU scene buffer, the cull_pass (compute), and the indirect draw path in both backends. Culling shader in GLSL and MSL; one algorithm, two spellings — a comment block noting where the spellings diverge is part of the deliverable.
  • The Lab 4.4 10k scene is the fixed benchmark scene for the A/B; do not change its content mid-lab or the comparison dies.
  • Add a HUD readout for visible count (read back from the count buffer with correct synchronization — itself a task) and a toggle: CPU culling / GPU culling / no culling.

Where results go:

Artifact Path
Notes, feature-query matrix, layout worksheet, A/B reconciliation labs/lab-5-4/notes.md
RenderDoc/Xcode captures of the cull+draw frame, HUD screenshots labs/lab-5-4/captures/
Tracy exports (CPU A/B), timestamp dumps (cull pass cost) labs/lab-5-4/benchmarks/

Background

The shape of GPU-driven. Everything the culling decision needs sits in a structured buffer, one record per object: bounding sphere (center, radius), transform (or an index to it), mesh/material indices, LOD data later. Frustum culling per object is Lab 4.4’s plane test verbatim — for each of six planes \((\mathbf{n}_k, d_k)\), an object with bounding sphere \((\mathbf{c}, r)\) survives while

\[ \mathbf{n}_k \cdot \mathbf{c} + d_k \;>\; -r \qquad \text{for all } k, \]

now evaluated by one GPU thread per object instead of one CPU loop iteration (Lengyel’s plane machinery, applied — the math was settled in 4.4).

  • Compaction is a scan. Survivors must land densely in a draw buffer. The textbook mechanism: each thread produces a 0/1 visibility flag, an exclusive prefix sum over the flags assigns each survivor its output slot, and a scatter writes the draw commands — precisely the stream-compaction pattern built on the scan ladder in Lab 1.3. In practice a single atomic counter on the count buffer is the simpler production spelling at these sizes; implement the atomic version, but write the scan version’s structure in your notes and say when ordering or scale would force it — the classroom-to-production handoff Lab 5.2 named, now doing real work.
  • Indirect draws. The draw’s arguments — index count, instance count, offsets — live in a GPU buffer the compute pass wrote; the CPU records one command consuming it. With vkCmdDrawIndexedIndirectCount, even the number of draws comes from the GPU-written count buffer. CPU submission cost stops depending on scene contents — it collapses to a constant.
  • The bindless question. A GPU-generated draw can reference any mesh’s material, so per-draw descriptor binding is dead. Descriptor indexing (Vulkan): large descriptor arrays indexed dynamically in-shader, with feature bits for update-after-bind, partially-bound, and non-uniform indexing — every one of which must be queried, because support differs across this course’s three device targets, MoltenVK being the partial one. Argument buffers (Metal): resources referenced from a GPU-visible table — the native answer, comfortable on all Apple GPUs. The lab’s feature-query matrix turns this from folklore into a recorded table.
  • Occlusion culling (discussed, optional build): frustum culling still draws everything behind the first hill. The standard extension tests object bounds against a depth pyramid (hierarchical Z from last frame’s depth, with the one-frame-late caveats). It is described here because the C&S chapters build it and the capstone may want it; the required scope of this lab is frustum-only.

Tasks

Engine (both backends)

  1. Per-object GPU scene buffer. Define the per-object record (a layout worksheet in notes.md first: field sizes, alignment, bytes/object ×10k — mind std430/Metal alignment rules), upload the 4.4 scene into it, and keep it live across frames (transforms updated in place or via a dirty-range upload).
  2. Culling compute pass. The cull_pass in the render graph: frustum planes in a uniform, one thread per object, visibility → atomic slot → indirect-command write, count buffer zeroed at frame start (a tiny clear/fill at the top of the graph). Graph must express compute-write → indirect-read ordering; verify the barrier in a capture.
  3. Indirect draw path. Consume the command + count buffers from the graphics side. First light: GPU-culled image must be pixel-identical to the CPU-culled image from the same camera — the 4.4 path is the oracle; a diff screenshot goes in captures/.
  4. Visible-count readback. Read the count buffer back to the HUD with proper synchronization (fence + a frames-in-flight-deep ring of readback slots — never a mid-frame stall). Confirm the number matches the CPU cull’s count exactly while both run.
  5. The A/B. On the fixed 10k scene: CPU-cull vs. GPU-cull, Tracy on the CPU side, timestamp HUD on the GPU side, several camera poses (all-visible, half-visible, nearly-none-visible). Record the table.
  6. Feature-query matrix. Query and record, on all three targets (MoltenVK, the Linux desktop (RTX 4090), Metal-native): multiDrawIndirect, draw-indirect-count support, and the descriptor-indexing feature bits (or argument-buffer tier on Metal). Expect the desktop NVIDIA driver to report the richest feature set of the three. This table is a first-class deliverable — it is the portability lesson in writing.

Backend notes — Vulkan

  • VkDrawIndexedIndirectCommand layout is spec-fixed — your compute shader writes it field-for-field. multiDrawIndirect and drawIndirectCount are exactly the features to query (the count-variant has an extension/core history worth one line in your notes); if a target lacks the count variant, fall back to a fixed max-draw count with zeroed tail commands — record which target forced it.
  • Descriptor indexing: enable only the feature bits the query granted; on MoltenVK expect partial support and design the material path to degrade (e.g. bounded texture array without update-after-bind) rather than assume.

Backend notes — Metal

  • Argument buffers carry the material table; indirect command buffers (ICBs) carry the draws — encode the ICB from the culling kernel per Apple’s docs. useResource/residency declarations replace Vulkan’s descriptor bookkeeping; the compute-to-render dependency is a fence/event the metal-cpp backend’s graph already models.
  • Note the asymmetry for the notes table: what Vulkan feature-queries, Metal tiers (argument buffer tier); the lesson is the same discipline with different spelling.

Deliverable & expected results

  • The 10k scene drawn via GPU culling + indirect draws on both backends, pixel-identical to the CPU path, with live visible-count HUD and the CPU/GPU/no-cull toggle.
  • notes.md: layout worksheet, scan-vs-atomic discussion, feature-query matrix, A/B table below.
Quantity Predicted Measured
CPU culling zone, 10k objects (from Lab 4.4’s measurement) the 4.4 measured number — copy it in as the baseline
CPU culling zone after GPU move → ~0: the zone disappears; remaining CPU cost is constant (one dispatch + one indirect draw recorded)
GPU cull pass cost, 10k objects qualitative: µs-scale — 10k threads is a trivially small dispatch for either GPU; likely launch-overhead-bound, not math-bound
CPU submission cost vs. visible count (indirect path) flat — constant commands recorded regardless of visibility; the direct path scaled with draw count
GPU vs. CPU visible count, matched camera bit-identical every frame (same planes, same test, same precision caveats — if not identical, find the float discrepancy)

Profiling & performance

Tracy tells the CPU story: capture the A/B as two traces and screenshot the culling zone existing, then not existing — the lab’s headline image. The GPU cull cost comes from the timestamp HUD (bracket the compute dispatch; the HUD becomes rigorous in Module 6 — Lab 6.3 formalizes timestamp hygiene, so treat this reading as provisional). RenderDoc on the Linux box: inspect the count buffer and indirect-command buffer contents post-cull — seeing your compute shader’s bytes become draw calls is the mental click this lab is for. Xcode capture on the Mac: the ICB encoding and the compute/render dependency on the timeline. Watch the readback task in Tracy specifically — a synchronization mistake there appears as a periodic frame spike, and finding it yourself is part of the curriculum.

Analysis & reconciliation

Reconcile the A/B: how much CPU time did Lab 4.4’s culling zone actually cost, where did it go, and what new CPU cost appeared (buffer updates, readback management)? Net win, in milliseconds, stated honestly. Then the asymptotic paragraph: submission cost direct vs. indirect as scene size grows — what you measured at 10k, and what the shapes imply at 100k (no invented numbers; argue from the scaling you observed). Reconcile the visible-count row: if GPU and CPU counts ever diverged, run the float-precision argument (Course 1 §3) on the plane test and identify the culprit term. Finally, one paragraph on the feature-query matrix: which single feature gap most constrains a cross-platform GPU-driven renderer, and how the engine’s design absorbs it — this paragraph is the capstone’s design doc being drafted early.

Going further

  • Build the depth-pyramid occlusion pass (last frame’s depth → mip pyramid → conservative bounds test) per the C&S chapters and measure the extra rejection rate on a hilly arrangement of the 10k scene.
  • Per-object LOD selection in the cull shader: pick a mesh LOD by projected size and write different indirect commands — GPU-driven LOD in one small step from here.
  • Multi-queue: run the cull pass on the async compute queue (Vulkan) and measure overlap with the previous frame’s graphics work — Nsight Systems on the 4090 shows it best.
  • Read (topic-level) about mesh shaders as the next step past indirect draws, and write one paragraph on what they would replace in this lab’s pipeline — noting Metal’s mesh shaders and Vulkan’s VK_EXT_mesh_shader availability on your targets, feature-queried in the same discipline.