Solver Internals

Every other page here assumes the MPM step and describes what sits on top of it. This one opens it. One substep, both directions, read out of the shaders: which nine nodes a particle touches and with what weights, what the fixed-point boundary is for, what the grid does in between, and why everything computed after the gather is invisible to the grid until the next substep.

Companion pages
Mechanics Relationship Map
The Explosive Lab
Mechanics Status Report
Why This Version

Source of truth: mpm_p2g.comp, mpm_grid_op.comp, mpm_g2p.comp
Updated: 2026-09-08

The Stencil

grid node, area = weight the particle 9 nodes per particle, every substep the 3x3 block it writes into 0.078 0.227 0.014 0.162 0.469 0.030 0.005 0.014 0.001 dpos base = floor(cell_pos - 0.5) sampled at fx = (0.80, 0.70) the 1D weights they came from w0 w1 w2 0.5 0.80 1.5 fx w = (0.245, 0.710, 0.045) the nine weights are the outer product of the three · both sum to exactly 1 quadratic B-spline transfer weights · the centre node always takes the most
grid nodes, drawn with area proportional to weightthe centre node takes 0.469 of this particle the particle, and one of its nine dpos offsets where the left panel sampled the three curves
The nine numbers on the left are the outer product of the three curves on the right. Both sum to exactly 1. Evaluated at fx = (0.80, 0.70).

Three lines of arithmetic

The whole transfer basis is one function, in shaders/common/mpm_utils.glsl:

NodeWeight
w0 (left / below)0.5 * (1.5 - fx) * (1.5 - fx)
w1 (centre)0.75 - (fx - 1.0) * (fx - 1.0)
w2 (right / above)0.5 * (fx - 0.5) * (fx - 0.5)

That is the quadratic B-spline. It has three properties the solver depends on and none of them are optional:

  • The three weights sum to 1 for every fx. This is what makes the transfer conserve mass. Any bug that breaks it leaks or invents material.
  • It is C¹ continuous. The weights and their first derivatives vary smoothly as a particle crosses a cell boundary, so nothing pops when it does. Linear weights would be cheaper and would visibly stutter.
  • It has compact support of exactly 3 cells. Nine nodes in 2D, always. Not a neighbour search, not a variable count — a fixed double loop.

The - 0.5 that centres it

The stencil origin is computed like this, identically in five shaders:

cell_pos = (pos - grid_origin) / dx
base     = floor(cell_pos - 0.5)
fx       = cell_pos - base

Without the - 0.5, base would be the cell the particle is standing in and the 3×3 block would be lopsided — one node behind, two ahead. With it, base + 1 is the nearest node, so the block is centred and fx lands in [0.5, 1.5] rather than [0, 1). That shifted range is exactly the domain over which the three weight expressions above are the correct B-spline pieces. The offset and the weight formulas are one design, not two.

This is a real trap, and it has bitten this codebase. The damage scatter/gather pair once used round(cell_pos) on one side and floor(cell_pos) on the other. Both are defensible in isolation; together they mean the pass writes to one block and reads from a different one, and the Laplacian it computes is over-magnified rather than obviously dead. It was measured at 1.5–2.8× too large, which is precisely the kind of error that looks like bad tuning.

The MLS Shortcut

One constant instead of a matrix inverse

APIC gives each particle an affine velocity matrix C so that momentum transfers carry local rotation and shear instead of losing them. The general form needs an inertia-like matrix D inverted per particle per step.

For a quadratic B-spline, D is always (1/4) · dx² · I. It does not depend on where the particle is inside its cell. So D⁻¹ is the compile-time constant 4 / dx², and the inverse disappears from the inner loop entirely. That is the MLS-MPM identity (Hu et al., 2018), and it is why you see this literal 4.0 in both directions:

// p2g
affine = (-dt * p_vol * 4.0 * inv_dx * inv_dx) * stress + mass * C;

// g2p
new_C += 4.0 * inv_dx * inv_dx * weight * outer(g_vel, dpos);

Change the interpolation order and that constant is wrong. It is not a tuning parameter.

Stress and APIC in one term

Look at the P2G line again. It fuses two things that are conceptually separate:

  • -dt · p_vol · D⁻¹ · stress — the internal force the material exerts, integrated over the step.
  • mass · C — the APIC affine momentum.

Both end up multiplying the same dpos in the scatter, so MLS-MPM adds them once and multiplies once:

momentum = weight * (mass * vel + affine * dpos) * z_occ

The practical consequence is that the constitutive model is the only thing a material author writes. Every material branch in mpm_p2g.comp — all sixty-odd of them — exists to produce one mat2 stress. The transfer machinery below it never changes. That is why a new material is a branch and not a subsystem.

p_vol is p_spacing², set once on the CPU at mpm_solver.cpp:1011.

The Round Trip

particle state grid state the read-back one substep, dt = 1/120 s carried to the next substep p2g scatter 9 atomic adds grid op per cell g2p gather 9 reads post forces per particle int32 x 1e6 1 particle to grid mass, momentum m v + A dpos, mass x temp 2 grid to grid v = p / m, then gravity, mouse, SDF, walls 3 grid to particle v gathered, C rebuilt from the same 9 weights 4 particle only buoyancy, drag, magnetic, chain, surface clamped twice: 3 cells per substep going in, per material coming out the grid is the only place two particles ever meet one MPM substep in both directions, and the lag on everything after the gather
particle state — stages 1, 3 and 4 grid state — stage 2, the only stage that is not per-particle the read-back — where a globally solved field re-enters a particle
Four stages, one substep. The dashed vertical is the fixed-point boundary; the long return path across the top is the lag on everything computed after the gather.

What actually crosses

StageDirectionPayloadShape
1particle → grid mass, mass·vel + affine·dpos, mass·temp 4 atomic adds × 9 nodes = 36 per particle
2grid → grid vel = mom / mass, then gravity, mouse force, SDF friction, wall boundaries, and (with Coupling v2) the SPH reaction impulse 1 invocation per cell
3grid → particle new_vel gathered, new_C rebuilt from the same nine weights 9 reads per particle
4particle only buoyancy, drag, magnetic Kelvin / chain / surface forces, held-magnet velocity, bulk lift no grid access at all

Note the asymmetry in stage 3: velocity is gathered but C is rebuilt. The particle’s previous C is discarded entirely and reconstructed from the grid velocities it just read. APIC has no memory; the affine field is a property of the current grid state, not an accumulated quantity.

The Grid Pass

Order matters, and it is deliberate

mpm_grid_op.comp runs once per grid cell, and its ordering encodes several decisions that are easy to get wrong:

aconsume, then early-out The Coupling v2 reaction impulse is read and cleared before the mass < 1e-10 early-out. SPH deposits with B-spline weights, so it reaches nodes carrying no MPM mass; clearing only on the mass path lets a fixed-point accumulator grow without bound until it wraps, and then slams whatever drifts in later with minutes of stale impulse.
bdecode momentum vel = mom / mass, both back from int32. Dividing by node mass is also what makes the impulse application dimensionally free — an impulse is already a momentum, so no dt appears.
cbody forces Gravity (optionally reduced by the Real 2.5D support factor), then the mouse force. Both before the boundary conditions, deliberately, so that neither can push material through a wall.
dSDF boundary The terrain normal comes from central differences of the SDF texture; the node velocity is projected and friction applied along it.
egrid boundary A hard zero on the normal component inside the border band. Last, so nothing after it can reintroduce penetration.

Why the grid is thrown away

The grid holds no state between substeps. It is zeroed, scattered into, operated on, gathered from, and discarded. Nothing about a node survives to the next step.

This is what makes MPM behave like a mesh-free method despite having a mesh: the grid is a scratchpad for resolving contact and internal forces, not a representation of the material. Material state lives entirely on particles — F, C, temperature, and the per-material lanes.

It also means the grid resolution is a purely numerical knob. Raising it makes contact sharper and self-collision tighter without changing what any material is. Lowering it makes thin features merge, because two particles closer than a cell can no longer be told apart by the only machinery that lets them interact.

This is the honest answer to “why does my thin sheet stick to itself?”. Nothing is sticky. Two surfaces within one cell of each other share nodes, and sharing nodes is contact in this method.

The Tail

Everything after the gather is per-particle

Once new_vel and new_C exist, mpm_g2p.comp keeps going for several hundred more lines. Buoyancy for burning particles, air drag, the whole magnetic force family, held-magnet dragging, bulk lift, chain force, surface Maxwell stress, corrugation, bridging. All of them do the same thing: new_vel += force * u_dt.

None of them touch the grid. They cannot — the grid pass has already run and will not run again until the next substep.

The consequence

A force applied in the tail is not seen by any other particle until the next P2G scatter. It arrives one substep late, and it arrives having already moved the particle it acted on. For most forces this is invisible. For anything that should produce a reaction — a magnet pulling ferro toward it should feel the pull back — the reaction simply is not there.

That is not a bug in the tail. It is the reason the magnetic force pipeline is drawn the way it is on the Relationship Map: the field solve is two-way, but the force application is not.

The material-specific speed caps

The gathered velocity is clamped, and the cap is per material. The default is 3 cells per substep; the magnetic family gets far less:

MaterialCapWhy
default3.00The CFL-ish safety limit; the same number appears in P2G.
para mist (69)1.40A light aerosol should move briskly.
rubber (58)1.20Room for bend response.
heavy ferro (67)0.82Headroom for cursor-following lift.
permanent ferro (81)0.72Heavy and remanent, but must still wick upward.
ferrofluid (31)0.60Must chase a moving pole without going ballistic.
diamagnetic (68)0.50Moderate cap; it is allowed to drift away.
sticky ferro (70)0.48Stays cohesive, so it stays slow.

These are not physical properties. They are an admission that the magnetic forces can exceed what the transfer can carry stably, and the cap is where that is absorbed. When the clamp fires it scales new_C by the same factor, so the affine field never outruns the velocity it was built from — clamping velocity alone would leave a particle spinning faster than it translates.

Safety Rails

Fixed point, because there is no atomic float

GLSL has no portable atomic float add on an SSBO. The scatter needs one — nine nodes, many particles, all racing. So the four grid accumulators are declared int and everything crosses scaled:

const float FIXED_SCALE = 1000000.0;
atomicAdd(grid_mass[cell], int(m * FIXED_SCALE));

PropertyValue
Resolution10⁻⁶ of a unit
Range before int32 wraps±2147.48 units at a single node
Temperature accumulator scaleFIXED_SCALE * 0.001

The temperature channel is deliberately a thousand times coarser. It accumulates mass × temp, and temperatures reach 1500 K, so at the full scale a hot, dense node would be much closer to the wrap point than a cold one. Dropping three decimal places buys back that headroom. It is the same trade every fixed-point format makes, made once, per channel.

The velocity clamp, twice

The same 3-cells-per-substep limit appears on both sides of the grid:

Before the scatter (mpm_p2g.comp), because a particle moving more than its own stencil width per step would deposit its momentum into nodes it has already left. This one also catches NaN explicitly — spd != spd zeroes the velocity rather than propagating a NaN into the grid, where it would poison every particle that shares a node.

After the gather (mpm_g2p.comp), per material, as above.

Neither clamp is a physical model, and both are load-bearing. A single NaN reaching atomicAdd is not recoverable at the particle level: the node is corrupt, and every particle that gathers from it next step inherits the corruption. The scatter-side check is the last place that can be stopped cheaply.

Related, and easy to miss: z_occ (Real 2.5D occupancy) scales the scattered footprint — mass, momentum and heat together — but the constitutive stress is not rescaled. A half-carved particle deposits half the grid mass but the same node velocity, because z_occ cancels in the mass-weighted average. The remaining material keeps full per-unit stiffness. That is an honest load-shed rather than a stiffness fudge, and the distinction is written into the shader comment at the line that does it.

Two Ceilings

Sixteen storage blocks

mpm_p2g.comp, mpm_g2p.comp and sph_force.comp each declare exactly 16 std430 buffer blocks. That is not a coincidence and it is not a style choice — it is the driver’s cap on shader storage blocks per stage, and all three shaders are sitting on it.

Anything that needs new per-particle state in those shaders cannot simply add a buffer. The escape hatches actually used in this codebase, in rough order of preference:

  • A samplerBuffer lookup table on a free texture unit — textures are a separate binding namespace. This is how the authored material, bake, thermal and fracture profile tables get in.
  • An imageBuffer, likewise a separate namespace, when writes are needed.
  • A dedicated compute pass with its own binding budget — how the damage scatter/gather pair exists at all.
  • Bit-packing into an existing lane. material_ids carries the z-occupancy in bits 8–15, which is why every read of it is masked with & 0x7Fu.
  • Reusing a lane that a given material does not need — the explosives borrow Jp_data for crack, described on The Explosive Lab.

Two particle index spaces

This is the single most dangerous detail in the codebase, because getting it wrong produces no error of any kind.

SpaceIndexSizedBuffers
Globalp = u_offset + idxSPH + MPM capacity positions, velocities, material_ids, masses, colors, temperatures
MPM-localidxMPM capacity only damage, Jp_data, fiber, phase_data, deform_grads, affine_moms

The offset is the SPH capacity, currently 300000. Both index forms appear within a few lines of each other in essentially every material branch:

temperatures[p] = clamp(temp, 0.0, 1400.0);
damage[idx]    = fuse;

Using idx where p belongs reads a different particle’s data — silently, plausibly, and with values in the right numeric range. Using p where idx belongs reads past the end of an MPM-local buffer. Neither produces a validation error, a crash, or a log line. The only defence is that the convention is uniform: if the buffer is in the MPM-local row above, it takes idx. Always.

Consequences You Can See

Most of what looks like a material bug is one of these five properties of the transfer, showing through. None of them are fixable by tuning a material.

What you seeWhat it actually is
Thin sheets stick to themselves; two surfaces will not separate cleanly They are within one cell, so they share grid nodes. Sharing nodes is contact. Raise the grid resolution or thicken the feature.
A fast object passes through a thin wall The 3-cells-per-substep clamp is the only tunnelling guard, and it is a clamp, not a continuous collision test. Below the clamp there is no sweep at all.
Fine detail dissolves after a few seconds Every substep round-trips the material through a grid and back. That is a filter, applied at 120 Hz. APIC loses far less than PIC would, but it does not lose nothing.
A magnet pulls but nothing pulls back Magnetic force is applied in the tail, after the grid pass. There is no path for a reaction this substep.
Terrain never moves, however hard something hits it The SDF writes into the grid pass; nothing writes back. This is drawn as an explicitly missing edge on the coupling graph.

What this page does not cover

  • The constitutive models themselves — the sixty-odd material branches that produce stress. Each is its own design; several are documented on The Explosive Lab and the Technique Guide.
  • The SVD. Every branch that needs principal stretches calls svd2x2(F) and works in sigma; the decomposition itself is a closed-form 2×2 routine and is not interesting.
  • The damage scatter/gather pair, which is a second, independent grid round trip layered on top of this one for the AnisoMPM fracture model.
  • Everything outside MPM: SPH, the Eulerian air solve, XPBD, and the field solves each have their own step. The frame loop figure places them in order.

Reading the shaders yourself

If you go looking, the order that makes sense is:

  • shaders/common/mpm_utils.glsl — twenty lines, and the weights are all of it.
  • shaders/physics/mpm_p2g.comp, from the bottom. The last forty lines are the transfer. Everything above them is one enormous else if (mat_type == ...) chain producing stress, and you can skip all of it on a first read.
  • shaders/physics/mpm_grid_op.comp — short, and reads top to bottom in the order the grid pass actually happens.
  • shaders/physics/mpm_g2p.comp — the gather is the first thirty lines. The rest is the tail.

One stale comment to ignore: mpm_utils.glsl describes an GL_NV_shader_atomic_float path and says the P2G shader declares its grid buffers as float. It does not. They are declared int at mpm_p2g.comp:21-24 and the transfer is fixed-point, as described above.