From c781983f85713f08547e8d6dad2213e8546cd4ad Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:59:55 +0000 Subject: [PATCH 1/3] feat(physics): add collision categories and masks; move the AABB onto the collider Colliders get a `category` and `mask`; the broad phase skips a pair unless each collider's category is in the other's mask (Box2D's symmetric rule). `raycast` takes an options object with `sort` and a `mask`. The broad phase now writes each collider's bounds to `collider.aabb`, so a collider takes part in collision detection and raycasts without a separate component. `AabbEcsComponent`, `aabbId` and `addAabbComponent` are removed, and every caller in /src tests, /demo and the docs site is migrated. A new collider's bounds start empty instead of a zero-size box at the origin, so a ray through the origin no longer hits colliders the broad phase hasn't seen yet. Implements Phase 1 of design/collision-events.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LVTLatSvTvhsw2P788Dcjj --- CHANGELOG.md | 13 +++ demo/src/game.ts | 3 - design/continuous-collision-detection.md | 2 +- documentation-site/docs/docs/physics/index.md | 10 +- .../docs/docs/physics/raycasting.md | 28 ++++- .../docs/docs/physics/rigid-bodies.md | 48 ++++++-- .../docs/docs/physics/terrain.md | 11 +- .../pages/demos/brick-breaker/_create-ball.ts | 2 - .../demos/brick-breaker/_create-boundaries.ts | 2 - .../demos/brick-breaker/_create-bricks.ts | 2 - .../demos/brick-breaker/_create-paddle.ts | 2 - .../src/pages/demos/car/_create-car.ts | 3 - .../src/pages/demos/car/_create-terrain.ts | 2 - .../_create-suspensions.ts | 2 - .../moving-platform/_create-boundaries.ts | 2 - .../demos/moving-platform/_create-platform.ts | 2 - .../demos/moving-platform/_spawn-crates.ts | 2 - .../demos/newtons-cradle/_create-cradle.ts | 2 - .../pages/demos/physics/_create-boundaries.ts | 2 - .../src/pages/demos/physics/_spawn-shapes.ts | 2 - .../demos/prismatic-joint/_create-sliders.ts | 2 - .../pages/demos/raycasting/_create-game.ts | 6 +- .../pages/demos/raycasting/_create-targets.ts | 5 +- .../demos/revolute-joint/_create-hinges.ts | 4 - .../demos/rolling-ball/_create-player.ts | 2 - .../demos/rolling-ball/_create-terrain.ts | 2 - .../space-shooter/_asteroid-spawner.system.ts | 2 - .../demos/space-shooter/_create-player.ts | 2 - .../pages/demos/space-shooter/_gun.system.ts | 2 - .../wrecking-ball/_create-wrecking-ball.ts | 2 - src/physics/collision/collision-filter.ts | 16 +++ src/physics/components/aabb-component.ts | 30 ----- src/physics/components/collider-component.ts | 39 ++++++- src/physics/components/index.ts | 1 - src/physics/raycast/raycast.test.ts | 53 ++++++++- src/physics/raycast/raycast.ts | 61 +++++++--- .../systems/broad-phase-system.test.ts | 106 +++++++++++++++--- src/physics/systems/broad-phase-system.ts | 34 +++--- .../systems/terrain-contact-stability.test.ts | 3 - 39 files changed, 347 insertions(+), 167 deletions(-) create mode 100644 src/physics/collision/collision-filter.ts delete mode 100644 src/physics/components/aabb-component.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2f7e2ce..b6f564d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +#### Added + +- **physics:** Collision filtering. `addColliderComponent` takes a `category` (the bits a collider belongs to, default `1`) and a `mask` (the categories it collides with, default `allCollisionCategories`). Two colliders are tested only when each one's category is in the other's mask, so pairs your game would ignore, such as bullets against bullets, never reach the narrow phase +- **physics:** `raycast` takes a `mask` option, so a ray only hits colliders whose `category` is in it + +#### Changed + +- **physics:** `raycast`'s fourth argument is now an options object. Replace `raycast(world, start, end, false)` with `raycast(world, start, end, { sort: false })` + +#### Removed + +- **physics:** `AabbEcsComponent`, `aabbId` and `addAabbComponent`. A collider no longer needs a separate AABB component to take part in collision detection or raycasts: the broad phase writes its bounds to the collider's new `aabb` field. Delete your `addAabbComponent` calls, and read `collider.aabb` where you read the AABB component + ## [0.25.8] - 2026-10-03 #### Fixed diff --git a/demo/src/game.ts b/demo/src/game.ts index 44f2999c2..31dddbc46 100644 --- a/demo/src/game.ts +++ b/demo/src/game.ts @@ -20,7 +20,6 @@ import { Vec2, } from '../../src'; import { - addAabbComponent, addColliderComponent, addGravityComponent, addRigidBodyComponent, @@ -129,7 +128,6 @@ function createFountainSpawnEcsSystem( angularVelocity, }); addColliderComponent(world, entity, { collider }); - addAabbComponent(world, entity); }; return { @@ -318,7 +316,6 @@ addColliderComponent(world, groundEntity, { { x: -groundHalfWidth, y: groundHalfHeight }, ]), }); -addAabbComponent(world, groundEntity); const random = new Random(); const fountainLeftX = -halfWidth + fountainMarginFromEdge; diff --git a/design/continuous-collision-detection.md b/design/continuous-collision-detection.md index 728f0900e..79bdab342 100644 --- a/design/continuous-collision-detection.md +++ b/design/continuous-collision-detection.md @@ -171,7 +171,7 @@ Each phase is independently completable and releasable, per this engine's own `f | Task | Description | Size | | --- | --- | --- | | `RigidBodyEcsComponent.continuousDetection` | New field on `RigidBodyDefaultedOptions` (default `true` for dynamic bodies against static bodies - matches "the reported bug is an ordinary wheel, not something a demo author should have to know to flag," §7 DL-4); settable `false` per body to opt out | S | -| `createContinuousCollisionEcsSystem` | The system itself: threshold check (§5.3), candidate-end-position computation, nearby-static-body query (reusing `AabbEcsComponent`/`aabbsOverlap` over a swept AABB spanning start→end), sweep dispatch, clamp write-back | L | +| `createContinuousCollisionEcsSystem` | The system itself: threshold check (§5.3), candidate-end-position computation, nearby-static-body query (reusing `ColliderEcsComponent.aabb`/`aabbsOverlap` over a swept AABB spanning start→end), sweep dispatch, clamp write-back | L | | `createEulerIntegrationEcsSystem` clamp support | The small addition in §5.4 | S | | Integration regression test | Full-pipeline test reproducing the diagnosed scenario | M | | Car demo wiring + manual verification | Register the system; browser-verify per AGENTS.md's "Documentation Site Demos" process | S | diff --git a/documentation-site/docs/docs/physics/index.md b/documentation-site/docs/docs/physics/index.md index a1fc2e93b..ea874f51a 100644 --- a/documentation-site/docs/docs/physics/index.md +++ b/documentation-site/docs/docs/physics/index.md @@ -16,8 +16,9 @@ Core concepts: - `ColliderEcsComponent`: an entity's collision shape (`CircleCollider`, `PolygonCollider`, or - `TerrainCollider`), plus friction and - restitution. + `TerrainCollider`), plus friction, + restitution, and the collision `category`/`mask` that filter which + colliders it's tested against. - `createBroadPhaseEcsSystem`/`createNarrowPhaseEcsSystem`/ `createCollisionResolutionEcsSystem`: detect and resolve collisions between collider entities each tick. @@ -37,7 +38,8 @@ Core concepts: Guides in this section: - [Bodies and Shapes](./rigid-bodies.md): creating bodies and shapes, - static/kinematic/dynamic bodies, and ECS integration. + static/kinematic/dynamic bodies, collision filtering, and ECS + integration. - [Applying Forces](./forces.md): gravity, impulses, torque, springs and dampers, and explosions. - [Raycasting](./raycasting.md): casting rays against colliders. @@ -65,7 +67,6 @@ import { createTransformEcsSystem, } from '@forge-game-engine/forge/common'; import { - addAabbComponent, addColliderComponent, addGravityComponent, addRigidBodyComponent, @@ -96,7 +97,6 @@ const collider = new PolygonCollider([ addPositionComponent(world, box); addRotationComponent(world, box); addColliderComponent(world, box, { collider }); -addAabbComponent(world, box); addRigidBodyComponent(world, box, { mass: collider.mass, momentOfInertia: collider.momentOfInertia, diff --git a/documentation-site/docs/docs/physics/raycasting.md b/documentation-site/docs/docs/physics/raycasting.md index 3bb383272..f27027aaf 100644 --- a/documentation-site/docs/docs/physics/raycasting.md +++ b/documentation-site/docs/docs/physics/raycasting.md @@ -4,7 +4,7 @@ sidebar_position: 3 # Raycasting -`raycast(world, start, end, sort?)` +`raycast(world, start, end, options?)` casts a line segment against every entity in an `EcsWorld` with a `ColliderEcsComponent` and returns every point where it intersects one, as a `RaycastHit`. Use it for hitscan @@ -33,7 +33,22 @@ if (closest) { ``` If you only need a yes/no line-of-sight check and don't care which entity -is nearest, pass `sort: false` to skip the sort. +is nearest, pass `{ sort: false }` to skip the sort. + +## Hitting only some colliders + +`mask` limits the ray to colliders whose `category` shares a bit with it, +the same categories colliders filter each other by (see +[Collision filtering](./rigid-bodies.md#collision-filtering)). It defaults +to every category. + +```ts +const WALLS = 1 << 0; +const ENEMIES = 1 << 1; + +// A line-of-sight check that looks through enemies and stops at walls. +const blocked = raycast(world, eye, target, { sort: false, mask: WALLS }); +``` `raycast` works against every collider shape - `CircleCollider`, `PolygonCollider`, and `TerrainCollider` - dispatching to the appropriate @@ -41,10 +56,11 @@ intersection test based on each entity's `Collider.type`. ## Performance -`raycast` reads each candidate entity's `AabbEcsComponent` directly rather -than recomputing it, so a system that updates it (for example -`createBroadPhaseEcsSystem`) must be registered and run at least once -before casting. Before testing an entity's exact collider shape, `raycast` +`raycast` reads each collider's `aabb` directly rather than recomputing +it, so `createBroadPhaseEcsSystem`, which writes it, must be registered and +run at least once before casting. Those bounds are from when the broad phase +last ran: a body that has moved since is tested against where it was, and a +collider added since can't be hit yet. Before testing an entity's exact collider shape, `raycast` then skips any entity whose AABB doesn't overlap the ray's own bounding box, so casting against a `world` with many entities is cheap as long as most of them aren't near the ray. diff --git a/documentation-site/docs/docs/physics/rigid-bodies.md b/documentation-site/docs/docs/physics/rigid-bodies.md index 030c0d6ef..127bff07c 100644 --- a/documentation-site/docs/docs/physics/rigid-bodies.md +++ b/documentation-site/docs/docs/physics/rigid-bodies.md @@ -7,9 +7,8 @@ sidebar_position: 1 A simulated body is an entity with a `ColliderEcsComponent` (a shape) plus, for anything that isn't static, a `RigidBodyEcsComponent` (mass, velocity, and how it participates in the simulation). Both sit alongside the entity's -`PositionEcsComponent`/`RotationEcsComponent` and an `AabbEcsComponent` used -for broad-phase culling. `RotationEcsComponent` is optional for collision -detection: a collider entity without one is treated as unrotated, so a +`PositionEcsComponent`/`RotationEcsComponent`. `RotationEcsComponent` is +optional for collision detection: a collider entity without one is treated as unrotated, so a static, axis-aligned wall or trigger volume can leave it off. A dynamic or kinematic body still needs one, since `createEulerIntegrationEcsSystem` only integrates entities that have it. This page covers the choices that aren't obvious @@ -23,7 +22,6 @@ import { addRotationComponent, } from '@forge-game-engine/forge/common'; import { - addAabbComponent, addColliderComponent, addRigidBodyComponent, CircleCollider, @@ -40,7 +38,6 @@ addColliderComponent(world, ball, { restitution: 0.6, friction: 0.4, }); -addAabbComponent(world, ball); addRigidBodyComponent(world, ball, { mass: collider.mass, momentOfInertia: collider.momentOfInertia, @@ -84,8 +81,8 @@ simulation: as crates, characters, and projectiles. - **Static**: infinite effective mass, never affected by anything, never integrated. The simplest way to make a body static is to give its entity - a `ColliderEcsComponent` (plus `PositionEcsComponent`/`AabbEcsComponent`, - and a `RotationEcsComponent` if it's rotated) and **no** + a `ColliderEcsComponent` (plus `PositionEcsComponent`, and a + `RotationEcsComponent` if it's rotated) and **no** `RigidBodyEcsComponent` at all - every static entity in the physics demos (floors, walls, `TerrainCollider` ground) follows this convention, and it still applies unchanged. Attaching a `RigidBodyEcsComponent` with @@ -124,6 +121,43 @@ by the solver (its effective mass is always treated as infinite). Pass its collider's `mass`/`momentOfInertia` the same as for a dynamic body. ::: +## Collision filtering + +By default every collider is tested against every other. Give colliders a +`category` and a `mask` to say which pairs matter: two colliders are tested +only when each one's `category` shares a bit with the other's `mask`. A pair +either mask excludes never reaches the narrow phase, so filtering also saves +the work of testing pairs your game would ignore anyway. + +```ts +const PLAYER = 1 << 0; +const ENEMY = 1 << 1; +const PLAYER_BULLET = 1 << 2; +const WALL = 1 << 3; + +// Player bullets hit enemies and walls, never the player or each other. +addColliderComponent(world, bullet, { + collider: bulletCollider, + category: PLAYER_BULLET, + mask: ENEMY | WALL, +}); + +// Enemies collide with everything except other enemies. +addColliderComponent(world, enemy, { + collider: enemyCollider, + category: ENEMY, + mask: allCollisionCategories & ~ENEMY, +}); +``` + +`category` defaults to `1` and `mask` to `allCollisionCategories` (every +bit), so colliders that set neither collide with everything. Categories are +32 bits, as JavaScript's bitwise operators allow. The test is symmetric: +either collider can rule a pair out, and both have to accept it. + +The broad phase writes each collider's world-space bounds to its `aabb` +field every tick. It's output only; read it, but don't write it. + ## ECS integration There's no single "physics world" object to step - each concern is its own diff --git a/documentation-site/docs/docs/physics/terrain.md b/documentation-site/docs/docs/physics/terrain.md index 6aa2f050c..c8bcfe350 100644 --- a/documentation-site/docs/docs/physics/terrain.md +++ b/documentation-site/docs/docs/physics/terrain.md @@ -14,7 +14,6 @@ slope. ```ts import { Vec2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, TerrainCollider, } from '@forge-game-engine/forge/physics'; @@ -33,15 +32,13 @@ addColliderComponent(world, groundEntity, { 200, // depth: how far the solid slab extends below the lowest point ), }); -addAabbComponent(world, groundEntity); ``` Terrain is static, so `groundEntity` only needs `PositionEcsComponent`/ `RotationEcsComponent` (for `computeAabb`/narrow-phase to read `.world` from; the rotation component is optional, and an entity without one is treated as -unrotated), `ColliderEcsComponent`, and `AabbEcsComponent` - no -`RigidBodyEcsComponent`, -the same convention every other static body (walls, ground boxes) in this +unrotated) and `ColliderEcsComponent` - no `RigidBodyEcsComponent`, the +same convention every other static body (walls, ground boxes) in this engine follows. See the [Bodies and Shapes guide](/Forge/docs/docs/physics/rigid-bodies) for that static/kinematic/dynamic distinction. @@ -120,8 +117,8 @@ fine spacing relative to the bodies rolling over it is the main way to make terrain contact expensive - space points no more finely than the detail you actually need, and let `buildTerrainCurve` do the visual smoothing. -Broad-phase culling (`createBroadPhaseEcsSystem`'s `AabbEcsComponent`) still -computes one AABB for the whole collider via `computeAabb`, which for a long +Broad-phase culling (the `aabb` that `createBroadPhaseEcsSystem` writes onto +each collider) still computes one AABB for the whole collider via `computeAabb`, which for a long terrain strip produces a large bounding box around the whole shape (the same simplification a very wide/tall `PolygonCollider` makes). This doesn't affect correctness, only how many pairs reach the narrow phase - for very diff --git a/documentation-site/src/pages/demos/brick-breaker/_create-ball.ts b/documentation-site/src/pages/demos/brick-breaker/_create-ball.ts index 6c4de2d88..29a156f9a 100644 --- a/documentation-site/src/pages/demos/brick-breaker/_create-ball.ts +++ b/documentation-site/src/pages/demos/brick-breaker/_create-ball.ts @@ -12,7 +12,6 @@ import { Vector2, } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, addRigidBodyComponent, CircleCollider, @@ -115,7 +114,6 @@ export async function createBall( restitution: 1, friction: 0, }); - addAabbComponent(world, entity); const rigidBody = addRigidBodyComponent(world, entity, { mass: collider.mass, diff --git a/documentation-site/src/pages/demos/brick-breaker/_create-boundaries.ts b/documentation-site/src/pages/demos/brick-breaker/_create-boundaries.ts index 2df398358..af9bed19d 100644 --- a/documentation-site/src/pages/demos/brick-breaker/_create-boundaries.ts +++ b/documentation-site/src/pages/demos/brick-breaker/_create-boundaries.ts @@ -6,7 +6,6 @@ import { } from '@forge-game-engine/forge/common'; import { Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, PolygonCollider, } from '@forge-game-engine/forge/physics'; @@ -109,7 +108,6 @@ export async function createBoundaries( restitution: 1, friction: 0, }); - addAabbComponent(world, entity); }; createWall({ x: 0, y: halfHeight - wallThickness / 2 }, width, wallThickness); diff --git a/documentation-site/src/pages/demos/brick-breaker/_create-bricks.ts b/documentation-site/src/pages/demos/brick-breaker/_create-bricks.ts index 235a21e4a..44209aae2 100644 --- a/documentation-site/src/pages/demos/brick-breaker/_create-bricks.ts +++ b/documentation-site/src/pages/demos/brick-breaker/_create-bricks.ts @@ -7,7 +7,6 @@ import { } from '@forge-game-engine/forge/common'; import { Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, PolygonCollider, } from '@forge-game-engine/forge/physics'; @@ -196,7 +195,6 @@ export async function createBrickField( restitution: 1, friction: 0, }); - addAabbComponent(world, entity); liveBricks.add(entity); }; diff --git a/documentation-site/src/pages/demos/brick-breaker/_create-paddle.ts b/documentation-site/src/pages/demos/brick-breaker/_create-paddle.ts index 0ef9d5156..501b93add 100644 --- a/documentation-site/src/pages/demos/brick-breaker/_create-paddle.ts +++ b/documentation-site/src/pages/demos/brick-breaker/_create-paddle.ts @@ -7,7 +7,6 @@ import { } from '@forge-game-engine/forge/common'; import { Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, PolygonCollider, } from '@forge-game-engine/forge/physics'; @@ -94,7 +93,6 @@ export async function createPaddle( restitution: 1, friction: 0, }); - addAabbComponent(world, entity); return position; } diff --git a/documentation-site/src/pages/demos/car/_create-car.ts b/documentation-site/src/pages/demos/car/_create-car.ts index 4dde69d08..54dd744a6 100644 --- a/documentation-site/src/pages/demos/car/_create-car.ts +++ b/documentation-site/src/pages/demos/car/_create-car.ts @@ -6,7 +6,6 @@ import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { Axis1dAction, TriggerAction } from '@forge-game-engine/forge/input'; import { degreesToRadians, Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addAngularVelocityMotorComponent, addColliderComponent, addGravityComponent, @@ -325,7 +324,6 @@ function createWheel( mass: wheelCollider.mass, momentOfInertia: wheelCollider.momentOfInertia, }); - addAabbComponent(world, entity); addGravityComponent(world, entity, { amount: gravity }); addAngularVelocityMotorComponent(world, entity, { targetVelocity: 0, @@ -489,7 +487,6 @@ export async function createCar( // instead of persisting indefinitely. angularDrag: 0.5, }); - addAabbComponent(world, chassisEntity); addGravityComponent(world, chassisEntity, { amount: gravity }); // Offset along the same tilted axis each wheel's mount constrains it to diff --git a/documentation-site/src/pages/demos/car/_create-terrain.ts b/documentation-site/src/pages/demos/car/_create-terrain.ts index 4673933e6..ea9580818 100644 --- a/documentation-site/src/pages/demos/car/_create-terrain.ts +++ b/documentation-site/src/pages/demos/car/_create-terrain.ts @@ -5,7 +5,6 @@ import { import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { Random, Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, TerrainCollider, } from '@forge-game-engine/forge/physics'; @@ -184,7 +183,6 @@ export async function createTerrain( collider: terrainCollider, friction: 1, }); - addAabbComponent(world, terrainEntity); const groundImage = await renderContext.imageCache.getOrLoad(groundTextureUrl); diff --git a/documentation-site/src/pages/demos/linear-spring-damper/_create-suspensions.ts b/documentation-site/src/pages/demos/linear-spring-damper/_create-suspensions.ts index d878fe81d..bf0bbce0a 100644 --- a/documentation-site/src/pages/demos/linear-spring-damper/_create-suspensions.ts +++ b/documentation-site/src/pages/demos/linear-spring-damper/_create-suspensions.ts @@ -5,7 +5,6 @@ import { import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, addGravityComponent, addLinearDamperComponent, @@ -209,7 +208,6 @@ function createSuspensionScenario( momentOfInertia: wheelCollider.momentOfInertia, velocity: Vec2.clone(bumpVelocity), }); - addAabbComponent(world, wheelEntity); addGravityComponent(world, wheelEntity, { amount: gravity }); addResetComponent(world, wheelEntity, { entity: wheelEntity, diff --git a/documentation-site/src/pages/demos/moving-platform/_create-boundaries.ts b/documentation-site/src/pages/demos/moving-platform/_create-boundaries.ts index 81f71e801..c2a7d57d6 100644 --- a/documentation-site/src/pages/demos/moving-platform/_create-boundaries.ts +++ b/documentation-site/src/pages/demos/moving-platform/_create-boundaries.ts @@ -5,7 +5,6 @@ import { } from '@forge-game-engine/forge/common'; import { Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, PolygonCollider, } from '@forge-game-engine/forge/physics'; @@ -83,7 +82,6 @@ export async function createBoundaries( addColliderComponent(world, entity, { collider: new PolygonCollider(rectangleVertices(wallWidth, wallHeight)), }); - addAabbComponent(world, entity); }; createWall( diff --git a/documentation-site/src/pages/demos/moving-platform/_create-platform.ts b/documentation-site/src/pages/demos/moving-platform/_create-platform.ts index ff36a1fdf..5e4babfd0 100644 --- a/documentation-site/src/pages/demos/moving-platform/_create-platform.ts +++ b/documentation-site/src/pages/demos/moving-platform/_create-platform.ts @@ -5,7 +5,6 @@ import { } from '@forge-game-engine/forge/common'; import { Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, addRigidBodyComponent, PolygonCollider, @@ -96,7 +95,6 @@ export async function createPlatform( ); addColliderComponent(world, entity, { collider, friction: 0.8 }); - addAabbComponent(world, entity); addRigidBodyComponent(world, entity, { mass: collider.mass, momentOfInertia: collider.momentOfInertia, diff --git a/documentation-site/src/pages/demos/moving-platform/_spawn-crates.ts b/documentation-site/src/pages/demos/moving-platform/_spawn-crates.ts index 62ecdeea8..372de61b9 100644 --- a/documentation-site/src/pages/demos/moving-platform/_spawn-crates.ts +++ b/documentation-site/src/pages/demos/moving-platform/_spawn-crates.ts @@ -5,7 +5,6 @@ import { } from '@forge-game-engine/forge/common'; import { Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, addGravityComponent, addRigidBodyComponent, @@ -82,7 +81,6 @@ export function spawnCrate( const collider = new PolygonCollider(rectangleVertices(crateSize, crateSize)); addColliderComponent(world, entity, { collider, friction: 0.6 }); - addAabbComponent(world, entity); addRigidBodyComponent(world, entity, { mass: collider.mass, momentOfInertia: collider.momentOfInertia, diff --git a/documentation-site/src/pages/demos/newtons-cradle/_create-cradle.ts b/documentation-site/src/pages/demos/newtons-cradle/_create-cradle.ts index 85a9c6658..b00c3cab4 100644 --- a/documentation-site/src/pages/demos/newtons-cradle/_create-cradle.ts +++ b/documentation-site/src/pages/demos/newtons-cradle/_create-cradle.ts @@ -5,7 +5,6 @@ import { } from '@forge-game-engine/forge/common'; import { degreesToRadians, Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, addGravityComponent, addRevoluteJointComponent, @@ -199,7 +198,6 @@ export async function createCradle( mass: ballCollider.mass, momentOfInertia: ballCollider.momentOfInertia, }); - addAabbComponent(world, ballEntity); addGravityComponent(world, ballEntity, { amount: gravity }); const jointEntity = world.createEntity(); diff --git a/documentation-site/src/pages/demos/physics/_create-boundaries.ts b/documentation-site/src/pages/demos/physics/_create-boundaries.ts index 5bb7ebba9..ca77161f4 100644 --- a/documentation-site/src/pages/demos/physics/_create-boundaries.ts +++ b/documentation-site/src/pages/demos/physics/_create-boundaries.ts @@ -5,7 +5,6 @@ import { } from '@forge-game-engine/forge/common'; import { Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, PolygonCollider, } from '@forge-game-engine/forge/physics'; @@ -82,7 +81,6 @@ export async function createBoundaries( addColliderComponent(world, entity, { collider: new PolygonCollider(rectangleVertices(wallWidth, wallHeight)), }); - addAabbComponent(world, entity); }; createWall( diff --git a/documentation-site/src/pages/demos/physics/_spawn-shapes.ts b/documentation-site/src/pages/demos/physics/_spawn-shapes.ts index 04ed68e7c..d0bd028f3 100644 --- a/documentation-site/src/pages/demos/physics/_spawn-shapes.ts +++ b/documentation-site/src/pages/demos/physics/_spawn-shapes.ts @@ -6,7 +6,6 @@ import { } from '@forge-game-engine/forge/common'; import { Random, Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, addGravityComponent, addRigidBodyComponent, @@ -168,7 +167,6 @@ export async function spawnShapes( restitution: 0.6, friction: 0.4, }); - addAabbComponent(world, entity); addRigidBodyComponent(world, entity, { mass: collider.mass, momentOfInertia: collider.momentOfInertia, diff --git a/documentation-site/src/pages/demos/prismatic-joint/_create-sliders.ts b/documentation-site/src/pages/demos/prismatic-joint/_create-sliders.ts index 4685f0515..9e2731e4f 100644 --- a/documentation-site/src/pages/demos/prismatic-joint/_create-sliders.ts +++ b/documentation-site/src/pages/demos/prismatic-joint/_create-sliders.ts @@ -5,7 +5,6 @@ import { } from '@forge-game-engine/forge/common'; import { lerp, Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, addGravityComponent, addPrismaticJointComponent, @@ -244,7 +243,6 @@ function createSliderScenario( mass: sliderCollider.mass, momentOfInertia: sliderCollider.momentOfInertia, }); - addAabbComponent(world, sliderEntity); addGravityComponent(world, sliderEntity, { amount: gravity }); const jointEntity = world.createEntity(); diff --git a/documentation-site/src/pages/demos/raycasting/_create-game.ts b/documentation-site/src/pages/demos/raycasting/_create-game.ts index 97b02e9a8..e968bcd1d 100644 --- a/documentation-site/src/pages/demos/raycasting/_create-game.ts +++ b/documentation-site/src/pages/demos/raycasting/_create-game.ts @@ -59,9 +59,9 @@ export const createRaycastingGame = async (): Promise => { const collisionPairs: CollisionPair[] = []; - // `raycast` reads each entity's `AabbEcsComponent` directly rather than - // recomputing it, so the broad-phase system still needs to run every - // tick to keep it in sync - even though nothing in this scene has a + // `raycast` reads each collider's `aabb` directly rather than recomputing + // it, so the broad-phase system still needs to run every tick to keep it + // in sync - even though nothing in this scene has a // `RigidBodyEcsComponent` for it to actually resolve collisions between. // `createTransformEcsSystem` runs first so the targets' `world` poses (which // the broad phase and `raycast` read) and the ray visual's `world` pose diff --git a/documentation-site/src/pages/demos/raycasting/_create-targets.ts b/documentation-site/src/pages/demos/raycasting/_create-targets.ts index e110f61bf..420d8ce30 100644 --- a/documentation-site/src/pages/demos/raycasting/_create-targets.ts +++ b/documentation-site/src/pages/demos/raycasting/_create-targets.ts @@ -5,7 +5,6 @@ import { import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, CircleCollider, Collider, @@ -80,8 +79,7 @@ function buildTargets(width: number): TargetSpec[] { * Creates a fixed arrangement of static circle and square "targets" for the * raycasting demo's ray to be cast against. None have a * `RigidBodyEcsComponent` - they're immovable scenery, not simulated bodies, - * so a plain `ColliderEcsComponent` plus `AabbEcsComponent` is all `raycast` - * needs to see them. + * so a plain `ColliderEcsComponent` is all `raycast` needs to see them. * @param world - The ECS world to add the target entities to. * @param renderContext - The render context used to load target sprites. * @param renderLayer - The render layer targets should be drawn on. @@ -134,6 +132,5 @@ export async function createTargets( height: target.size, }); addColliderComponent(world, entity, { collider }); - addAabbComponent(world, entity); } } diff --git a/documentation-site/src/pages/demos/revolute-joint/_create-hinges.ts b/documentation-site/src/pages/demos/revolute-joint/_create-hinges.ts index 54fa5f00c..4de683ccc 100644 --- a/documentation-site/src/pages/demos/revolute-joint/_create-hinges.ts +++ b/documentation-site/src/pages/demos/revolute-joint/_create-hinges.ts @@ -5,7 +5,6 @@ import { } from '@forge-game-engine/forge/common'; import { Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, addGravityComponent, addRevoluteJointComponent, @@ -197,7 +196,6 @@ function createDoorScenario( mass: doorCollider.mass, momentOfInertia: doorCollider.momentOfInertia, }); - addAabbComponent(world, doorEntity); addGravityComponent(world, doorEntity, { amount: gravity }); // referenceAngle is captured as doorAngle here, so the joint's own @@ -270,7 +268,6 @@ function createPendulumScenario( mass: bobCollider.mass, momentOfInertia: bobCollider.momentOfInertia, }); - addAabbComponent(world, bobEntity); addGravityComponent(world, bobEntity, { amount: gravity }); const jointEntity = world.createEntity(); @@ -322,7 +319,6 @@ function createWheelScenario( momentOfInertia: wheelCollider.momentOfInertia, angularVelocity: 5, }); - addAabbComponent(world, wheelEntity); addGravityComponent(world, wheelEntity, { amount: gravity }); const jointEntity = world.createEntity(); diff --git a/documentation-site/src/pages/demos/rolling-ball/_create-player.ts b/documentation-site/src/pages/demos/rolling-ball/_create-player.ts index 0e9826d9d..b01818baf 100644 --- a/documentation-site/src/pages/demos/rolling-ball/_create-player.ts +++ b/documentation-site/src/pages/demos/rolling-ball/_create-player.ts @@ -5,7 +5,6 @@ import { } from '@forge-game-engine/forge/common'; import { Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addAngularVelocityMotorComponent, addColliderComponent, addGravityComponent, @@ -81,7 +80,6 @@ export async function createPlayer( friction: 0.9, restitution: 0.15, }); - addAabbComponent(world, entity); addRigidBodyComponent(world, entity, { mass: collider.mass, momentOfInertia: collider.momentOfInertia, diff --git a/documentation-site/src/pages/demos/rolling-ball/_create-terrain.ts b/documentation-site/src/pages/demos/rolling-ball/_create-terrain.ts index 52442e858..e4f3ceca0 100644 --- a/documentation-site/src/pages/demos/rolling-ball/_create-terrain.ts +++ b/documentation-site/src/pages/demos/rolling-ball/_create-terrain.ts @@ -5,7 +5,6 @@ import { } from '@forge-game-engine/forge/common'; import { clamp, Random, Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, TerrainCollider, } from '@forge-game-engine/forge/physics'; @@ -175,7 +174,6 @@ export async function createTerrain( collider: terrainCollider, friction: 0.9, }); - addAabbComponent(world, terrainEntity); const [borderImage, fillImage] = await Promise.all([ renderContext.imageCache.getOrLoad(border.textureUrl), diff --git a/documentation-site/src/pages/demos/space-shooter/_asteroid-spawner.system.ts b/documentation-site/src/pages/demos/space-shooter/_asteroid-spawner.system.ts index af65d0a12..afca85ff1 100644 --- a/documentation-site/src/pages/demos/space-shooter/_asteroid-spawner.system.ts +++ b/documentation-site/src/pages/demos/space-shooter/_asteroid-spawner.system.ts @@ -8,7 +8,6 @@ import { import { Random } from '@forge-game-engine/forge/math'; import { addSpriteComponent } from '@forge-game-engine/forge/rendering'; import { - addAabbComponent, addColliderComponent, CircleCollider, } from '@forge-game-engine/forge/physics'; @@ -73,7 +72,6 @@ export const createAsteroidSpawnerEcsSystem = ( addColliderComponent(world, asteroidEntity, { collider: new CircleCollider(asteroidRadius), }); - addAabbComponent(world, asteroidEntity); } }, }); diff --git a/documentation-site/src/pages/demos/space-shooter/_create-player.ts b/documentation-site/src/pages/demos/space-shooter/_create-player.ts index dc6c0099b..4f5ad908f 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-player.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-player.ts @@ -15,7 +15,6 @@ import { } from '@forge-game-engine/forge/common'; import { - addAabbComponent, addColliderComponent, CircleCollider, } from '@forge-game-engine/forge/physics'; @@ -133,7 +132,6 @@ export function spawnPlayer( addColliderComponent(world, playerEntity, { collider: new CircleCollider(playerRadius), }); - addAabbComponent(world, playerEntity); } export async function createPlayer( diff --git a/documentation-site/src/pages/demos/space-shooter/_gun.system.ts b/documentation-site/src/pages/demos/space-shooter/_gun.system.ts index 3e485513e..b682a648a 100644 --- a/documentation-site/src/pages/demos/space-shooter/_gun.system.ts +++ b/documentation-site/src/pages/demos/space-shooter/_gun.system.ts @@ -17,7 +17,6 @@ import { } from '@forge-game-engine/forge/lifecycle'; import { addAudioComponent } from '@forge-game-engine/forge/audio'; import { - addAabbComponent, addColliderComponent, CircleCollider, } from '@forge-game-engine/forge/physics'; @@ -125,5 +124,4 @@ function createBulletWithOffset( addColliderComponent(world, bullet, { collider: new CircleCollider(bulletRadius), }); - addAabbComponent(world, bullet); } diff --git a/documentation-site/src/pages/demos/wrecking-ball/_create-wrecking-ball.ts b/documentation-site/src/pages/demos/wrecking-ball/_create-wrecking-ball.ts index 0168b6b7b..b4b506c31 100644 --- a/documentation-site/src/pages/demos/wrecking-ball/_create-wrecking-ball.ts +++ b/documentation-site/src/pages/demos/wrecking-ball/_create-wrecking-ball.ts @@ -6,7 +6,6 @@ import { } from '@forge-game-engine/forge/common'; import { Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { - addAabbComponent, addColliderComponent, addGravityComponent, addRevoluteJointComponent, @@ -165,7 +164,6 @@ function createPhysicsSpriteEntity( }), ...(options.friction !== undefined && { friction: options.friction }), }); - addAabbComponent(world, entity); if (!options.isStatic) { addRigidBodyComponent(world, entity, { diff --git a/src/physics/collision/collision-filter.ts b/src/physics/collision/collision-filter.ts new file mode 100644 index 000000000..45b3f8df2 --- /dev/null +++ b/src/physics/collision/collision-filter.ts @@ -0,0 +1,16 @@ +import { ColliderEcsComponent } from '../components/collider-component.js'; + +/** + * Checks whether two colliders' categories and masks let them collide: + * each one's `category` must share a bit with the other's `mask`. The test + * is symmetric, so either collider can opt out of the pair. + * @param a - The first collider. + * @param b - The second collider. + * @returns `true` if the pair should be tested for collision. + */ +export function collidersCanCollide( + a: Pick, + b: Pick, +): boolean { + return (a.category & b.mask) !== 0 && (b.category & a.mask) !== 0; +} diff --git a/src/physics/components/aabb-component.ts b/src/physics/components/aabb-component.ts deleted file mode 100644 index 895fc912e..000000000 --- a/src/physics/components/aabb-component.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createComponentId } from '../../ecs/ecs-component.js'; -import { EcsWorld } from '../../ecs/ecs-world.js'; -import { Vec2, Vector2 } from '../../math/index.js'; - -export interface AabbDefaultedOptions { - min: Vector2; - max: Vector2; -} - -export type AabbEcsComponent = AabbDefaultedOptions; - -export const aabbId = createComponentId('aabb'); - -export function addAabbComponent( - world: EcsWorld, - entity: number, - options?: Partial, -): AabbEcsComponent { - const defaultAabbOptions: AabbDefaultedOptions = { - min: Vec2.zero, - max: Vec2.zero, - }; - - const component: AabbEcsComponent = { - ...defaultAabbOptions, - ...options, - }; - - return world.addComponent(entity, aabbId, component); -} diff --git a/src/physics/components/collider-component.ts b/src/physics/components/collider-component.ts index a01ebd10c..d3e84c74d 100644 --- a/src/physics/components/collider-component.ts +++ b/src/physics/components/collider-component.ts @@ -1,6 +1,13 @@ import { createComponentId } from '../../ecs/ecs-component.js'; import { EcsWorld } from '../../ecs/ecs-world.js'; import { Collider } from '../colliders/collider.js'; +import { Aabb } from '../types/aabb.js'; + +/** + * A collision mask with every bit set: a collider (or ray) with this mask + * accepts every category. + */ +export const allCollisionCategories = 0xffffffff; /** * Fields of {@link ColliderEcsComponent} with a sensible default; callers @@ -20,6 +27,20 @@ export interface ColliderDefaultedOptions { * contacting colliders' restitution via their geometric mean. */ restitution: number; + + /** + * The category bits this collider belongs to, usually a single bit + * (`1 << n`). Up to 32 bits, as JavaScript's bitwise operators allow. + * Two colliders are tested against each other only when each one's + * `category` shares a bit with the other's `mask`. Defaults to `1`. + */ + category: number; + + /** + * The category bits this collider collides with. Defaults to + * {@link allCollisionCategories}. + */ + mask: number; } export interface ColliderRequiredOptions { @@ -27,23 +48,37 @@ export interface ColliderRequiredOptions { } export interface ColliderEcsComponent - extends ColliderRequiredOptions, ColliderDefaultedOptions {} + extends ColliderRequiredOptions, ColliderDefaultedOptions { + /** + * The collider's world-space bounds. Output only: written by + * `createBroadPhaseEcsSystem` every tick, from the entity's world + * position and rotation. Until the broad phase first runs, the box is + * empty and overlaps nothing. + */ + readonly aabb: Aabb; +} export const colliderId = createComponentId('collider'); export function addColliderComponent( world: EcsWorld, entity: number, - options: ColliderRequiredOptions & Partial, + options: ColliderRequiredOptions & Partial, ): ColliderEcsComponent { const defaultColliderOptions: ColliderDefaultedOptions = { friction: 0.6, restitution: 0.05, + category: 1, + mask: allCollisionCategories, }; const component: ColliderEcsComponent = { ...defaultColliderOptions, ...options, + aabb: { + min: { x: Infinity, y: Infinity }, + max: { x: -Infinity, y: -Infinity }, + }, }; return world.addComponent(entity, colliderId, component); diff --git a/src/physics/components/index.ts b/src/physics/components/index.ts index 7a383dbe3..78512f9a8 100644 --- a/src/physics/components/index.ts +++ b/src/physics/components/index.ts @@ -1,4 +1,3 @@ -export * from './aabb-component.js'; export * from './angular-velocity-motor-component.js'; export * from './collider-component.js'; export * from './gravity-component.js'; diff --git a/src/physics/raycast/raycast.test.ts b/src/physics/raycast/raycast.test.ts index bd199477e..8efbde026 100644 --- a/src/physics/raycast/raycast.test.ts +++ b/src/physics/raycast/raycast.test.ts @@ -8,7 +8,6 @@ import { EcsWorld } from '../../ecs/index.js'; import { Vector2 } from '../../math/index.js'; import { CircleCollider } from '../colliders/circle-collider.js'; import { PolygonCollider } from '../colliders/polygon-collider.js'; -import { addAabbComponent } from '../components/aabb-component.js'; import { addColliderComponent } from '../components/collider-component.js'; import { createBroadPhaseEcsSystem } from '../systems/broad-phase-system.js'; @@ -29,7 +28,6 @@ describe('raycast', () => { addPositionComponent(world, entity, { local: position }); addRotationComponent(world, entity); addColliderComponent(world, entity, { collider }); - addAabbComponent(world, entity); return entity; } @@ -80,8 +78,15 @@ describe('raycast', () => { addEntity({ x: -3, y: 0 }, new CircleCollider(1)); world.update(); - const sorted = raycast(world, { x: -10, y: 0 }, { x: 10, y: 0 }, true); - const unsorted = raycast(world, { x: -10, y: 0 }, { x: 10, y: 0 }, false); + const sorted = raycast(world, { x: -10, y: 0 }, { x: 10, y: 0 }); + const unsorted = raycast( + world, + { x: -10, y: 0 }, + { x: 10, y: 0 }, + { + sort: false, + }, + ); const byEntity = (a: number, b: number): number => a - b; @@ -115,7 +120,6 @@ describe('raycast', () => { { x: -2, y: 1 }, ]), }); - addAabbComponent(world, entity); world.update(); const hits = raycast(world, { x: -5, y: 0 }, { x: 5, y: 0 }); @@ -125,4 +129,43 @@ describe('raycast', () => { expect(hits[0].point.x).toBeCloseTo(-2); expect(hits[0].distance).toBeCloseTo(3); }); + + it('should only hit colliders whose category is in the mask', () => { + const wall = world.createEntity(); + const window = world.createEntity(); + + addPositionComponent(world, wall, { local: { x: 3, y: 0 } }); + addColliderComponent(world, wall, { + collider: new CircleCollider(1), + category: 0b01, + }); + addPositionComponent(world, window, { local: { x: -3, y: 0 } }); + addColliderComponent(world, window, { + collider: new CircleCollider(1), + category: 0b10, + }); + world.update(); + + const hits = raycast( + world, + { x: -10, y: 0 }, + { x: 10, y: 0 }, + { + mask: 0b01, + }, + ); + + expect(hits.map((hit) => hit.entity)).toEqual([wall]); + }); + + it('should not hit a collider whose bounds the broad phase has not computed yet', () => { + addEntity({ x: 3, y: 0 }, new CircleCollider(1)); + world.update(); + // Added after the broad phase ran, at the origin the ray passes through. + addEntity({ x: 0, y: 0 }, new CircleCollider(1)); + + const hits = raycast(world, { x: -10, y: 0 }, { x: 10, y: 0 }); + + expect(hits).toHaveLength(1); + }); }); diff --git a/src/physics/raycast/raycast.ts b/src/physics/raycast/raycast.ts index 0bc286c45..a49207a3f 100644 --- a/src/physics/raycast/raycast.ts +++ b/src/physics/raycast/raycast.ts @@ -8,8 +8,8 @@ import { EcsWorld } from '../../ecs/index.js'; import { Vector2 } from '../../math/index.js'; import { getColliderRotation } from '../collider-rotation.js'; import { aabbsOverlap } from '../collision/aabb-overlap.js'; -import { AabbEcsComponent, aabbId } from '../components/aabb-component.js'; import { + allCollisionCategories, ColliderEcsComponent, colliderId, } from '../components/collider-component.js'; @@ -52,6 +52,31 @@ function computeSegmentAabb(start: Vector2, end: Vector2): Aabb { }; } +/** + * Options for {@link raycast}. + */ +export interface RaycastOptions { + /** + * When `true`, results are ordered by distance from `start`, so the first + * result is the nearest entity along the ray. Pass `false` to skip the + * sort if you only need a yes/no check, or intend to find the closest hit + * yourself. Defaults to `true`. + */ + sort: boolean; + + /** + * The collider categories the ray can hit: a collider is tested only if + * its `category` shares a bit with this mask. Defaults to + * {@link allCollisionCategories}. + */ + mask: number; +} + +const defaultRaycastOptions: RaycastOptions = { + sort: true, + mask: allCollisionCategories, +}; + /** * Casts a line segment from `start` to `end` against every entity in * `world` with a {@link ColliderEcsComponent}, and returns every point where @@ -60,28 +85,31 @@ function computeSegmentAabb(start: Vector2, end: Vector2): Aabb { * two points?" without running a full simulation step. * * Before testing an entity's exact collider shape, `raycast` skips any - * entity whose {@link AabbEcsComponent} doesn't overlap the ray's own - * bounding box, so casting against a `world` with many entities is cheap as - * long as most of them aren't near the ray. + * collider whose {@link ColliderEcsComponent.aabb} doesn't overlap the + * ray's own bounding box, so casting against a `world` with many entities + * is cheap as long as most of them aren't near the ray. Those bounds are + * written by `createBroadPhaseEcsSystem`, so a collider added since the + * broad phase last ran can't be hit yet. * @param world - The ECS world to search for collider entities in. * @param start - The ray's world-space start point. * @param end - The ray's world-space end point. - * @param sort - When `true` (the default), results are ordered by distance - * from `start`, so the first result is the nearest entity along the ray. - * Pass `false` to skip the sort if you only need a yes/no check, or intend - * to find the closest hit yourself. + * @param options - Sorting and filtering; see {@link RaycastOptions}. * @returns Every entity the ray intersects, as a {@link RaycastHit}. */ export function raycast( world: EcsWorld, start: Vector2, end: Vector2, - sort: boolean = true, + options: Partial = {}, ): RaycastHit[] { + const { sort, mask } = { + ...defaultRaycastOptions, + ...options, + }; const { entities, components } = world.query< - [PositionEcsComponent, ColliderEcsComponent, AabbEcsComponent] - >([positionId, colliderId, aabbId]); - const [positions, colliders, aabbs] = components; + [PositionEcsComponent, ColliderEcsComponent] + >([positionId, colliderId]); + const [positions, colliders] = components; // Rotation is optional for colliders (an entity without one is treated as // unrotated), so it's read per entity rather than required by the query. const getRotation = @@ -91,14 +119,19 @@ export function raycast( const hits: RaycastHit[] = []; for (let i = 0; i < entities.length; i++) { - if (!aabbsOverlap(rayAabb, aabbs[i])) { + const collider = colliders[i]; + + if ( + (collider.category & mask) === 0 || + !aabbsOverlap(rayAabb, collider.aabb) + ) { continue; } const body: CollisionBody = { position: positions[i].world, rotation: getColliderRotation(getRotation(entities[i])), - collider: colliders[i].collider, + collider: collider.collider, }; const hit = raycastBody(body, start, end); diff --git a/src/physics/systems/broad-phase-system.test.ts b/src/physics/systems/broad-phase-system.test.ts index 633e1e303..e9f0ae24a 100644 --- a/src/physics/systems/broad-phase-system.test.ts +++ b/src/physics/systems/broad-phase-system.test.ts @@ -10,10 +10,10 @@ import { Vector2 } from '../../math/index.js'; import { CircleCollider } from '../colliders/circle-collider.js'; import { PolygonCollider } from '../colliders/polygon-collider.js'; import { - AabbEcsComponent, - addAabbComponent, -} from '../components/aabb-component.js'; -import { addColliderComponent } from '../components/collider-component.js'; + addColliderComponent, + ColliderEcsComponent, +} from '../components/collider-component.js'; +import { Aabb } from '../types/aabb.js'; import { CollisionManifold } from '../types/collision-manifold.js'; import { CollisionPair } from '../types/collision-pair.js'; import { createNarrowPhaseEcsSystem } from './narrow-phase-system.js'; @@ -34,7 +34,7 @@ describe('createBroadPhaseEcsSystem', () => { ): { entity: number; position: PositionEcsComponent; - aabb: AabbEcsComponent; + collider: ColliderEcsComponent; } { const entity = world.createEntity(); @@ -42,16 +42,17 @@ describe('createBroadPhaseEcsSystem', () => { local: position, }); addRotationComponent(world, entity); - addColliderComponent(world, entity, { + const collider = addColliderComponent(world, entity, { collider: new CircleCollider(radius), }); - const aabb = addAabbComponent(world, entity); - return { entity, position: positionComponent, aabb }; + return { entity, position: positionComponent, collider }; } - it('should update the AABB component from the collider', () => { - const { aabb } = addCircleEntity({ x: 2, y: 3 }, 1); + it("should update the collider's AABB from its shape and position", () => { + const { + collider: { aabb }, + } = addCircleEntity({ x: 2, y: 3 }, 1); world.update(); @@ -99,17 +100,16 @@ describe('createBroadPhaseEcsSystem', () => { function addUnrotatedEntity( position: Vector2, collider: CircleCollider | PolygonCollider, - ): { entity: number; aabb: AabbEcsComponent } { + ): { entity: number; aabb: Aabb } { const entity = world.createEntity(); addPositionComponent(world, entity, { local: position }); - addColliderComponent(world, entity, { collider }); - const aabb = addAabbComponent(world, entity); + const { aabb } = addColliderComponent(world, entity, { collider }); return { entity, aabb }; } - it('should still update the AABB component, treating rotation as 0', () => { + it("should still update the collider's AABB, treating rotation as 0", () => { const { aabb } = addUnrotatedEntity( { x: 0, y: 0 }, new PolygonCollider([ @@ -135,7 +135,7 @@ describe('createBroadPhaseEcsSystem', () => { addRotationComponent(world, entity, { local: Math.PI / 2, }); - addColliderComponent(world, entity, { + const { aabb } = addColliderComponent(world, entity, { collider: new PolygonCollider([ { x: -2, y: -1 }, { x: 2, y: -1 }, @@ -143,7 +143,6 @@ describe('createBroadPhaseEcsSystem', () => { { x: -2, y: 1 }, ]), }); - const aabb = addAabbComponent(world, entity); world.update(); @@ -187,4 +186,79 @@ describe('createBroadPhaseEcsSystem', () => { expect(collisionManifolds[0].entityB).toBe(trigger); }); }); + + describe('collision filtering', () => { + function addFilteredEntity( + x: number, + category: number, + mask: number, + ): number { + const entity = world.createEntity(); + + addPositionComponent(world, entity, { local: { x, y: 0 } }); + addColliderComponent(world, entity, { + collider: new CircleCollider(1), + category, + mask, + }); + + return entity; + } + + it('should pair colliders whose categories are in both masks', () => { + const entityA = addFilteredEntity(0, 0b01, 0b10); + const entityB = addFilteredEntity(1, 0b10, 0b01); + + world.update(); + + expect(collisionPairs).toEqual([{ entityA, entityB }]); + }); + + it('should skip a pair when the first mask excludes the second category', () => { + addFilteredEntity(0, 0b01, 0b01); + addFilteredEntity(1, 0b10, 0b11); + + world.update(); + + expect(collisionPairs).toHaveLength(0); + }); + + it('should skip a pair when the second mask excludes the first category', () => { + addFilteredEntity(0, 0b01, 0b11); + addFilteredEntity(1, 0b10, 0b10); + + world.update(); + + expect(collisionPairs).toHaveLength(0); + }); + + it('should pair colliders in the 32nd category bit', () => { + const highBit = 1 << 31; + const entityA = addFilteredEntity(0, highBit, highBit); + const entityB = addFilteredEntity(1, highBit, highBit); + + world.update(); + + expect(collisionPairs).toEqual([{ entityA, entityB }]); + }); + + it('should pair every collider by default', () => { + const entityA = world.createEntity(); + const entityB = world.createEntity(); + + addPositionComponent(world, entityA); + addColliderComponent(world, entityA, { + collider: new CircleCollider(1), + }); + addPositionComponent(world, entityB); + addColliderComponent(world, entityB, { + collider: new CircleCollider(1), + category: 1 << 5, + }); + + world.update(); + + expect(collisionPairs).toEqual([{ entityA, entityB }]); + }); + }); }); diff --git a/src/physics/systems/broad-phase-system.ts b/src/physics/systems/broad-phase-system.ts index 4e8ff5194..c2dc2ab15 100644 --- a/src/physics/systems/broad-phase-system.ts +++ b/src/physics/systems/broad-phase-system.ts @@ -7,7 +7,7 @@ import { import { EcsSystem } from '../../ecs/ecs-system.js'; import { getColliderRotation } from '../collider-rotation.js'; import { aabbsOverlap } from '../collision/aabb-overlap.js'; -import { AabbEcsComponent, aabbId } from '../components/aabb-component.js'; +import { collidersCanCollide } from '../collision/collision-filter.js'; import { ColliderEcsComponent, colliderId, @@ -15,11 +15,13 @@ import { import { CollisionPair } from '../types/collision-pair.js'; /** - * Creates an ECS system that recomputes each collider entity's - * {@link AabbEcsComponent} from its world position/rotation and performs a - * broad-phase, all-pairs AABB overlap test, writing every overlapping pair - * into `collisionPairs`. A collider entity needs a `PositionEcsComponent`, - * `ColliderEcsComponent`, and `AabbEcsComponent` to take part; + * Creates an ECS system that recomputes each collider's + * {@link ColliderEcsComponent.aabb} from its entity's world + * position/rotation and performs a broad-phase, all-pairs AABB overlap + * test, writing every overlapping pair into `collisionPairs`. Pairs whose + * categories and masks exclude each other (see `collidersCanCollide`) are + * skipped before the AABB test. A collider entity needs a + * `PositionEcsComponent` and a `ColliderEcsComponent` to take part; * `RotationEcsComponent` is optional, and an entity without one is treated * as unrotated. Must run after whatever system computes * `PositionEcsComponent.world`/`RotationEcsComponent.world` (e.g. @@ -31,31 +33,33 @@ import { CollisionPair } from '../types/collision-pair.js'; */ export const createBroadPhaseEcsSystem = ( collisionPairs: CollisionPair[], -): EcsSystem< - [PositionEcsComponent, ColliderEcsComponent, AabbEcsComponent] -> => ({ - query: [positionId, colliderId, aabbId], - update: (world, { entities, components: [positions, colliders, aabbs] }) => { +): EcsSystem<[PositionEcsComponent, ColliderEcsComponent]> => ({ + query: [positionId, colliderId], + update: (world, { entities, components: [positions, colliders] }) => { // Rotation is optional for colliders, so it can't be part of the query // above; resolving its storage once per tick avoids a lookup per entity. const getRotation = world.getComponentAccessor(rotationId); for (let i = 0; i < entities.length; i++) { - const aabb = colliders[i].collider.computeAabb( + const collider = colliders[i]; + const aabb = collider.collider.computeAabb( positions[i].world, getColliderRotation(getRotation(entities[i])), ); - aabbs[i].min = aabb.min; - aabbs[i].max = aabb.max; + collider.aabb.min = aabb.min; + collider.aabb.max = aabb.max; } collisionPairs.length = 0; for (let i = 0; i < entities.length; i++) { for (let j = i + 1; j < entities.length; j++) { - if (aabbsOverlap(aabbs[i], aabbs[j])) { + if ( + collidersCanCollide(colliders[i], colliders[j]) && + aabbsOverlap(colliders[i].aabb, colliders[j].aabb) + ) { collisionPairs.push({ entityA: entities[i], entityB: entities[j] }); } } diff --git a/src/physics/systems/terrain-contact-stability.test.ts b/src/physics/systems/terrain-contact-stability.test.ts index 9576d9760..5ef6a5348 100644 --- a/src/physics/systems/terrain-contact-stability.test.ts +++ b/src/physics/systems/terrain-contact-stability.test.ts @@ -10,7 +10,6 @@ import { Random, Vector2 } from '../../math/index.js'; import { CircleCollider } from '../colliders/circle-collider.js'; import { PolygonCollider } from '../colliders/polygon-collider.js'; import { TerrainCollider } from '../colliders/terrain-collider.js'; -import { addAabbComponent } from '../components/aabb-component.js'; import { addColliderComponent } from '../components/collider-component.js'; import { addGravityComponent } from '../components/gravity-component.js'; import { @@ -143,7 +142,6 @@ describe('contact stability on a multi-edge TerrainCollider', () => { ), friction: 0.8, }); - addAabbComponent(world, entity); return entity; } @@ -165,7 +163,6 @@ describe('contact stability on a multi-edge TerrainCollider', () => { friction: 0.8, restitution: 0, }); - addAabbComponent(world, entity); const rigidBody = addRigidBodyComponent(world, entity, { mass: 300, From 3da270bbb551d55af55af2fdf2792404d96ddaf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 18:03:42 +0000 Subject: [PATCH 2/3] feat(physics): add sensor colliders and per-entity contacts `addColliderComponent` takes `sensor: true` for a collider that's detected but never resolved. The narrow phase reports sensor overlaps through contacts only and never writes them to `collisionManifolds`, so the resolution system is unchanged. `raycast` passes through sensors unless given `includeSensors: true`. New `ContactsEcsComponent` (opt-in via `addContactsComponent`): the narrow phase queries it and, each tick, writes the entities it's touching (each listed once, however many manifolds the pair produced) and which contacts started and ended since the last tick. An entity that loses its collider, or whose partner is removed, gets its contacts ended; generational handles keep a removed entity distinct from a new one in its slot. The space shooter, brick breaker, rolling ball and car docs demos and /demo now read contacts instead of scanning `collisionManifolds`, and the space shooter filters asteroids, bullets and the player with categories. A new Sensors and Contacts docs demo shows trigger zones, and the physics guide gains a Collisions page covering filtering, sensors and contacts. Implements Phase 2 of design/collision-events.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LVTLatSvTvhsw2P788Dcjj --- CHANGELOG.md | 2 + demo/src/game.ts | 42 +-- .../docs/docs/physics/collisions.md | 182 ++++++++++ documentation-site/docs/docs/physics/index.md | 12 +- .../docs/docs/physics/raycasting.md | 6 +- .../docs/docs/physics/rigid-bodies.md | 60 +--- documentation-site/src/data/demos.ts | 7 + .../pages/demos/brick-breaker/_ball.system.ts | 50 ++- .../pages/demos/brick-breaker/_create-ball.ts | 3 + .../pages/demos/brick-breaker/_create-game.ts | 4 +- .../src/pages/demos/car/_create-car.ts | 3 + .../src/pages/demos/car/_create-game.ts | 6 +- .../demos/car/_ground-contact.component.ts | 11 +- .../pages/demos/car/_ground-contact.system.ts | 50 +-- .../src/pages/demos/car/index.tsx | 2 +- .../pages/demos/rolling-ball/_create-game.ts | 1 - .../demos/rolling-ball/_create-player.ts | 3 + .../pages/demos/rolling-ball/_jump.system.ts | 29 +- .../src/pages/demos/rolling-ball/index.tsx | 2 +- .../demos/sensors/_ball-spawner.system.ts | 85 +++++ .../src/pages/demos/sensors/_create-game.ts | 86 +++++ .../src/pages/demos/sensors/_create-scene.ts | 171 +++++++++ .../src/pages/demos/sensors/_drain.system.ts | 25 ++ .../demos/sensors/_trigger-zone.component.ts | 28 ++ .../demos/sensors/_trigger-zone.system.ts | 65 ++++ .../src/pages/demos/sensors/index.tsx | 36 ++ .../space-shooter/_asteroid-spawner.system.ts | 10 + .../space-shooter/_collision-categories.ts | 8 + .../demos/space-shooter/_collision.system.ts | 40 +-- .../pages/demos/space-shooter/_create-game.ts | 7 +- .../demos/space-shooter/_create-player.ts | 3 + .../pages/demos/space-shooter/_gun.system.ts | 3 + .../src/pages/demos/space-shooter/index.tsx | 5 + src/physics/components/collider-component.ts | 9 + src/physics/components/contacts-component.ts | 59 +++ src/physics/components/index.ts | 1 + src/physics/raycast/raycast.test.ts | 23 ++ src/physics/raycast/raycast.ts | 10 +- .../systems/narrow-phase-contacts.test.ts | 339 ++++++++++++++++++ src/physics/systems/narrow-phase-system.ts | 127 +++++-- 40 files changed, 1380 insertions(+), 235 deletions(-) create mode 100644 documentation-site/docs/docs/physics/collisions.md create mode 100644 documentation-site/src/pages/demos/sensors/_ball-spawner.system.ts create mode 100644 documentation-site/src/pages/demos/sensors/_create-game.ts create mode 100644 documentation-site/src/pages/demos/sensors/_create-scene.ts create mode 100644 documentation-site/src/pages/demos/sensors/_drain.system.ts create mode 100644 documentation-site/src/pages/demos/sensors/_trigger-zone.component.ts create mode 100644 documentation-site/src/pages/demos/sensors/_trigger-zone.system.ts create mode 100644 documentation-site/src/pages/demos/sensors/index.tsx create mode 100644 documentation-site/src/pages/demos/space-shooter/_collision-categories.ts create mode 100644 src/physics/components/contacts-component.ts create mode 100644 src/physics/systems/narrow-phase-contacts.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 57be2b3bc..bd89ce4d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **physics:** Collision filtering. `addColliderComponent` takes a `category` (the bits a collider belongs to, default `1`) and a `mask` (the categories it collides with, default `allCollisionCategories`). Two colliders are tested only when each one's category is in the other's mask, so pairs your game would ignore, such as bullets against bullets, never reach the narrow phase - **physics:** `raycast` takes a `mask` option, so a ray only hits colliders whose `category` is in it +- **physics:** Sensor colliders. `addColliderComponent(world, entity, { collider, sensor: true })` makes a collider that's detected but never resolved, so bodies pass through it, for trigger zones and pickups. Sensor overlaps never appear in `collisionManifolds`, and `raycast` passes through sensors unless you pass `includeSensors: true` +- **physics:** Per-entity contacts. Give an entity `addContactsComponent(world, entity)` and `createNarrowPhaseEcsSystem` fills its `ContactsEcsComponent` every tick with the entities it's `touching` (each listed once), the ones it `started` touching and the ones it `ended` touching, sensor overlaps included. A system can ask what its entities touched without scanning `collisionManifolds` #### Changed diff --git a/demo/src/game.ts b/demo/src/game.ts index 31dddbc46..20aa03933 100644 --- a/demo/src/game.ts +++ b/demo/src/game.ts @@ -16,11 +16,13 @@ import { positionId, Random, SpriteEcsComponent, + spriteId, Time, Vec2, } from '../../src'; import { addColliderComponent, + addContactsComponent, addGravityComponent, addRigidBodyComponent, CircleCollider, @@ -28,6 +30,8 @@ import { CollisionManifold, CollisionPair, ContactConstraint, + ContactsEcsComponent, + contactsId, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, createEulerIntegrationEcsSystem, @@ -128,6 +132,7 @@ function createFountainSpawnEcsSystem( angularVelocity, }); addColliderComponent(world, entity, { collider }); + addContactsComponent(world, entity); }; return { @@ -176,32 +181,19 @@ function createDespawnFallenShapesEcsSystem( } /** - * Creates a system that tints every tracked sprite red while its entity is - * involved in a collision this tick, and white otherwise, so collision + * Creates a system that tints every sprite with a `ContactsEcsComponent` red + * while its entity is touching something, and white otherwise, so collision * detection is visible without needing collision resolution. */ -function createCollisionTintEcsSystem( - collisionManifolds: CollisionManifold[], - spritesByEntity: Map, -): EcsSystem<[]> { +function createCollisionTintEcsSystem(): EcsSystem< + [SpriteEcsComponent, ContactsEcsComponent] +> { return { - query: [], - update: () => { - for (const sprite of spritesByEntity.values()) { - sprite.tintColor = Color.white; - } - - for (const manifold of collisionManifolds) { - const spriteA = spritesByEntity.get(manifold.entityA); - const spriteB = spritesByEntity.get(manifold.entityB); - - if (spriteA) { - spriteA.tintColor = Color.red; - } - - if (spriteB) { - spriteB.tintColor = Color.red; - } + query: [spriteId, contactsId], + update: (_world, { components: [sprites, contacts] }) => { + for (let i = 0; i < sprites.length; i++) { + sprites[i].tintColor = + contacts[i].touching.length > 0 ? Color.red : Color.white; } }, }; @@ -355,9 +347,7 @@ world.addSystem( -halfHeight - despawnMarginBelowGround, ), ); -world.addSystem( - createCollisionTintEcsSystem(collisionManifolds, spritesByEntity), -); +world.addSystem(createCollisionTintEcsSystem()); world.addSystem(createRenderEcsSystem(renderContext)); game.run(); diff --git a/documentation-site/docs/docs/physics/collisions.md b/documentation-site/docs/docs/physics/collisions.md new file mode 100644 index 000000000..4069fce1b --- /dev/null +++ b/documentation-site/docs/docs/physics/collisions.md @@ -0,0 +1,182 @@ +--- +sidebar_position: 1.5 +--- + +# Collisions + +The broad and narrow phase systems find which colliders overlap every +tick. This page covers the three things you control about that: which +colliders are tested against each other (filtering), which ones are +detected but never pushed (sensors), and how a system finds out what an +entity touched (contacts). + +Try it in the [Sensors demo](/Forge/demos/sensors), where falling bodies +light up while they pass through trigger zones that never block them. + +## Collision filtering + +By default every collider is tested against every other. Give colliders a +`category` and a `mask` to say which pairs matter: two colliders are tested +only when each one's `category` shares a bit with the other's `mask`. A pair +either mask excludes never reaches the narrow phase, so filtering also saves +the work of testing pairs your game would ignore anyway. + +```ts +import { + addColliderComponent, + allCollisionCategories, +} from '@forge-game-engine/forge/physics'; + +const PLAYER = 1 << 0; +const ENEMY = 1 << 1; +const PLAYER_BULLET = 1 << 2; +const WALL = 1 << 3; + +// Player bullets hit enemies and walls, never the player or each other. +addColliderComponent(world, bullet, { + collider: bulletCollider, + category: PLAYER_BULLET, + mask: ENEMY | WALL, +}); + +// Enemies collide with everything except other enemies. +addColliderComponent(world, enemy, { + collider: enemyCollider, + category: ENEMY, + mask: allCollisionCategories & ~ENEMY, +}); +``` + +`category` defaults to `1` and `mask` to `allCollisionCategories` (every +bit), so colliders that set neither collide with everything. Categories are +32 bits, as JavaScript's bitwise operators allow; test a bit with +`(value & bit) !== 0`, not `> 0`, since `1 << 31` is negative. The test is +symmetric: either collider can rule a pair out, and both have to accept it. + +Filtering applies to resolution, sensors and contacts alike: a pair the +masks exclude is never resolved and never shows up in either entity's +contacts. `raycast` takes its own `mask` (see +[Raycasting](./raycasting.md)). + +## Contacts + +Give an entity a `ContactsEcsComponent` and `createNarrowPhaseEcsSystem` +fills it every tick: + +- `touching`: every entity it overlaps this tick, each listed once. +- `started`: the entities in `touching` that weren't there last tick. +- `ended`: the entities that were touching it last tick and aren't now, + because they moved apart, lost their collider or were removed. + +Contacts are opt-in, so only add the component to entities whose systems +ask what they touch (the player, a projectile, a pickup), not to walls and +debris. A system reads them like any other component: + +```ts +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { + ContactsEcsComponent, + contactsId, +} from '@forge-game-engine/forge/physics'; + +export const createPickupEcsSystem = (): EcsSystem< + [PickupEcsComponent, ContactsEcsComponent] +> => ({ + query: [pickupId, contactsId], + update: (world, { entities, components: [pickups, contacts] }) => { + for (let i = 0; i < entities.length; i++) { + for (const other of contacts[i].started) { + if (!world.isAlive(other)) { + continue; + } + + const wallet = world.getComponent(other, walletId); + + if (wallet) { + wallet.coins += pickups[i].value; + world.removeEntity(entities[i]); + break; + } + } + } + }, +}); +``` + +Register systems that read contacts after `createNarrowPhaseEcsSystem`, or +they see the previous tick's. The narrow phase owns every field of the +component and replaces the lists each tick, so never write to them. + +Contacts are only recorded on entities that have a `ContactsEcsComponent`. +A bullet and an asteroid don't both need one: add it to the side whose +system reacts. + +### Removed entities + +A contact can name an entity that no longer exists: + +- `touching` is computed before your systems run, so another system may + already have removed one of its entities this tick (two asteroids hit by + the same bullet both list it). Check `world.isAlive(other)` before + acting on one. +- `ended` lists entities that were removed since the last tick, so a + "stopped touching" handler that reads the other entity's components + should check `isAlive` too. + +### Contacts vs. collision manifolds + +`collisionManifolds`, the array you pass to `createNarrowPhaseEcsSystem`, +holds the contact points, normal and depth of every solid collision. It's +the input to `createCollisionResolutionEcsSystem`. Read it only when you +need that geometry (for example, the impact point for a spark effect). To +find what an entity touched, read its contacts instead of scanning the +manifolds: a pair can produce several manifolds (one per terrain edge it +touches), manifolds never include sensor overlaps, and scanning them costs +one pass over every collision per entity. + +## Sensors + +A sensor collider is detected and reported through contacts, but never +resolved: nothing bounces off it or is pushed by it, and it never appears +in `collisionManifolds`. Use one for trigger zones, pickups, and anything +else a body should pass through while your game reacts. + +```ts +import { + addColliderComponent, + addContactsComponent, + PolygonCollider, +} from '@forge-game-engine/forge/physics'; + +const zone = world.createEntity(); + +addPositionComponent(world, zone, { local: { x: 0, y: -200 } }); +addColliderComponent(world, zone, { + collider: new PolygonCollider(rectangleVertices(400, 80)), + sensor: true, +}); +addContactsComponent(world, zone); +``` + +A sensor works with or without a `RigidBodyEcsComponent`. Like any static +collider, a trigger zone that doesn't move needs none; a sensor attached to +a moving body (a pickup radius around the player) moves with it. + +Gotchas: + +- A sensor overlap is only detected when at least one of the two entities + has a `ContactsEcsComponent`, since there's nowhere else to report it. + Put it on the sensor to ask "what's inside this zone?", or on the body + to ask "which zones am I in?". +- Two sensors that overlap are reported to each other. Give sensors a + `mask` without their own category if they shouldn't see each other. +- `raycast` passes through sensors unless you pass + `includeSensors: true`, so a line-of-sight ray isn't stopped by a trigger + zone. + +## Bounds + +The broad phase writes each collider's world-space bounds to its `aabb` +field every tick, from its world position and rotation. It's output only: +read it, but don't write it. A collider added since the broad phase last +ran has empty bounds, which overlap nothing, until the next tick. diff --git a/documentation-site/docs/docs/physics/index.md b/documentation-site/docs/docs/physics/index.md index ea874f51a..8d05ed4bd 100644 --- a/documentation-site/docs/docs/physics/index.md +++ b/documentation-site/docs/docs/physics/index.md @@ -17,11 +17,14 @@ Core concepts: collision shape (`CircleCollider`, `PolygonCollider`, or `TerrainCollider`), plus friction, - restitution, and the collision `category`/`mask` that filter which - colliders it's tested against. + restitution, the collision `category`/`mask` that filter which + colliders it's tested against, and whether it's a `sensor` (detected, + never resolved). - `createBroadPhaseEcsSystem`/`createNarrowPhaseEcsSystem`/ `createCollisionResolutionEcsSystem`: detect and resolve collisions between collider entities each tick. +- `ContactsEcsComponent`: which entities a collider entity is touching, + and which contacts started or ended this tick. - `raycast`: casts a ray against every collider entity in an `EcsWorld`. - `PrismaticJointEcsComponent`: a @@ -38,8 +41,9 @@ Core concepts: Guides in this section: - [Bodies and Shapes](./rigid-bodies.md): creating bodies and shapes, - static/kinematic/dynamic bodies, collision filtering, and ECS - integration. + static/kinematic/dynamic bodies, and ECS integration. +- [Collisions](./collisions.md): filtering which colliders collide, sensor + colliders for trigger zones, and reading what an entity touched. - [Applying Forces](./forces.md): gravity, impulses, torque, springs and dampers, and explosions. - [Raycasting](./raycasting.md): casting rays against colliders. diff --git a/documentation-site/docs/docs/physics/raycasting.md b/documentation-site/docs/docs/physics/raycasting.md index f27027aaf..fd762ccd6 100644 --- a/documentation-site/docs/docs/physics/raycasting.md +++ b/documentation-site/docs/docs/physics/raycasting.md @@ -39,7 +39,7 @@ is nearest, pass `{ sort: false }` to skip the sort. `mask` limits the ray to colliders whose `category` shares a bit with it, the same categories colliders filter each other by (see -[Collision filtering](./rigid-bodies.md#collision-filtering)). It defaults +[Collision filtering](./collisions.md#collision-filtering)). It defaults to every category. ```ts @@ -50,6 +50,10 @@ const ENEMIES = 1 << 1; const blocked = raycast(world, eye, target, { sort: false, mask: WALLS }); ``` +Rays pass through [sensor colliders](./collisions.md#sensors), so a trigger +zone doesn't block line of sight. Pass `includeSensors: true` to hit them +too, for example to ask which zone the cursor is over. + `raycast` works against every collider shape - `CircleCollider`, `PolygonCollider`, and `TerrainCollider` - dispatching to the appropriate intersection test based on each entity's `Collider.type`. diff --git a/documentation-site/docs/docs/physics/rigid-bodies.md b/documentation-site/docs/docs/physics/rigid-bodies.md index 127bff07c..1846edf32 100644 --- a/documentation-site/docs/docs/physics/rigid-bodies.md +++ b/documentation-site/docs/docs/physics/rigid-bodies.md @@ -121,43 +121,6 @@ by the solver (its effective mass is always treated as infinite). Pass its collider's `mass`/`momentOfInertia` the same as for a dynamic body. ::: -## Collision filtering - -By default every collider is tested against every other. Give colliders a -`category` and a `mask` to say which pairs matter: two colliders are tested -only when each one's `category` shares a bit with the other's `mask`. A pair -either mask excludes never reaches the narrow phase, so filtering also saves -the work of testing pairs your game would ignore anyway. - -```ts -const PLAYER = 1 << 0; -const ENEMY = 1 << 1; -const PLAYER_BULLET = 1 << 2; -const WALL = 1 << 3; - -// Player bullets hit enemies and walls, never the player or each other. -addColliderComponent(world, bullet, { - collider: bulletCollider, - category: PLAYER_BULLET, - mask: ENEMY | WALL, -}); - -// Enemies collide with everything except other enemies. -addColliderComponent(world, enemy, { - collider: enemyCollider, - category: ENEMY, - mask: allCollisionCategories & ~ENEMY, -}); -``` - -`category` defaults to `1` and `mask` to `allCollisionCategories` (every -bit), so colliders that set neither collide with everything. Categories are -32 bits, as JavaScript's bitwise operators allow. The test is symmetric: -either collider can rule a pair out, and both have to accept it. - -The broad phase writes each collider's world-space bounds to its `aabb` -field every tick. It's output only; read it, but don't write it. - ## ECS integration There's no single "physics world" object to step - each concern is its own @@ -218,22 +181,9 @@ since its velocity is in world space; integration throws otherwise. Connect bodies with joints or springs instead. See [Transforms](../common/transforms.md). -## Mapping collisions back to entities - -Because everything is ECS-native, there's no separate body object or -`userData` mapping to bridge: `collisionManifolds` (populated by -`createNarrowPhaseEcsSystem`) already holds the raw `entityA`/`entityB` -entity ids for every confirmed collision each tick. - -```ts -for (const manifold of collisionManifolds) { - // check tags/components on manifold.entityA and manifold.entityB to - // award a pickup, apply damage, play a sound, etc. -} -``` +## Reacting to collisions -Read `collisionManifolds` after `createCollisionResolutionEcsSystem` has run -(later in the same tick, or at the start of the next one) if you need it to -reflect this tick's resolved contacts; the array is cleared and refilled by -`createNarrowPhaseEcsSystem` every tick, so hold onto anything you need -before that system runs again. +To find out what an entity touched, give it a `ContactsEcsComponent` and +read its `touching`, `started` and `ended` lists in your own system. See +[Collisions](./collisions.md), which also covers filtering which colliders +collide and sensor colliders for trigger zones. diff --git a/documentation-site/src/data/demos.ts b/documentation-site/src/data/demos.ts index 0ce751686..5b494a4ff 100644 --- a/documentation-site/src/data/demos.ts +++ b/documentation-site/src/data/demos.ts @@ -54,6 +54,13 @@ export const demos: Demo[] = [ 'A kinematic platform that carries and pushes dynamic crates without being affected by them.', categories: ['physics'], }, + { + slug: 'sensors', + title: 'Sensors and Contacts', + description: + 'Trigger zones that detect bodies passing through without blocking them.', + categories: ['physics'], + }, { slug: 'raycasting', title: 'Raycasting', diff --git a/documentation-site/src/pages/demos/brick-breaker/_ball.system.ts b/documentation-site/src/pages/demos/brick-breaker/_ball.system.ts index 17f73d1a5..4d292750f 100644 --- a/documentation-site/src/pages/demos/brick-breaker/_ball.system.ts +++ b/documentation-site/src/pages/demos/brick-breaker/_ball.system.ts @@ -5,7 +5,8 @@ import { } from '@forge-game-engine/forge/common'; import { Random, Vec2 } from '@forge-game-engine/forge/math'; import { - CollisionManifold, + ContactsEcsComponent, + contactsId, RigidBodyEcsComponent, rigidBodyId, } from '@forge-game-engine/forge/physics'; @@ -15,14 +16,13 @@ import { BrickField } from './_create-bricks'; /** * Creates an ECS system that destroys any brick the ball is touching this - * tick (via `collisionManifolds`, populated by `createNarrowPhaseEcsSystem`) + * tick (via the ball's `ContactsEcsComponent`, filled by + * `createNarrowPhaseEcsSystem`) * and resets the ball back to its start position - relaunching it - once it * falls below `missY`. * - * Must run after `createNarrowPhaseEcsSystem`, so this tick's collisions are + * Must run after `createNarrowPhaseEcsSystem`, so this tick's contacts are * available before this system checks them. - * @param collisionManifolds - The narrow-phase system's output: this tick's - * confirmed collisions. * @param random - The random source used to vary the relaunch angle. * @param missY - The world-space y coordinate below which the ball is * considered to have missed the paddle. @@ -30,25 +30,35 @@ import { BrickField } from './_create-bricks'; * ball is touching. */ export const createBallEcsSystem = ( - collisionManifolds: CollisionManifold[], random: Random, missY: number, brickField: BrickField, ): EcsSystem< - [BallEcsComponent, PositionEcsComponent, RigidBodyEcsComponent] + [ + BallEcsComponent, + PositionEcsComponent, + RigidBodyEcsComponent, + ContactsEcsComponent, + ] > => ({ - query: [ballId, positionId, rigidBodyId], + query: [ballId, positionId, rigidBodyId, contactsId], update: ( _world, - { entities, components: [ballComponents, positionComponents, rigidBodies] }, + { + entities, + components: [ballComponents, positionComponents, rigidBodies, contacts], + }, ) => { for (let i = 0; i < entities.length; i++) { - const ballEntity = entities[i]; const ballComponent = ballComponents[i]; const positionComponent = positionComponents[i]; const rigidBody = rigidBodies[i]; - destroyCollidedBricks(collisionManifolds, ballEntity, brickField); + for (const otherEntity of contacts[i].touching) { + if (brickField.has(otherEntity)) { + brickField.destroy(otherEntity); + } + } if (positionComponent.local.y < missY) { positionComponent.local = Vec2.clone(ballComponent.startPosition); @@ -57,21 +67,3 @@ export const createBallEcsSystem = ( } }, }); - -const destroyCollidedBricks = ( - collisionManifolds: CollisionManifold[], - ballEntity: number, - brickField: BrickField, -): void => { - for (const { entityA, entityB } of collisionManifolds) { - if (entityA !== ballEntity && entityB !== ballEntity) { - continue; - } - - const otherEntity = entityA === ballEntity ? entityB : entityA; - - if (brickField.has(otherEntity)) { - brickField.destroy(otherEntity); - } - } -}; diff --git a/documentation-site/src/pages/demos/brick-breaker/_create-ball.ts b/documentation-site/src/pages/demos/brick-breaker/_create-ball.ts index 29a156f9a..843de14cc 100644 --- a/documentation-site/src/pages/demos/brick-breaker/_create-ball.ts +++ b/documentation-site/src/pages/demos/brick-breaker/_create-ball.ts @@ -13,6 +13,7 @@ import { } from '@forge-game-engine/forge/math'; import { addColliderComponent, + addContactsComponent, addRigidBodyComponent, CircleCollider, RigidBodyEcsComponent, @@ -114,6 +115,8 @@ export async function createBall( restitution: 1, friction: 0, }); + // Lets the ball system see which bricks the ball hit this tick. + addContactsComponent(world, entity); const rigidBody = addRigidBodyComponent(world, entity, { mass: collider.mass, diff --git a/documentation-site/src/pages/demos/brick-breaker/_create-game.ts b/documentation-site/src/pages/demos/brick-breaker/_create-game.ts index 26a0afd09..005042971 100644 --- a/documentation-site/src/pages/demos/brick-breaker/_create-game.ts +++ b/documentation-site/src/pages/demos/brick-breaker/_create-game.ts @@ -168,9 +168,7 @@ export const createBrickBreakerGame = async (): Promise => { time, ), ); - world.addSystem( - createBallEcsSystem(collisionManifolds, random, missY, brickField), - ); + world.addSystem(createBallEcsSystem(random, missY, brickField)); world.addSystem(createEulerIntegrationEcsSystem(time)); return game; diff --git a/documentation-site/src/pages/demos/car/_create-car.ts b/documentation-site/src/pages/demos/car/_create-car.ts index 54dd744a6..a132772a3 100644 --- a/documentation-site/src/pages/demos/car/_create-car.ts +++ b/documentation-site/src/pages/demos/car/_create-car.ts @@ -8,6 +8,7 @@ import { degreesToRadians, Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { addAngularVelocityMotorComponent, addColliderComponent, + addContactsComponent, addGravityComponent, addLinearDamperComponent, addLinearSpringComponent, @@ -338,6 +339,8 @@ function createWheel( maxTorque: motorMaxTorque * maxTorqueMultiplier, }); + // Lets the ground-contact system see what this wheel is touching. + addContactsComponent(world, entity); const groundContact = addGroundContactComponent(world, entity); return { entity, groundContact }; diff --git a/documentation-site/src/pages/demos/car/_create-game.ts b/documentation-site/src/pages/demos/car/_create-game.ts index cbebfba8f..d33602ea1 100644 --- a/documentation-site/src/pages/demos/car/_create-game.ts +++ b/documentation-site/src/pages/demos/car/_create-game.ts @@ -110,8 +110,8 @@ export const createCarGame = async (): Promise => { // transform, so it runs first, followed by `createTransformEcsSystem`, // which turns every entity's `local` pose into the `world` pose that the // physics and render systems read. `createGroundContactEcsSystem` recomputes - // each wheel's grounded state from this tick's `collisionManifolds` - // (populated by narrow-phase, just before it), and + // each wheel's grounded state from the wheel's contacts (filled by + // narrow-phase, just before it), and // `createWheelDriveEcsSystem` (sets each wheel's motor target from // `throttleInput`, but only requests full speed while that wheel's own // ground contact says it's grounded) / `createChassisStabilizerEcsSystem` @@ -130,7 +130,7 @@ export const createCarGame = async (): Promise => { world.addSystem( createNarrowPhaseEcsSystem(collisionPairs, collisionManifolds), ); - world.addSystem(createGroundContactEcsSystem(collisionManifolds)); + world.addSystem(createGroundContactEcsSystem()); world.addSystem(createWheelDriveEcsSystem()); world.addSystem(createLinearSpringEcsSystem(time)); world.addSystem(createLinearDamperEcsSystem(time)); diff --git a/documentation-site/src/pages/demos/car/_ground-contact.component.ts b/documentation-site/src/pages/demos/car/_ground-contact.component.ts index 6800fc49a..4509b95ca 100644 --- a/documentation-site/src/pages/demos/car/_ground-contact.component.ts +++ b/documentation-site/src/pages/demos/car/_ground-contact.component.ts @@ -1,13 +1,10 @@ import { createComponentId, EcsWorld } from '@forge-game-engine/forge/ecs'; /** - * Tracks how many static (ground) contacts this entity currently has, - * recomputed every tick by `createGroundContactEcsSystem` from this tick's - * collision manifolds. A count rather than a boolean so it generalizes to - * any number of static bodies a wheel might simultaneously contact - and - * because a wheel wide enough to span several of the course's - * `TerrainCollider` surface edges gets one manifold per edge it touches, so - * even a single ground body can contribute more than one. + * Tracks how many static (ground) bodies this entity is currently touching, + * recomputed every tick by `createGroundContactEcsSystem` from the entity's + * `ContactsEcsComponent`. A count rather than a boolean so it generalizes to + * any number of static bodies a wheel might simultaneously touch. * * Attached directly to the wheel's own entity, so anything else on that * same entity (e.g. `WheelDriveEcsComponent`) can query for it jointly. diff --git a/documentation-site/src/pages/demos/car/_ground-contact.system.ts b/documentation-site/src/pages/demos/car/_ground-contact.system.ts index a8da66bdc..7eec3cf18 100644 --- a/documentation-site/src/pages/demos/car/_ground-contact.system.ts +++ b/documentation-site/src/pages/demos/car/_ground-contact.system.ts @@ -1,6 +1,7 @@ import { EcsSystem } from '@forge-game-engine/forge/ecs'; import { - CollisionManifold, + ContactsEcsComponent, + contactsId, rigidBodyId, } from '@forge-game-engine/forge/physics'; import { @@ -10,41 +11,22 @@ import { /** * Recomputes each matched entity's `GroundContactEcsComponent.groundContacts` - * from this tick's `collisionManifolds`, counting how many of the entity's - * current contacts are against a static (no `RigidBodyEcsComponent`) body. - * Must run after whatever system populates `collisionManifolds` - * (`createNarrowPhaseEcsSystem`), and before any system that reads a - * `GroundContactEcsComponent` this same tick (`createWheelDriveEcsSystem`, - * `createChassisStabilizerEcsSystem`, `createAirControlEcsSystem`). - * @param collisionManifolds - The narrow-phase system's output: this tick's - * confirmed collisions. + * from its `ContactsEcsComponent`, counting how many of the entities it's + * touching are static (no `RigidBodyEcsComponent`) bodies. Must run after + * `createNarrowPhaseEcsSystem`, which fills the contacts, and before any + * system that reads a `GroundContactEcsComponent` this same tick + * (`createWheelDriveEcsSystem`, `createChassisStabilizerEcsSystem`, + * `createAirControlEcsSystem`). */ -export const createGroundContactEcsSystem = ( - collisionManifolds: CollisionManifold[], -): EcsSystem<[GroundContactEcsComponent]> => ({ - query: [groundContactId], - update: (world, { entities, components: [groundContacts] }) => { +export const createGroundContactEcsSystem = (): EcsSystem< + [GroundContactEcsComponent, ContactsEcsComponent] +> => ({ + query: [groundContactId, contactsId], + update: (world, { entities, components: [groundContacts, contacts] }) => { for (let i = 0; i < entities.length; i++) { - const entity = entities[i]; - let count = 0; - - for (const manifold of collisionManifolds) { - let other: number; - - if (manifold.entityA === entity) { - other = manifold.entityB; - } else if (manifold.entityB === entity) { - other = manifold.entityA; - } else { - continue; - } - - if (world.getComponent(other, rigidBodyId) === null) { - count++; - } - } - - groundContacts[i].groundContacts = count; + groundContacts[i].groundContacts = contacts[i].touching.filter( + (other) => world.getComponent(other, rigidBodyId) === null, + ).length; } }, }); diff --git a/documentation-site/src/pages/demos/car/index.tsx b/documentation-site/src/pages/demos/car/index.tsx index 8779d9174..d48a0e921 100644 --- a/documentation-site/src/pages/demos/car/index.tsx +++ b/documentation-site/src/pages/demos/car/index.tsx @@ -30,7 +30,7 @@ export default function Car(): JSX.Element { 'A demo showing how to set up a car with the physics engine: a chassis with two spring-suspended, motor-driven wheels climbing a procedurally generated hill.', }} header="Car" - blurb="This demo composes several of the physics engine's existing primitives into a drivable car: the chassis, both wheels, and a small invisible 'upright' body per wheel (a wheel hub/knuckle) are all independent entities with their own RigidBodyEcsComponent, and each wheel is driven by an AngularVelocityMotorEcsComponent (see the Torque and Motors demo). A demo-only GroundContactEcsComponent/system pair, attached to each wheel's own entity, tracks how many static bodies that wheel is touching, recomputed every tick from that tick's collision manifolds. While a wheel is grounded, its motor's target speed tracks the throttle input directly, and the engine's own Coulomb friction model - not an artificial clamp - is what correctly limits how much of that becomes real acceleration versus slip, the same way it would for a real tire. Only while a wheel is airborne does WheelDriveEcsSystem clamp its target to a bounded slip band around its current rolling speed (derived from the chassis's own velocity every tick): airborne, a wheel has nothing but its own rotational inertia to resist the motor, so left unclamped it would accelerate towards an arbitrary top speed almost instantly and land spinning far faster than the car is actually moving, burning torque on wheel spin instead of quickly regaining grip. Each wheel mounts to the chassis through its upright: a PrismaticJoint (see the Prismatic Joint demo) constrains the upright to slide only vertically relative to the chassis, a RevoluteJoint (see the Revolute Joint demo) pins the wheel's position to that upright while leaving its rotation completely free to spin, and a LinearSpring/LinearDamper pair (see the Linear Spring and Damper demo) along that same axis supplies the suspension's force. Wiring either joint straight to the wheel wouldn't work - a RevoluteJoint would pin it rigidly in place with no suspension travel, and a PrismaticJoint locks relative rotation, which would lock the wheel's spin to the chassis - so the upright exists specifically to give the wheel a rotation-free attachment point. Unlike a spring-only mount, both joints are hard, iteratively-solved constraints with no lateral give, so the wheel only ever travels along that one axis, no swinging or sideways slop. Because the mount's *force* still comes from a soft spring rather than a rigid frame, accelerating and braking visibly pitches the chassis backward and forward, the 'leaning' feel the genre is named for; a light demo-only ChassisStabilizerEcsComponent nudges the chassis back level once nothing else is actively tipping it, without fighting a deliberate acceleration or brake lean. Each wheel mount's PrismaticJoint axis is also tilted outward rather than straight up/down, so the line from each chassis anchor to its wheel splays into a trapezoid instead of a rectangle, like a monster truck's lifted suspension - besides the visual, this gives an impact that's perpendicular to the chassis (hitting a ledge face-on) a component along the suspension axis for the spring to absorb, rather than landing entirely on the joint's hard, unsprung constraint. The terrain is a single static TerrainCollider following a procedurally generated height profile (see the Terrain guide), and a demo-only camera-follow system (the engine's built-in camera only supports input-driven pan/zoom) keeps the car in view as it drives across a course much wider than the canvas. ChassisStabilizerEcsComponent and AirControlEcsComponent, which both live on the chassis rather than either wheel, hold direct references to both wheels' GroundContactEcsComponent objects to check grounded state - ChassisStabilizerEcsSystem stays out of the way entirely while both wheels are airborne, and in that same situation AirControlEcsSystem instead drives the chassis's angular velocity towards a target proportional to the throttle input (the same targetVelocity/maxTorque approach AngularVelocityMotorEcsComponent uses for the wheels) - gas targets a nose-up-and-back spin, brake targets nose-down-and-forward, and releasing the input targets zero rotation, actively cancelling existing spin rather than just coasting on momentum, the classic mid-air control for lining up a landing. Letting go of the throttle on the ground also drops the driven wheel's motor torque budget to zero rather than braking it to a stop, so the car coasts and freewheels downhill under gravity instead of being held by an invisible parking brake. The hills get steep further along the course - if the car flips or gets stuck, press R to restart." + blurb="This demo composes several of the physics engine's existing primitives into a drivable car: the chassis, both wheels, and a small invisible 'upright' body per wheel (a wheel hub/knuckle) are all independent entities with their own RigidBodyEcsComponent, and each wheel is driven by an AngularVelocityMotorEcsComponent (see the Torque and Motors demo). A demo-only GroundContactEcsComponent/system pair, attached to each wheel's own entity, tracks how many static bodies that wheel is touching, recomputed every tick from the wheel's ContactsEcsComponent. While a wheel is grounded, its motor's target speed tracks the throttle input directly, and the engine's own Coulomb friction model - not an artificial clamp - is what correctly limits how much of that becomes real acceleration versus slip, the same way it would for a real tire. Only while a wheel is airborne does WheelDriveEcsSystem clamp its target to a bounded slip band around its current rolling speed (derived from the chassis's own velocity every tick): airborne, a wheel has nothing but its own rotational inertia to resist the motor, so left unclamped it would accelerate towards an arbitrary top speed almost instantly and land spinning far faster than the car is actually moving, burning torque on wheel spin instead of quickly regaining grip. Each wheel mounts to the chassis through its upright: a PrismaticJoint (see the Prismatic Joint demo) constrains the upright to slide only vertically relative to the chassis, a RevoluteJoint (see the Revolute Joint demo) pins the wheel's position to that upright while leaving its rotation completely free to spin, and a LinearSpring/LinearDamper pair (see the Linear Spring and Damper demo) along that same axis supplies the suspension's force. Wiring either joint straight to the wheel wouldn't work - a RevoluteJoint would pin it rigidly in place with no suspension travel, and a PrismaticJoint locks relative rotation, which would lock the wheel's spin to the chassis - so the upright exists specifically to give the wheel a rotation-free attachment point. Unlike a spring-only mount, both joints are hard, iteratively-solved constraints with no lateral give, so the wheel only ever travels along that one axis, no swinging or sideways slop. Because the mount's *force* still comes from a soft spring rather than a rigid frame, accelerating and braking visibly pitches the chassis backward and forward, the 'leaning' feel the genre is named for; a light demo-only ChassisStabilizerEcsComponent nudges the chassis back level once nothing else is actively tipping it, without fighting a deliberate acceleration or brake lean. Each wheel mount's PrismaticJoint axis is also tilted outward rather than straight up/down, so the line from each chassis anchor to its wheel splays into a trapezoid instead of a rectangle, like a monster truck's lifted suspension - besides the visual, this gives an impact that's perpendicular to the chassis (hitting a ledge face-on) a component along the suspension axis for the spring to absorb, rather than landing entirely on the joint's hard, unsprung constraint. The terrain is a single static TerrainCollider following a procedurally generated height profile (see the Terrain guide), and a demo-only camera-follow system (the engine's built-in camera only supports input-driven pan/zoom) keeps the car in view as it drives across a course much wider than the canvas. ChassisStabilizerEcsComponent and AirControlEcsComponent, which both live on the chassis rather than either wheel, hold direct references to both wheels' GroundContactEcsComponent objects to check grounded state - ChassisStabilizerEcsSystem stays out of the way entirely while both wheels are airborne, and in that same situation AirControlEcsSystem instead drives the chassis's angular velocity towards a target proportional to the throttle input (the same targetVelocity/maxTorque approach AngularVelocityMotorEcsComponent uses for the wheels) - gas targets a nose-up-and-back spin, brake targets nose-down-and-forward, and releasing the input targets zero rotation, actively cancelling existing spin rather than just coasting on momentum, the classic mid-air control for lining up a landing. Letting go of the throttle on the ground also drops the driven wheel's motor torque budget to zero rather than braking it to a stop, so the car coasts and freewheels downhill under gravity instead of being held by an invisible parking brake. The hills get steep further along the course - if the car flips or gets stuck, press R to restart." createGame={createCarGame} interactions={ <> diff --git a/documentation-site/src/pages/demos/rolling-ball/_create-game.ts b/documentation-site/src/pages/demos/rolling-ball/_create-game.ts index 0890f45dd..c5716fc87 100644 --- a/documentation-site/src/pages/demos/rolling-ball/_create-game.ts +++ b/documentation-site/src/pages/demos/rolling-ball/_create-game.ts @@ -128,7 +128,6 @@ export const createRollingBallGame = async (): Promise => { ); world.addSystem( createJumpEcsSystem( - collisionManifolds, player.entity, terrain.entity, jumpInput, diff --git a/documentation-site/src/pages/demos/rolling-ball/_create-player.ts b/documentation-site/src/pages/demos/rolling-ball/_create-player.ts index b01818baf..c85b86d95 100644 --- a/documentation-site/src/pages/demos/rolling-ball/_create-player.ts +++ b/documentation-site/src/pages/demos/rolling-ball/_create-player.ts @@ -7,6 +7,7 @@ import { Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { addAngularVelocityMotorComponent, addColliderComponent, + addContactsComponent, addGravityComponent, addRigidBodyComponent, CircleCollider, @@ -80,6 +81,8 @@ export async function createPlayer( friction: 0.9, restitution: 0.15, }); + // Lets the jump system check whether the ball is touching the terrain. + addContactsComponent(world, entity); addRigidBodyComponent(world, entity, { mass: collider.mass, momentOfInertia: collider.momentOfInertia, diff --git a/documentation-site/src/pages/demos/rolling-ball/_jump.system.ts b/documentation-site/src/pages/demos/rolling-ball/_jump.system.ts index c25ea5a29..5485213fa 100644 --- a/documentation-site/src/pages/demos/rolling-ball/_jump.system.ts +++ b/documentation-site/src/pages/demos/rolling-ball/_jump.system.ts @@ -4,7 +4,7 @@ import { Vec2, Vector2 } from '@forge-game-engine/forge/math'; import { TriggerAction } from '@forge-game-engine/forge/input'; import { applyImpulse, - CollisionManifold, + contactsId, rigidBodyId, } from '@forge-game-engine/forge/physics'; @@ -19,38 +19,24 @@ const jumpImpulse = 500_000; */ const respawnFallDistance = 2000; -function involvesBoth( - manifold: CollisionManifold, - entityA: number, - entityB: number, -): boolean { - return ( - (manifold.entityA === entityA && manifold.entityB === entityB) || - (manifold.entityA === entityB && manifold.entityB === entityA) - ); -} - /** * Creates an ECS system that tracks whether the ball is currently touching - * the terrain (via `collisionManifolds`, which `createNarrowPhaseEcsSystem` - * populates every tick), applies an upward impulse when `jumpInput` triggers + * the terrain (via the ball's `ContactsEcsComponent`, which + * `createNarrowPhaseEcsSystem` fills every tick), applies an upward impulse when `jumpInput` triggers * while grounded, and resets the ball back to `spawnPosition` if it ever * falls `respawnFallDistance` below it (for example off the end of the * terrain). * - * Must run after `createNarrowPhaseEcsSystem`, so this tick's collisions are + * Must run after `createNarrowPhaseEcsSystem`, so this tick's contacts are * available before this system checks them, and before * `createEulerIntegrationEcsSystem`, so a jump/respawn applied this tick is * reflected in this same tick's integration. - * @param collisionManifolds - The narrow-phase system's output: this tick's - * confirmed collisions. * @param playerEntity - The ball's entity id. * @param terrainEntity - The terrain's entity id. * @param jumpInput - The jump trigger action. * @param spawnPosition - The world-space position to reset the ball to if it falls too far. */ export const createJumpEcsSystem = ( - collisionManifolds: CollisionManifold[], playerEntity: number, terrainEntity: number, jumpInput: TriggerAction, @@ -60,14 +46,13 @@ export const createJumpEcsSystem = ( update: (world) => { const position = world.getComponent(playerEntity, positionId); const rigidBody = world.getComponent(playerEntity, rigidBodyId); + const contacts = world.getComponent(playerEntity, contactsId); - if (position === null || rigidBody === null) { + if (position === null || rigidBody === null || contacts === null) { return; } - const isGrounded = collisionManifolds.some((manifold) => - involvesBoth(manifold, playerEntity, terrainEntity), - ); + const isGrounded = contacts.touching.includes(terrainEntity); if (jumpInput.isTriggered && isGrounded) { applyImpulse( diff --git a/documentation-site/src/pages/demos/rolling-ball/index.tsx b/documentation-site/src/pages/demos/rolling-ball/index.tsx index 81c6fdfe3..343dd50cf 100644 --- a/documentation-site/src/pages/demos/rolling-ball/index.tsx +++ b/documentation-site/src/pages/demos/rolling-ball/index.tsx @@ -21,7 +21,7 @@ export default function RollingBall(): JSX.Element { 'A demo showcasing TerrainCollider with a smooth, textured, curve-generated course and a player-controlled ball that rolls via friction from an AngularVelocityMotorEcsComponent.', }} header="Rolling Ball" - blurb="A standalone showcase of TerrainCollider: a long course whose smooth silhouette comes from a Catmull-Rom curve through sparse, randomly-placed control points, triangulated into a single mesh and textured with a tileable grass-like border blending into a tileable dirt-like fill. TerrainCollider's own collision points are sampled from that exact same curve, so what's drawn always matches what the ball touches. Roll input drives the ball's AngularVelocityMotorEcsComponent, and friction against the terrain - ordinary collision resolution, nothing special-cased - turns that spin into rolling motion up and down the hills. A small camera-follow system keeps the ball in view as it travels, and a jump impulse fires while grounded (tracked via this tick's collisionManifolds against the terrain entity)." + blurb="A standalone showcase of TerrainCollider: a long course whose smooth silhouette comes from a Catmull-Rom curve through sparse, randomly-placed control points, triangulated into a single mesh and textured with a tileable grass-like border blending into a tileable dirt-like fill. TerrainCollider's own collision points are sampled from that exact same curve, so what's drawn always matches what the ball touches. Roll input drives the ball's AngularVelocityMotorEcsComponent, and friction against the terrain - ordinary collision resolution, nothing special-cased - turns that spin into rolling motion up and down the hills. A small camera-follow system keeps the ball in view as it travels, and a jump impulse fires while grounded (the ball's ContactsEcsComponent lists the terrain entity)." createGame={createRollingBallGame} interactions={ <> diff --git a/documentation-site/src/pages/demos/sensors/_ball-spawner.system.ts b/documentation-site/src/pages/demos/sensors/_ball-spawner.system.ts new file mode 100644 index 000000000..b440b8f86 --- /dev/null +++ b/documentation-site/src/pages/demos/sensors/_ball-spawner.system.ts @@ -0,0 +1,85 @@ +import { + addPositionComponent, + addRotationComponent, + Time, +} from '@forge-game-engine/forge/common'; +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { Random } from '@forge-game-engine/forge/math'; +import { + addColliderComponent, + addGravityComponent, + addRigidBodyComponent, + CircleCollider, +} from '@forge-game-engine/forge/physics'; +import { + addSpriteComponent, + Color, + SpriteEcsComponent, +} from '@forge-game-engine/forge/rendering'; +import { ballCategory } from './_create-scene'; + +const spawnInterval = 0.2; +const minRadius = 8; +const maxRadius = 16; + +/** + * Creates an ECS system that drops a ball from just above the top of the + * scene every `spawnInterval` seconds. Balls are ordinary dynamic bodies: + * they collide with the walls, the ramps and each other, and pass through + * the sensor zones. + * @param time - The game's time, for spawn timing. + * @param ballSprite - The sprite every ball is drawn with. + * @param spawnY - The world y the balls are dropped from. + * @param spawnHalfWidth - Balls spawn at a random x within this distance + * of the center. + */ +export const createBallSpawnerEcsSystem = ( + time: Time, + ballSprite: SpriteEcsComponent, + spawnY: number, + spawnHalfWidth: number, +): EcsSystem<[]> => { + const random = new Random('sensors'); + let sinceLastSpawn = 0; + + return { + query: [], + update: (world) => { + sinceLastSpawn += time.deltaTimeInSeconds; + + if (sinceLastSpawn < spawnInterval) { + return; + } + + sinceLastSpawn = 0; + + const radius = random.randomFloat(minRadius, maxRadius); + const collider = new CircleCollider(radius); + const entity = world.createEntity(); + + addPositionComponent(world, entity, { + local: { + x: random.randomFloat(-spawnHalfWidth, spawnHalfWidth), + y: spawnY, + }, + }); + addRotationComponent(world, entity); + addSpriteComponent(world, entity, { + ...ballSprite, + width: radius * 2, + height: radius * 2, + tintColor: Color.white, + }); + addColliderComponent(world, entity, { + collider, + restitution: 0.5, + category: ballCategory, + }); + addRigidBodyComponent(world, entity, { + mass: collider.mass, + momentOfInertia: collider.momentOfInertia, + }); + addGravityComponent(world, entity, { amount: { x: 0, y: -600 } }); + }, + }; +}; diff --git a/documentation-site/src/pages/demos/sensors/_create-game.ts b/documentation-site/src/pages/demos/sensors/_create-game.ts new file mode 100644 index 000000000..29ef74376 --- /dev/null +++ b/documentation-site/src/pages/demos/sensors/_create-game.ts @@ -0,0 +1,86 @@ +import { createTransformEcsSystem } from '@forge-game-engine/forge/common'; +import { + CollisionManifold, + CollisionPair, + ContactConstraint, + createBroadPhaseEcsSystem, + createCollisionResolutionEcsSystem, + createEulerIntegrationEcsSystem, + createGravityEcsSystem, + createNarrowPhaseEcsSystem, +} from '@forge-game-engine/forge/physics'; +import { + calculateVisibleWorldSize, + createCamera, + createCameraEcsSystem, + createImageSprite, + createRenderEcsSystem, +} from '@forge-game-engine/forge/rendering'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; +import { createBallSpawnerEcsSystem } from './_ball-spawner.system'; +import { createScene } from './_create-scene'; +import { createDrainEcsSystem } from './_drain.system'; +import { createTriggerZoneEcsSystem } from './_trigger-zone.system'; + +const renderLayers = { + foreground: 1 << 0, +}; + +export const createSensorsGame = async (): Promise => { + const { game, world, renderContext, time } = createGame('demo-game'); + + createCamera(world, { + isStatic: true, + cullingMask: renderLayers.foreground, + verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, + }); + + await createScene(world, renderContext, renderLayers.foreground); + + const ballImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White_Circle.png'), + ); + const ballSprite = createImageSprite(ballImage, renderContext, { + pixelsPerUnit: 1, + layer: renderLayers.foreground, + }); + const { x: width, y: height } = calculateVisibleWorldSize( + renderContext.width, + renderContext.height, + DEMO_VERTICAL_WORLD_UNITS, + ); + + const collisionPairs: CollisionPair[] = []; + const collisionManifolds: CollisionManifold[] = []; + const contactConstraints: ContactConstraint[] = []; + + // The narrow phase fills every `ContactsEcsComponent` and writes only + // solid (non-sensor) collisions to `collisionManifolds`, so resolution + // never pushes a ball out of a sensor zone. The zone and drain systems + // read the contacts after it, in the same tick. + world.addSystem( + createBallSpawnerEcsSystem(time, ballSprite, height / 2 + 40, width * 0.4), + ); + world.addSystem(createTransformEcsSystem()); + world.addSystem(createGravityEcsSystem(time)); + world.addSystem(createBroadPhaseEcsSystem(collisionPairs)); + world.addSystem( + createNarrowPhaseEcsSystem(collisionPairs, collisionManifolds), + ); + world.addSystem(createTriggerZoneEcsSystem()); + world.addSystem(createDrainEcsSystem()); + world.addSystem( + createCollisionResolutionEcsSystem( + collisionManifolds, + contactConstraints, + time, + ), + ); + world.addSystem(createCameraEcsSystem(time)); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createEulerIntegrationEcsSystem(time)); + + return game; +}; diff --git a/documentation-site/src/pages/demos/sensors/_create-scene.ts b/documentation-site/src/pages/demos/sensors/_create-scene.ts new file mode 100644 index 000000000..ea2edeef7 --- /dev/null +++ b/documentation-site/src/pages/demos/sensors/_create-scene.ts @@ -0,0 +1,171 @@ +import { + addPositionComponent, + addRotationComponent, +} from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { Vector2 } from '@forge-game-engine/forge/math'; +import { + addColliderComponent, + addContactsComponent, + ColliderDefaultedOptions, + PolygonCollider, +} from '@forge-game-engine/forge/physics'; +import { + addSpriteComponent, + calculateVisibleWorldSize, + Color, + createImageSprite, + RenderContext, + SpriteEcsComponent, +} from '@forge-game-engine/forge/rendering'; +import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; +import { drainId } from './_drain.system'; +import { addTriggerZoneComponent } from './_trigger-zone.component'; + +/** + * Collision categories. Walls and ramps keep the default category and mask, + * so they collide with everything. The zones and the drain only accept + * balls, so the zone system never sees the walls the zones touch. + */ +export const ballCategory = 1 << 1; +const sensorCategory = 1 << 2; + +const sensorOptions: Partial = { + sensor: true, + category: sensorCategory, + mask: ballCategory, +}; + +const wallThickness = 40; +const zoneHeight = 70; + +function rectangleVertices(width: number, height: number): Vector2[] { + const halfWidth = width / 2; + const halfHeight = height / 2; + + return [ + { x: -halfWidth, y: -halfHeight }, + { x: halfWidth, y: -halfHeight }, + { x: halfWidth, y: halfHeight }, + { x: -halfWidth, y: halfHeight }, + ]; +} + +/** + * Creates the scene's static colliders: solid side walls and ramps that + * balls bounce off, two sensor trigger zones that balls fall straight + * through, and a sensor drain below the bottom edge that removes them. + * @param world - The ECS world to add the entities to. + * @param renderContext - The render context used to load the sprites. + * @param renderLayer - The render layer to draw the scene on. + */ +export async function createScene( + world: EcsWorld, + renderContext: RenderContext, + renderLayer: number, +): Promise { + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + const whiteSprite = createImageSprite(whiteImage, renderContext, { + pixelsPerUnit: 1, + layer: renderLayer, + }); + + const { x: width, y: height } = calculateVisibleWorldSize( + renderContext.width, + renderContext.height, + DEMO_VERTICAL_WORLD_UNITS, + ); + const halfWidth = width / 2; + const halfHeight = height / 2; + + const addBox = ( + position: Vector2, + size: Vector2, + rotation: number, + sprite: Partial | null, + colliderOptions: Partial = {}, + ): number => { + const entity = world.createEntity(); + + addPositionComponent(world, entity, { local: position }); + addRotationComponent(world, entity, { local: rotation }); + addColliderComponent(world, entity, { + collider: new PolygonCollider(rectangleVertices(size.x, size.y)), + ...colliderOptions, + }); + + if (sprite) { + addSpriteComponent(world, entity, { + ...whiteSprite, + ...sprite, + width: size.x, + height: size.y, + }); + } + + return entity; + }; + + const solid = { tintColor: new Color(0.55, 0.6, 0.7) }; + + // Solid side walls and two ramps: balls collide with and bounce off these. + addBox( + { x: -halfWidth + wallThickness / 2, y: 0 }, + { x: wallThickness, y: height }, + 0, + solid, + ); + addBox( + { x: halfWidth - wallThickness / 2, y: 0 }, + { x: wallThickness, y: height }, + 0, + solid, + ); + addBox( + { x: -halfWidth * 0.35, y: halfHeight * 0.35 }, + { x: width * 0.45, y: 16 }, + -0.3, + solid, + ); + addBox( + { x: halfWidth * 0.35, y: -halfHeight * 0.05 }, + { x: width * 0.45, y: 16 }, + 0.3, + solid, + ); + + // Two sensor trigger zones: detected, never resolved, so balls fall + // straight through while the zone system tints them. + const zones = [ + { y: -halfHeight * 0.35, color: new Color(1, 0.75, 0.2) }, + { y: -halfHeight * 0.7, color: new Color(0.3, 0.9, 0.5) }, + ]; + + for (const zone of zones) { + const entity = addBox( + { x: 0, y: zone.y }, + { x: width - wallThickness * 2, y: zoneHeight }, + 0, + { tintColor: zone.color }, + sensorOptions, + ); + + addTriggerZoneComponent(world, entity, { color: zone.color }); + addContactsComponent(world, entity); + } + + // A sensor drain just below the bottom edge removes every ball it touches. + const drain = addBox( + { x: 0, y: -halfHeight - 60 }, + { x: width * 2, y: 40 }, + 0, + null, + sensorOptions, + ); + + world.addTag(drain, drainId); + addContactsComponent(world, drain); +} diff --git a/documentation-site/src/pages/demos/sensors/_drain.system.ts b/documentation-site/src/pages/demos/sensors/_drain.system.ts new file mode 100644 index 000000000..5a86dc008 --- /dev/null +++ b/documentation-site/src/pages/demos/sensors/_drain.system.ts @@ -0,0 +1,25 @@ +import { createTagId, EcsSystem } from '@forge-game-engine/forge/ecs'; +import { + ContactsEcsComponent, + contactsId, +} from '@forge-game-engine/forge/physics'; + +/** Marks the sensor at the bottom of the scene that removes balls. */ +export const drainId = createTagId('drain'); + +/** + * Creates an ECS system that removes every entity the drain sensor started + * touching this tick. Must run after `createNarrowPhaseEcsSystem`, which + * fills the drain's contacts. + */ +export const createDrainEcsSystem = (): EcsSystem<[ContactsEcsComponent]> => ({ + query: [contactsId], + tags: [drainId], + update: (world, { components: [contacts] }) => { + for (const drainContacts of contacts) { + for (const ball of drainContacts.started) { + world.removeEntity(ball); + } + } + }, +}); diff --git a/documentation-site/src/pages/demos/sensors/_trigger-zone.component.ts b/documentation-site/src/pages/demos/sensors/_trigger-zone.component.ts new file mode 100644 index 000000000..564fe86bd --- /dev/null +++ b/documentation-site/src/pages/demos/sensors/_trigger-zone.component.ts @@ -0,0 +1,28 @@ +import { createComponentId, EcsWorld } from '@forge-game-engine/forge/ecs'; +import { Color } from '@forge-game-engine/forge/rendering'; + +/** + * A sensor zone that tints every ball inside it. + */ +export interface TriggerZoneEcsComponent { + /** The tint a ball takes while it's inside this zone. */ + color: Color; +} + +export const triggerZoneId = + createComponentId('triggerZone'); + +/** + * Attaches a {@link TriggerZoneEcsComponent} to `entity`. + * @param world - The ECS world `entity` belongs to. + * @param entity - The entity to attach the component to. + * @param options - The zone's tint. + * @returns The attached component. + */ +export function addTriggerZoneComponent( + world: EcsWorld, + entity: number, + options: TriggerZoneEcsComponent, +): TriggerZoneEcsComponent { + return world.addComponent(entity, triggerZoneId, { ...options }); +} diff --git a/documentation-site/src/pages/demos/sensors/_trigger-zone.system.ts b/documentation-site/src/pages/demos/sensors/_trigger-zone.system.ts new file mode 100644 index 000000000..a3a115703 --- /dev/null +++ b/documentation-site/src/pages/demos/sensors/_trigger-zone.system.ts @@ -0,0 +1,65 @@ +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { + ContactsEcsComponent, + contactsId, +} from '@forge-game-engine/forge/physics'; +import { + Color, + SpriteEcsComponent, + spriteId, +} from '@forge-game-engine/forge/rendering'; +import { + TriggerZoneEcsComponent, + triggerZoneId, +} from './_trigger-zone.component'; + +const emptyZoneOpacity = 0.25; +const occupiedZoneOpacity = 0.45; + +/** + * Creates an ECS system that reads each trigger zone's contacts: a ball + * that entered the zone this tick (`started`) takes the zone's color, and a + * ball that left it (`ended`) goes back to white. The zone itself brightens + * while anything is inside (`touching`). Must run after + * `createNarrowPhaseEcsSystem`, which fills the contacts. + */ +export const createTriggerZoneEcsSystem = (): EcsSystem< + [TriggerZoneEcsComponent, ContactsEcsComponent, SpriteEcsComponent] +> => ({ + query: [triggerZoneId, contactsId, spriteId], + update: (world, { entities, components: [zones, contacts, sprites] }) => { + for (let i = 0; i < entities.length; i++) { + const { color } = zones[i]; + const { touching, started, ended } = contacts[i]; + + for (const ball of started) { + const sprite = world.getComponent(ball, spriteId); + + if (sprite) { + sprite.tintColor = color; + } + } + + for (const ball of ended) { + // A ball that fell into the drain was removed, which also ends its + // contact with any zone it was still in. + if (!world.isAlive(ball)) { + continue; + } + + const sprite = world.getComponent(ball, spriteId); + + if (sprite) { + sprite.tintColor = Color.white; + } + } + + sprites[i].tintColor = new Color( + color.r, + color.g, + color.b, + touching.length > 0 ? occupiedZoneOpacity : emptyZoneOpacity, + ); + } + }, +}); diff --git a/documentation-site/src/pages/demos/sensors/index.tsx b/documentation-site/src/pages/demos/sensors/index.tsx new file mode 100644 index 000000000..537b58705 --- /dev/null +++ b/documentation-site/src/pages/demos/sensors/index.tsx @@ -0,0 +1,36 @@ +import React, { JSX } from 'react'; +import { createSensorsGame } from './_create-game'; +import gameCode from '!!raw-loader!./_create-game'; +import createSceneCode from '!!raw-loader!./_create-scene'; +import ballSpawnerSystemCode from '!!raw-loader!./_ball-spawner.system'; +import triggerZoneComponentCode from '!!raw-loader!./_trigger-zone.component'; +import triggerZoneSystemCode from '!!raw-loader!./_trigger-zone.system'; +import drainSystemCode from '!!raw-loader!./_drain.system'; + +import { Demo } from '@site/src/components/Demo'; + +export default function Sensors(): JSX.Element { + return ( + + ); +} diff --git a/documentation-site/src/pages/demos/space-shooter/_asteroid-spawner.system.ts b/documentation-site/src/pages/demos/space-shooter/_asteroid-spawner.system.ts index afca85ff1..fd1fc56d5 100644 --- a/documentation-site/src/pages/demos/space-shooter/_asteroid-spawner.system.ts +++ b/documentation-site/src/pages/demos/space-shooter/_asteroid-spawner.system.ts @@ -9,12 +9,18 @@ import { Random } from '@forge-game-engine/forge/math'; import { addSpriteComponent } from '@forge-game-engine/forge/rendering'; import { addColliderComponent, + addContactsComponent, CircleCollider, } from '@forge-game-engine/forge/physics'; import { AsteroidSpawnerEcsComponent, asteroidSpawnerId, } from './_asteroid-spawner.component'; +import { + asteroidCategory, + bulletCategory, + playerCategory, +} from './_collision-categories'; import { asteroidId } from './_asteroid.component'; const asteroidScale = 0.1; @@ -71,7 +77,11 @@ export const createAsteroidSpawnerEcsSystem = ( addColliderComponent(world, asteroidEntity, { collider: new CircleCollider(asteroidRadius), + category: asteroidCategory, + mask: bulletCategory | playerCategory, }); + // Asteroids are the only entities that check what they touch. + addContactsComponent(world, asteroidEntity); } }, }); diff --git a/documentation-site/src/pages/demos/space-shooter/_collision-categories.ts b/documentation-site/src/pages/demos/space-shooter/_collision-categories.ts new file mode 100644 index 000000000..262b6c20f --- /dev/null +++ b/documentation-site/src/pages/demos/space-shooter/_collision-categories.ts @@ -0,0 +1,8 @@ +/** + * Collision categories for the space shooter. Asteroids only collide with + * bullets and the player, so the broad phase never tests asteroids against + * each other, or bullets against bullets or the player. + */ +export const asteroidCategory = 1 << 0; +export const bulletCategory = 1 << 1; +export const playerCategory = 1 << 2; diff --git a/documentation-site/src/pages/demos/space-shooter/_collision.system.ts b/documentation-site/src/pages/demos/space-shooter/_collision.system.ts index f2db26d24..ff668f026 100644 --- a/documentation-site/src/pages/demos/space-shooter/_collision.system.ts +++ b/documentation-site/src/pages/demos/space-shooter/_collision.system.ts @@ -4,20 +4,21 @@ import { Time, } from '@forge-game-engine/forge/common'; import { EcsSystem } from '@forge-game-engine/forge/ecs'; -import { CollisionManifold } from '@forge-game-engine/forge/physics'; +import { + ContactsEcsComponent, + contactsId, +} from '@forge-game-engine/forge/physics'; import { AsteroidEcsComponent, asteroidId } from './_asteroid.component'; import { bulletId } from './_bullet.component'; import { ExplosionSpawner } from './_create-explosions'; import { PlayerId } from './_player.component'; /** - * Creates an ECS system that scans `collisionManifolds` (populated by - * `createNarrowPhaseEcsSystem`) each tick for any asteroid touching a - * bullet or the player, spawning an explosion and removing the involved - * entities. Must run after `createNarrowPhaseEcsSystem`, so this tick's - * collisions are available before this system checks them. - * @param collisionManifolds - The narrow-phase system's output: this tick's - * confirmed collisions. + * Creates an ECS system that checks what each asteroid is touching this + * tick (its `ContactsEcsComponent`, filled by `createNarrowPhaseEcsSystem`) + * for a bullet or the player, spawning an explosion and removing the + * involved entities. Must run after `createNarrowPhaseEcsSystem`, so this + * tick's contacts are available before this system checks them. * @param time - The time instance used to seed the spawned explosion's * animation start time. * @param explosionSpawner - Spawns an explosion effect at a world position. @@ -25,24 +26,27 @@ import { PlayerId } from './_player.component'; * asteroid. */ export const createAsteroidCollisionEcsSystem = ( - collisionManifolds: CollisionManifold[], time: Time, explosionSpawner: ExplosionSpawner, onPlayerDeath: () => void, -): EcsSystem<[AsteroidEcsComponent, PositionEcsComponent]> => ({ - query: [asteroidId, positionId], - update: (world, { entities, components: [, positionComponents] }) => { +): EcsSystem< + [AsteroidEcsComponent, PositionEcsComponent, ContactsEcsComponent] +> => ({ + query: [asteroidId, positionId, contactsId], + update: ( + world, + { entities, components: [, positionComponents, contactsComponents] }, + ) => { for (let i = 0; i < entities.length; i++) { const asteroidEntity = entities[i]; const positionComponent = positionComponents[i]; - for (const { entityA, entityB } of collisionManifolds) { - if (entityA !== asteroidEntity && entityB !== asteroidEntity) { + for (const otherEntity of contactsComponents[i].touching) { + // A bullet touching two asteroids is removed by the first one. + if (!world.isAlive(otherEntity)) { continue; } - const otherEntity = entityA === asteroidEntity ? entityB : entityA; - if (world.getComponent(otherEntity, bulletId)) { explosionSpawner.spawn( world, @@ -52,10 +56,6 @@ export const createAsteroidCollisionEcsSystem = ( world.removeEntity(asteroidEntity); world.removeEntity(otherEntity); - // Equivalent to the old per-entity `run`'s `return`: stop checking - // further collisions for this asteroid. Breaking the inner loop is - // enough since it's the last statement in the outer loop body, so - // control falls through to the next entity exactly as before. break; } diff --git a/documentation-site/src/pages/demos/space-shooter/_create-game.ts b/documentation-site/src/pages/demos/space-shooter/_create-game.ts index 604194acd..5234e9ecc 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-game.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-game.ts @@ -264,12 +264,7 @@ export const createSpaceShooterGame = async ( createNarrowPhaseEcsSystem(collisionPairs, collisionManifolds), ); world.addSystem( - createAsteroidCollisionEcsSystem( - collisionManifolds, - time, - explosionSpawner, - onPlayerDeath, - ), + createAsteroidCollisionEcsSystem(time, explosionSpawner, onPlayerDeath), ); world.addSystem(createGameOverEcsSystem(restartInput, respawnPlayer)); diff --git a/documentation-site/src/pages/demos/space-shooter/_create-player.ts b/documentation-site/src/pages/demos/space-shooter/_create-player.ts index 4f5ad908f..817492043 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-player.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-player.ts @@ -19,6 +19,7 @@ import { CircleCollider, } from '@forge-game-engine/forge/physics'; import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { asteroidCategory, playerCategory } from './_collision-categories'; import { PlayerId } from './_player.component'; import { gunId } from './_gun.component'; @@ -131,6 +132,8 @@ export function spawnPlayer( addColliderComponent(world, playerEntity, { collider: new CircleCollider(playerRadius), + category: playerCategory, + mask: asteroidCategory, }); } diff --git a/documentation-site/src/pages/demos/space-shooter/_gun.system.ts b/documentation-site/src/pages/demos/space-shooter/_gun.system.ts index b682a648a..78c25a103 100644 --- a/documentation-site/src/pages/demos/space-shooter/_gun.system.ts +++ b/documentation-site/src/pages/demos/space-shooter/_gun.system.ts @@ -21,6 +21,7 @@ import { CircleCollider, } from '@forge-game-engine/forge/physics'; import { bulletId } from './_bullet.component'; +import { asteroidCategory, bulletCategory } from './_collision-categories'; import { GunEcsComponent, gunId } from './_gun.component'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; @@ -123,5 +124,7 @@ function createBulletWithOffset( addColliderComponent(world, bullet, { collider: new CircleCollider(bulletRadius), + category: bulletCategory, + mask: asteroidCategory, }); } diff --git a/documentation-site/src/pages/demos/space-shooter/index.tsx b/documentation-site/src/pages/demos/space-shooter/index.tsx index 6b10eddac..9edc3ef6f 100644 --- a/documentation-site/src/pages/demos/space-shooter/index.tsx +++ b/documentation-site/src/pages/demos/space-shooter/index.tsx @@ -33,6 +33,7 @@ import asteroidSpawnerComponentCode from '!!raw-loader!./_asteroid-spawner.compo import asteroidSpawnerSystemCode from '!!raw-loader!./_asteroid-spawner.system'; import createAsteroidsCode from '!!raw-loader!./_create-asteroids'; import collisionSystemCode from '!!raw-loader!./_collision.system'; +import collisionCategoriesCode from '!!raw-loader!./_collision-categories'; import gameOverComponentCode from '!!raw-loader!./_game-over.component'; import gameOverSystemCode from '!!raw-loader!./_game-over.system'; @@ -186,6 +187,10 @@ export default function Rendering(): JSX.Element { name: 'camera-shake.system.ts', content: cameraShakeSystemCode, }, + { + name: 'collision-categories.ts', + content: collisionCategoriesCode, + }, { name: 'collision.system.ts', content: collisionSystemCode, diff --git a/src/physics/components/collider-component.ts b/src/physics/components/collider-component.ts index d3e84c74d..249d5e256 100644 --- a/src/physics/components/collider-component.ts +++ b/src/physics/components/collider-component.ts @@ -41,6 +41,14 @@ export interface ColliderDefaultedOptions { * {@link allCollisionCategories}. */ mask: number; + + /** + * When `true`, overlaps with this collider are detected and reported + * through `ContactsEcsComponent`, but never resolved: nothing bounces off + * or is pushed by it. Use it for trigger zones and pickups. Defaults to + * `false`. + */ + sensor: boolean; } export interface ColliderRequiredOptions { @@ -70,6 +78,7 @@ export function addColliderComponent( restitution: 0.05, category: 1, mask: allCollisionCategories, + sensor: false, }; const component: ColliderEcsComponent = { diff --git a/src/physics/components/contacts-component.ts b/src/physics/components/contacts-component.ts new file mode 100644 index 000000000..4001abe31 --- /dev/null +++ b/src/physics/components/contacts-component.ts @@ -0,0 +1,59 @@ +import { createComponentId } from '../../ecs/ecs-component.js'; +import { EcsWorld } from '../../ecs/ecs-world.js'; + +/** + * The entities a collider entity is touching, and which of those contacts + * started or ended this tick. Output only: `createNarrowPhaseEcsSystem` + * writes all three lists every tick, replacing each with a new array, so + * holding on to one of them keeps that tick's contents. Contacts are + * opt-in: only collider entities with this component have theirs + * recorded. + * + * Every overlap counts, including one with a sensor collider (which is + * never resolved) and one with a body this entity is resting on. + */ +export interface ContactsEcsComponent { + /** + * The entities this one is touching this tick, each listed once (even + * when the pair produced several collision manifolds, such as a body + * resting across two of a terrain's edges). Another system may remove + * one of them later in the same tick, so check `world.isAlive(entity)` + * if an earlier system could have. + */ + touching: readonly number[]; + + /** + * The entities in {@link ContactsEcsComponent.touching} that weren't + * touching this one last tick. + */ + started: readonly number[]; + + /** + * The entities that were touching this one last tick and aren't any + * more: they moved apart, lost their collider, or were removed. Check + * `world.isAlive(entity)` before treating one as still existing. + */ + ended: readonly number[]; +} + +export const contactsId = createComponentId('contacts'); + +/** + * Attaches an empty {@link ContactsEcsComponent} to `entity`, so + * `createNarrowPhaseEcsSystem` records what its collider touches. + * @param world - The ECS world `entity` belongs to. + * @param entity - The entity to attach the component to. + * @returns The attached component, for reading its contacts later. + */ +export function addContactsComponent( + world: EcsWorld, + entity: number, +): ContactsEcsComponent { + const component: ContactsEcsComponent = { + touching: [], + started: [], + ended: [], + }; + + return world.addComponent(entity, contactsId, component); +} diff --git a/src/physics/components/index.ts b/src/physics/components/index.ts index 78512f9a8..bcd1e23f0 100644 --- a/src/physics/components/index.ts +++ b/src/physics/components/index.ts @@ -1,5 +1,6 @@ export * from './angular-velocity-motor-component.js'; export * from './collider-component.js'; +export * from './contacts-component.js'; export * from './gravity-component.js'; export * from './linear-damper-component.js'; export * from './linear-spring-component.js'; diff --git a/src/physics/raycast/raycast.test.ts b/src/physics/raycast/raycast.test.ts index 8efbde026..d669aa262 100644 --- a/src/physics/raycast/raycast.test.ts +++ b/src/physics/raycast/raycast.test.ts @@ -168,4 +168,27 @@ describe('raycast', () => { expect(hits).toHaveLength(1); }); + + it('should pass through sensors unless asked to include them', () => { + const sensor = world.createEntity(); + + addPositionComponent(world, sensor, { local: { x: -3, y: 0 } }); + addColliderComponent(world, sensor, { + collider: new CircleCollider(1), + sensor: true, + }); + + const wall = addEntity({ x: 3, y: 0 }, new CircleCollider(1)); + world.update(); + + const start = { x: -10, y: 0 }; + const end = { x: 10, y: 0 }; + + expect(raycast(world, start, end).map((hit) => hit.entity)).toEqual([wall]); + expect( + raycast(world, start, end, { includeSensors: true }).map( + (hit) => hit.entity, + ), + ).toEqual([sensor, wall]); + }); }); diff --git a/src/physics/raycast/raycast.ts b/src/physics/raycast/raycast.ts index a49207a3f..237541d60 100644 --- a/src/physics/raycast/raycast.ts +++ b/src/physics/raycast/raycast.ts @@ -70,11 +70,18 @@ export interface RaycastOptions { * {@link allCollisionCategories}. */ mask: number; + + /** + * When `true`, the ray also hits sensor colliders. Defaults to `false`, + * so a ray aimed at walls passes through trigger zones. + */ + includeSensors: boolean; } const defaultRaycastOptions: RaycastOptions = { sort: true, mask: allCollisionCategories, + includeSensors: false, }; /** @@ -102,7 +109,7 @@ export function raycast( end: Vector2, options: Partial = {}, ): RaycastHit[] { - const { sort, mask } = { + const { sort, mask, includeSensors } = { ...defaultRaycastOptions, ...options, }; @@ -123,6 +130,7 @@ export function raycast( if ( (collider.category & mask) === 0 || + (collider.sensor && !includeSensors) || !aabbsOverlap(rayAabb, collider.aabb) ) { continue; diff --git a/src/physics/systems/narrow-phase-contacts.test.ts b/src/physics/systems/narrow-phase-contacts.test.ts new file mode 100644 index 000000000..25ed0bc2e --- /dev/null +++ b/src/physics/systems/narrow-phase-contacts.test.ts @@ -0,0 +1,339 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + addPositionComponent, + createTransformEcsSystem, + PositionEcsComponent, + positionId, + Time, +} from '../../common/index.js'; +import { EcsWorld } from '../../ecs/index.js'; +import { Vector2 } from '../../math/index.js'; +import { CircleCollider } from '../colliders/circle-collider.js'; +import { TerrainCollider } from '../colliders/terrain-collider.js'; +import { + addColliderComponent, + colliderId, +} from '../components/collider-component.js'; +import { + addContactsComponent, + ContactsEcsComponent, +} from '../components/contacts-component.js'; +import { addRigidBodyComponent } from '../components/rigidbody-component.js'; +import { CollisionManifold } from '../types/collision-manifold.js'; +import { CollisionPair } from '../types/collision-pair.js'; +import { ContactConstraint } from '../types/contact-constraint.js'; +import { createBroadPhaseEcsSystem } from './broad-phase-system.js'; +import { createCollisionResolutionEcsSystem } from './collision-resolution-system.js'; +import { createEulerIntegrationEcsSystem } from './euler-integration-system.js'; +import { createNarrowPhaseEcsSystem } from './narrow-phase-system.js'; + +describe('createNarrowPhaseEcsSystem contacts and sensors', () => { + let world: EcsWorld; + let collisionPairs: CollisionPair[]; + let collisionManifolds: CollisionManifold[]; + + beforeEach(() => { + world = new EcsWorld(); + collisionPairs = []; + collisionManifolds = []; + + world.addSystem(createTransformEcsSystem()); + world.addSystem(createBroadPhaseEcsSystem(collisionPairs)); + world.addSystem( + createNarrowPhaseEcsSystem(collisionPairs, collisionManifolds), + ); + }); + + function addCircle( + position: Vector2, + options: { sensor?: boolean; contacts?: boolean } = {}, + ): { entity: number; contacts: ContactsEcsComponent | null } { + const entity = world.createEntity(); + + addPositionComponent(world, entity, { local: position }); + addColliderComponent(world, entity, { + collider: new CircleCollider(1), + sensor: options.sensor ?? false, + }); + + const contacts = options.contacts + ? addContactsComponent(world, entity) + : null; + + return { entity, contacts }; + } + + function moveTo(entity: number, position: Vector2): void { + world.getComponentRequired(entity, positionId).local = + position; + } + + describe('contacts', () => { + it('should report a contact as started and touching on the first overlapping tick', () => { + const { contacts } = addCircle({ x: 0, y: 0 }, { contacts: true }); + const { entity: other } = addCircle({ x: 1, y: 0 }); + + world.update(); + + expect(contacts?.touching).toEqual([other]); + expect(contacts?.started).toEqual([other]); + expect(contacts?.ended).toEqual([]); + }); + + it('should keep a contact touching, but not started, while the overlap lasts', () => { + const { contacts } = addCircle({ x: 0, y: 0 }, { contacts: true }); + const { entity: other } = addCircle({ x: 1, y: 0 }); + + world.update(); + world.update(); + + expect(contacts?.touching).toEqual([other]); + expect(contacts?.started).toEqual([]); + expect(contacts?.ended).toEqual([]); + }); + + it('should report a contact as ended on the first separated tick', () => { + const { contacts } = addCircle({ x: 0, y: 0 }, { contacts: true }); + const { entity: other } = addCircle({ x: 1, y: 0 }); + + world.update(); + moveTo(other, { x: 10, y: 0 }); + world.update(); + + expect(contacts?.touching).toEqual([]); + expect(contacts?.started).toEqual([]); + expect(contacts?.ended).toEqual([other]); + + world.update(); + + expect(contacts?.ended).toEqual([]); + }); + + it('should not report a pair whose AABBs overlap but shapes do not', () => { + const { contacts } = addCircle({ x: 0, y: 0 }, { contacts: true }); + + // Diagonal neighbors: the square AABBs overlap at a corner, the + // circles don't. + addCircle({ x: 1.9, y: 1.9 }); + + world.update(); + + expect(collisionPairs).toHaveLength(1); + expect(contacts?.touching).toEqual([]); + }); + + it('should fill contacts on both entities of a pair that has them', () => { + const { entity: a, contacts: contactsA } = addCircle( + { x: 0, y: 0 }, + { contacts: true }, + ); + const { entity: b, contacts: contactsB } = addCircle( + { x: 1, y: 0 }, + { contacts: true }, + ); + + world.update(); + + expect(contactsA?.touching).toEqual([b]); + expect(contactsB?.touching).toEqual([a]); + }); + + it('should list an entity once even when the pair produces several manifolds', () => { + const ground = world.createEntity(); + + addPositionComponent(world, ground); + addColliderComponent(world, ground, { + collider: new TerrainCollider( + [ + { x: -10, y: 10 }, + { x: 0, y: 0 }, + { x: 10, y: 10 }, + ], + 5, + ), + }); + + // Sits in the valley, touching both of its edges. + const { contacts } = addCircle({ x: 0, y: 1 }, { contacts: true }); + + world.update(); + + expect(collisionManifolds.length).toBeGreaterThan(1); + expect(contacts?.touching).toEqual([ground]); + }); + + it('should end a contact when the other entity is removed', () => { + const { contacts } = addCircle({ x: 0, y: 0 }, { contacts: true }); + const { entity: other } = addCircle({ x: 1, y: 0 }); + + world.update(); + world.removeEntity(other); + world.update(); + + expect(contacts?.touching).toEqual([]); + expect(contacts?.ended).toEqual([other]); + expect(world.isAlive(other)).toBe(false); + }); + + it('should tell a removed entity apart from a new one that reuses its slot', () => { + const { contacts } = addCircle({ x: 0, y: 0 }, { contacts: true }); + const { entity: removed } = addCircle({ x: 1, y: 0 }); + + world.update(); + world.removeEntity(removed); + + const { entity: replacement } = addCircle({ x: 1, y: 0 }); + + world.update(); + + expect(replacement).not.toBe(removed); + expect(contacts?.touching).toEqual([replacement]); + expect(contacts?.started).toEqual([replacement]); + expect(contacts?.ended).toEqual([removed]); + }); + + it('should end a contact when the other entity loses its collider', () => { + const { contacts } = addCircle({ x: 0, y: 0 }, { contacts: true }); + const { entity: other } = addCircle({ x: 1, y: 0 }); + + world.update(); + world.removeComponent(other, colliderId); + world.update(); + + expect(contacts?.touching).toEqual([]); + expect(contacts?.ended).toEqual([other]); + }); + + it('should end every contact when its own entity loses its collider', () => { + const { entity, contacts } = addCircle( + { x: 0, y: 0 }, + { contacts: true }, + ); + const { entity: other } = addCircle({ x: 1, y: 0 }); + + world.update(); + world.removeComponent(entity, colliderId); + world.update(); + + expect(contacts?.touching).toEqual([]); + expect(contacts?.ended).toEqual([other]); + }); + + it('should not report pairs that collision filtering excludes', () => { + const entity = world.createEntity(); + + addPositionComponent(world, entity); + addColliderComponent(world, entity, { + collider: new CircleCollider(1), + category: 0b01, + mask: 0b01, + }); + + const contacts = addContactsComponent(world, entity); + const other = world.createEntity(); + + addPositionComponent(world, other, { local: { x: 1, y: 0 } }); + addColliderComponent(world, other, { + collider: new CircleCollider(1), + category: 0b10, + }); + + world.update(); + + expect(contacts.touching).toEqual([]); + }); + }); + + describe('sensors', () => { + it('should report a sensor overlap through contacts but never as a manifold', () => { + const { entity: sensor, contacts: sensorContacts } = addCircle( + { x: 0, y: 0 }, + { sensor: true, contacts: true }, + ); + const { entity: body, contacts: bodyContacts } = addCircle( + { x: 1, y: 0 }, + { contacts: true }, + ); + + world.update(); + + expect(collisionManifolds).toHaveLength(0); + expect(sensorContacts?.touching).toEqual([body]); + expect(bodyContacts?.touching).toEqual([sensor]); + }); + + it('should report a sensor overlapping a static collider', () => { + const { contacts } = addCircle( + { x: 0, y: 0 }, + { sensor: true, contacts: true }, + ); + const wall = world.createEntity(); + + addPositionComponent(world, wall, { local: { x: 1, y: 0 } }); + addColliderComponent(world, wall, { collider: new CircleCollider(1) }); + + world.update(); + + expect(collisionManifolds).toHaveLength(0); + expect(contacts?.touching).toEqual([wall]); + }); + + it('should report two overlapping sensors to each other', () => { + const { entity: a, contacts } = addCircle( + { x: 0, y: 0 }, + { sensor: true, contacts: true }, + ); + const { entity: b } = addCircle({ x: 1, y: 0 }, { sensor: true }); + + world.update(); + + expect(collisionManifolds).toHaveLength(0); + expect(contacts?.touching).toEqual([b]); + expect(a).not.toBe(b); + }); + + it('should still produce manifolds for solid pairs alongside a sensor', () => { + addCircle({ x: 0, y: 0 }, { sensor: true }); + addCircle({ x: 1, y: 0 }); + addCircle({ x: 1.5, y: 0 }); + + world.update(); + + expect(collisionManifolds).toHaveLength(1); + }); + + it('should never push a dynamic body that overlaps it', () => { + const time = new Time(); + + time.update(0); + world.addSystem( + createCollisionResolutionEcsSystem( + collisionManifolds, + [] as ContactConstraint[], + time, + ), + ); + world.addSystem(createEulerIntegrationEcsSystem(time)); + + addCircle({ x: 0, y: 0 }, { sensor: true }); + + const { entity: body } = addCircle({ x: 0.5, y: 0 }); + const collider = new CircleCollider(1); + + addRigidBodyComponent(world, body, { + mass: collider.mass, + momentOfInertia: collider.momentOfInertia, + }); + + for (let i = 0; i < 10; i++) { + time.update(i * 16); + world.update(); + } + + expect( + world.getComponentRequired(body, positionId) + .local, + ).toEqual({ x: 0.5, y: 0 }); + }); + }); +}); diff --git a/src/physics/systems/narrow-phase-system.ts b/src/physics/systems/narrow-phase-system.ts index 815c39f79..9c57b73e6 100644 --- a/src/physics/systems/narrow-phase-system.ts +++ b/src/physics/systems/narrow-phase-system.ts @@ -4,6 +4,10 @@ import { EcsWorld } from '../../ecs/ecs-world.js'; import { getColliderRotation } from '../collider-rotation.js'; import { detectCollision } from '../collision/detect-collision.js'; import { colliderId } from '../components/collider-component.js'; +import { + ContactsEcsComponent, + contactsId, +} from '../components/contacts-component.js'; import { CollisionBody } from '../types/collision-body.js'; import { CollisionManifold } from '../types/collision-manifold.js'; import { CollisionPair } from '../types/collision-pair.js'; @@ -12,48 +16,128 @@ import { CollisionPair } from '../types/collision-pair.js'; * Creates an ECS system that runs narrow-phase (SAT) collision detection * against every pair in `collisionPairs`, using each entity's world * position/rotation (local values are only meaningful to the parenting - * system; an entity with no `RotationEcsComponent` is treated as unrotated), - * writing every actual collision into `collisionManifolds`. A - * single pair can contribute more than one manifold - a body straddling - * several of a `TerrainCollider`'s surface edges gets one per edge it - * touches (see `detectCollision`). + * system; an entity with no `RotationEcsComponent` is treated as unrotated). + * + * Every actual collision between two solid colliders is written into + * `collisionManifolds`, the input to collision resolution. A single pair + * can contribute more than one manifold - a body straddling several of a + * `TerrainCollider`'s surface edges gets one per edge it touches (see + * `detectCollision`). An overlap involving a sensor collider is never + * written there, so it's never resolved. + * + * The system also owns every `ContactsEcsComponent`: it queries for them + * and, each tick, rewrites their `touching`, `started` and `ended` lists + * from this tick's overlaps, sensors included. An entity that has a + * `ContactsEcsComponent` but no collider touches nothing, so its previous + * contacts end. * @param collisionPairs - The broad-phase system's output: candidate - * entity pairs whose AABBs overlap. + * entity pairs whose AABBs overlap, each pair listed once. * @param collisionManifolds - The array the system clears and refills with - * the current tick's confirmed collisions. - * @returns An ECS system that populates `collisionManifolds` every tick. + * the current tick's confirmed collisions between solid colliders. + * @returns An ECS system that populates `collisionManifolds` and every + * `ContactsEcsComponent` every tick. */ export const createNarrowPhaseEcsSystem = ( collisionPairs: CollisionPair[], collisionManifolds: CollisionManifold[], -): EcsSystem<[]> => ({ - query: [], - update: (world) => { +): EcsSystem<[ContactsEcsComponent]> => ({ + query: [contactsId], + update: (world, { entities, components: [contacts] }) => { collisionManifolds.length = 0; - for (const pair of collisionPairs) { - const bodyA = getCollisionBody(world, pair.entityA); - const bodyB = getCollisionBody(world, pair.entityB); + const hasContacts = new Set(entities); + const touchingByEntity = new Map(); + + const recordContact = (entity: number, other: number): void => { + if (!hasContacts.has(entity)) { + return; + } + + const touching = touchingByEntity.get(entity); + + if (touching) { + touching.push(other); + } else { + touchingByEntity.set(entity, [other]); + } + }; + + for (const { entityA, entityB } of collisionPairs) { + const bodyA = getCollisionBody(world, entityA); + const bodyB = getCollisionBody(world, entityB); if (bodyA === null || bodyB === null) { continue; } - for (const manifold of detectCollision(bodyA, bodyB)) { - collisionManifolds.push({ - entityA: pair.entityA, - entityB: pair.entityB, - ...manifold, - }); + const isSensorPair = bodyA.sensor || bodyB.sensor; + + // A sensor overlap is only ever reported through contacts, so there's + // nothing to detect when neither entity records them. + if ( + isSensorPair && + !hasContacts.has(entityA) && + !hasContacts.has(entityB) + ) { + continue; + } + + const manifolds = detectCollision(bodyA, bodyB); + + if (manifolds.length === 0) { + continue; + } + + // Each pair is listed once, so recording it once here (not once per + // manifold) keeps every entity's `touching` free of duplicates. + recordContact(entityA, entityB); + recordContact(entityB, entityA); + + if (isSensorPair) { + continue; + } + + for (const manifold of manifolds) { + collisionManifolds.push({ entityA, entityB, ...manifold }); } } + + for (let i = 0; i < entities.length; i++) { + updateContacts( + contacts[i], + touchingByEntity.get(entities[i]) ?? noContacts, + ); + } }, }); +const noContacts: readonly number[] = Object.freeze([]); + +function updateContacts( + contacts: ContactsEcsComponent, + touching: readonly number[], +): void { + const previous = contacts.touching; + + if (touching.length === 0 && previous.length === 0) { + contacts.started = noContacts; + contacts.ended = noContacts; + + return; + } + + const previousSet = new Set(previous); + const currentSet = new Set(touching); + + contacts.started = touching.filter((entity) => !previousSet.has(entity)); + contacts.ended = previous.filter((entity) => !currentSet.has(entity)); + contacts.touching = touching; +} + function getCollisionBody( world: EcsWorld, entity: number, -): CollisionBody | null { +): (CollisionBody & { sensor: boolean }) | null { const position = world.getComponent(entity, positionId); const rotation = world.getComponent(entity, rotationId); const collider = world.getComponent(entity, colliderId); @@ -66,5 +150,6 @@ function getCollisionBody( position: position.world, rotation: getColliderRotation(rotation), collider: collider.collider, + sensor: collider.sensor, }; } From 52953376e4875b088b96a231ff3cfe40f886ed16 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 18:26:51 +0000 Subject: [PATCH 3/3] test(physics): cover multiple contacts per tick and unrecorded sensor pairs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LVTLatSvTvhsw2P788Dcjj --- .../systems/narrow-phase-contacts.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/physics/systems/narrow-phase-contacts.test.ts b/src/physics/systems/narrow-phase-contacts.test.ts index 25ed0bc2e..cb505a0ab 100644 --- a/src/physics/systems/narrow-phase-contacts.test.ts +++ b/src/physics/systems/narrow-phase-contacts.test.ts @@ -122,6 +122,18 @@ describe('createNarrowPhaseEcsSystem contacts and sensors', () => { expect(contacts?.touching).toEqual([]); }); + it('should list every entity it touches in the same tick', () => { + const { contacts } = addCircle({ x: 0, y: 0 }, { contacts: true }); + const { entity: left } = addCircle({ x: -1, y: 0 }); + const { entity: right } = addCircle({ x: 1, y: 0 }); + + world.update(); + + expect(contacts?.touching).toHaveLength(2); + expect(contacts?.touching).toEqual(expect.arrayContaining([left, right])); + expect(contacts?.started).toHaveLength(2); + }); + it('should fill contacts on both entities of a pair that has them', () => { const { entity: a, contacts: contactsA } = addCircle( { x: 0, y: 0 }, @@ -292,6 +304,19 @@ describe('createNarrowPhaseEcsSystem contacts and sensors', () => { expect(a).not.toBe(b); }); + it('should report nothing for a sensor pair where neither entity records contacts', () => { + addCircle({ x: 0, y: 0 }, { sensor: true }); + addCircle({ x: 1, y: 0 }); + + const { contacts } = addCircle({ x: 10, y: 0 }, { contacts: true }); + + world.update(); + + expect(collisionPairs).toHaveLength(1); + expect(collisionManifolds).toHaveLength(0); + expect(contacts?.touching).toEqual([]); + }); + it('should still produce manifolds for solid pairs alongside a sensor', () => { addCircle({ x: 0, y: 0 }, { sensor: true }); addCircle({ x: 1, y: 0 });