6. Roadmap
Ordered so that each phase is verifiable on its own, the cheap de-risking comes before the expensive commitments, and nothing has to be built twice.
One constraint drives the order: the meshing execution model has to be fixed before Marching Cubes lands. MC is several times more expensive per chunk than face culling, and the digging model makes remeshes frequent rather than occasional.
Collision no longer gates anything. It is settled by staging rather than by choosing (risk 1) — grid collision ships with MC unchanged, and a triangle narrowphase is added later behind the same interface.
Real generation, on the cube renderer
fBm with domain warping for the surface, 3D coherent noise for caves and overhangs, genuine strata, seeded and deterministic from world position. Ambient occlusion in the cube mesher so the result reads as solid rock.
The vertical composition is D10's, and it sets the shape of this phase. Layers divide into surface-following (topsoil, sand, subsoil, weathered rock — depth below the local surface) and datum-anchored (the rock strata — absolute Y, warped by low-frequency noise). A stratum is a datum Y plus a thickness field, so where the thickness falls to zero the layer pinches out and is absent in that column — "different depths, or not present at all" with no special cases.
Build the generator as D11's staged pipeline, not as a single function — a registry of stages, each reading what the previous ones produced, with new stages appendable later. Phase 1 registers the first three plus the two terminal ones; infrastructure stages (roads, railways, cities, POIs) are anticipated and slot in without redesign.
base density + strata → caves → seams → [ … later stages … ] → bedrock clamp → LOD generation
Caves and seams are separate stages rather than one concurrent stage, even though they write disjoint fields, because a stage only reads what earlier stages produced — sharing would deny caves any read of the strata's material. The reasoning is in D11. Note also that the bedrock clamp runs before LOD generation, not after: terrain LOD reads the density the clamp writes.
Three consequences to get right here rather than retrofit. Parallelism is per-stage — chunks run concurrently within a stage, with a barrier between, since a seam originating in one chunk may reach into another. The bedrock clamp and LOD generation are terminal and the registry must not be able to schedule around them: anything appended after the clamp can carve through the world floor, and anything appended after LOD generation is invisible at distance. And stage outputs should be cacheable, so re-running from the first changed stage is possible — under D9 iterating on the generator means regenerating, and tuning the ore pass should not cost strata again.
Bedrock is a hardcoded special case — undamageable, never deviating from full density, enforced directly rather than via a very large toughness value that would round to zero density removed and look like a bug.
Every generator writes smoothly varying density, never binary
(D11). A cave carved as
if (caveNoise > t) density = 0 gives Marching Cubes nothing to
interpolate, so it renders as cubes — and D10 puts caves, rivers and ore pockets
throughout the world.
This phase cannot see a violation on its own. The cube renderer thresholds at
kSolidDensity, so a binary field and a band-limited one look
identical — "fly around and find caves" would pass either. Nothing would surface
the problem until Marching Cubes lands in phase 3.
So the contract ships with a max-Δdensity test in
voxel_tests: generate a world, sample 6-adjacent cell pairs, assert the
difference stays within a bound. No Vulkan, so it fits the existing target unchanged.
Liquids and gases are out of scope here by decision (D10). The generator may place their host voids; how they behave is a later discussion.
Why first: the generator writes density and material into cells and is identical under either mesher, so none of this is thrown away. It also produces terrain with cliffs, overhangs and cave mouths — without which no mesher can be evaluated honestly.
Build the density mip-chain here. Phase 6's LOD needs the coarse field to be a genuine downsample of the fine one, not the noise re-sampled at half resolution — different functions, and the difference shows as terrain changing shape when the player approaches. The chain is produced at generation time, which is cheap because generation is deterministic. The operator starts as averaging and must be a swap-in point, not an inlined loop: trying min, max or a feature-preserving variant against real terrain should cost an afternoon, not a refactor (risk 5).
Verifiable by: flying around and finding caves. No new subsystems, no interface changes.
Touches: fillDensityTerrain (replaced), CubeMesher
(AO), MeshVertex (an AO term).
Required by AO, not optional: widen the dirty stencil in
VoxelWorld::set from six face-adjacent chunks to the cross-product of the
borders the edited cell touches — 1 / 2 / 4 / 8 chunks for interior / face / edge /
corner. Ambient occlusion reads diagonal neighbours, so the current face-only rule
starts leaving stale meshes the moment AO lands. Marching Cubes needs the same stencil
later, for the same reason. See risk 2, gap 1.
Worth doing while in there: close the dirty-tracking bypass — drop the
non-const Chunk::volume() overload and make Chunk::set private
with VoxelWorld a friend, so VoxelWorld::set is provably the
only mutation path (gap 2). And have
CollideResult carry a contact normal, populated by resolveAxis
from the axis and sign it backed off along — about two lines, useful immediately, and
the one piece of plumbing the phase-5 slope work would otherwise retrofit.
And the test that proves the stencil: a
testDirtyStencilCompleteness() in voxel_tests — seed an RNG,
apply a few hundred random edits through VoxelWorld::set, then compare every
chunk's incrementally-maintained mesh hash against a fresh full rebuild. Lands with the
stencil change rather than after it, so the widening is verified rather than assumed. No
Vulkan needed, so it fits the existing test target unchanged.
World scale: up-front generation, residency, async meshing
World generation as a distinct step producing a stored world; a versioned world format; hash-map chunk storage keyed by chunk coordinate; floor-division throughout so negative coordinates work; a residency disc around the player; worker-thread meshing behind a job queue with cancellation; an explicit dirty set; and a bounded per-frame upload budget.
Why before Marching Cubes: MC plus the digging model turns the current synchronous full-sweep remesh from "adequate" into "stutters on every pickaxe swing". Fixing execution first means MC lands into an architecture that can carry it.
D7 makes the world finite, which is easy to mistake for "small enough to hold in memory". It is not: at one voxel per metre and 256 tall, a 2 km world is ~4.3 GB dense and a 4 km world ~17 GB. Anything at reference scale streams.
So this phase loses unbounded addressing and generate-forever, and keeps the expensive parts — async meshing, the dirty set, the version counter, cancellation and the upload budget are all consequences of D5, not of world scale. In exchange it gains world-generation-as-a-step and the world format.
The size is a parameter, and that imposes a constraint: changing it must not change the terrain that was already there, or two sizes cannot be compared. Generation samples noise at absolute world position so size is a pure clip rectangle, the world is anchored at its centre so growing it adds terrain at the edges, and size leaks into neither the seed nor the save format (section 5, sub-decision B).
Generation throughput is now a first-class concern. Under D9 iterating on the generator means regenerating, so a fast regenerate path is worth building here rather than enduring. Generation is also a bulk up-front job rather than a trickle — same worker pool, different scheduling shape: a progress bar and a completion barrier.
The world format carries data, not just a seed. Under D9 the world is stored rather than reconstituted, so re-running the generator to reproduce it would make every generator improvement silently alter worlds the player already has. Versioned, and a mismatch fails loudly. And compressed — a 2 km world is ~6.4 GB raw, which is unreasonable on disk. Per-chunk RLE over field-split planes, with uniform chunks elided to a header record and a codec byte per chunk so the scheme can be upgraded later without a format break (section 8, question 11).
The world edge: an invisible wall stops the player and only the skybox is drawn beyond it. Deliberately the simple option — no cliff face, no sealed wall.
The dirty set needs a companion: a monotonic Chunk::version
bumped on every write. A chunk can be edited while its mesh job is running on a worker;
the job then returns a mesh built from pre-edit data and clears a flag that should have
stayed set. Jobs capture the version at start and their result is discarded and
re-queued if it no longer matches. The set answers "what needs work", the version
answers "is the work I got back still valid" (risk 2, gap 3).
The runtime half of the verifier lands here: a debug-only sampler that re-meshes a clean chunk and compares its hash, biased toward neighbours of recently-edited chunks rather than sampled uniformly — that is where a missed diagonal mark shows up. It is naturally just another job type once the queue exists.
The residency radius is the view-distance setting, and it is also the full-resolution radius — the static distant mesh removed the coarse-but-resident band that once made those two different knobs (risk 5). Starting values and the invariants binding them are in section 8.
The meshing budget has two dirty sources, not three. Edits and residency. An earlier draft anticipated a third from phase 6 — a chunk's mesh depending on its neighbours' LOD levels, so movement alone drives background remeshing. The static distant mesh removed it: nothing in phase 6 dirties a chunk (risk 5).
Most of this phase's cost is decisions rather than code, and they are written up in section 8 with the options considered. Two of them change interfaces rather than adding code behind them, so they are worth reading before the phase starts:
- Chunk lifetime under concurrent access — mesh jobs take a bordered snapshot (~93 KB per worker) rather than reading shared chunks, so eviction cannot pull memory out from under a running job. The version counter handles staleness; it does not handle use-after-free.
- GPU resource lifetime —
IRenderergains a deferred deletion queue keyed by frame index. Continuous eviction cannot usewaitIdle, which is currently the only mechanism the interface offers. - Ownership — a new
Worldtype owns generate/load, residency, jobs and persistence, with the format behind a narrow versioned interface.Enginegoes back to being the frame loop.
The other seven: flush policy and the dirty-versus-modified distinction, a two-radius residency margin, hysteresis plus an LRU memory cap, the LOD version stamp in the format, a default world with a debug-only path override, surface spawn, and a save/load round-trip test.
Phase 4's construction half moves into this phase
(section 10, question 10): the tool increments the
sparse DamageMap, and a per-chunk damage lookup is read in the fragment
shader to blend a crack overlay from a four-stage texture array. No remesh on any hit —
only on the break.
It comes here because its IRenderer addition is the same interface
pass as this phase's deferred deletion queue, and because it needs nothing from
Marching Cubes. Three consequences for the work above: the damage buffer is allocated
lazily and freed through the deletion queue; the sparse map serialises into the
world format while the dense GPU buffer is a derived cache rebuilt on load; and a chunk
whose only change is damage still counts as modified for write-back.
From phase 3 a chunk carries two meshes — MC terrain and cube construction — on two pipelines, with independent dirty state (section 9, question 1). Three things in this phase are shaped by that and are cheap now, annoying to retrofit: the mesh job's result type, the upload budget counting meshes rather than chunks, and the deferred deletion queue's granularity.
Build the chunk render state as single-mesh here and phase 3 splits all three.
Verifiable by: generating a world, then walking from one edge of it to the other at a stable frame rate — and doing the same on a world generated by someone else.
Open sub-decisions: see section 5 — how much of the world format lands here, what sizes are worth targeting, feature-pass ambition.
Marching Cubes terrain
An MC mesher behind IMesher with gradient normals. A second render
pipeline with triplanar projection and material splatting. Construction cells read as
full density via the existing terrainFieldDensity(); cubes are drawn on
top; the two interpenetrate with no attempt at a shared boundary.
Zero holes is a hard requirement, and it reaches five parts of this phase: the asymptotic decider on ambiguous faces (necessary and sufficient for watertightness — full MC33 interior subcases are a later quality upgrade, not a holes fix); an explicit MC-cube ownership rule at chunk boundaries, so a boundary cube is computed once rather than twice; edge-id vertex welding rather than position-epsilon, so near-coincident vertices cannot fail to merge; a guard for crossings exactly on the isovalue, which the integer density field genuinely produces and which otherwise yields NaN normals; and the exhaustive table test below.
The test that proves it: for all 256 corner sign configurations, and for each
of the six faces, check against all 256 neighbour configurations that both cells emit
matching boundary polygons on the shared face. ~400k checks, milliseconds, and it
proves watertightness rather than sampling it. Pair with a fuzz post-condition
over assembled MeshData — every edge shared by exactly two triangles — run
over random density fields including values seeded at exactly 128. Both fit
voxel_tests unchanged.
And the test that proves chunking is invisible: monolithic-vs-chunked equivalence. Mesh a density field once as a single large volume, then mesh the same field as chunks with borders, and assert the chunked union is identical — positions, normals and material weights, exactly, not within a tolerance. This is the acceptance criterion for "no visual failure at chunk boundaries" made into something that can fail: a too-narrow border, a non-canonical interpolation order or an attribute derived from a clamped neighbourhood each fail it and name the offending vertex.
Collision is left exactly as it is — grid cells, snapped to grid lines. The mismatch against the marched surface is accepted for now and refined in phase 5.
No external prerequisite any more. This phase previously waited on a Transvoxel investigation — whether to build the regular cells on Lengyel's formulation, and what the licensing on those tables allowed. Risk 5 is now settled as a pre-generated static distant mesh and solutions requiring those tables are ruled out, so both questions are closed. Build bespoke MC plus the asymptotic decider that risk 3 already requires, and start when phase 2 lands.
Three structural constraints to honour here. First, factor the mesher so a
single cell can be marched standalone — marchCell(8 densities) → triangles,
called in a loop by the chunk mesher. The collision narrowphase later calls the same
function, so physics and rendering cannot disagree about where the surface is. Cheap
now, a refactor later.
Second, the vertex weld is keyed by (owning cell, axis) into a dense scratch array — three entries per cell over the chunk plus its border, ~430 KB reused per worker — rather than into a hash container. That gives 3× fewer vertices, crack-free merging with no position epsilon, and byte-identical output for free, since there is no iteration order to depend on. It satisfies the mesh verifier's determinism requirement structurally rather than by discipline (risks 2, 3 and 4).
Across a chunk boundary the vertex is duplicated rather than shared — each chunk owns its buffer — so the two copies must be bit-identical. Evaluate every edge crossing from its lower-coordinate endpoint so float non-associativity cannot put the copies a last-bit apart, which would show as a hairline crack.
Normals come from the density gradient at the vertex by central differences, not from averaging face normals: smoother, triangulation-independent, and computed in one pass.
Third: the border must be sized for attributes, not geometry. MC geometry needs
one cell of border; central-difference normals at a border vertex need two, and
material weights need whatever their derivation samples. Take the maximum. Too narrow a
border makes the mesher fall back to a one-sided difference, computing a different normal
than the neighbour computes for the geometrically identical vertex — a lighting seam along
every chunk boundary, with watertight geometry underneath, invisible to any crack test.
IVoxelSource already accepts arbitrary offsets, so this costs nothing at the
interface (risk 4).
Leave room in the vertex layout for a secondary position. Phase 6 may need geomorphing to hide LOD transitions, and its cost is mostly in the vertex format — which this phase fixes. Reserving the slot now keeps the option open without refixing the format later. Load-bearing only if phase 6 takes the runtime-hierarchy route, but cheap enough to reserve before that is known (risk 5, sub-decisions 0 and 8).
The geometry above was settled by risks 3 and 4. What was left — texturing, the vertex format and integration — is worked through in section 9 with the options considered:
- A chunk carries two meshes, with per-mesh dirty state — and a construction
edit dirties both, since placing a block changes that cell's
terrainFieldDensityand so moves the isosurface. This one lands in phase 2. - Material weights come from the owning cell's eight corners, top four by contribution, with a mandatory stable tiebreak — without it two chunks disagree on a boundary vertex and the equivalence test fails intermittently.
- Triplanar × four-way splatting is 12 fetches per fragment. Built naive and measured, with the reduction kept a swappable branch — and weights emitted sorted descending so "take the top two" is later a truncation, not a format change.
- The vertex packs to ~32 bytes, except position, which stays full-precision float because boundary duplicates must be bit-identical.
- No terrain AO in this phase — SSAO later, rather than a per-vertex term that would cost a vertex field, an equivalence-test term and a border width all at once.
Plus: a depth bias rather than any geometric fix at the interpenetration; out-of-world
reads as air; the highlight becomes the targeted cell's marched triangles for terrain; and
the terrain texture array is rebuilt on the construction path, with a
gen_textures placeholder for every material in D10's stratigraphy.
Verifiable by: the terrain from phase 1 rendered smooth, with buildings embedded in it and no see-through cracks anywhere.
Touches: new mesher, new pipeline, new shaders, restored terrain texture
array, MeshVertex gains material weights.
Terrain damage
The tool subtracts density; the surface recedes under it; the cell empties. Edit ops
dispatch on VoxelClass. A strike removes
kToolPower × (127 / toughness), and the strike reaching the isovalue clears
the cell — so kToughness keeps meaning "strikes from full".
It depends on nothing from phase 3, its renderer interface addition is the same pass as section 8's deferred deletion queue, and it is independently demonstrable — so it lands early and gives phase 2 a visible payoff beyond "walking works" (section 10, question 10). This phase is the terrain half only, and it stays here because the receding surface is the entire point of it.
Decided in section 10: Edit mode clears the whole cell rather than subtracting a swing; the numeric health readout is dropped for terrain since the shrinking surface is a better progress bar, while the highlight tint derives from density instead of the DamageMap — which terrain no longer has an entry in; and one tool dispatches on class for now, since the deferred damage model has several.
Keep the per-strike damage a single function rather than an inlined expression. Player attributes, tool types and per-material modifiers are all deferred, and they all eventually arrive at that one scalar.
Verifiable by: mining a hillside away hit by hit and watching it recede.
Collision refinement
Two independently shippable steps in the same area of code. Neither gates anything else, and each can be reverted on its own.
5a — Narrowphase. Keep the existing cell enumeration as a broadphase;
add a narrowphase testing the player's AABB against the marched triangles of the
candidate cells, re-derived through marchCell rather than retained from the
render mesh. Fully contained inside VoxelCollision.h.
5b — Slope response. Classify contacts by
n.y > cos(maxWalkAngle); project the wish velocity onto the ground plane
so input moves along a slope rather than into it; depenetrate along the contact normal
rather than the axis; add ground snapping. Contained in
PlayerController.h.
Why 5b is optional: the narrowphase is functional without it. Per-axis resolution introduces no tangential component so the player does not slide, and the existing 1-voxel step-up clears a marched slope the same way it clears a voxel staircase. What 5b buys is smoothness — without it the lift/settle cycle fires every frame on an incline. It also fixes a pre-existing wrinkle that step-up currently masks: per-axis resolution can stick the player in an inside corner.
Why the whole phase sits here rather than earlier: the collision mismatch is
bounded by one cell and only becomes conspicuous once phase 4 makes the surface recede
under the tool. Deferring costs nothing provided phase 3 honours the
marchCell constraint and phase 1 has added contact normals.
The trap: solidity is not a conservative broadphase — a marched surface
protrudes into cells whose own density is below the isovalue. Gather candidates from
the overlapped cell range expanded by one on each axis, unfiltered by
isVoxelSolid. Details in risk 1.
Distant terrain
Load and draw the coarse terrain mesh that D11's terminal LOD step already produced and stored in the world artifact, behind and below the streamed near field — plus the simplified construction LOD models the same step generated, so cities and POIs keep their silhouette at distance instead of popping in. Player-built structures are culled at a horizon until dynamic construction LOD is designed, which is deferred.
Much smaller than it was. This phase was previously Transvoxel: runtime level selection, transition cells, regenerated case tables, and LOD as a third dirty-remesh trigger competing with edits for the meshing budget. All of that is gone (risk 5). There is one coarse mesh, fixed at generation time, that never re-levels — so nothing here dirties a chunk and nothing here can crack.
What is actually left:
- Where the handoff ring sits. The one tuning decision, and the lever for every residual artifact — far enough out that the coarse surface subtends few pixels.
- Overlap rather than swap. The near field occludes the distant mesh instead of replacing it, so there is no discrete transition to hide and geomorphing is probably unnecessary.
- Drawing two distant representations, not one — the coarse terrain mesh and the simplified construction models, both loaded from the artifact.
The one problem the distant mesh does not solve is popping at the ring. Building the coarse field by sampling the noise at half resolution gives a different surface than downsampling the fine density field, so the hill moves as the near field takes over — and overlapping the two representations does not hide that the way it hides a crack. Avoiding it needs the coarse field to be a genuine downsample: a density mip-chain built by the generation pipeline. That is a generation concern, so it reaches back into phase 1 — which is why risk 5's sub-decisions 1 and 2 survived every other change to that risk.
Note: distant terrain reduces triangles, not storage. Density stays full-resolution wherever the player might edit.
Prerequisite from earlier phases: the MC mesher must be able to march a coarser field, since the LOD step meshes mip levels. Cheaper than it sounds — the resolution is baked into the mip, so the mesher marches it as an ordinary field rather than needing a runtime level parameter threaded through it.
Structural integrity
Support propagation over both representations. Design deferred to its own discussion.
Interaction to remember: the digging model gives terrain cells partial density, so "how much support does a 40%-dense cell provide?" is a question this design creates and structural integrity has to answer.
What is deliberately not in the plan
- Greedy meshing. Worth having for construction, but it is an optimisation with no bearing on any decision here, and construction geometry is a small share of the world. Slot it in whenever it becomes the bottleneck.
- A construction shape palette — wedges, ramps, corner pieces. The reference has a large one and it is a big part of how its buildings read. Out of scope for terrain parity, but it is the natural follow-on once construction is otherwise done.
- Divergence from the reference. Under D3 the bar is parity. Improvements over the reference — sharper cliffs via Dual Contouring, sub-cell edit precision, better material blending — are all deliberately deferred until parity is reached and can be measured against.