Skip to content

A world restored from a snapshot diverges from the live world when a soft body is in contact (JS 0.21.0) #1038

Open

Description

@whatrwewaitingf0r

Summary

With @dimforge/rapier3d-deterministic-compat 0.21.0, World.restoreSnapshot(world.takeSnapshot()) no longer continues
exactly like the world the snapshot was taken from, as soon as a soft body is in a contact that deforms it. With rigid
bodies only, the restored world and the live world stay bit-identical (as in 0.20.0 in our tests).

Stepping itself is deterministic, and so is restoring: two fresh processes produce identical per-step hashes, and two
worlds restored from the same bytes stay identical to each other. Only "live" vs "restored" differ, which suggests that
some state influencing the next step is not part of the snapshot.

Reproduction

repro-softbody-snapshot.mjs (full source at the end of this issue, 77 lines, depends on the Rapier package only):

node repro-softbody-snapshot.mjs                 # resolves @dimforge/rapier3d-deterministic-compat
node repro-softbody-snapshot.mjs path/to/dist/rapier.mjs
node repro-softbody-snapshot.mjs path/to/dist/rapier.mjs --rigid-only   # control

The scene is the sheet of the testbed demo softTearing.ts: a 24 × 24 cloth pinned along its border
(SoftBodyMaterial.uniform(100, 1), bend softness 3 Hz, no tear threshold, so the topology never changes), and a
rigid ball (radius 0.6, density 30) dropped on it from 3 units above. timestep = 1 / 64, sleeping disabled.

At every step k the script takes bytes = live.takeSnapshot(), builds restored = World.restoreSnapshot(bytes),
steps both worlds once, and compares every rigid-body pose and velocity and every particle position and velocity
bit for bit (Object.is).

Expected

0 diverging steps — what the control gives (--rigid-only replaces the cloth with a fixed rigid plate):

rapier 0.21.0, rigid plate + rigid ball (control), 160 steps
snapshot of the restored world equals the original bytes at every step: true
steps k where restore(snapshot(k)) + 1 step != live + 1 step: 0 of 160

Actual

rapier 0.21.0, pinned cloth + rigid ball, 160 steps
snapshot of the restored world equals the original bytes at every step: true
steps k where restore(snapshot(k)) + 1 step != live + 1 step: 37 of 160 (k = 48, 49, …, 67, 143, 144, …, 159)
first difference: {"snapshotStep":48,"field":"rigid body 5e-324 #3","live":-0.0004143694241065532,"restored":-0.0004143690748605877,"numbersDiffering":1478,"of":3495}

The first differing number is a rotation component of the cloth's hidden root body; after that single step 1478 of the
3495 compared numbers differ.

Versions

  • @dimforge/rapier3d-deterministic-compat 0.21.0 from npm (sha256 of dist/rapier.mjs:
    9f0c4ea949c1d632228f7b2557b4fdbe20b1981d131dffb55c0b19f89f6769b6).
  • Node v26.7.0, macOS (darwin, arm64).
  • Not tested: the non-deterministic, SIMD and 2D builds, the Rust API directly, other platforms.

What holds (so the problem is narrow)

  • restored.takeSnapshot() is byte-identical to the bytes it was restored from, at every step tested.
  • Two worlds restored from the same bytes stay bit-identical to each other on every following step — in one process
    (5 snapshot points × both solvers, up to 1945 steps) and in two fresh processes loading the same snapshot file
    (1686 steps).
  • Without any restore, two fresh Node processes produce identical per-step state hashes, and identical hashes of the
    snapshot bytes taken every 100th step, over 2000 steps, for both SoftBodySolver.Constraints and
    SoftBodySolver.Fem; headless Chrome 154 gives the same hashes as Node.
  • The same scenes with the soft bodies removed: 0 diverging steps of 450. A soft cuboid resting on the ground: 0 of 200.

What I narrowed down

Measured on a larger scene that is not included here (a 5 × 5 × 5 soft cuboid with Corotational cells, plasticity and a
tear strain; a pinned cloth; a cantilever beam; a few rigid bodies hitting them; 2000 steps): the restored world differs
from the live one after a single step for 1692 of 2000 snapshot steps (Constraints) and 1674 of 2000 (Fem). Restoring
is exact while everything rests before the first impact; it is not exact during the impacts, and it stays inexact — even
at rest — once the cuboid has been torn into pieces.

  • Size of the difference after one step: positions 1e-9 … 5e-7, velocities up to 8.5e-5 (scene scale ~1–10), i.e.
    more than last-bit noise; a few steps later the two worlds tear differently and end with different numbers of pieces.
  • Which feature (ground + one soft cuboid + a 131 kg rigid ball hitting it at 12 units/s; diverging steps of 420,
    Constraints / Fem): elastic only, no plasticity, no tearing — 3 / 5 (only during the impact); plasticity only —
    3 / 2; tearing only (cracks, no piece split off) — 0 / 2; plasticity and tearing (pieces split off and lie against each
    other) — 66 / 97. So neither plasticity nor tearing is needed.
  • softBodiesRecovery switches (larger scene, snapshot steps 0…449, Constraints; 142 diverging steps with the
    defaults): crossBodyDetection = false → 14; overlapConstraints = false → 16; both → 12;
    overlapRigid = false → 117; crossBodyExpelGate = false → 135; overlapNormalPush = false → 134;
    softBodiesMaxExtraSubsteps = 0 → 105. No change (142) for authoredVelocityMargin, invertedCellDetection,
    selfCrossingDetection, detectionMotionGating, selfStandDown, edgeStandDown, overlapSkipSelfTangled,
    overlapEdgeStandDown. The 12 steps that remain with both switches off are the cloth-and-ball contact (the repro
    above) and one step of the cuboid impact.
  • Two soft bodies (two elastic 4 × 4 × 4 Corotational cuboids, defaults otherwise, 400 steps): one dropped onto
    the other and resting on it — 0 diverging steps; the same two cuboids created overlapping by 0.4 — 4 diverging steps
    (k = 2, 4, 5, 6), and 0 with either crossBodyDetection = false or overlapConstraints = false.

So there seem to be at least two contributors: state kept by the cross-body crossing detection / overlap constraints
between soft surfaces (removed by the two switches; long-lived between the pieces of a torn body), and something in
soft–rigid contacts under strong deformation that those switches do not affect (the repro above).

repro-softbody-snapshot.mjs
// Rapier 0.21.0 (@dimforge/rapier3d-deterministic-compat): a world restored from a snapshot does not continue like the
// live world it was taken from, as soon as a soft body is in contact. Self-contained: only the Rapier package.
//   node repro-softbody-snapshot.mjs [path/to/dist/rapier.mjs] [--rigid-only]
// At every step k: bytes = live.takeSnapshot(); restored = World.restoreSnapshot(bytes); both worlds take ONE step;
// particle positions/velocities and rigid-body poses are compared bit for bit.
// Expected: 0 diverging steps, as in the control run (--rigid-only: a rigid plate instead of the cloth).
// Observed on 0.21.0 (Node 26.7, macOS arm64): 37 of 160 steps diverge (k = 48…67 and 143…159).
import { pathToFileURL } from "node:url";

const RAPIER = await import(process.argv[2] ? pathToFileURL(process.argv[2]).href : "@dimforge/rapier3d-deterministic-compat");
await RAPIER.init();
const SOFT = !process.argv.includes("--rigid-only");
const STEPS = 160;

function build() {
  const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
  world.timestep = 1 / 64;
  world.createCollider(RAPIER.ColliderDesc.cuboid(30, 0.1, 30), world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(0, -0.1, 0)));
  if (SOFT) {
    // The sheet of the testbed demo `softTearing.ts`: pinned along its border. No tearing threshold is set here,
    // so the topology never changes: the divergence does not need a tear.
    const n = 24, border = [];
    for (let k = 0; k < n * n; k += 1) if (k < n || k >= n * (n - 1) || k % n === 0 || k % n === n - 1) border.push(k);
    const material = RAPIER.SoftBodyMaterial.uniform(100, 1);
    material.bendSoftness = { naturalFrequency: 3, dampingRatio: 1 };
    world.createSoftBody(
      RAPIER.SoftBodyDesc.cloth({ x: -1.15, y: 3, z: -1.15 }, { x: 0.1, y: 0, z: 0 }, { x: 0, y: 0, z: 0.1 }, n, n)
        .setPinnedParticles(border).setMaterial(material).setParticleMass(0.02).setParticleRadius(0.05)
        .setSurfaceCollider(RAPIER.ColliderDesc.ball(0.05).setFriction(0.8)).setCanSleep(false),
    );
  } else {
    const plate = world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(0, 3, 0));
    world.createCollider(RAPIER.ColliderDesc.cuboid(1.15, 0.05, 1.15), plate);
  }
  // A heavy rigid ball dropped on it (27 kg), as in the demo.
  const ball = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 6, 0).setCanSleep(false));
  world.createCollider(RAPIER.ColliderDesc.ball(0.6).setDensity(30), ball);
  return world;
}

/** Every number of the simulation state we can read, labelled. */
function state(world) {
  const out = [];
  world.bodies.forEach((body) => {
    const t = body.translation(), r = body.rotation(), v = body.linvel(), w = body.angvel();
    [t.x, t.y, t.z, r.x, r.y, r.z, r.w, v.x, v.y, v.z, w.x, w.y, w.z].forEach((value, i) => out.push([`rigid body ${body.handle} #${i}`, value]));
  });
  world.softBodies?.forEach((body) => {
    body.particlePositions().forEach((value, i) => out.push([`soft body ${body.handle} position[${i}]`, value]));
    body.particleVelocities().forEach((value, i) => out.push([`soft body ${body.handle} velocity[${i}]`, value]));
  });
  return out;
}

const live = build();
const diverging = [];
let first = null;
let roundTripOk = true;
for (let k = 0; k < STEPS; k += 1) {
  const bytes = live.takeSnapshot();
  const restored = RAPIER.World.restoreSnapshot(bytes);
  const again = restored.takeSnapshot();
  roundTripOk &&= again.length === bytes.length && again.every((byte, i) => byte === bytes[i]);
  live.step();
  restored.step();
  const a = state(live), b = state(restored);
  const at = a.findIndex(([, value], i) => !Object.is(value, b[i][1]));
  if (at >= 0) {
    diverging.push(k);
    first ??= { snapshotStep: k, field: a[at][0], live: a[at][1], restored: b[at][1], numbersDiffering: a.filter(([, value], i) => !Object.is(value, b[i][1])).length, of: a.length };
  }
  restored.free();
}
console.log(`rapier ${RAPIER.version()}, ${SOFT ? "pinned cloth + rigid ball" : "rigid plate + rigid ball (control)"}, ${STEPS} steps`);
console.log(`snapshot of the restored world equals the original bytes at every step: ${roundTripOk}`);
console.log(`steps k where restore(snapshot(k)) + 1 step != live + 1 step: ${diverging.length} of ${STEPS}${diverging.length ? ` (k = ${diverging.join(", ")})` : ""}`);
if (first !== null) console.log(`first difference: ${JSON.stringify(first)}`);

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions