@woosh/meep-engine 2.141.0 → 2.143.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/shape/CapsuleShape3D.d.ts +1 -1
  3. package/src/core/geom/3d/shape/CapsuleShape3D.js +1 -1
  4. package/src/core/geom/3d/shape/SphereShape3D.d.ts +47 -0
  5. package/src/core/geom/3d/shape/SphereShape3D.d.ts.map +1 -0
  6. package/src/core/geom/3d/shape/SphereShape3D.js +127 -0
  7. package/src/core/geom/3d/shape/UnitSphereShape3D.d.ts +30 -18
  8. package/src/core/geom/3d/shape/UnitSphereShape3D.d.ts.map +1 -1
  9. package/src/core/geom/3d/shape/UnitSphereShape3D.js +44 -92
  10. package/src/core/geom/3d/shape/json/shape_to_type.d.ts.map +1 -1
  11. package/src/core/geom/3d/shape/json/shape_to_type.js +4 -2
  12. package/src/core/geom/3d/shape/json/type_adapters.d.ts +12 -3
  13. package/src/core/geom/3d/shape/json/type_adapters.d.ts.map +1 -1
  14. package/src/core/geom/3d/shape/json/type_adapters.js +16 -4
  15. package/src/core/geom/3d/shape/util/shape_to_visual_entity.js +2 -2
  16. package/src/engine/control/first-person/DESIGN_COLLISION.md +255 -0
  17. package/src/engine/control/first-person/prototype_first_person_controller.js +5 -0
  18. package/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.d.ts.map +1 -1
  19. package/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.js +70 -43
  20. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOShader.d.ts +12 -22
  21. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOShader.d.ts.map +1 -1
  22. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOShader.js +345 -186
  23. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOUpscaleShader.d.ts +44 -0
  24. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOUpscaleShader.d.ts.map +1 -0
  25. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOUpscaleShader.js +151 -0
  26. package/src/engine/graphics/render/buffer/simple-fx/ao/generateHilbertNoiseTexture.d.ts +14 -0
  27. package/src/engine/graphics/render/buffer/simple-fx/ao/generateHilbertNoiseTexture.d.ts.map +1 -0
  28. package/src/engine/graphics/render/buffer/simple-fx/ao/generateHilbertNoiseTexture.js +78 -0
  29. package/src/engine/physics/PLAN.md +705 -578
  30. package/src/engine/physics/REVIEW_003.md +166 -0
  31. package/src/engine/physics/constraint/solve_constraints.d.ts +24 -2
  32. package/src/engine/physics/constraint/solve_constraints.d.ts.map +1 -1
  33. package/src/engine/physics/constraint/solve_constraints.js +402 -165
  34. package/src/engine/physics/ecs/Joint.d.ts +115 -0
  35. package/src/engine/physics/ecs/Joint.d.ts.map +1 -1
  36. package/src/engine/physics/ecs/Joint.js +168 -0
  37. package/src/engine/physics/ecs/JointSerializationAdapter.d.ts +29 -0
  38. package/src/engine/physics/ecs/JointSerializationAdapter.d.ts.map +1 -0
  39. package/src/engine/physics/ecs/JointSerializationAdapter.js +72 -0
  40. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  41. package/src/engine/physics/narrowphase/narrowphase_step.js +20 -13
  42. package/src/engine/physics/narrowphase/ray_shapes.d.ts +66 -0
  43. package/src/engine/physics/narrowphase/ray_shapes.d.ts.map +1 -0
  44. package/src/engine/physics/narrowphase/ray_shapes.js +187 -0
  45. package/src/engine/physics/narrowphase/refine_ray_concave.d.ts +16 -0
  46. package/src/engine/physics/narrowphase/refine_ray_concave.d.ts.map +1 -0
  47. package/src/engine/physics/narrowphase/refine_ray_concave.js +145 -0
  48. package/src/engine/physics/narrowphase/refine_ray_hit.d.ts +39 -0
  49. package/src/engine/physics/narrowphase/refine_ray_hit.d.ts.map +1 -0
  50. package/src/engine/physics/narrowphase/refine_ray_hit.js +78 -0
  51. package/src/engine/physics/narrowphase/sphere_sphere_contact.d.ts +8 -7
  52. package/src/engine/physics/narrowphase/sphere_sphere_contact.d.ts.map +1 -1
  53. package/src/engine/physics/narrowphase/sphere_sphere_contact.js +8 -7
  54. package/src/engine/physics/queries/raycast.d.ts +11 -9
  55. package/src/engine/physics/queries/raycast.d.ts.map +1 -1
  56. package/src/engine/physics/queries/raycast.js +108 -159
  57. package/src/engine/physics/vehicle/RaycastVehicle.d.ts +114 -0
  58. package/src/engine/physics/vehicle/RaycastVehicle.d.ts.map +1 -0
  59. package/src/engine/physics/vehicle/RaycastVehicle.js +333 -0
@@ -1,578 +1,705 @@
1
- # Physics engine — state of play
2
-
3
- Tracker for what's built, what's pending, and what's deferred.
4
-
5
- ---
6
-
7
- ## Context
8
-
9
- Deterministic JS rigid-body physics engine for the meep ECS. Target: game
10
- scenarios with up to millions of mostly-sleeping bodies, deterministic replays
11
- for netcode and reproducible debugging, broad shape coverage for common game
12
- collisions. Pure JS — no WASM, no SIMD, no worker threads.
13
-
14
- Architectural references for design choices:
15
- - **Jolt** — pre-allocated body pool, active-list iteration, two-tree
16
- broadphase (static + dynamic).
17
- - **Bullet** — `btPersistentManifold` cache layout with up to 4 points.
18
- - **Box2D / Catto** — sequential impulse with warm-starting, Sutherland-Hodgman
19
- face clipping for box-box.
20
-
21
- ---
22
-
23
- ## Done
24
-
25
- ### Foundations
26
- - `RigidBody`, `Collider`, `BodyKind`, `RigidBodyFlags`, `ColliderFlags`,
27
- `SleepState`, `PhysicsEvents`.
28
- - `BodyStorage`: SoA pool, generation-tracked stable IDs, dense awake list,
29
- min-heap free for deterministic ID reuse.
30
- - `PhysicsSystem`: full public API surface (gravity, force/impulse with and
31
- without application point, torque, velocity setter, wake/sleep, contact
32
- filter callback).
33
- - Binary serialization adapters for `RigidBody` and `Collider` (transient
34
- runtime state deliberately excluded).
35
- - `PairUint32Map`: open-addressed Robin Hood + Fibonacci hash for the
36
- pair → manifold-slot index (the one new collection added to `core/collection/`).
37
-
38
- ### Pipeline (`PhysicsSystem.fixedUpdate`)
39
- 1. Velocity integration (semi-implicit Euler, linear + angular, gravity,
40
- damping, world-frame inverse-inertia for torque)
41
- 2. Per-collider broadphase refit with fat AABB (Box2D-style velocity-padded
42
- slack)
43
- 3. Pair generation: per-leaf query against both BVHs (static + dynamic),
44
- canonical `(min, max)` pairs, dedup via manifold touched flag
45
- 4. Wake propagation for sleeping bodies in the pair list
46
- 5. Narrowphase cross-product over collider lists
47
- 6. Sequential-impulse solver (Catto-style, warm-start, friction, Baumgarte)
48
- 7. Position integration (linear + quaternion)
49
- 8. Sleep test (per-body velocity² below threshold for ≥ 0.5 s)
50
- 9. Manifold diff → `ContactBegin` / `Stay` / `End` event dispatch
51
- 10. `manifolds.advance_frame()` — roll touched bits, evict grace-expired slots
52
-
53
- ### Shape coverage
54
- | Pair | Path | Manifold |
55
- |---|---|---|
56
- | sphere-sphere | closed-form | 1 point |
57
- | sphere-box | closed-form (handles centre-inside-box) | 1 point |
58
- | capsule-sphere | point-on-segment closed-form | 1 point |
59
- | capsule-capsule | segment-segment closest pair | 1 point |
60
- | capsule-box | iterative segment-vs-OBB (primary) + cap-centre sphere-vs-OBB at each endpoint | up to 3 |
61
- | box-box face-face | SAT + Sutherland-Hodgman clipping | up to 4 |
62
- | box-box edge-edge | SAT + midpoint fallback | 1 point |
63
- | convex × concave (heightmap, mesh) | per-triangle GJK + EPA via decomposition dispatcher | 1 point per triangle (deepest wins) |
64
- | anything else | GJK + EPA | 1 point (may fail on smooth shapes) |
65
-
66
- ### Non-convex shapes
67
- - **`is_convex` flag** on `AbstractShape3D.prototype` (default `true`).
68
- Overridden to `false` on `HeightMapShape3D`, `MeshShape3D`, `UnionShape3D`.
69
- `TransformedShape3D` inherits via getter that reads the wrapped subject.
70
- - **`HeightMapShape3D`** orientation-vector + `Sampler2D`-backed terrain
71
- shape. Heights sampled via `sampleChannelCatmullRomUV` (matching the
72
- terrain system's geometry construction). Compute_bounding_box,
73
- contains_point, signed_distance, nearest_point_on_surface all
74
- implemented; `support` throws (non-convex by construction).
75
- - **`Triangle3D`** buffer-flyweight convex shape. `bind(buffer, offset)`
76
- repoints at 9 consecutive floats in an external Float64Array. Zero
77
- allocation per emission; used by the decomposition path.
78
- - **Triangle decomposition machinery** under
79
- `engine/physics/narrowphase/decomposition/`:
80
- - `TRIANGLE_FLOAT_STRIDE = 10` per triangle (`vA.xyz`, `vB.xyz`,
81
- `vC.xyz`, `feature_id`).
82
- - `heightmap_enumerate_triangles(out, offset, shape, ...aabb)` —
83
- Arvo-projects the convex's AABB into heightmap-local, intersects
84
- with the footprint to derive a cell range, emits 2 triangles per
85
- cell with stable feature_ids.
86
- - `mesh_enumerate_triangles(out, offset, shape, ...aabb)` — linear
87
- O(N) scan over `MeshShape3D.indices` with tight per-triangle AABB
88
- filtering. feature_id = triangle index.
89
- - `aabb_world_to_local(out, world_aabb, pos, rot)` — 8-corner
90
- projection of a world AABB into a body's local frame.
91
- - `decompose_to_triangles(...)` dispatcher switching on shape
92
- type marker.
93
- - **Narrowphase concave dispatch** in `narrowphase_step.js`: detects
94
- `is_convex === false`, computes convex's world AABB, projects to
95
- concave's local frame, decomposes, per-triangle GJK + EPA with
96
- one-sided face-normal rejection and contact-normal dedup. Concave-vs-
97
- concave dynamic pairs are explicitly refused.
98
-
99
- ### Solver
100
- - Sequential impulse with warm-starting (10 velocity iterations by default).
101
- - Coulomb friction with disk-clamped tangent impulses.
102
- - Baumgarte position correction folded into the velocity solve.
103
- - Full angular Jacobian (`I_w⁻¹ = R · diag · R^T`) and angular impulse
104
- application.
105
- - Public force/impulse-at-point API (`applyForceAt`, `applyImpulseAt`,
106
- `applyTorque`).
107
-
108
- ### Sleep + events
109
- - Per-island **atomic sleep**: an island sleeps when `max(|v|² + |ω|²)`
110
- across all members stays below the threshold long enough; the whole
111
- island sleeps in the same frame. Replaces the per-body chatter on
112
- weakly-connected piles.
113
- - **Atomic wake**: members of a sleeping island are threaded into a
114
- circular doubly-linked list (`sleep_group_next` / `sleep_group_prev`);
115
- waking any one member walks the chain and wakes the rest in the same
116
- call. A 100-block stack hit at the base wakes top-down in one frame
117
- rather than over 100 frames of broadphase propagation.
118
- - `DisableSleep` on any island member exempts the whole island.
119
- - ContactBegin / Stay / End buffer + dispatch through both
120
- `PhysicsSystem.onContactBegin/Stay/End` Signals and the per-entity
121
- `entity.sendEvent(PhysicsEvents.ContactBegin, ...)` channel (when a
122
- dataset is attached).
123
-
124
- ### Islands
125
- - **Union-find** with path halving + union by min-index over the awake-body
126
- + touched-contact graph (`engine/physics/island/union_find.js`).
127
- - **`IslandBuilder`** produces deterministic CSR-style output: bodies and
128
- manifold slots grouped by island, sorted ascending within and across
129
- islands. Static / kinematic bodies are constraint anchors only — they
130
- don't merge islands, so disjoint piles on the same floor are separate
131
- islands.
132
- - **Solver iterates per island**: impulse convergence happens inside an
133
- island without waiting on unrelated bodies' Gauss-Seidel updates, and
134
- disconnected awake bodies don't pay each other's solver cost.
135
-
136
- ### Compound bodies
137
- - A body has 0..N attached colliders. Each collider has its own world
138
- transform and its own BVH leaf.
139
- - Same-entity colliders, child-entity colliders (via `ParentEntity`), or
140
- hybrids all supported.
141
- - `ColliderObserverSystem` auto-attaches colliders via the dataset when
142
- paired with `PhysicsSystem` in an EntityManager.
143
- - Narrowphase runs the cross-product over both bodies' collider lists per
144
- body-pair, accumulates candidates, reduces to ≤4 contacts by
145
- depth + spread.
146
-
147
- ### Public queries
148
- - `raycast(origin, dir, max_dist, filter?)` — nearest broadphase AABB hit
149
- across both trees.
150
- - `shapeCast(ray, shape, rotation, result, filter?)` — broadphase swept
151
- AABB against both BVHs; per-candidate AABB-slab interval narrowing,
152
- coarse step over the narrowed window, GJK bisection to time-of-impact.
153
- Output normal is the true contact-surface normal at the kiss point,
154
- recovered by re-running GJK + EPA at `best_t` on the winning candidate.
155
- Falls back to `-ray.direction` only on EPA degeneracies (NaN / zero
156
- depth). Tests cover axis-aligned, off-axis, and oblique cube-vs-cube;
157
- sphere-vs-smooth-shape near-tangent has documented angular tolerance
158
- bands inherited from EPA on smooth supports.
159
- - `overlap(shape, position, rotation, output, output_offset, filter?)`
160
- broadphase + per-candidate GJK overlap detection. Writes body_ids
161
- into a caller-sized buffer; returns count. Convex query shapes only
162
- (concave throws). Concave candidates routed through the per-triangle
163
- decomposition path. Designed for speculative kinematic queries on
164
- kinematic bodies (character controllers, AOE selection).
165
-
166
- ### Standalone narrowphase utilities
167
- - `compute_penetration(out_direction, shape_a, pos_a, rot_a, shape_b,
168
- pos_b, rot_b)` non-system geometry primitive: positive penetration
169
- depth + outward direction (B A convention) on overlap, 0 otherwise.
170
- Convex × convex uses GJK + EPA. Convex × concave uses per-triangle
171
- half-space test (`convex.support(-face_normal)` projected onto each
172
- triangle's plane), aggregated deepest-wins. The half-space approach
173
- sidesteps `Triangle3D`'s degenerate support along face-normal axes
174
- (the same issue that makes per-triangle GJK return false positives
175
- on clearly non-overlapping sphere-above-flat configurations).
176
- Concave × concave throws (M×N triangle pairs is out of scope).
177
- Naturally handles "body inside the concave solid" — reports the depth
178
- needed to push back through the nearest face. Documented limitation:
179
- closed meshes can over-report on side faces whose 2D extent the
180
- convex shape's flank crosses; a future closed-form triangle-vs-X
181
- solver fixes this.
182
-
183
- ### Determinism
184
- - Direct typed-array writes on hot paths (bypassing `Vector3#set`'s observer
185
- dispatch) Transform writes still go through `set()` because external
186
- systems subscribe (TransformAttachment, EntityNode, FogOfWarRevealer,
187
- ViewportPosition).
188
- - Active body iteration sorted by body index.
189
- - Pair canonicalisation `(min, max)`.
190
- - Min-heap free list for slot reuse.
191
- - No `Math.random` anywhere in the simulation step.
192
- - Same-runtime bit-exact determinism by design; cross-runtime is a known
193
- future seam.
194
-
195
- ### Migration
196
- - `Motion` / `MotionSystem` / `MotionSerializationAdapter` relocated from
197
- the meep core (`engine/ecs/`) to the game-domain layer
198
- (`mir-engine/model/game/ecs/`). meep no longer ships the legacy shim.
199
-
200
- ### Alternative narrowphase: MPR
201
- - `engine/physics/gjk/mpr.js` Minkowski Portal Refinement (XenoCollide,
202
- Snethen GDC 2009). Single-pass overlap test + MTV computation,
203
- output convention matches EPA so it's drop-in compatible at any
204
- narrowphase call site. Tends to converge in 5–15 iterations on
205
- smooth shapes where EPA stalls (the polytope-on-curved-surface
206
- failure mode the torus-knot reproducer exercised). Not yet wired
207
- into `narrowphase_step` available as a swap candidate / per-pair
208
- preference once we want to fall back to it on EPA non-convergence,
209
- or as the default for any shape pair that involves a mesh.
210
-
211
- ### Bonus utilities
212
- - `core/geom/3d/line/line3_closest_points_segment_segment.js` generally
213
- useful 3D segment-segment closest-pair via Ericson §5.1.9.
214
- - `core/collection/PairUint32Map.js` non-allocating
215
- `Map<(u32, u32) u32>` with Robin Hood + Fibonacci hash.
216
-
217
- ---
218
-
219
- ## Limitations / Known caveats
220
-
221
- - **Multi-collider material precision**: solver reads friction/restitution
222
- from the first-attached collider of each body. Mixed-material compound
223
- bodies lose accuracy here. The contact-filter callback's `colliderA/B`
224
- arguments are similarly the body's primary collider, not the specific
225
- collider in contact.
226
- - **EPA on smooth shapes**: degenerates (no flat face to converge on).
227
- Mitigated by closed-form paths for sphere/cube/capsule pairs; exotic
228
- convex shapes vs spheres can still fail.
229
- - **EPA on `Triangle3D`** (concave-shape narrowphase): the triangle's
230
- support is degenerate along its face-normal axis (all 3 vertices
231
- project to the same value), so per-triangle GJK + EPA in the
232
- narrowphase concave dispatch produces imprecise depths near the
233
- iteration cap. A sphere dropping onto a flat heightmap decelerates
234
- ~70% on first contact but eventually sinks through over ~50 steps —
235
- the `narrowphase_concave.spec.js` "drop and settle" cases are
236
- `test.skip` for this reason. Workaround in `compute_penetration` is
237
- the half-space pre-test that avoids running GJK on degenerate
238
- triangle supports altogether; long-term fix is closed-form
239
- triangle-vs-primitive solvers.
240
- - **Box-box edge-edge contact**: single midpoint contact rather than
241
- multi-point. Skewed-orientation cube collisions are stable-enough but
242
- not as precise as face-face.
243
- - **CCD floor only**: speculative margin via the fattened AABB prevents
244
- most tunnelling. No per-body swept shape-cast for very fast objects.
245
- - **Cross-runtime determinism is not guaranteed**: `Math.sin/cos/exp/log`
246
- are ULP-correct but not bit-exact across V8 / SpiderMonkey / JSC.
247
- - **Dynamic concave bodies settle poorly under TGS**: the substep loop
248
- re-derives contact geometry analytically from the per-triangle contact
249
- feature (witness anchors + normal) captured once by narrowphase and held
250
- fixed for the whole outer step. For a convex body the contact feature is
251
- stable under the small per-step motion, so this is exact; for a *dynamic
252
- concave mesh body* (e.g. a torus knot rocking on its own lobes) the
253
- supporting triangle itself changes as the body rocks, so freezing the
254
- feature pumps a little energy in and the body rocks / slowly sinks instead
255
- of settling (the `PhysicsSystem.spec.js` torus-knot dynamic-settle test is
256
- `test.skip` for this reason). Note this is NOT a contact-precision issue —
257
- the knot already uses the exact closed-form box-triangle solver (P1.1b);
258
- the problem is purely that TGS freezes *which* feature is in contact across
259
- substeps. The common concave case a convex dynamic body on static concave
260
- terrain is unaffected (the convex side's feature is stable), and that is
261
- the only concave case the engine targets.
262
-
263
- **Interim fix (implemented): per-substep concave re-detection.** For
264
- contact pairs involving a concave body, the substep loop re-runs the
265
- concave narrowphase geometry at the current substep pose (instead of the
266
- analytic refresh that freezes the feature) and re-prepares those contacts
267
- from the fresh witness/normal/depth — so the contact normal tracks the
268
- rocking body and no energy is pumped in. Convex pairs keep the cheap
269
- analytic refresh. This is ~Nx narrowphase cost on concave-involved pairs
270
- (acceptable they're rare), gated by collider convexity. Un-skips the
271
- torus-knot dynamic-settle test.
272
-
273
- **Better long-term fix: convex collision proxies (not raw concave).** Every
274
- major engine (Box2D, Jolt, PhysX, Rapier) requires dynamic bodies to be
275
- convex or convex-decomposed; raw concave meshes are static-only. The right
276
- granularity is a *few* convex pieces — NOT the thousands of tets a
277
- volumetric mesher produces (tet count ≈ collider/BVH-leaf count, which
278
- explodes the broadphase for an awake body; tet meshing is for a future
279
- FEM/soft-body subsystem, not rigid collision). See the "Convex collision
280
- proxies for dynamic concave bodies" backlog item a 3D convex hull builder
281
- (single-hull proxy covers most dynamic objects) plus an optional
282
- few-hull (V-HACD-style) decomposition. Those supersede the interim
283
- per-substep re-detection once built.
284
-
285
- ---
286
-
287
- ## Backlog (planned, in scope)
288
-
289
- ### Solver quality (next major work)
290
-
291
- These items move the engine from "competent" to "great". TGS is the next
292
- significant solver-architecture change; joints come after, once the TGS
293
- scaffolding is in place.
294
-
295
- - **TGS (Temporal Gauss-Seidel) substepping with split-impulse** — Phases
296
- 1–3 **LANDED**. The solver is now a staged TGS pipeline
297
- (`solver/solve_contacts.js`: `prepare_contacts` per substep
298
- [`refresh_contacts` `warm_start_contacts` → `solve_velocity` →
299
- `solve_position`] → `apply_restitution`), driven by the substep loop in
300
- `PhysicsSystem.fixedUpdate`. Defaults: `substeps = 4`,
301
- `velocityIterations = 4`, `positionIterations = 1` (all fields on
302
- `PhysicsSystem`).
303
- - **Phase 1 split impulse.** Position correction runs on a per-body
304
- pseudo-velocity (`__pseudo_velocity`) folded into the pose by
305
- `integrate_position` and discarded; depth correction never
306
- contaminates persistent velocity.
307
- - **Phase 2 — one-shot restitution.** Velocity pass is pure
308
- non-penetration; restitution is a single post-loop pass driving
309
- `vn → -e·vn_approach`, gated on a running max normal impulse
310
- (`maxNormalImpulse`) so transient collisions still bounce under
311
- per-substep warm-start.
312
- - **Phase 3substep loop.** `substeps` sub-iterations at `h = dt/N`.
313
- Forces consumed once at full `dt` before the loop; gravity applied
314
- per substep; **warm-start replayed per substep** (the crux a
315
- per-substep impulse balances one substep of gravity, so resting
316
- stacks hold at zero velocity). Contact geometry is re-derived
317
- **analytically** each substep from frozen local witness anchors +
318
- the trusted prepare-time depth (a sign-robust delta), so narrowphase
319
- runs **once** per outer stepcheaper than the originally-planned
320
- per-substep match-and-merge refresh, and exact for convex
321
- primitives whose contact feature is stable under small motion.
322
-
323
- Results vs the single-step solver: a 100:1 mass ratio now stacks
324
- instead of crushing through (regression test added); 8-cube stacks
325
- settle to zero velocity and sleep (were impossible long-term under SI);
326
- falling-tower bench cost unchanged (~48 ms/1000 active bodies);
327
- `substeps = 1` reproduces the single-step result bit-for-bit-ish
328
- (one-frame restitution delay aside).
329
-
330
- **Hard-won lessons (for REVIEW_002):**
331
- - Warm-start MUST be per-substep, not once. Replaying a full-frame
332
- impulse once while gravity arrives per substep over-pushes resting
333
- contacts and *explodes* deep stacks. Per-substep warm-start +
334
- per-substep gravity cancel exactly at rest.
335
- - Restitution must gate on the *running max* normal impulse, not the
336
- end-of-loop value per-substep warm-start relaxes a transient
337
- contact's `j_n` back to ~0 by the end, which would suppress the
338
- bounce.
339
- - Analytic separation re-derivation beats per-substep narrowphase
340
- for convex shapes (cheaper, no manifold-lifecycle churn) but is
341
- only as good as the frozen normal see the concave caveat below.
342
-
343
- Follow-ups since the core landed:
344
- - [x] **Box-box SAT reference tie-break deadband** — aligned cube
345
- stacks (4–10 high) now settle to zero velocity and sleep; the
346
- reference-face flip-flop that creeped/toppled them is gone.
347
- - [x] **Per-substep contact re-detection for concave pairs** — lifts
348
- the dynamic-concave-body limitation; the torus-knot dynamic-settle
349
- test is un-skipped. Concave pairs re-run narrowphase geometry each
350
- substep (`redetect_concave_contacts`); convex pairs keep the cheap
351
- analytic refresh.
352
-
353
- Remaining (Phases 4–6) now complete:
354
- - [x] Regression coverage: heavy-on-light pyramid (10× capstone on two
355
- light cubes settles + sleeps) and a ragdoll-stub (shoulder
356
- ball-socket + elbow hinge arm hangs, stays articulated, settles).
357
- - [x] REVIEW_002 retrospective — `engine/physics/REVIEW_002.md`.
358
-
359
- References: Catto 2018 ("Soft Constraints" GDC talk + the TGS
360
- follow-up); Box2D v3 source (`b2ApplyRestitution`, the substep solver
361
- stages); Rapier as the closest architectural sibling.
362
-
363
- - [ ] **Constraints / joints — the next major work.** Now unblocked: TGS is
364
- in (joint-chain convergence is a TGS sweet spot), warm-start +
365
- per-substep + island machinery is reusable, and the SPOOK compliance
366
- dial already in the contact solver gives soft/spring constraints
367
- essentially for free. Target use cases: chains/ropes, ragdolls,
368
- vehicles (incl. suspension), plus the common mechanical set (doors,
369
- pistons, welds, grab/drag, winches, drivetrains).
370
-
371
- **Foundational work (do first): generalise the solver to constraint
372
- rows.** Today `solver/solve_contacts.js` is hard-coded to the
373
- contact-shape constraint (normal + 2 friction tangents, ≥0 clamp,
374
- restitution, penetration bias). Joints are equality / inequality
375
- constraints on relative velocity at anchors, generally bilateral
376
- (impulse may be ±) with optional limits and motors. The clean shape —
377
- and what Jolt / Box2D-v3 do is a **generic constraint row**: a
378
- Jacobian (linear + angular parts per body), an effective mass, a bias
379
- (position error × SPOOK gain, or motor target), and impulse bounds
380
- `[lo, hi]` (`[0,∞)` for a contact/limit, `(−∞,∞)` for an equality,
381
- `[−maxForce·h, +maxForce·h]` for a motor). Each joint type just fills
382
- in its rows; the existing per-body impulse-apply primitive
383
- (`apply_impulse_to_body` + `world_inverse_inertia_apply`), the
384
- per-substep warm-start, the islands, and the split-impulse / SPOOK
385
- position handling are all reused. Contacts become *one* constraint
386
- type among several rather than the hard-coded path.
387
-
388
- The specific constraint set, its use-case mapping, and per-type
389
- architecture-fit assessment are under review (see the constraints
390
- sketch). High level: ball-socket / distance / spring / weld and the
391
- grab constraint are near drop-ins on the row machinery; hinge /
392
- prismatic / cone-twist / motors / limits add angular-row + bounded-row
393
- mechanics (still within the impulse framework); raycast vehicles,
394
- conveyor surface-velocity, and gear/pulley coupling are higher-level
395
- systems or contact modifiers that sit *on top of* the primitives
396
- rather than being generic rows.
397
-
398
- **Decision: build ONE configurable 6-DOF constraint** (PhysX D6 / Jolt
399
- SixDOF), implemented mode-by-mode. The `Joint` ECS component carries
400
- `dofMode[6]` (3 linear, 3 angular) each `{locked|free|limited|spring|
401
- motor}` + per-DOF limit/spring/motor config + warm-start accumulators.
402
- Concrete joints are configs, not new code (ball-socket = lock 3 linear;
403
- hinge = lock 3 linear + 2 angular; weld = lock 6; cone-twist = lock 3
404
- linear + limit 3 angular; suspension = spring 1 linear + lock rest).
405
-
406
- Phasing:
407
- 1. [x] Constraint-row solver as a **parallel row set** in the TGS
408
- substep loop (contacts left untouched, not ported lower risk).
409
- `constraint/solve_constraints.js` reuses `world_inverse_inertia`,
410
- per-substep warm-start, and the SPOOK position bias; `Joint`
411
- component + `link_joint`/`unlink_joint` in PhysicsSystem;
412
- `jointIterations` knob. Bodies need no collider.
413
- 2. [x] **LOCKED linear DOFs ball-socket.** Pendulum (anchor pinned
414
- to a world pivot, body swings) and a 2-link chain (body↔body,
415
- joints stay connected, chain hangs) pass. → **chains, ropes,
416
- pendulums working.**
417
- 3. [x] LOCKED angular + linear DOFs in the frame basis — **weld,
418
- hinge, prismatic done**. Joint frame bases
419
- (`localBasisA`/`localBasisB`); BOTH linear and angular rows now
420
- resolve in frame A's axes (cleared the world-axis linear debt — the
421
- solver is fully frame-relative). Angular: relative rotation
422
- `qD = conj(qA)·qB` small-angle error, ωB−ωA rows + SPOOK bias.
423
- Linear: `C·axis` error, vA−vB rows. `asWeld()` / `asHinge(axis)` /
424
- `asPrismatic(axis)` presets. Verified: weld holds pose + orientation
425
- against an off-centre torque; hinge swings about its free axis only
426
- (locked axes < 0.02); prismatic slides along its one free axis,
427
- locked on the others; all LOCKED-mode tests still green after the
428
- frame-basis rewrite.
429
- 4. [ ] LIMITED + MOTOR (bounded rows) → doors, pistons, wheel
430
- spin/drive, joint ROM.
431
- 5. [ ] SPRING (SPOOK soft) suspension, bungees, soft ragdolls.
432
- 6. [ ] Cone-twist / swing-twist angular limits ragdolls.
433
- 7. [ ] Vehicle layer recommend a **raycast-vehicle controller**
434
- (raycast + suspension force + tire friction; what most games ship)
435
- on top of the queries, with simulated wheels via the 6-DOF as an
436
- option. vehicles.
437
- 8. [ ] Extras: pulley, gear, conveyor (contact surface-velocity),
438
- breakable-joint flag.
439
-
440
- Foundation gaps both now closed:
441
- - [x] **Island integration.** Jointed dynamic-dynamic bodies are
442
- unioned into one island (`IslandBuilder` Pass 1b), so a chain /
443
- ragdoll sleeps and wakes as a unit; `__wake_joints` propagates wake
444
- across a joint when one side is awake and the other asleep
445
- (e.g. a kinematic/motor driver pulling a sleeping chain). Verified:
446
- a damped chain settles and both links sleep in one sleep group.
447
- - [x] **Generation-checked body references.** `solve_joints`,
448
- `IslandBuilder` Pass 1b and `__wake_joints` all gate on
449
- `storage.is_valid(packedId)`, so a joint to an unlinked / slot-reused
450
- body goes inert instead of attaching to the wrong body or crashing.
451
- Verified: unlinking a jointed body leaves the joint inert and the
452
- survivor free.
453
-
454
- References: Catto / Box2D-v3 joint solvers; Jolt's `Constraint` base
455
- (`SetupVelocityConstraint` / `WarmStartVelocityConstraint` /
456
- `SolveVelocityConstraint` / `SolvePositionConstraint`); PhysX D6 /
457
- ODE joint taxonomy.
458
-
459
- ### Stability
460
- - [ ] **Closed-form triangle-vs-primitive solvers**
461
- (`triangle_sphere_contact`, `triangle_box_contact`,
462
- `triangle_capsule_contact`). The decomposition machinery is in
463
- place (`Triangle3D` flyweight, `heightmap_enumerate_triangles` /
464
- `mesh_enumerate_triangles`, `decompose_to_triangles` dispatcher,
465
- `aabb_world_to_local`, `narrowphase_step.js` concave branch), but
466
- the per-triangle narrowphase uses GJK + EPA which hits the
467
- smooth-shape iteration cap and `Triangle3D`'s degenerate-support
468
- issue. Closed-form solvers per primitive bypass both. This is now
469
- the single biggest accuracy gap in the engine — it would:
470
- - Unblock the `narrowphase_concave.spec.js` skipped tests (ball
471
- drops on heightmap / mesh-cube settle correctly).
472
- - Unblock the `PhysicsSystem.spec.js` torus-knot test.
473
- - Improve `compute_penetration`'s closed-mesh accuracy (currently
474
- documented over-reports on side faces).
475
- Existing primitive pair solvers (`sphere_box_contact`,
476
- `capsule_box_multi_contacts`, `box_box_manifold`) are the
477
- blueprint. Triangle is roughly a box with two half-extents = 0.
478
- - [ ] **Edge-edge multi-point manifold** for skewed box contacts.
479
- - [ ] **Per-contact source-collider tracking** so multi-material compound
480
- bodies get accurate per-contact friction/restitution. Requires
481
- stashing the collider identity in the manifold contact stride.
482
-
483
- ### Performance / Scale
484
- - [ ] **Per-body linear CCD shape-cast**: optional opt-in for fast-moving
485
- bodies where speculative margin isn't enough. The bench's falling
486
- tower (1km drop onto a 1cm floor) is the concrete reproducer —
487
- 180 / 1000 bodies tunnel.
488
- - [ ] **Per-island parallel solve**: today's island data layout would
489
- allow worker-based solving once `SharedArrayBuffer` is available.
490
- Out-of-scope unless / until SAB is universally usable.
491
-
492
- ### Features
493
- - [ ] **Convex collision proxies for dynamic concave bodies.** The long-term
494
- replacement for the interim per-substep concave re-detection (see
495
- Limitations) and how every major engine handles dynamic non-convex
496
- shapes: collide a *few* convex pieces, never the raw concave mesh.
497
- 1. **3D convex hull builder** (meep has only 2D hulls today —
498
- `core/geom/2d/convex-hull/`). A single hull of a mesh is one
499
- collider / one broadphase leaf and covers the overwhelming majority
500
- of dynamic objects (thrown props, debris). Pairs with the existing
501
- "Convex hull shape + eigen-inertia" item below.
502
- 2. **Few-hull (V-HACD-style) approximate convex decomposition** for
503
- shapes whose concavity matters (a cup, a chair): ~8–64 fat convex
504
- hulls = 8–64 colliders, two orders of magnitude below a tet mesh.
505
- Each hull is convex → stable contact feature → the TGS analytic refresh
506
- is exact → no per-substep re-detection, no rocking. Granularity is the
507
- whole point: collider/BVH-leaf count must stay small for an *awake*
508
- dynamic body (the volumetric tet-mesher under `core/geom/3d/tetrahedra/`
509
- is the wrong tool here thousands of pieces and belongs to a future
510
- FEM/soft-body subsystem, not rigid collision).
511
- - [ ] **Convex hull shape** with eigen-based principal-axes inertia
512
- derivation. Hooks `matrix_eigenvalues_in_place` from the existing
513
- linalg layer.
514
- - [ ] **Cylinder / cone shapes** (closed-form pairs against the existing
515
- family + GJK+EPA fallback for general convex).
516
-
517
- ### API polish
518
- - [x] **`overlap(shape, position, rotation, output, output_offset,
519
- filter?)`** — broadphase + narrowphase overlap query for kinematic
520
- / AOE / selection use cases. Body_ids written into a caller-sized
521
- Uint32Array buffer. Convex query shape only; concave candidates
522
- are routed through the per-triangle decomposition path.
523
- - [x] **`shapeCast(ray, shape, rotation, result, filter?)`** for
524
- character controllers and kinematic shape sweeps. Broadphase
525
- swept-AABB against both BVHs; per-candidate AABB-slab interval
526
- narrowing + coarse step + GJK bisection for time-of-impact. The
527
- output `result.normal` is the true contact-surface normal at the
528
- kiss point, computed by re-running GJK + EPA at `best_t` on the
529
- winning candidate (falls back to `-ray.direction` only on EPA
530
- degeneracies).
531
- - [x] **`compute_penetration(out_direction, shape_a, pos_a, rot_a,
532
- shape_b, pos_b, rot_b)`** standalone geometry primitive (no
533
- PhysicsSystem) for resolving overlap between two shapes at given
534
- poses. Returns depth + outward direction. Convex × convex via
535
- GJK + EPA; convex × concave via per-triangle half-space test.
536
-
537
- ---
538
-
539
- ## Future / out-of-scope
540
-
541
- These are explicit architectural exclusions or post-v1 explorations.
542
-
543
- ### Architecture
544
- - **Cross-runtime bit-exact determinism**: a soft-float library would
545
- replace `Math.sin/cos/exp/log/pow` in the hot path. The codebase is
546
- already structured to make this a swap-in at `quat_integrate.js` and
547
- tangent-basis construction in `build_manifold.js`. Not pursued because
548
- the same-runtime determinism we have covers the common cases (single-
549
- device replay, networked lockstep where all clients run the same JS
550
- engine).
551
- - **WASM / SIMD**: the engine targets pure-JS portability. SIMD would
552
- invalidate the determinism story (V8 doesn't expose deterministic
553
- Float64x2 ops).
554
- - **Multi-threaded solver**: workers don't share memory cheaply without
555
- `SharedArrayBuffer` plus the COOP/COEP HTTP headers, which are not
556
- always available. Single-threaded is good-enough for the awake-body
557
- budget that matters.
558
-
559
- ### Simulation extensions
560
- - **Soft body / cloth / fluids**: the SoA layout in `BodyStorage` and the
561
- manifold cache are rigid-body shaped. A soft-body system would be a
562
- parallel subsystem, not an extension.
563
- - **Reduced-coordinate articulations** (MuJoCo / Featherstone-style):
564
- game-physics audience runs in maximal coordinates by convention. Not
565
- on the roadmap.
566
-
567
- ### Game-side
568
- - **Vehicle physics** (suspensions, drivetrains): a domain layer that
569
- sits on top of the rigid-body primitives, not in `meep/`.
570
- - **Character controllers**: same `engine/control/first-person/` is the
571
- natural home.
572
-
573
- ---
574
-
575
- ## Notable design files
576
-
577
- - Original design plan: `C:\Users\Alex\.claude\plans\let-s-plan-to-implement-transient-harp.md`
578
- - This file (state of play): `engine/physics/PLAN.md`
1
+ # Physics engine — state of play
2
+
3
+ Tracker for what's built, what's pending, and what's deferred.
4
+
5
+ ---
6
+
7
+ ## Context
8
+
9
+ Deterministic JS rigid-body physics engine for the meep ECS. Target: game
10
+ scenarios with up to millions of mostly-sleeping bodies, deterministic replays
11
+ for netcode and reproducible debugging, broad shape coverage for common game
12
+ collisions. Pure JS — no WASM, no SIMD, no worker threads.
13
+
14
+ Architectural references for design choices:
15
+ - **Jolt** — pre-allocated body pool, active-list iteration, two-tree
16
+ broadphase (static + dynamic).
17
+ - **Bullet** — `btPersistentManifold` cache layout with up to 4 points.
18
+ - **Box2D / Catto** — sequential impulse with warm-starting, Sutherland-Hodgman
19
+ face clipping for box-box.
20
+
21
+ ---
22
+
23
+ ## Done
24
+
25
+ ### Foundations
26
+ - `RigidBody`, `Collider`, `BodyKind`, `RigidBodyFlags`, `ColliderFlags`,
27
+ `SleepState`, `PhysicsEvents`.
28
+ - `BodyStorage`: SoA pool, generation-tracked stable IDs, dense awake list,
29
+ min-heap free for deterministic ID reuse.
30
+ - `PhysicsSystem`: full public API surface (gravity, force/impulse with and
31
+ without application point, torque, velocity setter, wake/sleep, contact
32
+ filter callback).
33
+ - Binary serialization adapters for `RigidBody` and `Collider` (transient
34
+ runtime state deliberately excluded).
35
+ - `PairUint32Map`: open-addressed Robin Hood + Fibonacci hash for the
36
+ pair → manifold-slot index (the one new collection added to `core/collection/`).
37
+
38
+ ### Pipeline (`PhysicsSystem.fixedUpdate`)
39
+ 1. Velocity integration (semi-implicit Euler, linear + angular, gravity,
40
+ damping, world-frame inverse-inertia for torque)
41
+ 2. Per-collider broadphase refit with fat AABB (Box2D-style velocity-padded
42
+ slack)
43
+ 3. Pair generation: per-leaf query against both BVHs (static + dynamic),
44
+ canonical `(min, max)` pairs, dedup via manifold touched flag
45
+ 4. Wake propagation for sleeping bodies in the pair list
46
+ 5. Narrowphase cross-product over collider lists
47
+ 6. Sequential-impulse solver (Catto-style, warm-start, friction, Baumgarte)
48
+ 7. Position integration (linear + quaternion)
49
+ 8. Sleep test (per-body velocity² below threshold for ≥ 0.5 s)
50
+ 9. Manifold diff → `ContactBegin` / `Stay` / `End` event dispatch
51
+ 10. `manifolds.advance_frame()` — roll touched bits, evict grace-expired slots
52
+
53
+ ### Shape coverage
54
+ | Pair | Path | Manifold |
55
+ |---|---|---|
56
+ | sphere-sphere | closed-form | 1 point |
57
+ | sphere-box | closed-form (handles centre-inside-box) | 1 point |
58
+ | capsule-sphere | point-on-segment closed-form | 1 point |
59
+ | capsule-capsule | segment-segment closest pair | 1 point |
60
+ | capsule-box | iterative segment-vs-OBB (primary) + cap-centre sphere-vs-OBB at each endpoint | up to 3 |
61
+ | box-box face-face | SAT + Sutherland-Hodgman clipping | up to 4 |
62
+ | box-box edge-edge | SAT + segment-segment closest-pair | 1 point |
63
+ | sphere / box / capsule × concave (heightmap, mesh) | closed-form `*_triangle_contact` per triangle via decomposition dispatcher | up to a few points per triangle (deepest wins) |
64
+ | other convex × concave | per-triangle GJK + EPA via decomposition dispatcher | 1 point per triangle |
65
+ | anything else | GJK + EPA, MPR fallback on EPA non-convergence | 1 point |
66
+
67
+ ### Non-convex shapes
68
+ - **`is_convex` flag** on `AbstractShape3D.prototype` (default `true`).
69
+ Overridden to `false` on `HeightMapShape3D`, `MeshShape3D`, `UnionShape3D`.
70
+ `TransformedShape3D` inherits via getter that reads the wrapped subject.
71
+ - **`HeightMapShape3D`** orientation-vector + `Sampler2D`-backed terrain
72
+ shape. Heights sampled via `sampleChannelCatmullRomUV` (matching the
73
+ terrain system's geometry construction). Compute_bounding_box,
74
+ contains_point, signed_distance, nearest_point_on_surface all
75
+ implemented; `support` throws (non-convex by construction).
76
+ - **`Triangle3D`** buffer-flyweight convex shape. `bind(buffer, offset)`
77
+ repoints at 9 consecutive floats in an external Float64Array. Zero
78
+ allocation per emission; used by the decomposition path.
79
+ - **Triangle decomposition machinery** under
80
+ `engine/physics/narrowphase/decomposition/`:
81
+ - `TRIANGLE_FLOAT_STRIDE = 10` per triangle (`vA.xyz`, `vB.xyz`,
82
+ `vC.xyz`, `feature_id`).
83
+ - `heightmap_enumerate_triangles(out, offset, shape, ...aabb)`
84
+ Arvo-projects the convex's AABB into heightmap-local, intersects
85
+ with the footprint to derive a cell range, emits 2 triangles per
86
+ cell with stable feature_ids.
87
+ - `mesh_enumerate_triangles(out, offset, shape, ...aabb)` linear
88
+ O(N) scan over `MeshShape3D.indices` with tight per-triangle AABB
89
+ filtering. feature_id = triangle index.
90
+ - `aabb_world_to_local(out, world_aabb, pos, rot)` 8-corner
91
+ projection of a world AABB into a body's local frame.
92
+ - `decompose_to_triangles(...)` — dispatcher switching on shape
93
+ type marker.
94
+ - **Narrowphase concave dispatch** in `narrowphase_step.js`: detects
95
+ `is_convex === false`, computes convex's world AABB, projects to
96
+ concave's local frame, decomposes, per-triangle GJK + EPA with
97
+ one-sided face-normal rejection and contact-normal dedup. Concave-vs-
98
+ concave dynamic pairs are explicitly refused.
99
+
100
+ ### Solver
101
+ - Sequential impulse with warm-starting (10 velocity iterations by default).
102
+ - Coulomb friction with disk-clamped tangent impulses.
103
+ - Baumgarte position correction folded into the velocity solve.
104
+ - Full angular Jacobian (`I_w⁻¹ = R · diag · R^T`) and angular impulse
105
+ application.
106
+ - Public force/impulse-at-point API (`applyForceAt`, `applyImpulseAt`,
107
+ `applyTorque`).
108
+
109
+ ### Sleep + events
110
+ - Per-island **atomic sleep**: an island sleeps when `max(|v|² + |ω|²)`
111
+ across all members stays below the threshold long enough; the whole
112
+ island sleeps in the same frame. Replaces the per-body chatter on
113
+ weakly-connected piles.
114
+ - **Atomic wake**: members of a sleeping island are threaded into a
115
+ circular doubly-linked list (`sleep_group_next` / `sleep_group_prev`);
116
+ waking any one member walks the chain and wakes the rest in the same
117
+ call. A 100-block stack hit at the base wakes top-down in one frame
118
+ rather than over 100 frames of broadphase propagation.
119
+ - `DisableSleep` on any island member exempts the whole island.
120
+ - ContactBegin / Stay / End buffer + dispatch through both
121
+ `PhysicsSystem.onContactBegin/Stay/End` Signals and the per-entity
122
+ `entity.sendEvent(PhysicsEvents.ContactBegin, ...)` channel (when a
123
+ dataset is attached).
124
+
125
+ ### Islands
126
+ - **Union-find** with path halving + union by min-index over the awake-body
127
+ + touched-contact graph (`engine/physics/island/union_find.js`).
128
+ - **`IslandBuilder`** produces deterministic CSR-style output: bodies and
129
+ manifold slots grouped by island, sorted ascending within and across
130
+ islands. Static / kinematic bodies are constraint anchors only they
131
+ don't merge islands, so disjoint piles on the same floor are separate
132
+ islands.
133
+ - **Solver iterates per island**: impulse convergence happens inside an
134
+ island without waiting on unrelated bodies' Gauss-Seidel updates, and
135
+ disconnected awake bodies don't pay each other's solver cost.
136
+
137
+ ### Compound bodies
138
+ - A body has 0..N attached colliders. Each collider has its own world
139
+ transform and its own BVH leaf.
140
+ - Same-entity colliders, child-entity colliders (via `ParentEntity`), or
141
+ hybrids all supported.
142
+ - `ColliderObserverSystem` auto-attaches colliders via the dataset when
143
+ paired with `PhysicsSystem` in an EntityManager.
144
+ - Narrowphase runs the cross-product over both bodies' collider lists per
145
+ body-pair, accumulates candidates, reduces to ≤4 contacts by
146
+ depth + spread.
147
+
148
+ ### Public queries
149
+ - `raycast(origin, dir, max_dist, filter?)` — nearest hit across both trees,
150
+ **refined to the true shape surface** (narrowphase). `result.t` /
151
+ `result.normal` are exact for sphere / box / capsule / mesh / heightmap
152
+ colliders (per-leaf analytic ray tests + triangle Möller–Trumbore for
153
+ concave); composite convex shapes fall back to the broadphase AABB hit. A ray
154
+ crossing a fat leaf AABB but missing the true shape is correctly a miss.
155
+ - `shapeCast(ray, shape, rotation, result, filter?)` broadphase swept
156
+ AABB against both BVHs; per-candidate AABB-slab interval narrowing,
157
+ coarse step over the narrowed window, GJK bisection to time-of-impact.
158
+ Output normal is the true contact-surface normal at the kiss point,
159
+ recovered by re-running GJK + EPA at `best_t` on the winning candidate.
160
+ Falls back to `-ray.direction` only on EPA degeneracies (NaN / zero
161
+ depth). Tests cover axis-aligned, off-axis, and oblique cube-vs-cube;
162
+ sphere-vs-smooth-shape near-tangent has documented angular tolerance
163
+ bands inherited from EPA on smooth supports.
164
+ - `overlap(shape, position, rotation, output, output_offset, filter?)`
165
+ — broadphase + per-candidate GJK overlap detection. Writes body_ids
166
+ into a caller-sized buffer; returns count. Convex query shapes only
167
+ (concave throws). Concave candidates routed through the per-triangle
168
+ decomposition path. Designed for speculative kinematic queries on
169
+ kinematic bodies (character controllers, AOE selection).
170
+
171
+ ### Standalone narrowphase utilities
172
+ - `compute_penetration(out_direction, shape_a, pos_a, rot_a, shape_b,
173
+ pos_b, rot_b)` non-system geometry primitive: positive penetration
174
+ depth + outward direction (B A convention) on overlap, 0 otherwise.
175
+ Convex × convex uses GJK + EPA. Convex × concave uses per-triangle
176
+ half-space test (`convex.support(-face_normal)` projected onto each
177
+ triangle's plane), aggregated deepest-wins. The half-space approach
178
+ sidesteps `Triangle3D`'s degenerate support along face-normal axes
179
+ (the same issue that makes per-triangle GJK return false positives
180
+ on clearly non-overlapping sphere-above-flat configurations).
181
+ Concave × concave throws (M×N triangle pairs is out of scope).
182
+ Naturally handles "body inside the concave solid" — reports the depth
183
+ needed to push back through the nearest face. Documented limitation:
184
+ closed meshes can over-report on side faces whose 2D extent the
185
+ convex shape's flank crosses; a future closed-form triangle-vs-X
186
+ solver fixes this.
187
+
188
+ ### Determinism
189
+ - Direct typed-array writes on hot paths (bypassing `Vector3#set`'s observer
190
+ dispatch) Transform writes still go through `set()` because external
191
+ systems subscribe (TransformAttachment, EntityNode, FogOfWarRevealer,
192
+ ViewportPosition).
193
+ - Active body iteration sorted by body index.
194
+ - Pair canonicalisation `(min, max)`.
195
+ - Min-heap free list for slot reuse.
196
+ - No `Math.random` anywhere in the simulation step.
197
+ - Same-runtime bit-exact determinism by design; cross-runtime is a known
198
+ future seam.
199
+
200
+ ### Migration
201
+ - `Motion` / `MotionSystem` / `MotionSerializationAdapter` relocated from
202
+ the meep core (`engine/ecs/`) to the game-domain layer
203
+ (`mir-engine/model/game/ecs/`). meep no longer ships the legacy shim.
204
+
205
+ ### Alternative narrowphase: MPR
206
+ - `engine/physics/gjk/mpr.js` Minkowski Portal Refinement (XenoCollide,
207
+ Snethen GDC 2009). Single-pass overlap test + MTV computation,
208
+ output convention matches EPA so it's drop-in compatible at any
209
+ narrowphase call site. Tends to converge in 5–15 iterations on
210
+ smooth shapes where EPA stalls (the polytope-on-curved-surface
211
+ failure mode the torus-knot reproducer exercised). **Wired as the EPA
212
+ non-convergence fallback** in `narrowphase_step` at both the body-level
213
+ and per-triangle GJK+EPA paths: when EPA returns a non-positive / non-finite
214
+ depth, MPR is tried before giving up. `shape_cast` and `compute_penetration`
215
+ use it for the same reason.
216
+
217
+ ### Bonus utilities
218
+ - `core/geom/3d/line/line3_closest_points_segment_segment.js` — generally
219
+ useful 3D segment-segment closest-pair via Ericson §5.1.9.
220
+ - `core/collection/PairUint32Map.js` — non-allocating
221
+ `Map<(u32, u32) u32>` with Robin Hood + Fibonacci hash.
222
+
223
+ ---
224
+
225
+ ## Limitations / Known caveats
226
+
227
+ - **Multi-collider material precision**: solver reads friction/restitution
228
+ from the first-attached collider of each body. Mixed-material compound
229
+ bodies lose accuracy here. The contact-filter callback's `colliderA/B`
230
+ arguments are similarly the body's primary collider, not the specific
231
+ collider in contact.
232
+ - **EPA on smooth shapes**: degenerates (no flat face to converge on).
233
+ Mitigated by closed-form paths for sphere/cube/capsule pairs and by the
234
+ **MPR fallback** on EPA non-convergence; exotic convex shapes vs spheres can
235
+ still occasionally fail if both EPA and MPR degenerate.
236
+ - **EPA on `Triangle3D`** *resolved.* The concave dispatch now uses the
237
+ closed-form `sphere_triangle_contact` / `box_triangle_contact` /
238
+ `capsule_triangle_contact` solvers (P1.1a–c) instead of per-triangle GJK+EPA
239
+ for those primitives, so a sphere/box/capsule on a heightmap or mesh decelerates
240
+ and settles correctly; the `narrowphase_concave.spec.js` "drop and settle"
241
+ cases and the mesh torus-knot settle test are **un-skipped**. Per-triangle
242
+ GJK+EPA remains only as the fallback for *other* convex shapes vs triangles
243
+ (and `compute_penetration` still uses the half-space pre-test there).
244
+ - **Box-box edge-edge contact**: a single point at the true closest-pair of the
245
+ two edges (P3.2), not the old body-centre midpoint. This is geometrically
246
+ correct for a true edge-edge crossing; **multi-point** edge contacts (for
247
+ near-parallel edges) remain a backlog refinement.
248
+ - **CCD floor only**: speculative margin via the fattened AABB prevents
249
+ most tunnelling. No per-body swept shape-cast for very fast objects.
250
+ - **Cross-runtime determinism is not guaranteed**: `Math.sin/cos/exp/log`
251
+ are ULP-correct but not bit-exact across V8 / SpiderMonkey / JSC.
252
+ - **Dynamic concave bodies under TGS** *resolved by per-substep re-detection
253
+ (below); kept here for the rationale.* The substep loop normally re-derives
254
+ contact geometry analytically from the per-triangle contact feature (witness
255
+ anchors + normal) captured once by narrowphase and held fixed for the whole
256
+ outer step. For a convex body the contact feature is stable under the small
257
+ per-step motion, so this is exact; for a *dynamic concave mesh body* (e.g. a
258
+ torus knot rocking on its own lobes) the supporting triangle itself changes
259
+ as the body rocks, so freezing the feature would pump a little energy in and
260
+ the body would rock / slowly sink instead of settling. Note this is NOT a
261
+ contact-precision issue
262
+ the knot already uses the exact closed-form box-triangle solver (P1.1b);
263
+ the problem is purely that TGS freezes *which* feature is in contact across
264
+ substeps. The common concave case — a convex dynamic body on static concave
265
+ terrain is unaffected (the convex side's feature is stable), and that is
266
+ the only concave case the engine targets.
267
+
268
+ **Interim fix (implemented): per-substep concave re-detection.** For
269
+ contact pairs involving a concave body, the substep loop re-runs the
270
+ concave narrowphase geometry at the current substep pose (instead of the
271
+ analytic refresh that freezes the feature) and re-prepares those contacts
272
+ from the fresh witness/normal/depth — so the contact normal tracks the
273
+ rocking body and no energy is pumped in. Convex pairs keep the cheap
274
+ analytic refresh. This is ~Nx narrowphase cost on concave-involved pairs
275
+ (acceptable they're rare), gated by collider convexity. Un-skips the
276
+ torus-knot dynamic-settle test.
277
+
278
+ **Better long-term fix: convex collision proxies (not raw concave).** Every
279
+ major engine (Box2D, Jolt, PhysX, Rapier) requires dynamic bodies to be
280
+ convex or convex-decomposed; raw concave meshes are static-only. The right
281
+ granularity is a *few* convex pieces NOT the thousands of tets a
282
+ volumetric mesher produces (tet count collider/BVH-leaf count, which
283
+ explodes the broadphase for an awake body; tet meshing is for a future
284
+ FEM/soft-body subsystem, not rigid collision). See the "Convex collision
285
+ proxies for dynamic concave bodies" backlog item — a 3D convex hull builder
286
+ (single-hull proxy covers most dynamic objects) plus an optional
287
+ few-hull (V-HACD-style) decomposition. Those supersede the interim
288
+ per-substep re-detection once built.
289
+
290
+ ---
291
+
292
+ ## Backlog (planned, in scope)
293
+
294
+ ### Solver quality (next major work)
295
+
296
+ These items move the engine from "competent" to "great". TGS is the next
297
+ significant solver-architecture change; joints come after, once the TGS
298
+ scaffolding is in place.
299
+
300
+ - **TGS (Temporal Gauss-Seidel) substepping with split-impulse** — Phases
301
+ 1–3 **LANDED**. The solver is now a staged TGS pipeline
302
+ (`solver/solve_contacts.js`: `prepare_contacts` → per substep
303
+ [`refresh_contacts` `warm_start_contacts` `solve_velocity`
304
+ `solve_position`] `apply_restitution`), driven by the substep loop in
305
+ `PhysicsSystem.fixedUpdate`. Defaults: `substeps = 4`,
306
+ `velocityIterations = 4`, `positionIterations = 1` (all fields on
307
+ `PhysicsSystem`).
308
+ - **Phase 1 split impulse.** Position correction runs on a per-body
309
+ pseudo-velocity (`__pseudo_velocity`) folded into the pose by
310
+ `integrate_position` and discarded; depth correction never
311
+ contaminates persistent velocity.
312
+ - **Phase 2one-shot restitution.** Velocity pass is pure
313
+ non-penetration; restitution is a single post-loop pass driving
314
+ `vn -e·vn_approach`, gated on a running max normal impulse
315
+ (`maxNormalImpulse`) so transient collisions still bounce under
316
+ per-substep warm-start.
317
+ - **Phase 3 substep loop.** `substeps` sub-iterations at `h = dt/N`.
318
+ Forces consumed once at full `dt` before the loop; gravity applied
319
+ per substep; **warm-start replayed per substep** (the crux a
320
+ per-substep impulse balances one substep of gravity, so resting
321
+ stacks hold at zero velocity). Contact geometry is re-derived
322
+ **analytically** each substep from frozen local witness anchors +
323
+ the trusted prepare-time depth (a sign-robust delta), so narrowphase
324
+ runs **once** per outer step cheaper than the originally-planned
325
+ per-substep match-and-merge refresh, and exact for convex
326
+ primitives whose contact feature is stable under small motion.
327
+
328
+ Results vs the single-step solver: a 100:1 mass ratio now stacks
329
+ instead of crushing through (regression test added); 8-cube stacks
330
+ settle to zero velocity and sleep (were impossible long-term under SI);
331
+ falling-tower bench cost unchanged (~48 ms/1000 active bodies);
332
+ `substeps = 1` reproduces the single-step result bit-for-bit-ish
333
+ (one-frame restitution delay aside).
334
+
335
+ **Hard-won lessons (for REVIEW_002):**
336
+ - Warm-start MUST be per-substep, not once. Replaying a full-frame
337
+ impulse once while gravity arrives per substep over-pushes resting
338
+ contacts and *explodes* deep stacks. Per-substep warm-start +
339
+ per-substep gravity cancel exactly at rest.
340
+ - Restitution must gate on the *running max* normal impulse, not the
341
+ end-of-loop valueper-substep warm-start relaxes a transient
342
+ contact's `j_n` back to ~0 by the end, which would suppress the
343
+ bounce.
344
+ - Analytic separation re-derivation beats per-substep narrowphase
345
+ for convex shapes (cheaper, no manifold-lifecycle churn) but is
346
+ only as good as the frozen normal — see the concave caveat below.
347
+
348
+ Follow-ups since the core landed:
349
+ - [x] **Box-box SAT reference tie-break deadband** aligned cube
350
+ stacks (4–10 high) now settle to zero velocity and sleep; the
351
+ reference-face flip-flop that creeped/toppled them is gone.
352
+ - [x] **Per-substep contact re-detection for concave pairs** — lifts
353
+ the dynamic-concave-body limitation; the torus-knot dynamic-settle
354
+ test is un-skipped. Concave pairs re-run narrowphase geometry each
355
+ substep (`redetect_concave_contacts`); convex pairs keep the cheap
356
+ analytic refresh.
357
+
358
+ Remaining (Phases 4–6) — now complete:
359
+ - [x] Regression coverage: heavy-on-light pyramid (10× capstone on two
360
+ light cubes settles + sleeps) and a ragdoll-stub (shoulder
361
+ ball-socket + elbow hinge arm hangs, stays articulated, settles).
362
+ - [x] REVIEW_002 retrospective — `engine/physics/REVIEW_002.md`.
363
+
364
+ References: Catto 2018 ("Soft Constraints" GDC talk + the TGS
365
+ follow-up); Box2D v3 source (`b2ApplyRestitution`, the substep solver
366
+ stages); Rapier as the closest architectural sibling.
367
+
368
+ - [x] **Constraints / joints DONE (phases 1–7 below).** One configurable
369
+ 6-DOF joint (lock/free/limit/motor/spring + swing-twist cone-twist) plus
370
+ the raycast vehicle. Covers chains/ropes, ragdolls, vehicles (incl.
371
+ suspension), and the mechanical set (doors, pistons, welds, sliders,
372
+ powered hinges/wheels). The design rationale below is kept as history; the
373
+ phasing checklist records what landed. Solver/joint retrospective in
374
+ `REVIEW_003.md`.
375
+
376
+ Original framing (now satisfied): TGS unblocked it (joint-chain
377
+ convergence is a TGS sweet spot), warm-start + per-substep + island
378
+ machinery was reusable, and the SPOOK compliance dial gave spring
379
+ constraints essentially for free.
380
+
381
+ **Foundational work (do first): generalise the solver to constraint
382
+ rows.** Today `solver/solve_contacts.js` is hard-coded to the
383
+ contact-shape constraint (normal + 2 friction tangents, ≥0 clamp,
384
+ restitution, penetration bias). Joints are equality / inequality
385
+ constraints on relative velocity at anchors, generally bilateral
386
+ (impulse may be ±) with optional limits and motors. The clean shape —
387
+ and what Jolt / Box2D-v3 do — is a **generic constraint row**: a
388
+ Jacobian (linear + angular parts per body), an effective mass, a bias
389
+ (position error × SPOOK gain, or motor target), and impulse bounds
390
+ `[lo, hi]` (`[0,∞)` for a contact/limit, `(−∞,∞)` for an equality,
391
+ `[−maxForce·h, +maxForce·h]` for a motor). Each joint type just fills
392
+ in its rows; the existing per-body impulse-apply primitive
393
+ (`apply_impulse_to_body` + `world_inverse_inertia_apply`), the
394
+ per-substep warm-start, the islands, and the split-impulse / SPOOK
395
+ position handling are all reused. Contacts become *one* constraint
396
+ type among several rather than the hard-coded path.
397
+
398
+ The specific constraint set, its use-case mapping, and per-type
399
+ architecture-fit assessment are under review (see the constraints
400
+ sketch). High level: ball-socket / distance / spring / weld and the
401
+ grab constraint are near drop-ins on the row machinery; hinge /
402
+ prismatic / cone-twist / motors / limits add angular-row + bounded-row
403
+ mechanics (still within the impulse framework); raycast vehicles,
404
+ conveyor surface-velocity, and gear/pulley coupling are higher-level
405
+ systems or contact modifiers that sit *on top of* the primitives
406
+ rather than being generic rows.
407
+
408
+ **Decision: build ONE configurable 6-DOF constraint** (PhysX D6 / Jolt
409
+ SixDOF), implemented mode-by-mode. The `Joint` ECS component carries
410
+ `dofMode[6]` (3 linear, 3 angular) each `{locked|free|limited|spring|
411
+ motor}` + per-DOF limit/spring/motor config + warm-start accumulators.
412
+ Concrete joints are configs, not new code (ball-socket = lock 3 linear;
413
+ hinge = lock 3 linear + 2 angular; weld = lock 6; cone-twist = lock 3
414
+ linear + limit 3 angular; suspension = spring 1 linear + lock rest).
415
+
416
+ Phasing:
417
+ 1. [x] Constraint-row solver as a **parallel row set** in the TGS
418
+ substep loop (contacts left untouched, not ported lower risk).
419
+ `constraint/solve_constraints.js` reuses `world_inverse_inertia`,
420
+ per-substep warm-start, and the SPOOK position bias; `Joint`
421
+ component + `link_joint`/`unlink_joint` in PhysicsSystem;
422
+ `jointIterations` knob. Bodies need no collider.
423
+ 2. [x] **LOCKED linear DOFs ball-socket.** Pendulum (anchor pinned
424
+ to a world pivot, body swings) and a 2-link chain (body↔body,
425
+ joints stay connected, chain hangs) pass. **chains, ropes,
426
+ pendulums working.**
427
+ 3. [x] LOCKED angular + linear DOFs in the frame basis — **weld,
428
+ hinge, prismatic done**. Joint frame bases
429
+ (`localBasisA`/`localBasisB`); BOTH linear and angular rows now
430
+ resolve in frame A's axes (cleared the world-axis linear debt — the
431
+ solver is fully frame-relative). Angular: relative rotation
432
+ `qD = conj(qA)·qB` → small-angle error, ωB−ωA rows + SPOOK bias.
433
+ Linear: `C·axis` error, vA−vB rows. `asWeld()` / `asHinge(axis)` /
434
+ `asPrismatic(axis)` presets. Verified: weld holds pose + orientation
435
+ against an off-centre torque; hinge swings about its free axis only
436
+ (locked axes < 0.02); prismatic slides along its one free axis,
437
+ locked on the others; all LOCKED-mode tests still green after the
438
+ frame-basis rewrite.
439
+ 4. [x] LIMITED + MOTOR (bounded rows) → doors, pistons, wheel
440
+ spin/drive, joint ROM. **LIMITED done** (linear + angular):
441
+ `setLinearLimit(axis,lo,hi)` / `setAngularLimit(axis,lo,hi)` set a
442
+ per-DOF travel/ROM range. The whole row set is now **one mode-
443
+ agnostic solve** parameterised by `(bias, clamp range)`: LOCKED is
444
+ the bilateral case (Baumgarte bias, unclamped); LIMITED is a
445
+ **speculative (β=1) one-sided velocity constraint** that removes
446
+ exactly the approach velocity so the DOF *lands on* its stop (no
447
+ penetration, no rebound — an inelastic end-stop) and self-gates when
448
+ far from the bound; only the push-out side of the bias is clamped so
449
+ a teleport is eased out, not yanked. Verified: a vertical slider
450
+ falls freely then stops dead on its lower stop (lands at the bound,
451
+ no overshoot/rebound, locked axes held); a spun hinge stops dead on
452
+ each ±end-stop with no rebound and holds. Angular position is the
453
+ small-angle measure (`2·sin(θ/2)`) — accurate for modest ROM, see
454
+ phase 6 for wide cones. **MOTOR next** (target-velocity row, impulse
455
+ clamped to `±maxForce·h`).
456
+ 5. [x] SPRING (SPOOK soft) suspension, bungees, soft ragdolls.
457
+ `setLinearSpring(axis,k,c)` / `setAngularSpring(axis,k,c)`. A
458
+ compliant (regularised) row in the same unified solve: per substep
459
+ `denom = c + h·k`, compliance `γ = 1/(h·denom)`, restoring bias
460
+ `(k/denom)·C`, softened mass `1/(K+γ)`; the iteration carries one
461
+ extra `+ γ·λ_accum` term (γ = 0 ⇒ the LOCKED/LIMITED/MOTOR rows are
462
+ bit-for-bit unchanged). Verified: a vertical strut settles at exactly
463
+ the m·g/k deflection and a stiffer spring sags less and stays stable;
464
+ an undamped spring oscillates about equilibrium (stores energy) while
465
+ a damped one comes to rest; a torsional spring holds a gravity-loaded
466
+ hinge at its balance angle. Suspension element ready (the simulated-
467
+ wheel option for phase 7); also the soft basis for cone-twist.
468
+ 6. [x] Cone-twist / swing-twist angular limits → ragdolls. Opt-in
469
+ `Joint.swingTwist` (or the `asConeTwist(twistLo,twistHi,swingY[,swingZ])`
470
+ preset) switches the angular position measure from the per-axis
471
+ small-angle vector to a swing-twist decomposition: angular X = twist
472
+ about the bone, Y/Z = swing off it, each an **exact** angle. The
473
+ existing LIMITED/SPRING/LOCKED rows are reused unchanged on those
474
+ positions, so a twist/swing limit holds at the true angle at wide
475
+ ROM (a 1.2 rad swing stops at 1.2, where the small-angle proxy
476
+ drifts to ~1.287). Verified: exact swing/twist stops, free-within-
477
+ cone, twist/swing independence; default (small-angle) path untouched.
478
+ **Decision inlined, not the Quaternion method.** Benchmarked the
479
+ allocation-free inlined `swing_twist_error` against
480
+ `Quaternion.computeSwingAndTwist` (`swing_twist.bench.spec.js`): the
481
+ inline is **~5x** faster than the method with reused out-params and
482
+ **~10x** vs the naive fresh-allocation form (object property access +
483
+ normalize + a quaternion multiply + GC). In the per-substep
484
+ per-joint hot loop that margin is worth the duplicated math, so the
485
+ solver inlines it (the Quaternion method stays for general callers).
486
+ 7. [x] Vehicle layer **raycast-vehicle controller**
487
+ (`vehicle/RaycastVehicle.js`): single chassis body + raycast wheels.
488
+ Per frame (before `fixedUpdate`) each wheel casts its suspension ray,
489
+ applies a spring+damper suspension force along the contact normal
490
+ (`applyForceAt`), and a tyre-friction impulse (`applyImpulseAt`)
491
+ lateral grip that cancels side-slip plus longitudinal drive/brake,
492
+ clamped together to a friction circle μ·N. `addWheel`, `setSteering`,
493
+ `setDriveForce`, `setBrake`; per-wheel runtime (contact, compression,
494
+ normal, spin) for rendering. A controller on top of the public
495
+ `raycast` + force API, not a new constraint; the 6-DOF spring+motor
496
+ is the simulated-wheel alternative. Verified: hovers on its springs
497
+ (4 contacts, settled), drives/coasts/brakes along its axis, tyre grip
498
+ arrests a sideways shove, steering turns it upright, and it free-falls
499
+ cleanly when airborne. Note: suspension is one dt-force per frame (not
500
+ per-substep), so a resting chassis carries a ~g·h velocity-sample
501
+ artifact (it hovers stably; position is steady to sub-cm). Ray
502
+ accuracy follows `PhysicsSystem.raycast` now narrowphase-exact for
503
+ sphere / box / capsule / mesh / heightmap ground.
504
+ 8. [ ] Extras: pulley, gear, conveyor (contact surface-velocity),
505
+ breakable-joint flag.
506
+
507
+ Foundation gaps both now closed:
508
+ - [x] **Island integration.** Jointed dynamic-dynamic bodies are
509
+ unioned into one island (`IslandBuilder` Pass 1b), so a chain /
510
+ ragdoll sleeps and wakes as a unit; `__wake_joints` propagates wake
511
+ across a joint when one side is awake and the other asleep
512
+ (e.g. a kinematic/motor driver pulling a sleeping chain). Verified:
513
+ a damped chain settles and both links sleep in one sleep group.
514
+ - [x] **Generation-checked body references.** `solve_joints`,
515
+ `IslandBuilder` Pass 1b and `__wake_joints` all gate on
516
+ `storage.is_valid(packedId)`, so a joint to an unlinked / slot-reused
517
+ body goes inert instead of attaching to the wrong body or crashing.
518
+ Verified: unlinking a jointed body leaves the joint inert and the
519
+ survivor free.
520
+
521
+ References: Catto / Box2D-v3 joint solvers; Jolt's `Constraint` base
522
+ (`SetupVelocityConstraint` / `WarmStartVelocityConstraint` /
523
+ `SolveVelocityConstraint` / `SolvePositionConstraint`); PhysX D6 /
524
+ ODE joint taxonomy.
525
+
526
+ ### Stability
527
+ - [x] **Closed-form triangle-vs-primitive solvers** `sphere_triangle_contact`
528
+ / `box_triangle_contact` / `capsule_triangle_contact` (P1.1a–c), wired into
529
+ the concave decomposition dispatch in place of per-triangle GJK+EPA for
530
+ those primitives. Un-skipped the `narrowphase_concave.spec.js` ball-on-
531
+ heightmap / mesh-cube settle tests and the `PhysicsSystem.spec.js`
532
+ torus-knot test. Per-triangle GJK+EPA remains only as the fallback for
533
+ *other* convex shapes vs triangles; `compute_penetration` keeps its
534
+ half-space pre-test there (its closed-mesh over-report caveat stands for
535
+ that fallback path).
536
+ - [ ] **Edge-edge multi-point manifold** for near-parallel box edge contacts
537
+ (the single closest-pair point from P3.2 is correct for a true edge-edge
538
+ crossing; this is the multi-point refinement).
539
+ - [ ] **Per-contact source-collider tracking** so multi-material compound
540
+ bodies get accurate per-contact friction/restitution. Requires
541
+ stashing the collider identity in the manifold contact stride.
542
+
543
+ ### Performance / Scale
544
+ - [ ] **Per-body linear CCD shape-cast**: optional opt-in for fast-moving
545
+ bodies where speculative margin isn't enough. The bench's falling
546
+ tower (1km drop onto a 1cm floor) is the concrete reproducer —
547
+ 180 / 1000 bodies tunnel.
548
+ - [ ] **Broadphase BVH balance / raycast traversal cost**: the raycast bench
549
+ (`queries/raycast.bench.spec.js`) shows ~linear-in-N per-ray cost
550
+ (~50 µs/ray at 500 bodies), i.e. a ray walks most of the tree — the static
551
+ BVH built by sequential `link` inserts is poorly balanced. Orthogonal to
552
+ raycast narrowphase (which adds only per-crossed-leaf refine, <1% here);
553
+ affects every BVH query. Needs a balanced/refitting build (SAH or
554
+ incremental rotation) on the static tree.
555
+ - [ ] **Per-island parallel solve**: today's island data layout would
556
+ allow worker-based solving once `SharedArrayBuffer` is available.
557
+ Out-of-scope unless / until SAB is universally usable.
558
+
559
+ ### Features
560
+ - [ ] **Convex collision proxies for dynamic concave bodies.** The long-term
561
+ replacement for the interim per-substep concave re-detection (see
562
+ Limitations) and how every major engine handles dynamic non-convex
563
+ shapes: collide a *few* convex pieces, never the raw concave mesh.
564
+ 1. **3D convex hull builder** (meep has only 2D hulls today —
565
+ `core/geom/2d/convex-hull/`). A single hull of a mesh is one
566
+ collider / one broadphase leaf and covers the overwhelming majority
567
+ of dynamic objects (thrown props, debris). Pairs with the existing
568
+ "Convex hull shape + eigen-inertia" item below.
569
+ 2. **Few-hull (V-HACD-style) approximate convex decomposition** for
570
+ shapes whose concavity matters (a cup, a chair): ~8–64 fat convex
571
+ hulls = 8–64 colliders, two orders of magnitude below a tet mesh.
572
+ Each hull is convex → stable contact feature → the TGS analytic refresh
573
+ is exact → no per-substep re-detection, no rocking. Granularity is the
574
+ whole point: collider/BVH-leaf count must stay small for an *awake*
575
+ dynamic body (the volumetric tet-mesher under `core/geom/3d/tetrahedra/`
576
+ is the wrong tool here — thousands of pieces — and belongs to a future
577
+ FEM/soft-body subsystem, not rigid collision).
578
+ - [ ] **Convex hull shape** with eigen-based principal-axes inertia
579
+ derivation. Hooks `matrix_eigenvalues_in_place` from the existing
580
+ linalg layer.
581
+ - [ ] **Cylinder / cone shapes** (closed-form pairs against the existing
582
+ family + GJK+EPA fallback for general convex).
583
+
584
+ ### API polish
585
+ - [x] **`overlap(shape, position, rotation, output, output_offset,
586
+ filter?)`** — broadphase + narrowphase overlap query for kinematic
587
+ / AOE / selection use cases. Body_ids written into a caller-sized
588
+ Uint32Array buffer. Convex query shape only; concave candidates
589
+ are routed through the per-triangle decomposition path.
590
+ - [x] **`shapeCast(ray, shape, rotation, result, filter?)`** for
591
+ character controllers and kinematic shape sweeps. Broadphase
592
+ swept-AABB against both BVHs; per-candidate AABB-slab interval
593
+ narrowing + coarse step + GJK bisection for time-of-impact. The
594
+ output `result.normal` is the true contact-surface normal at the
595
+ kiss point, computed by re-running GJK + EPA at `best_t` on the
596
+ winning candidate (falls back to `-ray.direction` only on EPA
597
+ degeneracies).
598
+ - [x] **`compute_penetration(out_direction, shape_a, pos_a, rot_a,
599
+ shape_b, pos_b, rot_b)`** — standalone geometry primitive (no
600
+ PhysicsSystem) for resolving overlap between two shapes at given
601
+ poses. Returns depth + outward direction. Convex × convex via
602
+ GJK + EPA; convex × concave via per-triangle half-space test.
603
+
604
+ ### Raycast narrowphase (done)
605
+
606
+ **Problem.** `raycast` (and the suspension ray inside `RaycastVehicle`) resolves
607
+ only to the nearest BVH leaf's *inflated* AABB: `result.t` is the distance to
608
+ that fattened box and `result.normal` is its face normal. Exact for an
609
+ axis-aligned box (modulo the broadphase margin), coarse for spheres / capsules /
610
+ rotated boxes / meshes / heightmaps. Refine each candidate against the true
611
+ shape to return the exact surface distance + normal. `shapeCast` already does
612
+ this for swept convex shapes via GJK+EPA; `raycast` should get the same
613
+ treatment with cheap analytic primitives on the hot path.
614
+
615
+ **Design.** Mirror `narrowphase_step`'s dispatch: closed-form ray tests for the
616
+ common primitives, a generic GJK fallback for the rest. The structural change is
617
+ in the BVH walk — the nearest *leaf AABB* is **not** the nearest *shape hit* (a
618
+ ray can clip a near fat-AABB but miss its shape while hitting a farther one), so
619
+ every crossing leaf must be refined, with subtrees pruned by inflated-AABB
620
+ `t_near` vs the best *refined* `t` (conservative-correct: a shape hit is always
621
+ ≥ its tight AABB entry ≥ its inflated AABB entry). A leaf whose ray crosses the
622
+ fat AABB but misses the true shape now contributes **no hit** — the key
623
+ correctness gain.
624
+
625
+ Phasing (each phase: implement → spec → run from `H:/git/moh` → commit):
626
+
627
+ 1. [x] **Ray-primitive helpers** — landed as `narrowphase/ray_shapes.js`
628
+ (local-frame `ray_sphere_local` / `ray_box_local` / `ray_capsule_local`,
629
+ not `core/geom`: the (`t`, normal, miss = `Infinity`, first-hit-from-outside)
630
+ convention is raycast-specific, and the dispatch shares one ray→local
631
+ transform across them). Built local-frame (unit direction ⇒ `t` preserved;
632
+ rotate the local normal back). Triangle MT is inlined in the concave path
633
+ (the existing `computeTriangleRayIntersection` writes a `SurfacePoint3` and
634
+ returns no `t` — unsuited to the buffer-flyweight loop). Colocated specs.
635
+ 2. [x] **Ray-narrowphase dispatch** `narrowphase/refine_ray_hit.js`:
636
+ `(shape, position, rotation, ox,oy,oz, dx,dy,dz, tMax, outNormal) → t`.
637
+ Type-marker dispatch (`isUnitSphereShape3D` / `isBoxShape3D` /
638
+ `isCapsuleShape3D`) to the analytic primitives; a generic convex fallback
639
+ for `TransformedShape3D` / `UnionShape3D` / other (GJK ray-cast, or reuse
640
+ `shape_cast` with a zero-radius `PointShape3D`).
641
+ 3. [x] **Concave path** in the dispatch: for `is_convex === false` (mesh /
642
+ heightmap), enumerate the triangles overlapping the ray's swept AABB
643
+ (`mesh_enumerate_triangles` / `heightmap_enumerate_triangles`), Möller–
644
+ Trumbore each, take the nearest; normal from the triangle winding.
645
+ 4. [x] **Rewire `queries/raycast.js`**: at each leaf, call `refine_ray_hit` on
646
+ the true shape + pose instead of accepting the AABB `t_near`; track the best
647
+ refined `(t, body, normal)`; keep subtree pruning on inflated-AABB `t_near`.
648
+ Same signature / `PhysicsSurfacePoint` result; drop the AABB-face-normal
649
+ block. Multi-collider bodies still resolve the primary collider only
650
+ (inherited BVH-leaf limitation; note it).
651
+ 5. [x] **Tests**: per-shape exactness (sphere / OBB / capsule / mesh /
652
+ heightmap) — exact `t` and true normal; the **fat-AABB-cross-but-shape-miss
653
+ ⇒ no hit** case (the correctness win); nearest-of-several across a near miss;
654
+ `filter` and `tMax` honoured. Re-verify `RaycastVehicle` (ride height now
655
+ exact — tighten the test bands if they shift by the old broadphase margin).
656
+ 6. [x] **Bench + docs**: a raycast micro-bench (analytic fast-path cost; confirm
657
+ the fat-AABB-miss rejection doesn't regress throughput); update the "Public
658
+ queries" entry, `raycast.js` header, and the `RaycastVehicle` "AABB-level"
659
+ caveat once exact.
660
+
661
+ Note: this sharpens `RaycastVehicle` suspension on non-box ground and every
662
+ shape query; it does not change the broadphase or any API surface.
663
+
664
+ ---
665
+
666
+ ## Future / out-of-scope
667
+
668
+ These are explicit architectural exclusions or post-v1 explorations.
669
+
670
+ ### Architecture
671
+ - **Cross-runtime bit-exact determinism**: a soft-float library would
672
+ replace `Math.sin/cos/exp/log/pow` in the hot path. The codebase is
673
+ already structured to make this a swap-in at `quat_integrate.js` and
674
+ tangent-basis construction in `build_manifold.js`. Not pursued because
675
+ the same-runtime determinism we have covers the common cases (single-
676
+ device replay, networked lockstep where all clients run the same JS
677
+ engine).
678
+ - **WASM / SIMD**: the engine targets pure-JS portability. SIMD would
679
+ invalidate the determinism story (V8 doesn't expose deterministic
680
+ Float64x2 ops).
681
+ - **Multi-threaded solver**: workers don't share memory cheaply without
682
+ `SharedArrayBuffer` plus the COOP/COEP HTTP headers, which are not
683
+ always available. Single-threaded is good-enough for the awake-body
684
+ budget that matters.
685
+
686
+ ### Simulation extensions
687
+ - **Soft body / cloth / fluids**: the SoA layout in `BodyStorage` and the
688
+ manifold cache are rigid-body shaped. A soft-body system would be a
689
+ parallel subsystem, not an extension.
690
+ - **Reduced-coordinate articulations** (MuJoCo / Featherstone-style):
691
+ game-physics audience runs in maximal coordinates by convention. Not
692
+ on the roadmap.
693
+
694
+ ### Game-side
695
+ - **Vehicle physics** (suspensions, drivetrains): a domain layer that
696
+ sits on top of the rigid-body primitives, not in `meep/`.
697
+ - **Character controllers**: same — `engine/control/first-person/` is the
698
+ natural home.
699
+
700
+ ---
701
+
702
+ ## Notable design files
703
+
704
+ - Original design plan: `C:\Users\Alex\.claude\plans\let-s-plan-to-implement-transient-harp.md`
705
+ - This file (state of play): `engine/physics/PLAN.md`