Mental model
- Physics runs in an invisible parallel world. Every visible mesh that should collide gets a twin body in the physics world. Each frame: step the physics world, then copy each body's position and quaternion onto its mesh, then render
- Choose 2D over 3D when the simulation can be projected onto a plane (pool game, side scroller). 2D engines are much faster. The sync code is the same, only the axes differ
- Engine landscape: cannon-es (
import * as CANNON from 'cannon-es', the maintained fork of abandoned cannon.js, easy API), Rapier (Rust compiled to WebAssembly, fast, deterministic, maintained, 2D and 3D, the modern default and what R3F uses), Ammo.js (Bullet port, wasm, more features, harder API). Avoid unmaintained cannon.js, Oimo, p2
World setup (cannon-es)
const world = new CANNON.World()
world.gravity.set(0, -9.82, 0)
world.broadphase = new CANNON.SAPBroadphase(world)
world.allowSleep = true
Vec3mirrors Three'sVector3(x, y, z,set), and Three'scopy()accepts it, which is what makes syncing one-liners work
Bodies and shapes
- A
Body= mass + position + one or more shapes.mass: 0makes it static (the floor). A body can compose multiple shapes via repeatedaddShapefor complex solid objects
const sphereBody = new CANNON.Body({
mass: 1,
position: new CANNON.Vec3(0, 3, 0),
shape: new CANNON.Sphere(0.5),
})
world.addBody(sphereBody)
const floorBody = new CANNON.Body()
floorBody.mass = 0
floorBody.addShape(new CANNON.Plane())
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(-1, 0, 0), Math.PI * 0.5)
world.addBody(floorBody)
- Gotchas: a
Planeis infinite and faces +z by default, so an unrotated floor makes objects shoot sideways. Rotation is quaternion-only,setFromAxisAngle(axis, angle)is the usual tool CANNON.Boxtakes half extents (new CANNON.Vec3(w/2, h/2, d/2)), not width/height/depth likeBoxGeometry. Forgetting the halving makes boxes visually sink into the floor or float
Materials and contact materials
- A physics
Materialis just a named tag on a body. AContactMaterialpairs two of them and defines what happens on contact:friction(rub) andrestitution(bounce), both defaulting to 0.3
const defaultMaterial = new CANNON.Material('default')
const defaultContactMaterial = new CANNON.ContactMaterial(
defaultMaterial, defaultMaterial,
{ friction: 0.1, restitution: 0.7 }
)
world.addContactMaterial(defaultContactMaterial)
world.defaultContactMaterial = defaultContactMaterial
- Unless you genuinely need per-pair behavior, one default material assigned via
world.defaultContactMaterialbeats a matrix of pairwise ContactMaterials, and you can then skip settingmaterialon bodies entirely
Stepping and syncing
world.step(fixedTimeStep, deltaSinceLastCall, maxSubSteps), typicallyworld.step(1/60, deltaTime, 3). The fixed timestep keeps simulation speed identical across refresh rates- Compute delta yourself from
clock.getElapsedTime()minus the previous frame's value. Do NOT callclock.getDelta(), it interferes with the Clock's internal state and gives wrong results
let oldElapsedTime = 0
const tick = () => {
const elapsedTime = clock.getElapsedTime()
const deltaTime = elapsedTime - oldElapsedTime
oldElapsedTime = elapsedTime
world.step(1 / 60, deltaTime, 3)
for (const object of objectsToUpdate) {
object.mesh.position.copy(object.body.position)
object.mesh.quaternion.copy(object.body.quaternion)
}
renderer.render(scene, camera)
}
- Copy the quaternion too, not just position. Spheres hide the bug because rotation is invisible on them; boxes visibly stand upright while their body tumbles, then appear to fall through the floor
Managing many objects
- Pattern: a
createX(size, position)factory that builds mesh + body together and pushes{ mesh, body }into anobjectsToUpdatearray; the tick loop syncs the whole array - Share one geometry and one material across all meshes of a kind; build the geometry at unit size and use
mesh.scale.set(...)for per-object size. Creating geometry and material per object is the main perf leak - Reset/removal: for each object,
body.removeEventListener('collide', handler),world.removeBody(body),scene.remove(mesh), then empty the array (arr.splice(0, arr.length)). Forgetting the event listener removal leaks handlers
Forces and impulses
applyForce(force, worldPoint): continuous push, accumulate into acceleration (wind, sustained thrust). For a body-wide force, usebody.positionas the point, applied each frame beforeworld.stepapplyImpulse: writes into velocity directly, for instant kicks (projectile launch, jump)applyLocalForce/applyLocalImpulse: same, but coordinates are local to the body (0,0,0is the body center), convenient at spawn time
sphereBody.applyLocalForce(new CANNON.Vec3(150, 0, 0), new CANNON.Vec3(0, 0, 0))
Performance
- Broadphase: default
NaiveBroadphasetests every body against every other. Switch toSAPBroadphase(world)(sweep and prune); rare missed collisions only with very fast bodies - Sleep:
world.allowSleep = truestops simulating bodies that have come to rest until code or a collision wakes them. Tune withsleepSpeedLimit/sleepTimeLimitif needed - Workers: physics competes with rendering on the main thread. Moving the simulation into a Web Worker and messaging positions back is the real fix for CPU-bound scenes
Collision events and sounds
- Bodies emit
'collide','sleep','wakeup'. Wire sound playback to collide:
const hitSound = new Audio('/sounds/hit.mp3')
const playHitSound = (collision) => {
const impactStrength = collision.contact.getImpactVelocityAlongNormal()
if (impactStrength > 1.5) {
hitSound.volume = Math.random()
hitSound.currentTime = 0
hitSound.play()
}
}
body.addEventListener('collide', playHitSound)
- Three gotchas: reset
currentTime = 0or an already-playing sound ignoresplay(); gate on impact strength (getImpactVelocityAlongNormal()) or every grazing touch fires; browsers block audio before the first user interaction. Randomized volume plus multiple sound variants plus a short replay cooldown is the polish path
Constraints
HingeConstraint(door hinge),DistanceConstraint(keep bodies apart at a distance),LockConstraint(weld into one piece),PointToPointConstraint(pin at a point). Rapier calls these joints and adds rope/robot-arm style articulations
React Three Fiber: @react-three/rapier
The R3F path removes all the manual twin-world bookkeeping. No stepping, no sync loop, no shape math.
Setup
import { Physics, RigidBody } from '@react-three/rapier'
<Physics debug gravity={[0, -9.81, 0]}>
<RigidBody colliders="ball" position={[-1.5, 2, 0]}>
<mesh castShadow><sphereGeometry /><meshStandardMaterial color="orange" /></mesh>
</RigidBody>
<RigidBody type="fixed">
<mesh receiveShadow position-y={-1.25}>
<boxGeometry args={[10, 0.5, 10]} /><meshStandardMaterial color="greenyellow" />
</mesh>
</RigidBody>
</Physics>
<RigidBody>must live inside<Physics>. Wrapping a mesh makes it dynamic;type="fixed"makes it static; mass, colliders, and sync are automatic- Make floors thick boxes, not thin planes: engines miss collisions on thin geometry
debugrenders collider wireframes. Dev only: the wireframes themselves are unoptimized and tank performance at scale. HMR support is imperfect, reload after physics changes
Colliders
- Automatic colliders via the
collidersprop on<RigidBody>:"cuboid"(default, fits boxes even after scale changes),"ball","hull"(convex hull, elastic-membrane fit, ignores holes),"trimesh"(exact triangles, holes included) - Trimesh warning: trimesh colliders are hollow, so fast dynamic bodies tunnel through or stick. Reserve trimesh for
fixedbodies; dynamic bodies get hull or primitives - Approximate is fine: box colliders on complex models are a normal, performance-friendly choice players are used to. Cuboid and ball are cheapest, hull costs more, trimesh most
- Multiple meshes inside one
<RigidBody>become one compound body with one collider each; offset parts shift the center of mass realistically - Custom colliders: set
colliders={false}and add collider components (CuboidCollider,BallCollider,CylinderCollider,CapsuleCollider,ConeCollider,RoundCuboidCollider,ConvexHullCollider,TrimeshCollider,HeightfieldColliderfor terrains). Cuboidargsare half extents; cylinder and capsule take half height first - Position and rotation for custom-collider bodies go on the
<RigidBody>(colliders and mesh inherit them); per-colliderposition/rotationoffset within the body.scaleis NOT supported on<RigidBody> - A
<RigidBody>needs no mesh at all: invisible walls are a fixed body with fourCuboidColliders
Body settings
gravityon<Physics>(array of 3, changeable at runtime);gravityScaleper body (0.2 floats like the moon, negative rises like helium)restitution(default 0, no bounce) andfriction(default 0.7) as<RigidBody>props. Rapier averages the two bodies' values on contact; both surfaces need restitution for a strong bounce. Changing the combine rule requiresCoefficientCombineRuleon the collider itself- Mass is auto-computed from collider shape and volume; to set it, use
colliders={false}and putmasson the collider component. Mass does not change fall speed, it changes how forces and collisions resolve, so scale impulses byref.current.mass()
Forces via refs
- A
refon<RigidBody>gives the raw Rapier RigidBody with its full API:applyImpulse(short kick, jumps),addForce(sustained, wind),applyTorqueImpulse(rotation kick). Plain{ x, y, z }objects work as vectors
const cube = useRef()
const cubeJump = () => {
const mass = cube.current.mass()
cube.current.applyImpulse({ x: 0, y: 5 * mass, z: 0 })
cube.current.applyTorqueImpulse({ x: Math.random() - 0.5, y: Math.random() - 0.5, z: Math.random() - 0.5 })
}
- R3F pointer events still work on meshes inside RigidBodies (
onClick={cubeJump})
Moving objects: never teleport
- Do not mutate
position/rotationprops of dynamic or fixed bodies at runtime; that teleports them and breaks the simulation. Those props set the starting pose only - To move something on a schedule (platforms, carousels), use
type="kinematicPosition"(you feed the next pose, Rapier derives velocity) ortype="kinematicVelocity"(you feed velocity), then drive it inuseFrame:
useFrame((state) => {
const time = state.clock.getElapsedTime()
const euler = new THREE.Euler(0, time * 3, 0)
const quat = new THREE.Quaternion().setFromEuler(euler)
twister.current.setNextKinematicRotation(quat)
const angle = time * 0.5
twister.current.setNextKinematicTranslation({ x: Math.cos(angle) * 2, y: -0.8, z: Math.sin(angle) * 2 })
})
setNextKinematicRotationwants a Quaternion, not an Euler. Build aTHREE.Euler, convert withQuaternion.setFromEuler; Three and Rapier math objects interoperate
Events
<RigidBody>props:onCollisionEnter,onCollisionExit,onSleep,onWake. Same sound recipe as cannon (resetcurrentTime, randomize volume); instantiate the Audio once withuseState(() => new Audio('./hit.mp3'))so re-renders do not recreate it- Sleeping is automatic in Rapier and a sleeping body ignores everything until collided with or acted on by code, so remove noisy event logs before stress tests
Instanced meshes at scale
- Render hundreds of physics cubes in one draw call: wrap
<instancedMesh args={[null, null, count]}>(geometry and material declared as children) in<InstancedRigidBodies instances={instances} /> - Build
instancesonce withuseMemo: one{ key, position: [x,y,z], rotation: [x,y,z] }per instance. InstancedRigidBodies owns the matrices, so drop any manualsetMatrixAtcode and the mesh ref - Turn
debugoff before stress testing; the collider wireframes are usually the bottleneck, not the physics
Common mistakes
- Using
clock.getDelta()for the physics delta, corrupting the Clock and the step size - Forgetting to rotate the cannon
Planefloor, so bodies fly toward the camera - Passing full extents to
CANNON.BoxorCuboidColliderinstead of half extents - Syncing position but not quaternion, so boxes appear to sink through the floor unrotated
- Leaving
NaiveBroadphaseand sleep disabled in scenes with many bodies - Creating a new geometry and material per spawned object instead of sharing them
- Removing bodies without removing their collide listeners
- Playing collision sounds without resetting
currentTimeor thresholding impact strength - Using a trimesh collider on a dynamic body, causing tunneling and stuck objects
- Setting
scaleon a<RigidBody>(unsupported) or putting position/rotation on the mesh when custom colliders need them on the body - Mutating a dynamic body's position at runtime instead of applying forces or using a kinematic type
- Shipping with
<Physics debug>enabled and blaming the physics engine for the frame drop