10. The two damage models
Phase 4 in detail. Terrain damage is density and shows as the surface receding; construction damage is a sparse map and shows as crack textures. Ten decisions, plus the future damage model that most of this has to leave room for.
The two halves share almost nothing. Terrain is arithmetic on a byte that already exists — no new storage, no new renderer work, and it depends on phase 3 to be visible at all. Construction needs a new renderer capability and depends on nothing from phase 3, which is why Q10 moves it earlier.
Decided Q1 — How much density a terrain strike removes
- (a)
(255 − 128) / toughnessper strike; the strike reaching 128 clears the cell.kToughnesscarries over meaning "strikes from full". - (b) Accumulate strikes in a separate counter, converting to density only when a full swing's worth is banked.
- (c) Floor every cell at N strikes regardless of its current density.
Decided: (a). It preserves the D5
finding that density is the terrain damage store — which is what makes terrain damage
free to persist and lets DamageMap narrow to construction only. (b) reintroduces
exactly the separate store that finding removed.
A previous draft argued against (a): the generator's soft band leaves surface cells at around 150 density, so a hillside's outer shell would break in one hit whatever it is made of — and the first hit is the most visible one.
D10 largely answers that. The soft band sits at the surface, and the surface material is topsoil and sand. A crust of loose soil breaking in one hit is correct behaviour, not a bug. Deep rock generates saturated at 255 and takes the full count.
The narrow case that remains is an exposed rock cliff face, where the originally-exposed layer goes fast — and only that layer, since everything behind it was interior and is fully dense. A one-time effect at each exposed surface rather than a pervasive one.
Decided Q2 — What kToolPower means across two models
kToolPower is 1 today, in DamageMap units. Terrain now works in density
units.
- (a) Two separate constants — a tool power for construction, a density rate for terrain.
- (b) One multiplier — terrain removal is
kToolPower × (127 / toughness), construction damage stayskToolPower. - (c) Normalise both to a 0–1 "work per swing" that each model scales.
Decided: (b), with kToolPower staying 1. One number scales
both models, so "a better tool" remains a single concept. Deliberately simple for now — but
see the deferred model below, because that scalar is where all of it eventually
arrives.
Damage will eventually be a function of considerably more than material toughness:
- Player attributes — a stronger character does more damage, so removes more density per swing.
- Tool type, with per-material modifiers — shovels are good at loose soils like sand and dirt and poor at rock; pickaxes are the reverse; sledgehammers are good against brick construction. The right tool for the material, rather than a single universal one.
- Material toughness, which is the only one of the three modelled today.
All of it is deferred, and toughness-by-material carries the whole model for now. The design consequence is not to build any of it, but to leave the door open cheaply:
Keep the per-strike damage a single function, not an inlined
expression. Something shaped like
damageFor(material, tool, attributes) → scalar, with tool and attributes
stubbed today. Then the model above lands as a change to one function body rather than a hunt
through the edit ops, the HUD and the highlight — the same reasoning that made the mip
downsampling operator a swap-in point. It also means Q8's one-tool decision is
explicitly a "for now", since the deferred model has several.
Decided Q3 — What Edit mode does to terrain
Edit mode's LMB deletes a voxel outright today. Under a density model, "delete" is ambiguous.
- (a) Set density to 0 — the whole cell goes, matching current behaviour exactly.
- (b) Subtract one swing's worth — which would leave Edit and Game modes differing only in cooldown, defeating the point of Edit mode.
- (c) (a) plus a terrain-raise op for authoring, on another button.
Decided: (a); (c) deferred. Edit mode exists for authoring speed, so whole-cell removal is the point. A raise op is a real authoring want, but it is a new interaction rather than parity, and nothing else in the plan needs it.
Decided Q4 — How construction crack damage reaches the shader
D6 only pays off if damaging a block costs no remesh.
- (a) A per-chunk damage lookup — a buffer indexed by cell coordinate, sampled in the fragment shader to blend a crack overlay from a stage texture array.
- (b) Bake the stage into vertex data — a remesh per hit, which is precisely what D6 rules out.
- (c) A damage-decal pass — overlay quads on damaged faces. No remesh and no per-chunk buffer, but geometry per damaged face and a second draw.
Decided: (a). The only option costing nothing per hit and nothing per undamaged chunk.
IRenderer has no concept of a frequently-updated per-draw buffer. Neither
does it have deferred resource destruction, which section 8's
question 2 adds for phase 2.
Two additions to the same interface, from two different phases. Design them as a single
interface pass during phase 2 rather than opening IRenderer twice — which is
half the argument for Q10 moving this work earlier.
The per-draw buffer landed as a dynamic storage-buffer descriptor
(StorageBinding): the descriptor is written once against an arena holding every
damaged chunk's table at once, and each draw selects its own slice with
bindStorageOffset. The alternative — a descriptor set per chunk — means
allocating and freeing sets as chunks stream in and out, which is the bookkeeping this phase
spent #73 and #79 removing everywhere else.
Which cell a fragment is on is derived, not stored. The fragment steps half a cell
back along the face normal from its interpolated local position —
floor(pos − 0.5 × normal) — because a face lies
on the boundary between two cells and the position alone is ambiguous. A cell index in
MeshVertex would have been simpler and cost four bytes on every vertex in the
world, around 90 MB at R = 12, to carry something the position
already implies. The derivation is only correct if it agrees with what the mesher emits, and
nothing else would catch it drifting, so a test applies the same formula to every triangle of
a meshed voxel.
Decided Q5 — How the per-chunk damage buffer is allocated and persisted
A 323 chunk is 32,768 cells. One byte each is 32 KB.
- (a) Always allocated — 32 KB per resident chunk, so roughly 200 MB at
R= 12 for data that is almost entirely zero. - (b) Allocated lazily on a chunk's first damage, freed when its last damage clears.
- (c) A shared pool of N buffers assigned to whichever chunks currently have damage.
Decided: (b). DamageMap is sparse precisely because almost
no cell is damaged at once, and the same holds for chunks — a handful at a time, usually one.
(c) adds a bound and an eviction policy for a problem (b) does not have. Freeing goes through
section 8's deferred deletion queue like any other buffer.
Construction damage must survive a save/load cycle: a wall left half-hammered has to still be half-hammered next session. But there are two representations of it, and only one of them is state.
- The sparse
DamageMapis the source of truth, and it is what serialises. A chunk's entries are a handful at most, so it costs almost nothing in the world format. - The dense 32 KB GPU buffer is a derived render-side cache. It is never written to disk — it is rebuilt from the sparse map.
Which gives the lazy allocation in (b) a second trigger: a chunk becoming resident with damage already recorded allocates and fills its buffer at load, not just on the first strike.
It also means damage participates in the modified flag from section 8's question 4 — a chunk whose only change is construction damage still differs from generated and still needs writing back.
The lifetime is exactly (b) — a chunk takes a slot on its first damage and gives it back when its last damage clears or it leaves memory. What differs from the letter of (b) is that a slot is 32 KB of one growable arena rather than a buffer of its own, so binding a chunk's table is an offset rather than a descriptor write.
That is not the shared pool (c) rejected. (c) was turned down for needing a bound and an eviction policy; this has neither — the arena doubles when the high-water mark of concurrently damaged chunks exceeds it, freed slots go on a free list and are reused before it grows again, and the old buffer is retired through the deletion queue rather than freed under an in-flight frame. In practice it sits at its initial four slots, because "a handful at a time, usually one" is what actually happens.
Slot 0 is reserved, permanently zero, and belongs to no chunk. An undamaged chunk is drawn pointing at it and reads stage 0 everywhere — so "no damage" needs no flag, no shader branch and no allocation. It also makes the failure mode of a draw that forgets to set its offset undamaged, rather than wearing some other chunk's cracks, since binding a pipeline resets the offset to 0.
The cache rebuilds off two counters — DamageMap::version() and
VoxelWorld::residencyVersion() — so a frame where neither moved costs two
comparisons, and walking around does not re-upload a wall hit ten minutes ago.
Decided Q6 — Crack stages: how many, and shared or per-material
- (a) Four stages, one shared crack set for every material, blended multiplicatively over the base tile.
- (b) Per-material crack sets — stone cracking differently from wood.
- (c) Two stages — cheapest, but very coarse feedback on a high-toughness block.
Decided: (a) for now. And the shared half matters more than the count: cracks are geometry of fracture rather than material identity, so one set overlays every material — which makes the crack art four tiles total, not four per material.
Given D10 already turned three material bands into a full stratigraphy, that distinction is the difference between a bounded art task and one that compounds with every material added. (b) stays available as a later refinement if a material genuinely reads wrong.
The overlay multiplies over the finished albedo, so a texel the crack tile leaves at white passes the base tile through untouched — no alpha channel and no second blend state. The stage is quantised CPU-side on the way into the lookup, which is what keeps it one byte per cell and spares the fragment stage a toughness table.
The first pass at the art was far too dark (0.55 down to 0.10) and jittered every step. At
16×16 sampled nearest one texel is a large square on screen, so a crack can only ever be
blocky — and at that weight it stopped reading as a fracture in the surface and
started reading as a glyph painted on top of it. Lighter ink and whole-texel steps fixed it.
Placeholder art either way: gen_textures emits the four tiles, and
textures/cracks is on the hot-reload watch list.
Decided Q7 — The damage-clearing invariant
The rule is that any op changing a cell's occupant must clear its damage entry, or the next
occupant inherits stale wear. It holds today — removeVoxel is called with
&damage at Engine.cpp:404, and
strikeVoxel clears on break.
But it holds by discipline: the DamageMap * on
removeVoxel is optional with a nullptr default, and
placeVoxel has no damage parameter at all — so it could not clear a stale entry
even if one existed.
- (a) Leave it — correct today.
- (b) Make the parameter non-optional on
removeVoxel, and add one toplaceVoxel. - (c) Route every occupant change through a single function that clears damage as part of writing the cell.
Decided: (c), falling back to (b) if it proves awkward. This phase makes damage far more prominent and adds a GPU buffer that must stay in step with it, so a stale entry stops being a display glitch and becomes a visibly cracked brand-new block.
Same move as closing the Chunk::volume() bypass in phase 1
(risk 2, gap 2): make the failure unrepresentable rather than
merely avoided.
Decided Q8 — One tool, or two
- (a) One tool dispatching on
VoxelClass. - (b) Two tools, with a right-tool-for-the-job penalty.
Decided: (a), explicitly for now. Smaller change, and it keeps the current single-button interaction intact. (b)'s "both work, the wrong one is slow" needs an inventory to mean anything, and there isn't one.
But the deferred damage model above has several tool types, each with per-material modifiers. So this is a staging decision rather than a final one — which is exactly why Q2's damage scalar should be a function that can grow a tool parameter without disturbing its callers.
Decided Q9 — The health HUD and the highlight's wear source
A consequence nobody had noted: the HUD reads health/toughness from the
DamageMap and the face highlight lerps amber→red on damage.progress(). Under
Q1, terrain has no DamageMap entry at all — so without another source, both read as
undamaged forever.
- (a) Derive both from density for terrain — remaining strikes are
(density − 128) / (127 / toughness). - (b) Drop the numeric readout for terrain, derive only the highlight tint from density.
- (c) Drop both for terrain.
Decided: (b). The shrinking surface is a better progress bar than a number, and that argument is stronger now that the shrink is the headline feature of the phase — but the highlight tint is nearly free from density and still shows the cell is being worked.
Both stay for construction, where four crack stages over a high-toughness block means several hits between visible changes and a number adds real information.
Decided Q10 — Scheduling: the construction half moves earlier
- (a) Keep both halves in phase 4, after phase 3.
- (b) Move the construction half alongside phase 2.
- (c) Split into 4a and 4b without committing to when 4a lands.
Decided: (b). Two reasons. Its renderer interface addition is the
same pass as section 8's deferred deletion queue, and opening IRenderer
once is meaningfully cheaper than twice. And it is independently demonstrable — hammering a
wall until it cracks and breaks needs nothing from Marching Cubes — so it gives phase 2 a
visible payoff beyond "walking works".
Phase 4 is therefore the terrain half only, and it stays after phase 3 because the receding surface is the entire point of it.
Decided Q11 — A cell's lifecycle, and when it accepts a new voxel
Question 4 originally asked what happens to a terrain cell's density when a construction block is placed into it, and recommended discarding it. The question does not arise: a block cannot be placed into a terrain cell that still has density at all.
The lifecycle is explicit:
occupied → destroyed → empty → placeable. A terrain
cell whose density falls below the isovalue is destroyed — cleared to a genuinely empty
voxel, Voxel{}, which is Air at density 0 — rather than left sitting at some
residual value. Only an empty cell accepts a new voxel, of either class.
Why this is better than "discard on overwrite", which lands on the same bytes: destruction becomes an event with a single place to observe it, rather than a silent side effect of writing over a cell. That is the natural hook for everything the plan defers — drops, sound, and structural-integrity re-evaluation (D8) all want to know a cell was destroyed, and none of them want to discover it by diffing the grid.
It also converges with Q7: the single mutation path that clears damage is exactly where destroy-then-place belongs, so the two decisions produce one function rather than two.
Mined cells never sit at a residual value, because Q1 clears the cell at the
isovalue. But the generator produces sub-isovalue density above the surface: the
soft band is kBand = 40 per voxel, so the three cells directly above ground
carry density 88, 48 and 8 before anything touches them.
Those cells are born partially dense rather than destroyed, so a strict reading of "no density" would make them unplaceable — and you could not build within about three voxels of the ground, anywhere. Clearly not the intent.
- (a) Require density exactly 0 — breaks building near terrain, as above.
- (b) Placement destroys any residual sub-isovalue density first, zeroing the cell, then places into it.
- (c) Have the generator hard-clamp to 0 above the surface — which reintroduces a binary transition and violates D11's band-limited contract, giving cube-shaped terrain exactly where the player is standing.
Decided: (b). Placing into a sub-isovalue cell destroys the terrain voxel first, then places. It lands on the same bytes as the old "discard" answer, but the destroy step is explicit and runs through the same single mutation path — so the event fires, the damage entry clears, and the cell is genuinely empty before anything is written into it. The residual density it removes is a sliver of surface that the placed block covers anyway.
So the placement gate is emptiness, not solidity. Today
placeVoxel refuses only when isVoxelSolid — which is
density ≥ 128 — and would otherwise write straight over a cell holding density 1
to 127. Under this rule it instead destroys that cell and then places, which is the same
outcome reached deliberately rather than by the residual density going unnoticed.
What this adds
| Kind | Lands in | |
|---|---|---|
| Q1 density per strike | Arithmetic on an existing byte | Phase 4 |
| Q2 one damage scalar, as a function | Shape for a deferred model | Phase 4 |
| Q3 Edit mode clears the cell | Ordinary | Phase 4 |
| Q4 per-chunk damage lookup | Interface change | Phase 2 |
| Q5 lazy buffer, sparse map persists | Ordinary + format | Phase 2 |
| Q6 four shared crack stages | Assets | Phase 2 |
| Q7 single mutation path | Small refactor | Phase 2 |
| Q8 one tool for now | Ordinary | Phase 4 |
| Q9 tint from density, no number | HUD + overlay | Phase 4 |
| Q10 construction half moves | Scheduling | — |
| Q11 destroy → empty → placeable | Same function as Q7 | Phase 4 |