Context
For simulations that need real rigid-body physics — collision response, joints, constraints — you need a physics engine. Two realistic options for browser-based Three.js projects are cannon-es and Rapier.js.
I've used both for different simulations on this site. Here's a direct comparison.
At a Glance
| Criterion | cannon-es | Rapier.js |
|---|---|---|
| Language | TypeScript (JS fork of cannon.js) | Rust → compiled to WASM |
| Bundle size | ~150 KB gzip | ~500 KB gzip (WASM) |
| Performance | Good for <500 bodies | Excellent, scales to 10,000+ |
| Determinism | Not guaranteed | Deterministic across platforms |
| API ergonomics | Simple, JavaScript-native | Verbose (WASM bindings) |
| Three.js wrappers | cannon-es-debugger, easy setup | rapier3d-compat available |
| Collision shapes | Sphere, Box, Cylinder, Convex hull | All + Heightfield, Trimesh |
| Continuous collision | No (tunnelling possible) | Yes (CCD built in) |
When to Use cannon-es
cannon-es is the right choice when:
- You're prototyping and want fast setup
- Your scene has under ~300 rigid bodies
- Bundle size matters (PWA, slow connections)
- You need a clean Three.js integration without WASM loading complexity
import * as CANNON from 'cannon-es';
const world = new CANNON.World({ gravity: new CANNON.Vec3(0, -9.82, 0) });
const body = new CANNON.Body({ mass: 1, shape: new CANNON.Sphere(0.5) });
world.addBody(body);
// Sync in animation loop:
mesh.position.copy(body.position);
mesh.quaternion.copy(body.quaternion);
When to Use Rapier.js
Rapier is the right choice when:
- You need deterministic replay or multiplayer sync
- Body count exceeds ~500
- You need terrain collision (heightfield) or imported mesh shapes (trimesh)
- Tunnelling of fast-moving objects is a concern
My rule of thumb: Start with cannon-es. If you hit its limits (performance, tunnelling, trimesh collision), migrate to Rapier. The concepts transfer directly.
Hidden Gotcha: WASM Loading
Rapier's WASM module must be initialised before use. This adds async setup that can trip you up:
import RAPIER from '@dimforge/rapier3d-compat';
await RAPIER.init(); // Must await before any Rapier calls
const world = new RAPIER.World({ x: 0.0, y: -9.81, z: 0.0 });
If you call Rapier functions before the WASM is ready you get cryptic errors. Wrapping your sim initialisation in an async function solves this completely.