From abd71a52bb6da05991b6b3af267e42d394ba6b11 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:26:45 +0000 Subject: [PATCH] feat(physics): add continuous collision detection for fast circles Adds createContinuousCollisionEcsSystem, registered right after createEulerIntegrationEcsSystem. It sweeps each fast dynamic circle from where this tick's collision detection saw it (position.world) to where integration moved it (position.local) against static colliders, and moves position.local back to the first contact when the step would sink the circle more than a tenth of its radius into a surface. Adds the public sweepCircleCircle/sweepCirclePolygon/sweepCircleTerrain primitives and SweepHit, registers the system in every demo pipeline and the physics guides, and records the implementation's deviations from the design doc. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GPKzFGs2aYn7pN8vMzcQ6o --- CHANGELOG.md | 4 + demo/src/game.ts | 2 + design/continuous-collision-detection.md | 68 ++- .../docs/docs/common/transforms.md | 3 +- .../physics/continuous-collision-detection.md | 119 +++++ documentation-site/docs/docs/physics/index.md | 7 + .../docs/docs/physics/rigid-bodies.md | 5 +- .../docs/docs/physics/terrain.md | 5 + .../pages/demos/brick-breaker/_create-game.ts | 2 + .../src/pages/demos/car/_create-game.ts | 15 +- .../linear-spring-damper/_create-game.ts | 2 + .../demos/moving-platform/_create-game.ts | 2 + .../demos/newtons-cradle/_create-game.ts | 2 + .../src/pages/demos/physics/_create-game.ts | 2 + .../demos/prismatic-joint/_create-game.ts | 2 + .../demos/revolute-joint/_create-game.ts | 2 + .../pages/demos/rolling-ball/_create-game.ts | 2 + .../src/pages/demos/torque/_create-game.ts | 2 + .../pages/demos/wrecking-ball/_create-game.ts | 2 + src/physics/ccd/circle-sweep-hits.ts | 368 ++++++++++++++++ src/physics/ccd/index.ts | 3 + src/physics/ccd/sweep-circle-circle.test.ts | 72 +++ src/physics/ccd/sweep-circle-circle.ts | 34 ++ src/physics/ccd/sweep-circle-polygon.test.ts | 154 +++++++ src/physics/ccd/sweep-circle-polygon.ts | 34 ++ src/physics/ccd/sweep-circle-terrain.test.ts | 155 +++++++ src/physics/ccd/sweep-circle-terrain.ts | 39 ++ src/physics/index.ts | 1 + .../continuous-collision-system.test.ts | 411 ++++++++++++++++++ .../systems/continuous-collision-system.ts | 207 +++++++++ src/physics/systems/index.ts | 1 + src/physics/types/index.ts | 1 + src/physics/types/sweep-hit.ts | 24 + 33 files changed, 1744 insertions(+), 8 deletions(-) create mode 100644 documentation-site/docs/docs/physics/continuous-collision-detection.md create mode 100644 src/physics/ccd/circle-sweep-hits.ts create mode 100644 src/physics/ccd/index.ts create mode 100644 src/physics/ccd/sweep-circle-circle.test.ts create mode 100644 src/physics/ccd/sweep-circle-circle.ts create mode 100644 src/physics/ccd/sweep-circle-polygon.test.ts create mode 100644 src/physics/ccd/sweep-circle-polygon.ts create mode 100644 src/physics/ccd/sweep-circle-terrain.test.ts create mode 100644 src/physics/ccd/sweep-circle-terrain.ts create mode 100644 src/physics/systems/continuous-collision-system.test.ts create mode 100644 src/physics/systems/continuous-collision-system.ts create mode 100644 src/physics/types/sweep-hit.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2f7e2ce..325522b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +#### 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 + ## [0.25.8] - 2026-10-03 #### Fixed diff --git a/demo/src/game.ts b/demo/src/game.ts index 44f2999c2..0df84ff04 100644 --- a/demo/src/game.ts +++ b/demo/src/game.ts @@ -31,6 +31,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -352,6 +353,7 @@ world.addSystem( ), ); world.addSystem(createEulerIntegrationEcsSystem(time)); +world.addSystem(createContinuousCollisionEcsSystem()); world.addSystem( createDespawnFallenShapesEcsSystem( spritesByEntity, diff --git a/design/continuous-collision-detection.md b/design/continuous-collision-detection.md index 728f0900e..84fc96dd6 100644 --- a/design/continuous-collision-detection.md +++ b/design/continuous-collision-detection.md @@ -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 | @@ -218,3 +218,69 @@ 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 + engine has no sensors, so opting a body out could only let it tunnel, and + "force it on below the threshold" is by definition a case discrete + detection handles. Revisit when sensors exist. +- **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. diff --git a/documentation-site/docs/docs/common/transforms.md b/documentation-site/docs/docs/common/transforms.md index df6011147..95b520579 100644 --- a/documentation-site/docs/docs/common/transforms.md +++ b/documentation-site/docs/docs/common/transforms.md @@ -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 diff --git a/documentation-site/docs/docs/physics/continuous-collision-detection.md b/documentation-site/docs/docs/physics/continuous-collision-detection.md new file mode 100644 index 000000000..a942cb749 --- /dev/null +++ b/documentation-site/docs/docs/physics/continuous-collision-detection.md @@ -0,0 +1,119 @@ +--- +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`. + 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`). 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. diff --git a/documentation-site/docs/docs/physics/index.md b/documentation-site/docs/docs/physics/index.md index a1fc2e93b..bc7e98d1a 100644 --- a/documentation-site/docs/docs/physics/index.md +++ b/documentation-site/docs/docs/physics/index.md @@ -21,6 +21,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. - `raycast`: casts a ray against every collider entity in an `EcsWorld`. - `PrismaticJointEcsComponent`: a @@ -41,6 +43,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 @@ -74,6 +79,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -120,6 +126,7 @@ world.addSystem( ), ); world.addSystem(createEulerIntegrationEcsSystem(time)); +world.addSystem(createContinuousCollisionEcsSystem()); ``` See [Bodies and Shapes](./rigid-bodies.md) for static and kinematic bodies, diff --git a/documentation-site/docs/docs/physics/rigid-bodies.md b/documentation-site/docs/docs/physics/rigid-bodies.md index 030c0d6ef..efe358f9d 100644 --- a/documentation-site/docs/docs/physics/rigid-bodies.md +++ b/documentation-site/docs/docs/physics/rigid-bodies.md @@ -142,6 +142,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -154,7 +155,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)); @@ -167,6 +169,7 @@ world.addSystem( ), ); world.addSystem(createEulerIntegrationEcsSystem(time)); +world.addSystem(createContinuousCollisionEcsSystem()); ``` Add joint (`createRevoluteJointEcsSystem`/`createPrismaticJointEcsSystem`) diff --git a/documentation-site/docs/docs/physics/terrain.md b/documentation-site/docs/docs/physics/terrain.md index 6aa2f050c..fc5f22209 100644 --- a/documentation-site/docs/docs/physics/terrain.md +++ b/documentation-site/docs/docs/physics/terrain.md @@ -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_ 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..268d62cb8 100644 --- a/documentation-site/src/pages/demos/brick-breaker/_create-game.ts +++ b/documentation-site/src/pages/demos/brick-breaker/_create-game.ts @@ -11,6 +11,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createNarrowPhaseEcsSystem, } from '@forge-game-engine/forge/physics'; @@ -172,6 +173,7 @@ export const createBrickBreakerGame = async (): Promise => { createBallEcsSystem(collisionManifolds, random, missY, brickField), ); world.addSystem(createEulerIntegrationEcsSystem(time)); + world.addSystem(createContinuousCollisionEcsSystem()); return game; }; diff --git a/documentation-site/src/pages/demos/car/_create-game.ts b/documentation-site/src/pages/demos/car/_create-game.ts index cbebfba8f..3959a8311 100644 --- a/documentation-site/src/pages/demos/car/_create-game.ts +++ b/documentation-site/src/pages/demos/car/_create-game.ts @@ -14,6 +14,7 @@ import { createAngularVelocityMotorEcsSystem, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createLinearDamperEcsSystem, @@ -94,10 +95,10 @@ export const createCarGame = async (): Promise => { // world units are pixel-scale instead (`gravity` above is -600, roughly // 60x real-world `g`, and `wheelRadius` alone is 100 units), so a wheel // that lands hard after catching air off a hill can end up tens of units - // deep in the terrain in a single tick, and 3 units/second of correction - // then takes many seconds to dig it back out - long enough to read as the - // wheel being stuck clipped into the ground rather than momentarily - // compressed into it. Scaling the cap up by roughly the same ~60-100x + // deep in the terrain, and 3 units/second of correction then takes many + // seconds to dig it back out - long enough to read as the wheel being + // stuck clipped into the ground rather than momentarily compressed into + // it. Scaling the cap up by roughly the same ~60-100x // this course's units are bigger than Box2D's assumed meters (confirmed // empirically: 300 clears a hard landing within a fraction of a second, // matching how quickly the suspension itself settles, without changing @@ -120,7 +121,10 @@ export const createCarGame = async (): Promise => { // suspension's spring/damper forces run before collision resolution (like // gravity), and the prismatic/revolute joints that hard-constrain each // wheel mount run after it, so they get the "last word" on velocity each - // tick. `createCameraFollowEcsSystem` writes the camera's `local` position + // tick. `createContinuousCollisionEcsSystem` runs right after + // `createEulerIntegrationEcsSystem`, so it can stop a wheel that this + // tick's integration would sink deep into the terrain at the surface + // instead. `createCameraFollowEcsSystem` writes the camera's `local` position // after `createTransformEcsSystem` has run, so its smoothed camera // position is rendered from the next tick on. world.addSystem(createCarResetEcsSystem()); @@ -152,6 +156,7 @@ export const createCarGame = async (): Promise => { world.addSystem(createTerrainRenderEcsSystem(renderContext)); world.addSystem(createRenderEcsSystem(renderContext)); world.addSystem(createEulerIntegrationEcsSystem(time)); + world.addSystem(createContinuousCollisionEcsSystem()); return game; }; diff --git a/documentation-site/src/pages/demos/linear-spring-damper/_create-game.ts b/documentation-site/src/pages/demos/linear-spring-damper/_create-game.ts index cd9d025a2..fba47c1d8 100644 --- a/documentation-site/src/pages/demos/linear-spring-damper/_create-game.ts +++ b/documentation-site/src/pages/demos/linear-spring-damper/_create-game.ts @@ -6,6 +6,7 @@ import { import { createGame, Game } from '@forge-game-engine/forge/utilities'; import { createTransformEcsSystem } from '@forge-game-engine/forge/common'; import { + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createLinearDamperEcsSystem, @@ -46,6 +47,7 @@ export const createLinearSpringDamperGame = async (): Promise => { world.addSystem(createCameraEcsSystem(time)); world.addSystem(createRenderEcsSystem(renderContext)); world.addSystem(createEulerIntegrationEcsSystem(time)); + world.addSystem(createContinuousCollisionEcsSystem()); return game; }; diff --git a/documentation-site/src/pages/demos/moving-platform/_create-game.ts b/documentation-site/src/pages/demos/moving-platform/_create-game.ts index 3a7002197..87d7be54c 100644 --- a/documentation-site/src/pages/demos/moving-platform/_create-game.ts +++ b/documentation-site/src/pages/demos/moving-platform/_create-game.ts @@ -14,6 +14,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -101,6 +102,7 @@ export const createMovingPlatformGame = async (): Promise => { world.addSystem(createCameraEcsSystem(time)); world.addSystem(createRenderEcsSystem(renderContext)); world.addSystem(createEulerIntegrationEcsSystem(time)); + world.addSystem(createContinuousCollisionEcsSystem()); // Click anywhere to drop another crate at that position - the camera is // static at the world origin with a zoom of 1 (see `createCamera` above), diff --git a/documentation-site/src/pages/demos/newtons-cradle/_create-game.ts b/documentation-site/src/pages/demos/newtons-cradle/_create-game.ts index 44390de05..d2be05e2d 100644 --- a/documentation-site/src/pages/demos/newtons-cradle/_create-game.ts +++ b/documentation-site/src/pages/demos/newtons-cradle/_create-game.ts @@ -11,6 +11,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -67,6 +68,7 @@ export const createNewtonsCradleGame = async (): Promise => { world.addSystem(createCameraEcsSystem(time)); world.addSystem(createRenderEcsSystem(renderContext)); world.addSystem(createEulerIntegrationEcsSystem(time)); + world.addSystem(createContinuousCollisionEcsSystem()); return game; }; diff --git a/documentation-site/src/pages/demos/physics/_create-game.ts b/documentation-site/src/pages/demos/physics/_create-game.ts index 16e788575..66226a599 100644 --- a/documentation-site/src/pages/demos/physics/_create-game.ts +++ b/documentation-site/src/pages/demos/physics/_create-game.ts @@ -14,6 +14,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -72,6 +73,7 @@ export const createPhysicsGame = async (): Promise => { world.addSystem(createCameraEcsSystem(time)); world.addSystem(createRenderEcsSystem(renderContext)); world.addSystem(createEulerIntegrationEcsSystem(time)); + world.addSystem(createContinuousCollisionEcsSystem()); // The camera is static at the world origin with a zoom of 1 (see // `createCamera` above), so screen coordinates can be converted to world diff --git a/documentation-site/src/pages/demos/prismatic-joint/_create-game.ts b/documentation-site/src/pages/demos/prismatic-joint/_create-game.ts index 58ec7f230..093a71cf6 100644 --- a/documentation-site/src/pages/demos/prismatic-joint/_create-game.ts +++ b/documentation-site/src/pages/demos/prismatic-joint/_create-game.ts @@ -11,6 +11,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -64,6 +65,7 @@ export const createPrismaticJointGame = async (): Promise => { world.addSystem(createCameraEcsSystem(time)); world.addSystem(createRenderEcsSystem(renderContext)); world.addSystem(createEulerIntegrationEcsSystem(time)); + world.addSystem(createContinuousCollisionEcsSystem()); return game; }; diff --git a/documentation-site/src/pages/demos/revolute-joint/_create-game.ts b/documentation-site/src/pages/demos/revolute-joint/_create-game.ts index 43b96d8aa..0e165ddc4 100644 --- a/documentation-site/src/pages/demos/revolute-joint/_create-game.ts +++ b/documentation-site/src/pages/demos/revolute-joint/_create-game.ts @@ -11,6 +11,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -64,6 +65,7 @@ export const createRevoluteJointGame = async (): Promise => { world.addSystem(createCameraEcsSystem(time)); world.addSystem(createRenderEcsSystem(renderContext)); world.addSystem(createEulerIntegrationEcsSystem(time)); + world.addSystem(createContinuousCollisionEcsSystem()); return game; }; 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..61e4712e2 100644 --- a/documentation-site/src/pages/demos/rolling-ball/_create-game.ts +++ b/documentation-site/src/pages/demos/rolling-ball/_create-game.ts @@ -12,6 +12,7 @@ import { createAngularVelocityMotorEcsSystem, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -139,6 +140,7 @@ export const createRollingBallGame = async (): Promise => { world.addSystem(createTerrainRenderEcsSystem(renderContext)); world.addSystem(createRenderEcsSystem(renderContext)); world.addSystem(createEulerIntegrationEcsSystem(time)); + world.addSystem(createContinuousCollisionEcsSystem()); return game; }; diff --git a/documentation-site/src/pages/demos/torque/_create-game.ts b/documentation-site/src/pages/demos/torque/_create-game.ts index 70f9ab8bf..270c71e56 100644 --- a/documentation-site/src/pages/demos/torque/_create-game.ts +++ b/documentation-site/src/pages/demos/torque/_create-game.ts @@ -8,6 +8,7 @@ import { createGame, Game } from '@forge-game-engine/forge/utilities'; import { createTransformEcsSystem } from '@forge-game-engine/forge/common'; import { createAngularVelocityMotorEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, } from '@forge-game-engine/forge/physics'; import { @@ -86,6 +87,7 @@ export const createTorqueGame = async (): Promise => { world.addSystem(createCameraEcsSystem(time)); world.addSystem(createRenderEcsSystem(renderContext)); world.addSystem(createEulerIntegrationEcsSystem(time)); + world.addSystem(createContinuousCollisionEcsSystem()); return game; }; diff --git a/documentation-site/src/pages/demos/wrecking-ball/_create-game.ts b/documentation-site/src/pages/demos/wrecking-ball/_create-game.ts index 0669a3766..ca5221c55 100644 --- a/documentation-site/src/pages/demos/wrecking-ball/_create-game.ts +++ b/documentation-site/src/pages/demos/wrecking-ball/_create-game.ts @@ -11,6 +11,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -63,6 +64,7 @@ export const createWreckingBallGame = async (): Promise => { world.addSystem(createCameraEcsSystem(time)); world.addSystem(createRenderEcsSystem(renderContext)); world.addSystem(createEulerIntegrationEcsSystem(time)); + world.addSystem(createContinuousCollisionEcsSystem()); return game; }; diff --git a/src/physics/ccd/circle-sweep-hits.ts b/src/physics/ccd/circle-sweep-hits.ts new file mode 100644 index 000000000..4f3e54c6b --- /dev/null +++ b/src/physics/ccd/circle-sweep-hits.ts @@ -0,0 +1,368 @@ +import { Vec2, Vector2 } from '../../math/index.js'; +import { CircleCollider } from '../colliders/circle-collider.js'; +import { Collider } from '../colliders/collider.js'; +import { PolygonCollider } from '../colliders/polygon-collider.js'; +import { + TerrainCollider, + TerrainSurfaceEdge, +} from '../colliders/terrain-collider.js'; +import { CollisionBody } from '../types/collision-body.js'; +import { SweepHit } from '../types/sweep-hit.js'; + +/** + * Finds every point at which a circle moving in a straight line from + * `start` to `end` first touches one feature (a face or a vertex) of a + * target collider. Each feature contributes at most one hit. + */ +export type CircleSweepHitsFinder = ( + circle: CircleCollider, + targetBody: CollisionBody, + start: Vector2, + end: Vector2, +) => SweepHit[]; + +/** + * A circle's center moving in a straight line over a sweep: at `start` + * when `t` is `0`, and at `start + translation` when `t` is `1`. + */ +interface SweptCircle { + start: Vector2; + translation: Vector2; + radius: number; +} + +function createSweptCircle( + circle: CircleCollider, + start: Vector2, + end: Vector2, +): SweptCircle { + // Clone before adding: `start`/`end` are the caller's own positions. + const startCenter = Vec2.add(Vec2.clone(start), circle.offset); + const endCenter = Vec2.add(Vec2.clone(end), circle.offset); + + return { + start: startCenter, + translation: Vec2.subtract(endCenter, startCenter), + radius: circle.radius, + }; +} + +function centerAt(swept: SweptCircle, t: number): Vector2 { + return Vec2.add(Vec2.multiply(Vec2.clone(swept.translation), t), swept.start); +} + +/** + * Sweeps a circle against one face of a shape: the segment from `faceStart` + * to `faceEnd`, pushed out along its own outward `normal` by the circle's + * radius (one side of the shape's Minkowski sum with the circle). + * + * Only a circle approaching the face from in front of it counts. One that + * starts already touching or behind the face is left to narrow-phase + * collision, which handles every contact that already exists. + * @returns The hit, or `null` if the circle doesn't reach the face's span + * during the sweep. + */ +function sweepFace( + swept: SweptCircle, + faceStart: Vector2, + faceEnd: Vector2, + normal: Vector2, +): SweepHit | null { + const approach = Vec2.dot(swept.translation, normal); + + if (approach >= 0) { + return null; + } + + // Clone before subtracting: `swept.start` is reused for every feature. + const separation = + Vec2.dot(Vec2.subtract(Vec2.clone(swept.start), faceStart), normal) - + swept.radius; + + if (separation < 0) { + return null; + } + + const t = separation / -approach; + + if (t > 1) { + return null; + } + + const contactCenter = centerAt(swept, t); + // Clone before subtracting: `faceStart`/`faceEnd` are the shape's own + // vertices. + const face = Vec2.subtract(Vec2.clone(faceEnd), faceStart); + const projection = + Vec2.dot(Vec2.subtract(Vec2.clone(contactCenter), faceStart), face) / + Vec2.magnitudeSquared(face); + + if (projection < 0 || projection > 1) { + return null; + } + + return { + point: Vec2.subtract( + contactCenter, + Vec2.multiply(Vec2.clone(normal), swept.radius), + ), + normal: Vec2.clone(normal), + t, + }; +} + +/** + * Sweeps a circle against a round feature: a circle of `reach` around + * `vertex`, where `reach` is the moving circle's radius plus the feature's + * own (`0` for a polygon's corner, the target's radius for a circle). + * + * A polygon's corner only owns the part of that round where the circle + * touches the corner itself rather than one of the faces meeting there: + * the contact normal must point past the end of the face coming into the + * corner (`incoming`) and before the start of the face leaving it + * (`outgoing`). Testing the whole round would report hits from inside the + * shape's Minkowski sum. + * @returns The hit, or `null` if the circle doesn't reach the feature + * during the sweep, starts already touching it, or touches it outside the + * corner's own part. + */ +function sweepRound( + swept: SweptCircle, + vertex: Vector2, + reach: number, + incoming: Vector2 | null, + outgoing: Vector2 | null, +): SweepHit | null { + const a = Vec2.dot(swept.translation, swept.translation); + // Clone before subtracting: `swept.start` is reused for every feature. + const fromVertex = Vec2.subtract(Vec2.clone(swept.start), vertex); + const b = 2 * Vec2.dot(fromVertex, swept.translation); + const c = Vec2.dot(fromVertex, fromVertex) - reach * reach; + + if (a === 0 || c < 0 || b >= 0) { + return null; + } + + const discriminant = b * b - 4 * a * c; + + if (discriminant < 0) { + return null; + } + + const t = (-b - Math.sqrt(discriminant)) / (2 * a); + + if (t > 1) { + return null; + } + + const normal = Vec2.normalize(Vec2.subtract(centerAt(swept, t), vertex)); + + if (incoming !== null && Vec2.dot(normal, incoming) < 0) { + return null; + } + + if (outgoing !== null && Vec2.dot(normal, outgoing) > 0) { + return null; + } + + return { + point: Vec2.add( + Vec2.multiply(Vec2.clone(normal), reach - swept.radius), + vertex, + ), + normal, + t, + }; +} + +function pushHit(hits: SweepHit[], hit: SweepHit | null): void { + if (hit !== null) { + hits.push(hit); + } +} + +/** + * Finds where a moving circle first touches a circle-collider body. + */ +export const findCircleCircleSweepHits: CircleSweepHitsFinder = ( + circle, + targetBody, + start, + end, +) => { + const target = targetBody.collider as CircleCollider; + // Clone before adding: `targetBody.position` is the entity's live world + // position. + const targetCenter = Vec2.add(Vec2.clone(targetBody.position), target.offset); + const hits: SweepHit[] = []; + + pushHit( + hits, + sweepRound( + createSweptCircle(circle, start, end), + targetCenter, + circle.radius + target.radius, + null, + null, + ), + ); + + return hits; +}; + +/** + * Finds where a moving circle first touches each face and corner of a + * polygon-collider body. + */ +export const findCirclePolygonSweepHits: CircleSweepHitsFinder = ( + circle, + targetBody, + start, + end, +) => { + const polygon = targetBody.collider as PolygonCollider; + const vertices = polygon.getWorldVertices( + targetBody.position, + targetBody.rotation, + ); + const normals = polygon.getWorldNormals(targetBody.rotation); + const swept = createSweptCircle(circle, start, end); + const hits: SweepHit[] = []; + + for (let i = 0; i < vertices.length; i++) { + const previous = vertices[(i - 1 + vertices.length) % vertices.length]; + const vertex = vertices[i]; + const next = vertices[(i + 1) % vertices.length]; + + pushHit(hits, sweepFace(swept, vertex, next, normals[i])); + pushHit( + hits, + sweepRound( + swept, + vertex, + circle.radius, + Vec2.subtract(Vec2.clone(vertex), previous), + Vec2.subtract(Vec2.clone(next), vertex), + ), + ); + } + + return hits; +}; + +function edgeDirection(edge: TerrainSurfaceEdge): Vector2 { + // Clone before subtracting: `edge.end` is the collider's own stored point. + return Vec2.subtract(Vec2.clone(edge.end), edge.start); +} + +function toTerrainLocal(point: Vector2, terrainBody: CollisionBody): Vector2 { + // Clone before subtracting: `point` is the caller's own vector. + return Vec2.rotate( + Vec2.subtract(Vec2.clone(point), terrainBody.position), + -terrainBody.rotation, + ); +} + +/** + * Finds where a moving circle first touches each edge and corner of a + * terrain-collider body's surface chain. + * + * Like narrow-phase collision, the sweep only meets the terrain's surface + * (see {@link TerrainSurfaceEdge}), never the sides of the solid columns + * beneath it. A circle rolling quickly along the ground would otherwise + * catch on the top corner of every column it moves onto. Corners only + * exist where the surface bends away from the circle; where it bends + * toward the circle, the two edges' faces already meet. + */ +export const findCircleTerrainSweepHits: CircleSweepHitsFinder = ( + circle, + targetBody, + start, + end, +) => { + const terrain = targetBody.collider as TerrainCollider; + const { surface } = terrain; + const swept = createSweptCircle( + circle, + toTerrainLocal(start, targetBody), + toTerrainLocal(end, targetBody), + ); + const sweptEnd = centerAt(swept, 1); + const minX = Math.min(swept.start.x, sweptEnd.x) - circle.radius; + const maxX = Math.max(swept.start.x, sweptEnd.x) + circle.radius; + const hits: SweepHit[] = []; + + for (let i = 0; i < surface.length; i++) { + const edge = surface[i]; + + if (maxX < edge.start.x || minX > edge.end.x) { + continue; + } + + const direction = edgeDirection(edge); + const previous = i > 0 ? surface[i - 1] : null; + + pushHit(hits, sweepFace(swept, edge.start, edge.end, edge.normal)); + + // Each edge owns the corner at its start; the last edge also owns the + // corner at the chain's end. + if (previous === null || Vec2.dot(direction, previous.normal) < 0) { + pushHit( + hits, + sweepRound( + swept, + edge.start, + circle.radius, + previous === null ? null : edgeDirection(previous), + direction, + ), + ); + } + + if (i === surface.length - 1) { + pushHit( + hits, + sweepRound(swept, edge.end, circle.radius, direction, null), + ); + } + } + + // The hits are fresh vectors, so transforming them in place is safe. + return hits.map((hit) => ({ + point: Vec2.add( + Vec2.rotate(hit.point, targetBody.rotation), + targetBody.position, + ), + normal: Vec2.rotate(hit.normal, targetBody.rotation), + t: hit.t, + })); +}; + +/** + * The sweep-hit finder for a moving circle against each kind of collider. + */ +export const circleSweepHitFinders: Record< + Collider['type'], + CircleSweepHitsFinder +> = { + circle: findCircleCircleSweepHits, + polygon: findCirclePolygonSweepHits, + terrain: findCircleTerrainSweepHits, +}; + +/** + * Picks the hit the moving shape reaches first. + * @param hits - The hits to choose from. + * @returns The hit with the smallest `t`, or `null` if `hits` is empty. + */ +export function earliestSweepHit(hits: readonly SweepHit[]): SweepHit | null { + let earliest: SweepHit | null = null; + + for (const hit of hits) { + if (earliest === null || hit.t < earliest.t) { + earliest = hit; + } + } + + return earliest; +} diff --git a/src/physics/ccd/index.ts b/src/physics/ccd/index.ts new file mode 100644 index 000000000..311eec152 --- /dev/null +++ b/src/physics/ccd/index.ts @@ -0,0 +1,3 @@ +export * from './sweep-circle-circle.js'; +export * from './sweep-circle-polygon.js'; +export * from './sweep-circle-terrain.js'; diff --git a/src/physics/ccd/sweep-circle-circle.test.ts b/src/physics/ccd/sweep-circle-circle.test.ts new file mode 100644 index 000000000..63e65636f --- /dev/null +++ b/src/physics/ccd/sweep-circle-circle.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { CircleCollider } from '../colliders/circle-collider.js'; +import { CollisionBody } from '../types/collision-body.js'; +import { sweepCircleCircle } from './sweep-circle-circle.js'; + +describe('sweepCircleCircle', () => { + const circle = new CircleCollider(10); + const target: CollisionBody = { + position: { x: 0, y: 0 }, + rotation: 0, + collider: new CircleCollider(20), + }; + + it('should find where the circles first touch', () => { + const hit = sweepCircleCircle( + circle, + target, + { x: -100, y: 0 }, + { x: 0, y: 0 }, + ); + + expect(hit?.t).toBeCloseTo(0.7); + expect(hit?.normal.x).toBeCloseTo(-1); + expect(hit?.normal.y).toBeCloseTo(0); + expect(hit?.point.x).toBeCloseTo(-20); + expect(hit?.point.y).toBeCloseTo(0); + }); + + it('should find the near side of a circle the sweep would pass straight through', () => { + const hit = sweepCircleCircle( + circle, + target, + { x: -100, y: 0 }, + { x: 100, y: 0 }, + ); + + expect(hit?.t).toBeCloseTo(0.35); + }); + + it('should return null for a sweep that misses', () => { + expect( + sweepCircleCircle(circle, target, { x: -100, y: 31 }, { x: 100, y: 31 }), + ).toBeNull(); + }); + + it('should return null for a sweep that starts already overlapping', () => { + expect( + sweepCircleCircle(circle, target, { x: -25, y: 0 }, { x: 0, y: 0 }), + ).toBeNull(); + }); + + it('should return null for a sweep that stops short', () => { + expect( + sweepCircleCircle(circle, target, { x: -100, y: 0 }, { x: -31, y: 0 }), + ).toBeNull(); + }); + + it("should account for both circles' offsets", () => { + const offsetTarget = new CircleCollider(20); + + offsetTarget.offset = { x: 0, y: 50 }; + + const hit = sweepCircleCircle( + circle, + { position: { x: 0, y: -50 }, rotation: 0, collider: offsetTarget }, + { x: -100, y: 0 }, + { x: 0, y: 0 }, + ); + + expect(hit?.t).toBeCloseTo(0.7); + }); +}); diff --git a/src/physics/ccd/sweep-circle-circle.ts b/src/physics/ccd/sweep-circle-circle.ts new file mode 100644 index 000000000..9e445fd8f --- /dev/null +++ b/src/physics/ccd/sweep-circle-circle.ts @@ -0,0 +1,34 @@ +import { Vector2 } from '../../math/index.js'; +import { CircleCollider } from '../colliders/circle-collider.js'; +import { CollisionBody } from '../types/collision-body.js'; +import { SweepHit } from '../types/sweep-hit.js'; +import { + earliestSweepHit, + findCircleCircleSweepHits, +} from './circle-sweep-hits.js'; + +/** + * Sweeps a circle moving in a straight line from `start` to `end` against + * a circle-collider body, finding the first point at which they touch. + * + * A sweep that starts with the circle already touching a feature of the + * target ignores that feature: there is no time of impact to find, and + * narrow-phase collision already handles every contact that exists. + * @param circle - The moving circle's collider. Its center is `start`/`end` + * plus its `offset`, as in narrow-phase collision. + * @param targetBody - The body to sweep against, in its world pose. + * @param start - The moving body's world position at the start of the sweep. + * @param end - The moving body's world position at the end of the sweep. + * @returns The first contact, or `null` if the circle doesn't touch the + * target on the way from `start` to `end`. + */ +export function sweepCircleCircle( + circle: CircleCollider, + targetBody: CollisionBody, + start: Vector2, + end: Vector2, +): SweepHit | null { + return earliestSweepHit( + findCircleCircleSweepHits(circle, targetBody, start, end), + ); +} diff --git a/src/physics/ccd/sweep-circle-polygon.test.ts b/src/physics/ccd/sweep-circle-polygon.test.ts new file mode 100644 index 000000000..3893d5f21 --- /dev/null +++ b/src/physics/ccd/sweep-circle-polygon.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { CircleCollider } from '../colliders/circle-collider.js'; +import { PolygonCollider } from '../colliders/polygon-collider.js'; +import { CollisionBody } from '../types/collision-body.js'; +import { sweepCirclePolygon } from './sweep-circle-polygon.js'; + +/** + * A static 100x100 box centered on the origin. + */ +function boxBody(rotation: number = 0): CollisionBody { + return { + position: { x: 0, y: 0 }, + rotation, + collider: new PolygonCollider([ + { x: -50, y: -50 }, + { x: 50, y: -50 }, + { x: 50, y: 50 }, + { x: -50, y: 50 }, + ]), + }; +} + +describe('sweepCirclePolygon', () => { + const circle = new CircleCollider(10); + + it('should find where a circle first touches the face it moves into', () => { + const hit = sweepCirclePolygon( + circle, + boxBody(), + { x: -100, y: 0 }, + { x: 0, y: 0 }, + ); + + expect(hit).not.toBeNull(); + expect(hit?.t).toBeCloseTo(0.4); + expect(hit?.normal.x).toBeCloseTo(-1); + expect(hit?.normal.y).toBeCloseTo(0); + expect(hit?.point.x).toBeCloseTo(-50); + expect(hit?.point.y).toBeCloseTo(0); + }); + + it('should find the near face of a box the circle would pass straight through', () => { + const hit = sweepCirclePolygon( + circle, + boxBody(), + { x: -100, y: 0 }, + { x: 100, y: 0 }, + ); + + expect(hit?.t).toBeCloseTo(0.2); + expect(hit?.normal.x).toBeCloseTo(-1); + }); + + it('should return null for a sweep that misses the polygon', () => { + expect( + sweepCirclePolygon( + circle, + boxBody(), + { x: -100, y: 100 }, + { x: 100, y: 100 }, + ), + ).toBeNull(); + }); + + it('should return null for a sweep that starts already overlapping the polygon', () => { + expect( + sweepCirclePolygon(circle, boxBody(), { x: -55, y: 0 }, { x: 0, y: 0 }), + ).toBeNull(); + }); + + it('should hit at the very end of a sweep that exactly reaches the surface', () => { + const hit = sweepCirclePolygon( + circle, + boxBody(), + { x: -100, y: 0 }, + { x: -60, y: 0 }, + ); + + expect(hit?.t).toBeCloseTo(1); + }); + + it('should return null for a sweep that stops just short of the surface', () => { + expect( + sweepCirclePolygon( + circle, + boxBody(), + { x: -100, y: 0 }, + { x: -61, y: 0 }, + ), + ).toBeNull(); + }); + + it('should return null for a circle moving away from the polygon', () => { + expect( + sweepCirclePolygon( + circle, + boxBody(), + { x: -60.5, y: 0 }, + { x: -200, y: 0 }, + ), + ).toBeNull(); + }); + + it('should hit a corner with a normal pointing from the corner to the circle', () => { + // Moving along y = 55, the circle clips the box's (-50, 50) corner + // rather than either face meeting there. + const hit = sweepCirclePolygon( + circle, + boxBody(), + { x: -100, y: 55 }, + { x: 0, y: 55 }, + ); + + const touchX = -50 - Math.sqrt(75); + + expect(hit?.t).toBeCloseTo((touchX + 100) / 100); + expect(hit?.point.x).toBeCloseTo(-50); + expect(hit?.point.y).toBeCloseTo(50); + expect(hit?.normal.x).toBeCloseTo((touchX + 50) / 10); + expect(hit?.normal.y).toBeCloseTo(0.5); + }); + + it("should sweep against the polygon's rotated world pose", () => { + // Rotated 45 degrees, the box's corner points straight at the circle. + const cornerX = -50 * Math.SQRT2; + const hit = sweepCirclePolygon( + circle, + boxBody(Math.PI / 4), + { x: -200, y: 0 }, + { x: 0, y: 0 }, + ); + + expect(hit?.t).toBeCloseTo((cornerX - 10 + 200) / 200); + expect(hit?.point.x).toBeCloseTo(cornerX); + expect(hit?.point.y).toBeCloseTo(0); + expect(hit?.normal.x).toBeCloseTo(-1); + }); + + it("should sweep the circle's center, including its offset", () => { + const offsetCircle = new CircleCollider(10); + + offsetCircle.offset = { x: 0, y: 5 }; + + const hit = sweepCirclePolygon( + offsetCircle, + boxBody(), + { x: -100, y: -5 }, + { x: 0, y: -5 }, + ); + + expect(hit?.t).toBeCloseTo(0.4); + expect(hit?.point.y).toBeCloseTo(0); + }); +}); diff --git a/src/physics/ccd/sweep-circle-polygon.ts b/src/physics/ccd/sweep-circle-polygon.ts new file mode 100644 index 000000000..786818910 --- /dev/null +++ b/src/physics/ccd/sweep-circle-polygon.ts @@ -0,0 +1,34 @@ +import { Vector2 } from '../../math/index.js'; +import { CircleCollider } from '../colliders/circle-collider.js'; +import { CollisionBody } from '../types/collision-body.js'; +import { SweepHit } from '../types/sweep-hit.js'; +import { + earliestSweepHit, + findCirclePolygonSweepHits, +} from './circle-sweep-hits.js'; + +/** + * Sweeps a circle moving in a straight line from `start` to `end` against + * a polygon-collider body, finding the first point at which they touch. + * + * A sweep that starts with the circle already touching a feature of the + * target ignores that feature: there is no time of impact to find, and + * narrow-phase collision already handles every contact that exists. + * @param circle - The moving circle's collider. Its center is `start`/`end` + * plus its `offset`, as in narrow-phase collision. + * @param targetBody - The body to sweep against, in its world pose. + * @param start - The moving body's world position at the start of the sweep. + * @param end - The moving body's world position at the end of the sweep. + * @returns The first contact, or `null` if the circle doesn't touch the + * target on the way from `start` to `end`. + */ +export function sweepCirclePolygon( + circle: CircleCollider, + targetBody: CollisionBody, + start: Vector2, + end: Vector2, +): SweepHit | null { + return earliestSweepHit( + findCirclePolygonSweepHits(circle, targetBody, start, end), + ); +} diff --git a/src/physics/ccd/sweep-circle-terrain.test.ts b/src/physics/ccd/sweep-circle-terrain.test.ts new file mode 100644 index 000000000..efd4dbd75 --- /dev/null +++ b/src/physics/ccd/sweep-circle-terrain.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import { Vector2 } from '../../math/index.js'; +import { CircleCollider } from '../colliders/circle-collider.js'; +import { TerrainCollider } from '../colliders/terrain-collider.js'; +import { CollisionBody } from '../types/collision-body.js'; +import { sweepCircleTerrain } from './sweep-circle-terrain.js'; + +/** + * Terrain bodies here are unrotated unless stated otherwise, so their solid + * slab extends toward +y and their surface faces -y. + */ +function terrainBody(points: Vector2[], rotation: number = 0): CollisionBody { + return { + position: { x: 0, y: 0 }, + rotation, + collider: new TerrainCollider(points, 100), + }; +} + +const flatGround = [ + { x: -500, y: 0 }, + { x: 0, y: 0 }, + { x: 500, y: 0 }, +]; + +describe('sweepCircleTerrain', () => { + const circle = new CircleCollider(10); + + it('should find where a falling circle first touches the surface', () => { + const hit = sweepCircleTerrain( + circle, + terrainBody(flatGround), + { x: 100, y: -100 }, + { x: 100, y: 100 }, + ); + + expect(hit?.t).toBeCloseTo(0.45); + expect(hit?.normal.x).toBeCloseTo(0); + expect(hit?.normal.y).toBeCloseTo(-1); + expect(hit?.point.x).toBeCloseTo(100); + expect(hit?.point.y).toBeCloseTo(0); + }); + + it("should sweep against the terrain's rotated world pose", () => { + // Rotated half a turn, the surface faces +y, as in a world where + // gravity pulls toward -y. + const hit = sweepCircleTerrain( + circle, + terrainBody(flatGround, Math.PI), + { x: 100, y: 100 }, + { x: 100, y: -100 }, + ); + + expect(hit?.t).toBeCloseTo(0.45); + expect(hit?.normal.x).toBeCloseTo(0); + expect(hit?.normal.y).toBeCloseTo(1); + expect(hit?.point.y).toBeCloseTo(0); + }); + + it('should return null for a sweep that stays above the surface', () => { + expect( + sweepCircleTerrain( + circle, + terrainBody(flatGround), + { x: -100, y: -50 }, + { x: 100, y: -50 }, + ), + ).toBeNull(); + }); + + it('should ignore the edge a circle is resting on but find a wall it runs into', () => { + const steppedGround = [ + { x: -500, y: 0 }, + { x: 0, y: 0 }, + { x: 1, y: -200 }, + { x: 500, y: -200 }, + ]; + + // Resting 0.5 units into the flat edge, rolling right into the wall. + const hit = sweepCircleTerrain( + circle, + terrainBody(steppedGround), + { x: -100, y: -9.5 }, + { x: 100, y: -9.5 }, + ); + + expect(hit?.t).toBeCloseTo(0.45, 2); + expect(hit?.normal.x).toBeCloseTo(-1, 3); + }); + + it('should not catch on the corner of a crest the circle rolls over', () => { + const crest = [ + { x: -500, y: 0 }, + { x: 0, y: 0 }, + { x: 500, y: 100 }, + ]; + + expect( + sweepCircleTerrain( + circle, + terrainBody(crest), + { x: -100, y: -9.5 }, + { x: 100, y: -9.5 }, + ), + ).toBeNull(); + }); + + it('should not catch on the joints between nearly flat edges', () => { + const ripples: Vector2[] = []; + + for (let i = -20; i <= 20; i++) { + ripples.push({ x: i * 25, y: i % 2 === 0 ? 0 : 0.01 }); + } + + expect( + sweepCircleTerrain( + circle, + terrainBody(ripples), + { x: -200, y: -9.5 }, + { x: 200, y: -9.5 }, + ), + ).toBeNull(); + }); + + it('should hit the corner of a crest the circle drops onto', () => { + const crest = [ + { x: -500, y: 100 }, + { x: 0, y: 0 }, + { x: 500, y: 100 }, + ]; + + const hit = sweepCircleTerrain( + circle, + terrainBody(crest), + { x: 0, y: -100 }, + { x: 0, y: 100 }, + ); + + expect(hit?.t).toBeCloseTo(0.45); + expect(hit?.point.x).toBeCloseTo(0); + expect(hit?.point.y).toBeCloseTo(0); + expect(hit?.normal.y).toBeCloseTo(-1); + }); + + it('should return null for a sweep that starts below the surface', () => { + expect( + sweepCircleTerrain( + circle, + terrainBody(flatGround), + { x: 100, y: 20 }, + { x: 100, y: 80 }, + ), + ).toBeNull(); + }); +}); diff --git a/src/physics/ccd/sweep-circle-terrain.ts b/src/physics/ccd/sweep-circle-terrain.ts new file mode 100644 index 000000000..1fb27d9ae --- /dev/null +++ b/src/physics/ccd/sweep-circle-terrain.ts @@ -0,0 +1,39 @@ +import { Vector2 } from '../../math/index.js'; +import { CircleCollider } from '../colliders/circle-collider.js'; +import { CollisionBody } from '../types/collision-body.js'; +import { SweepHit } from '../types/sweep-hit.js'; +import { + earliestSweepHit, + findCircleTerrainSweepHits, +} from './circle-sweep-hits.js'; + +/** + * Sweeps a circle moving in a straight line from `start` to `end` against + * a terrain-collider body, finding the first point at which they touch. + * + * Like narrow-phase collision, the sweep only meets the terrain's surface + * chain (see {@link TerrainSurfaceEdge}), and tests each of its edges on + * its own: a circle already resting on one edge still finds the first edge + * it would run into. + * + * A sweep that starts with the circle already touching a feature of the + * target ignores that feature: there is no time of impact to find, and + * narrow-phase collision already handles every contact that exists. + * @param circle - The moving circle's collider. Its center is `start`/`end` + * plus its `offset`, as in narrow-phase collision. + * @param targetBody - The body to sweep against, in its world pose. + * @param start - The moving body's world position at the start of the sweep. + * @param end - The moving body's world position at the end of the sweep. + * @returns The first contact, or `null` if the circle doesn't touch the + * target on the way from `start` to `end`. + */ +export function sweepCircleTerrain( + circle: CircleCollider, + targetBody: CollisionBody, + start: Vector2, + end: Vector2, +): SweepHit | null { + return earliestSweepHit( + findCircleTerrainSweepHits(circle, targetBody, start, end), + ); +} diff --git a/src/physics/index.ts b/src/physics/index.ts index 103af1765..d3da41f81 100644 --- a/src/physics/index.ts +++ b/src/physics/index.ts @@ -1,6 +1,7 @@ export * from './apply-explosive-force.js'; export * from './apply-impluse.js'; export * from './apply-torque.js'; +export * from './ccd/index.js'; export * from './colliders/index.js'; export * from './collision/index.js'; export * from './components/index.js'; diff --git a/src/physics/systems/continuous-collision-system.test.ts b/src/physics/systems/continuous-collision-system.test.ts new file mode 100644 index 000000000..e1e0613cf --- /dev/null +++ b/src/physics/systems/continuous-collision-system.test.ts @@ -0,0 +1,411 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + addPositionComponent, + addRotationComponent, + createTransformEcsSystem, + PositionEcsComponent, + Time, +} from '../../common/index.js'; +import { EcsWorld } from '../../ecs/index.js'; +import { Random, Vector2 } from '../../math/index.js'; +import { CircleCollider } from '../colliders/circle-collider.js'; +import { Collider } from '../colliders/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 { + addRigidBodyComponent, + RigidBodyEcsComponent, + RigidBodyType, +} 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 { createContinuousCollisionEcsSystem } from './continuous-collision-system.js'; +import { createEulerIntegrationEcsSystem } from './euler-integration-system.js'; +import { createGravityEcsSystem } from './gravity-system.js'; +import { createNarrowPhaseEcsSystem } from './narrow-phase-system.js'; + +const fixedDeltaMilliseconds = 1000 / 60; + +/** + * Terrain is rotated half a turn throughout this file, so its surface faces + * +y, the way a world with gravity pulling toward -y uses it (see + * documentation-site/docs/docs/physics/terrain.md). + */ +const terrainRotation = Math.PI; + +interface Body { + entity: number; + position: PositionEcsComponent; + rigidBody: RigidBodyEcsComponent; +} + +function addStaticCollider( + world: EcsWorld, + collider: Collider, + position: Vector2, + rotation: number = 0, +): number { + const entity = world.createEntity(); + + addPositionComponent(world, entity, { local: position }); + addRotationComponent(world, entity, { local: rotation }); + addColliderComponent(world, entity, { collider, restitution: 0 }); + addAabbComponent(world, entity); + + return entity; +} + +function addBody( + world: EcsWorld, + collider: Collider, + position: Vector2, + velocity: Vector2, + type: RigidBodyType = 'dynamic', +): Body { + const entity = world.createEntity(); + const positionComponent = addPositionComponent(world, entity, { + local: position, + }); + + addRotationComponent(world, entity); + addColliderComponent(world, entity, { collider, restitution: 0 }); + addAabbComponent(world, entity); + + const rigidBody = addRigidBodyComponent(world, entity, { + mass: collider.mass, + momentOfInertia: collider.momentOfInertia, + velocity, + type, + }); + + return { entity, position: positionComponent, rigidBody }; +} + +function box(halfSize: number): PolygonCollider { + return new PolygonCollider([ + { x: -halfSize, y: -halfSize }, + { x: halfSize, y: -halfSize }, + { x: halfSize, y: halfSize }, + { x: -halfSize, y: halfSize }, + ]); +} + +function flatTerrain(): TerrainCollider { + return new TerrainCollider( + [ + { x: -1000, y: 0 }, + { x: 1000, y: 0 }, + ], + 100, + ); +} + +describe('createContinuousCollisionEcsSystem', () => { + let world: EcsWorld; + let time: Time; + + beforeEach(() => { + world = new EcsWorld(); + time = new Time(); + time.update(0); + + world.addSystem(createTransformEcsSystem()); + world.addSystem(createBroadPhaseEcsSystem([])); + world.addSystem(createEulerIntegrationEcsSystem(time)); + world.addSystem(createContinuousCollisionEcsSystem()); + }); + + function tick(): void { + time.update(time.rawTimeInMilliseconds + fixedDeltaMilliseconds); + world.update(); + } + + it('should stop a fast circle just inside the surface it would pass through', () => { + addStaticCollider(world, flatTerrain(), { x: 0, y: 0 }, terrainRotation); + + // 6000 units/second is 100 units this tick: far past the surface. + const ball = addBody( + world, + new CircleCollider(10), + { x: 0, y: 30 }, + { x: 0, y: -6000 }, + ); + + tick(); + + // Touching at y = 10, then left 1% of the radius inside. + expect(ball.position.local.y).toBeCloseTo(9.9); + expect(ball.position.local.x).toBeCloseTo(0); + expect(ball.rigidBody.velocity.y).toBe(-6000); + }); + + it('should stop at the first of several colliders in the way', () => { + addStaticCollider(world, box(10), { x: 0, y: -60 }); + addStaticCollider(world, box(10), { x: 0, y: -20 }); + + const ball = addBody( + world, + new CircleCollider(5), + { x: 0, y: 30 }, + { x: 0, y: -6000 }, + ); + + tick(); + + expect(ball.position.local.y).toBeCloseTo(-10 + 5 - 0.05); + }); + + it('should sweep against a static rigid body like a collider with none', () => { + const entity = addStaticCollider(world, box(10), { x: 0, y: -20 }); + + addRigidBodyComponent(world, entity, { + mass: 1, + momentOfInertia: 1, + type: 'static', + }); + + const ball = addBody( + world, + new CircleCollider(5), + { x: 0, y: 30 }, + { x: 0, y: -6000 }, + ); + + tick(); + + expect(ball.position.local.y).toBeCloseTo(-10 + 5 - 0.05); + }); + + it('should not sweep against kinematic or dynamic bodies', () => { + addBody(world, box(10), { x: 0, y: -20 }, { x: 0, y: 0 }, 'kinematic'); + addBody(world, box(10), { x: 0, y: -60 }, { x: 0, y: 0 }); + + const ball = addBody( + world, + new CircleCollider(5), + { x: 0, y: 30 }, + { x: 0, y: -6000 }, + ); + + tick(); + + expect(ball.position.local.y).toBeCloseTo(-70); + }); + + it('should not move kinematic circles or polygon bodies', () => { + addStaticCollider(world, flatTerrain(), { x: 0, y: 0 }, terrainRotation); + + const kinematicBall = addBody( + world, + new CircleCollider(10), + { x: 0, y: 30 }, + { x: 0, y: -6000 }, + 'kinematic', + ); + const crate = addBody( + world, + box(10), + { x: 100, y: 30 }, + { x: 0, y: -6000 }, + ); + + tick(); + + expect(kinematicBall.position.local.y).toBeCloseTo(-70); + expect(crate.position.local.y).toBeCloseTo(-70); + }); + + it('should leave a shallow landing to the discrete pipeline', () => { + addStaticCollider(world, flatTerrain(), { x: 0, y: 0 }, terrainRotation); + + // 1.4 units this tick, ending 0.9 units (under a tenth of the radius) + // into the surface. + const ball = addBody( + world, + new CircleCollider(10), + { x: 0, y: 10.5 }, + { x: 0, y: -84 }, + ); + + tick(); + + expect(ball.position.local.y).toBeCloseTo(9.1); + }); + + it('should not slow a fast circle rolling along the surface', () => { + addStaticCollider(world, flatTerrain(), { x: 0, y: 0 }, terrainRotation); + + const ball = addBody( + world, + new CircleCollider(10), + { x: 0, y: 9.5 }, + { x: 6000, y: 0 }, + ); + + tick(); + + expect(ball.position.local.x).toBeCloseTo(100); + expect(ball.position.local.y).toBeCloseTo(9.5); + }); +}); + +describe('continuous collision in the full physics pipeline', () => { + /** + * The Car demo's scale: a wheel 100 units across, gravity of 600 + * units/second², and the demo's `maxBiasSpeed`. + */ + const wheelRadius = 100; + + /** + * Rolling hills with a point every 50 units, like the Car demo's course. + */ + function hillPoints(): Vector2[] { + const random = new Random('continuous-collision'); + const points: Vector2[] = []; + + for (let x = -6000; x <= 6000; x += 50) { + points.push({ + x, + y: 80 * Math.sin(x / 400) + random.randomFloat(-2, 2), + }); + } + + return points; + } + + interface Simulation { + wheel: Body; + collisionManifolds: CollisionManifold[]; + tick: () => void; + } + + function createSimulation( + withContinuousCollision: boolean, + wheelPosition: Vector2, + wheelVelocity: Vector2, + ): Simulation { + const world = new EcsWorld(); + const time = new Time(); + const collisionPairs: CollisionPair[] = []; + const collisionManifolds: CollisionManifold[] = []; + const contactConstraints: ContactConstraint[] = []; + + time.update(0); + + world.addSystem(createTransformEcsSystem()); + world.addSystem(createGravityEcsSystem(time)); + world.addSystem(createBroadPhaseEcsSystem(collisionPairs)); + world.addSystem( + createNarrowPhaseEcsSystem(collisionPairs, collisionManifolds), + ); + world.addSystem( + createCollisionResolutionEcsSystem( + collisionManifolds, + contactConstraints, + time, + { maxBiasSpeed: 300 }, + ), + ); + world.addSystem(createEulerIntegrationEcsSystem(time)); + + if (withContinuousCollision) { + world.addSystem(createContinuousCollisionEcsSystem()); + } + + addStaticCollider( + world, + new TerrainCollider(hillPoints(), 500), + { x: 0, y: 0 }, + terrainRotation, + ); + + const wheel = addBody( + world, + new CircleCollider(wheelRadius), + wheelPosition, + wheelVelocity, + ); + + addGravityComponent(world, wheel.entity, { amount: { x: 0, y: -600 } }); + + return { + wheel, + collisionManifolds, + tick: () => { + time.update(time.rawTimeInMilliseconds + fixedDeltaMilliseconds); + world.update(); + }, + }; + } + + function deepestPenetration(simulation: Simulation, ticks: number): number { + let deepest = 0; + + for (let i = 0; i < ticks; i++) { + simulation.tick(); + + for (const manifold of simulation.collisionManifolds) { + deepest = Math.max(deepest, manifold.depth); + } + } + + return deepest; + } + + it('should keep a wheel landing hard on terrain from sinking deep into it', () => { + // A wheel coming down at about 1500 units/second (25 units a tick) + // from a jump, the landing measured sinking 25-30 units into the Car + // demo's terrain. How deep a single landing sinks depends on how close + // to the surface the tick before contact leaves it, so drop it from + // heights spread across one tick's fall and take the deepest. + const deepestLanding = (withContinuousCollision: boolean): number => { + let deepest = 0; + + for (let drop = 0; drop < 25; drop += 2.5) { + deepest = Math.max( + deepest, + deepestPenetration( + createSimulation( + withContinuousCollision, + { x: 0, y: 400 + drop }, + { x: 900, y: -1500 }, + ), + 60, + ), + ); + } + + return deepest; + }; + + expect(deepestLanding(false)).toBeGreaterThan(0.2 * wheelRadius); + expect(deepestLanding(true)).toBeLessThanOrEqual(0.1 * wheelRadius); + }); + + it('should not slow a fast wheel rolling over bumpy terrain', () => { + const distanceRolled = (withContinuousCollision: boolean): number => { + const simulation = createSimulation( + withContinuousCollision, + { x: 0, y: 200 }, + { x: 1500, y: 0 }, + ); + + for (let i = 0; i < 180; i++) { + simulation.tick(); + } + + return simulation.wheel.position.local.x; + }; + + const withoutSweep = distanceRolled(false); + + expect(withoutSweep).toBeGreaterThan(2000); + expect(distanceRolled(true)).toBeCloseTo(withoutSweep, -1); + }); +}); diff --git a/src/physics/systems/continuous-collision-system.ts b/src/physics/systems/continuous-collision-system.ts new file mode 100644 index 000000000..8638d8025 --- /dev/null +++ b/src/physics/systems/continuous-collision-system.ts @@ -0,0 +1,207 @@ +import { + PositionEcsComponent, + positionId, + RotationEcsComponent, + rotationId, +} from '../../common/index.js'; +import { EcsSystem } from '../../ecs/ecs-system.js'; +import { Vec2, Vector2 } from '../../math/index.js'; +import { circleSweepHitFinders } from '../ccd/circle-sweep-hits.js'; +import { getColliderRotation } from '../collider-rotation.js'; +import { CircleCollider } from '../colliders/circle-collider.js'; +import { aabbsOverlap } from '../collision/aabb-overlap.js'; +import { AabbEcsComponent, aabbId } from '../components/aabb-component.js'; +import { + ColliderEcsComponent, + colliderId, +} from '../components/collider-component.js'; +import { + RigidBodyEcsComponent, + rigidBodyId, +} from '../components/rigidbody-component.js'; +import { Aabb } from '../types/aabb.js'; +import { CollisionBody } from '../types/collision-body.js'; + +/** + * How deep, as a fraction of its radius, a circle may sink into a static + * collider in one tick before the sweep steps in. Shallower penetrations + * are left to the discrete pipeline, whose next collision-resolution pass + * pushes them back out within a tick or two. Stepping in for those as well + * would cut short the tick's travel every time a fast body rolls over a + * slight bend in the ground, which shows as stutter. + */ +const maxDiscretePenetrationRatio = 0.1; + +/** + * How deep, as a fraction of its radius, a swept circle is left inside the + * surface it hit. Leaving it slightly inside, rather than exactly touching, + * gives the next tick's narrow phase an overlap to report, so the + * collision resolution system can stop it; a body left just short of the + * surface would be swept and stopped again every tick without ever + * reaching it. + */ +const contactDepthRatio = 0.01; + +/** + * Creates an ECS system that stops fast dynamic circles from passing into + * or through static colliders between two ticks (continuous collision + * detection). + * + * Narrow-phase collision only tests where each body is at the start of a + * tick, so a body that integration then moves further than the gap to a + * surface ends up deep inside it, or out the other side, before the next + * tick's test runs. This system sweeps each dynamic body with a + * {@link CircleCollider} from where this tick's collision detection saw it + * (`position.world`) to where integration just moved it (`position.local`) + * against every static collider (an entity with no + * `RigidBodyEcsComponent`, or a `'static'` one) its path overlaps. If it + * would sink more than a tenth of its radius into one, the system moves + * `position.local` back to where the circle first touched that collider, + * leaving it just inside the surface. Velocity is left alone: the next + * tick's narrow phase reports that contact and collision resolution + * responds to it (friction, restitution) as it does for any other. + * + * Register it directly after `createEulerIntegrationEcsSystem`, whose + * output it checks. A body moved directly (a teleport) must be moved + * before `createTransformEcsSystem` runs, or the sweep treats the jump as + * motion and can stop it at a wall in between. A swept body keeps its full + * velocity but covers less ground this tick, the same trade Box2D makes. + * Polygon bodies and kinematic or dynamic targets aren't swept. + * @returns The ECS system. + */ +export const createContinuousCollisionEcsSystem = (): EcsSystem< + [PositionEcsComponent, ColliderEcsComponent, AabbEcsComponent] +> => ({ + query: [positionId, colliderId, aabbId], + update: (world, { entities, components: [positions, colliders, aabbs] }) => { + // Rigid body and rotation are both optional for colliders (a collider + // with no rigid body is static), so neither can be part of the query. + const getRigidBody = + world.getComponentAccessor(rigidBodyId); + const getRotation = + world.getComponentAccessor(rotationId); + + const targets: SweepTarget[] = []; + + for (let i = 0; i < entities.length; i++) { + const rigidBody = getRigidBody(entities[i]); + + if (rigidBody === null || rigidBody.type === 'static') { + targets.push({ + aabb: aabbs[i], + body: { + position: positions[i].world, + rotation: getColliderRotation(getRotation(entities[i])), + collider: colliders[i].collider, + }, + }); + } + } + + for (let i = 0; i < entities.length; i++) { + const { collider } = colliders[i]; + + if ( + getRigidBody(entities[i])?.type !== 'dynamic' || + !(collider instanceof CircleCollider) + ) { + continue; + } + + const start = positions[i].world; + const end = positions[i].local; + const stopT = findStopT(collider, start, end, targets); + + if (stopT < 1) { + end.x = start.x + (end.x - start.x) * stopT; + end.y = start.y + (end.y - start.y) * stopT; + } + } + }, +}); + +/** + * A static collider a swept circle may hit. + */ +interface SweepTarget { + aabb: AabbEcsComponent; + body: CollisionBody; +} + +/** + * Finds how far along this tick's motion, from `0` to `1`, a circle should + * stop so that it ends up just inside the first static collider it would + * otherwise sink deeply into. + * @param collider - The moving circle. + * @param start - Where the circle's body was at the start of the tick. + * @param end - Where integration moved the circle's body this tick. + * @param targets - Every static collider in the world. + * @returns The fraction of the motion to keep; `1` keeps all of it. + */ +function findStopT( + collider: CircleCollider, + start: Vector2, + end: Vector2, + targets: readonly SweepTarget[], +): number { + // Clone before subtracting: `end` is the entity's live local position. + const translation = Vec2.subtract(Vec2.clone(end), start); + const maxDiscretePenetration = maxDiscretePenetrationRatio * collider.radius; + + if (Vec2.magnitude(translation) <= maxDiscretePenetration) { + return 1; + } + + const sweptAabb = computeSweptAabb(collider, start, end); + let stopT = 1; + + for (const target of targets) { + if (!aabbsOverlap(sweptAabb, target.aabb)) { + continue; + } + + const hits = circleSweepHitFinders[target.body.collider.type]( + collider, + target.body, + start, + end, + ); + + for (const hit of hits) { + const approach = -Vec2.dot(translation, hit.normal); + + // A graze, or a surface the circle wouldn't sink deeply into by the + // end of the tick, is left to the discrete pipeline. + if (approach * (1 - hit.t) <= maxDiscretePenetration) { + continue; + } + + stopT = Math.min( + stopT, + hit.t + (contactDepthRatio * collider.radius) / approach, + ); + } + } + + return stopT; +} + +function computeSweptAabb( + collider: CircleCollider, + start: Vector2, + end: Vector2, +): Aabb { + const startAabb = collider.computeAabb(start); + const endAabb = collider.computeAabb(end); + + return { + min: { + x: Math.min(startAabb.min.x, endAabb.min.x), + y: Math.min(startAabb.min.y, endAabb.min.y), + }, + max: { + x: Math.max(startAabb.max.x, endAabb.max.x), + y: Math.max(startAabb.max.y, endAabb.max.y), + }, + }; +} diff --git a/src/physics/systems/index.ts b/src/physics/systems/index.ts index 800d1b4c3..1c1f0950b 100644 --- a/src/physics/systems/index.ts +++ b/src/physics/systems/index.ts @@ -1,6 +1,7 @@ export * from './angular-velocity-motor-system.js'; export * from './broad-phase-system.js'; export * from './collision-resolution-system.js'; +export * from './continuous-collision-system.js'; export * from './euler-integration-system.js'; export * from './gravity-system.js'; export * from './linear-damper-system.js'; diff --git a/src/physics/types/index.ts b/src/physics/types/index.ts index ffb5bb5d5..9be055289 100644 --- a/src/physics/types/index.ts +++ b/src/physics/types/index.ts @@ -4,3 +4,4 @@ export * from './collision-manifold.js'; export * from './collision-pair.js'; export * from './contact-constraint.js'; export * from './raycast-hit.js'; +export * from './sweep-hit.js'; diff --git a/src/physics/types/sweep-hit.ts b/src/physics/types/sweep-hit.ts new file mode 100644 index 000000000..9f2f44072 --- /dev/null +++ b/src/physics/types/sweep-hit.ts @@ -0,0 +1,24 @@ +import { Vector2 } from '../../math/index.js'; + +/** + * The first contact between a moving shape and a target collider, as found + * by a sweep (see {@link sweepCirclePolygon}). + */ +export interface SweepHit { + /** + * The world-space point on the target's surface the moving shape first + * touches. + */ + point: Vector2; + + /** + * The target's outward-facing surface normal at `point`, in world space. + */ + normal: Vector2; + + /** + * How far along the sweep, from `0` (its start) to `1` (its end), the + * moving shape first touches the target. + */ + t: number; +}