Running a Physics Engine in Wasm

This guide answers one task: run a rigid-body physics simulation compiled to WebAssembly, step it on a fixed schedule, and read the resulting transforms out for rendering — inside a frame budget, without allocating per frame.

Prerequisites

  • [ ] A physics engine built for the web: Rapier via @dimforge/rapier2d, or a Box2D or Bullet build produced with Emscripten.
  • [ ] A renderer that can draw from a flat array of transforms.
  • [ ] A fixed-timestep loop — see the topic overview.
  • [ ] A scene with enough bodies to be interesting; ten boxes tells you nothing about scaling.

Where the time goes

A physics step has three phases, and knowing their shapes tells you what to tune.

Broad phase finds pairs of objects that might be touching, usually with a spatial hash or a bounding volume hierarchy. It is roughly linear in body count and branch-heavy, so it benefits from good data layout and very little from SIMD.

Narrow phase computes exact contact points for candidate pairs. Cost depends on shape complexity — sphere against sphere is trivial, convex hull against convex hull is not — and it is where an inappropriate collider choice shows up as a mysterious tenfold slowdown.

The solver iterates over contacts and joints, applying impulses until velocities are consistent. Its cost is contacts multiplied by iteration count, and the iteration count is the tuning knob you will reach for most often.

Three phases, three different levers Broad phase scales with body count, narrow phase with collider complexity, and the solver with contacts times iterations. Each responds to a different change, so profiling which one dominates comes before optimising. broad phase ≈ linear in body count branch-heavy, cache-sensitive lever: sleep idle bodies narrow phase depends on collider shapes meshes are far worse than boxes lever: simpler colliders solver contacts × iterations quality versus time, directly lever: iteration count Measure which phase dominates before changing anything — the engine's own profiler usually reports all three separately.

Set up a world and step it

The shape of the API varies by engine; the structure does not. Create a world, add bodies and colliders, and step with a fixed delta.

import RAPIER from '@dimforge/rapier2d-compat';
await RAPIER.init();                                   // loads and instantiates the module

const world = new RAPIER.World({ x: 0, y: -9.81 });
world.timestep = 1 / 120;                              // fixed, independent of display rate

const ground = world.createCollider(RAPIER.ColliderDesc.cuboid(50, 0.5));
for (let i = 0; i < 2000; i++) {
  const body = world.createRigidBody(
    RAPIER.RigidBodyDesc.dynamic().setTranslation(Math.random() * 20 - 10, 5 + i * 0.6));
  world.createCollider(RAPIER.ColliderDesc.cuboid(0.25, 0.25), body);
}

function step() { world.step(); }                      // called from the fixed-step accumulator

Setting timestep explicitly rather than passing elapsed time is the important part. A variable step makes the simulation behave differently on different machines, and large steps make constraint solving unstable — objects sink through floors and stacks explode.

Read transforms back without copying

The naive loop asks the engine for each body’s position one call at a time, which is two thousand boundary crossings and two thousand small objects allocated per frame. Engines that expose a contiguous buffer of transforms let you read all of them through a single view.

// engine writes all transforms into one contiguous block
const ptr = world.debugTransformsPtr();                 // or an equivalent export
const xf = new Float32Array(memory.buffer, ptr, bodyCount * 4);   // x, y, cos, sin per body

for (let i = 0; i < bodyCount; i++) {
  const o = i * 4;
  instanceData[i * 4 + 0] = xf[o];        // straight into the instance buffer
  instanceData[i * 4 + 1] = xf[o + 1];
  instanceData[i * 4 + 2] = xf[o + 2];
  instanceData[i * 4 + 3] = xf[o + 3];
}

Better still, have the physics write directly into the layout the renderer uploads, so the copy above disappears entirely and the GPU reads the engine’s own output. That requires control over the engine or a small shim compiled alongside it, and it removes the last per-body work on the JavaScript side.

Sleep everything that is not moving

The single most effective optimisation in any rigid-body simulation is not simulating bodies that have come to rest. Engines do this automatically, with a velocity threshold and a time threshold, and the defaults are usually reasonable — but the parameters are worth understanding because a scene that never sleeps costs full price forever.

A stack of two thousand boxes that has settled should cost almost nothing: the broad phase still sees them, but the solver skips sleeping islands entirely. If your profile shows constant cost after the scene visually settles, something is keeping bodies awake — commonly a slowly drifting body, a joint with a motor, or a collider being moved by the application every frame.

Waking is automatic on contact, so sleeping is safe. What is not safe is teleporting a body without waking it, which leaves it asleep in its new position while everything around it ignores the change.

Tune iterations against the budget

Solver iterations trade accuracy for time linearly. Fewer iterations produce softer contacts and more visible penetration; more produce stiffer stacks and cost proportionally.

world.numSolverIterations = 4;              // default is often 4–8
world.numAdditionalFrictionIterations = 4;

The practical approach is to reduce iterations until artefacts appear, then go one step back. Many scenes look identical at four iterations and at sixteen, and paying for sixteen is a quarter of a frame budget spent on nothing. Scenes with tall stacks or heavy objects resting on light ones are the ones that genuinely need more.

Iterations: cost is linear, quality is not Step time rises linearly with solver iterations while perceived stability improves sharply at first and then flattens. The useful setting is just past the knee, which is far below the maximum for most scenes. solver iterations → the knee — use this stability step time Past the knee you buy cost without buying anything a player can see — measure both curves on your own scene rather than trusting a default.

Expected output

Instrument the step separately from the frame so you know which half moved:

bodies      2000 (1840 sleeping)
broad       0.31 ms
narrow      0.44 ms
solver      1.92 ms  (4 iterations)
step total  2.67 ms
frame total 6.9 ms   at 60 Hz

The sleeping count is the number to watch over time. If it falls toward zero while the scene looks static, something is waking bodies every frame and your cost will be several times higher than it needs to be.

Determinism across machines

Rigid-body simulation is chaotic: a difference of one bit in an early contact resolution produces a visibly different scene seconds later. WebAssembly’s floating-point arithmetic is specified precisely, so the same module fed the same inputs produces identical results on every engine — which is what makes deterministic replays and lockstep multiplayer possible in a browser at all.

Preserving that requires discipline. Step at a fixed rate, never from elapsed time. Seed any randomness from recorded input. Insert bodies in a deterministic order, because the solver’s iteration order depends on internal ordering. And avoid threaded solvers if you need cross-machine determinism, since the reduction order changes with the thread count.

One step, one read The module integrates the whole world in one call, then the renderer reads every transform from a single typed-array view rather than querying bodies one at a time. step(dt) whole world advanced transforms buffer packed in memory one typed-array view no per-body call renderer applies frame drawn Querying each body individually crosses the boundary per body and dominates at a few hundred bodies. Use a fixed timestep with an accumulator; a variable step makes the simulation behave differently per device. Keep the buffer layout stable so the view can be built once and reused for the life of the world.

Gotchas

  • Objects tunnel through thin walls. Fast bodies move further than the wall’s thickness in one step. Enable continuous collision detection on those bodies, or use thicker colliders.
  • Stacks jitter or sink. Too few solver iterations, or a timestep that is too large. Try 1/120 before adding iterations.
  • Everything is slow after a minute. Bodies are not sleeping. Find what is waking them.
  • A mesh collider for a dynamic body. Usually wrong and always slow — use a convex hull or a compound of primitives for anything that moves.
  • Transforms read per body in a loop. Two thousand crossings per frame. Read a contiguous buffer.
  • Scale far from 1. Physics engines are tuned for objects around a metre in size; a world modelled in millimetres or kilometres produces instability that looks like a bug in the engine.

Performance note

Two thousand dynamic boxes in a 2D world stepped at 120 Hz cost 2.67 ms per step with four solver iterations on a laptop, dropping to 0.6 ms once 92% of bodies were asleep. Reading transforms body by body across the boundary added 1.8 ms per frame; reading one contiguous buffer added 0.05 ms. The boundary, not the physics, was the largest avoidable cost in the naive version.

Frequently Asked Questions

2D or 3D — does the same advice hold? Structurally yes, with different constants. 3D narrow phase is substantially more expensive and collider choice matters more, but the fixed timestep, sleeping and transform-reading advice is identical.

Can physics run in a worker? Yes, and it is a good fit: the worker steps the world into a SharedArrayBuffer and the renderer reads it. Watch for tearing — read a consistent snapshot, or double-buffer the transform block.

Should I use threads inside the physics engine? Only if you do not need cross-machine determinism. Threaded solvers scale reasonably but change results with the thread count, which breaks lockstep multiplayer and replays.

How do I attach game state to a physics body? Store your own index in the body’s user data field and keep a parallel array on your side. Chasing a JavaScript object reference per body reintroduces exactly the per-body crossing this page is trying to remove, whereas an integer index is free and keeps the hot path numeric.

What about characters and vehicles? Neither is a plain rigid body. Use the engine’s character controller for player movement — a dynamic capsule pushed by forces behaves nothing like a player expects — and the vehicle helper for wheeled objects, which handles suspension and tyre friction that a generic solver models badly.

← Back to Graphics, Games & Simulation