Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

#### 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
- **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`
- **ecs:** Systems and system groups can run conditionally. Pass a `runIf` function of the world to `addSystem` or `addSystemGroup`, and the world checks it each tick just before the system or group would run, skipping it (and its query) when it returns `false`. `EcsWorld` also gains a built-in `firstSystemGroup` that runs before every other group of the tick. A group ordered `after` it joins the start of the tick, before every other group. Ordering a group `before` the first group throws, and so does ordering a group before a start-of-tick group, or a start-of-tick group after one that isn't
- **states:** New `@forge-game-engine/forge/states` module for a game's top-level states (menu, playing, paused, game over). `createGameState(world, initial)` returns a `GameState` whose `set` switches state at the start of the next tick. Its `exitGroup` and `enterGroup` run once per transition, before any other system, holding systems that use `onExit`/`onEnter` as their `runIf`; `inState` runs a system only in some states. The initial state is entered on the first tick, and setting the current state again restarts it. `addStateScopedComponent` removes an entity when its state leaves one of `removeOnExit` or enters one of `removeOnEnter`. See the new Game States guide and demo
- **rendering:** `getCameraView(world, camera, renderContext)` (and `computeCameraView(camera, position, renderContext)` for systems that already hold the camera's components) returns what a camera sees: the world area it shows (`bounds`, `size`, accounting for its position and zoom), its `pixelsPerUnit` in CSS pixels, and `worldToViewport`/`viewportToWorld` conversions to and from CSS pixels on the canvas. To place something drawn by one camera over something drawn by another, convert through the viewport: `hudView.viewportToWorld(gameView.worldToViewport(position))`
Expand All @@ -26,13 +30,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **math:** Every angle and direction now follows one convention: radians, `0` along `+X`, positive turning towards `+Y` (counter-clockwise, since the world is Y-up). `Vec2.up` is now `(0, 1)` and `Vec2.down` is `(0, -1)`; if you used `Vec2.up` to mean "down the screen", use `Vec2.down`. `radiansToVector(angle)` now returns `(cos angle, sin angle)`, so `radiansToVector(0)` is `(1, 0)` and it's the inverse of `vectorToRadians`; drop any `+ Math.PI / 2` you added to round-trip between them, and add `- Math.PI / 2` when facing a direction with art drawn facing up. `applyExplosiveForce` and circle-circle collisions now push coincident bodies up rather than down
- **particles:** `ParticleEmitter`'s `directionRange` and `rotationRange` are now in radians, following the same convention (`directionRange` defaults to `{ min: 0, max: 2 * Math.PI }`). Convert an old `directionRange` (degrees clockwise from up) as `{ min: Math.PI / 2 - degreesToRadians(oldMax), max: Math.PI / 2 - degreesToRadians(oldMin) }` (note that `min` and `max` swap), and an old `rotationRange` with `degreesToRadians`. An emitter's spawn shape and `directionRange` now turn with the world rotation of the entity it's on, so an emitter on a child entity follows its parent; if an emitter's entity has a rotation and you want world-space directions, move the emitter to an unrotated entity. `emitParticleBurst` takes a `rotation` option to turn a burst the same way
- **physics:** `TerrainCollider` and `createTerrainMesh` now put the solid ground below the surface points (toward `-y`), as the Y-up world expects, instead of above them. Author terrain points as the ground's surface in world coordinates on an unrotated entity: if you rotated the terrain entity by `Math.PI` and negated and reversed its points to get ground underneath, remove all three. `TerrainCollider.bottomY` is now `depth` below the lowest point (`min(y) - depth`) and surface normals point towards `+y`
- **physics:** `raycast`'s fourth argument is now an options object. Replace `raycast(world, start, end, false)` with `raycast(world, start, end, { sort: false })`
- **rendering:** The render system no longer draws sprites, nine-slice regions or text glyphs whose quads are entirely outside a camera's view. Code that disabled sprites only to save drawing them while off screen can be deleted. A material with a custom vertex shader that moves vertices beyond the sprite's quad may be skipped while partly visible
- **rendering:** `createProjectionMatrix` now takes the world-space `Rect` to show, such as `getCameraView(...).bounds`, instead of `(width, height, cameraPosition, zoom, pixelsPerUnit)`
- **ui:** A `'screenPixels'`-unit size or margin, and `UiSafeAreaEcsComponent` insets, now follow the canvas camera's `zoom`, so they keep their on-screen size on a world-space canvas whose camera zooms. A screen-space canvas's root rect now fills its camera's view, so it follows a moved or zoomed UI camera
- **text:** `FontAtlasCache.getOrLoad` takes the URL of both atlas files, `getOrLoad({ metricsUrl, imageUrl })`, instead of finding the image next to the JSON, so atlases imported through Vite, webpack or another bundler that renames files now load. Pass the URLs your bundler gives you for the `.json` and `.png` (with Vite, `import metricsUrl from './my-font.json?url'` and `import imageUrl from './my-font.png'`), and look loaded atlases up with `get(metricsUrl)`. `getOrLoad` rejects if the image's size doesn't match the JSON's `atlasSize`, or if one `metricsUrl` is requested with two different image URLs. Concurrent calls for the same atlas now share one load. `FontAtlasCache` no longer implements `AssetCache` and its `load` method and `assets` map are no longer public; call `getOrLoad` instead. `FontAtlasData` and `FontAtlasFileData` no longer have an `atlasImage` field and `forge-generate-font-atlas` no longer writes one; existing JSON files that have it still load

#### 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
- **rendering:** `calculateVisibleWorldSize`, `calculatePixelsPerUnit`, `screenToWorldSpace`, `worldToScreenSpace` and `canvasToWorldSpace` are removed; use the camera's view instead. `calculateVisibleWorldSize(width, height, verticalWorldUnits)` becomes `getCameraView(world, camera, renderContext).size`, which also accounts for zoom; `screenToWorldSpace(pointer, ...)` becomes `view.viewportToWorld(pointer)`; `worldToScreenSpace(...)` becomes `view.worldToViewport(position)`, which unlike the old function flips Y to the Y-down viewport; `calculatePixelsPerUnit(...)` becomes `view.pixelsPerUnit`. Constants that mirrored a camera's `verticalWorldUnits` for these calls can be deleted
- **rendering:** `CameraEcsComponent.scissorRect`, which nothing read, is removed

Expand Down
45 changes: 16 additions & 29 deletions demo/src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,22 @@ import {
positionId,
Random,
SpriteEcsComponent,
spriteId,
Time,
Vec2,
} from '../../src';
import {
addAabbComponent,
addColliderComponent,
addContactsComponent,
addGravityComponent,
addRigidBodyComponent,
CircleCollider,
Collider,
CollisionManifold,
CollisionPair,
ContactConstraint,
ContactsEcsComponent,
contactsId,
createBroadPhaseEcsSystem,
createCollisionResolutionEcsSystem,
createEulerIntegrationEcsSystem,
Expand Down Expand Up @@ -129,7 +132,7 @@ function createFountainSpawnEcsSystem(
angularVelocity,
});
addColliderComponent(world, entity, { collider });
addAabbComponent(world, entity);
addContactsComponent(world, entity);
};

return {
Expand Down Expand Up @@ -178,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<number, SpriteEcsComponent>,
): 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;
}
},
};
Expand Down Expand Up @@ -318,7 +308,6 @@ addColliderComponent(world, groundEntity, {
{ x: -groundHalfWidth, y: groundHalfHeight },
]),
});
addAabbComponent(world, groundEntity);

const random = new Random();
const fountainLeftX = -halfWidth + fountainMarginFromEdge;
Expand Down Expand Up @@ -358,9 +347,7 @@ world.addSystem(
-halfHeight - despawnMarginBelowGround,
),
);
world.addSystem(
createCollisionTintEcsSystem(collisionManifolds, spritesByEntity),
);
world.addSystem(createCollisionTintEcsSystem());
world.addSystem(createRenderEcsSystem(renderContext));

game.run();
Expand Down
2 changes: 1 addition & 1 deletion design/continuous-collision-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
182 changes: 182 additions & 0 deletions documentation-site/docs/docs/physics/collisions.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading