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

Mechanics Relationship Map
how the solvers exchange state

Mechanics Status Report
what works, per material

Technique Guide
material atlas and roadmap

The Explosive Lab
one pressure vessel, three charges

Solver Internals
the MPM step, opened

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

defaultWhat you get out of the box selectableStill in the UI, still useful supersededSelectable, but strictly worse goneRemoved, or archived only

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.

Legacy (Poisson + gates) New Magnetic Fantasy (arcane)

The generations

GenerationWhat it didThe symptom you sawRoot 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.
grid / field state particle state the seed external field = 0 legacy: x = max(alpha|H|, 1e-4) current: L(x) = 0.0 below 1e-6 M x M(0) = sat/30000 M x M(0) = 0 exactly N particles per cell; M sums cell M grows with N the same sum, seeded with 0 cell M = 0 1024, cleared 256, warm M div M phi ∇|H|² density rises, so cell M rises no gate on an external source M div M phi ∇|H|² H_here = H_at(pos) only demag pushes; never magnetizes the floor was the seed; the loop was the amplifier
solved / field statethe magnetization source, phi, and the field gradient particle stateper-particle M, and the force that packs particles the seeda non-zero M returned for a genuinely zero field
The bug was four characters wide and the fix is one line, but neither number is the point. A per-particle floor of 3.33e−5 is harmless in isolation; it is decisive because the rasterizer sums M into shared cells and because the loop it feeds has gain above one. On the right, the same loop still exists — the self-field is back, and it is what produces interference, dipole chains and real spikes — but it is opened permanently at one edge (the white ring): the analytic external field is the only input to magnetization, and the demagnetizing field is the only thing excluded from it.

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 makes physics_active() return false, which sets u_use_real_magnetics = 0 in 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 Tait j_floor alone (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_dir falls back to a hardcoded (0,1) and chi_eff = dot(m, field_dir)/M_REF collapses to m.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 = 32 with 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.comp still carries the old max(alpha*hmag, 1e-4) form. It cannot reproduce the clumping — its only call site is guarded by hmag > 1e-5 and 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_magnetics is 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.

Culture Automata [legacy] Colony + Bloom [legacy]

The generations

GenerationWhat it didThe symptom you sawRoot 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_floor branch (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 / 4000 climb 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

GenerationWhat it didThe symptom you sawRoot 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.

Legacy (hand-tuned) AnisoMPM isotropic AnisoMPM anisotropic

The generations

GenerationWhat it didThe symptom you sawRoot 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.
damage field d plate and grain failed particle same plate, same load legacy: a local strain threshold AnisoMPM: a regularized field d += max(stretch - thr, 0) rate dt each particle crosses alone, so there is no tip and no front crack width = particle spacing, so crack speed changes with dx floor 0.18: a failed particle keeps 18 percent, and fibers degrade too grain l0 eta d' = (1-d)D - (d - l0² Lap d) Lap d from the B-spline second derivative on the scatter support g(d) = (1-d)²: shear always, volumetric in tension, fiber NEVER alpha = -1: cracks run ALONG grain measured: 500 to 971 particles, 25 s the tip is the one place a neighbour term can put damage
the damage fielda weight normalized grid, gathered back as l0 squared times Lap(d) the plate and its grainfiber direction, which the anisotropic mode follows an individually failed particlelegacy has only these; there is no field to connect them
Left is not a crack, it is a set. Each particle crossed its own threshold with no knowledge of any other, so there is nothing that concentrates, nothing that advances, and the only length in the problem is the particle spacing — which is why the same scene cracks at a different speed on a different grid. Right, the non-local term supplies the missing length l₀: damage at a node is pulled toward its neighbours' damage, so it piles up at the tip (white ring) and the front advances into intact material. The three nested contours are that concentration, not a glow effect.

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-8 cannot 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.

Legacy (v1) v2: Barrier v3: World Scale

The generations

GenerationWhat it didThe symptom you sawRoot 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

GenerationWhat it didThe symptom you sawRoot 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

GenerationWhat it didThe symptom you sawRoot 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.
grid cell state particles deposit one ember, two cold bodies legacy: atomicMax ratchet v2: conserving mixture grid_contact_temp max(900, 300, 300) = 900 no return SPH 900 MPM 300 MPM 300 ten warm particles read as one nothing lowers a cell, so a second atomicMax channel was bolted on SPH never receives contact heat energy from nothing, every frame num += w mC T, den += w mC T_avg = num / den = 460 900 cools 300 warms 300 warms both solvers deposit and both relax warm plus warm sums hotter than either; water carries 2x mC, so it quenches natively impact heat still deposits num only the ratchet could only ever go one way
the shared grid cella maximum on the left, a mass weighted mean on the right the participating particleson the left only SPH deposits and only MPM absorbs a depositatomicMax on the left, atomicAdd into two accumulators on the right
The dashed arrow on the left is the whole bug: it is drawn because it is what a thermal model needs, and it is dashed because it does not exist. With a maximum there is no arithmetic that could lower the ember, which is why cooling had to arrive as a second, separate, also one-way channel. On the right the same three particles both write and read, so the ember sits above the mean and falls toward it while the clay rises — and because the accumulators are mass-weighted sums rather than a maximum, ten warm particles genuinely sum hotter than one. The white ring marks the single shared value every participant reads.

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

GenerationWhat it didThe symptom you sawRoot 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

GenerationWhat it didThe symptom you sawRoot 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.

FamilyDetail lives onWhat 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.