Three.js From Zero · Article s2-01

Physics: Rigid Bodies with Rapier

← threejs-from-zero S2 · Article 01 Season 2
⚡ Season 2 begins — Real Worlds
Article S2-01 · Three.js From Zero

Physics: Rigid Bodies with Rapier

Welcome to Season 2. In Season 1 you learned to render anything. Now you make the render behave. We start with physics — the single most effective upgrade you can make to a 3D scene, and the foundation for everything else this season (character controllers, VR grabs, multiplayer interactions, procedural worlds).

We'll use Rapier — a deterministic Rust physics engine compiled to WebAssembly. It runs at native speed in the browser, is used by Bevy and a dozen production Three.js apps, and has a clean JavaScript API.

The demo below is a physics playground. Drop boxes and balls, adjust gravity, watch them pile up on a tilted ramp. Everything you see is Three.js meshes visually, with their transforms driven each frame by Rapier's simulation.

loading Rapier (WASM)…
drag to orbit · press reset to drop

The mental model

A physics engine has three layers you have to hold in your head:

LayerPurpose
WorldThe simulation container. Has gravity, a timestep, and all the bodies.
RigidBodyA thing with mass + inertia + velocity. Has a position/rotation. Moves under forces.
ColliderA shape attached to a body that participates in collisions. Body can have multiple.

Your Three.js scene is purely visual. The physics world is purely numerical. Every frame, you:

  1. Step the physics world forward by a fixed amount of time.
  2. Read the position and rotation of each body.
  3. Copy those into the corresponding Three.js mesh.
  4. Render the scene.

That's the whole integration. Mesh ↔ body are linked by convention; Three.js doesn't know Rapier exists, and Rapier doesn't know Three.js exists.

Loading Rapier

Rapier ships as WebAssembly. You can use the "compat" build which bundles the .wasm inline (one import, no separate asset), or the regular build which loads the .wasm separately (slightly faster startup, one more HTTP request).

// Compat build — easiest, works in a single HTML file
import RAPIER from '@dimforge/rapier3d-compat';

await RAPIER.init();    // parse the WASM — do this once at app start

Once initialized, RAPIER.World and the rest of the API are available synchronously.

Creating a world

const gravity = { x: 0, y: -9.81, z: 0 };
const world = new RAPIER.World(gravity);

That's it. The world is empty but running. Every call to world.step() advances time by a fixed amount (the default is 1/60 second, 16.6ms).

Adding a static ground

// A fixed (never moves) rigid body, and a cuboid collider attached to it
const groundBody = world.createRigidBody(
  RAPIER.RigidBodyDesc.fixed().setTranslation(0, -0.5, 0)
);
world.createCollider(
  RAPIER.ColliderDesc.cuboid(20, 0.5, 20),
  groundBody,
);

Three body types:

  • fixed() — doesn't move, infinite mass. Walls, floors, static geometry.
  • dynamic() — simulated normally. Moves under forces, collisions, gravity.
  • kinematicPositionBased() / kinematicVelocityBased() — you set its position/velocity directly; it pushes other dynamic bodies but isn't pushed. Elevators, moving platforms, character controllers.

Adding a dynamic body

const body = world.createRigidBody(
  RAPIER.RigidBodyDesc.dynamic()
    .setTranslation(0, 5, 0)
    .setLinvel(0, 0, 0)               // initial velocity
    .setAngvel({ x: 0, y: 0, z: 0 })  // initial angular velocity
    .setLinearDamping(0.1)            // slow down over time
    .setAngularDamping(0.1)
);

world.createCollider(
  RAPIER.ColliderDesc.cuboid(0.5, 0.5, 0.5)
    .setRestitution(0.3)              // bounciness
    .setFriction(0.5),
  body,
);

Two material properties govern how things behave on contact:

  • Restitution (bounciness) — 0 = no bounce, 1 = perfect bounce. Super-balls: 0.9. Bowling balls: 0.1. Clay: 0.
  • Friction — how much bodies resist sliding past each other. 0 = ice, 1 = rubber, 2+ = feels glued.

When two bodies collide, the pair's restitution and friction are combined by a rule (default: multiplied). That's why both need values.

The fixed-timestep loop

Physics must step with a fixed timestep — otherwise stability tanks at variable frame rates. But rendering is whenever the browser wants to paint. The bridge is an accumulator:

const PHYS_DT = 1/60;   // 60 Hz physics
let accumulator = 0;

const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
  const frameDt = Math.min(clock.getDelta(), 0.1);  // clamp the spiral-of-death
  accumulator += frameDt;

  // step as many fixed ticks as needed to catch up
  while (accumulator >= PHYS_DT) {
    world.step();
    accumulator -= PHYS_DT;
  }

  // sync every body → its mesh
  for (const { mesh, body } of bodies) {
    const t = body.translation();
    const r = body.rotation();
    mesh.position.set(t.x, t.y, t.z);
    mesh.quaternion.set(r.x, r.y, r.z, r.w);
  }

  renderer.render(scene, camera);
});

Three gotchas in this loop:

  1. Clamp frameDt. If the user tabs away for 10 seconds, you'd otherwise run 600 physics steps at once when they come back — the spiral of death. Clamp to 0.1s (6 steps).
  2. Interpolate for smoothness (advanced). Rendering between physics ticks looks slightly jerky. Lerp between the previous body state and the current one by accumulator / PHYS_DT. We skip this in the demo but it's the industry-standard trick.
  3. Sync all bodies every frame, even sleepers. Rapier's isSleeping() is a perf hint, not a guarantee the position is stale. Sync unconditionally unless profiling tells you otherwise.

Sleeping — when the engine stops simulating

Idle bodies don't need integrating every frame. Rapier tracks a body's recent velocity and, if it stays below a threshold, marks it sleeping. Sleeping bodies cost basically nothing in the broadphase.

A new contact wakes sleeping neighbours automatically. You can also force wake:

body.wakeUp();
body.isSleeping();         // → boolean

This is why the demo can have 300 bodies stacked without melting your CPU — once the pile settles, 95% of them are sleeping.

Applying forces and impulses

body.applyImpulse({ x: 0, y: 5, z: 0 }, true);   // instantaneous Δvelocity
body.addForce({ x: 0, y: 10, z: 0 }, true);      // continuous (reset next step)
body.setLinvel({ x: 0, y: 3, z: 0 }, true);      // directly set velocity
body.applyTorqueImpulse({ x: 0, y: 0.5, z: 0 }, true);

The true is "wake the body if it's sleeping" — you almost always want it.

Queries — raycast, shapecast, intersection

Rapier answers spatial questions on the physics world, not the render scene:

const ray = new RAPIER.Ray({ x: 0, y: 5, z: 0 }, { x: 0, y: -1, z: 0 });
const hit = world.castRay(ray, 100, true);
if (hit) {
  const point = ray.pointAt(hit.timeOfImpact);
  const body  = world.getRigidBody(hit.collider.parent());
}

Why you'd use this instead of Three.js's Raycaster: physics raycasts respect dynamics (moving colliders) and Rapier's broadphase is faster for thousands of bodies. Perfect for:

  • Character ground detection (castShape downward from the feet)
  • Line-of-sight AI
  • Projectile-style lasers
  • Placing a cursor/reticle on world surfaces

Determinism

Run the same simulation twice with the same inputs and the same initial state: do you get the same result? For single-player games: doesn't matter. For replays, multiplayer lockstep, or test snapshots: critical.

Rapier is deterministic if:

  • You use the same build (version + target platform).
  • You seed all randomness yourself.
  • You call the same methods in the same order.

Floating-point math is deterministic on a single machine and with a single build. Cross-machine determinism is harder; for that, use Rapier's fixed-point build or simulate identical inputs on each client (lockstep model).

Performance budget

Scene sizeTypical cost (mid-range desktop)
50 dynamic bodies, simple shapes< 0.5ms/step
500 dynamic bodies, boxes~1–2ms/step
2000 dynamic bodies, boxes~4–6ms/step (mostly sleeping)
Dynamic trimesh collider vs trimeshvery expensive — use convex hulls

Your physics budget at 60fps is 16ms total. Realistically reserve 2–5ms for physics on desktop, 1–2ms on mobile. Tricks:

  • Use simple colliders (cuboid, ball, capsule) not trimeshes for dynamics.
  • Put background bodies to sleep aggressively with setLinearDamping.
  • Disable collision between groups of bodies that don't need to interact (collision groups and filters).
  • For massive scenes, consider swapping to world.step(events) and processing contact events only when needed.

Common first-time pitfalls

  • Mesh doesn't move even though physics is running. Sync loop isn't copying body.translation() into mesh.position.
  • Bodies fly off the world axis. Gravity is wrong direction, or collider shape is tiny and they're inside each other at t=0 (the solver panics and applies huge forces).
  • Stack explodes. Restitution too high, or timestep too large, or dynamic masses wildly different.
  • Body clips through the floor at high speed. Tunneling — substep the physics (world.timestep = 1/120) or enable CCD on fast bodies (RigidBodyDesc.dynamic().setCcdEnabled(true)).
  • Scene freezes after tab-switch. Didn't clamp the frame delta; the accumulator is trying to catch up on minutes of missed time.
  • World never seems to simulate. You forgot await RAPIER.init() before any other Rapier calls.

Disposal

// Remove one body + its colliders:
world.removeRigidBody(body);

// When you're done with the whole sim:
world.free();

Exercises

  1. Add a click-to-spawn: raycast (Article 06 pattern) to the clicked point, then create a dynamic ball at the camera position with velocity toward the click point.
  2. Make the ground a tilted ramp via setRotation, then drop 100 cubes on it. Tune friction until you get a satisfying slide-to-stop.
  3. Replace the cubes with a glTF asteroid model from Article 05. Use a ConvexHull collider (not trimesh — convex is much cheaper).

What's next

Article S2-02 — Colliders Deep Dive. Every collision shape Rapier ships — cuboid, ball, capsule, cylinder, cone, convex hull, trimesh, heightfield — plus the trimesh-vs-convex tradeoff for imported glTFs.