@woosh/meep-engine 2.140.0 → 2.142.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 (74) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/quaternion/quat3_multiply.d.ts +21 -0
  3. package/src/core/geom/3d/quaternion/quat3_multiply.d.ts.map +1 -0
  4. package/src/core/geom/3d/quaternion/quat3_multiply.js +25 -0
  5. package/src/engine/control/first-person/prototype_first_person_controller.js +5 -0
  6. package/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.d.ts.map +1 -1
  7. package/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.js +67 -42
  8. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOShader.d.ts +12 -22
  9. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOShader.d.ts.map +1 -1
  10. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOShader.js +340 -186
  11. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOUpscaleShader.d.ts +44 -0
  12. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOUpscaleShader.d.ts.map +1 -0
  13. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOUpscaleShader.js +151 -0
  14. package/src/engine/graphics/render/buffer/simple-fx/ao/generateHilbertNoiseTexture.d.ts +14 -0
  15. package/src/engine/graphics/render/buffer/simple-fx/ao/generateHilbertNoiseTexture.d.ts.map +1 -0
  16. package/src/engine/graphics/render/buffer/simple-fx/ao/generateHilbertNoiseTexture.js +78 -0
  17. package/src/engine/physics/PLAN.md +705 -461
  18. package/src/engine/physics/REVIEW_002.md +151 -0
  19. package/src/engine/physics/REVIEW_003.md +166 -0
  20. package/src/engine/physics/constraint/DofMode.d.ts +28 -0
  21. package/src/engine/physics/constraint/DofMode.d.ts.map +1 -0
  22. package/src/engine/physics/constraint/DofMode.js +35 -0
  23. package/src/engine/physics/constraint/solve_constraints.d.ts +38 -0
  24. package/src/engine/physics/constraint/solve_constraints.d.ts.map +1 -0
  25. package/src/engine/physics/constraint/solve_constraints.js +673 -0
  26. package/src/engine/physics/ecs/Joint.d.ts +294 -0
  27. package/src/engine/physics/ecs/Joint.d.ts.map +1 -0
  28. package/src/engine/physics/ecs/Joint.js +402 -0
  29. package/src/engine/physics/ecs/PhysicsSystem.d.ts +52 -0
  30. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  31. package/src/engine/physics/ecs/PhysicsSystem.js +126 -4
  32. package/src/engine/physics/fluid/FluidField.d.ts +14 -10
  33. package/src/engine/physics/fluid/FluidField.d.ts.map +1 -1
  34. package/src/engine/physics/fluid/FluidField.js +14 -10
  35. package/src/engine/physics/fluid/FluidSimulator.d.ts.map +1 -1
  36. package/src/engine/physics/fluid/FluidSimulator.js +0 -1
  37. package/src/engine/physics/fluid/solver/v3_grid_compute_solid_neighbour_mask.d.ts +17 -10
  38. package/src/engine/physics/fluid/solver/v3_grid_compute_solid_neighbour_mask.d.ts.map +1 -1
  39. package/src/engine/physics/fluid/solver/v3_grid_compute_solid_neighbour_mask.js +18 -11
  40. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure.d.ts +13 -10
  41. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure.d.ts.map +1 -1
  42. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure.js +18 -13
  43. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_pcg.d.ts +4 -3
  44. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_pcg.d.ts.map +1 -1
  45. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_pcg.js +15 -11
  46. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.d.ts +24 -22
  47. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.d.ts.map +1 -1
  48. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.js +26 -22
  49. package/src/engine/physics/island/IslandBuilder.d.ts +4 -1
  50. package/src/engine/physics/island/IslandBuilder.d.ts.map +1 -1
  51. package/src/engine/physics/island/IslandBuilder.js +33 -16
  52. package/src/engine/physics/narrowphase/box_box_manifold.d.ts.map +1 -1
  53. package/src/engine/physics/narrowphase/box_box_manifold.js +27 -1
  54. package/src/engine/physics/narrowphase/narrowphase_step.d.ts +33 -0
  55. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  56. package/src/engine/physics/narrowphase/narrowphase_step.js +75 -0
  57. package/src/engine/physics/narrowphase/ray_shapes.d.ts +66 -0
  58. package/src/engine/physics/narrowphase/ray_shapes.d.ts.map +1 -0
  59. package/src/engine/physics/narrowphase/ray_shapes.js +187 -0
  60. package/src/engine/physics/narrowphase/refine_ray_concave.d.ts +16 -0
  61. package/src/engine/physics/narrowphase/refine_ray_concave.d.ts.map +1 -0
  62. package/src/engine/physics/narrowphase/refine_ray_concave.js +145 -0
  63. package/src/engine/physics/narrowphase/refine_ray_hit.d.ts +39 -0
  64. package/src/engine/physics/narrowphase/refine_ray_hit.d.ts.map +1 -0
  65. package/src/engine/physics/narrowphase/refine_ray_hit.js +78 -0
  66. package/src/engine/physics/queries/raycast.d.ts +11 -9
  67. package/src/engine/physics/queries/raycast.d.ts.map +1 -1
  68. package/src/engine/physics/queries/raycast.js +108 -159
  69. package/src/engine/physics/solver/solve_contacts.d.ts +28 -0
  70. package/src/engine/physics/solver/solve_contacts.d.ts.map +1 -1
  71. package/src/engine/physics/solver/solve_contacts.js +169 -1
  72. package/src/engine/physics/vehicle/RaycastVehicle.d.ts +114 -0
  73. package/src/engine/physics/vehicle/RaycastVehicle.d.ts.map +1 -0
  74. package/src/engine/physics/vehicle/RaycastVehicle.js +333 -0
@@ -1,461 +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). The fix is
261
- per-substep contact re-detection for pairs involving a concave body
262
- (re-running narrowphase, or at least re-selecting the deepest triangle,
263
- inside the substep loop) while convex pairs keep the cheap analytic
264
- refresh — a hybrid the substep architecture already accommodates. Medium+
265
- *perfectly* axis-aligned cube stacks can fail to fully settle (and so never
266
- sleep), but *erratically* with height and exact placement — a 5-stack may
267
- jitter while a 7-stack sleeps, and a sub-mm gap flips the outcome. That
268
- chaotic, configuration-sensitive signature is box-box contact-point jitter
269
- (Sutherland-Hodgman clipping selecting different points frame to frame),
270
- NOT the solver confirmed by running the same stacks through both the
271
- single-step and TGS paths. It's the separate box-box-manifold robustness /
272
- stable-feature-ID backlog item, independent of TGS. Realistic (slightly
273
- perturbed, mixed-shape) stacks are unaffected; mass ratios up to ~100:1 and
274
- 4-/7-/8-cube aligned stacks settle and sleep cleanly under TGS.
275
-
276
- ---
277
-
278
- ## Backlog (planned, in scope)
279
-
280
- ### Solver quality (next major work)
281
-
282
- These items move the engine from "competent" to "great". TGS is the next
283
- significant solver-architecture change; joints come after, once the TGS
284
- scaffolding is in place.
285
-
286
- - **TGS (Temporal Gauss-Seidel) substepping with split-impulse** Phases
287
- 1–3 **LANDED**. The solver is now a staged TGS pipeline
288
- (`solver/solve_contacts.js`: `prepare_contacts` per substep
289
- [`refresh_contacts` → `warm_start_contacts` → `solve_velocity` →
290
- `solve_position`] → `apply_restitution`), driven by the substep loop in
291
- `PhysicsSystem.fixedUpdate`. Defaults: `substeps = 4`,
292
- `velocityIterations = 4`, `positionIterations = 1` (all fields on
293
- `PhysicsSystem`).
294
- - **Phase 1 split impulse.** Position correction runs on a per-body
295
- pseudo-velocity (`__pseudo_velocity`) folded into the pose by
296
- `integrate_position` and discarded; depth correction never
297
- contaminates persistent velocity.
298
- - **Phase 2 — one-shot restitution.** Velocity pass is pure
299
- non-penetration; restitution is a single post-loop pass driving
300
- `vn -e·vn_approach`, gated on a running max normal impulse
301
- (`maxNormalImpulse`) so transient collisions still bounce under
302
- per-substep warm-start.
303
- - **Phase 3 — substep loop.** `substeps` sub-iterations at `h = dt/N`.
304
- Forces consumed once at full `dt` before the loop; gravity applied
305
- per substep; **warm-start replayed per substep** (the crux — a
306
- per-substep impulse balances one substep of gravity, so resting
307
- stacks hold at zero velocity). Contact geometry is re-derived
308
- **analytically** each substep from frozen local witness anchors +
309
- the trusted prepare-time depth (a sign-robust delta), so narrowphase
310
- runs **once** per outer step — cheaper than the originally-planned
311
- per-substep match-and-merge refresh, and exact for convex
312
- primitives whose contact feature is stable under small motion.
313
-
314
- Results vs the single-step solver: a 100:1 mass ratio now stacks
315
- instead of crushing through (regression test added); 8-cube stacks
316
- settle to zero velocity and sleep (were impossible long-term under SI);
317
- falling-tower bench cost unchanged (~48 ms/1000 active bodies);
318
- `substeps = 1` reproduces the single-step result bit-for-bit-ish
319
- (one-frame restitution delay aside).
320
-
321
- **Hard-won lessons (for REVIEW_002):**
322
- - Warm-start MUST be per-substep, not once. Replaying a full-frame
323
- impulse once while gravity arrives per substep over-pushes resting
324
- contacts and *explodes* deep stacks. Per-substep warm-start +
325
- per-substep gravity cancel exactly at rest.
326
- - Restitution must gate on the *running max* normal impulse, not the
327
- end-of-loop value — per-substep warm-start relaxes a transient
328
- contact's `j_n` back to ~0 by the end, which would suppress the
329
- bounce.
330
- - Analytic separation re-derivation beats per-substep narrowphase
331
- for convex shapes (cheaper, no manifold-lifecycle churn) but is
332
- only as good as the frozen normal — see the concave caveat below.
333
-
334
- Remaining (Phases 4–6, backlog):
335
- - More regression coverage: heavy-on-light *pyramid*, a
336
- ragdoll-stub once joints exist.
337
- - **Per-substep contact re-detection for concave pairs** to lift the
338
- dynamic-concave-body limitation (see Limitations) and un-skip the
339
- torus-knot dynamic-settle test. The analytic refresh freezes *which*
340
- triangle is the contact feature across substeps, which is wrong for
341
- a body rocking on a mesh; convex pairs keep the cheap analytic path.
342
- - REVIEW_002 retrospective.
343
-
344
- References: Catto 2018 ("Soft Constraints" GDC talk + the TGS
345
- follow-up); Box2D v3 source (`b2ApplyRestitution`, the substep solver
346
- stages); Rapier as the closest architectural sibling.
347
-
348
- - [ ] **Joints** (distance, hinge, ball-socket, prismatic — and beyond).
349
- *To be refined when we get to it.* Joints want the TGS substep
350
- iteration model in place first joint-chain convergence is a TGS
351
- sweet spot and a PGS pain point, and any constraint structures
352
- written against PGS today become migration cost once TGS lands.
353
- The solver loop is already set up to iterate
354
- `contacts joints` and the manifold-style impulse persistence is
355
- there; what's missing is the constraint structures themselves,
356
- joint-limit handling, the motor / soft-constraint surface, and
357
- the authoring API. Plan the phased breakdown once TGS lands —
358
- until then this stays as a visible dependency placeholder.
359
-
360
- ### Stability
361
- - [ ] **Closed-form triangle-vs-primitive solvers**
362
- (`triangle_sphere_contact`, `triangle_box_contact`,
363
- `triangle_capsule_contact`). The decomposition machinery is in
364
- place (`Triangle3D` flyweight, `heightmap_enumerate_triangles` /
365
- `mesh_enumerate_triangles`, `decompose_to_triangles` dispatcher,
366
- `aabb_world_to_local`, `narrowphase_step.js` concave branch), but
367
- the per-triangle narrowphase uses GJK + EPA which hits the
368
- smooth-shape iteration cap and `Triangle3D`'s degenerate-support
369
- issue. Closed-form solvers per primitive bypass both. This is now
370
- the single biggest accuracy gap in the engine — it would:
371
- - Unblock the `narrowphase_concave.spec.js` skipped tests (ball
372
- drops on heightmap / mesh-cube settle correctly).
373
- - Unblock the `PhysicsSystem.spec.js` torus-knot test.
374
- - Improve `compute_penetration`'s closed-mesh accuracy (currently
375
- documented over-reports on side faces).
376
- Existing primitive pair solvers (`sphere_box_contact`,
377
- `capsule_box_multi_contacts`, `box_box_manifold`) are the
378
- blueprint. Triangle is roughly a box with two half-extents = 0.
379
- - [ ] **Edge-edge multi-point manifold** for skewed box contacts.
380
- - [ ] **Per-contact source-collider tracking** so multi-material compound
381
- bodies get accurate per-contact friction/restitution. Requires
382
- stashing the collider identity in the manifold contact stride.
383
-
384
- ### Performance / Scale
385
- - [ ] **Per-body linear CCD shape-cast**: optional opt-in for fast-moving
386
- bodies where speculative margin isn't enough. The bench's falling
387
- tower (1km drop onto a 1cm floor) is the concrete reproducer
388
- 180 / 1000 bodies tunnel.
389
- - [ ] **Per-island parallel solve**: today's island data layout would
390
- allow worker-based solving once `SharedArrayBuffer` is available.
391
- Out-of-scope unless / until SAB is universally usable.
392
-
393
- ### Features
394
- - [ ] **Convex hull shape** with eigen-based principal-axes inertia
395
- derivation. Hooks `matrix_eigenvalues_in_place` from the existing
396
- linalg layer.
397
- - [ ] **Cylinder / cone shapes** (closed-form pairs against the existing
398
- family + GJK+EPA fallback for general convex).
399
-
400
- ### API polish
401
- - [x] **`overlap(shape, position, rotation, output, output_offset,
402
- filter?)`** broadphase + narrowphase overlap query for kinematic
403
- / AOE / selection use cases. Body_ids written into a caller-sized
404
- Uint32Array buffer. Convex query shape only; concave candidates
405
- are routed through the per-triangle decomposition path.
406
- - [x] **`shapeCast(ray, shape, rotation, result, filter?)`** for
407
- character controllers and kinematic shape sweeps. Broadphase
408
- swept-AABB against both BVHs; per-candidate AABB-slab interval
409
- narrowing + coarse step + GJK bisection for time-of-impact. The
410
- output `result.normal` is the true contact-surface normal at the
411
- kiss point, computed by re-running GJK + EPA at `best_t` on the
412
- winning candidate (falls back to `-ray.direction` only on EPA
413
- degeneracies).
414
- - [x] **`compute_penetration(out_direction, shape_a, pos_a, rot_a,
415
- shape_b, pos_b, rot_b)`** — standalone geometry primitive (no
416
- PhysicsSystem) for resolving overlap between two shapes at given
417
- poses. Returns depth + outward direction. Convex × convex via
418
- GJK + EPA; convex × concave via per-triangle half-space test.
419
-
420
- ---
421
-
422
- ## Future / out-of-scope
423
-
424
- These are explicit architectural exclusions or post-v1 explorations.
425
-
426
- ### Architecture
427
- - **Cross-runtime bit-exact determinism**: a soft-float library would
428
- replace `Math.sin/cos/exp/log/pow` in the hot path. The codebase is
429
- already structured to make this a swap-in at `quat_integrate.js` and
430
- tangent-basis construction in `build_manifold.js`. Not pursued because
431
- the same-runtime determinism we have covers the common cases (single-
432
- device replay, networked lockstep where all clients run the same JS
433
- engine).
434
- - **WASM / SIMD**: the engine targets pure-JS portability. SIMD would
435
- invalidate the determinism story (V8 doesn't expose deterministic
436
- Float64x2 ops).
437
- - **Multi-threaded solver**: workers don't share memory cheaply without
438
- `SharedArrayBuffer` plus the COOP/COEP HTTP headers, which are not
439
- always available. Single-threaded is good-enough for the awake-body
440
- budget that matters.
441
-
442
- ### Simulation extensions
443
- - **Soft body / cloth / fluids**: the SoA layout in `BodyStorage` and the
444
- manifold cache are rigid-body shaped. A soft-body system would be a
445
- parallel subsystem, not an extension.
446
- - **Reduced-coordinate articulations** (MuJoCo / Featherstone-style):
447
- game-physics audience runs in maximal coordinates by convention. Not
448
- on the roadmap.
449
-
450
- ### Game-side
451
- - **Vehicle physics** (suspensions, drivetrains): a domain layer that
452
- sits on top of the rigid-body primitives, not in `meep/`.
453
- - **Character controllers**: same `engine/control/first-person/` is the
454
- natural home.
455
-
456
- ---
457
-
458
- ## Notable design files
459
-
460
- - Original design plan: `C:\Users\Alex\.claude\plans\let-s-plan-to-implement-transient-harp.md`
461
- - 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 2 one-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 value per-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`