4. Open risks
Ranked by how likely they are to derail the work rather than merely cost time. The top two are both consequences of the digging model, and neither existed as a problem before it was chosen.
Risk 1 Collision under a continuously shrinking surface
The physics sweep in VoxelCollision.h resolves motion per-axis by snapping the player's AABB back to integer grid lines:
// Entered a voxel from above its max face; rest just on top of it.
const float up = std::ceil(box.min[axis]) + kEpsilon - box.min[axis];
Under Marching Cubes alone, this means walking on invisible cube stairs inside a smooth hill. D5 makes it materially worse: the terrain surface now moves continuously while you mine it. Strike a hillside three times and it has visibly receded, but the collision shape has not changed at all until density crosses the threshold, at which point a whole cell of collision vanishes at once. The mismatch is not static geometry the player can learn — it is a discrepancy that opens and closes under the tool.
Ship grid collision unchanged with Marching Cubes, then refine it: keep the existing
cell enumeration as a broadphase, and add a narrowphase that tests the
player's AABB against the marched triangles of just the candidate cells. Deferrable
because it is fully contained inside VoxelCollision.h —
moveAabb's signature does not change and nothing outside knows.
This fits better here than the generic broadphase/narrowphase split usually does, because the voxel grid is already the acceleration structure. General mesh collision is expensive largely because a BVH has to be built and maintained over the triangles; a uniform grid with O(1) lookup is the ideal spatial index and already exists. Four things to get right:
Solidity is not a conservative broadphase
The trap. A marched surface protrudes into cells whose own density is below
the isovalue: cell A at 200 beside cell B at 100 puts the crossing at
(128−200)/(100−200) = 0.72 — 72% of the way from A's centre toward B's,
which is inside B's half of the span. There is geometry in B, and B reads as non-solid.
So the narrowphase cannot gather triangles from "the solid cells the box overlaps" — it would miss exactly the sliver being stood on. It must gather from the overlapped cell range expanded by one on each axis, unfiltered by solidity. That is still tiny: a player AABB covers ~2×3×2 cells, and +1 margin makes 4×5×4 = 80.
Re-derive the triangles; do not retain them
Per-cell MC is a table lookup and a few lerps, and moveAabb substeps to
~10–15 overlap tests per frame — one or two thousand cell evaluations, trivial next to
the mesher. The alternative, indexing the render mesh's triangles by cell, costs memory
and couples physics to mesh lifetime.
The decisive argument is async meshing: once meshing moves to workers, the
render mesh is legitimately several frames stale relative to the density field — that is
the point of doing it off-thread. Physics reading density directly is correct by
construction; physics reading retained triangles inherits the staleness and collides
against terrain already mined away. Re-deriving is also stateless, so nothing needs
invalidating on edit. The CPU-side MeshData is already discarded after
upload today, so this keeps that true.
The work is in resolution, not the test
The exact test is standard — AABB versus triangle by SAT, 13 axes, no dependencies.
What changes is resolveAxis, which has no grid line to snap to any more. The
cheap version that fits the existing structure: keep the per-axis substepped move and
replace the snap with "back off along this axis until nothing overlaps", by computed
penetration depth or a short bisection. The existing 0.4-voxel substep cap keeps
penetration shallow, which is what makes that viable instead of a full swept test.
Slopes: polish, not a blocker
The narrowphase does not require slope handling to be functional. Per-axis resolution
introduces no tangential component, so the player does not slide; and
PlayerController already steps up 1-voxel ledges
(kStepHeight = 1.05f), which is why the current voxel staircase is walkable.
The same mechanism clears a marched slope: gravity settles the box onto the surface,
horizontal motion is blocked, step-up lifts and settles at the new height.
What you get without a slope fix is functional-but-steppy — the lift/settle cycle firing every frame on an incline instead of smooth traversal, with horizontal velocity zeroed each frame. Playable, visibly worse than it should be. So the slope fix is a polish item on its own schedule, not something that arrives coupled to the narrowphase.
The constraint that keeps the slope fix cheap: contact normals
Slope handling is entirely a response-layer change — it lives in
PlayerController and resolveAxis, not in the broadphase or the
triangle test. It only becomes expensive if the contact information it needs is missing.
Today the collision layer is boolean: overlapsSolid returns
bool and CollideResult carries only
hitX/hitY/hitZ. Slope logic needs the contact
normal — that is what separates walkable ground from wall, and what depenetration
resolves along.
This can be fixed now, on grid collision, for almost nothing:
resolveAxis already knows the axis and the sign it backed off along, which
is the normal. Populating it makes the interface correct before the narrowphase exists,
and it is useful independently of Marching Cubes — construction cubes have normals too,
and the controller can take its grounded test from normal.y rather than
inferring it from hitY && velocity.y < 0.
With normals flowing, the slope fix is: classify contacts by
n.y > cos(maxWalkAngle); project the wish velocity onto the ground plane
so input moves along the slope instead of into it; depenetrate along the normal rather
than the axis; add ground snapping so convex ridges do not launch the player. None of it
touches the broadphase, the triangle test or marchCell.
The full staging
grid → grid + contact normals → narrowphase → slope response. Every arrow is
independently shippable and independently revertible. Step 2 is today's code plus two
lines; step 3 is contained in VoxelCollision.h; step 4 is contained in
PlayerController.h.
One pre-existing wrinkle, unchanged by any of this: per-axis resolution can stick the player in a corner where the X and Z passes each back off independently — on a diagonal slope, or an inside corner. Step-up currently masks it. Normal-based depenetration in step 4 is what actually fixes it, which is a second reason to do the slope work eventually rather than never.
The one constraint to honour now: factor the mesher so a single
cell can be marched standalone — a marchCell(8 densities) → triangles
function the chunk mesher calls in a loop, rather than MC logic woven into the chunk
iteration. Physics then calls the same function and is guaranteed to agree with what is
drawn. If MC is written as a monolithic chunk loop, the narrowphase either duplicates it
(two sources of truth for the surface — the exact bug class kSolidDensity
exists to prevent) or forces a refactor.
Risk 2 Remesh cost per strike
Created directly by D5. Every terrain hit changes a density byte, which dirties a
chunk, which triggers a full Marching Cubes rebuild of 323 cells plus a GPU
upload — currently synchronous, on the main thread, behind
renderer.waitIdle(), and preceded by a sweep over every chunk in the world
just to discover which ones are dirty.
At a sustained swing rate this is a stutter on every hit, and it gets worse rather than better as the world grows, because the dirty-discovery sweep is proportional to world size.
What it needs:
- Async meshing — generate and mesh on worker threads; the main thread only uploads, with a bounded budget per frame.
- A dirty set, not a dirty sweep — an explicit queue of dirty chunk coordinates, so discovery is O(edits) rather than O(world).
- Possibly sub-chunk dirty regions — remeshing only the affected 83 region rather than the whole chunk. Adds complexity to the seam handling; worth measuring before assuming it is needed.
Is a dirty set sound? Three ways changes get missed
The structure is right — O(edits) discovery, free deduplication (which matters once a single corner edit touches eight chunks), and a direct mapping to the job queue. But it is a notification mechanism, so it is only as correct as the completeness of the mutation paths. Two gaps are live today and a third arrives with async.
Gap 1 — the neighbour stencil is face-only
VoxelWorld::set marks the six face-adjacent chunks when the edited cell
sits on a chunk border. That is exactly right for the current cube mesher, which reads
only the 6-neighbourhood. It is wrong the moment the mesher reads diagonals — and
both of the next two steps do:
- Ambient occlusion samples, per face vertex, the two edge cells and the corner cell touching it. Changing one cell alters the AO of faces in diagonally adjacent cells. This bites in phase 1, before Marching Cubes exists.
- Marching Cubes needs a one-cell border on all sides including diagonals — the MC cube spanning a chunk corner draws its eight samples from all eight chunks meeting there.
Edit a corner cell today and three of the seven affected neighbours are marked. The rest keep a stale mesh until something else happens to dirty them. The fix is a cross-product rather than six independent tests:
// offsets per axis: always 0, plus -1 at the low border, +1 at the high border
int ox[2], oy[2], oz[2]; int nx = 0, ny = 0, nz = 0;
ox[nx++] = 0; if (lx == 0) ox[nx++] = -1; else if (lx == kChunkSize-1) ox[nx++] = +1;
// … same for y, z
for (i in nx) for (j in ny) for (k in nz)
markChunkDirty(cx + ox[i], cy + oy[j], cz + oz[k]);
One chunk in the interior, two on a face, four on an edge, eight at a corner — and it degenerates to today's behaviour for a face-only mesher.
Gap 2 — dirty tracking can be bypassed entirely
Chunk exposes a mutable volume: Volume &volume(),
and VoxelVolume::set is public. So
chunk->volume().set(x, y, z, v) writes a voxel and sets no dirty flag
at all — not even the chunk's own. Chunk::set is a smaller version of the
same hole: it marks itself but never its neighbours.
Nothing misuses either today, but the hole is open and the failure mode is a mesh that
silently never updates. The fix turns a discipline problem into a compile error: drop the
non-const volume() overload (Engine only reads through it), and
make Chunk::set private with VoxelWorld a friend. Then
VoxelWorld::set is provably the only mutation path.
Gap 3 — a dirty flag is insufficient once meshing is async
A chunk can be edited while its mesh job is running on a worker. The job finishes, hands back a mesh built from pre-edit data, and the dirty flag has already been cleared — permanently stale, intermittently, under load.
The fix is a monotonic version counter alongside the set: Chunk::version
increments on every write; a mesh job captures the version when it starts; on completion,
if the versions differ the result is discarded and the chunk re-queued. The async design
wants both — the dirty set answers "what needs work", the version answers "is the
work I got back still valid".
Cheap insurance — decided
Two forms of the same check. Both compare a hash rather than a retained mesh:
FNV-1a over the vertex and index bytes, stored at mesh time — 8 bytes per chunk, against
the roughly doubled CPU mesh memory that retaining MeshData would cost. The
CPU-side mesh is discarded after upload today, and this keeps it that way.
- A deterministic test (
voxel_tests): seed an RNG, build a world, apply a few hundred random edits throughVoxelWorld::set, then verify every chunk's incrementally-maintained hash against a fresh full rebuild. A stencil bug fails this reliably rather than by luck, and it runs in CI. Fits the existing target exactly —CubeMesherplus glm, no Vulkan. Lands in phase 1, alongside the stencil change it verifies. - A runtime sampler (debug builds only): periodically re-mesh a clean chunk and
compare, logging through
VC_LOG_ERRon mismatch. Sample a random neighbour of a recently-edited chunk rather than uniformly over the world — that is exactly where a missed diagonal mark shows up, and it raises the hit rate enormously for the same cost. Covers paths the test does not exercise, and keeps watching after the code has moved on.
MeshVerifier (src/MeshVerifier.h)
submits through the same queue as real meshing, with a MeshPurpose riding along
to the result; the main thread compares rather than uploads. Same capture, same worker, same
mesher — a verifier that meshed differently would be checking its own code path rather than
the one that runs.
Three things keep it from crying wolf, which is how a check like this gets switched off: it skips dirty chunks (one still owed a remesh is supposed to disagree), it skips comparisons where the chunk's version has moved since the snapshot, and it yields whenever the queue is busy, so it never competes with chunks the player is waiting to see. It also never marks anything dirty: an observer that could change what it watches could hide the bug it exists to find.
One line per run reports what it managed to look at — a check that silently never fires is indistinguishable from one that passes. A 22-second session with edits recorded reported 14 meshes checked, 0 mismatches.
Hash comparison requires the mesher to be deterministic — same voxels in, same
MeshData byte-for-byte. True of the current cube mesher, which sweeps in a
fixed order. Not automatically true of Marching Cubes with vertex welding: if
the weld is keyed through a hash map and vertices are emitted in map-iteration order,
identical input can produce different vertex ordering and the hash differs for no real
reason.
So the weld must emit in a deterministic sweep order — index by edge id, walk cells in a fixed order — rather than iterating whatever the hash container returns.
Resolved by risk 4: the chosen weld uses a dense scratch array keyed by (owning cell, axis) rather than a hash container, so there is no iteration order to depend on and determinism falls out of the structure. Nothing here needs to be remembered during implementation.
Consequence for ordering: the meshing execution model has to be fixed before Marching Cubes lands, not after. MC is several times more expensive per chunk than face culling, and D5 makes remeshes frequent rather than occasional.
Risk 3 Marching Cubes ambiguous cases
The classic 15-case Marching Cubes table is ambiguous for certain corner configurations — the same inside/outside pattern admits more than one valid surface, and picking inconsistently between neighbouring cells leaves holes in what should be a watertight mesh. The failure is small, intermittent and geometry-dependent, which makes it exactly the kind of bug that is discovered late and is miserable to chase.
The requirement is absolute: no holes in the terrain mesh whatsoever. That is stronger than "fix the MC ambiguity", and it reaches six places, not one.
Watertight is not the same as topologically correct
Two different problems live under "MC ambiguity", and only one causes holes.
Face ambiguity makes holes. Two cells sharing a face each triangulate it, and if they disagree there is a gap. The fix is the asymptotic decider: at an ambiguous face, evaluate the bilinear interpolant at the face's saddle point and let its sign choose. Both cells compute that value from the same four corner densities, so they agree by arithmetic rather than by convention.
Interior ambiguity — what MC33's larger case set is mostly about — decides whether two sheets connect through the cell interior, producing a tunnel or two separate blobs. Whichever is chosen, the polygons on all six faces are identical, so watertightness is unaffected. It is a correctness-of-shape problem.
So: face disambiguation is necessary and sufficient for no holes. Take the asymptotic decider now — a well-bounded addition over standard MC — and treat full MC33 interior subcases as an optional later upgrade if spurious tunnels prove visible. Avoid the cheap alternative of always picking the same variant: it is consistent and watertight, so it meets the letter of the requirement, but it visibly joins things that should be separate.
The other five sources of holes
- Chunk seams. A perfect table still cracks if two chunks compute the boundary
MC cube from different samples. Define ownership explicitly — a chunk owns the MC cubes
whose minimum corner lies inside it — and take the last row from the border samples
IVoxelSourcealready supplies. Without an ownership rule the failure is double-drawn geometry (z-fighting) rather than a gap, but it is still wrong. - Vertex welding by epsilon. Merging by position with a float tolerance lets near-coincident vertices fail to merge, leaving hairline cracks. Keying by edge id is exact — no epsilon, no cracks. This is the same design the mesh verifier already requires for determinism, so the two constraints converge.
- Crossings exactly on the isovalue. Density is an integer byte and the
isovalue is 128, so a corner sitting exactly at 128 is reachable — the generator
produces it whenever
ylands exactly on the surface height. That is a zero-length crossing: degenerate triangles, and an unguarded division by zero in the interpolation. Not a hole itself, but it yields NaN normals, which render as one. Needs an explicit guard. - Distant terrain (phase 6). Was the real threat, and no longer is. A runtime LOD hierarchy meshes chunks at different resolutions that disagree along their shared boundary — guaranteed cracks that move as the player moves. The pre-generated static distant mesh has no shared boundary at all: it sits behind and below the near field and the two overlap, so a mismatch shows terrain rather than sky. See risk 5 below.
- Newly streamed neighbours. Reading unloaded chunks as air closes the surface, so no hole — but the mesh changes when the neighbour arrives. A visible pop, handled by the widened dirty stencil from risk 2.
Proving it rather than hoping
Given how firm the requirement is, this is worth more than implementation care:
- Exhaustively verify the table. For all 28 = 256 corner sign configurations, and for each of the six faces, check against all 256 possible neighbour configurations that the two cells emit matching boundary polygons on the shared face. About 400k checks, runs in milliseconds, and it proves the table is watertight instead of sampling it. The single highest-value test in the plan.
- Fuzz the assembled mesh. Post-condition over
MeshData: every edge shared by exactly two triangles. Run over randomised density fields, including fields deliberately seeded with values at exactly 128. Catches assembly bugs the table test cannot see — welding, seams, ownership.
Both fit voxel_tests unchanged: pure logic plus glm, no Vulkan.
Note: this whole risk is a cost of choosing MC for parity. Both Surface Nets and Dual Contouring, being dual methods, are structurally immune to it.
Risk 4 Vertex count and welding
Naive Marching Cubes emits three unshared vertices per triangle, so a surface costs roughly three times the vertices it needs and loses smooth shading, since each vertex belongs to exactly one triangle and cannot carry an averaged normal.
Index vertices by the grid edge they sit on, so cells sharing an edge share the vertex. Required independently by three things: vertex count, crack-freedom (risk 3) and determinism (risk 2).
Accepted on the condition of no visual failure of any kind at chunk boundaries — which is broader than cracks, and turns out not to be fully covered by the geometry work. See below.
What "no visual failure between chunks" actually covers
| Failure | Covered by | Status |
|---|---|---|
| Geometric cracks | Ownership rule + bit-identical crossings | Risk 3 / 4 |
| Double-drawn boundary geometry (z-fighting) | Ownership rule | Risk 3 |
| Lighting seams from mismatched normals | — | Border width, below |
| Material seams from mismatched weights | — | Border width, below |
| LOD cracks | No longer arises — the distant mesh overlaps rather than abuts | Risk 5 |
The gap: attributes need a wider border than geometry
MC geometry needs a one-cell border — the eight samples of a boundary cube. The vertex attributes need more. Gradient normals by central differences sample density at ±1 cell around the vertex position, so a vertex at the outer edge of a one-cell border needs a sample two cells out. Without it, a one-sided difference or a clamp computes a different normal than the neighbouring chunk computes for the geometrically identical vertex — a visible lighting discontinuity along every chunk boundary, with perfectly watertight geometry underneath. One of the most common chunked-terrain artifacts, and invisible to any crack test. The same argument applies to the four-way material weights.
So border width is a number to settle rather than assume: 1 cell for MC geometry,
2 cells for central-difference normals, and whatever the material weight derivation
needs. Take the maximum. IVoxelSource already accepts arbitrary out-of-bounds
offsets, so this costs nothing at the interface — it only changes how much the mesher
reads.
The alternative — the analytic gradient of the trilinear interpolant — needs only the cube's own eight samples, so one cell suffices. But that gradient is discontinuous across cell boundaries (trilinear interpolation is not C1), giving faceting everywhere rather than only at seams. Central differences with a 2-cell border is the better trade.
Making the criterion testable
"No visual failure" is a judgement, so it can regress silently. It converts cleanly into something that fails loudly: 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 — same vertex positions, same normals, same material weights, exactly, not within a tolerance.
Any border that is too narrow, any non-canonical interpolation order, any attribute
computed from a clamped neighbourhood fails it immediately and points at the exact vertex.
Stronger than the exhaustive table test, because it covers the whole assembly path rather
than the case tables. Fits voxel_tests — pure logic and glm, no Vulkan.
A dense scratch array, not a hash map
The natural edge id is owning cell + axis: each cell owns the three edges running in +X, +Y, +Z from its minimum corner, so every edge in the lattice has exactly one owner and a cube's twelve edges resolve to owner cells by fixed offsets.
So the edge→vertex-index map is a plain array, not a hash container: three entries per cell over the chunk range plus its border, initialised to −1. At 333 × 3 that is roughly 430 KB of scratch per meshing job, reused per worker thread.
The payoff is that the determinism constraint from risk 2 stops being a
constraint. There is no iteration order to depend on — cells are swept in a fixed
order and a vertex is emitted on first touch of each edge, so byte-identical output falls
out of the structure rather than out of care. The same move as closing the
Chunk::volume() hole: make the failure unrepresentable rather than merely
avoidable. It is also faster than hashing.
Welding is within a chunk; identity is across
Welding merges vertices inside one chunk. Across a chunk boundary the vertex is necessarily duplicated — each chunk owns its own GPU buffer and cannot share — which is fine provided the two copies sit at bit-identical positions. Coincident duplicates produce no crack; positions differing in the last float bit produce a hairline one.
Both chunks compute from the same density bytes, so they agree — as long as the
interpolation is evaluated in a canonical order. Float arithmetic is not associative, so
lerp(a, b, t) and lerp(b, a, 1−t) can differ in the last bit.
Always evaluate an edge crossing from its lower-coordinate endpoint, regardless of which
cube is asking.
Normals from the gradient, not from face averaging
Welding makes averaged face normals possible, but the density gradient at the vertex position (central differences on the field) is the better source for an isosurface — smoother, independent of triangulation, and computed per-vertex at creation with no second accumulate-and-normalise pass. Raycast.h already has this pattern.
The saving: roughly 3× on vertices; index count unchanged. That matters more than it sounds because the terrain vertex will be fat — position, normal, four material weights and four layer indices, possibly AO, so 48–56 bytes. Tripling that across a streamed world is real memory.
Risk 5 Distant terrain and the handoff ring
Some form of LOD is mandatory regardless of world scale — a few kilometres of visible terrain needs it whether or not there is more terrain beyond.
Near the player, full-detail voxel chunks. Beyond, a coarse distant terrain mesh built once by the terminal LOD step of the generation pipeline (D11) and stored in the world artifact. At runtime it is loaded, not computed: no runtime level selection, no transition cells, no case tables.
Available only because D7 and D9 make the world finite and generated up front, and plausibly what the reference does. Any solution requiring Lengyel's Transvoxel transition tables — and the licensing question attached to them — is ruled out by decision.
Scope note, so this does not over-apply: what is retired is Transvoxel's transition tables. Marching Cubes' own 256-case table is standard textbook material and unencumbered, and the asymptotic decider that risk 3 requires for zero holes is a separately published algorithm. MC itself is unaffected.
Why this is a much smaller risk than it was
The original risk was runtime LOD cracks: chunks meshed at different resolutions disagreeing along a shared boundary, with the cracks moving as the player moves because level assignment is view-dependent. None of that exists here — there is one coarse mesh, fixed at generation time, and it never re-levels.
What remains is the handoff ring where the near field meets the distant mesh. That is a materially weaker problem for two reasons:
- The distant mesh sits behind and below the near field, so the two can simply overlap — the near field occludes the coarse mesh rather than replacing it. A mismatch reads as terrain-behind-terrain, not as see-through-to-sky, which is what risk 3's zero-holes requirement is actually about.
- Nothing swaps. Chunks stream in behind an already-drawn distant mesh, so there is no discrete level transition to hide. This is why geomorphing is probably unnecessary here, unlike under a runtime hierarchy.
The residual artifact is the coarse surface being visibly coarser where the ring falls, and the fix for that is where the ring sits — far enough out that it subtends few pixels. That is a tuning problem, not an algorithm.
What the LOD step must still get right
- It downsamples the finished voxel field, not a heightmap. That is what preserves the caves and overhangs D4 makes mandatory — a heightmap distant representation would seal cave mouths at range and reopen them on approach, which is the popping problem in a new costume.
- It runs last, after any step that modifies terrain — so road cuts and flattened city ground are present in the distant view.
- It covers terrain only — a coarse voxel field is the wrong representation for a building. Construction gets its own simplified LOD models, generated separately by the same terminal step and stored alongside, so generated cities and POIs stay visible at distance rather than popping in. Player-built structures are culled at a horizon until dynamic construction LOD is designed, which is deferred (D11).
Everything below this point assumes (a), and is retained because the decision is not made. Sub-decisions 1 and 2 (the density mip-chain and its downsampling operator) survive under every option, since any coarse representation needs a genuine downsample of the fine field — so phase 1 is unaffected by how this resolves.
What the distant mesh does not solve: popping at the ring
Loading a fixed coarse mesh removes cracks and level-swapping. It does not remove the surface changing shape as the player approaches, and that has a specific avoidable cause.
Building the coarse field by sampling the noise at half resolution produces a different surface than downsampling the fine density field — they are not the same function. So where the near field takes over from the distant mesh, a hill visibly moves. That is a visual failure at a boundary, the class of thing the risk-4 acceptance criterion exists to rule out — and unlike a crack, overlapping the two representations does not hide it.
Avoiding it means the coarse field must be a genuine downsample of the fine one — a density mip-chain built by the generation pipeline, which is cheap because generation is deterministic and already has the finished field in hand. This is a generation concern, not a meshing one, so it reaches back into phase 1's design and is the reason sub-decisions 1 and 2 survive every other change here.
And "downsample" is itself a choice, not a given. The operator decides what distant terrain preserves:
- Average — smooths thin features away, so distant cliffs soften and narrow cave mouths close up.
- Minimum — biases toward empty; terrain thins and shrinks with distance.
- Maximum — biases toward solid; caves and overhangs fill in.
There is no free answer — it is a decision about what the coarse representation should keep, and it interacts with D4 (caves and overhangs are required, so a cave system that seals itself at distance and reopens on approach is its own kind of popping).
Coarse density comes from a density mip-chain built at generation time, and the operator starts as averaging. Averaging is the conventional default and the least surprising; whether it holds up against D4's cave requirement is an empirical question best answered against real terrain rather than in advance.
So make the operator a swap-in point, not an inlined loop. The whole value of "revisit later" depends on trying min, max or a feature-preserving variant costing an afternoon rather than a refactor. One function, chosen once per mip build.
Full-resolution radius — no longer a separate setting
Distant terrain reduces triangles, not storage: density stays full-resolution wherever the player might edit. An earlier draft made how far that region extends a user setting in its own right, floored above reach distance and clamped to the streaming radius — because a runtime LOD hierarchy put a coarse-but-resident band between the fine chunks and the horizon.
The static distant mesh removed that band. There are now two tiers only — resident full-resolution voxels, and the pre-generated distant mesh — so the full-resolution radius is the residency radius, and this stops being a separate knob. Both couplings it carried survive automatically: it cannot exceed what is resident, and even the smallest sane residency radius (8 chunks, 256 m) is far beyond the 8 m edit reach. Values in section 8.
Normals and material weights at the ring
Under a runtime hierarchy this was a substantial sub-problem: two levels meeting along a shared boundary compute different normals for geometrically identical vertices, and lighting is a derivative, so the mismatch reads as a hard bright/dark line. A static distant mesh mostly dissolves it, because the two representations overlap rather than abut — there is no shared boundary along which they must agree.
What remains is that the coarse mesh is visibly coarser where it emerges from behind the near field: softer normals, blurrier material blending. Both are low-frequency at that distance, and both are addressed by ring placement rather than by matching maths.
So: do nothing, and measure. The ring is distant by construction — it subtends few pixels, and material blending and atmospheric falloff may hide it outright. If it does not, the first lever is moving the ring out, not blending normals. Geomorphing is probably unnecessary here, since nothing swaps — which downgrades, but does not remove, the reason to leave room for a secondary vertex position in phase 3's layout.
Sub-decision status
Where each sub-decision stands after the approach was settled:
| # | Sub-decision | Status | Due |
|---|---|---|---|
| 0 | Approach — static distant mesh, or runtime hierarchy with transition cells? | Decided — pre-generated static distant mesh, built by D11's terminal LOD step and loaded at runtime. Transvoxel dropped | Settled |
| 1 | Source of the coarse density field | Decided — generation-time density mip-chain | Phase 1 |
| 2 | Downsampling operator | Decided — start with averaging; keep the operator a swap-in point and revisit against real terrain | Phase 1 |
| 3 | Full-resolution density radius | Closed — no longer a separate setting. The static distant mesh removed the coarse-but-resident band, so this is the residency radius (section 8, parameter 4) | Phase 2 |
| 4 | Lengyel's regular-cell tables, or bespoke MC + asymptotic decider? | Closed — no longer arises. Bespoke MC plus the asymptotic decider, as risk 3 already requires | — |
| 5 | Licensing on the published tables | Closed — moot, since solutions requiring those tables are ruled out. Recorded for completeness: the published tables permit free use provided the source credits Lengyel and transvoxel.org, but state the file itself "may not be publicly reproduced without permission" — so the data was usable, the file was not committable to a public repo | — |
| 6 | LOD scheme — level count, shell radii, octree versus shells | Closed — replaced by a single question: where the handoff ring sits | Phase 6 |
| 7 | Transition cells: all six faces, or generated on demand? | Closed — there are no transition cells | — |
| 8 | Matching normals and material weights across a transition | Mostly dissolved — the representations overlap rather than abut, so there is no shared boundary to match along. Do nothing and measure; move the ring out if it shows | Phase 6 |
| 9 | Generated construction at distance | Decided — construction gets its own simplified LOD models, generated separately by the terminal step and stored alongside the terrain LOD | Phase 6 |
| 10 | Distant LOD for player-built construction | Deferred by decision — needs simplified models regenerated at runtime on changing content. Until then, player-built structures are culled at a horizon | Its own discussion |
Still worth carrying into phase 3: leave room for a secondary vertex position in the terrain vertex layout. Geomorphing is probably unnecessary now that nothing swaps, so this is cheaper insurance than it was — but the vertex format is fixed in phase 3 and reserving a slot costs nothing.
Risk 6 Terrain texturing and the vertex format
A marched vertex sits between cells that may carry different materials, so it cannot pick one — it needs per-vertex material weights and splat blending, plus triplanar projection because there is no natural UV layout on a marched surface.
Four materials may blend at a vertex: four layer indices plus four weights, which
packs into 8 bytes as uint8 indices and unorm8 weights.
D10 makes this a requirement rather than a rule of thumb —
many thin strata means a dug wall cuts across them constantly, and a pinch-out is a point
where three bands meet, which is exactly where a two-material blend fails. See
question 6.
This was the least-decided risk in the plan. Section 9 settles the rest of it: weights come from the owning cell's eight corners, top four by contribution, with a mandatory stable tiebreak and renormalisation after truncation; the vertex packs to about 32 bytes with position left at full precision because boundary duplicates must be bit-identical; triplanar × four-way splatting costs 12 fetches per fragment, built naive with the reduction kept swappable and weights emitted sorted descending; and the terrain texture array is rebuilt on the construction path with a placeholder tile per material.
What is left is not an engineering risk. colorIndex is a
byte, so 256 materials is not the constraint — texture-array memory and the work of
authoring a tile set per material are, and D10 turned three
material bands into a full stratigraphy. That is the item most likely to become the practical
bottleneck, and no decision here reduces it.
Risk 7 Deterministic generation across chunk borders
This risk was a consequence of generating lazily in an unbounded world, where a chunk had to generate identically regardless of when it was first visited or which neighbours existed. Under D9 the world is generated in one up-front pass with every chunk present, so a feature spanning four chunks is written into four chunks that all exist. There is no consistency problem left to solve.
The bounded-radius two-pass scheme becomes an available optimisation rather than a requirement — which also lifts the constraint that the largest feature the world may ever contain had to be decided up front.
What survives is narrower, and worth keeping visible so it is not assumed away with the rest:
- Reproducibility, for a different reason. The same seed and size should still produce the same world — so a world can be described by its parameters, and so generator changes are diffable against a known output.
- Generation order must not affect the result. Generation is now a blocking up-front step, so it will be parallelised across chunks; a feature pass writing into shared chunks then needs a defined order or it is racy. Much easier than the unbounded case, but not automatic.
- The stages themselves are ordered, and that is new.
D10 puts mineral seams only within specific host rock, so
the ore pass reads the strata pass's output — the generator stops being a bag of
independent functions of position and becomes a pipeline:
base density + strata → caves → seams → bedrock clamp → LOD generation. Parallelism is then per-stage with a barrier between stages, and the bedrock clamp must run after everything that carves or a later carving pass tunnels through the world floor. - Generation outputs inherit the requirement. The density mip-chain, and any pre-generated distant terrain (risk 5, option c), are produced by the same pass and must be reproducible on the same terms.
Discussed further in section 5, sub-decision C.
What is no longer a risk
Two items that dominated the earlier draft of this wiki are now closed:
- The terrain / construction seam. Resolved by decision, not by engineering: the two representations interpenetrate. The five commits of history in section 2 are the argument for why this is the right call.
- Interaction coherence. The
faceNormal/normalsplit onRayHitalready exists, DDA already identifies the cell correctly, andVoxelClassalready exists to dispatch on. What remains is a presentation choice — what the crosshair highlight looks like on a smooth surface — not a structural problem.