Lab 5.3 — Terrain: Heightmaps, Quadtrees & LOD

Course 4 syllabus · Module 5 · Prev: « Lab 5.2 · Next: Lab 5.4 »

Goal

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.

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.

Where results go:

Artifact Path
Notes, SSE worksheet, reverse-Z derivation, streaming postmortems labs/lab-5-3/notes.md
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:

\[ \rho \;=\; \frac{\delta}{d}\cdot\frac{h}{2\tan(\theta_v/2)} , \]

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)

  1. 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/.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.