diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9062cf..80243f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/demo/src/game.ts b/demo/src/game.ts index d2300790..aee6d3d9 100644 --- a/demo/src/game.ts +++ b/demo/src/game.ts @@ -34,6 +34,7 @@ import { contactsId, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -341,6 +342,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 79bdab34..cfa84d7b 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,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. diff --git a/documentation-site/docs/docs/common/transforms.md b/documentation-site/docs/docs/common/transforms.md index df601114..95b52057 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 00000000..c5aaad17 --- /dev/null +++ b/documentation-site/docs/docs/physics/continuous-collision-detection.md @@ -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. diff --git a/documentation-site/docs/docs/physics/index.md b/documentation-site/docs/docs/physics/index.md index 8d05ed4b..b073fa75 100644 --- a/documentation-site/docs/docs/physics/index.md +++ b/documentation-site/docs/docs/physics/index.md @@ -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 @@ -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 @@ -79,6 +84,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -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, diff --git a/documentation-site/docs/docs/physics/rigid-bodies.md b/documentation-site/docs/docs/physics/rigid-bodies.md index 1846edf3..5984a190 100644 --- a/documentation-site/docs/docs/physics/rigid-bodies.md +++ b/documentation-site/docs/docs/physics/rigid-bodies.md @@ -139,6 +139,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -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)); @@ -164,6 +166,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 fd88cd85..ef5e9072 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 6829bad0..4ff45f8c 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'; @@ -175,6 +176,7 @@ export const createBrickBreakerGame = async (): Promise => { ); world.addSystem(createBallEcsSystem(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 d33602ea..2fbcc0a8 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 9b0e0e07..31bb978f 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, @@ -51,6 +52,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 b750c0bf..df2bb0fa 100644 --- a/documentation-site/src/pages/demos/moving-platform/_create-game.ts +++ b/documentation-site/src/pages/demos/moving-platform/_create-game.ts @@ -12,6 +12,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -98,6 +99,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. renderContext.canvas.addEventListener('mousedown', (event: MouseEvent) => { 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 44390de0..d2be05e2 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 820086b1..a13d74f0 100644 --- a/documentation-site/src/pages/demos/physics/_create-game.ts +++ b/documentation-site/src/pages/demos/physics/_create-game.ts @@ -13,6 +13,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -70,6 +71,7 @@ export const createPhysicsGame = async (): Promise => { world.addSystem(createCameraEcsSystem(time)); world.addSystem(createRenderEcsSystem(renderContext)); world.addSystem(createEulerIntegrationEcsSystem(time)); + world.addSystem(createContinuousCollisionEcsSystem()); renderContext.canvas.addEventListener('mousedown', (event: MouseEvent) => { const canvasBounds = renderContext.canvas.getBoundingClientRect(); 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 e337d781..f2731762 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 f50d4097..e0327e78 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 c5716fc8..cf773518 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, @@ -138,6 +139,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/sensors/_create-game.ts b/documentation-site/src/pages/demos/sensors/_create-game.ts index 15347a3f..6dda76a7 100644 --- a/documentation-site/src/pages/demos/sensors/_create-game.ts +++ b/documentation-site/src/pages/demos/sensors/_create-game.ts @@ -5,6 +5,7 @@ import { ContactConstraint, createBroadPhaseEcsSystem, createCollisionResolutionEcsSystem, + createContinuousCollisionEcsSystem, createEulerIntegrationEcsSystem, createGravityEcsSystem, createNarrowPhaseEcsSystem, @@ -81,6 +82,7 @@ export const createSensorsGame = 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/torque/_create-game.ts b/documentation-site/src/pages/demos/torque/_create-game.ts index 8cb37e26..8eff96a2 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 0669a376..ca5221c5 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 00000000..4f3e54c6 --- /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 00000000..311eec15 --- /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 00000000..63e65636 --- /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 00000000..9e445fd8 --- /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 00000000..3893d5f2 --- /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 00000000..78681891 --- /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 00000000..2da81b58 --- /dev/null +++ b/src/physics/ccd/sweep-circle-terrain.test.ts @@ -0,0 +1,154 @@ +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, like a ceiling. + 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 00000000..1fb27d9a --- /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 103af176..d3da41f8 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 00000000..60b0764a --- /dev/null +++ b/src/physics/systems/continuous-collision-system.test.ts @@ -0,0 +1,462 @@ +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 { + addColliderComponent, + ColliderDefaultedOptions, +} 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; + +interface Body { + entity: number; + position: PositionEcsComponent; + rigidBody: RigidBodyEcsComponent; +} + +function addStaticCollider( + world: EcsWorld, + collider: Collider, + position: Vector2, + rotation: number = 0, + colliderOptions: Partial = {}, +): number { + const entity = world.createEntity(); + + addPositionComponent(world, entity, { local: position }); + addRotationComponent(world, entity, { local: rotation }); + addColliderComponent(world, entity, { + collider, + restitution: 0, + ...colliderOptions, + }); + + return entity; +} + +function addBody( + world: EcsWorld, + collider: Collider, + position: Vector2, + velocity: Vector2, + type: RigidBodyType = 'dynamic', + colliderOptions: Partial = {}, +): Body { + const entity = world.createEntity(); + const positionComponent = addPositionComponent(world, entity, { + local: position, + }); + + addRotationComponent(world, entity); + addColliderComponent(world, entity, { + collider, + restitution: 0, + ...colliderOptions, + }); + + 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 }); + + // 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 sweep against sensors', () => { + addStaticCollider(world, box(10), { x: 0, y: -20 }, 0, { sensor: true }); + + 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 stop a sensor circle', () => { + addStaticCollider(world, box(10), { x: 0, y: -20 }); + + const ball = addBody( + world, + new CircleCollider(5), + { x: 0, y: 30 }, + { x: 0, y: -6000 }, + 'dynamic', + { sensor: true }, + ); + + tick(); + + expect(ball.position.local.y).toBeCloseTo(-70); + }); + + it("should not sweep against colliders the circle's category and mask exclude", () => { + addStaticCollider(world, box(10), { x: 0, y: -20 }, 0, { + category: 1 << 1, + mask: 1 << 1, + }); + + 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 }); + + 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 }); + + // 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 }); + + 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, + }); + + 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 00000000..d59cca4e --- /dev/null +++ b/src/physics/systems/continuous-collision-system.ts @@ -0,0 +1,219 @@ +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 { collidersCanCollide } from '../collision/collision-filter.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 and its + * category and mask let it collide with. Sensors are never swept and never + * stop anything, since nothing is resolved against them. 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] +> => ({ + query: [positionId, colliderId], + update: (world, { entities, components: [positions, colliders] }) => { + // 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 ( + !colliders[i].sensor && + (rigidBody === null || rigidBody.type === 'static') + ) { + targets.push({ + collider: colliders[i], + body: { + position: positions[i].world, + rotation: getColliderRotation(getRotation(entities[i])), + collider: colliders[i].collider, + }, + }); + } + } + + for (let i = 0; i < entities.length; i++) { + const mover = colliders[i]; + + if ( + mover.sensor || + getRigidBody(entities[i])?.type !== 'dynamic' || + !(mover.collider instanceof CircleCollider) + ) { + continue; + } + + const start = positions[i].world; + const end = positions[i].local; + const stopT = findStopT(mover, mover.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 { + collider: ColliderEcsComponent; + 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 mover - The moving circle's collider component, for its category + * and mask. + * @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( + mover: ColliderEcsComponent, + 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 ( + !collidersCanCollide(mover, target.collider) || + !aabbsOverlap(sweptAabb, target.collider.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 800d1b4c..1c1f0950 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 ffb5bb5d..9be05528 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 00000000..9f2f4407 --- /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; +}