Three.js From Zero · Article s2-02
Colliders Deep Dive
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.
The catalog — every collider Rapier ships
| Shape | ColliderDesc factory | When to use |
|---|---|---|
| Cuboid | cuboid(hx, hy, hz) | Walls, crates, box-shaped things. Fast, stable. |
| Ball | ball(radius) | Projectiles, planets, bowling balls. Cheapest collider. |
| Capsule | capsule(halfHeight, radius) | Character controllers. Slides smoothly up slopes without catching. |
| Cylinder | cylinder(halfHeight, radius) | Pillars, pipes, wheels, barrels. |
| Cone | cone(halfHeight, radius) | Traffic cones, arrows, funnels. |
| Round-cuboid | roundCuboid(hx, hy, hz, borderRadius) | Rounded-corner boxes. Stack more stably than sharp cuboids. |
| Convex hull | convexHull(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. |
| Trimesh | trimesh(vertices, indices) | Static environments (levels, terrain). Exact, slow, mostly fixed bodies only. |
| Heightfield | heightfield(nrows, ncols, heights, scale) | Terrain from a grid. Fast, simple. |
| Polyline | polyline(vertices, indices) | 2D-ish walls, borders, custom line shapes. |
Fast vs accurate — the fundamental tradeoff
Every collider balances two properties the engine cares about:
| Shape | Collision speed | Accuracy vs real mesh |
|---|---|---|
| Ball / Cuboid | O(1) — basically free | Poor for anything not spherical/box |
| Capsule / Cylinder / Cone | O(1) | Better for limb/pillar shapes |
| Round-cuboid | O(1) | Good for rounded boxes; stacks nicely |
| Convex hull | O(log n) per pair | Correct outside, fills holes |
| Trimesh | O(log n) per pair + BVH build | Exact — every triangle |
| Heightfield | O(1) per quad | Exact 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:
| Collider | Step cost (60 balls) | Notes |
|---|---|---|
| Ball | ~0.3 ms | Baseline. Sphere-sphere is the cheapest pair in any engine. |
| Cuboid | ~0.4 ms | Almost free. |
| Capsule / Cylinder / Cone | ~0.5–0.6 ms | Still cheap. |
| Convex hull (hundreds of verts) | ~1.0–1.5 ms | GJK runs per pair. |
| Trimesh (torus knot, ~4k tris) | ~1.5–2.5 ms | BVH 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
roundCuboidwith 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
- 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.
- Build a compound dumbbell: two ball colliders + one cylinder, on a single dynamic body. Roll it down a ramp.
- 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.