Colliders & shapes
The Collider component - collision shapes, surface materials, layer/mask filtering, sensors, contact events, and raycasts.
A Collider pairs an immutable shape with a surface material (friction, restitution). Its world pose comes from the entity’s Transform64.
import { Collider } from "@woosh/meep-engine/src/engine/physics/ecs/Collider.js";
import { BoxShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/BoxShape3D.js";
const collider = new Collider();
collider.shape = BoxShape3D.from_size(2, 1, 2);
collider.friction = 0.6;
collider.restitution = 0.2;
Add it to the same entity as the body. The ColliderObserverSystem is what attaches a collider to its body, so register it alongside the PhysicsSystem - and register the PhysicsSystem first, so the body’s slot exists before the collider attaches to it.
Shapes
Shapes are shared, immutable values - build one and reuse it across every collider of that size.
| Shape | Construct | Notes |
|---|---|---|
| Sphere | SphereShape3D.from(radius) | The simplest and fastest shape. |
| Box | BoxShape3D.from_size(w, h, d) | Also BoxShape3D.from(hx, hy, hz) for half-extents. |
| Capsule | CapsuleShape3D.from(radius, height) | Y-aligned; height is the cylinder section only (the rounded caps add radius at each end). |
| Cylinder | CylinderShape3D.from(radius, height) | Y-aligned; height is the full height. |
| Heightmap | HeightMapShape3D.from(sampler, sizeX, sizeY, sizeZ, orientation, tessellation) | A terrain surface read from a Sampler2D of heights, spanning sizeX × sizeZ with heights up to sizeY. It’s a non-convex surface - put it on a Static body for ground. tessellation (default 1) splits each sampler cell into tessellation × tessellation collision sub-cells, so a coarse sampler drawn as a finely tessellated mesh can still collide against something close to the rendered surface. |
Sphere, box, and capsule pairs are handled by fast, exact routines written for those specific shapes - quicker and more robust than the general-purpose path on the common, smooth cases.
Size lives on the shape, not the
Transform64. The simulation reads translation and rotation only - the transform’s scale is ignored. Bake dimensions into the shape (from_size,from, the capsule radius/height), and match your render mesh to it.
Surface material
friction- Coulomb coefficient. Combined with the other surface via a geometric-mean rule.restitution- bounciness,0(inelastic) to1(elastic).density- used by inertia helpers if you opt in; informational otherwise.
Layers and masks
Each body carries a layer bitmask (its category) and a mask (the layers it collides with). A pair (A, B) collides only when both sides agree:
(A.layer & B.mask) !== 0 && (B.layer & A.mask) !== 0
Defaults are layer = 1, mask = 0xFFFFFFFF - everything collides with everything. Carve groups out by assigning bits (e.g. put debris on its own layer and clear that bit from the player’s mask so debris never blocks the player).
Sensors and contact events
A sensor detects overlaps and fires events but applies no impulse - the basis for triggers and volumes. Flag the whole body (RigidBodyFlags.IsSensor) or a single collider (ColliderFlags.IsSensor); either flag makes the body present as a sensor.
Being a sensor is not just “no impulse”. A sensor is not a solid surface anywhere in the pipeline: the contact solver skips its impulse, the island builder leaves it out of sleep islands (so a trigger overlapping a pile can’t hold it permanently awake), and raycasts pass straight through it.
The system dispatches contact events through the dataset’s per-entity event channel - each contacting pair fires on both of its entities. Subscribe with addEntityEventListener for the entities you care about:
import { PhysicsEvents } from "@woosh/meep-engine/src/engine/physics/ecs/PhysicsEvents.js";
const ecd = entityManager.dataset;
ecd.addEntityEventListener(entity, PhysicsEvents.ContactBegin, (contact) => { /* new pair this step */ });
ecd.addEntityEventListener(entity, PhysicsEvents.ContactStay, (contact) => { /* persisting pair */ });
ecd.addEntityEventListener(entity, PhysicsEvents.ContactEnd, (contact) => { /* pair separated */ });
The contact payload carries entityA, entityB, point, normal, and depth. It is reused per dispatch - copy any field you need to keep past the listener.
For broad-strokes filtering, install a contact filter; return false to discard a pair before the full collision test (the cheap layer/mask test runs first):
physics.setContactFilter((entityA, entityB, colliderA, colliderB) => {
return true; // keep, or false to ignore this pair
});
Querying without colliding
These same shapes drive the read-only spatial queries - raycast, shapeCast, and overlap - which ask the physics world questions (“what’s under the cursor?”, “would I fit here?”) without adding a body or stepping the simulation.
Where to go next
- Rigid bodies - the body the collider attaches to.
- Spatial queries - raycasts, shape casts, and overlap tests using these shapes.
- Raining rigid bodies - spheres, boxes, and capsules in one scene.
- Raycast vehicle - a
HeightMapShape3Dterrain driven on.