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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

#### Added

- **physics:** Continuous collision detection. Register the new `createContinuousCollisionEcsSystem()` directly after `createEulerIntegrationEcsSystem` and a fast dynamic `CircleCollider` body no longer sinks deep into, or passes through, a static collider (polygon, circle or terrain) between two ticks. A wheel coming down from a jump, for example, now stops at the terrain's surface instead of sinking 25-30 units into it in a single tick. Write teleports to `position.local` before `createTransformEcsSystem` runs, or the sweep treats the jump as motion. The sweeps it uses are public too: `sweepCircleCircle`, `sweepCirclePolygon` and `sweepCircleTerrain` return the first `SweepHit` (`point`, `normal`, `t`) of a circle moving from one position to another
- **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`
Expand Down
2 changes: 2 additions & 0 deletions demo/src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
contactsId,
createBroadPhaseEcsSystem,
createCollisionResolutionEcsSystem,
createContinuousCollisionEcsSystem,
createEulerIntegrationEcsSystem,
createGravityEcsSystem,
createNarrowPhaseEcsSystem,
Expand Down Expand Up @@ -341,6 +342,7 @@ world.addSystem(
),
);
world.addSystem(createEulerIntegrationEcsSystem(time));
world.addSystem(createContinuousCollisionEcsSystem());
world.addSystem(
createDespawnFallenShapesEcsSystem(
spritesByEntity,
Expand Down
69 changes: 68 additions & 1 deletion design/continuous-collision-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

| | |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Status** | Proposed |
| **Status** | Implemented (Phases 1-2) |
| **Target module** | `/src/physics` → `@forge-game-engine/forge/physics` |
| **Engine version at time of writing** | `0.25.4` |
| **Modules** | See §1 table below |
Expand Down Expand Up @@ -218,3 +218,70 @@ Nothing in this design forecloses substepping being added later. If it is, the t
- **Full-pipeline level** (Phase 2): an integration test in the style of `src/physics/systems/terrain-resting-contact.test.ts` - real `EcsWorld`, real gravity/broad-phase/narrow-phase/resolution/CCD/integration systems, a `CircleCollider` body given a velocity and starting position chosen to reproduce the diagnosed tunneling geometry against a `TerrainCollider`, asserting the body's final penetration stays bounded (near the solver's existing `slop`) rather than reaching the previously-measured tens of units.
- **Demo-level** (Phase 2, manual): per AGENTS.md's "Documentation Site Demos" process - rebuild, browser-verify the Car demo under the same sustained-throttle/highish-speed conditions that originally reproduced the bug, confirming no visible wheel/chassis embedding on a hard, wheel-first landing.
- **Regression guard for §8 Open Question 3** (once resolved): a wide-body-across-many-segments jitter test mirroring `detect-circle-terrain-collision.test.ts`'s own "should never flip feature ids for a wide body..." test, applied to the sweep path.

---

## 11. Implementation notes

Phases 1 and 2 shipped with these deviations from the plan above. The physics
guide (`documentation-site/docs/docs/physics/continuous-collision-detection.md`)
describes the shipped behavior.

- **DL-3 replaced: rewind after integration instead of a clamp field.**
`createContinuousCollisionEcsSystem` registers directly *after*
`createEulerIntegrationEcsSystem`, sweeps from `position.world` (where this
tick's broad/narrow phase saw the body) to `position.local` (where
integration moved it), and on a hit moves `position.local` back to the time
of impact. No field is added to `RigidBodyEcsComponent` and
`createEulerIntegrationEcsSystem` is unchanged. A field written by one
system and cleared by another has two writers; the rewind matches how
Box2D's `b2SolveContinuous` (after body finalization) and Avian's swept CCD
(after the solver) are structured.
- **The body is left slightly inside the surface, not just short of it** (1%
of its radius). Forge has no speculative contacts, so a body stopped short
would give the next tick's narrow phase nothing to report and would be
swept and stopped again every tick.
- **DL-4 replaced: no per-body flag and no configurable threshold.** The
sweep follows the same rules as discrete collision: it skips sensors and
pairs whose `category`/`mask` exclude each other. Any other opt-out could
only let a body tunnel, and "force it on below the threshold" is by
definition a case discrete detection handles.
- **Threshold (§8 Q1): a tenth of the radius, not half.** The diagnosed wheel
moves 0.2-0.25 of its radius per tick, so `0.5` would never have fired for
the reported bug. A hit is acted on only when the unclamped step would end
more than `0.1 * radius` inside the surface; the same value is the
per-body speed pre-filter. This doubles as Box2D's "prevent pausing" rule:
grazes and slight bends in the ground are left to the solver rather than
cutting short every tick of a fast roll.
- **Targets are static colliders only.** §2 counted `'kinematic'` as static;
a kinematic body moves during the tick, so a sweep against its start pose
gives the wrong time of impact. Box2D's non-bullet CCD likewise only sweeps
against static bodies.
- **Dedicated sweep math instead of reusing `raycastConvexPolygon`/
`raycastCircle`.** Those test both crossing directions, and `raycastCircle`
returns the exit point for a ray starting inside. The sweeps only count
entering hits on the true boundary of the Minkowski sum (front faces
within their span, corner rounds within their normal cone).
- **Terrain is swept against the surface chain, not the slab** (§8 Q3). Each
edge is tested on its own and corners only exist where the surface bends
away from the circle, so a fast wheel rolling along the ground doesn't
catch on the tops of neighboring columns. The sweep only decides where to
stop the body; the next tick's contacts (and their feature ids) still come
from narrow phase, so no extra tie-break was needed.
- **§8 Q6: the sweeps are public** (`sweepCircleCircle`,
`sweepCirclePolygon`, `sweepCircleTerrain`), mirroring `raycast`.
- **Registered in every pipeline**, not only the Car demo: every demo that
registers `createEulerIntegrationEcsSystem`, `/demo`, and the physics
guides' system listings.
- **Car demo finding: most of its visible wheel embedding is not
tunneling.** Probing the live demo under sustained throttle, CCD removes
every "no contact to deep" landing (without it: 11-19 units at 750-1500
units/second downward, matching §1). But the demo's deepest penetrations
(50-80 units) come from wheels that are *already* in contact sinking
further over several ticks, which CCD deliberately leaves alone. The
cause is solver ordering: the prismatic/revolute joint systems run after
`createCollisionResolutionEcsSystem` and get the last word on velocity,
so they drive a wheel back into the ground after its contact was solved.
Running contact resolution after the joints, as an experiment, dropped
the worst depth to about 12 units. The fix is solving contacts and joints
in one iteration loop, as Box2D does, which needs its own design.
3 changes: 2 additions & 1 deletion documentation-site/docs/docs/common/transforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ before the systems that read `world`:
1. Your game logic, and `registerUiSystems` if you use the UI.
2. `createTransformEcsSystem()`.
3. Physics: gravity, broad phase, narrow phase, collision resolution,
joints and springs, then `createEulerIntegrationEcsSystem`.
joints and springs, then `createEulerIntegrationEcsSystem` and
`createContinuousCollisionEcsSystem`.
4. Rendering.

Physics reads `world` and integrates velocity into `local`, so running the
Expand Down
121 changes: 121 additions & 0 deletions documentation-site/docs/docs/physics/continuous-collision-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
---
sidebar_position: 7
---

# Continuous Collision Detection

Collision detection runs once per tick, against where each body is at the
start of that tick. A body that integration then moves further than the gap
to a surface ends the tick deep inside it, or out the other side, before the
next tick's test ever sees the contact. A wheel landing hard after a jump
sinks visibly into the ground; a ball fast enough to cross a thin wall in a
single tick passes straight through it.

`createContinuousCollisionEcsSystem` fixes this for fast dynamic circles
against static colliders. Each tick it sweeps every dynamic body with a
`CircleCollider` along the path integration just moved it, and if that path
would take the circle deep into a static collider, it moves the body back to
where it first touched that collider.

## Registering it

Register it directly after `createEulerIntegrationEcsSystem`. It takes no
arguments:

```ts
import {
createContinuousCollisionEcsSystem,
createEulerIntegrationEcsSystem,
} from '@forge-game-engine/forge/physics';

// ...transform, gravity, broad phase, narrow phase, collision resolution,
// joints...
world.addSystem(createEulerIntegrationEcsSystem(time));
world.addSystem(createContinuousCollisionEcsSystem());
```

It sweeps from `position.world` (where this tick's collision detection saw
the body) to `position.local` (where integration moved it), so it has to run
after integration and before the next tick's `createTransformEcsSystem`.
See [Bodies and Shapes](./rigid-bodies.md) for the full registration order.

## What it does

- **Which bodies are swept**: `'dynamic'` bodies with a `CircleCollider`
that isn't a sensor. Kinematic bodies follow the velocity your code gives
them and aren't stopped.
- **What they're swept against**: static colliders, meaning collider
entities with no `RigidBodyEcsComponent` or a `'static'` one, of every
shape (`CircleCollider`, `PolygonCollider` and `TerrainCollider`), as
long as the two colliders' `category` and `mask` let them collide.
Sensors are skipped, since nothing is ever resolved against them. Against
a terrain, the sweep only meets the surface, the same as
[narrow-phase collision](./terrain.md#how-collision-works).
- **When it steps in**: only when the circle would end the tick more than a
tenth of its radius inside a surface. Shallower contacts are left to
collision resolution, which pushes them out within a tick or two. A body
rolling fast over bumpy ground therefore isn't slowed down at every bend.
- **What it changes**: the body's `local` position, which it moves back to
the point of first contact, plus a hundredth of the radius into the
surface so that the next tick's narrow phase reports the contact.
Velocity is left alone: collision resolution handles that contact on the
next tick (friction, restitution and so on) like any other.

A stopped body covers less ground than its velocity says it should that
tick. This is the trade-off Box2D also makes. Gravity applied this tick still
counts in full.

## Gotchas

:::caution[Teleport before the transform system]
A body moved by writing its `local` position between
`createTransformEcsSystem` and this system looks to the sweep like a fast
move. If a static collider lies between the old and new position, the sweep
stops the body against it. Write teleports before `createTransformEcsSystem`
runs (the Car demo's reset system does this).
:::

- **Polygon bodies aren't swept.** A fast `PolygonCollider` body can still
sink into, or pass through, thin static geometry. Keep fast polygon bodies
slow relative to their size, or make walls thicker than the distance the
body travels in one tick.
- **Moving targets aren't swept against.** Two fast dynamic bodies, or a
fast body and a kinematic platform, can still pass through each other in a
single tick.

## Sweeping shapes yourself

The sweeps the system uses are public, for gameplay questions like "will
this projectile hit that wall before the end of the tick?":
`sweepCircleCircle`, `sweepCirclePolygon` and `sweepCircleTerrain`. Each
takes the moving circle's collider, the target body, and the start and end
positions of the move, and returns a `SweepHit` (the first contact's
`point`, the target's surface `normal` there, and `t`, how far along the
move it happened, from `0` to `1`) or `null`.

```ts
import { Vec2 } from '@forge-game-engine/forge/math';
import { sweepCirclePolygon } from '@forge-game-engine/forge/physics';

const hit = sweepCirclePolygon(
projectileCollider,
{ position: wallPosition, rotation: wallRotation, collider: wallCollider },
projectilePosition,
Vec2.add(Vec2.clone(projectilePosition), plannedMove),
);

if (hit !== null) {
// Impact after `hit.t` of the move, at `hit.point`.
}
```

A sweep that starts with the circle already touching the target (or, for a
terrain, already touching that stretch of ground) ignores that contact and
returns the next one, if any. Use `detectCollision` or `raycast` to ask about
contacts that already exist.

## Performance

The system only sweeps circles that move more than a tenth of their radius in
a tick, and only against static colliders whose bounding box overlaps the
circle's path. Bodies at rest or moving slowly cost one length check each.
7 changes: 7 additions & 0 deletions documentation-site/docs/docs/physics/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Core concepts:
- `createBroadPhaseEcsSystem`/`createNarrowPhaseEcsSystem`/
`createCollisionResolutionEcsSystem`: detect and resolve collisions
between collider entities each tick.
- `createContinuousCollisionEcsSystem`: stops fast dynamic circles from
sinking into or passing through static colliders between two ticks.
- `ContactsEcsComponent`: which entities a collider entity is touching,
and which contacts started or ended this tick.
- `raycast`: casts a ray against every
Expand All @@ -47,6 +49,9 @@ Guides in this section:
- [Applying Forces](./forces.md): gravity, impulses, torque, springs and
dampers, and explosions.
- [Raycasting](./raycasting.md): casting rays against colliders.
- [Continuous Collision Detection](./continuous-collision-detection.md):
keeping fast circles from sinking into or tunneling through static
colliders.
- [Prismatic Joints (Sliders)](./joints.md): constraining bodies to slide
along a single axis.
- [Revolute Joints (Hinges)](./revolute-joints.md): pinning bodies together
Expand Down Expand Up @@ -79,6 +84,7 @@ import {
ContactConstraint,
createBroadPhaseEcsSystem,
createCollisionResolutionEcsSystem,
createContinuousCollisionEcsSystem,
createEulerIntegrationEcsSystem,
createGravityEcsSystem,
createNarrowPhaseEcsSystem,
Expand Down Expand Up @@ -124,6 +130,7 @@ world.addSystem(
),
);
world.addSystem(createEulerIntegrationEcsSystem(time));
world.addSystem(createContinuousCollisionEcsSystem());
```

See [Bodies and Shapes](./rigid-bodies.md) for static and kinematic bodies,
Expand Down
5 changes: 4 additions & 1 deletion documentation-site/docs/docs/physics/rigid-bodies.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ import {
ContactConstraint,
createBroadPhaseEcsSystem,
createCollisionResolutionEcsSystem,
createContinuousCollisionEcsSystem,
createEulerIntegrationEcsSystem,
createGravityEcsSystem,
createNarrowPhaseEcsSystem,
Expand All @@ -151,7 +152,8 @@ const contactConstraints: ContactConstraint[] = [];
// Order matters: the transform system first, so every system below reads
// this tick's world transforms, then gravity/forces before collision
// resolution, before integration, so each tick's forces are reflected in
// that same tick's position update.
// that same tick's position update. Continuous collision detection checks
// integration's result, so it runs right after it.
world.addSystem(createTransformEcsSystem());
world.addSystem(createGravityEcsSystem(time));
world.addSystem(createBroadPhaseEcsSystem(collisionPairs));
Expand All @@ -164,6 +166,7 @@ world.addSystem(
),
);
world.addSystem(createEulerIntegrationEcsSystem(time));
world.addSystem(createContinuousCollisionEcsSystem());
```

Add joint (`createRevoluteJointEcsSystem`/`createPrismaticJointEcsSystem`)
Expand Down
5 changes: 5 additions & 0 deletions documentation-site/docs/docs/physics/terrain.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ a `PolygonCollider` for those. Raycasting is unaffected: `raycastTerrain`
tests the whole solid, so a ray can still enter the slab from any direction.
:::

A fast circle landing on terrain can still sink into it, or pass through it,
within a single tick, before narrow-phase collision sees the contact.
[Continuous Collision Detection](./continuous-collision-detection.md) stops
it at the surface.

### Choosing a point spacing

Point spacing trades detail against solver work. A body resting across _n_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ContactConstraint,
createBroadPhaseEcsSystem,
createCollisionResolutionEcsSystem,
createContinuousCollisionEcsSystem,
createEulerIntegrationEcsSystem,
createNarrowPhaseEcsSystem,
} from '@forge-game-engine/forge/physics';
Expand Down Expand Up @@ -175,6 +176,7 @@ export const createBrickBreakerGame = async (): Promise<Game> => {
);
world.addSystem(createBallEcsSystem(random, missY, brickField));
world.addSystem(createEulerIntegrationEcsSystem(time));
world.addSystem(createContinuousCollisionEcsSystem());

return game;
};
Loading
Loading