Large-world rendering: a terrain far bigger than any fixed mesh budget, rendered from a heightmap through a chunked quadtree whose refinement follows the camera. The pieces: a flat patch grid displaced in the vertex shader; a screen-space-error metric that decides, in pixels, when a chunk is too coarse; crack handling between adjacent LOD levels (skirts here, stitching discussed); asynchronous streaming of tiles from disk on the Module 4 job system, with explicit residency states and eviction; per-chunk frustum culling from Lab 4.4. And because a terrain finally gives the camera somewhere far to look, the depth buffer’s long-range failure arrives on schedule — solved properly, engine-wide, with reverse-Z: floating-point depth, near/far swapped, GREATER compare. Why that pairing works is a floating-point argument, not folklore — Course 1 Section 3 cashed in. One deliberate scoping note: no hardware tessellation — MoltenVK’s tessellation support is limited (and geometry shaders are absent entirely), so LOD selection lives on the CPU/compute side and geometry arrives as instanced patches, which is also the shape modern engines have converged on.
Recommended reading
Lengyel — the visibility-determination and polygonal-techniques chapters (topic-level; confirm against the copy in hand) for frustum/plane math applied per chunk, plus the projection chapter’s treatment of depth mapping — the reverse-Z derivation leans on it.
C&S — skim the resource-streaming material (topic-level) for how a Vulkan engine shapes async uploads; the lab’s loader is a small version of that.
LearnOpenGL — the heightmap/terrain material for the basic displaced-grid pass, theory-level.
Course 1 Section 3 — floating point: spacing of representable values, why precision is densest near zero. Reread before the reverse-Z background; the lab’s derivation is an application exercise.
Course 1 Section 8 — the heightmap is a sampled 2-D signal; chunk LOD is resampling, and the screen-space-error metric is an anti-aliasing budget in disguise.
Prerequisites
Lab 4.3: the job system — the tile loader runs on it, not on a bespoke thread.
Lab 4.4: frustum culling and instancing — chunks are culled as AABBs and drawn as instanced patches.
Lab 5.2 complete; the terrain renders through whichever path (forward or deferred) you leave switched on — it is just more opaque geometry to the graph.
Project & environment setup
Assets: one or more large heightmaps in assets/heightmaps/ (16-bit grayscale preferred; free sources — e.g. public terrain/DEM tilesets — are fine; record the source and license in docs/asset-sources.md). Pre-split into tiles once with a small offline tool (a Python script in cuda/’s venv or a C++ utility — this is setup scaffolding, fair game).
Engine work: engine/render/terrain_* — quadtree, residency manager, patch renderer; a terrain_pass in the graph. The reverse-Z switch touches engine/core/math.hpp’s projection builders from Lab 0.2 — one place, by design.
Runtime controls: SSE threshold slider, freeze-LOD toggle (freeze refinement while flying, to inspect chunk boundaries), wireframe toggle, residency HUD readout.
Z-fight before/after evidence, wireframe LOD screenshots, soak video
labs/lab-5-3/captures/
Triangle-count vs. threshold sweep, residency logs
labs/lab-5-3/benchmarks/
Background
Chunked LOD. The terrain is a quadtree of square chunks; each level halves the world extent per chunk and (with a fixed patch resolution per chunk) doubles the sample density. Each chunk stores its geometric error\(\delta\) — the maximum height deviation between its simplified geometry and the finest data. Refinement is driven by projecting that world-space error to pixels:
where \(d\) is distance from camera to chunk, \(h\) the viewport height in pixels, and \(\theta_v\) the vertical field of view — split the chunk while \(\rho > \tau\) for a pixel-error threshold \(\tau\). Halving \(\tau\) demands one more refinement level at any given distance; in 2-D that quadruples chunk count, so triangle count scales like \(T \propto \tau^{-2}\) — a law the sweep will test.
Cracks. Adjacent chunks at different LODs disagree along their shared edge — T-junctions and holes. Skirts (chosen here): each chunk extrudes a short downward ribbon around its perimeter, hiding gaps for the cost of a few extra triangles and some overdraw. Stitching (discussed, not built): constrain edge vertices to the coarser neighbor’s grid — watertight and overdraw-free, but it couples neighbors and multiplies patch index-buffer variants. Skirts are the honest engineering default; write down what would push you to stitching.
Streaming. Tile heightfields load from disk asynchronously on the job system; a chunk moves through explicit residency states, and a bounded cache evicts least-recently-needed tiles. The renderer never blocks: a chunk whose data is not resident renders at its parent’s coarser level until the load lands.
stateDiagram-v2 [*] --> Unloaded Unloaded --> Queued : needed by quadtree Queued --> Loading : job picked up Loading --> Resident : upload complete Resident --> Evictable : not needed this frame Evictable --> Resident : needed again Evictable --> Unloaded : cache pressure
stateDiagram-v2
[*] --> Unloaded
Unloaded --> Queued : needed by quadtree
Queued --> Loading : job picked up
Loading --> Resident : upload complete
Resident --> Evictable : not needed this frame
Evictable --> Resident : needed again
Evictable --> Unloaded : cache pressure
Reverse-Z, derived. With the 0-to-1 depth convention (pinned since Lab 0.2), the standard projection maps view-space depth \(z\) hyperbolically: \(z_{ndc} = \frac{f}{f-n}\bigl(1 - \frac{n}{z}\bigr)\), so \(z_{ndc}\) rockets toward 1 within a few near-plane distances and is nearly flat for the entire far field — almost all of \([0,1]\)’s resolution is spent within meters of the camera. A fixed-point depth buffer at least spaces values uniformly; a float buffer makes it worse-than-useless in this orientation, because float32’s representable values are densest near 0 (exponents pile up there — §3) and sparsest near 1 — exactly where all the distant geometry landed. Swap near and far so the map sends near → 1, far → 0, and the two nonuniformities cancel instead of compounding: the hyperbola’s flat far tail lands in the float-dense region near zero, and the resulting distribution of distinguishable depths is close to logarithmic in \(z\) — which matches how projected error actually shrinks with distance. Hence the package deal: D32_SFLOAT (float, not fixed) + reversed near/far in the projection + GREATER(_OR_EQUAL) compare + clear depth 0. Do the derivation in your own notation in notes.md; the lab then demonstrates it with a before/after.
Tasks
Engine (both backends)
Displaced patch. A shared patch grid mesh (one vertex/index buffer, reused by every chunk), instanced per chunk with a per-instance transform + tile handle; the vertex shader samples the height tile and displaces. Wireframe screenshot of a few LOD rings into captures/.
Quadtree select/split. Implement the SSE-driven selection with \(\tau\) on a slider; per-chunk AABB frustum culling (heights bound the box — reuse 4.4’s test). The freeze-LOD toggle plus wireframe makes the ring structure inspectable; capture it at two thresholds.
Skirts. Generate skirt geometry (or extend the patch mesh with a perimeter ring pushed down in the shader); before/after screenshots of a crack at an LOD boundary. In notes.md, the skirt-vs-stitching paragraph.
Async tile loader. Residency manager over the job system implementing the state machine above (shape only is specified here — the code is yours): bounded cache, LRU-style eviction, coarser-parent fallback while loading, and an artificial slow-load mode (sleep injection) to prove the renderer never stalls — Tracy is the witness.
Reverse-Z, engine-wide. Flip the projection builders, depth format, compare ops, and clear values in one commit. Regression-check everything — shadows from 5.1 and the deferred depth reconstruction from 5.2 both consume depth conventions and both will bite if hardcoded. Capture the before/after z-fighting evidence: two overlapping-ish surfaces (or terrain-vs-decal) at long range, standard-Z shimmering, reverse-Z clean.
Fly-through soak. A scripted or recorded long flight at speed across the terrain: no hitches (Tracy frame plot), residency count bounded, no visible cracks or LOD pops beyond the τ you chose. Save the video and the Tracy capture.
Backend notes — Vulkan
Vertex-shader texture sampling of the height tile needs the tile in a sampled-image array or a bindless-ish table — keep it simple here (a small descriptor array indexed per instance); Lab 5.4 does bindless properly. D32_SFLOAT support is universal on this course’s devices but query it anyway; depth compare op and clear value change with reverse-Z.
Tile uploads ride the transfer path from Lab 2.2 — staging buffer, transfer queue if you use one, semaphore into the graphics timeline.
Backend notes — Metal
Same structure in metal-cpp: height tiles as textures indexed from an argument buffer, MTLCompareFunction flipped to greater, clear depth 0. Metal’s unified memory makes tile upload cheaper than the Vulkan staging dance — note the asymmetry in notes.md; it is a preview of 5.4’s platform-difference table.
Deliverable & expected results
Terrain flying at interactive rates on both backends: SSE-driven LOD, skirted seams, streaming with no stalls, reverse-Z engine-wide with all prior labs still rendering correctly.
notes.md with the reverse-Z derivation, the skirt/stitch discussion, and the table; captures/ with the LOD wireframes, crack before/after, z-fight before/after, and the soak evidence.
Quantity
Predicted
Measured
Triangle count vs. SSE threshold \(\tau\)
\(T \propto \tau^{-2}\) — halving τ ≈ 4× triangles; sweep τ over ~3 doublings and fit the slope
…
Resident tile count at cruise speed
derivable bound: (visible-ring tile count from τ and the SSE formula) + (prefetch margin ≈ speed × load-latency ÷ tile world size); compute it for your numbers
…
Frame hitch during loads (slow-load mode on)
none — loads on job threads; frame time unchanged, only latency to full detail grows
…
Z-fight onset distance, standard-Z vs. reverse-Z (D32_SFLOAT, near plane fixed)
qualitative: pushed out by orders of magnitude — far-field depth spacing goes from float-sparse to near-logarithmic; artifacts at former ranges vanish
…
Skirt overhead
small constant fraction of terrain triangles (perimeter ∝ patch edge vs. area ∝ edge²) — compute the exact fraction for your patch resolution
…
Profiling & performance
Tracy is the primary instrument: zones for quadtree selection, per-chunk culling, and the loader jobs; the frame plot during the soak is the no-hitch proof. The in-engine timestamp HUD reads the terrain pass’s GPU cost across the τ sweep (the HUD matures into a real tool in Lab 6.1 — here its two numbers suffice). On the Linux desktop (RTX 4090), RenderDoc to inspect a frame’s chunk draws and verify culling; on the Mac, one Xcode capture to confirm vertex-stage cost scales with the triangle sweep as predicted. If the vertex-shader height fetch looks expensive, note it and park it — texture-fetch-in-VS behavior differs across the two GPUs and makes a good Module 6 question.
Analysis & reconciliation
Three reconciliations in notes.md. First, the scaling law: fit measured triangle counts against \(\tau^{-2}\) and explain the deviation (frustum clipping the ring structure, minimum/maximum LOD clamps). Second, residency: compare the measured steady-state tile count against your derived bound, and attribute the gap (prefetch conservatism, eviction lag). Third, the depth story: from your derivation, estimate where standard-Z’s distinguishable-depth spacing exceeds the geometric separation of your test surfaces, and check the measured onset moved as predicted under reverse-Z — the point is not the exact meter mark but that the direction and magnitude follow from §3’s float-spacing argument. Close with the paragraph on what τ you shipped and why — the pixel-error budget is a product decision wearing math.
Going further
Implement stitching for one LOD boundary case and measure the overdraw saved vs. skirts (the 6.4 overdraw tooling can quantify it later).
Morph vertices between LOD levels (geomorphing) to kill the residual pop at chunk transitions; note the vertex-shader cost.
Compute-pass LOD selection: move quadtree refinement to a compute shader writing an indirect chunk list — a bridge you will cross properly in Lab 5.4.
Normal-map the terrain from the heightmap (finite differences — Course 1 §16’s gradient-as-filter) and light it through the Module 3 stack.