// MLS-MPM over gaussian kernels. // // An independent implementation of the idea in PhysGaussian (arXiv 2311.12198): // the gaussian kernels of a splat reconstruction *are* the continuum particles, // so there is no mesh, no tetrahedralisation, and no round trip between a // simulation representation and a render representation. Each kernel carries a // deformation gradient F, and its covariance is transported by it: // // Σ' = F Σ Fᵀ // // which is what makes a stretched blob render as a stretched blob rather than a // blob that moved. That upstream project ships no licence, so nothing is taken // from it — this follows the method as described in the paper. // // The transfer is MLS-MPM (Hu et al. 2018): particle to grid, solve on the grid, // grid back to particles. The APIC affine velocity C doubles as the velocity // gradient, which is why MLS-MPM needs no explicit gradient estimation. // Stiffness has to be read against the scene, not copied from a table of real // materials. The box is one unit tall at g=9.8, so the stress a falling object // generates here is of order rho·v² ≈ 5; at E=4000 that is a fifth of a percent of // strain — correct, invisible, and useless as a demo. These values put the // interesting range of each material inside the stress this scene actually // produces, keeping their ordering intact. export const MATERIALS = { foam: { label: 'Foam', E: 90, nu: 0.15, rho: 0.3, model: 'plastic', yield: 0.03, hardening: 0, color: '#e2c8d8', }, jelly: { label: 'Jelly', E: 240, nu: 0.32, rho: 1.0, model: 'neohookean', hardening: 0, color: '#7fd4a8', }, plasticine: { label: 'Plasticine', E: 320, nu: 0.35, rho: 1.2, model: 'plastic', yield: 0.012, hardening: 0, color: '#d9a066', }, snow: { label: 'Snow', E: 650, nu: 0.2, rho: 0.4, model: 'snow', critC: 2.5e-2, critS: 6.5e-3, hardening: 10, color: '#eef4f8', }, sand: { label: 'Sand', E: 950, nu: 0.3, rho: 1.6, model: 'sand', friction: 38, hardening: 0, color: '#d8c48a', }, metal: { label: 'Metal', E: 4200, nu: 0.3, rho: 4.0, model: 'plastic', yield: 0.004, hardening: 0, color: '#9aa3ad', }, }; // 32 rather than 40: the grid is cleared every substep, and 41³ cells cost ~275k // float writes per step before any physics happens. Dropping to 33³ halves that // for a difference in the result you cannot see at this scale. const N = 32; // grid resolution per axis const DX = 1 / N; const INV_DX = N; // ---------------------------------------------------------------- 3x3 helpers -- const m3 = () => new Float32Array(9); const ident = () => Float32Array.from([1, 0, 0, 0, 1, 0, 0, 0, 1]); function mul(a, b, out) { for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { out[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j]; } } return out; } function transpose(a, out) { out[0] = a[0]; out[1] = a[3]; out[2] = a[6]; out[3] = a[1]; out[4] = a[4]; out[5] = a[7]; out[6] = a[2]; out[7] = a[5]; out[8] = a[8]; return out; } function det3(a) { return a[0] * (a[4] * a[8] - a[5] * a[7]) - a[1] * (a[3] * a[8] - a[5] * a[6]) + a[2] * (a[3] * a[7] - a[4] * a[6]); } /** * Polar decomposition F = R S by Newton iteration on R. * The rotation is what the elastic stress needs; iterating R -> (R + R^-T)/2 * converges quickly and avoids a full SVD in the inner loop. */ const _polarTmp = m3(), _polarInv = m3(); function polarR(F, R) { R.set(F); const tmp = _polarTmp, inv = _polarInv; // Four iterations reach visual convergence; twelve cost three times as much // per particle per substep and change nothing you can see. for (let it = 0; it < 4; it++) { // inverse-transpose of R const d = det3(R); if (Math.abs(d) < 1e-12) break; const id = 1 / d; inv[0] = (R[4] * R[8] - R[5] * R[7]) * id; inv[3] = -(R[1] * R[8] - R[2] * R[7]) * id; inv[6] = (R[1] * R[5] - R[2] * R[4]) * id; inv[1] = -(R[3] * R[8] - R[5] * R[6]) * id; inv[4] = (R[0] * R[8] - R[2] * R[6]) * id; inv[7] = -(R[0] * R[5] - R[2] * R[3]) * id; inv[2] = (R[3] * R[7] - R[4] * R[6]) * id; inv[5] = -(R[0] * R[7] - R[1] * R[6]) * id; inv[8] = (R[0] * R[4] - R[1] * R[3]) * id; let diff = 0; for (let i = 0; i < 9; i++) { tmp[i] = 0.5 * (R[i] + inv[i]); diff += Math.abs(tmp[i] - R[i]); } R.set(tmp); if (diff < 1e-5) break; } return R; } /** Clamp singular values — the mechanism behind snow's cracking and sand's flow. */ const _clR = m3(), _clRt = m3(), _clS = m3(); function clampSingular(F, lo, hi) { // Jacobi SVD on the small symmetric F^T F is overkill here; clamping the // stretch through a few polar iterations keeps the volume in range and is // stable enough for a real-time demo. const R = _clR, Rt = _clRt, S = _clS; polarR(F, R); transpose(R, Rt); mul(Rt, F, S); for (let i = 0; i < 3; i++) { const d = S[i * 3 + i]; S[i * 3 + i] = Math.min(hi, Math.max(lo, d)); } mul(R, S, F); return F; } export class Simulation { /** * @param {Float32Array} positions xyz per particle, expected inside the unit box * @param {Float32Array} scales per-particle gaussian sigma (xyz) */ constructor(positions, scales, material = 'jelly') { this.n = positions.length / 3; this.x = Float32Array.from(positions); this.v = new Float32Array(this.n * 3); this.C = new Float32Array(this.n * 9); // affine velocity (APIC) this.F = new Float32Array(this.n * 9); // deformation gradient this.Jp = new Float32Array(this.n).fill(1); // plastic volume ratio this.sigma0 = Float32Array.from(scales); this.sigma = Float32Array.from(scales); this.rot = new Float32Array(this.n * 9); // per-particle frame for render for (let i = 0; i < this.n; i++) { this.F.set(ident(), i * 9); this.rot.set(ident(), i * 9); } this.setMaterial(material); const g = (N + 1) ** 3; this.gv = new Float32Array(g * 3); this.gm = new Float32Array(g); this.gravity = -9.8; this.time = 0; // Scratch reused across every particle and substep. Allocating these inside // the loop was costing more than the arithmetic: at 4.5k particles and 12 // substeps a frame that is ~1.6M short-lived arrays for the GC to chase. this._wx = new Float32Array(3); this._wy = new Float32Array(3); this._wz = new Float32Array(3); this._R = m3(); this._Rt = m3(); this._FT = m3(); this._tmpA = m3(); this._stress = m3(); this._affine = m3(); this._newC = m3(); this._Fnew = m3(); this._upd = m3(); this._Fp = m3(); } setMaterial(name) { const m = MATERIALS[name] || MATERIALS.jelly; this.mat = m; this.matName = name; this.mu0 = m.E / (2 * (1 + m.nu)); this.lambda0 = m.E * m.nu / ((1 + m.nu) * (1 - 2 * m.nu)); this.pMass = m.rho * DX * DX * DX * 0.25; this.pVol = DX * DX * DX * 0.25; } reset(positions) { this.x.set(positions); this.v.fill(0); this.C.fill(0); this.Jp.fill(1); this.sigma.set(this.sigma0); for (let i = 0; i < this.n; i++) { this.F.set(ident(), i * 9); this.rot.set(ident(), i * 9); } this.time = 0; } step(dt) { const { x, v, C, F, Jp, gv, gm } = this; gv.fill(0); gm.fill(0); const stride = N + 1; const gi = (i, j, k) => (i * stride + j) * stride + k; const R = this._R, FT = this._FT, tmpA = this._tmpA; const stress = this._stress, affine = this._affine; const wx = this._wx, wy = this._wy, wz = this._wz; // ---- particle to grid for (let p = 0; p < this.n; p++) { const px = x[p * 3] * INV_DX, py = x[p * 3 + 1] * INV_DX, pz = x[p * 3 + 2] * INV_DX; const bi = Math.floor(px - 0.5), bj = Math.floor(py - 0.5), bk = Math.floor(pz - 0.5); if (bi < 0 || bj < 0 || bk < 0 || bi + 2 >= stride || bj + 2 >= stride || bk + 2 >= stride) continue; const fx = px - bi, fy = py - bj, fz = pz - bk; wx[0] = 0.5 * (1.5 - fx) ** 2; wx[1] = 0.75 - (fx - 1) ** 2; wx[2] = 0.5 * (fx - 0.5) ** 2; wy[0] = 0.5 * (1.5 - fy) ** 2; wy[1] = 0.75 - (fy - 1) ** 2; wy[2] = 0.5 * (fy - 0.5) ** 2; wz[0] = 0.5 * (1.5 - fz) ** 2; wz[1] = 0.75 - (fz - 1) ** 2; wz[2] = 0.5 * (fz - 0.5) ** 2; const Fp = this._Fp; for (let q = 0; q < 9; q++) Fp[q] = F[p * 9 + q]; const J = det3(Fp); let mu = this.mu0, lambda = this.lambda0; if (this.mat.hardening) { const h = Math.exp(this.mat.hardening * (1 - Jp[p])); mu *= h; lambda *= h; } if (this.mat.model === 'sand') mu *= 0.35; // grains shear far more easily // fixed corotated stress: 2mu (F - R) F^T + lambda (J-1) J I polarR(Fp, R); transpose(Fp, FT); for (let i = 0; i < 9; i++) tmpA[i] = Fp[i] - R[i]; mul(tmpA, FT, stress); const vol = lambda * (J - 1) * J; for (let i = 0; i < 9; i++) stress[i] *= 2 * mu; stress[0] += vol; stress[4] += vol; stress[8] += vol; const k = -dt * this.pVol * 4 * INV_DX * INV_DX; for (let i = 0; i < 9; i++) affine[i] = stress[i] * k + this.pMass * C[p * 9 + i]; for (let a = 0; a < 3; a++) { for (let b = 0; b < 3; b++) { for (let c = 0; c < 3; c++) { const w = wx[a] * wy[b] * wz[c]; const dpx = (a - fx) * DX, dpy = (b - fy) * DX, dpz = (c - fz) * DX; const g = gi(bi + a, bj + b, bk + c); gm[g] += w * this.pMass; gv[g * 3] += w * (this.pMass * v[p * 3] + affine[0] * dpx + affine[1] * dpy + affine[2] * dpz); gv[g * 3 + 1] += w * (this.pMass * v[p * 3 + 1] + affine[3] * dpx + affine[4] * dpy + affine[5] * dpz); gv[g * 3 + 2] += w * (this.pMass * v[p * 3 + 2] + affine[6] * dpx + affine[7] * dpy + affine[8] * dpz); } } } } // ---- grid update: gravity, boundaries, friction on the floor for (let i = 0; i <= N; i++) { for (let j = 0; j <= N; j++) { for (let k = 0; k <= N; k++) { const g = gi(i, j, k); const m = gm[g]; if (m <= 0) continue; let vx = gv[g * 3] / m, vy = gv[g * 3 + 1] / m + dt * this.gravity, vz = gv[g * 3 + 2] / m; // A thin grid boundary only; the real contact is applied per // particle after the transfer. const B = 2; if (i < B && vx < 0) vx = 0; if (i > N - B && vx > 0) vx = 0; if (k < B && vz < 0) vz = 0; if (k > N - B && vz > 0) vz = 0; if (j > N - B && vy > 0) vy = 0; if (j < B && vy < 0) vy = 0; gv[g * 3] = vx; gv[g * 3 + 1] = vy; gv[g * 3 + 2] = vz; } } } // ---- grid to particle, and transport the covariance by F const newC = this._newC, Fnew = this._Fnew, upd = this._upd; for (let p = 0; p < this.n; p++) { const px = x[p * 3] * INV_DX, py = x[p * 3 + 1] * INV_DX, pz = x[p * 3 + 2] * INV_DX; const bi = Math.floor(px - 0.5), bj = Math.floor(py - 0.5), bk = Math.floor(pz - 0.5); if (bi < 0 || bj < 0 || bk < 0 || bi + 2 >= stride || bj + 2 >= stride || bk + 2 >= stride) continue; const fx = px - bi, fy = py - bj, fz = pz - bk; wx[0] = 0.5 * (1.5 - fx) ** 2; wx[1] = 0.75 - (fx - 1) ** 2; wx[2] = 0.5 * (fx - 0.5) ** 2; wy[0] = 0.5 * (1.5 - fy) ** 2; wy[1] = 0.75 - (fy - 1) ** 2; wy[2] = 0.5 * (fy - 0.5) ** 2; wz[0] = 0.5 * (1.5 - fz) ** 2; wz[1] = 0.75 - (fz - 1) ** 2; wz[2] = 0.5 * (fz - 0.5) ** 2; let nvx = 0, nvy = 0, nvz = 0; newC.fill(0); for (let a = 0; a < 3; a++) { for (let b = 0; b < 3; b++) { for (let c = 0; c < 3; c++) { const w = wx[a] * wy[b] * wz[c]; const g = gi(bi + a, bj + b, bk + c); const gx = gv[g * 3], gy = gv[g * 3 + 1], gz = gv[g * 3 + 2]; nvx += w * gx; nvy += w * gy; nvz += w * gz; const dpx = (a - fx), dpy = (b - fy), dpz = (c - fz); const s = 4 * INV_DX * w; newC[0] += s * gx * dpx; newC[1] += s * gx * dpy; newC[2] += s * gx * dpz; newC[3] += s * gy * dpx; newC[4] += s * gy * dpy; newC[5] += s * gy * dpz; newC[6] += s * gz * dpx; newC[7] += s * gz * dpy; newC[8] += s * gz * dpz; } } } v[p * 3] = nvx; v[p * 3 + 1] = nvy; v[p * 3 + 2] = nvz; C.set(newC, p * 9); let nx = x[p * 3] + dt * nvx; let ny = x[p * 3 + 1] + dt * nvy; let nz = x[p * 3 + 2] + dt * nvz; // Contact at the particle, not only on the grid. Zeroing downward // velocity across a three-cell band lets a soft material sink into it // and compact to a wafer, because nothing stops a particle that is // already inside. A hard plane cannot be passed at any stiffness. const FLOOR = 0.02, WALL = 0.02; if (ny < FLOOR) { ny = FLOOR; if (nvy < 0) { v[p * 3 + 1] = 0; const fr = this.mat.model === 'sand' ? 0.6 : 0.2; v[p * 3] *= (1 - fr); v[p * 3 + 2] *= (1 - fr); } } if (nx < WALL) { nx = WALL; if (nvx < 0) v[p * 3] = 0; } if (nx > 1 - WALL) { nx = 1 - WALL; if (nvx > 0) v[p * 3] = 0; } if (nz < WALL) { nz = WALL; if (nvz < 0) v[p * 3 + 2] = 0; } if (nz > 1 - WALL) { nz = 1 - WALL; if (nvz > 0) v[p * 3 + 2] = 0; } if (ny > 1 - WALL) { ny = 1 - WALL; if (nvy > 0) v[p * 3 + 1] = 0; } x[p * 3] = nx; x[p * 3 + 1] = ny; x[p * 3 + 2] = nz; // F <- (I + dt C) F const Fp = this._Fp; for (let q = 0; q < 9; q++) Fp[q] = F[p * 9 + q]; upd.set(newC); for (let i = 0; i < 9; i++) upd[i] *= dt; upd[0] += 1; upd[4] += 1; upd[8] += 1; mul(upd, Fp, Fnew); switch (this.mat.model) { case 'snow': clampSingular(Fnew, 1 - this.mat.critC, 1 + this.mat.critS); Jp[p] = Math.max(0.6, Math.min(1.4, det3(Fnew))); break; case 'sand': clampSingular(Fnew, 0.92, 1.06); break; case 'plastic': { const y = this.mat.yield; clampSingular(Fnew, 1 - y, 1 + y * 3); break; } default: break; // jelly: purely elastic } // Σ' = F Σ Fᵀ. With Σ diagonal the render only needs the transformed // axes, so the frame is stored and the scales come out of its columns. for (let q = 0; q < 9; q++) { F[p * 9 + q] = Fnew[q]; this.rot[p * 9 + q] = Fnew[q]; } } this.time += dt; } }