Engineering history
Why this version
Nine mechanics in this project ship as two or three generations at once, all still selectable. This page is the argument for each current default: what the old version actually did, the exact way it failed, what replaced it, why that fixes the cause rather than the symptom — and what is still wrong with the one you are running now.
Companion pages
Source of record: data/changelog.json.
Where the changelog and the code disagree, the code wins and this page says so.
Preface
How to read this page
Why old versions are still here
The project convention is that an improvement ships as a selectable version and the old one is never deleted. That is not sentiment. Every one of these rewrites was justified by an A/B that a person had to be able to run, and several of the old paths still own mechanisms the new path never ported. A toggle that is only ever left at its default is a toggle that was never worth the cost; each section below therefore ends with a concrete reason to move it.
Almost every entry in the changelog carries "verified": false. That flag means
the numeric bounds were machine-checked and the build ran clean, but the question of whether
the thing reads right was deferred to a human looking at it. Treat the numbers as
measured and the aesthetics as claimed.
What the badges mean here
A pattern worth naming up front. Most of these rewrites replaced a compensator with a cause fix. The legacy magnetic force multiplied a wrong field by five gates; the legacy ash regrowth had two shaders writing one buffer and tuned the thresholds instead; the legacy contact heat could not cool, so a second one-way channel was bolted on to cool. In every case the tuning log embedded in the shader comments is the tell: when a comment reads like a history of threshold changes, the layer underneath it is wrong.
Magnetics · three backends
Ferrofluid: from a truncated Poisson solve to a closed-form field
A ferrofluid needs a field H everywhere a particle might be, so it can feel the Kelvin body force F = ½χ∇|H|². Getting that field right in 2D is the whole problem: solve it on a grid and you pay for accuracy in iterations; evaluate it analytically and you give up the contribution of the magnetized matter itself. Both mistakes were made here, in that order, and each produced a distinct, nameable artifact.
The generations
| Generation | What it did | The symptom you saw | Root cause |
|---|---|---|---|
| Pre-April 2026 gone |
Per-particle magnetization from langevin_response(), which dodged the
coth(0) singularity by flooring the argument:
x = max(alpha * hmag, 1e-4). |
Ferrofluid clumped into lumps and then dots with no external field at all — cursor off, no scene magnets, ambient H = 0. A puddle that should have been a plain viscous fluid gathered itself. | The floor is applied per particle, but the rasterizer sums M into shared cells. At the floor, L(x) = x/3 = 3.33e−5, so N co-located particles create roughly N·sat/30000 of cell magnetization out of nothing. That non-zero source closed a feedback loop with no entry condition. Only the high-x branch ever needed divisor protection. |
| Legacy backend still default selectable |
Cold-start Jacobi Poisson on 1024², run seven times per frame
(scene, drive, three induction passes, remanent particles, total). On top of the solved field,
magnetic_body_force() multiplies the Kelvin force by five stacked heuristic gates
before a per-material acceleration cap. |
The project's number one complaint, verbatim: "moving the magnet doesn't pull the ferrofluid; it affects a ring, then a new ring; most material doesn't follow." A held, stationary magnet also did not hold — the gathered blob slumped and fell. | Two independent causes, and the ring is where they meet.
(A) Truncation: φ is cleared to zero before every solve and Jacobi moves
information one cell per iteration; at the runtime setting of 52 iterations (the header's 144
is overridden), |H| exists only about 10 to 20 cells out and is exactly zero beyond,
recovering under 0.4% of the 1/r² tail. Converging that grid needs roughly 30k to 490k
iterations. And because φ restarts cold, moving the cursor drops the old field entirely.
(B) The gate stack: ext_drive closes far from the pole,
hf_suppress divides by field sharpness so it closes at the pole,
wall_gate multiplies by 0.22 within about 2 cells of an SDF surface (which is where
a ferro pond rests), density_damp cuts the inward force to 8% inside a blob,
and sink_cancel subtracts up to 92% of the rest. Their pass-bands overlap only
on a thin annulus. (C) Holding was pure velocity entrainment toward the cursor
velocity, with a gate that floors at 0.35 when the cursor is still — so a stationary held
magnet drives particle velocity toward zero. It brakes. |
| Ferro D1 selectable |
Five opt-in patches rather than a rebuild: an M Raw debug view, a runtime
256/512/1024/2048 grid selector, capillary halved 0.34 to 0.18 with spike strength raised
1.0 to 1.6, surface-only corrugation, and an explicit apply_distance_falloff
offered as a cleaner replacement for the ad-hoc gates. |
Spikes still did not appear; the ring survived every setting. | D1 tuned the constitutive and surface layer, and added a sixth composable gate, while the field underneath was still truncated and cold-started. Raising spike strength cannot manufacture a spike lattice when the surface has no symmetry-breaking perturbation to amplify. Its own changelog entry pre-registered the failure: "D1 is the experiment, D2 is the principled rebuild… if no spike-like behavior appears, escalate." |
| New Magnetic v1 folded in |
Deleted the solver from the drive path. Cursor presets and SDF magnets are decomposed on the CPU into at most 32 signed 2D pole kernels and evaluated in closed form per particle: H(p) = ambient + ∑ qi(p−xi)/(|p−xi|²+ci²). One plain Kelvin force, one per-material accel cap, no spatial gates. Holding became a real positional spring-damper. | Not a failure, a deliberately narrow v1 — but the field looked too clean: two magnetized bodies did not distort each other's pull, no dipole chains formed, and a magnet over a pool produced a smooth dome instead of a Rosensweig spike row. The field overlay did not work at all, because it sampled a texture this path never produces. | The analytic pole list contains only external sources. Magnetized matter contributed nothing to the field, so a neighbour's M was simply not in the force gradient. |
| New Magnetic (current) selectable |
Three additions closed the v1 gaps. (1) Per-particle M state in its own SSBO, with per-material relaxation and thermal decay — hard magnets keep M, Curie ferro fades past about 680 K, superconductors expel field below about 180 K. (2) NM Self-Field: particle M is scattered onto a 256² grid tracking the MPM grid and solved with a warm-started Jacobi (32 iterations, φ deliberately never cleared). (3) Rosensweig spikes on that field, with a corrugation seed. | Current. Its live defects are listed below, not fixed by a later generation. | The structural decision that makes the self-field safe is an
asymmetry: H_here = H_at(pos) excludes the demag texture
and is the only thing that magnetizes M; h2_at() includes it and is the
only thing that feels it. The self-field enters the force and never the magnetization. |
| Fantasy v1 selectable |
A byte-for-byte clone of New with its physical safety switches deliberately flipped to wild. M is magnetized off the total field so activation propagates edge to edge; a write gate latches M under a weak field, giving remanence; an orbital vortex makes matter spiral rather than clamp; chain boost 2.2×, self-field 1.6, spike strength 3.0 against New's 1.0 / 1.6 / 9.0. | A permanent-magnet body that had been magnetized and then left alone strobed — its colour and its overlay membrane oscillated frame to frame. Legacy and New never flickered. | Follows directly from the design choice. For a latched body with the cursor
away the external field is near zero, so Htotal is dominated by the body's
own demagnetizing field, which by definition points opposite its M. The target
langevin(|H_total|) * dir(-M) tried to reverse M every frame; the demag field
reversed with it. The bug and the feature share one mechanism, which is why the fix had to be
a coercivity model rather than a retreat to the external field. |
| Fantasy + Anti-Flicker selectable |
A coercivity rule that forbids the self-field from reversing a latched M while
leaving a strong external field free to re-magnetize. The target is decomposed along
and perpendicular to the previous M; the reversing component is clamped out
(max(along, 0)), the perpendicular swing is damped to 0.6, and the raw target is
blended back by smoothstep(0.15, 0.60, |H_external|). |
Current for Fantasy. Latched bodies settle and hold; propagation and activation strength still come from the total field, so the wild behaviour survives. | A heuristic ratchet, not hysteresis — see the caveats below. |
Why the current backend is good enough
It removed a cause instead of adding a compensator. The field is now a closed-form sum of at
most 32 softened 2D pole kernels evaluated per particle: exact at every distance, zero solver
cost, zero lag, and structurally incapable of truncating. Once the field was exact, all five
gates became unnecessary at once, because every one of them had been tuned against the
truncated field. The ring had an outer edge (Jacobi never reached there) and an inner edge
(hf_suppress and sink_cancel closing at the pole and inside blobs);
both edges disappear when the field is right. What survives as force shaping is one
per-material acceleration cap, so the falloff you see is physics rather than tuning.
The collapse those gates were also fighting was independently re-diagnosed as a Langevin feedback runaway rather than mechanical compression — M read off the full field grows M — and that loop is now broken structurally rather than damped. The dead numerical seed is gone at the source. Holding became a spring-damper, so a stationary held magnet holds instead of braking.
It is also two orders of magnitude cheaper: seven cold-start 1024² solves at 52 to 96 iterations per frame (roughly 380 to 700 million Jacobi cell updates) became one warm-started 256² solve at 32 iterations (about 2.1 million), and that solve accumulates convergence across frames instead of throwing it away.
What is still wrong
- The biggest one is a regression the other docs do not mention. Selecting
New or Fantasy sets
suppressed_by_new_backend, which makesphysics_active()return false, which setsu_use_real_magnetics = 0in both p2g and g2p. That silently switches off five of the seven anti-compaction mitigations the status page still advertises: the demag pressure, the occupancy anti-collapse pressure,hf_suppress, the positional cursor gate and the per-cell M cap (CELL_M_CAP = 28), plus the legacy surface force and chain force. Under New and Fantasy, compaction is held by the unconditional Taitj_flooralone (0.85 ferro, 0.90 heavy, i.e. 10 to 15% maximum compression) and one accel cap. Ferro compacts more than physical under a strong source is therefore not fixed by the current backend; the stack that used to fight it is off and a blunt clamp replaced it. The project's own remedy is a slider. - A world-up bias. With poles off and ambient zero but self-field on,
field_dirfalls back to a hardcoded (0,1) andchi_eff = dot(m, field_dir)/M_REFcollapses tom.y/M_REF. A remanent body whose M points sideways gets near-zero force; one pointing downward is repelled from its own gradient. - The demag solve lags. Warm-started but never converged (32 iterations, 256² over the whole MPM grid, far coarser than the legacy 1024²). Chain spacing and spike sharpness are resolution-limited, and fast material outruns its own field. Truncation was traded for lag, not eliminated.
- Silent pole overflow.
kMaxPoles = 32with an early return; permanent magnets cost two poles each, so past about 16 magnetic SDF objects the extras contribute literally nothing, with no warning. - Soft iron does not self-consist. It is a single-pass induced estimate evaluated in pole-insertion order, so two soft-iron pieces near each other never mutually magnetize. The legacy ran three induction passes for exactly that.
- Fantasy's anti-flicker is a ratchet, not hysteresis. There is no per-material coercive field and no switching model, so the 0.15 / 0.60 thresholds are global constants applied identically to a hard magnet (saturation 22) and Para Mist (1.0). A latched M can rotate but effectively cannot shrink along its own axis under the self-field alone, so a body can hold a physically unjustified magnetization for a long time. Fantasy also runs self-field 1.6 and chain boost 2.2, which makes it the generation most likely to over-compact.
- The default is still Legacy (
g_magnetic_backend = 0). Out of the box you get the ring, despite the consolidation proposal recommending New as canonical. Only the combo, or a scene preset carrying"magnetic_backend": 1, switches it. - Doc drift, code wins. The backend combo tooltip still claims New does not yet do per-particle remanence, Rosensweig spikes, or the field overlay — all three shipped. The status page's Heavy Ferrofluid row still lists saturation 14 and alpha 3.0 while both shaders use 8.0 and 2.0 (its own caveat list, four lines above, has the correct numbers).
- A deliberate 2D compromise. The pole kernel falls off as 1/r for a monopole and 1/r² for a dipole pair, not the 3D 1/r³. Consistent for a 2D world, but no ring is partly bought with the magnet reaches the whole world.
- Vestigial, harmless.
magnetic_rasterize.compstill carries the oldmax(alpha*hmag, 1e-4)form. It cannot reproduce the clumping — its only call site is guarded byhmag > 1e-5and applies to SDF shell pixels, not thousands of particles — but it should go.
When you would still pick an older version. Four real reasons, not nostalgia.
(1) Legacy is the only way to see the ring and the drop-on-move, which is the
demonstration the whole rebuild exists to justify. (2) Six mechanisms are legacy-only and go
completely dead under New and Fantasy because they are gated on
u_use_real_magnetics: demag pressure, occupancy anti-collapse pressure, the per-cell
M cap, the interface Maxwell shaping in ferro_surface_force, the legacy dipole chain
force, and hf_suppress. If a scene depends on demag-pressure anti-packing, New has no
equivalent. (3) The Mechanic Toggles panel exposes each legacy gate individually, which is how you
attribute a symptom to a specific gate; New has no analogue. (4) Legacy is the only backend that
produces a real solved field texture, so every debug view that samples one — M Raw,
the source texture, the magnetization texture — works only there.
Fantasy versus New is not old versus new at all. Pick Fantasy when you want the game feel — activation that propagates and latches, orbiting matter, bold chains, arcane colour — and New when you want the physically defensible answer. Fantasy is a deliberate sibling, not a successor.
The A/B to run. Load Ferro Spike Bench (it already sets
magnetic_backend: 1, self-field on, spikes on), let a pool settle, then flip
Magnetics → Magnetic Backend between the three entries with the same pool and the same
cursor motion.
- The ring. On Legacy, hold a magnet above the pool: only a thin annulus moves, the fluid directly under the pole and everything past about 10 to 20 cells sits still. Sweep sideways and the gathered blob is dropped in place while a new ring forms at the new position. On New the whole pool leans toward the cursor and follows continuously.
- The hold. Gather a blob and stop moving. Legacy: it slumps and falls. New: the spring-damper holds it at the cursor.
- The self-field. On New, toggle NM Self-Field off — the spike row collapses into an undifferentiated mound and two magnetized bodies stop distorting each other's pull. NM Spike Spacing changes the lattice pitch.
- The 1e-4 bug. Select Legacy, set cursor strength 0, no scene magnets, Ambient H = 0, and watch a puddle. Today it stays a viscous puddle. The pre-April build that lumped under zero field is preserved in the archived snapshots for comparison.
- Fantasy. Sweep the source over a permanent-magnet body: the glow propagates edge to edge and holds after the source leaves; hold it over ferro and matter orbits in a vortex instead of clamping on. Then turn FM Anti-Flicker off, magnetize a body and move the cursor away: colour and field membrane strobe frame to frame. Turn it back on and it settles.
- The hidden regression. On Legacy, watch the demag-pressure and occupancy
anti-collapse toggles do something. Switch to New: those toggles no longer do anything at all,
because
u_use_real_magneticsis 0 on that backend.
Living material · four modules, three selectable
Growing tissue: what actually produces new area
A living material has to answer two separate questions — where should the body be, and how does material get there — and every failure in this family came from answering them with one mechanism. Growth also has to terminate: a body that keeps growing is not an organism, it is a leak.
The generations
| Generation | What it did | The symptom you saw | Root cause |
|---|---|---|---|
| Colony · spawn-first selectable |
A dedicated 128² life grid with occupancy, a diffusing energy field sourced by placed cores, and a selectable SmoothLife or Gray-Scott guide. Growth is particle spawning: a frontier cell that is wanted, fed and empty atomically claims a slot, and the CPU expands it into a 2×2 cluster of new cells. Energy is debited at each spawn, so a colony cut off from a core runs down; cores latch dead below vitality 0.06, making "burn every heart" a permanent kill. | The body did not grow, it appeared — discrete 2×2 clumps popping in at the edge (the changelog's "strange cell-generating look") — and it ate the particle budget at 256 cells × 4 particles every 3 frames. | Making discrete particle insertion the primary motion makes the visual quantum of growth a whole cluster. There is no continuous mechanism between nothing and four new particles. |
| Colony · expand + fill selectable |
Expansion becomes primary, spawning demoted to gap-fill. A per-cell
growth_drive is baked into phase data and read by the constitutive branch as
J_rest = 1 + phase_data, so fed cells push outward continuously; the frontier scan
can now only repair already-living cells that thinned. |
It grows forever. The body inflates smoothly and pleasantly and keeps inflating until it hits the soft particle cap or the energy radius. There is no state at which it is done. | Named in the design spec that replaced it: "the current EXPAND_FILL growth
never saturates". The drive uses max(want, sparse), and the edge of a spreading body
is permanently sparse, so the drive never turns off. The first cut also spawned at the empty
edge, compounding spawn front and expansion into a 36k-particle runaway before the predicate
was narrowed. |
| Bloom · bounded selectable |
Eases each cell's rest-volume excess toward a fixed target and holds there; spawning fires only in a genuine interior tear (an empty cell with tissue on opposite sides), never at the advancing edge. The same entry also made the living materials burnable at all — they had been absent from the thermal gate, so their temperature literally never moved and fire did nothing to them. | It inflates once and is then inert. A balloon, not an organism. Roughly four tear repairs across a whole verified run at about 725 particles. | The bounded target has no guide term and no occupancy term at all. The pattern engine is still computed every frame and contributes nothing to a bloom's shape. Boundedness was achieved by removing the feedback rather than closing it. |
| Automata selectable |
Split the two questions properly for the first time: a SmoothLife field that is an automaton on itself (so it self-bounds) decides where, and a purely geometric ring rule decides whether to insert — one particle into an empty cell encircled by at least 6 of 8 occupied neighbours. A bio force is the link: particles drift up grad(field − occ) into the field's empty birth ring. Two distinct burn end-states as separate materials: hard brittle ash that shatters, and soft wood char. | The ring rule essentially never fires: 8 insertions in 2520 frames (about 21 s at 120 Hz) on an 882-particle blob. Tissue thickens and drifts but never develops the porous structure the field preview promises. | The field is force-fed the body's own shape: target = max(target, occ),
then an ash boost on top. The field can never dip below occupancy inside the body, so no
interior hole can be carved — only torn open mechanically — and the ring
rule then demands that torn hole also be ringed by 6 of 8. Compounding it, the kernel is a toy:
17 inner plus 32 ring taps at radii 2 and 5 cells, which can only low-pass occupancy into a
blob. |
| Culture default |
A 256² module with a faithful port of a reference SmoothLife: 489 taps at radius 13, an order of magnitude more kernel, and critically not max()'d with occupancy. Occupancy only confines the field (a blurred mask multiplies it) and nucleates it (hash noise injected where tissue exists but the field is still flat). The coupling is inverted: instead of asking for particles to be inserted into holes, every particle is pushed up grad(field) onto the membrane ridge, so holes evacuate and the material inherits the porous structure. Sculpt mode is mass-conserving; grow mode inserts only on membrane cells adjacent to tissue, under a hard lifetime budget. | Current. Its own first cut ran away to the 100k cap because the density target was never reached, so the same cells re-fired forever. | That took three stacked guards to bound — a build-once density threshold, a frontier adjacency gate, and a hard lifetime counter — rather than one principled bound. Worth knowing before trusting grow mode in a new scene. |
Why Culture is good enough
It is the first generation where the pattern engine and the material are coupled in the direction that works. Colony asked spawning to be the growth (quantised, popcorn); expand+fill asked inflation to be the growth (never terminates); Automata asked the geometry to find holes the field never carved (8 firings in 21 seconds). Culture separates them: a properly resolved continuous automaton — 489 taps at radius 13, with occupancy confining and nucleating it but never overwriting it — genuinely produces membranes and holes; then a gradient force drags existing matter onto the ridges.
That is the whole argument. The porous structure is achieved by rearrangement, which is mass-conserving and therefore bounded by construction, and insertion becomes an optional budgeted extra rather than the mechanism. Sculpt mode cannot run away; grow mode's bound is a counter, not a tuned feedback loop.
The regrowth rule is deliberately asymmetric — living tissue heals to full unless actually hot, dead tissue converts back only where green is nearby — so green always wins at a boundary and a burn scar heals as a visible front. Every earlier heal/starve pair diluted into a grey equilibrium instead. The project confirmed the result in-app before building on it, and tagged a checkpoint at that state.
What is still wrong
- It cannot die of anything but heat. The starve rate is still passed in, and the shader marks it unused: living tissue is sticky, only burning kills — while the panel tooltip still tells you the body dies if you do not beat the starve rate. That tooltip is false.
- No kill switch. Culture has no cores and no energy budget, so it lost Colony's best mechanic: a permanently latched dead core. Any surviving green reclaims any adjacent white, so the only kill is burning 100% of the body.
- The changelog overstates the material transition. It says burnable tissue
becomes a brittle husk material; the code never changes a material id — the husk is a
vitality <= u_dead_floorbranch (default 0.15) inside the tissue material, and the husk id is only ever placeable by hand. - The revival field is not self-limiting, contrary to the changelog. Its source is the blurred living occupancy; the husk texture is read only by the stats pass. Ash gating happens per particle, not in the field, so the field is computed over empty air around any green tissue.
- Hidden coupling. One mask texture is used both as the field's confinement mask and as the revival field's green source, so moving Confine Radius silently changes regrowth reach. No tooltip says so.
- It only reads correctly at low gravity. The showcase scenes hard-code −1.5 g with the comment "so the porous structure shows". At −9.81 the membrane impulse (clamped to 1.8) loses to gravity and the tissue puddles.
- It is the most expensive backend: 489 taps × 65,536 cells per frame, 8 to 14 ms/frame against Automata's 6 to 8.
- Grow-then-hold is a lifetime counter, not physiology. The counter resets only when the module is disabled, so a body cut in half can recover vitality but never mass.
- Discoverability. The Creation menu still lists all eight living materials, but placing a Colony core in a default scene does nothing, because scene load forces the Version selector back to Culture and disables the other two modules. Only one tooltip mentions the Version dropdown at all.
When you would still pick an older version. Colony + Bloom is the only generation with cores, metabolic energy and a permanent kill — "burn the heart and the creature stays dead" exists nowhere else. It is also the only one whose growth follows level geometry (a bias toward SDF surfaces) and the only one offering Gray-Scott mitosis as a growth guide; within it, Bounded organism is the cheapest way to get a fixed-size organic body (about 725 particles, about 4 spawns for a whole run). Automata is the cheapest per frame and the only one with two distinct burn end-states as separate materials — hard brittle ash that accumulates fracture and shatters into chunks, and soft wood char — plus an ash accelerant that makes regrowth race into dead ash. If you want a shatterable burnt organism rather than a porous one, Automata is still the right pick.
The A/B to run. Scene load sets the backend to Culture and disables all three modules every time; only the five legacy showcase scenes override it.
- Load Showcase: Living Culture and open Advanced → Living Material. Three live
previews: field, occupancy, revival. The field should be bright rings around dark holes, and the
occupancy preview should have holes in the same places — that is the membrane
force working. Watch
grown: N / 4000climb and then stop. - Switch Version to Automata [legacy], tick Enable, load its showcase. Its single field preview is a smoothed copy of the body silhouette with no interior holes, and last insert sits at 0 for tens of seconds. That is the ring rule not firing, visibly.
- Switch to Colony + Bloom [legacy] with Growth Mode = Spawn-first. The particle readout jumps in blocks of hundreds and the body pops in as 2×2 clumps. Change to Expand + fill mid-run: spawning collapses to 4 to 16 per tick and the body inflates smoothly — and never stops until it hits the soft cap. Set Bounded organism and it swells once and freezes.
- Burn test: heat-gun a corner of Culture tissue. Plain tissue burns only where you point and the green sweeps back as a front; the flammable variant self-heats and the whole blob goes. Uncheck Revive first to see the burnt patch stay white forever — that is the A/B for the regrowth field itself.
Ash regrowth · two generations
Two shaders, one buffer: an ownership bug wearing a tuning bug's clothes
This family is small and worth reading anyway, because it is the cleanest example in the project of a defect that no amount of threshold tuning could have fixed. The goal is modest: burnt ash next to living tissue should come back.
The generations
| Generation | What it did | The symptom you saw | Root cause |
|---|---|---|---|
| Legacy superseded |
Two independent state machines wrote the same two per-particle buffers every frame. p2g integrated regrow and ash from temperature alone (heal below 360 K, burn above 540 K) and stored them; g2p then integrated the same two channels from a field-gated neighbour rule and stored them again. g2p runs last, so g2p won every frame. | Burn a corner off an ash block, let it cool: the black patch either sits there or briefly lightens and then goes dark again. After you release the heat gun it idles for a long time doing nothing at all. | Three defects stacked. Fighting: g2p ran second and relaxed regrow toward a near-zero field target, stalling or reversing p2g's temperature heal. Dead code: the whole g2p neighbour branch sat behind a bio-field toggle that was off by default and reset to false on every scene load, so in ordinary scenes the rule never executed at all. Band mismatch: p2g healed below 360 K while g2p's ash gate faded from 365 K, leaving a recovery-critical window where neither machine progressed. The p2g comment block still preserves the archaeology (420K was too low, raise to 540K, widen the neutral band) — a log of failed tuning against an ownership bug. |
| Living Regrowth v2 default |
Single-owner state. p2g still computes its values for this frame's stress but defers the writes; g2p owns the buffers. Impact crumble stays p2g-owned, because g2p does not compute it. The neighbour branch is forced on rather than depending on legacy toggles. "Living nearby" is redefined as a robust locality envelope, seeded by both ash and living tissue and surviving fire heat. The temperature band collapses to a single ramp over 380 to 420 K, eliminating the dead zone. The integrator is an explicit per-second rate (0.25/s, boosted up to 2.5× when adjacent), so roughly 3 to 8 s to full. Finally, the Eulerian bio passes auto-enable whenever the solver reports living materials present. | Current and default-on. | The remaining defects are scoping, not correctness — see below. |
Why v2 is good enough
The legacy failure was not a tuning problem, it was an ownership problem: two writers to one buffer, with the loser recomputed from scratch every frame. That is why the shader comment reads like a log of failed thresholds. v2 makes g2p the sole owner and leaves p2g's values as read-only intermediates for the current frame's stress. The second fix is equally structural: the neighbour rule had been gated behind a toggle scene loading reset to false, so the feature was unreachable in normal play; v2 forces the branch on and auto-enables the bio passes from spawn-time material detection, so it works without the user knowing a bio field exists. Aligning the temperature bands — the only part that looks like tuning — only became possible once there was a single owner to align.
What is still wrong
- "Same object" is a proximity heuristic. The changelog admits it: no per-particle object id exists. Two unrelated ash bodies touching will heal each other.
- The detection latch is sticky. It is cleared only by a full particle clear, so once any living material has existed in a session the Eulerian bio passes stay on for the rest of that scene even after the material is gone.
- It costs work the default configuration does not use. The detection list includes the Culture tissue materials, so placing Culture tissue flips the bio passes on — but nothing in the Culture backend reads that field. Culture has its own 256² revival field.
- Narrower scope than the name suggests. The v2 branch applies to the two ash-regrowth materials only. Morph Tissue and Cell Sheet are the neighbours that make ash regrow; they do not regrow through this path themselves.
- It is state recovery, not resurrection. No material-id transition and no particle spawn, so a fully destroyed region cannot come back.
- No measured regrow curve. Only a smoke-test recipe was recorded.
When you would still pick the older version. Never for behaviour — the legacy path is strictly worse. The toggle survives as a diagnostic bisector: if regrow or ash values look wrong, turning v2 off tells you whether the g2p neighbour path or the p2g temperature path produced them, and Force Bio Passes separately isolates whether the problem is the field gate or the two-writer conflict.
The A/B to run. In an ordinary scene (not a bench), place an ash-regrowth block directly beside Morph Tissue or Cell Sheet. Burn part of the ash, release, let it cool. With v2 on the burnt region darkens and then visibly regrows to alive in roughly 3 to 8 s, bulging slightly past its original outline; raise Regrow Rate to move the front faster. Untick Living Regrowth v2 and repeat: either no neighbour regrowth at all, or a front that starts and stalls. Then turn Force Bio Passes on with v2 still off — the passes now run, so whatever stall remains is the two state machines fighting, not the dead gate.
2026-09-19 qualification: the nonlocal damage path exists, but
l0 = profile_ratio × scale × dx in shaders/physics/mpm_p2g.comp.
Keeping the ratio fixed changes the physical length with resolution. References below to
resolution-independent crack width or speed describe an intended property, not a demonstrated
result. See the current audit.
Fracture · three models
A threshold makes a scatter of failures; a length scale makes a crack
Breaking a solid in MPM means deciding, per particle, how much of its stress to keep. The hard part is that a crack is not a property of any single particle: it is a front, and a front needs its neighbours in the equation. The legacy model had no neighbour term at all, which is why its cracks had no tip and why their speed depended on the grid you happened to be running.
The generations
| Generation | What it did | The symptom you saw | Root cause |
|---|---|---|---|
| Legacy still default selectable |
Per-material, hand-tuned strain thresholds on the singular values, replicated roughly 35
times across the constitutive branches with their own magic numbers. The glass branch is
representative: crack += max(tensile*1.6 + shear*1.2 - thresh, 0) * rate * dt, then
integrity = max(1 - crack*0.82, 0.18) multiplying the already-summed stress. |
What looks like a crack is a scatter of independently failed particles. Tears go limp and mushy instead of stringy. The same scene cracks at a visibly different speed if you change the grid resolution. | Four separate problems. The drive was singular-value stretches, not energy or stress — no fracture energy, no critical stress anywhere in the code. There was no regularization, so no crack tip, no propagation front, and a crack width equal to the particle spacing, which is exactly why the crack speed tracks the resolution. Degradation was linear with a large floor (18% of the stress survives full damage) instead of (1−d)². And it multiplied the already-summed stress including the fiber term — the direct cause of limp tears — while the drive itself was essentially isotropic even though fiber directions were driving orthotropic stiffness. |
| AnisoMPM isotropic selectable |
The paper's own controlled reference at α = 0: an anisotropic driving force Φ = (Aσ⁺):(σ⁺A)/σc² from a structural tensor built on the polar rotation, an elastic threshold below which no damage accrues at all, a monotone driving-force history so cracks do not stall mid-propagation, and explicit damage evolution with the non-local term ηḋ = ⟨(1−d)D̃ − (d − l₀²∇²d)⟩. | Selectable, off by default. This mode exists so that switching mode 1 to mode 2 changes directionality only, at matched toughness. | Getting that matched-toughness property right took a correctness pass: the critical stress had been normalized hydrostatically while the stress it was compared against was uniaxial, so the isotropic mode needed √2× more stress than authored. Mode 1 to mode 2 therefore changed toughness as well as direction, defeating the entire point of having a control. |
| AnisoMPM anisotropic selectable |
α = −1, where the structural tensor is the cross-fiber projector, so along-fiber tension cannot drive damage at all and cracks run along the grain. Degradation g(d) = (1−d)² is applied selectively: shear always, volumetric only in tension (so fragments push apart instead of interpenetrating), the fiber term never — intact fibers surviving a failed matrix is what makes a tear stringy. | Selectable, off by default. Enrolled so far: the orthotropic tear material, brittle and tough. | The legacy orthotropic drive
(cross*3.2 + shear*1.8 - along*0.80) was an unnormalized hand-rolled approximation
of exactly the projector this formalizes. |
Why AnisoMPM is the right model, and why it is not the default
The single load-bearing change is the non-local term. Everything else — an energy-based drive, an elastic threshold, a monotone history, (1−d)² degradation — is correctness housekeeping that a careful person could have hand-tuned into the legacy branch. The Laplacian cannot be: it introduces a physical length l₀ into a model that previously had none, and that is what converts a threshold heuristic into an actual propagating crack with resolution-independent speed. It also makes the anisotropy real, because the structural tensor is built on the polar rotation rather than the deformation-gradient pushforward, which would contaminate direction with stretch.
It stays off by default because the shipped v1 had six real defects, all found by a re-run
adversarial review after the first review was cut off mid-flight. The most instructive: the
damage transfer was not weight-normalized — the scatter early-returned on
d ≤ 0, so the denominator accumulated weight only from already-damaged particles, making
the node value a mean over damaged particles that intact neighbours never diluted, and making
the free-surface fallback fire for nodes full of intact material. All four review lenses
converged on that one line independently. The gather's Laplacian was also a hand-rolled 5-point
stencil centred on floor(cell_pos) while the scatter deposits on
round(cell_pos), so for about half of all particles it was sampled a full node
down-left, and the offset flipped discontinuously as a particle drifted across each half-cell
line. A third defect is a good warning for this codebase generally: the per-particle damage aux
was cleared only in init(), but the particle buffer is a bump allocator whose
rewind does not re-run init, and the driving-force history is monotone — so a respawned
particle could be born at maximum driving force and damage itself to failure under no
load.
Storage deserves a note, because it is a reusable trick here: both p2g and g2p sit
at the 16-SSBO driver cap, so the per-material fracture profile rides a
samplerBuffer and the per-particle damage aux rides an imageBuffer
— image uniforms live in a separate binding namespace. That escape hatch is now used by
three subsystems on this page.
What is still wrong
- Enrollment is real work, not a data edit — and the first changelog entry said otherwise, which the correctness pass explicitly retracts. p2g has exactly two enrolled hooks, which is why exactly three materials work. Ceramic, splinter, tear-skin, laminated pastry, stoneware, composite and aniso each have their own constitutive branch with no hook, so setting the allow bit would enrol them in the scatter and gather — polluting the shared damage grid — while p2g never ran the damage step.
- The damage grid carries no material key. Damage can diffuse across a contact between two different enrolled materials under zero load.
- The free-surface test is a magnitude test.
den < 1e-8cannot express "negligible weight" given the 1e−6 fixed-point quantization. - Φ is exactly invariant to thermal softening, because σc and the stress scale with the same moduli. That is arguably correct for a strain criterion, but the code comment claims otherwise, so one of the two is wrong.
- A claim in the record did not survive measurement, and it is left standing on purpose. The verifier argued Lap(d) was identically zero for an isolated damaged particle, i.e. that the regularization was inert. It did not reproduce: a seeded probe gives min −811 / mean −327 with the bug against −604 / −201 with the fix — nonzero and correctly signed in both, because the idealized isolation is destroyed within a frame or two. The real effect was that the length scale came out 1.5 to 2.8× too large, not that the term was dead. Fixed anyway, because it is not what the spec prescribes.
- Nine lower-severity findings were capped unverified when the review run hit a usage limit, and live A/B in the tearing-lab scenes — the actual calibration step — has not been done.
- Doc drift. The status page still says phase-field brittle has no Laplacian regularization and calls the AT2 PDE a future improvement of about 300 lines. That is essentially what shipped here, for the enrolled materials.
When you would still pick Legacy. For everything except the three enrolled materials, Legacy is the model — there is no alternative branch to select. Beyond that: Legacy is byte-identical off (the CPU gates both damage dispatches entirely, so it is also strictly cheaper: zero GPU cost, two fewer passes per substep), and its per-material hand tuning encodes a lot of look development that the AnisoMPM profiles have not been calibrated against yet. If a scene's brittle or glass shatter reads right today, switching fracture models will change it. Mode 1 against mode 2 is the interesting comparison; mode 0 against the rest is a different material model entirely.
The A/B to run. Take a tearing-lab scene with the orthotropic tear material and pull it apart with the cursor. On Legacy, watch the failure region: a widening mush with no leading point, and the tear goes limp because the fiber term is degraded along with everything else. Switch to AnisoMPM: anisotropic and repeat: the crack acquires a tip, and because the fiber term is never degraded you get stringy fibrils bridging the gap. Then switch to AnisoMPM: isotropic with the same toughness slider — the crack should be equally easy to start but should no longer prefer the grain direction. That pair is the controlled experiment. To see the resolution dependence directly, run the same scene at two grid resolutions on Legacy and time the crack across the plate.
Stiffness · three models
Why everything was too soft: a catalog written for the wrong world size
A one-metre column of material has to support ρgH at its base, which in this 6.4 m world is roughly 10 to 20 kPa. The material catalog was authored at 4 to 12 kPa. That single mismatch produced most of the project's "everything feels floaty" complaints, and two of the three generations here are attempts to fix it without retuning the catalog.
The generations
| Generation | What it did | The symptom you saw | Root cause |
|---|---|---|---|
| Legacy v1 selectable |
Plain fixed-corotated elasticity with a deformation-gradient singular-value clamp to [0.5, 3.0] and a stress-norm ceiling at roughly 0.8×E. | Collapse into a line. Drop a heavy ball on a brittle pillar and past about 50% compression it keeps compacting with no growing resistance. Spawn a rope or fiber horizontally and it flattens to a literal line. | The clamp and the ceiling together saturate the compressive restoring stress, so gravity keeps loading a material that has stopped pushing back — bottomless compaction. Separately, the orthotropic cross-fiber stiffness was 5 to 8% of an already-kPa-scale modulus, which is below the weight of one grid cell, so those materials had no cross-section to defend. |
| v2: Barrier selectable |
An isotropic compression-barrier pressure that keeps growing with the raw pre-clamp overcompression, applied after the ceiling so it cannot be clipped; floors on the ortho cross and shear scales while keeping strong anisotropy; and a global log-scale stiffness multiplier (default 1.0×, i.e. unchanged) instead of blind-retuning the whole catalog. | Columns stop collapsing into lines and ropes keep a cross-section. But structural solids are still soft: a tall column still compacts, just more gently, and the fix for that is a slider the user has to find. | v2 fixed the saturation without fixing the scale. The volumetric law is hard-bounded at 0.25λ, so at E = 9000 the maximum support is around 1.3 kPa — an order of magnitude short of what the column base needs, and the material compacts until the SVD floor freezes it in a shrunken, non-physical rest state. A global multiplier can reach the right number, but applying one multiplier to bread and to granite is not a model, it is a knob. |
| v3: World Scale default |
Three coordinated changes. (1) Per-family multipliers: structural solids ×12 so λ finally exceeds ρgH, the puffy/soft-body family ×2 so it firms up without losing squish, fluids and living culture untouched. (2) The solid SVD compression floor rises 0.5 to 0.85, so the clamp becomes a rare safety guard rather than the resting state — and since the compression barrier activates at J = σmin², it now engages at J about 0.72 instead of 0.25, growing support continuously. (3) Substeps scale with √(E ratio), 5 up to 20, so the raised elastic wave speed stays CFL-stable. | Current, and promoted to default after live user validation: "some overly weak materials are now firm enough (like the glass / tempered glass)". | The cost is real and known: at ×12 the solver runs about 18 substeps, measured at 7.5 to 9.3 ms/frame against a roughly 7 ms baseline. |
Why v3 is good enough
Because it is the first version that made the three changes together. Raising E alone blows up an explicit scheme — that was the audit's own caveat — so the substep scaling is not a performance afterthought, it is part of the fix. Raising E without moving the SVD floor would have left the clamp as the resting state, so the material would still have been frozen in a shrunken configuration, just a stiffer one. And doing it per family rather than globally is what lets bread stay squishy while a column holds: the soft-body list is shared with the existing stress-ceiling branch, so the two agree by construction rather than by coincidence.
The honest framing is that v3 is a calibration, not a new constitutive model. It does not change any law; it puts the catalog's numbers into the right decade for the world size and then pays the CFL bill that implies.
What is still wrong
- The multipliers are per family, not per material. Twelve is one number covering every structural solid; the right answer is a re-authored catalog in physical units, which nobody has done.
- It costs up to 4× the substeps. That is the known trade, and it is charged to every MPM material in the scene, not only the stiffened ones.
- Real-Time Pacing is a separate, still-off toggle. The legacy loop runs exactly one fixed 1/120 s step per rendered frame, so at 60 Hz vsync the entire simulation runs at half real-time speed and sim speed depends on frame rate — a large part of the same floaty feel that v3 addresses from the other side. If a scene still feels weak with v3 on, check the pacing toggle before touching the multipliers.
- ×12 changes look, not just strength. Any scene tuned against v1 or v2 will read differently: stiffer bounces, less settle, different pile angles.
- The technique guide's glass section is now partly stale. "Why glass still collapses too easily" describes a v1/v2 world; glass is one of the materials the user specifically confirmed as fixed by v3.
When you would still pick an older version. Pick v2: Barrier when frame budget matters more than realism — it keeps the anti-collapse fix, which is the one that prevents outright broken-looking behaviour, at 5 substeps instead of up to 20. Pick it also for any scene whose look was authored before v3 became default. Pick Legacy v1 only to demonstrate the collapse: it is the cleanest possible illustration of what a saturating stress law does under sustained load, and the pillar test takes about five seconds.
The A/B to run. Build a tall column of Elastic or Ceramic. On Legacy (v1) it compacts and collapses. On v2: Barrier it resists with growing force but still settles noticeably shorter. On v3: World Scale it should hold its height. Then check the other end of the range in the same run: bread, goo, crumb and tissue should still be squishy under v3 — if they have gone rigid, the soft multiplier is too high, not the solid one. Watch the substep counter while you switch; the frame-time rise from 5 to 18 substeps is the price on the label.
Explosions · two models
One energy, or four unrelated polynomials
An explosion in this engine has to do five things at once: push air, push particles, heat, make smoke, and draw a ring. The legacy model did all five from separate hand-authored curves, so they could not agree with each other; the current one derives all five from a single energy scalar and a closed-form front radius.
The generations
| Generation | What it did | The symptom you saw | Root cause |
|---|---|---|---|
| Legacy (per-frame push) still default |
A per-frame emitter at the blast site that injects strength, heat, smoke and velocity
divergence into the air grid from four independent polynomial falloffs, while the drawn
shockwave ring integrates its own speed × dt radius separately. |
Blasts feel weak or too strong and the knobs fight each other; the drawn ring and the thing that actually pushes you are not in the same place; the outward push is largely eaten by the pressure projection. | Named in the audit as "independent polynomials". With four unrelated curves there is no conserved quantity anywhere, so tuning the push changes the heat by an unrelated amount and the visible ring is decoration rather than a readout. Worse, a per-frame velocity injection is exactly the kind of source a divergence-free projection is designed to annihilate. |
| Sedov Front selectable |
One analytic front per rupture, with radius following the 2D Sedov–Taylor law r(t) = ξ(Et²/ρ₀)1/4, transitioning to a constant acoustic speed once it slows below c₀. One energy scalar E drives front speed, overpressure, heat, smoke and particle impulse together. Each substep the CPU advances the radius and three GPU passes read the annulus between the old and new radius: a divergence, heat and smoke deposit before the projection; a one-frame directional gust on the MAC faces after it; and an outward particle kick where each particle is hit at most once per front via a persistent guard buffer. | Selectable, still off by default pending live-fire calibration. | The ordering is the point: depositing divergence before the projection makes it the one blast channel the projection cannot annihilate — it gets converted into wall-aware outward flow instead — and reflection and diffraction of the aftermath then come free from the Neumann boundary conditions. |
Why Sedov is the better model
Two structural wins. First, single ownership of energy: under Sedov the legacy per-frame emitter is skipped entirely, so exactly one thing owns strength, heat, smoke and divergence, and a follow-on pass extended that discipline to vents (the trapped pressure inside a vented cluster is deducted so the legacy cavity-vent branch cannot release the same energy twice). Second, correct time semantics: all front-crossing deposits are dt-free impulses consumed by exactly one projection per step, which is what makes the behaviour independent of substep count.
The follow-on pass also made the drawn ring honest: it now follows the physical front radius instead of a fabricated integration, and a retired front hands its record back to the legacy integrator so it fades from its last radius with no pop. Casing particles are pre-seeded into the guard buffer at rupture, so the front no longer double-kicks the shell that the legacy burst pushes already carry.
What is still wrong
- It is not the default, because live-fire calibration was never done. Every number in it — ξ, c₀, overpressure, impulse, thickness — is a live knob with an unvalidated starting value.
- Vent fronts and backdraft flash fronts are both default-off even under Sedov, so out of the box a dug-wall rupture still uses the legacy path.
- Mirror and reflection fronts were deliberately deferred: the image-source origin lands inside the reflecting wall, so it would need a change to the front packing to paint anything. Reflections you see are the aftermath through the projection, not reflected fronts.
- Two real defects were found by review after shipping, both fixed, both worth knowing as failure modes of this design: the ring froze at radius 0 in single-substep pacing because the radius query shared its zero sentinel between "absent" and "alive at t = 0"; and backdraft fronts replayed frozen bins after the backdraft feature was turned off.
- The cosmetic emitters still fire at rupture (shockwave, scorch, crack ring) independently of the model, so there remain two sources of visual truth.
When you would still pick Legacy. It is the default and the calibrated one: every shipped explosion scene was authored against it. Sedov is the physically defensible model with unvalidated constants; Legacy is the tuned model with no conserved quantity. Until the calibration pass happens, that trade favours Legacy for authored content and Sedov for anything where you care that the ring you draw is the thing that pushed you.
The A/B to run. Rupture a vessel in an enclosed room with the Explosion panel open. On Legacy, watch the drawn ring and the debris: they diverge, and the outward gust is weak relative to the ring because the projection ate most of it. Switch Blast Model to Sedov Front: the ring now is the front, its radius visibly decelerates and then settles to a constant acoustic speed, and the wind reaches around obstacles because it went through the projection rather than around it. Sweep the single Energy knob and check that speed, heat, smoke and impulse all move together — on Legacy the equivalent sweep requires four sliders that do not agree.
2026-09-19 qualification: both hot and cold participants now respond, which fixes the one-way ratchet. However, each particle gathers a different normalized mixture; equal relaxation rates alone do not establish global energy conservation. The word “conserving” in this historical section should be read as the design intention. The current audit gives a counterexample and separates this path from the new shared-face grid alternative.
Thermal · two generations
A maximum is not a mixture
Two touching particles at different temperatures should end up somewhere between
them. The legacy path implemented that with an atomicMax on a shared grid cell,
which is a perfectly reasonable way to make a lock-free write and a completely unreasonable model
of thermal contact — it has no way to express cooling, no way to add contributions, and no
way to run backwards.
The generations
| Generation | What it did | The symptom you saw | Root cause |
|---|---|---|---|
| Legacy ratchet selectable |
SPH particles scatter an atomicMax'd temperature into a grid channel; MPM
particles only ever absorb from it in g2p. A second, separate channel — also an
atomicMax, of 2000 − T — was bolted on later to allow
cooling at all. |
Drop burning embers into water: the water heats, the embers never cool, and they keep igniting things. Ten warm particles in a cell read as a single warm particle. SPH itself never receives contact heat at all, in either direction. | The audit called it "energy from nothing", severity 8. A maximum cannot conserve: contributions do not add, nothing can lower a cell (hence the second channel), and the exchange is structurally one-way, so a hot source heats an unlimited amount of cold material while staying exactly as hot as it started. |
| Contact Heat v2 default |
A conserving mixture grid. Two fixed-point accumulators hold
∑w·mC and ∑w·mC·T. Every thermal particle, SPH and MPM alike,
deposits with atomicAdd once per frame; then each particle relaxes toward
Tavg = num/den at 0.45/s in its own dedicated pass. Water carries 2× thermal
mass. Impact heating still injects as genuinely generated energy, numerator only. The legacy g2p
ratchet consume is disabled under v2 so heat is not applied twice. |
Current, and default-on since 2026-07-27. Embers visibly quench while the water warms; a pile of warm particles reads hotter than one. | Relaxing every participant toward the common mass-weighted mean at roughly equal rates conserves energy in aggregate, which is the property the ratchet could not have at any threshold setting. |
Why v2 is good enough
It replaces a comparison with an average, which is the entire content of the fix. Once the cell holds ∑w·mC and ∑w·mC·T instead of a maximum, three separate symptoms stop being symptoms: contributions add, so a crowd is hotter than an individual; the mean can be below a participant, so hot things cool; and water having twice the thermal mass makes quenching fall out of the same arithmetic instead of needing its own quench channel. Impact heating is correctly left as a numerator-only deposit, because kinetic-to-thermal really is generated energy and should not be conserved against the mixture.
It cost two dedicated passes rather than an edit to the existing shaders, because both g2p and the SPH force shader sit exactly at the driver's SSBO-block limit and adding two buffers fails to link at all. That is the same wall the fracture and coupling work hit, and it is worth internalising: in this codebase, new shared state usually means a new small pass.
Measured cost: no measurable frame-time change over 15 s forced on, about 7 ms/frame.
What is still wrong
- The SDF-wall and air-injection heat paths are still one-way, explicitly deferred. A hot wall still heats a particle without cooling; that half of the audit finding is open.
- The relaxation rate is a single global constant (0.45/s) that stands in for every real contact conductance. Two materials with wildly different conductivity exchange at the same rate.
- Conservation is aggregate, not exact. "Roughly equal rates keep the exchange near-conserving" is the actual claim; nothing enforces a balance per pair, and the fixed-point accumulators quantize.
- The deposit is once per frame, the relax is its own pass, so the effective exchange rate has a mild dependence on how the frame is subdivided.
- The sound-field SDF diffusion is untouched and remains a separate thermal path with its own semantics.
When you would still pick the legacy ratchet. Only two reasons. It is the A/B that makes the conservation argument visible, and it is the behaviour every pre-July scene was authored against — a scene tuned so that embers stay hot forever will read very differently when the embers start quenching. If an old fire scene suddenly goes out, this toggle is the first thing to check.
The A/B to run. Drop burning embers into a pool of SPH water. With Contact Heat v2 on (default) the embers visibly cool and go out while the water warms; turn it off and the embers heat the water, stay exactly as hot as they were, and keep igniting whatever they touch. Second test, for the summing property: put one warm particle against a cold block, then ten. Under v2 the ten drive the block hotter; under the ratchet they read identically to one, because a maximum ignores the other nine.
Fire · two generations
Why the burning wavefront never read as a front
Fire spreading through a solid should look like a narrow advancing band with cool spent material behind it. It looked instead like a whole object turning orange at once, and no amount of water would put it out. Five separate mechanisms were responsible, and one of them made fire physically inextinguishable rather than merely hard to extinguish.
The generations
| Generation | What it did | The symptom you saw | Root cause |
|---|---|---|---|
| Legacy selectable |
Deterministic ignition above a fixed temperature, exothermic self-heating, and — the
decisive line — a temperature floor applied to any burning particle:
temp = max(temp, 505 + …), with ember ash clamped to 410 K forever. |
Ignite one end of a wood log row: the whole log lights up at once, there is no ragged edge, and the ash glows forever producing infinite smoke. Pouring water on it does nothing. | Five stacked causes. (1) The floor teleported a barely-ignited particle at burn = 0.011 straight to at least 505 K, so every touched particle instantly became a full-power heat source — smearing the front — and because the floor is re-applied every frame, no amount of cooling could ever reduce the temperature. Fire was unextinguishable by construction, not by tuning. (2) Ignition was fully deterministic, so whole bands lit in lockstep. (3) Burn duration far exceeded layer-to-layer conduction lag, so the whole object burned as one phase. (4) A one-way preheat ratchet let fuel absorb heat ten times faster than it shed it, creeping entire bodies to ignition. (5) Ash was clamped hot forever: an eternal pilot light. |
| Burn Front v2 default |
Bounded exothermic self-heating gated by a sustain ramp instead of a floor, so
active cooling can smother a flame; per-particle ignition jitter so neighbours do not light in
lockstep; 2.2× default burn-through so the front is thin with a trailing char edge; the
preheat ratchet softened 0.1 to 0.45; ash cools to near ambient. Shipped with
Water Quench, a cold-contact grid channel that lets cool SPH liquid pull grid
temperature down hard. |
Current and default-on. A visible advancing front with a ragged edge and burnt-out cool ash behind it; water puts fire out. | Replacing a clamp with a gate is the whole structural change: a clamp is unconditional and destroys information, a gate multiplies and leaves the cooling term able to win. |
Why v2 is good enough
Because the floor was not a tuning parameter, it was a lower bound on the state
variable, and no other subsystem could beat it. Nothing about a heat-conduction term, a
water quench, an air-cooling term or an oxygen gate matters if the last thing that runs sets the
temperature back to 505 K. The replacement — a smoothstep(330, 380)
sustain multiplier on the self-heating — keeps the property the floor was there to provide
(a burning particle resists ordinary grid cooling) while restoring the property it destroyed
(enough cooling wins).
The other four fixes are each small and each removes a distinct reason the front smeared. The
one worth calling out is jitter: a deterministic threshold on a regular particle lattice is a
synchroniser, and any spatial randomness at all breaks the lockstep. Water Quench is
the complementary half — the legacy SPH-to-MPM heat exchange was strictly hot-to-cold via
atomicMax, so water had no channel through which to cool anything, which is the
same defect the contact-heat family fixed properly a fortnight later.
What is still wrong
- Water Quench is now partly redundant. Contact Heat v2 makes cold water quench natively through the mixture mean. Two mechanisms now cool with water, both default-on, and nothing reconciles them.
- The sustain thresholds are global constants (330 to 380 K for burning material, 320 to 370 for embers), not per-material. A material that should smoulder at low temperature and one that should not are gated identically.
- Burn-through speed is a look knob, not a fuel property. The 2.2× default multiplies every material's burn rate; per-material burn duration remains hand-authored.
- Ignition jitter is a hash of the particle index, so it is spatially uncorrelated. Real flame fronts are rough at a length scale; this is rough at the particle scale.
- It interacts with the later fire oxygen chemistry and structural fire damage, neither of which was re-tuned against v2's thinner front.
When you would still pick Legacy. One legitimate use: a scene that wants guaranteed, unstoppable, decorative fire — a permanently burning brazier, a torch that must not go out when a splash lands on it. The legacy floor gives you exactly that, for free, and there is no "unquenchable" flag in v2 to reproduce it. It is also the A/B that demonstrates why a clamp on a state variable is a different kind of bug from a badly chosen constant.
The A/B to run. Lay a row of wood logs and ignite one end. With Burn Front v2 on you should see an advancing band with a ragged leading edge and cool dark ash behind it; toggle it off and the whole row lights at once and the ash keeps glowing and smoking indefinitely. Then pour SPH water on burning material: with v2 and Water Quench on the fire goes out; with Water Quench off the water does nothing at all. Turn Water Quench off but leave Contact Heat v2 on to see the newer, more general quench path working on its own.
MPM ↔ SPH · two generations
A contact that teleported, and a force that only pointed one way
The MPM and SPH solvers share a world but not a pressure solve, so they meet through a contact model. That model had two defects, one per direction: it moved positions instead of applying forces, and it never applied any force back at all.
The generations
| Generation | What it did | The symptom you saw | Root cause |
|---|---|---|---|
| Legacy one-way penalty still default |
SPH particles that penetrate MPM material are pushed out by a fixed fraction of the grid spacing per frame, and their normal velocity is reflected with a stacked factor. MPM never sees SPH at all. | Fluid slowly pops and jitters its way out of concave MPM pockets while resting in them. A water jet cannot move a sand pile. The same scene behaves differently at a different timestep or grid resolution. | Two independent defects. (a) The correction teleported position: it was dt-independent and scaled with the grid spacing, so depenetration speed tracked dx and frame rate rather than time. The reflection factor summed to 1.05 + 1.30 + 0.75 = 3.10; since k = 1 kills the normal velocity and k = 2 reflects it perfectly, k = 3.10 reverses it at about 2.1× speed — resting contact injected kinetic energy. (b) The coupling was strictly one-way: SPH felt MPM as an immovable static collider, MPM felt nothing, and momentum was created from nothing every frame. |
| Coupling v2 selectable |
A passive response: k = 1 + e with e in [0,1], so it can never add energy, and a rate-based recovery in 1/s so behaviour is timestep-independent. Then the loop closes: the SPH force pass records the exact impulse each particle received from the contact and MPM receives its negative. | Selectable, still off by default pending live calibration. | Two hard constraints shaped it, and both are characteristic of this codebase.
The SPH force shader sits at the 16-SSBO cap so it cannot bind the grid — the
reaction rides an imageBuffer instead, which also avoids duplicating contact logic
in a second shader that could drift. And SPH runs after the whole MPM step, which the
clear pass wipes, so the impulse lands in persistent reaction grids and is consumed by the
next step: a documented one-frame latency, the same order of accuracy as the rest of the
explicit scheme. |
Why v2 is the right model
Restitution above 1 is not a tuning choice, it is a sign error in disguise: any resting contact with k > 2 is a pump. Clamping k to 1 + e with e ≤ 1 makes the response passive by construction, which is a property rather than a setting — you cannot tune your way back into the bug. Making recovery a rate in 1/s rather than a fraction of dx per frame is the same kind of change: it removes dx and frame rate from a physical quantity's units.
The back-reaction is the part that makes it a coupling rather than a boundary condition. Recording the impulse at the one site where the contact force is actually known, rather than recomputing it in the scatter pass, is a deliberate anti-drift decision worth copying.
The verification is the most instructive part of this entry. Forcing it on with a temporary GPU readback caught a bug that a clean build and a no-crash run would both have missed: the consume-and-zero sat after grid_op's empty-cell early-out, but SPH deposits with B-spline weights and therefore reaches nodes carrying no MPM mass. Those cells were never cleared and the fixed-point accumulator climbed monotonically, 0.6 to 126 over 25 s — int32 wraparound in roughly seven minutes, and a phantom kick of minutes-worth of stale impulse for anything that later drifted in. Moving the read and clear above the early-out made it flat: about 140 cells at about 0.18 peak impulse, steady over 30 s.
What is still wrong
- It is not the default, because the live A/B is the calibration step and it has not been run: does a water jet now visibly move a sand pile, and does fluid stop climbing out of pockets?
- One frame of latency on the back-reaction, by construction. Acceptable in an explicit scheme, but it means action and reaction are not simultaneous.
- The applied velocity change is clamped to one cell per step so a near-empty node cannot produce an unbounded kick. That is a safety clamp, i.e. exactly the kind of compensator this page is generally suspicious of.
- It is still a penalty contact, not a unified pressure solve. The relationship map's own wording remains accurate: soft coupling for contact and thermal exchange. Two-way momentum makes it honest, not unified.
- SDF geometry still receives no momentum back from either solver. That one-way arrow is a separate, still-open item.
When you would still pick Legacy. Default-off here is enforced structurally — the diff across every touched shader and source is 111 lines added and zero removed, with the legacy contact block reproduced verbatim in the else branch — so Legacy is byte-identical and every existing scene is unaffected. Keep it for any scene where SPH is meant to act as a decorative fluid over static terrain and you do not want a water jet redistributing your carefully placed sand. Switch to v2 when the interaction is the point.
The A/B to run. Two tests, one per defect. (1) Energy: rest a pool of SPH fluid in a concave MPM bowl and leave it alone. On Legacy it jitters and slowly climbs out; on v2 it sits. (2) Momentum: aim a water jet at a loose sand pile. On Legacy the sand does not move at all no matter how hard you push, because MPM never receives the force; on v2 the pile erodes. Then change the timestep or grid resolution and repeat test 1: Legacy's depenetration speed changes with both, v2's does not.
Cross-reference
Where each family's detail table lives
This page is the argument, not the reference. Per-material parameters, status badges and coupling rows live on the three companion pages and are deliberately not duplicated here.
| Family | Detail lives on | What you will find there |
|---|---|---|
| Magnetics | mechanics_status.html #magnetic mechanics_relationships.html #ferro |
Per-material magnetic table (saturation, alpha, phase behaviour), the April post-mortem, and the list of anti-compaction mitigations. The relationship map carries the force-decomposition figure: Kelvin lift, surface Maxwell stress, Tait plus demag pressure, pseudo-thickness bridge. check both still quote 14 / 3.0 for Heavy Ferrofluid where the shaders use 8.0 / 2.0. |
| Living material | mechanics_relationships.html #atlas the same page, #thermal |
The bio and automata fields as solver inputs, and the regrowth lens. There is no per-material table for the eight living materials anywhere yet; the Creation menu is the only enumeration. |
| Ash regrowth | mechanics_relationships.html #thermal | The regrowth chain, plus the symptom table that includes "regrowth turns black" and what to check for it. |
| Fracture | mechanics_status.html #granular technique_guide.html #materials |
Granular and fracture status rows, and the full material atlas. check the status row still says phase-field brittle has no Laplacian regularization and calls it a future improvement. |
| Stiffness | technique_guide.html #glass mechanics_status.html #solids |
The long-form analysis of why glass collapsed, written against v1 and v2. check glass is one of the materials v3 was validated on. |
| Blast | mechanics_relationships.html #thermal | The explosion chain: combustion and sealed charges feeding vapor, heat, airtight pressure and blast impulse, and how the cavity mask decides vent against pressurize. |
| Contact heat | mechanics_status.html #thermal mechanics_relationships.html #thermal |
Thermal coupling status, and the SPH / MPM / air / SDF thermal chain. |
| Burn front | mechanics_relationships.html #thermal technique_guide.html #systems |
The thermal chain, and the current simulation stack including where fire reads air temperature back. |
| MPM ↔ SPH | mechanics_relationships.html #relationships | The coupling matrix. The row that matters is SPH to MPM: soft coupling for contact and thermal exchange, not a fully unified pressure solve — still accurate under v2. |
| All of them | mechanics_relationships.html #frame the same page, #debugging |
The frame loop, which is the ordering every argument on this page depends on, and the debug view index for running the A/Bs. |