Lab 5.5 — Point Clouds & Gaussian Splatting
← Course 4 syllabus · Module 5 · Prev: « Lab 5.4 · Next: Lab 6.1 »
Goal
Close the module with a different rendering paradigm entirely: no meshes, no rasterized surfaces — a scene represented as millions of 3-D Gaussians and rendered by splatting them, sorted and alpha-blended, onto the screen. The source is a pretrained 3D Gaussian Splatting scene (Kerbl et al. 2023 — the paper that made radiance-field-quality rendering real-time) loaded from the reference implementation’s .ply format. Every ingredient is something this module already built the muscles for: each splat is an anisotropic Gaussian whose covariance comes from a stored scale + rotation (Course 1 Section 1’s linear algebra, cashed as \(\Sigma = R S S^T R^T\)); rendering is depth-sorted, alpha-blended instanced quads with the Gaussian falloff evaluated per fragment; the per-frame sort is a GPU radix sort — Lab 1.3’s scan/sort thinking, spelled in-API; color comes from spherical harmonics, at least the DC band. This is compute-plus-graphics interop at production shape — several compute passes feeding an instanced draw, every frame — and it is a fitting final exam for the module before Module 6 turns the profilers on everything.
Recommended reading
- Kerbl, Kopanas, Leimkühler, Drettakis — “3D Gaussian Splatting for Real-Time Radiance Field Rendering” (SIGGRAPH 2023) — the primary source. Read §§ on the representation and the rasterizer; skip training (the scene arrives pretrained). The covariance math below is the paper’s, in this course’s notation.
- Lab 1.3 — your own reduction/scan notes; radix sort is scan applied per digit, and the paper’s own renderer sorts exactly this way (in CUDA — you are porting the idea in-API).
- Halladay — the alpha-blending and transparency chapters (topic-level; confirm against the copy in hand) for blending intuition; the order-dependence experiment in the tasks makes it concrete.
- Lengyel — the quaternion material (topic-level) for the stored-rotation-to-matrix conversion, applied not derived.
- Course 1 Section 1 — covariance as a symmetric positive-definite matrix, congruence transforms, eigenstructure = ellipsoid axes; the lab’s math is this section applied.
- Course 1 Section 8 — a Gaussian is its own band-limiting filter; worth one paragraph of your notes on why splats antialias more gracefully than triangles.
Prerequisites
Project & environment setup
- Asset: a pretrained splat scene in
.plyformat from the 3DGS reference-implementation ecosystem (the authors’ released scenes and other public pretrained scenes exist; download one, place it underassets/splats/, and record the exact source URL and license indocs/asset-sources.md). Start with a small-to-mid scene — first light on millions of splats is needless pain. - Engine work:
engine/render/splat_*— the.plyloader, a one-timecovariance_pass, per-framesort_pass(multi-dispatch), and thesplat_drawpass. This renderer can live beside the mesh path as an alternate scene mode; compositing splats with meshes is Going-further material. - Runtime controls: point-budget slider (draw the first N splats by sorted order), falloff-cutoff slider, front-to-back/back-to-front toggle for the correctness experiment, SH-band toggle if you implement beyond DC.
Where results go:
| Artifact | Path |
|---|---|
| Notes, memory worksheet, sort-pass design sketch, reconciliation | labs/lab-5-5/notes.md |
| Screenshots (first light, order experiment, budget sweep), captures | labs/lab-5-5/captures/ |
| Budget-sweep timings, sort-cost timestamp dumps | labs/lab-5-5/benchmarks/ |
Background
The representation. Each splat stores a position \(\boldsymbol{\mu}\), an anisotropic covariance factored as scale + rotation, an opacity, and SH color coefficients. The covariance is built from a diagonal scale matrix \(S\) and a rotation \(R\) (stored as a quaternion):
\[ \Sigma \;=\; R\, S\, S^{T} R^{T}, \]
symmetric positive-semidefinite by construction — a congruence transform of a diagonal matrix, which is §1 saying “an ellipsoid with axes \(R\)’s columns and radii \(S\)’s entries.” To render, the 3-D Gaussian is projected to a 2-D Gaussian on screen: with \(W\) the view rotation and \(J\) the Jacobian of the projective mapping (the paper’s local affine approximation),
\[ \Sigma' \;=\; J\, W\, \Sigma\, W^{T} J^{T}, \]
whose upper-left 2×2 block is the screen-space footprint — the ellipse the instanced quad must cover, and the falloff the fragment shader evaluates.
- Rendering = ordered blending. Splats are semi-transparent; the image is the classic over-composite along each ray,
\[ C \;=\; \sum_{i} c_i\, \alpha_i \prod_{j<i} \bigl(1 - \alpha_j\bigr), \]
which is only correct if splats arrive depth-ordered. Hence the per-frame sort: build a depth key per visible splat, radix-sort the splat indices by it, draw instanced quads in sorted order. Back-to-front with standard over-blending, or front-to-back with the premultiplied variant and an accumulated-transmittance formulation — the tasks make you run both and photograph why order is not optional.
- The sort is Module 1 coming home. Radix sort = for each digit of the key: histogram, exclusive scan of the histogram, scatter — three dispatch shapes per digit, all built from Lab 1.3’s primitives, now written as GLSL/MSL compute in the render graph with barriers between dispatches. Design (bit-width of the depth key, digits per pass, dispatch count) goes in
notes.mdas a sketch before implementation; the implementation is yours. - Spherical harmonics color. The scene stores view-dependent color as SH coefficients per channel: \(c(\mathbf{d}) = \sum_{\ell, m} c_{\ell m} Y_{\ell m}(\mathbf{d})\) over view direction \(\mathbf{d}\). Band 0 (DC, \(Y_{00} = \tfrac{1}{2\sqrt{\pi}}\)) is view-independent base color — the required minimum; higher bands add glints and sheen and are optional here. The full files carry degree-3 coefficients — \((\deg+1)^2 = 16\) per channel — which dominates the memory worksheet below.
- No geometry shaders. Each splat becomes a quad by instanced vertex-shader expansion — 4 vertices (or 6) per instance, corner offsets derived from \(\Sigma'\)’s extent in the vertex shader. Geometry shaders would be the textbook-2010 answer; MoltenVK has none, and no modern engine misses them.
Tasks
Engine (both backends)
.plyloader. Parse the reference-implementation layout (structure only is specified: position, scale (log-stored — check and note), rotation quaternion, opacity (pre-sigmoid — check and note), SH coefficients) into tightly packed GPU buffers. The memory worksheet innotes.mdcomes first: fields × floats × splat count, against the file size on disk as a checksum of your understanding.- Covariance precompute pass. A one-time compute pass turning scale+quaternion into whatever packed form your draw path consumes (3-D covariance’s six unique elements, or leave it factored — defend the choice in a line). Numerically verify a handful of splats against a CPU reference calculation.
- First light, unsorted. Instanced-quad expansion + Gaussian falloff + blending, no sort — deliberately. Screenshot the shimmering wrongness; it is the before of the experiment.
- Depth key + radix sort. Per-frame: compute pass writes a depth key per splat (view-space depth, quantized — note your key width), radix-sort passes reorder an index buffer, draw consumes it. Frustum-cull splats into the sorted set while you are at it — 5.4’s compaction, reused.
- The order experiment. Render the same view back-to-front (correct) and front-to-back with naive over-blending (wrong), screenshot both, and explain the difference from the compositing equation — which term breaks. Then, optionally, the correct front-to-back transmittance variant.
- Point-budget sweep. The slider draws the first N splats (by significance if the file’s ordering supplies it, else by your sort). Sweep N over ~4 doublings: screenshot quality + frame time each step → the quality/perf curve in
benchmarks/. - SH color. DC band minimum; if ambition allows, band 1+ with the view-direction evaluation in the vertex shader, and an A/B screenshot of a glossy surface.
Backend notes — Vulkan
- Sort passes are back-to-back compute dispatches with buffer barriers between digit phases — the render graph must chain them; this is the stress test of its compute support. Blending state per Lab 2.5; depth test against the mesh scene’s buffer optional, depth write off.
- Reverse-Z interaction from Lab 5.3: the sort key is view-space depth, not buffer depth — keep the conventions from tangling, and write the one sentence in your notes that untangles them.
Backend notes — Metal
- Same passes in MSL; threadgroup memory for the histogram/scan phases maps one-to-one from the CUDA shared-memory version. On Apple GPUs, heavy overlapping blending is where the TBDR story from Lab 5.2 gets stress-tested — blending happens in tile memory, and the measured cost profile will differ from the Linux desktop’s RTX 4090; note what you observe for the reconciliation.
Deliverable & expected results
- A pretrained 3DGS scene rendering correctly (sorted, blended, DC-SH color) on both backends, with the order experiment and budget sweep documented.
notes.md: memory worksheet, sort design sketch, order-experiment explanation, reconciliation.
| Quantity | Predicted | Measured |
|---|---|---|
| Memory per splat (reference layout, SH degree 3) | from the attribute list: \(3 + 3 + 3(\deg{+}1)^2 + 1 + 3 + 4\) floats — count the file’s actual fields and do the ×4-bytes arithmetic by hand; check total ≈ file size | … |
| GPU buffer total for your scene’s splat count | splats × your packed per-splat size (worksheet arithmetic) | … |
| Sort cost share of frame vs. splat count | qualitative: grows to dominate at high counts — multiple full passes over the key/index buffers per frame vs. one draw’s worth of quad work; the budget sweep will show the takeover | … |
| Frame cost, camera pushed close to the scene | fill-rate/blend-bound: cost tracks covered-pixels × overlap, nearly independent of splat count — the overdraw story Lab 6.4 will quantify properly | … |
| Unsorted vs. sorted image | unsorted: popping/shimmering, order-dependent color; sorted: stable — matches the compositing-equation argument | … |
Profiling & performance
Timestamp the three phases separately — key/cull, sort (all digit passes bracketed together), draw — via the HUD (Lab 6.1 hardens this HUD; the phase split is what matters now). The budget sweep doubles as the profiling deliverable: plot (in your notes, by hand is fine) how the sort share and the draw share move as N doubles. Mac: one Xcode GPU capture, looking specifically at the compute-to-render dependency chain and where blending cost lands. The Linux box: Nsight Systems for the dispatch chain; RenderDoc to inspect the sorted index buffer (spot-check monotonic keys — the cheapest correctness probe in the lab). Tracy confirms the CPU is now a bystander: this frame is almost entirely GPU-authored, the destination Module 5 has been driving toward.
Analysis & reconciliation
Reconcile the memory worksheet first: predicted bytes/splat × count vs. actual file size and GPU allocation — a mismatch means a misread field, and finding it is the point. Then the sort: from your key width and digit choice, count the passes and the bytes each moves, and check the measured sort share against that traffic argument (Course 3 M2 thinking, GPU-sized). Explain the order experiment from the compositing product — one paragraph, equation-anchored. Compare the close-range fill-bound behavior across the two GPUs and connect it to the 5.2 architecture story (tile-memory blending vs. DRAM blending). Close the module with the retrospective paragraph: five techniques, one engine — which of the five moved the most work off the CPU, and what single measurement across the module most changed how you think about GPUs?
Going further
- Composite splats with the mesh scene: depth-test splats against the terrain’s depth buffer and note every convention (reverse-Z again) that has to line up.
- View-dependent SH (bands 1–3) and an A/B on a reflective surface from the captured scene.
- Tile-binned splatting: the paper’s actual rasterizer bins splats per screen tile and sorts per tile — sketch (no build) how that maps onto the module’s tiled-light-culling shapes from Lab 5.2, and what it buys over the global sort.
- Level-of-detail for splats: cut low-significance splats by opacity×scale heuristics and measure quality vs. the budget slider’s naive truncation.
- Run the point-budget sweep on the Pi 5 as a stretch portability test — the V3DV compute path gets an honest workout.