Tutorial · Physics · Position Based Dynamics · JavaScript
📅 July 2026 ⏱ ≈ 22 min 🎯 Intermediate

Particle Simulator with Springs — Position Based Dynamics (PBD)

Force-based mass-spring systems explode the moment stiffness gets high and the timestep gets long. Position Based Dynamics sidesteps the problem entirely: instead of integrating spring forces, it directly moves particles to satisfy distance constraints, then iterates. The result is a spring network that is unconditionally stable at any stiffness, cheap to compute, and trivial to combine with collisions, pinning, and dragging — the exact technique used in cloth, rope, and soft-body simulators like NVIDIA Flex and Unity's Obi.

1. Why PBD Instead of Force Springs

A classic mass-spring system computes a Hookean force for every spring, F = -k·(|d| - L₀)·d̂, sums forces per particle, and integrates with explicit or semi-implicit Euler. The problem is stiffness: to make a spring feel rigid you raise k, but a large k combined with a fixed timestep dt makes the system numerically stiff — the integrator overshoots, energy grows every step, and the whole net turns into an oscillating, exploding mess. You can shrink dt or switch to implicit integration (solving a linear system every frame), but both add real complexity for a browser demo running at 60 fps.

Position Based Dynamics (Müller et al., 2007) reframes the problem. Instead of asking "what force keeps this spring at rest length?", it asks "where should these two points be so the constraint is satisfied right now?" Each spring becomes a distance constraint that directly displaces the two particle positions toward the target length, weighted by inverse mass. Because it operates on positions, not forces, there is no stiffness-induced blow-up — stiffness is instead controlled by how many times you repeat the projection per frame.

Trade-off: PBD constraints are not physically exact springs — they are geometric projections that approximate spring behaviour under iteration. This is fine (and often preferable) for games and visualisations; if you need exact Hookean energy conservation, look at XPBD (Extended PBD), which reintroduces a compliance parameter equivalent to 1/k.

2. Verlet-Style Particles

PBD particles don't store velocity as a first-class variable during the constraint phase — they use Störmer-Verlet integration: velocity is implicit in the difference between the current and previous position. This makes constraint projection trivial (just move pos) while still producing correct motion once you reconcile positions back into a velocity at the end of the step.

class Particle {
  constructor(x, y, mass = 1) {
    this.pos     = { x, y };       // current position (predicted, then corrected)
    this.prevPos = { x, y };       // position at the end of last frame
    this.vel     = { x: 0, y: 0 };
    this.invMass = mass > 0 ? 1 / mass : 0; // 0 = pinned/static
  }
}

function predictPosition(p, dt, gravity) {
  // external forces (gravity) act on velocity, then we predict a new position
  p.vel.x += 0 * dt;         // no horizontal force
  p.vel.y += gravity * dt;
  p.prevPos.x = p.pos.x;
  p.prevPos.y = p.pos.y;
  p.pos.x += p.vel.x * dt;
  p.pos.y += p.vel.y * dt;
}

function reconcileVelocity(p, dt) {
  // after constraints moved p.pos, derive the velocity that explains the move
  p.vel.x = (p.pos.x - p.prevPos.x) / dt;
  p.vel.y = (p.pos.y - p.prevPos.y) / dt;
}

3. The Distance Constraint

A spring between particles A and B with rest length L₀ becomes a constraint function:

C(p_A, p_B) = |p_B − p_A| − L₀ Gradient w.r.t. each particle (unit direction along the spring): ∇C_A = −n̂, ∇C_B = +n̂, n̂ = (p_B − p_A) / |p_B − p_A| Mass-weighted correction that drives C → 0: Δp_A = −(w_A / (w_A + w_B)) · C · n̂ Δp_B = +(w_B / (w_A + w_B)) · C · n̂ where w_A = invMass_A, w_B = invMass_B. A particle with invMass = 0 (pinned) never moves — all the correction is absorbed by the other particle.

This is exactly the same shape as the impulse formula in a force-based solver, except it corrects position directly instead of accumulating a velocity change. Heavier particles (smaller invMass) move less; lighter particles move more — mass ratios are respected automatically.

function solveDistanceConstraint(a, b, restLength, stiffness = 1) {
  const dx = b.pos.x - a.pos.x;
  const dy = b.pos.y - a.pos.y;
  const dist = Math.hypot(dx, dy) || 1e-6; // avoid divide-by-zero
  const C = dist - restLength;

  const wSum = a.invMass + b.invMass;
  if (wSum === 0) return; // both pinned, nothing to do

  const nx = dx / dist, ny = dy / dist;
  const corr = (C / wSum) * stiffness; // stiffness in [0,1], see §5

  a.pos.x += a.invMass * corr * nx;
  a.pos.y += a.invMass * corr * ny;
  b.pos.x -= b.invMass * corr * nx;
  b.pos.y -= b.invMass * corr * ny;
}

4. The PBD Solver Loop

The full per-frame algorithm has three phases: predict, project (repeatedly), reconcile.

  1. Predict: apply gravity and external forces to velocities, then predict new positions (no constraints applied yet).
  2. Project: loop over all constraints N times, nudging positions toward satisfying each one. Because moving A to fix constraint 1 can un-satisfy constraint 2, this is a Gauss-Seidel-style relaxation — it converges toward a solution rather than solving exactly.
  3. Reconcile: derive velocity from the position delta and store it for the next frame's prediction and for rendering-time interpolation.
function simulate(particles, springs, dt) {
  const GRAVITY = 9.8;
  const SOLVER_ITERATIONS = 8;

  // 1. Predict
  for (const p of particles) {
    if (p.invMass === 0) continue; // pinned particles don't move
    predictPosition(p, dt, GRAVITY);
  }

  // 2. Project constraints, several passes for convergence
  for (let iter = 0; iter < SOLVER_ITERATIONS; iter++) {
    for (const s of springs) {
      solveDistanceConstraint(s.a, s.b, s.restLength, s.stiffness);
    }
  }

  // 3. Reconcile velocity from the position change
  for (const p of particles) {
    if (p.invMass === 0) continue;
    reconcileVelocity(p, dt);
  }
}
Unconditional stability: Notice there is no k (spring constant) anywhere in the loop. Because each projection directly sets positions rather than integrating a force, the solver cannot diverge numerically the way a stiff ODE can — the worst case is slow convergence, not explosion.

5. Stiffness via Iteration Count and SOR

Perceived "springiness" in PBD comes from two knobs, not from a force constant:

Iteration-count-independent stiffness (Müller et al. 2007, §3.2): k' = 1 − (1 − k)^(1/N) Use k' inside the loop instead of the raw k when N (solver iterations) can change at runtime — otherwise a 4-iteration and a 16-iteration solver will feel like different spring materials for the same k.

A useful mental model: iterations trade CPU for rigidity. A loose cloth might use 4 iterations; a taut trampoline sheet or a near-rigid rod approximated with constraints wants 15–30.

6. Velocity Damping and Stretch Limiting

Two refinements make the network look and feel much better:

Global velocity damping

Without damping, energy injected by dragging a particle never decays and the net jitters forever. Scale velocity by a damping factor each frame:

for (const p of particles) {
  p.vel.x *= 0.995;
  p.vel.y *= 0.995;
}

Stretch limiting

Cloth simulated with soft springs alone can stretch to several times its rest length under load. A common fix is a hard maximum-stretch constraint applied after the soft springs: if a spring exceeds, say, 10% over rest length, clamp it back exactly (stiffness = 1) regardless of the material's normal softness.

function limitStretch(a, b, restLength, maxStretch = 1.1) {
  const dx = b.pos.x - a.pos.x;
  const dy = b.pos.y - a.pos.y;
  const dist = Math.hypot(dx, dy) || 1e-6;
  const maxLen = restLength * maxStretch;
  if (dist <= maxLen) return;
  solveDistanceConstraint(a, b, maxLen, 1); // hard clamp, full stiffness
}

7. Building a Spring Network (Cloth Grid)

A grid of particles connected with three kinds of springs produces a convincing cloth or net: structural springs (horizontal/vertical neighbours) hold the shape, shear springs (diagonal neighbours) resist skewing, and bend springs (two cells away) resist folding.

function buildGrid(cols, rows, spacing) {
  const particles = [];
  const springs = [];

  for (let y = 0; y < rows; y++)
    for (let x = 0; x < cols; x++)
      particles.push(new Particle(x * spacing, y * spacing));

  const idx = (x, y) => y * cols + x;
  const link = (i, j, stiffness) => {
    const a = particles[i], b = particles[j];
    const rest = Math.hypot(a.pos.x - b.pos.x, a.pos.y - b.pos.y);
    springs.push({ a, b, restLength: rest, stiffness });
  };

  for (let y = 0; y < rows; y++)
    for (let x = 0; x < cols; x++) {
      // structural
      if (x < cols - 1) link(idx(x,y), idx(x+1,y), 0.9);
      if (y < rows - 1) link(idx(x,y), idx(x,y+1), 0.9);
      // shear (diagonals)
      if (x < cols - 1 && y < rows - 1) {
        link(idx(x,y),   idx(x+1,y+1), 0.6);
        link(idx(x+1,y), idx(x,y+1),   0.6);
      }
      // bend (2 cells away, stops sharp folding)
      if (x < cols - 2) link(idx(x,y), idx(x+2,y), 0.3);
      if (y < rows - 2) link(idx(x,y), idx(x,y+2), 0.3);
    }

  return { particles, springs };
}

8. Pinning, Dragging, and Collisions

PBD's mass-weighted correction makes interaction almost free:

function applyGroundCollision(particles, floorY, restitution = 0.3) {
  for (const p of particles) {
    if (p.invMass === 0 || p.pos.y <= floorY) continue;
    p.pos.y = floorY;
    // reflect stored prevPos so reconciled velocity bounces instead of stopping dead
    p.prevPos.y = floorY + (floorY - p.prevPos.y) * restitution;
  }
}

9. Common Pitfalls

Going further: Once distance constraints feel solid, look at XPBD (Extended Position Based Dynamics, Macklin et al. 2016), which adds a compliance term so constraint stiffness becomes independent of both iteration count and timestep — the modern standard used in NVIDIA Flex, Unity Obi, and Blender's cloth solver.