Skip to content

Physics

A Three.js guide for coding agents. Also covers physics engine, cannon-es, cannon.js, rapier, ammo.js, @react-three/rapier, and 22 more.

Show all 28 aliases

physics engine, cannon-es, cannon.js, rapier, ammo.js, @react-three/rapier, RigidBody, colliders, CuboidCollider, trimesh, convex hull collider, ContactMaterial, restitution, friction, applyImpulse, applyForce, world.step, fixed timestep, broadphase, body sleep, collision events, collision sound, InstancedRigidBodies, kinematic body, how do I add physics to three.js, how do I make objects fall and collide, object falls through floor, sync mesh with physics body

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
  • Vec3 mirrors Three's Vector3 (x, y, z, set), and Three's copy() accepts it, which is what makes syncing one-liners work

Bodies and shapes

  • A Body = mass + position + one or more shapes. mass: 0 makes it static (the floor). A body can compose multiple shapes via repeated addShape for 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 Plane is 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.Box takes half extents (new CANNON.Vec3(w/2, h/2, d/2)), not width/height/depth like BoxGeometry. Forgetting the halving makes boxes visually sink into the floor or float

Materials and contact materials

  • A physics Material is just a named tag on a body. A ContactMaterial pairs two of them and defines what happens on contact: friction (rub) and restitution (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.defaultContactMaterial beats a matrix of pairwise ContactMaterials, and you can then skip setting material on bodies entirely

Stepping and syncing

  • world.step(fixedTimeStep, deltaSinceLastCall, maxSubSteps), typically world.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 call clock.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 an objectsToUpdate array; 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, use body.position as the point, applied each frame before world.step
  • applyImpulse: writes into velocity directly, for instant kicks (projectile launch, jump)
  • applyLocalForce / applyLocalImpulse: same, but coordinates are local to the body (0,0,0 is the body center), convenient at spawn time
sphereBody.applyLocalForce(new CANNON.Vec3(150, 0, 0), new CANNON.Vec3(0, 0, 0))

Performance

  • Broadphase: default NaiveBroadphase tests every body against every other. Switch to SAPBroadphase(world) (sweep and prune); rare missed collisions only with very fast bodies
  • Sleep: world.allowSleep = true stops simulating bodies that have come to rest until code or a collision wakes them. Tune with sleepSpeedLimit / sleepTimeLimit if 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 = 0 or an already-playing sound ignores play(); 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
  • debug renders 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 colliders prop 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 fixed bodies; 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, HeightfieldCollider for terrains). Cuboid args are 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-collider position / rotation offset within the body. scale is NOT supported on <RigidBody>
  • A <RigidBody> needs no mesh at all: invisible walls are a fixed body with four CuboidColliders

Body settings

  • gravity on <Physics> (array of 3, changeable at runtime); gravityScale per body (0.2 floats like the moon, negative rises like helium)
  • restitution (default 0, no bounce) and friction (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 requires CoefficientCombineRule on the collider itself
  • Mass is auto-computed from collider shape and volume; to set it, use colliders={false} and put mass on the collider component. Mass does not change fall speed, it changes how forces and collisions resolve, so scale impulses by ref.current.mass()

Forces via refs

  • A ref on <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 / rotation props 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) or type="kinematicVelocity" (you feed velocity), then drive it in useFrame:
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 })
})
  • setNextKinematicRotation wants a Quaternion, not an Euler. Build a THREE.Euler, convert with Quaternion.setFromEuler; Three and Rapier math objects interoperate

Events

  • <RigidBody> props: onCollisionEnter, onCollisionExit, onSleep, onWake. Same sound recipe as cannon (reset currentTime, randomize volume); instantiate the Audio once with useState(() => 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 instances once with useMemo: one { key, position: [x,y,z], rotation: [x,y,z] } per instance. InstancedRigidBodies owns the matrices, so drop any manual setMatrixAt code and the mesh ref
  • Turn debug off 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 Plane floor, so bodies fly toward the camera
  • Passing full extents to CANNON.Box or CuboidCollider instead of half extents
  • Syncing position but not quaternion, so boxes appear to sink through the floor unrotated
  • Leaving NaiveBroadphase and 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 currentTime or thresholding impact strength
  • Using a trimesh collider on a dynamic body, causing tunneling and stuck objects
  • Setting scale on 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

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "physics" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems