collision-3d
Import from @pierre/ecs/modules/collision-3d.
3D shapes and domain-free narrowphase helpers — the parallel sibling of
modules/collision, not an extension of it. Box,
sphere, oriented box and plane tests, plus the swept-AABB push used to move a
body up to first contact.
Like its 2D sibling, the module owns data shapes and maths, not policy:
it has no broadphase, no spatial index, and no opinion about what “overlap”
means for your game. It does not ship a trigger system, because
makeTriggerSystem in the 2D module already takes its narrowphase as an
injected predicate and is therefore dimension-free — a 3D game wires the same
factory with one of these helpers.
Components
Section titled “Components”interface ShapeAabb3 { d: number; h: number; w: number } // 'shape-aabb3d'interface ShapeSphere3 { radius: number } // 'shape-sphere3d'Both are anchored on the entity’s position3d, which they require, and both
use full extents (a box’s w/h/d, a sphere’s radius).
That centre anchor is the deliberate contrast with the 2D shapes, which hang
off the top-left corner of PositionDef. Do not “fix” the difference: a 3D
overlap is symmetric about the centre, and the three 3D prototypes all store it
this way.
Narrowphase helpers
Section titled “Narrowphase helpers”Every helper is a pure function with no ECS knowledge.
interface Aabb3 { center: Vec3; half: Vec3 } // half extentsinterface Obb3 { center: Vec3; half: Vec3; rotation: Quat }interface Plane3 { constant: number; normal: Vec3 } // normal·p + constant = 0interface RayHit3 { axis: 'x' | 'y' | 'z'; t: number }interface SweptHit3 { hit: boolean; normal: Vec3; tEntry: number }
rayVsAabb3(origin, dir, box) aabb3VsAabb3(a, b)aabb3ContainsPoint(box, p) aabb3VsSphere3(box, center, radius)sphere3VsSphere3(aCenter, aRadius, bCenter, bRadius)sphere3ContainsPoint(center, radius, p) aabb3VsAabb3Swept(a, motionA, b)plane3DistanceToPoint(plane, p) rayVsPlane3(origin, dir, plane)aabb3VsPlane3(box, plane) sphere3VsPlane3(center, radius, plane)
rayVsObb3(origin, dir, obb) obb3VsSphere3(obb, center, radius)obb3VsObb3(a, b) aabb3VsObb3(box, obb)Edge semantics
Section titled “Edge semantics”- Box vs box is strict — boxes that merely touch along a face do not
overlap. This matches
aabbVsAabb, and it is the one place where adopting this module can change a caller’s behaviour. - Anything involving a sphere is inclusive — touching counts.
- Containment is inclusive —
aabb3ContainsPointandsphere3ContainsPointaccept points exactly on the surface.
Ray semantics
Section titled “Ray semantics”t is parametric in units of dir, which lets one function serve both ray
shapes: a unit dir makes t a world distance, while dir = to - from makes
t <= 1 a segment test. rayVsAabb3 and rayVsObb3 return null when the ray
misses, when the box lies entirely behind the origin, or when the origin is on
or inside the box; rayVsPlane3 returns null for a parallel ray or a plane
behind the origin.
Corner tie-break differs by function and is documented on each: a ray corner
goes to the earlier axis in x, y, z order (the rule the prototypes’ own
rayAabb had, so replacing it cannot move a grazing hit), while
aabb3VsAabb3Swept ties to the later axis, matching aabbVsAabbSwept in
the 2D sibling.
Oriented boxes
Section titled “Oriented boxes”Obb3.rotation must be unit-length (quatRotate’s contract). The tests
transform the other shape into the box’s local frame rather than rotating the
box, so rayVsObb3 reports axis as one of the box’s own axes, not a world
axis. obb3VsObb3 is a 15-axis separating-axis test, with the degenerate
parallel cross products skipped. There is no ShapeObb3Def — a shape def has to
pair with an orientation component, and the engine has none yet (that is
modules/transform-3d’s job).
Swept boxes
Section titled “Swept boxes”aabb3VsAabb3Swept returns the fraction of the motion at which two boxes first
touch and the contact normal on the second. It returns no hit — the shared
frozen sentinel — when they already overlap at t = 0: run aabb3VsAabb3
first if you need depenetration. A zero-length motion never hits.
- Signed zero is normalised away wherever it could appear (a ray starting on
a plane divides to
-0), because it leaks intoObject.isand1 / v. - Non-finite input propagates, as in
modules/math: aNaNcoordinate givesNaNresults rather than a silent zero. - No allocation on the miss path, and no argument is ever mutated.
- The only cross-module source dependency is
modules/math—clamp, plus theVec3/Quattypes and the rotation helpers. That is deliberate; this module does not re-derive vector maths.
Not included (by design)
Section titled “Not included (by design)”- Capsules. More a character-controller shape than a general narrowphase
one, so they wait for
modules/kinematics-3d’s first capsule-bodied consumer: the shape and its narrowphase land here, and that module adopts them. - Convex hulls, meshes, triangles. A different layer: these need a triangle/barycentric primitive this module does not own.
- A broadphase or spatial index.
modules/spatialis the 2D answer;SpatialStructure<TPos>in core is generic, so a 3D backend can drop in without a change here. - A 3D trigger system. The 2D
makeTriggerSystemis dimension-free (see above) — do not add a second one. bounceOffAabb/reflect. Those live in the 2D narrowphase file as motion helpers; in 3D,vec3Reflectinmodules/mathcovers it.
import { makeTriggerSystem } from '@pierre/ecs/modules/collision';import { aabb3VsAabb3, rayVsAabb3, sphere3VsSphere3 } from '@pierre/ecs/modules/collision-3d';
// Compose a world-space box from the entity's position and shape components.const pos = world.getStore(Position3DDef).get(id)!;const shape = world.getStore(ShapeAabb3Def).get(id)!;const box = { center: pos, half: { x: shape.w / 2, y: shape.h / 2, z: shape.d / 2 } };
// Hitscan: a unit dir makes t a world distance.const hit = rayVsAabb3(eye, forward, box);if (hit && hit.t < range) mark(hit.axis);
// Two spheres (projectile vs target).const reach = bulletRadius + targetRadius;if (sphere3VsSphere3(bulletPos, bulletRadius, targetPos, targetRadius)) destroy(target);
// Pairing entities up is still the 2D module's factory — the predicate is yours.const pickups = makeTriggerSystem({ broadphase: ctx => pairsOfPlayerAndCoins(ctx), onOverlap: (ctx, _player, coin) => collect(ctx, coin), overlaps: (ctx, a, b) => aabb3VsAabb3(boxOf(ctx, a), boxOf(ctx, b)),});