Three.js From Zero · Article s2-02

Colliders Deep Dive

← threejs-from-zero S2 · Article 02 Season 2
Article S2-02 · Three.js From Zero

Colliders Deep Dive

In S2-01 we used cuboids, balls, and capsules — the three primitives every physics engine ships. Rapier has more, and every real project runs into the same fork in the road: your artist handed you a glTF, and you need to collide with it. Do you wrap it in a box? Wrap it tight with a convex hull? Use the actual triangles (trimesh)? All three work. Only one is fast. Only one is accurate. They're not the same one.

The demo is a fixed target — a torus knot suspended in midair — with rain of dynamic balls falling on it. Switch the target's collider with the dropdown and watch what happens:

  • Box — balls bounce off an invisible cage; you can see the shape of the true mesh poking out
  • Sphere — balls deflect off a tight sphere, obvious misalignment
  • Capsule / Cylinder / Cone — same story
  • ConvexHull — wraps the outside of the knot correctly but fills the holes
  • Trimesh — balls fall through the holes of the torus knot, correctly 👌

A wireframe overlay shows what the physics engine actually "sees". Stats panel shows the per-frame cost of each.

loading…
drag to orbit · flip collider to compare

The catalog — every collider Rapier ships

ShapeColliderDesc factoryWhen to use
Cuboidcuboid(hx, hy, hz)Walls, crates, box-shaped things. Fast, stable.
Ballball(radius)Projectiles, planets, bowling balls. Cheapest collider.
Capsulecapsule(halfHeight, radius)Character controllers. Slides smoothly up slopes without catching.
Cylindercylinder(halfHeight, radius)Pillars, pipes, wheels, barrels.
Conecone(halfHeight, radius)Traffic cones, arrows, funnels.
Round-cuboidroundCuboid(hx, hy, hz, borderRadius)Rounded-corner boxes. Stack more stably than sharp cuboids.
Convex hullconvexHull(Float32Array)Wrapping arbitrary meshes. Fills concave regions.
Convex decomposition(VHACD, manual)Many convex hulls for one concave mesh. The right answer for complex dynamic models.
Trimeshtrimesh(vertices, indices)Static environments (levels, terrain). Exact, slow, mostly fixed bodies only.
Heightfieldheightfield(nrows, ncols, heights, scale)Terrain from a grid. Fast, simple.
Polylinepolyline(vertices, indices)2D-ish walls, borders, custom line shapes.

Fast vs accurate — the fundamental tradeoff

Every collider balances two properties the engine cares about:

ShapeCollision speedAccuracy vs real mesh
Ball / CuboidO(1) — basically freePoor for anything not spherical/box
Capsule / Cylinder / ConeO(1)Better for limb/pillar shapes
Round-cuboidO(1)Good for rounded boxes; stacks nicely
Convex hullO(log n) per pairCorrect outside, fills holes
TrimeshO(log n) per pair + BVH buildExact — every triangle
HeightfieldO(1) per quadExact for grid-aligned terrain
Rule of thumb: pick the simplest shape that approximates the visual within reason. An enemy character: capsule for the body. A dropped loot box: cuboid. A landscape: heightfield or trimesh. A glTF prop: convex hull. Only reach for trimesh on dynamic bodies if you've verified nothing simpler works — they're expensive and can tunnel.

Convex hull — the glTF default

Given an arbitrary mesh's vertices, convexHull() returns the smallest convex shape that contains them all. Most artist-authored glTF assets are "mostly convex" (chairs, barrels, rocks, vehicles) so this is a one-line win:

// Grab vertex positions from any Three.js BufferGeometry
const positions = geometry.attributes.position.array;
const collider = world.createCollider(
  RAPIER.ColliderDesc.convexHull(positions),
  body,
);

// Returns null if the vertices are degenerate (all on a line/plane)
if (!collider) console.warn('could not compute convex hull');

What convex hull can't do: holes. If your model is a torus (donut) or has an archway, the hull will fill it. The demo shows this — select the torus knot target + Convex Hull, then watch balls bounce off where the holes should be.

Convex decomposition — the proper fix

A concave mesh like a chair with gaps between the legs needs multiple convex hulls, each wrapping a part. This is called convex decomposition. It's an offline preprocessing step — you run an algorithm (V-HACD is the standard) once, get a list of convex parts, and ship those as separate colliders on the same body.

// conceptual — after running V-HACD offline
for (const part of convexParts) {
  world.createCollider(RAPIER.ColliderDesc.convexHull(part.vertices), body);
}

Tools for this in the JS ecosystem:

  • v-hacd-js — the V-HACD algorithm compiled to WASM, runs in-browser
  • Blender's CellFracture add-on (offline)
  • three-to-cannon / three-physx (other engine ports with decomp helpers)

Trimesh — when you need exact

A trimesh collider uses every triangle of your mesh. Exact collision, no approximation. But:

  • Building the BVH is expensive. A 10k-triangle mesh takes ~50ms to build the first time.
  • Trimesh vs trimesh is extremely expensive, even with BVH. Rapier throws a warning.
  • Trimesh can tunnel. Fast dynamic bodies pass through because collision is discrete-in-time. Enable CCD on fast bodies.
  • Trimesh is thin. Hitting an edge at the right angle bounces you into a weird direction. Round corners on your source mesh help.

The right pattern: trimesh on fixed bodies (level geometry, static props), convex or convex-decomposition on dynamic bodies. The demo's "trimesh" option uses the target as a fixed trimesh — so balls correctly fall through the knot's holes.

const vertices = geometry.attributes.position.array;
const indices  = geometry.index ? geometry.index.array : null;

const desc = indices
  ? RAPIER.ColliderDesc.trimesh(vertices, indices)
  : RAPIER.ColliderDesc.trimesh(vertices);   // non-indexed geometry

world.createCollider(desc, body);

Heightfield — terrain from a grid

A 2D array of heights laid over a grid. Super fast, no BVH, trivially updatable. The right answer for any grid-aligned terrain:

// 64 × 64 grid of height samples
const nrows = 64, ncols = 64;
const heights = new Float32Array(nrows * ncols);
for (let i = 0; i < heights.length; i++) {
  heights[i] = Math.sin(...) * Math.cos(...);   // your noise function
}

const desc = RAPIER.ColliderDesc.heightfield(
  nrows, ncols,
  heights,
  { x: 100, y: 5, z: 100 },   // scale: x/z = size in meters, y = height multiplier
);
world.createCollider(desc, body);

Heightfields come with limits: they can't represent caves, overhangs, or vertical cliffs. For that, you fall back to trimesh or voxel approaches.

Compound colliders — many shapes, one body

A single rigid body can have many colliders. Complex dynamic shapes are almost always built as compounds of primitives:

// A dumbbell: two spheres + a connecting cylinder
const body = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic());

world.createCollider(
  RAPIER.ColliderDesc.ball(0.4).setTranslation(-1, 0, 0), body,
);
world.createCollider(
  RAPIER.ColliderDesc.ball(0.4).setTranslation(1, 0, 0), body,
);
world.createCollider(
  RAPIER.ColliderDesc.cylinder(0.8, 0.1).setRotation(
    quaternionFromEuler(0, 0, Math.PI / 2)
  ),
  body,
);

The compound's mass is computed from the sum of the parts (and you can override with setMass). Collision is each-part-against-each-other — so 3 compound parts against 3 compound parts = 9 pair tests. Don't over-use compounds.

Collision groups & filters

What if you want a projectile to pass through its own team's units but collide with enemies? That's a collision group / filter problem.

Rapier has a 32-bit memberships mask + 32-bit filter mask per collider:

const GROUP_PLAYER  = 0x0001;
const GROUP_ENEMY   = 0x0002;
const GROUP_PROJECT = 0x0004;

// Enemies collide with players and projectiles, but not other enemies
const enemyCollider = world.createCollider(
  RAPIER.ColliderDesc.capsule(0.8, 0.3)
    .setCollisionGroups(
      (GROUP_ENEMY << 16) | (GROUP_PLAYER | GROUP_PROJECT)
    ),
  body,
);

The format packs membership + filter into one 32-bit integer (high 16 bits = memberships, low 16 bits = filter). Rapier also has setSolverGroups for separately controlling whether contacts generate forces vs just fire events.

Sensors — triggers without physical response

const trigger = world.createCollider(
  RAPIER.ColliderDesc.cuboid(2, 1, 2).setSensor(true),
  body,
);

Sensor colliders participate in contact detection but don't push anything. Perfect for level zones (enemy spawn radius, pickup areas, checkpoint triggers).

Listen for events via the event queue:

const events = new RAPIER.EventQueue(true);
world.step(events);

events.drainCollisionEvents((h1, h2, started) => {
  if (started) console.log('contact between', h1, h2);
});
events.drainContactForceEvents((event) => {
  // fires when a contact exceeds a force threshold — useful for impact sounds
});

Cost comparison — the stats panel

The demo's stats panel shows the step cost in ms for each collider on the target. At 60 balls dropping, here's the typical ordering on a modern laptop:

ColliderStep cost (60 balls)Notes
Ball~0.3 msBaseline. Sphere-sphere is the cheapest pair in any engine.
Cuboid~0.4 msAlmost free.
Capsule / Cylinder / Cone~0.5–0.6 msStill cheap.
Convex hull (hundreds of verts)~1.0–1.5 msGJK runs per pair.
Trimesh (torus knot, ~4k tris)~1.5–2.5 msBVH helps but scales with contact count.

Flip the dropdown while watching the stats — the numbers correlate with the chart above. Trimesh is ~5× slower than a primitive. For a single hero object in your scene it's fine; for 50 of them, not fine.

Visualizing the collider — the wireframe pattern

The demo draws a wireframe that matches each collider. You'd want this during development for every scene. The pattern:

function makeColliderWireframe(colliderDesc) {
  if (colliderDesc.shape.type === RAPIER.ShapeType.Cuboid) {
    return new THREE.Mesh(
      new THREE.BoxGeometry(halfExtents.x * 2, halfExtents.y * 2, halfExtents.z * 2),
      new THREE.MeshBasicMaterial({ wireframe: true, color: 0x38bdf8 }),
    );
  }
  // ... similar branches for Ball, Capsule, etc.
}

Rapier 0.14+ also exposes world.debugRender() — returns a line-list representation of every collider in the world, perfect for one-line debug overlays.

Common first-time pitfalls

  • Convex hull call returns null. Your vertices are degenerate (colinear). Verify the mesh has real 3D extent.
  • Trimesh vs trimesh warning in console. Two dynamic trimesh colliders can't efficiently detect each other. Make one fixed, or decompose one into convexes.
  • Fast balls tunnel through trimesh. Enable CCD: RigidBodyDesc.dynamic().setCcdEnabled(true).
  • Stack of cuboids jitters forever. Sharp corners catch in other cuboids. Switch to roundCuboid with a small border radius (0.02) — stacks stabilize instantly.
  • Collider doesn't match visual. Collider's transform is relative to its body, not the world. Body translation + collider translation compose.
  • Memory balloons over time. You're creating colliders every frame. Build once, reuse. Or properly call world.removeCollider(c, true).

Exercises

  1. Load a real glTF asset (Article 05) and try it with both ConvexHull and Trimesh. Watch balls pile vs pass through holes. Measure step cost difference.
  2. Build a compound dumbbell: two ball colliders + one cylinder, on a single dynamic body. Roll it down a ramp.
  3. Add a sensor trigger zone: a sensor cuboid at the bottom of the scene that logs when a ball enters (via EventQueue). Despawn balls that fall below the world.

What's next

Article S2-03 — Joints, Constraints, Ragdoll. We've covered shapes and bodies; next we connect them. Revolute joints (hinges), prismatic (sliders), spherical (ball-and-socket), fixed, rope. Then we stack them: an interactive ragdoll that collapses and recovers.