9. Marching Cubes in detail

Phase 3's remaining decisions. The geometry was already settled by risks 3 and 4; what was left was texturing, the vertex format and integration — which is why risk 6 was the least-decided risk in the plan until now.

Already settled elsewhere, and not revisited here

Marching Cubes specifically (D2); the asymptotic decider for face ambiguity and the five other sources of holes (risk 3); edge-id welding into a dense scratch array keyed by (owning cell, axis); a border sized for attributes rather than geometry; gradient normals by central differences; canonical lower-coordinate edge evaluation so boundary duplicates are bit-identical (risk 4); marchCell factored standalone (risk 1); four-way material blending (question 6); the three tests; collision left on the grid; and construction reading full density so the two representations interpenetrate.

One constraint worth restating, because it has grown: the mesher now has three consumers — generation's terminal LOD step, runtime chunk meshing, and phase 5's physics narrowphase. That strengthens the marchCell factoring and requires the mesher stay free of renderer dependencies, which the voxel_tests target already enforces.

Decided Q1 — A chunk's render state carries two meshes

From this phase a chunk has an MC terrain mesh and a cube construction mesh, drawn by two pipelines. Today ChunkRender holds a single MeshStream.

  • (a) Two independent ChunkRender entries, one per representation. Simple, but splits the chunk's identity across two structures that must stay in sync through streaming and eviction.
  • (b) One ChunkRender holding two MeshStreams, with shared chunk identity and per-mesh dirty state, version and buffers.
  • (c) A generic array of meshes indexed by pipeline — extensible if a third representation ever appears.

Decided: (b). It matches how the data behaves — one chunk, one coordinate, one residency lifetime, two meshes with independent dirty state. (a) is the bookkeeping that produces leaks; (c) is speculative, and there is no third representation in the plan.

This reaches back into phase 2

Three things in section 8 are shaped by it, and all are cheap to design in and annoying to retrofit: the mesh job's result type (a job produces two meshes, or is parameterised by which), the upload budget counting meshes rather than chunks, and the deferred deletion queue's granularity.

And dirty state is per-mesh, with an asymmetry to get right. A terrain edit dirties the terrain mesh only. A construction edit dirties both — placing a block flips that cell's terrainFieldDensity to 255, so the terrain isosurface changes; removing one drops it to 0, since a block is only ever placed into a cell that was already destroyed and emptied, leaving no terrain density to restore (section 10, question 11). Getting that backwards leaves a visible hole in the terrain around every placed block.

Decided Q2 — How a marched vertex gets its material weights

A vertex sits between cells that may carry different materials, so it carries four indices and four weights (question 6). Where those come from was never specified, and it is load-bearing three ways: it contributes to the border width, the monolithic-vs-chunked test asserts weights match exactly, and it is what makes strata read correctly on a dug wall.

  • (a) The edge's two endpoint cells — natural and cheap, but yields at most two materials, which defeats the four-way decision.
  • (b) The owning cell's eight corners, weighted by each corner's contribution to the crossing, taking the top four.
  • (c) A wider neighbourhood gather weighted by distance — smoothest blends, widest border.

Decided: (b). Eight candidates is enough that a three-way strata meeting resolves properly, and it needs only a 1-cell border — so central-difference normals remain what sets the border at 2, and this adds nothing to it.

The tiebreak is mandatory, not a detail

Top-four-of-eight with equal weights must sort by a stable key: weight descending, then material index ascending. Without one, two chunks pick different winners for the geometrically identical boundary vertex and the monolithic-vs-chunked equivalence test fails intermittently.

That is exactly the bug class the dense-scratch weld was chosen to make unrepresentable (risk 4) — and without this rule it walks back in through the attributes instead of the geometry.

Weights are renormalised to sum to 1 after truncation to four, identically on both sides.

Decided Q3 — The cost of triplanar times four-way splatting

Three projections times four materials is 12 texture fetches per terrain fragment, and it doubles the moment normal or roughness maps arrive. Nobody had costed this.

  • (a) Naive — all 12.
  • (b) Top-2 weights per fragment — most fragments are dominated by one or two materials. 6.
  • (c) Dominant-axis projection where the normal is strongly axis-aligned, full triplanar only near 45°. ~4 typical.
  • (d) Both — 2 to 3 typical.

Decided: build (a), measure, and keep (b) and (c) as a swappable branch — the same principle applied to the mip downsampling operator (risk 5): the value of "optimise later" depends on later costing an afternoon rather than a refactor.

One thing settled now because it is free now and awkward later: the mesher emits the four weights sorted descending. Then "take the top two" is a truncation in the shader rather than a sort, and (b) becomes a one-line change instead of a vertex-format change.

Decided Q4 — The terrain vertex format

  • (a) Everything raw — position 12 + normal 12 + indices 4 + weights 4 + AO 4 + secondary position 12 = 48 bytes.
  • (b) Packed — oct-encoded normal, unorm8 weights, secondary position as a half-precision delta. ~32 bytes.
  • (c) Minimal now, extended when AO and geomorphing are actually needed.

Decided: (b), with one exception — position stays full-precision float. Position is load-bearing for crack-freedom: risk 4 requires the two copies of a chunk-boundary vertex to be bit-identical, and packing reintroduces precisely the last-bit differences that show as hairline cracks. Everything else packs freely.

A note on an earlier argument: risk 4 justified welding partly by "the terrain vertex will be fat — 48 to 56 bytes — so tripling it is real memory". At ~32 bytes that is much less alarming. Welding is still right, for crack-freedom and determinism; it is simply less urgent for memory than it looked.

The geomorph slot is still reserved even though the static distant mesh makes geomorphing unlikely to be needed (risk 5). Cheap insurance, and the format is fixed in this phase.

Decided Q5 — Whether terrain gets ambient occlusion

Phase 1 puts AO in the cube mesher, computed per face vertex from the diagonal neighbourhood. None of that transfers to a marched surface.

  • (a) No terrain AO in phase 3 — rely on gradient normals, lighting and material contrast.
  • (b) Per-vertex, from density-field sampling at mesh time. Free at runtime, costs meshing time.
  • (c) SSAO — one system covering terrain and construction, nothing in the vertex format, but a new renderer capability and a per-frame cost.

Decided: (a) for phase 3, and plan (c) rather than (b) afterwards.

Why not (b), which looks cheapest: it costs three times over — a field in the vertex format, a term in the monolithic-vs-chunked equivalence assertion, and its own border width to get right — all inside the heaviest phase in the plan. And it would never visually match phase 1's cube AO, which is computed a completely different way, so the two representations would occlude differently where they meet.

(c) fixes that: a screen-space pass treats both representations identically, arrives as a separable renderer feature on its own schedule, and keeps AO out of the vertex format entirely. Phase 1's cube AO stays as-is in the meantime — it is still right for construction.

Decided Q6 — Draw order and depth where the two representations interpenetrate

  • (a) Terrain then construction, plain depth test — near-coplanar surfaces z-fight.
  • (b) Terrain then construction with a small depth bias, so cubes reliably win.
  • (c) Nudge terrain density so the surface never lands exactly on a cube face.

Decided: (b). Entirely pipeline state, no geometry changes, and the design already says construction is drawn on top and wins visually.

Explicitly not (c). That is the family of geometry-level fixes at this exact junction that Epic #24 spent five commits and a revert on (section 2). The lesson from that history is that the junction is not to be solved geometrically.

Decided not to do — culling hidden terrain

Terrain is marched underneath buildings and never seen, which with D11 generating cities is real hidden geometry. Culling it would require the terrain mesher to know about construction occupancy, re-coupling the two representations the design deliberately decoupled. Left in place; revisit only if city interiors measure badly.

Decided Q7 — What the mesher reads outside the world boundary

D7 gives an invisible wall with only the skybox beyond.

  • (a) Air — the surface closes, giving a clean terrain wall at the boundary.
  • (b) Solid — the surface never closes; the player sees backfaces and the terrain interior.
  • (c) Clamp or mirror the edge column — extends terrain visually, conflicts with the wall.

Decided: (a). The only option consistent with the zero-holes requirement and with D7. Trivial once stated — which is exactly why it is stated, since it is the same rule governing the out-of-bounds IVoxelSource reads that risk 3 already depends on.

Decided Q8 — The crosshair highlight on a smooth surface

Parked earlier as "a presentation choice, not a structural problem". This phase is when it becomes visible.

  • (a) Keep the face-aligned box outline — reads as wrong, since a marched surface has no face.
  • (b) Highlight the targeted cell's marched triangles as an overlay.
  • (c) A projected ring or decal at the hit point, oriented by the gradient normal.

Decided: (b) for terrain, keeping (a) for construction. That mirrors the two damage models exactly — a cube's damage is the whole cube, so a box outline is truthful; a terrain strike affects one cell's contribution to the surface, so that cell's triangles are truthful.

Raycast.h already returns both the cell and the gradient normal, so the data is in hand. (c) is prettier and less informative, and remains available later as a hybrid.

Decided Q9 — The terrain texture array and the art budget

The terrain texture array was deleted in 4656681 and has to be rebuilt — and D10 means it is now N materials rather than three.

  • (a) Mirror the construction pathVoxelTextures.* builds a sampler2DArray from resources/textures/terrain/, one layer per material, with gen_textures placeholders and hot-reload for free.
  • (b) A separate array per projection axis — unnecessary, since triplanar samples the same 2D layer three ways.
  • (c) Procedural materials in the shader, no art. Cheap to start, hard ceiling on quality.

Decided: (a), reusing the existing pattern exactly. It already provides hot-reload and a placeholder generator, which is what makes iterating on strata tolerable rather than blocked on art.

Where the art risk stops being theoretical

D10 replaced three material bands with a full stratigraphy — multiple soils and sands, several rock types, mineral seams. Every one needs a tile.

So gen_textures emits a placeholder tile for every material in the table from the start. An unauthored material then shows as a visibly ugly tile rather than crashing, or — worse — silently falling back to a neighbour's texture and looking almost right.

What this adds to phase 3

KindTouches
Q1 two meshes per chunkReaches into phase 2Job result type, upload budget, deletion queue
Q2 8-corner weights + tiebreakMesherMC mesher, equivalence test
Q3 naive triplanar, weights sortedShader + mesherTerrain fragment shader, vertex emit
Q4 packed vertex, exact positionFormatTerrain vertex layout
Q5 no terrain AO yetDeferralNothing — SSAO later
Q6 depth biasPipeline stateConstruction pipeline
Q7 out-of-world reads as airOne ruleIVoxelSource
Q8 cell-triangle highlightPresentationOverlayPipeline
Q9 terrain texture arrayAssetsVoxelTextures, gen_textures, art