3. Target architecture
What the decisions add up to: one grid, two representations, two meshers, two damage models, two pipelines — and a subsystem-by-subsystem delta from the code today.
The shape of it
VoxelWorld (hash map of chunks, streamed)
per cell: type | material | density | class
│
┌──────────────────────┴──────────────────────┐
│ │
class == Terrain class == Construction
│ │
density field, 0..255 discrete, always full
│ │
┌────────┴────────┐ ┌────────┴────────┐
│ MarchingCubes │ │ CubeMesher │
│ Mesher │ │ (+ greedy) │
└────────┬────────┘ └────────┬────────┘
│ construction reads as │
│ full density → surface │
│ closes against walls │
│ │
┌────────┴────────┐ ┌────────┴────────┐
│ terrain pipeline│ │ cube pipeline │
│ triplanar+splat │ │ per-face UV + │
│ gradient normals│ │ crack overlay │
└─────────────────┘ └─────────────────┘
│ │
└──────────── interpenetrate ─────────────────┘
(no shared boundary)
Two damage models
The split from D5 / D6 reaches further than rendering — it decides where the state lives and what a strike costs.
Terrain: damage is density
- A strike subtracts from
Voxel::densityin the dense chunk array. - The chunk goes dirty; Marching Cubes rebuilds it; the new surface is uploaded.
- The surface has visibly receded around the struck cell — because MC interpolates between neighbouring densities, lowering one cell pulls the surface inward and dimples its neighbourhood rather than cutting a cube-shaped bite.
- When density falls past the threshold, the cell is empty and the dimple becomes a hole.
No separate damage record exists. Persistence, collision solidity, the material blend and the visible shape are all driven by the one byte.
Construction: damage is a shader lookup
- A strike increments the sparse
DamageMapentry for that cell. - Nothing is remeshed. The geometry is unchanged by definition.
- Per chunk, a small damage buffer or texture — indexed by cell coordinate — is updated and read by the fragment shader, which blends a crack overlay from a stage texture array over the base tile.
- At full damage the cell is cleared, which does dirty the chunk — once, at the end, instead of on every hit.
The crack overlay must not be baked into vertex data. Baking it would make every hammer hit a remesh, which is precisely the cost D6 avoids by not changing shape. A per-chunk damage lookup read in the fragment shader keeps construction damage at zero meshing cost. Worth designing in from the start — retrofitting it means changing the vertex format.
Why chunks at all?
A reasonable question, given how much of the design spends effort on chunk seams: why not mesh all visible terrain as one buffer and have no seams to reconcile?
Because rebuild granularity is set by edit granularity, not by render granularity. Under D5 every strike changes one density byte, and the cost of that depends entirely on how much geometry has to be rebuilt in response. For a 12-chunk radius with bounded Y = 384:
| One visible-world mesh | One chunk | |
|---|---|---|
| Cells to march | ~590k columns × depth | 323 = 32,768 |
| Vertices | ~1M | ~2,000 |
| Rebuild + reupload | ~56 MB | ~110 KB |
Roughly 500× the work per swing. Patching sub-ranges of the big buffer instead requires knowing which byte ranges changed — which is a spatial partition of the buffer, i.e. chunking again, implicit and harder to reason about. Four further reasons, in rough order of severity: streaming (chunks enter and leave constantly while walking, each a full rebuild), frustum culling (one mesh is one draw and cannot be culled; per-chunk culling typically discards ~70% at that radius), parallelism (chunks are the unit of work for the job queue — one mesh is one job on one thread), and LOD (per-region resolution is its definition).
Against that, the seam cost is small: one ownership convention — a chunk owns the MC cubes whose minimum corner lies inside it — and one rule — evaluate each edge crossing from its lower-coordinate endpoint. Both are verified by the exhaustive table test.
What you can have instead: a suballocated arena
The instinct behind the question has a real answer. Separate the two concerns that "one mesh" conflates:
- Chunk = the unit of rebuild and culling. Must be small, because edits are frequent and local.
- Buffer = the unit of allocation. Can be huge.
Place every chunk's mesh into one large GPU vertex/index buffer as sub-ranges and draw them with multi-draw-indirect: one buffer binding and effectively one submission for all visible terrain, while still rebuilding only the ~110 KB that changed. Strictly better than a monolithic mesh — the same render efficiency without the update cost.
Today's code is one buffer and one draw per chunk (ChunkRender in
Engine.cpp), which is the simple version. The
arena is a later optimisation, not a phase-3 requirement. One wrinkle if it is taken: chunks
carry a per-chunk model matrix today, and batched drawing needs that offset per-draw instead
— a push constant or an instance buffer. Keep positions chunk-local rather than baking world
space: the world is bounded now (D7), but its size is a
parameter, and float precision still degrades with distance from the origin — a centre-anchored
multi-kilometre world puts geometry several thousand units out in every direction.
And the chunk size is a dial
323 is a default, not a law: bigger chunks mean fewer seams, fewer draws and a better surface-to-volume ratio, but rebuild cost scales with the cube of the side — 643 is 8× the rebuild per edit. Minecraft uses 163 sections; 7 Days to Die uses 16×16 columns with sub-chunks. If per-edit cost ever dominates, the lever is sub-chunk dirty regions rather than smaller chunks: keep the draw-side benefits of a large chunk and shrink only the rebuild unit.
Subsystem delta
| Subsystem | Today | Target | Delta |
|---|---|---|---|
| Generation | Sine heightmap, 3 depth bands | Seeded 3D field: fBm + domain warping, cave noise, strata, deterministic per chunk | Rewrite |
| World storage | Dense vector, fixed extent | Hash map keyed by chunk coordinate, streamed around the player | Rewrite (interface already designed for it) |
| Terrain mesher | — | Marching Cubes behind IMesher, gradient normals, welded vertices |
New |
| Cube mesher | Naive face culling | Face culling + ambient occlusion + greedy merging | Extend |
| Meshing execution | Synchronous full-grid sweep behind waitIdle |
Worker threads, per-chunk job queue, bounded uploads per frame | Rewrite |
| Pipelines | One (construction) | Two — construction cubes, terrain triplanar | Restore the second |
| Terrain texturing | — | Triplanar projection, per-vertex material weights, splat blending | New (was deleted) |
| Construction texturing | Per-face UV + array layer | Unchanged, plus a crack overlay sampled from a per-chunk damage lookup | Additive |
| Damage | One sparse map for all voxels | Terrain → density in the grid; construction → sparse map, no remesh | Split & narrow |
| Edit ops | removeVoxel / strikeVoxel, class-agnostic |
Class-dispatched: terrain reduces density, construction increments damage | Branch on VoxelClass |
| Picking | DDA + trilinear crossing, both normals already present | Unchanged in mechanism; needs a highlight convention per class | Mostly intact |
| Collision | AABB swept against grid cells, snapped to grid lines | Unchanged through phase 4; then the same cell enumeration as a broadphase plus a triangle narrowphase over candidate cells | Staged — contained in VoxelCollision.h |
| Distant terrain | — | A pre-generated static coarse mesh loaded from the world artifact, drawn behind the streamed near field; horizon culling for cubes | New, later phase |
| Structural integrity | — | Support propagation over both representations | New, deferred |
Things that stay exactly as they are
Worth naming, because they are the parts of the existing design that were built for this and do not need to move:
IMesher— the strategy interface takes a volume plus a neighbour source and returns plainMeshData. Marching Cubes fits behind it unchanged.IVoxelSource— resolves samples outside a chunk's own bounds. MC needs a one-cell border to mesh seam-correctly; this is exactly what provides it.terrainFieldDensity()— reports construction cells as full density. The single function that makes "terrain closes against walls, no shared boundary" work.- The renderer interface layer —
IRenderer,IRenderPipeline,ICommandBuffer. A second pipeline is an addition, not a change. - The dirty-flag protocol on
VoxelWorld::set, including marking border-adjacent neighbours. MC needs this for the same reason the cube mesher does. - The chunk-coordinate bit-packing already written for the DamageMap key — a ready-made hash key for the streamed chunk map.
Where the two representations touch
Three interactions, all of which have an answer that avoids the trap from Epic #24:
| Interaction | Resolution |
|---|---|
| Terrain surface adjacent to a construction cube | Terrain marches with the cube reading as full density; the cube is drawn opaquely on top; they interpenetrate. No sealing, no snapping. |
| Placing a construction block into terrain | Open — does the block displace the terrain density in that cell, or coexist with it? See question 4. |
| Picking a cell that could be either | DDA finds the cell; VoxelClass dispatches the tool, the highlight
style and the damage model. The mechanism already exists. |