@woosh/meep-engine 2.139.0 → 2.140.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.
- package/package.json +1 -1
- package/src/core/bvh2/bvh3/query/bvh_query_user_data_overlaps_aabb.d.ts +3 -3
- package/src/core/bvh2/bvh3/query/bvh_query_user_data_overlaps_aabb.d.ts.map +1 -1
- package/src/core/bvh2/bvh3/query/bvh_query_user_data_overlaps_aabb.js +4 -4
- package/src/{engine/physics/broadphase/aabb_transform_oriented.d.ts → core/geom/3d/aabb/aabb3_transform_oriented.d.ts} +2 -2
- package/src/core/geom/3d/aabb/aabb3_transform_oriented.d.ts.map +1 -0
- package/src/{engine/physics/broadphase/aabb_transform_oriented.js → core/geom/3d/aabb/aabb3_transform_oriented.js} +1 -1
- package/src/core/geom/3d/quaternion/quat3_to_matrix3.d.ts +54 -0
- package/src/core/geom/3d/quaternion/quat3_to_matrix3.d.ts.map +1 -0
- package/src/core/geom/3d/quaternion/quat3_to_matrix3.js +69 -0
- package/src/core/geom/3d/shape/AbstractShape3D.d.ts +24 -2
- package/src/core/geom/3d/shape/AbstractShape3D.d.ts.map +1 -1
- package/src/core/geom/3d/shape/AbstractShape3D.js +24 -1
- package/src/core/geom/3d/shape/HeightMapShape3D.d.ts +148 -0
- package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -0
- package/src/core/geom/3d/shape/HeightMapShape3D.js +451 -0
- package/src/core/geom/3d/shape/MeshShape3D.d.ts +210 -0
- package/src/core/geom/3d/shape/MeshShape3D.d.ts.map +1 -0
- package/src/core/geom/3d/shape/MeshShape3D.js +593 -0
- package/src/core/geom/3d/shape/TransformedShape3D.d.ts.map +1 -1
- package/src/core/geom/3d/shape/TransformedShape3D.js +46 -2
- package/src/core/geom/3d/shape/Triangle3D.d.ts +95 -0
- package/src/core/geom/3d/shape/Triangle3D.d.ts.map +1 -0
- package/src/core/geom/3d/shape/Triangle3D.js +318 -0
- package/src/core/geom/3d/shape/UnionShape3D.js +13 -0
- package/src/core/geom/3d/shape/shape_mesh_from_geometry.d.ts +30 -0
- package/src/core/geom/3d/shape/shape_mesh_from_geometry.d.ts.map +1 -0
- package/src/core/geom/3d/shape/shape_mesh_from_geometry.js +64 -0
- package/src/core/geom/3d/tetrahedra/prototype_tetrahedrize_mesh.js +9 -11
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_build_vertex_to_tets_map.d.ts +28 -0
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_build_vertex_to_tets_map.d.ts.map +1 -0
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_build_vertex_to_tets_map.js +48 -0
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_improve_quality.d.ts.map +1 -1
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_improve_quality.js +40 -18
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_smooth_vertex.d.ts +9 -5
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_smooth_vertex.d.ts.map +1 -1
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_smooth_vertex.js +38 -10
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_vertex_is_boundary.d.ts +14 -5
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_vertex_is_boundary.d.ts.map +1 -1
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_vertex_is_boundary.js +47 -5
- package/src/core/geom/3d/topology/struct/binary/BinaryElementPool.d.ts +19 -0
- package/src/core/geom/3d/topology/struct/binary/BinaryElementPool.d.ts.map +1 -1
- package/src/core/geom/3d/topology/struct/binary/BinaryElementPool.js +75 -13
- package/src/core/geom/3d/triangle/v3_compute_triangle_normal.d.ts +2 -2
- package/src/core/geom/3d/triangle/v3_compute_triangle_normal.d.ts.map +1 -1
- package/src/core/geom/3d/triangle/v3_compute_triangle_normal.js +1 -1
- package/src/core/geom/vec3/v3_dot_array_array.d.ts +3 -3
- package/src/core/geom/vec3/v3_dot_array_array.d.ts.map +1 -1
- package/src/core/geom/vec3/v3_dot_array_array.js +2 -2
- package/src/core/geom/vec3/v3_negate_array.d.ts +3 -3
- package/src/core/geom/vec3/v3_negate_array.d.ts.map +1 -1
- package/src/core/geom/vec3/v3_negate_array.js +2 -2
- package/src/core/geom/vec3/v3_quat3_apply.d.ts +29 -0
- package/src/core/geom/vec3/v3_quat3_apply.d.ts.map +1 -0
- package/src/core/geom/vec3/v3_quat3_apply.js +39 -0
- package/src/core/geom/vec3/v3_quat3_apply_inverse.d.ts +30 -0
- package/src/core/geom/vec3/v3_quat3_apply_inverse.d.ts.map +1 -0
- package/src/core/geom/vec3/v3_quat3_apply_inverse.js +41 -0
- package/src/core/geom/vec3/v3_triple_cross_product.d.ts +32 -0
- package/src/core/geom/vec3/v3_triple_cross_product.d.ts.map +1 -0
- package/src/core/geom/vec3/v3_triple_cross_product.js +45 -0
- package/src/engine/control/first-person/FirstPersonPlayerController.d.ts +16 -3
- package/src/engine/control/first-person/FirstPersonPlayerController.d.ts.map +1 -1
- package/src/engine/control/first-person/FirstPersonPlayerController.js +211 -211
- package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts +72 -8
- package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts.map +1 -1
- package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js +37 -5
- package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts +101 -3
- package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts.map +1 -1
- package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js +1789 -1416
- package/src/engine/control/first-person/TODO.md +173 -127
- package/src/engine/control/first-person/abilities/Slide.d.ts.map +1 -1
- package/src/engine/control/first-person/abilities/Slide.js +9 -1
- package/src/engine/control/first-person/prototype_first_person_controller.js +88 -2
- package/src/engine/control/first-person/test/buildTestPlayer.d.ts.map +1 -1
- package/src/engine/control/first-person/test/buildTestPlayer.js +9 -1
- package/src/engine/graphics/geometry/CapsuleGeometry.d.ts +42 -0
- package/src/engine/graphics/geometry/CapsuleGeometry.d.ts.map +1 -0
- package/src/engine/graphics/geometry/CapsuleGeometry.js +171 -0
- package/src/engine/physics/BULLET_REVIEW.md +945 -0
- package/src/engine/physics/CANNON_REVIEW.md +1300 -0
- package/src/engine/physics/JOLT_REVIEW.md +913 -0
- package/src/engine/physics/PLAN.md +461 -236
- package/src/engine/physics/RAPIER_REVIEW.md +934 -0
- package/src/engine/physics/REVIEW_001_ACTION_PLAN.md +642 -0
- package/src/engine/physics/broadphase/compute_fat_world_aabb.js +2 -2
- package/src/engine/physics/contact/ManifoldStore.d.ts +83 -10
- package/src/engine/physics/contact/ManifoldStore.d.ts.map +1 -1
- package/src/engine/physics/contact/ManifoldStore.js +608 -499
- package/src/engine/physics/ecs/ColliderObserverSystem.d.ts +2 -2
- package/src/engine/physics/ecs/ColliderObserverSystem.d.ts.map +1 -1
- package/src/engine/physics/ecs/PhysicsSystem.d.ts +128 -20
- package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
- package/src/engine/physics/ecs/PhysicsSystem.js +1301 -1159
- package/src/engine/physics/fluid/FluidSimulator.d.ts.map +1 -1
- package/src/engine/physics/fluid/FluidSimulator.js +2 -1
- package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.d.ts +28 -6
- package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.d.ts.map +1 -1
- package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.js +39 -17
- package/src/engine/physics/gjk/expanding_polytope_algorithm.d.ts +6 -6
- package/src/engine/physics/gjk/expanding_polytope_algorithm.d.ts.map +1 -1
- package/src/engine/physics/gjk/expanding_polytope_algorithm.js +68 -22
- package/src/engine/physics/gjk/gjk.d.ts +28 -2
- package/src/engine/physics/gjk/gjk.d.ts.map +1 -1
- package/src/engine/physics/gjk/gjk.js +421 -378
- package/src/engine/physics/gjk/minkowski_support.d.ts +37 -0
- package/src/engine/physics/gjk/minkowski_support.d.ts.map +1 -0
- package/src/engine/physics/gjk/minkowski_support.js +75 -0
- package/src/engine/physics/gjk/mpr.d.ts +56 -0
- package/src/engine/physics/gjk/mpr.d.ts.map +1 -0
- package/src/engine/physics/gjk/mpr.js +344 -0
- package/src/engine/physics/inertia/world_inverse_inertia.d.ts +20 -5
- package/src/engine/physics/inertia/world_inverse_inertia.d.ts.map +1 -1
- package/src/engine/physics/inertia/world_inverse_inertia.js +36 -38
- package/src/engine/physics/integration/integrate_position.d.ts +25 -7
- package/src/engine/physics/integration/integrate_position.d.ts.map +1 -1
- package/src/engine/physics/integration/integrate_position.js +43 -12
- package/src/engine/physics/integration/integrate_velocity.d.ts +30 -0
- package/src/engine/physics/integration/integrate_velocity.d.ts.map +1 -1
- package/src/engine/physics/integration/integrate_velocity.js +82 -1
- package/src/engine/physics/narrowphase/PosedShape.d.ts +0 -8
- package/src/engine/physics/narrowphase/PosedShape.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/PosedShape.js +28 -30
- package/src/engine/physics/narrowphase/box_box_manifold.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/box_box_manifold.js +113 -17
- package/src/engine/physics/narrowphase/box_triangle_contact.d.ts +30 -0
- package/src/engine/physics/narrowphase/box_triangle_contact.d.ts.map +1 -0
- package/src/engine/physics/narrowphase/box_triangle_contact.js +811 -0
- package/src/engine/physics/narrowphase/capsule_contacts.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/capsule_contacts.js +10 -56
- package/src/engine/physics/narrowphase/capsule_triangle_contact.d.ts +71 -0
- package/src/engine/physics/narrowphase/capsule_triangle_contact.d.ts.map +1 -0
- package/src/engine/physics/narrowphase/capsule_triangle_contact.js +375 -0
- package/src/engine/physics/narrowphase/compute_penetration.d.ts +91 -0
- package/src/engine/physics/narrowphase/compute_penetration.d.ts.map +1 -0
- package/src/engine/physics/narrowphase/compute_penetration.js +396 -0
- package/src/engine/physics/narrowphase/decomposition/aabb_world_to_local.d.ts +35 -0
- package/src/engine/physics/narrowphase/decomposition/aabb_world_to_local.d.ts.map +1 -0
- package/src/engine/physics/narrowphase/decomposition/aabb_world_to_local.js +80 -0
- package/src/engine/physics/narrowphase/decomposition/decompose_to_triangles.d.ts +31 -0
- package/src/engine/physics/narrowphase/decomposition/decompose_to_triangles.d.ts.map +1 -0
- package/src/engine/physics/narrowphase/decomposition/decompose_to_triangles.js +55 -0
- package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts +42 -0
- package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts.map +1 -0
- package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.js +204 -0
- package/src/engine/physics/narrowphase/decomposition/mesh_enumerate_triangles.d.ts +42 -0
- package/src/engine/physics/narrowphase/decomposition/mesh_enumerate_triangles.d.ts.map +1 -0
- package/src/engine/physics/narrowphase/decomposition/mesh_enumerate_triangles.js +94 -0
- package/src/engine/physics/narrowphase/decomposition/triangle_buffer_layout.d.ts +37 -0
- package/src/engine/physics/narrowphase/decomposition/triangle_buffer_layout.d.ts.map +1 -0
- package/src/engine/physics/narrowphase/decomposition/triangle_buffer_layout.js +37 -0
- package/src/engine/physics/narrowphase/narrowphase_step.d.ts +8 -2
- package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/narrowphase_step.js +1422 -382
- package/src/engine/physics/narrowphase/sphere_box_contact.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/sphere_box_contact.js +16 -23
- package/src/engine/physics/narrowphase/sphere_triangle_contact.d.ts +48 -0
- package/src/engine/physics/narrowphase/sphere_triangle_contact.d.ts.map +1 -0
- package/src/engine/physics/narrowphase/sphere_triangle_contact.js +143 -0
- package/src/engine/physics/queries/overlap_shape.d.ts +51 -0
- package/src/engine/physics/queries/overlap_shape.d.ts.map +1 -0
- package/src/engine/physics/queries/overlap_shape.js +183 -0
- package/src/engine/physics/queries/shape_cast.d.ts +56 -0
- package/src/engine/physics/queries/shape_cast.d.ts.map +1 -0
- package/src/engine/physics/queries/shape_cast.js +387 -0
- package/src/engine/physics/solver/solve_contacts.d.ts +116 -30
- package/src/engine/physics/solver/solve_contacts.d.ts.map +1 -1
- package/src/engine/physics/solver/solve_contacts.js +641 -223
- package/src/engine/physics/broadphase/aabb_transform_oriented.d.ts.map +0 -1
- package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_unmasked_legacy.d.ts +0 -20
- package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_unmasked_legacy.d.ts.map +0 -1
- package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_unmasked_legacy.js +0 -83
|
@@ -0,0 +1,1300 @@
|
|
|
1
|
+
# Physics Engine Review: meep vs. cannon-es
|
|
2
|
+
|
|
3
|
+
A deep technical comparison of the meep in-house rigid-body engine
|
|
4
|
+
(`H:/git/moh/app/src/mir-engine/meep/src/engine/physics/`) against
|
|
5
|
+
**cannon-es** (https://github.com/pmndrs/cannon-es), the
|
|
6
|
+
community-maintained fork of Stefan Hedman's **cannon.js**
|
|
7
|
+
(https://github.com/schteppe/cannon.js).
|
|
8
|
+
|
|
9
|
+
Cannon is uniquely interesting as a reference because it shares every
|
|
10
|
+
hard constraint we work under: **pure JavaScript, single-threaded, no
|
|
11
|
+
SIMD, no SharedArrayBuffer, GC-sensitive hot paths**. Anywhere Cannon
|
|
12
|
+
discovered a JS-specific workaround, it translates directly to us.
|
|
13
|
+
Anywhere we beat Cannon, it's a fair fight on engineering merit — not
|
|
14
|
+
"they had SIMD intrinsics and we didn't".
|
|
15
|
+
|
|
16
|
+
This review respects PLAN.md's documented out-of-scope decisions (pure
|
|
17
|
+
JS, no SAB, no multi-threaded solver, action-log netcode). Cannon
|
|
18
|
+
citations use `src/<dir>/<File>.ts` paths; our citations use
|
|
19
|
+
`path:line` relative to the package root.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 1. Overall Architecture
|
|
24
|
+
|
|
25
|
+
### Pipeline shape
|
|
26
|
+
|
|
27
|
+
| Stage | meep `PhysicsSystem.fixedUpdate` (`ecs/PhysicsSystem.js:1075`) | cannon-es `World.internalStep` (`src/world/World.ts`) |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| Apply gravity | Folded into Stage 1 `integrate_velocity` (`integration/integrate_velocity.js`) | Standalone "gravity" loop over all bodies |
|
|
30
|
+
| Forces from user constraints | n/a (no joints in v1) | `for (const c of constraints) c.update()` adds equations |
|
|
31
|
+
| Subsystem callbacks | n/a | `subsystems` user-extension hook |
|
|
32
|
+
| Velocity integration | Stage 1, awake-only | Last (after solver); leap-frog |
|
|
33
|
+
| Broadphase refit | Stage 2, awake-only, fat-AABB `compute_fat_world_aabb.js` | Per-body `updateAABB()` inside `broadphase.collisionPairs` |
|
|
34
|
+
| Pair generation | Stage 3, `generate_pairs.js` — leaf query into both BVHs, dedup via touched bit | `broadphase.collisionPairs(this, p1, p2)` |
|
|
35
|
+
| Wake propagation | Stage 4, explicit `__wake_pairs` over the pair list | Implicit — narrowphase sets `wakeUpAfterNarrowphase = true` when relative speed exceeds threshold |
|
|
36
|
+
| Narrowphase | Stage 5, `narrowphase_step.js` cascade `if (a_kind && b_kind) ...` | `Narrowphase.getContacts(p1, p2, ...)` giant switch dispatch |
|
|
37
|
+
| Island build | Stage 6, `IslandBuilder` union-find CSR | Lazy in `SplitSolver` (BFS over equation graph), or no partitioning if using bare `GSSolver` |
|
|
38
|
+
| Solver | Stage 7, `solve_contacts.js` per island, 10 iters | `solver.solve(dt, this)` over the flat equation list |
|
|
39
|
+
| Position integration | Stage 8 | Same as velocity (leap-frog `pos += velo * dt`) |
|
|
40
|
+
| Sleep | Stage 9, per-island atomic | Stage 14, per-body `body.sleepTick(time)` |
|
|
41
|
+
| Damping | Folded into integrators | Stage 11, explicit multiplicative decay |
|
|
42
|
+
| Event diff | Stage 10, `diff_manifolds` → Begin/Stay/End | `collisionMatrix` flip + `collide`/`endContact` events from narrowphase |
|
|
43
|
+
| End-of-step | Stage 11, `manifolds.advance_frame()` (roll touched, evict grace-expired) | `clearForces()` — accumulators wiped |
|
|
44
|
+
|
|
45
|
+
The single most consequential structural divergence is **what each step
|
|
46
|
+
iterates over**. Cannon's documented `internalStep` walks **every body**
|
|
47
|
+
on every pass (`for (let i = 0; i !== N; i++) { const bi = bodies[i] }`
|
|
48
|
+
appears repeatedly in `World.ts`), with sleep state being a property
|
|
49
|
+
checked inline rather than a filter. We instead iterate
|
|
50
|
+
`storage.awake_at(i)` for `i in [0, awake_count)` — the awake set is a
|
|
51
|
+
dense `Uint32Array` (`body/BodyStorage.js:101-110`) and grows / shrinks
|
|
52
|
+
via swap-with-last (`body/BodyStorage.js:330-337`). At million-body
|
|
53
|
+
scale where 99% of bodies are mostly sleeping (the design target in
|
|
54
|
+
PLAN.md), we pay O(awake) per stage while Cannon pays O(total). This
|
|
55
|
+
is **the** scalability story between the two engines.
|
|
56
|
+
|
|
57
|
+
### Data layout — body pool
|
|
58
|
+
|
|
59
|
+
| Aspect | meep `BodyStorage` | cannon-es `Body` |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| ID encoding | 24-bit index ǁ 8-bit generation (`body/BodyStorage.js:11-19`) | Bare object reference (`Body` instance) + monotonically increasing `id` |
|
|
62
|
+
| Slot reuse policy | Min-heap of free indices for deterministic reuse (`body/BodyStorage.js:154-176`, `383-426`) | No reuse — destroyed bodies are GC'd |
|
|
63
|
+
| Active list | Dense `Uint32Array __awake_list` + reverse map `__awake_pos` (`body/BodyStorage.js:101-103`) | None — bodies live in `World.bodies: Body[]` regardless of sleep state |
|
|
64
|
+
| Body state storage | SoA — `__entities`, `__generations`, `__kinds`, `__flags` as parallel typed arrays. Hot mutable state (velocity / accumulators) on `RigidBody` instances | AoS — every field on the `Body` class instance (`position: Vec3`, `velocity: Vec3`, `force: Vec3`, `torque: Vec3`, `sleepState: number`, etc.) |
|
|
65
|
+
| Iteration | `for (let i = 0; i < awake_count; i++) { const idx = awake_at(i); const rb = __bodies[idx]; ... }` | `for (let i = 0; i !== N; i++) { const bi = bodies[i]; if (bi.sleepState === Body.SLEEPING) continue; ... }` |
|
|
66
|
+
| Generation safety | Stale packed id detected via `is_valid()` mask + generation check (`body/BodyStorage.js:210-219`) | Stale references possible (caller is responsible) |
|
|
67
|
+
|
|
68
|
+
**Where we win:** SoA identity tables + awake-list filtering. Hot
|
|
69
|
+
streaming reads of cold flags / kinds / generations don't pull
|
|
70
|
+
unrelated body fields into cache, and disconnected sleeping bodies
|
|
71
|
+
contribute literally zero work per step. Cannon's per-body
|
|
72
|
+
`sleepState` check is cheap-but-not-free, and on a scene of 1M static
|
|
73
|
+
chairs in a town the difference is dramatic.
|
|
74
|
+
|
|
75
|
+
**Where Cannon wins (or rather, "is more idiomatic"):** Cannon's
|
|
76
|
+
`Body` is a single class with every field on it. V8 monomorphises
|
|
77
|
+
this beautifully — every `body.velocity` access is the same hidden
|
|
78
|
+
class lookup. There's no parallel-array index arithmetic, no
|
|
79
|
+
`body_id_index(packed)` mask-and-shift. Debugging is plain
|
|
80
|
+
object inspection. Our split (RigidBody for the JS-side hot state,
|
|
81
|
+
BodyStorage for identity SoA, Collider list as a separate per-body
|
|
82
|
+
array, transforms on a Transform component) costs a few extra
|
|
83
|
+
indirections per body in the solver inner loop — but those are array
|
|
84
|
+
indexed loads, not pointer-chases, so still cache-friendly.
|
|
85
|
+
|
|
86
|
+
The crucial nuance: **even Cannon's `Body` AoS is "AoS per body but
|
|
87
|
+
cache-unfriendly per pass"**. When the integrator wants only
|
|
88
|
+
`position`, `velocity`, and `force`, it still pulls all 25-ish other
|
|
89
|
+
fields of `Body` into cache. Pure SoA (one `Float64Array` per
|
|
90
|
+
attribute) would be better, but we don't go fully SoA on the hot
|
|
91
|
+
fields either — `RigidBody.linearVelocity` is a `Vector3` extending
|
|
92
|
+
`Float64Array`, accessed as `lv[0]/[1]/[2]` (`solver/solve_contacts.js:427`).
|
|
93
|
+
Our SoA win is therefore narrower than the layout suggests; the
|
|
94
|
+
honest comparison is "we got the identity / scheduling pieces into
|
|
95
|
+
SoA, the per-body kinematic state is still AoS like Cannon's, just
|
|
96
|
+
with typed-array storage". The Jolt/Bullet style of fully SoA kinematic
|
|
97
|
+
state remains a future optimisation.
|
|
98
|
+
|
|
99
|
+
### Broadphase
|
|
100
|
+
|
|
101
|
+
| Engine | Strategies | Default |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| meep | One: BVH × 2 (`staticBvh` + `dynamicBvh`), with fat-AABB refit (`broadphase/compute_fat_world_aabb.js`) | The only choice |
|
|
104
|
+
| cannon-es | Three: `NaiveBroadphase` (O(N²)), `GridBroadphase` (uniform grid, spheres+planes only), `SAPBroadphase` (single-axis sweep & prune) | `NaiveBroadphase` |
|
|
105
|
+
|
|
106
|
+
`NaiveBroadphase.collisionPairs` is literally:
|
|
107
|
+
```js
|
|
108
|
+
for (let i = 0; i !== n; i++) {
|
|
109
|
+
for (let j = 0; j !== i; j++) {
|
|
110
|
+
if (!this.needBroadphaseCollision(bi, bj)) continue;
|
|
111
|
+
this.intersectionTest(bi, bj, pairs1, pairs2);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
The class docstring even concedes the algorithm's complexity is "N²
|
|
116
|
+
(which is bad)". Cannon's expectation is that users will swap in
|
|
117
|
+
`SAPBroadphase` for scenes with more than a few hundred bodies. SAP
|
|
118
|
+
uses insertion-sort on one axis (chosen via `autoDetectAxis()` —
|
|
119
|
+
variance maximisation), which handles small frame-to-frame motion in
|
|
120
|
+
~O(N) but degrades to O(N²) on sudden mass motion. Cannon makes no
|
|
121
|
+
attempt at a tree.
|
|
122
|
+
|
|
123
|
+
**Our BVH split with awake-list-driven pair generation is
|
|
124
|
+
unambiguously better at our target scale.** At 1M bodies the
|
|
125
|
+
NaiveBroadphase is 10¹² pair checks per frame — completely
|
|
126
|
+
unusable. Our `generate_pairs.js:50-99` does one BVH query per
|
|
127
|
+
awake-leaf into each of two BVHs and dedups via the manifold
|
|
128
|
+
touched flag, scaling O(awake · log(total)) at worst. This is a
|
|
129
|
+
*million-fold* difference at the design-target scale.
|
|
130
|
+
|
|
131
|
+
### Island structure
|
|
132
|
+
|
|
133
|
+
Cannon ships **`SplitSolver`** as an opt-in wrapper around `GSSolver`.
|
|
134
|
+
On each `solve()` call it builds a graph (every body is a node, every
|
|
135
|
+
equation an edge), runs BFS to find connected components, then calls
|
|
136
|
+
the inner solver per island. Each rebuild allocates fresh `nodes`,
|
|
137
|
+
`children`, and intermediate arrays — there's an internal pool but
|
|
138
|
+
graph-shape changes between frames defeat it. Cannon's island
|
|
139
|
+
construction is also coupled to the solver, not to the contact
|
|
140
|
+
generator, so it's purely a solver-side optimisation; sleep and
|
|
141
|
+
events use unrelated machinery (per-body sleep tick, per-pair
|
|
142
|
+
narrowphase-driven events).
|
|
143
|
+
|
|
144
|
+
We pull islands up to a first-class pipeline stage with `IslandBuilder`
|
|
145
|
+
(`island/IslandBuilder.js`) producing CSR-shaped output that the
|
|
146
|
+
solver and sleep test *both* consume. Bodies are sorted ascending
|
|
147
|
+
within an island and islands ascending by root index, giving us
|
|
148
|
+
**determinism by construction** without sorting at the solver
|
|
149
|
+
boundary. The PLAN.md atomic-sleep + atomic-wake story (sleep groups
|
|
150
|
+
threaded via `sleep_group_next/prev` so waking any member walks the
|
|
151
|
+
chain) is structurally impossible in Cannon's per-body model — see §3.
|
|
152
|
+
|
|
153
|
+
### Solver
|
|
154
|
+
|
|
155
|
+
Cannon's `GSSolver` is a beautifully general sequential-impulse
|
|
156
|
+
solver over a flat equation list (contacts, friction, joints all the
|
|
157
|
+
same shape: an `Equation` with `computeB`, `computeC`, lambda
|
|
158
|
+
bounds). It implements the **SPOOK formulation** (Lacoursière /
|
|
159
|
+
Erleben) of constraint regularisation: `a = 4/(h*(1+4*d))`, `b =
|
|
160
|
+
4d/(1+4d)`, `eps = 4/(h*h*k*(1+4*d))` parameterised by stiffness `k`
|
|
161
|
+
and relaxation `d` (`src/equations/Equation.ts`). Per-iteration:
|
|
162
|
+
|
|
163
|
+
```js
|
|
164
|
+
deltalambda = invC * (B - GWlambda - c.eps * lambdaj);
|
|
165
|
+
```
|
|
166
|
+
The `c.eps * lambdaj` term is what makes SPOOK different from naive
|
|
167
|
+
Baumgarte — it's **continuous regularisation**, applied at every
|
|
168
|
+
iteration to soften the constraint, rather than a one-shot velocity
|
|
169
|
+
bias. Defaults: `stiffness = 1e7, relaxation = 4, iterations = 10`.
|
|
170
|
+
|
|
171
|
+
Our `solve_contacts.js` is a more specialised contacts-only sequential
|
|
172
|
+
impulse solver: warm-start replay (`solver/solve_contacts.js:466-477`),
|
|
173
|
+
Baumgarte position correction folded into the velocity bias
|
|
174
|
+
(`solver/solve_contacts.js:442-450`), Coulomb friction with disk-clamp
|
|
175
|
+
(`solver/solve_contacts.js:566-571`), per-island iteration
|
|
176
|
+
(`solver/solve_contacts.js:308-313`). 10 iterations default
|
|
177
|
+
(matches Cannon). The split-impulse / split-position-pass story is
|
|
178
|
+
discussed in §3.
|
|
179
|
+
|
|
180
|
+
### Sleep system
|
|
181
|
+
|
|
182
|
+
Cannon's per-body sleep is the chatter-prone classic algorithm.
|
|
183
|
+
`Body.sleepTick(time)` (verbatim from `src/objects/Body.ts`):
|
|
184
|
+
```js
|
|
185
|
+
if (this.allowSleep) {
|
|
186
|
+
const sleepState = this.sleepState;
|
|
187
|
+
const speedSquared = velocity² + angularVelocity²;
|
|
188
|
+
const speedLimitSquared = this.sleepSpeedLimit ** 2;
|
|
189
|
+
if (sleepState === Body.AWAKE && speedSquared < speedLimitSquared) {
|
|
190
|
+
this.sleepState = Body.SLEEPY;
|
|
191
|
+
this.timeLastSleepy = time;
|
|
192
|
+
} else if (sleepState === Body.SLEEPY && speedSquared > speedLimitSquared) {
|
|
193
|
+
this.wakeUp();
|
|
194
|
+
} else if (sleepState === Body.SLEEPY && time - this.timeLastSleepy > this.sleepTimeLimit) {
|
|
195
|
+
this.sleep();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
This is the failure mode that **specifically motivates** our atomic
|
|
200
|
+
sleep: in a stack of 100 blocks, frame N the bottom block falls
|
|
201
|
+
asleep, frame N+1 a top block's micro-jitter wakes a middle block via
|
|
202
|
+
broadphase pair propagation, frame N+2 the wake cascades down,
|
|
203
|
+
frame N+3 the bottom re-wakes. The whole stack chatters indefinitely.
|
|
204
|
+
PLAN.md "Sleep + events" calls this out explicitly:
|
|
205
|
+
|
|
206
|
+
> "Per-island atomic sleep: an island sleeps when `max(|v|² + |ω|²)`
|
|
207
|
+
> across all members stays below the threshold long enough; the whole
|
|
208
|
+
> island sleeps in the same frame. Replaces the per-body chatter on
|
|
209
|
+
> weakly-connected piles."
|
|
210
|
+
|
|
211
|
+
Our `__sleep_test` (`ecs/PhysicsSystem.js:942-1006`) walks each
|
|
212
|
+
island, takes `max(v² + ω²)` over all members, increments every
|
|
213
|
+
member's timer if-and-only-if the **island as a whole** is below
|
|
214
|
+
threshold, and atomically sleeps all members the same frame
|
|
215
|
+
(`__atomic_sleep_island_range` at `:754-786`). The dual atomic-wake
|
|
216
|
+
walks the `sleep_group_next` circular DLL (`:705-737`).
|
|
217
|
+
|
|
218
|
+
Cannon has no equivalent. The `wakeUpAfterNarrowphase` flag is a
|
|
219
|
+
one-frame deferral, not an island-aware operation.
|
|
220
|
+
|
|
221
|
+
### Threading
|
|
222
|
+
|
|
223
|
+
Both single-threaded by deliberate design.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 2. Specific Algorithms and Tradeoffs
|
|
228
|
+
|
|
229
|
+
### Body storage — class instances vs SoA + stable IDs
|
|
230
|
+
|
|
231
|
+
Cannon's `Body` declares ~30 mutable public fields including
|
|
232
|
+
`position`, `velocity`, `quaternion`, `angularVelocity`, `force`,
|
|
233
|
+
`torque`, `mass`, `linearDamping`, `angularDamping`, `linearFactor`,
|
|
234
|
+
`angularFactor`, `sleepState`, `allowSleep`, `collisionFilterGroup`,
|
|
235
|
+
`collisionFilterMask`, `collisionResponse`, `isTrigger`, `shapes`,
|
|
236
|
+
`shapeOffsets`, `shapeOrientations`, `aabb`, `boundingRadius`,
|
|
237
|
+
`inertia`, `invInertia`, `invInertiaWorld`. They're set in the
|
|
238
|
+
constructor with `||` default coalescing — typical Cannon idiom is
|
|
239
|
+
`this.mass = typeof options.mass === 'number' ? options.mass : 0`.
|
|
240
|
+
V8 monomorphises `Body` instances aggressively because the
|
|
241
|
+
initialisation order is fixed and every field gets a typed default.
|
|
242
|
+
|
|
243
|
+
Our split:
|
|
244
|
+
- `RigidBody` (`ecs/RigidBody.js`) — JS-side hot state (velocity,
|
|
245
|
+
accumulators, damping, mass, gravity scale, sleep state). Class
|
|
246
|
+
fields declared at class-body scope (`= new Vector3(0,0,0)` etc.),
|
|
247
|
+
same V8 hidden-class win.
|
|
248
|
+
- `BodyStorage` — identity SoA (`__entities`, `__generations`,
|
|
249
|
+
`__kinds`, `__flags`, `__alive` as parallel typed arrays) and
|
|
250
|
+
scheduling (`__awake_list`).
|
|
251
|
+
- `Collider` (component, attached separately, possibly compound).
|
|
252
|
+
- `Transform` (independent, shared with rendering / IK / etc.).
|
|
253
|
+
|
|
254
|
+
**GC pressure.** Cannon allocates one `Body` per body, plus the
|
|
255
|
+
`Vec3` / `Quaternion` instance for each field. A scene of 100k bodies
|
|
256
|
+
is ~100k bodies × ~10 Vec3/Quat ≈ 1M small objects on the heap, all
|
|
257
|
+
of which the GC has to walk on every major collection. We allocate
|
|
258
|
+
~100k bodies × 4 Vector3s (linear/angular vel, accumulated
|
|
259
|
+
force/torque, inertia) ≈ 400k small objects — better, but still on
|
|
260
|
+
the heap. Where we materially win is the **per-step work**: our SoA
|
|
261
|
+
identity tables are TypedArray slabs that don't appear in GC
|
|
262
|
+
traversal at all, and we don't allocate per-step.
|
|
263
|
+
|
|
264
|
+
**Iteration speed.** For the solver inner loop the comparison is a
|
|
265
|
+
wash: both engines access `rb.linearVelocity[0..2]` (us, since
|
|
266
|
+
Vector3 extends Float64Array) or `body.velocity.x/y/z` (Cannon). V8
|
|
267
|
+
should compile both to the same machine code. The difference appears
|
|
268
|
+
at the broadphase / wake phase, where Cannon's per-body iteration
|
|
269
|
+
pulls the whole `Body` into cache for a `sleepState` check while ours
|
|
270
|
+
reads a single byte from `__alive` / `__awake_pos` and jumps over the
|
|
271
|
+
slot.
|
|
272
|
+
|
|
273
|
+
**Observability.** Cannon wins here — `console.log(world.bodies[42])`
|
|
274
|
+
shows you everything. Our `system.__bodies[idx]` shows RigidBody but
|
|
275
|
+
not its colliders, transform, or storage entry; you have to know to
|
|
276
|
+
look in three places. This is the cost of decomposition; it's a
|
|
277
|
+
trade we deliberately made for ECS clarity and we shouldn't undo it.
|
|
278
|
+
But adding a `dump(body_idx)` helper that aggregates the four
|
|
279
|
+
sources would be cheap and would close the gap.
|
|
280
|
+
|
|
281
|
+
### Broadphase
|
|
282
|
+
|
|
283
|
+
Already covered above; the headline is that **Cannon ships three
|
|
284
|
+
broadphases and defaults to the worst one**. The `NaiveBroadphase`
|
|
285
|
+
default exists for tutorial-grade simplicity ("first 50 bodies just
|
|
286
|
+
work, then read the docs"). For a real physics engine `SAPBroadphase`
|
|
287
|
+
is the standard choice and it's still single-axis SAP, which is
|
|
288
|
+
state-of-the-art-1990s.
|
|
289
|
+
|
|
290
|
+
A subtle point worth flagging: cannon-es's `SAPBroadphase` uses
|
|
291
|
+
event listeners on the World (`world.addEventListener('addBody',
|
|
292
|
+
...)`) to keep its `axisList` in sync. This is allocation-free per
|
|
293
|
+
step but has a hidden cost — the `addBody` / `removeBody` events fire
|
|
294
|
+
synchronously on dataset mutations, and adding / removing many bodies
|
|
295
|
+
mid-step is **explicitly fragile in Cannon**. We sidestep this
|
|
296
|
+
entirely because our broadphase reads `storage.awake_at(i)` directly
|
|
297
|
+
and the BVH allocates/frees its own nodes on
|
|
298
|
+
`attach_collider`/`detach_collider`. No event-listener indirection.
|
|
299
|
+
|
|
300
|
+
### Narrowphase pairs — SAT vs GJK/EPA + closed-form
|
|
301
|
+
|
|
302
|
+
This is the **deepest structural divergence**. Cannon has
|
|
303
|
+
**no GJK and no EPA**. Its entire narrowphase is built on:
|
|
304
|
+
|
|
305
|
+
1. Closed-form per-pair handlers (`sphereSphere`, `spherePlane`,
|
|
306
|
+
`sphereBox`, `boxBox`, etc. — a large method-per-pair switch on
|
|
307
|
+
`Narrowphase.getContacts`)
|
|
308
|
+
2. **SAT** for arbitrary `ConvexPolyhedron` shapes (face normals + edge
|
|
309
|
+
crosses), followed by
|
|
310
|
+
3. **Sutherland-Hodgman clipping** of the incident face against the
|
|
311
|
+
reference face's bounding planes to extract a multi-point manifold
|
|
312
|
+
|
|
313
|
+
Our cascade in `narrowphase/narrowphase_step.js:296-782`:
|
|
314
|
+
|
|
315
|
+
| Pair | Path | Cannon equivalent |
|
|
316
|
+
|---|---|---|
|
|
317
|
+
| sphere-sphere | `sphere_sphere_contact` closed-form (`:309-325`) | `Narrowphase.sphereSphere` |
|
|
318
|
+
| sphere-box | `sphere_box_contact` closed-form, handles centre-inside-box (`:328-359`) | `Narrowphase.sphereBox` |
|
|
319
|
+
| capsule-X | `capsule_*` closed-form family (`:397-483`) | Cannon has no capsule shape |
|
|
320
|
+
| box-box | SAT + Sutherland-Hodgman in `box_box_manifold.js` (`:362-394`) | `ConvexPolyhedron` via `Narrowphase.convexConvex` — same algorithm, more general implementation |
|
|
321
|
+
| convex × concave (heightmap/mesh) | Per-triangle GJK+EPA via decomposition dispatcher (`:498-713`) | `convexTrimesh` / `convexHeightfield` — Trimesh BVH-queries triangles, each treated as a `ConvexPolyhedron` for SAT |
|
|
322
|
+
| anything else convex | GJK + EPA fallback (`:715-781`) | **No fallback exists** — must be `ConvexPolyhedron` with explicit faces |
|
|
323
|
+
|
|
324
|
+
**The structural tradeoff:** SAT works only on shapes with explicit
|
|
325
|
+
face / edge lists. Cannon can't represent a smooth shape (sphere is
|
|
326
|
+
its own special case; capsule, cylinder, cone, ellipsoid, generic
|
|
327
|
+
convex-hull-from-point-cloud — all impossible without an explicit
|
|
328
|
+
polyhedral mesh). We can, via GJK on the support function. But our
|
|
329
|
+
EPA degenerates on smooth shapes (PLAN.md "Limitations / Known
|
|
330
|
+
caveats") so the *practical* shape coverage of the two engines is
|
|
331
|
+
closer than the algorithm choice suggests.
|
|
332
|
+
|
|
333
|
+
**Cannon's `ConvexPolyhedron.clipFaceAgainstHull`** is its quality
|
|
334
|
+
crown jewel for convex-convex contacts. The pipeline:
|
|
335
|
+
|
|
336
|
+
1. `findSeparatingAxis` iterates face normals of A, then face normals
|
|
337
|
+
of B, then 9 (or `uniqueAxesA × uniqueAxesB`) edge-cross axes.
|
|
338
|
+
Tracks the minimum penetration depth across all axes. Returns the
|
|
339
|
+
normal of the deepest axis or `false` (no overlap).
|
|
340
|
+
2. Choose the witness face on B whose normal is most parallel to the
|
|
341
|
+
separating normal — this is the *incident face*.
|
|
342
|
+
3. Clip the incident face polygon against every neighbouring face of
|
|
343
|
+
A's witness face, treated as a half-space. After all clips, the
|
|
344
|
+
surviving vertices that lie *behind* the witness face become
|
|
345
|
+
contact points. Snippet (paraphrased from the source):
|
|
346
|
+
|
|
347
|
+
```js
|
|
348
|
+
for (let i = 0; i < numVerticesA; i++) {
|
|
349
|
+
localPlaneNormal.copy(this.faceNormals[otherFace]);
|
|
350
|
+
this.clipFaceAgainstPlane(pVtxIn, pVtxOut, planeNormalWS, planeEqWS);
|
|
351
|
+
// swap buffers
|
|
352
|
+
}
|
|
353
|
+
// finally:
|
|
354
|
+
for (let i = 0; i < pVtxIn.length; i++) {
|
|
355
|
+
let depth = planeNormalWS.dot(pVtxIn[i]) + planeEqWS;
|
|
356
|
+
if (depth <= maxDist && depth <= 1e-6) {
|
|
357
|
+
result.push({ point, normal, depth });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Our `box_box_manifold.js` does exactly the same thing but specialised
|
|
363
|
+
to two boxes (3 face normals × 2 + 9 edge crosses = 15 axes, hard-coded).
|
|
364
|
+
The result is 0..4 contact points per box-box pair, same as Cannon.
|
|
365
|
+
**The major gap:** we have no general convex-hull shape, only `BoxShape3D`.
|
|
366
|
+
If we add `ConvexHullShape3D` (on PLAN.md backlog), we'll want
|
|
367
|
+
basically Cannon's `ConvexPolyhedron.clipFaceAgainstHull` ported. The
|
|
368
|
+
algorithm is already in our codebase as the inner clipping pass of
|
|
369
|
+
`box_box_manifold.js`; the missing piece is the SAT outer loop
|
|
370
|
+
that iterates an arbitrary face/edge list rather than the hard-coded 15.
|
|
371
|
+
|
|
372
|
+
### GJK / EPA — what each engine can actually handle
|
|
373
|
+
|
|
374
|
+
| Shape pair | meep | cannon-es |
|
|
375
|
+
|---|---|---|
|
|
376
|
+
| sphere–sphere | closed-form | closed-form |
|
|
377
|
+
| sphere–box | closed-form (interior-handling) | closed-form |
|
|
378
|
+
| sphere–plane | closed-form (via box flat path) | closed-form |
|
|
379
|
+
| sphere–convex polyhedron | GJK + EPA (works) | `sphereConvex` via vertex-iteration; works for explicit hulls |
|
|
380
|
+
| sphere–trimesh | per-triangle GJK + EPA via decomp | `sphereTrimesh` via BVH triangle query |
|
|
381
|
+
| capsule–anything | closed-form for cap-sphere / cap-cap / cap-box | n/a (no capsule shape) |
|
|
382
|
+
| convex–convex | GJK + EPA fallback (may degenerate on smooth) | SAT + clip via `ConvexPolyhedron` |
|
|
383
|
+
| convex–trimesh | per-triangle GJK + EPA (PLAN.md "drop and settle" tests skipped) | per-triangle SAT + clip (more reliable for polyhedral cases) |
|
|
384
|
+
| concave–concave | refused | not supported |
|
|
385
|
+
| anything smooth | GJK works for overlap test; EPA degenerates on depth recovery | impossible — needs polyhedral approximation |
|
|
386
|
+
|
|
387
|
+
The structural picture is: **Cannon trades expressivity for
|
|
388
|
+
robustness on its supported shape set**. We trade robustness on
|
|
389
|
+
smooth shapes for general support-function-based collision. The cost
|
|
390
|
+
of our choice is the EPA-on-smooth-shapes failure mode, which we
|
|
391
|
+
mitigate by routing all common pairs through closed-form handlers.
|
|
392
|
+
PLAN.md "Limitations / Known caveats" calls this out:
|
|
393
|
+
|
|
394
|
+
> "EPA on smooth shapes: degenerates (no flat face to converge on).
|
|
395
|
+
> Mitigated by closed-form paths for sphere/cube/capsule pairs; exotic
|
|
396
|
+
> convex shapes vs spheres can still fail."
|
|
397
|
+
|
|
398
|
+
### MPR (Minkowski Portal Refinement)
|
|
399
|
+
|
|
400
|
+
Cannon has nothing comparable. We have `gjk/mpr.js` (XenoCollide,
|
|
401
|
+
Snethen GDC 2009) but it's not yet wired into `narrowphase_step` —
|
|
402
|
+
PLAN.md "Alternative narrowphase: MPR" calls it a candidate for
|
|
403
|
+
falling back when EPA stalls. **Wiring it as the fallback for
|
|
404
|
+
EPA non-convergence is a clear next step** — concrete payoff: torus-knot
|
|
405
|
+
test (currently skipped), ball-on-curved-surface contact stability.
|
|
406
|
+
|
|
407
|
+
### Solver — SPOOK vs Baumgarte
|
|
408
|
+
|
|
409
|
+
Cannon's solver is built on the **SPOOK formulation** described in
|
|
410
|
+
Lacoursière's PhD thesis and Erleben's papers. The key equations
|
|
411
|
+
(verbatim from `src/equations/Equation.ts.setSpookParams`):
|
|
412
|
+
|
|
413
|
+
```js
|
|
414
|
+
a = 4.0 / (h * (1 + 4 * d)); // bias gain (analogous to Baumgarte β/h)
|
|
415
|
+
b = (4.0 * d) / (1 + 4 * d); // velocity-error gain
|
|
416
|
+
eps = 4.0 / (h * h * k * (1 + 4 * d)); // regularisation
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
And the per-iteration update (`GSSolver.solve`):
|
|
420
|
+
```js
|
|
421
|
+
deltalambda = invC * (B - GWlambda - c.eps * lambdaj);
|
|
422
|
+
// then clamped to [minForce*h, maxForce*h]
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
The interesting term is **`c.eps * lambdaj`** — the regularisation
|
|
426
|
+
softens the constraint at every iteration in a continuous way, not as
|
|
427
|
+
a one-shot velocity bias. This is fundamentally different from
|
|
428
|
+
Baumgarte: instead of "if penetration > slop, add a corrective
|
|
429
|
+
velocity bias", SPOOK adds a per-iteration drag on the lambda that
|
|
430
|
+
makes the constraint behave like a stiff spring with controlled
|
|
431
|
+
damping. `eps` scales as `1 / (h² k)` — at our 60 Hz timestep
|
|
432
|
+
(`h ≈ 0.0167`) with `k = 1e7`, `eps ≈ 0.024` for `d = 4`. Cannon's
|
|
433
|
+
default produces a stable, soft constraint.
|
|
434
|
+
|
|
435
|
+
Our `solver/solve_contacts.js:442-450`:
|
|
436
|
+
```js
|
|
437
|
+
let bias = 0;
|
|
438
|
+
if (depth > PENETRATION_SLOP) {
|
|
439
|
+
bias = -BAUMGARTE_BETA / dt * (depth - PENETRATION_SLOP);
|
|
440
|
+
if (bias < -MAX_BAUMGARTE_BIAS) bias = -MAX_BAUMGARTE_BIAS;
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
This is classic Catto / Box2D-Lite Baumgarte: one-shot velocity bias
|
|
444
|
+
proportional to penetration depth minus slop, with a hard cap to stop
|
|
445
|
+
EPA-misreported deep penetrations from launching bodies. The cap is
|
|
446
|
+
itself an admission that pure Baumgarte is fragile against bad depth
|
|
447
|
+
readings — SPOOK's `eps` doesn't have this failure mode because it
|
|
448
|
+
doesn't try to fully correct in one tick.
|
|
449
|
+
|
|
450
|
+
**SPOOK is genuinely more principled and should be considered as a
|
|
451
|
+
swap-in.** Concretely:
|
|
452
|
+
|
|
453
|
+
1. Replace `bias = -BAUMGARTE_BETA / dt * (depth - PENETRATION_SLOP)`
|
|
454
|
+
with `bias = -a * (depth - PENETRATION_SLOP)` where `a` comes from
|
|
455
|
+
`setSpookParams(k, d, dt)`. Choose `k = 1e7` and `d = 4` as
|
|
456
|
+
Cannon does (these are the field-tested defaults).
|
|
457
|
+
2. Add the regularisation term to the iteration update — currently
|
|
458
|
+
`lambda_n = -m_eff_n * (vn + bias_n)`, becomes
|
|
459
|
+
`lambda_n = -m_eff_n * (vn + bias_n + eps * j_n_accum)` and
|
|
460
|
+
`m_eff_n = 1 / (k_n + eps)`.
|
|
461
|
+
3. The `MAX_BAUMGARTE_BIAS` cap can probably be removed — SPOOK's
|
|
462
|
+
softness handles inflated-depth EPA outputs gracefully because
|
|
463
|
+
the regularisation prevents lambda from running away.
|
|
464
|
+
|
|
465
|
+
This is the most concrete and highest-confidence improvement
|
|
466
|
+
opportunity in the review. **Estimated impact:** tall-stack
|
|
467
|
+
stability comparable to Cannon (which handles tens of bodies without
|
|
468
|
+
substepping using SPOOK + 10 iterations), removal of the inflated-EPA
|
|
469
|
+
launch failure mode, more robust feel without changing the public
|
|
470
|
+
API. **Risk:** SPOOK changes contact feel slightly — bodies "settle"
|
|
471
|
+
rather than "snap" to non-penetration. PLAN.md's note about TGS
|
|
472
|
+
substepping being blocked on split-impulse becomes much less urgent.
|
|
473
|
+
|
|
474
|
+
### Manifold caching — persistent vs per-frame rebuild
|
|
475
|
+
|
|
476
|
+
Cannon has **no persistent manifold cache**. Every step,
|
|
477
|
+
`Narrowphase.getContacts` rebuilds the contact list from scratch:
|
|
478
|
+
|
|
479
|
+
```
|
|
480
|
+
const oldcontacts = World_step_oldContacts;
|
|
481
|
+
for (i = 0; i !== NoldContacts; i++) { oldcontacts.push(contacts[i]); }
|
|
482
|
+
contacts.length = 0;
|
|
483
|
+
this.narrowphase.getContacts(p1, p2, ..., oldcontacts, ...);
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
The "oldcontacts" array is a **pool of allocated `ContactEquation`
|
|
487
|
+
objects** to recycle — pool-via-array-of-instances, not warm-start
|
|
488
|
+
information. The per-pair contact identity is rebuilt every frame,
|
|
489
|
+
which means **Cannon cannot do per-pair warm-start at all**. It can
|
|
490
|
+
warm-start within the solver iteration (lambda persists across
|
|
491
|
+
iterations within one frame's solve call) but not across frames.
|
|
492
|
+
|
|
493
|
+
Our `contact/ManifoldStore.js` is a real persistent manifold cache,
|
|
494
|
+
designed exactly like Bullet's `btPersistentManifold`:
|
|
495
|
+
- Keyed by canonical body pair via `PairUint32Map`
|
|
496
|
+
(`ManifoldStore.js:114`).
|
|
497
|
+
- Up to 4 contacts per slot (`MAX_CONTACTS_PER_MANIFOLD = 4`,
|
|
498
|
+
`ManifoldStore.js:10`).
|
|
499
|
+
- Per-contact stride includes `j_n`, `j_t1`, `j_t2` accumulated
|
|
500
|
+
impulses (`ManifoldStore.js:13-33`) that survive across frames.
|
|
501
|
+
- Touched / prev_touched / grace flag scheme so transient one-frame
|
|
502
|
+
separations don't evict the manifold (`ManifoldStore.js:57-69`,
|
|
503
|
+
`416-445`).
|
|
504
|
+
- `begin_refill(slot)` explicitly preserves impulse fields while
|
|
505
|
+
overwriting geometry (`ManifoldStore.js:265-268`).
|
|
506
|
+
|
|
507
|
+
And `narrowphase_step.js:837-931` implements **match-and-merge**:
|
|
508
|
+
candidates from this frame are matched to previous-frame contacts by
|
|
509
|
+
**feature_id first, position-fallback within 2 cm second**, with the
|
|
510
|
+
matched prev-contact's impulses copied to the corresponding new slot
|
|
511
|
+
index. This is a genuine warm-start that survives:
|
|
512
|
+
- Sphere-on-box sliding (feature_id is the box voronoi region,
|
|
513
|
+
`narrowphase_step.js:226-242`, stable while the sphere stays on the
|
|
514
|
+
same face/edge).
|
|
515
|
+
- Triangle-decomposition contacts (feature_id is the triangle index,
|
|
516
|
+
stable per-triangle, `narrowphase_step.js:590`).
|
|
517
|
+
- Box-box (feature_id = 0, falls back to position matching within
|
|
518
|
+
`MATCH_TOL_SQR = 0.0004 m²`).
|
|
519
|
+
|
|
520
|
+
**Status correction.** Prior reviews (Bullet, Jolt, Rapier) flagged
|
|
521
|
+
"warm-start impulses wiped every frame" — that was true at the time
|
|
522
|
+
of those reviews. Reading `ManifoldStore.js` and `narrowphase_step.js`
|
|
523
|
+
as they stand today, **warm-start is wired and working** on the
|
|
524
|
+
steady-state contact path. The only branches that wipe impulses are
|
|
525
|
+
`clear_contacts(slot)` calls (when narrowphase determines no contact
|
|
526
|
+
or empty collider lists), which is correct behaviour — separated
|
|
527
|
+
contacts should not warm-start. The `clear_impulses(slot, k)` for
|
|
528
|
+
unmatched-by-match-and-merge candidates is also correct: a fresh
|
|
529
|
+
contact at a slot index with stale data shouldn't inherit it.
|
|
530
|
+
|
|
531
|
+
This is **a substantial structural advantage over Cannon**. Cannon's
|
|
532
|
+
solver pays 10 iterations of convergence from a cold start every
|
|
533
|
+
frame; ours starts from the previous frame's solution and typically
|
|
534
|
+
converges in 2-3 iterations (with the remaining 7-8 absorbing
|
|
535
|
+
geometry changes). For tall stacks specifically, this is the
|
|
536
|
+
difference between PGS-with-warm-start being usable and needing TGS
|
|
537
|
+
substepping.
|
|
538
|
+
|
|
539
|
+
**Improvement opportunity:** consider lowering iteration count from
|
|
540
|
+
10 to e.g. 6 for steady-state, with a wake-event re-bump to 10 for
|
|
541
|
+
the first few frames after wake. The savings would scale with the
|
|
542
|
+
warm-start hit rate.
|
|
543
|
+
|
|
544
|
+
### Sleep — chatter vs atomic
|
|
545
|
+
|
|
546
|
+
Already covered above. Cannon's per-body sleep chatters on
|
|
547
|
+
weakly-connected piles; ours is atomic per island. The improvement
|
|
548
|
+
opportunity is in the **other** direction — Cannon's `sleepTick`
|
|
549
|
+
hooks `Body.dispatchEvent(Body.sleepyEvent)` /
|
|
550
|
+
`Body.dispatchEvent(Body.wakeupEvent)` so user code can subscribe.
|
|
551
|
+
We have `Signal onContactBegin/Stay/End` but no `onBodySleep` /
|
|
552
|
+
`onBodyWake` events. For game code (turn music off when player
|
|
553
|
+
stops, despawn enemies that have been asleep for N seconds), this is
|
|
554
|
+
a missing affordance.
|
|
555
|
+
|
|
556
|
+
### CCD (Continuous Collision Detection)
|
|
557
|
+
|
|
558
|
+
Cannon has no meaningful CCD — no per-body sweep, no speculative
|
|
559
|
+
margin. Fast-moving bodies tunnel.
|
|
560
|
+
|
|
561
|
+
We have **speculative margin** via the fat-AABB swept extent:
|
|
562
|
+
`compute_fat_world_aabb` pads the broadphase AABB by `linearVelocity
|
|
563
|
+
* dt`, so the broadphase catches imminent collisions and the
|
|
564
|
+
narrowphase sees the future-position pair early. PLAN.md
|
|
565
|
+
"Limitations / Known caveats" notes this is "CCD floor only" — the
|
|
566
|
+
falling-tower repro at 1 km drop onto 1 cm floor still tunnels 180
|
|
567
|
+
out of 1000 bodies because a single-step sweep isn't a true
|
|
568
|
+
swept-shape cast. PLAN.md backlog has "Per-body linear CCD
|
|
569
|
+
shape-cast" as the planned fix.
|
|
570
|
+
|
|
571
|
+
**We are categorically ahead of Cannon here.** Even our "CCD floor"
|
|
572
|
+
catches everything Cannon misses.
|
|
573
|
+
|
|
574
|
+
### Concave / triangle mesh
|
|
575
|
+
|
|
576
|
+
Cannon's `Trimesh` stores vertices in a `Float32Array`, indices in
|
|
577
|
+
`Int16Array`, normals in `Float32Array`. The narrowphase uses an
|
|
578
|
+
`Octree` to query triangles overlapping the convex's AABB. Each
|
|
579
|
+
returned triangle is **treated as a `ConvexPolyhedron`** for the
|
|
580
|
+
convex-convex SAT + clip path, which is correctness-wise solid for
|
|
581
|
+
polyhedral convex shapes but doesn't help with smooth shapes (which
|
|
582
|
+
Cannon doesn't have anyway).
|
|
583
|
+
|
|
584
|
+
Our `MeshShape3D` + `mesh_enumerate_triangles` does an O(N) linear
|
|
585
|
+
scan with tight per-triangle AABB filtering. No BVH per mesh.
|
|
586
|
+
**This is genuinely a regression compared to Cannon's Octree** for
|
|
587
|
+
large meshes (>10k triangles). PLAN.md doesn't list this as a
|
|
588
|
+
backlog item, but should — a per-mesh BVH built once at attach time
|
|
589
|
+
is exactly the same machinery as the global broadphase BVH, just
|
|
590
|
+
indexed by triangle. **Improvement opportunity: build a per-mesh
|
|
591
|
+
triangle BVH in `MeshShape3D` construction (or first-query), query
|
|
592
|
+
it instead of linear scanning.**
|
|
593
|
+
|
|
594
|
+
Both engines have boundary-edge correctness issues at internal
|
|
595
|
+
triangle junctions. Cannon's polyhedron-per-triangle treats each
|
|
596
|
+
triangle's edges as collision-active, producing the well-known
|
|
597
|
+
"phantom contacts on internal edges" bug. Ours has the same problem
|
|
598
|
+
plus the EPA-on-degenerate-triangle issue (PLAN.md "Limitations").
|
|
599
|
+
Neither engine implements Catto's "voronoi region triangle culling"
|
|
600
|
+
fix. **PLAN.md "Closed-form triangle-vs-primitive solvers"** is the
|
|
601
|
+
correct prescription here.
|
|
602
|
+
|
|
603
|
+
### Heightmaps
|
|
604
|
+
|
|
605
|
+
Cannon's `Heightfield` constructs an explicit convex polyhedron per
|
|
606
|
+
cell via `getConvexTrianglePillar(i, j, isUpper)` and caches them in
|
|
607
|
+
a `_cachedPillars` dictionary. Cache key is `"i_j_isUpper"` (string!
|
|
608
|
+
allocates on every lookup). Each pillar is a 5-faced extruded
|
|
609
|
+
triangle that goes from the upper surface down to a fixed depth —
|
|
610
|
+
giving the heightfield a "thickness" rather than the infinite-thin
|
|
611
|
+
surface our heightmap has.
|
|
612
|
+
|
|
613
|
+
Our `HeightMapShape3D` is a `Sampler2D`-backed surface; we sample
|
|
614
|
+
heights via `sampleChannelCatmullRomUV` and emit triangles
|
|
615
|
+
on-demand via `heightmap_enumerate_triangles`. No per-cell convex
|
|
616
|
+
caching, no extrusion — the heightmap is a one-sided surface and
|
|
617
|
+
"falling through" from below is undefined behaviour (we explicitly
|
|
618
|
+
reject contacts where the body is on the wrong side via the
|
|
619
|
+
one-sided face-normal check at `narrowphase_step.js:654`).
|
|
620
|
+
|
|
621
|
+
**Tradeoffs:**
|
|
622
|
+
|
|
623
|
+
| Aspect | meep heightmap | cannon-es Heightfield |
|
|
624
|
+
|---|---|---|
|
|
625
|
+
| Memory | O(1) — `Sampler2D` reference + orientation vector | O(cells × cached_pillars) — each pillar is a `ConvexPolyhedron` with 6 vertices, 5 faces |
|
|
626
|
+
| Per-step cost | Catmull-Rom sample × 4 per cell × triangles touched | Polyhedron support × triangle count touched (SAT) |
|
|
627
|
+
| Smoothness | Catmull-Rom interpolation matches the terrain renderer's geometry | Linear interpolation (raw heights) |
|
|
628
|
+
| Two-sidedness | One-sided (per design) | Two-sided (extruded pillar) |
|
|
629
|
+
| Allocation hot-path | None (rebound `Triangle3D` flyweight) | `_cachedPillars` string-key dict + Object spread on miss |
|
|
630
|
+
|
|
631
|
+
The per-step performance is roughly comparable. The Catmull-Rom
|
|
632
|
+
sampling we do is more expensive per-cell than Cannon's linear
|
|
633
|
+
read, but Cannon's polyhedron-per-cell SAT inflates the constant
|
|
634
|
+
factor on the narrowphase side. **Concrete win for us:** zero
|
|
635
|
+
allocation on the heightmap hot path; Cannon's string-key dict
|
|
636
|
+
allocation is a visible GC pressure source.
|
|
637
|
+
|
|
638
|
+
### Joints / Constraints
|
|
639
|
+
|
|
640
|
+
Cannon has a rich constraint library: `PointToPointConstraint`,
|
|
641
|
+
`DistanceConstraint`, `HingeConstraint`, `LockConstraint`,
|
|
642
|
+
`ConeTwistConstraint`. All are built on `Constraint` (base class)
|
|
643
|
+
that holds an array of `Equation` objects, plus a polymorphic
|
|
644
|
+
`update()` that recomputes the equations' Jacobians from the current
|
|
645
|
+
body poses. The solver iterates contacts AND joint equations
|
|
646
|
+
uniformly because they all share the SPOOK formulation.
|
|
647
|
+
|
|
648
|
+
We have **none**. PLAN.md "Backlog → Features → Joints" lists this
|
|
649
|
+
as planned ("distance, hinge, ball-socket, prismatic") and notes
|
|
650
|
+
"the solver loop is already set up to iterate contacts ∪ joints;
|
|
651
|
+
only constraint pre-step + warm-start hook is missing."
|
|
652
|
+
|
|
653
|
+
**Reading Cannon's `Constraint` API is the right move for the
|
|
654
|
+
joint implementation**. Specifically the SPOOK-equation pattern
|
|
655
|
+
makes it trivial to add new joints — each new joint type just
|
|
656
|
+
specifies how to compute G, what bounds the lambda has, and what `B`
|
|
657
|
+
should be at this pose. If we adopt SPOOK (see solver section) we
|
|
658
|
+
get this "joints and contacts are uniform" property for free.
|
|
659
|
+
|
|
660
|
+
### Queries
|
|
661
|
+
|
|
662
|
+
Cannon has `World.raycastClosest`, `raycastAny`, `raycastAll` — each
|
|
663
|
+
walks all bodies, then per-body iterates `shapes[i]`, then calls the
|
|
664
|
+
shape's `raycast` method. The shape-side `raycast` is implemented
|
|
665
|
+
per-shape (`Sphere.raycast`, `Box.raycast` via `ConvexPolyhedron`,
|
|
666
|
+
`Plane.raycast`, `Heightfield.raycast`, `Trimesh.raycast`). No BVH
|
|
667
|
+
traversal — the broadphase isn't consulted for raycasts at all.
|
|
668
|
+
|
|
669
|
+
Our `queries/raycast.js` traverses both BVHs by AABB-ray test and
|
|
670
|
+
reports the nearest hit. Currently the result is the leaf AABB hit,
|
|
671
|
+
not refined to the actual shape geometry — so a raycast against a
|
|
672
|
+
sphere reports a hit on the bounding cube. PLAN.md "Public queries"
|
|
673
|
+
notes this: "narrowphase refinement against the actual shape
|
|
674
|
+
geometry is a follow-up — for now `result.t` is the distance to the
|
|
675
|
+
leaf's inflated AABB". This is a **legitimate gap vs Cannon** —
|
|
676
|
+
ours is broadphase-fast but coarse, Cannon's is exact-shape but
|
|
677
|
+
linear-scan. **Improvement opportunity: add per-shape exact-raycast
|
|
678
|
+
refinement** after broadphase narrows candidates. The closed-form
|
|
679
|
+
sphere/box/capsule cases are trivial; the convex case can use
|
|
680
|
+
shape-cast machinery already in place.
|
|
681
|
+
|
|
682
|
+
`shapeCast` is something **Cannon does not have** in any form. Ours
|
|
683
|
+
sweeps a convex shape via swept-AABB broadphase + GJK bisection,
|
|
684
|
+
reporting time-of-impact and contact normal recovered via EPA. This
|
|
685
|
+
is what character controllers need; Cannon users have to roll their
|
|
686
|
+
own from raycasts.
|
|
687
|
+
|
|
688
|
+
`overlap` similarly has no Cannon equivalent. Ours filters
|
|
689
|
+
broadphase candidates by GJK and writes body ids into a caller-sized
|
|
690
|
+
`Uint32Array`. Useful for AOE selection, kinematic probes.
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
## 3. In-depth comparison of our code against Cannon
|
|
695
|
+
|
|
696
|
+
I'll pick eight touchpoints and walk through them.
|
|
697
|
+
|
|
698
|
+
### 3.1 Per-step body iteration — O(awake) vs O(total)
|
|
699
|
+
|
|
700
|
+
**Our code, `ecs/PhysicsSystem.js:1083`:**
|
|
701
|
+
```js
|
|
702
|
+
const count = this.storage.awake_count;
|
|
703
|
+
for (let i = 0; i < count; i++) {
|
|
704
|
+
const idx = this.storage.awake_at(i);
|
|
705
|
+
const rb = this.__bodies[idx];
|
|
706
|
+
const tr = this.__transforms[idx];
|
|
707
|
+
integrate_velocity(rb, tr, gx, gy, gz, dt);
|
|
708
|
+
}
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
**Cannon's pattern, `src/world/World.ts`:**
|
|
712
|
+
```js
|
|
713
|
+
for (let i = 0; i !== N; i++) {
|
|
714
|
+
const bi = bodies[i];
|
|
715
|
+
bi.sleepTick(this.time);
|
|
716
|
+
// ... unconditional work happens here, sleep state checked inline
|
|
717
|
+
}
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
Cannon's loop visits all `N` bodies regardless of sleep state. The
|
|
721
|
+
per-iteration cost is dominated by the field access pattern (cache
|
|
722
|
+
miss on cold bodies) + sleep state branch. For a scene with 1M
|
|
723
|
+
bodies and 100 awake, Cannon does 1M unconditional loads + 1M
|
|
724
|
+
branches; we do 100 indexed loads.
|
|
725
|
+
|
|
726
|
+
This is **the single most important scalability difference between
|
|
727
|
+
the two engines**. At our design target of "millions of mostly
|
|
728
|
+
sleeping bodies" Cannon's per-step cost grows linearly with the
|
|
729
|
+
total body count, ours with the awake count. A 1000:1 ratio
|
|
730
|
+
(realistic for a populated town) is a 1000× speedup.
|
|
731
|
+
|
|
732
|
+
**Improvement on our side:** none — the structure is correct. **The
|
|
733
|
+
risk to keep an eye on:** the per-body collider list lives in
|
|
734
|
+
`this.__body_collider_lists[idx]`, a JS array. Each
|
|
735
|
+
`compute_fat_world_aabb` call inside Stage 2 indirects through
|
|
736
|
+
`list[k].collider.shape.compute_bounding_box(...)`. That's three
|
|
737
|
+
pointer-chases per leaf. For a fully-monomorphic hot loop we could
|
|
738
|
+
flatten the leaves into a per-body Float64Array of `(node_id,
|
|
739
|
+
shape_local_aabb)`. Probably not worth the structural cost until
|
|
740
|
+
benchmarks show it dominating.
|
|
741
|
+
|
|
742
|
+
### 3.2 SPOOK constraint formulation
|
|
743
|
+
|
|
744
|
+
Side-by-side comparison of the per-iteration update:
|
|
745
|
+
|
|
746
|
+
**Cannon `GSSolver`:**
|
|
747
|
+
```js
|
|
748
|
+
deltalambda = invC * (B - GWlambda - c.eps * lambdaj);
|
|
749
|
+
```
|
|
750
|
+
Where `invC = 1 / (G·M⁻¹·Gᵀ + eps)`, `B = -g*a - GW*b - h*GiMf`, and
|
|
751
|
+
`eps` is the SPOOK regularisation.
|
|
752
|
+
|
|
753
|
+
**Ours `solve_contacts.js:531`:**
|
|
754
|
+
```js
|
|
755
|
+
const lambda_n = -m_eff_n * (vn + bias_n);
|
|
756
|
+
```
|
|
757
|
+
Where `m_eff_n = 1 / k_n` (no eps), `bias_n = -BAUMGARTE_BETA/dt *
|
|
758
|
+
(depth - slop)` capped at `MAX_BAUMGARTE_BIAS = 3 m/s`, plus
|
|
759
|
+
restitution velocity-target if `vn_pre < -RESTITUTION_VELOCITY_THRESHOLD`.
|
|
760
|
+
|
|
761
|
+
**The qualitative difference:**
|
|
762
|
+
|
|
763
|
+
- **SPOOK is unconditional and continuous.** The `eps * lambdaj` term
|
|
764
|
+
is present every iteration, regardless of depth. It acts like a
|
|
765
|
+
spring damping. As lambda grows, the regularisation eats into it,
|
|
766
|
+
preventing runaway.
|
|
767
|
+
- **Baumgarte is conditional and one-shot.** The bias kicks in only
|
|
768
|
+
above the slop threshold, applies a velocity correction once per
|
|
769
|
+
step, and the `MAX_BAUMGARTE_BIAS` cap is a panic guard against
|
|
770
|
+
EPA returning bad depths.
|
|
771
|
+
|
|
772
|
+
**Concrete failure mode of our code:** when EPA misreports depth on a
|
|
773
|
+
torus knot (PLAN.md mentions this as one of the motivating cases for
|
|
774
|
+
MAX_BAUMGARTE_BIAS), our solver caps the bias at 3 m/s. That's
|
|
775
|
+
enough to stop the body launching but not enough to actually
|
|
776
|
+
resolve a 10cm phantom penetration — the body sinks. SPOOK doesn't
|
|
777
|
+
have this dichotomy: the regularisation moderates the response
|
|
778
|
+
naturally.
|
|
779
|
+
|
|
780
|
+
**Recommended action:** when joints land (they need SPOOK or SPOOK-like
|
|
781
|
+
structure anyway for the constraint/joint uniformity), refactor the
|
|
782
|
+
contact solver to use SPOOK. The pre-step changes:
|
|
783
|
+
|
|
784
|
+
```js
|
|
785
|
+
// In pre-step (where bias is currently computed):
|
|
786
|
+
const k_spook = 1e7; // stiffness
|
|
787
|
+
const d_spook = 4; // relaxation
|
|
788
|
+
const a_spook = 4 / (dt * (1 + 4 * d_spook));
|
|
789
|
+
const b_spook = (4 * d_spook) / (1 + 4 * d_spook);
|
|
790
|
+
const eps = 4 / (dt * dt * k_spook * (1 + 4 * d_spook));
|
|
791
|
+
pre[pre_off + 12] = 1 / (k_n + eps); // includes regularisation
|
|
792
|
+
pre[pre_off + 15] = -a_spook * Math.max(0, depth - PENETRATION_SLOP);
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
(With a corresponding inner-loop change to subtract `eps * j_n_accum`
|
|
796
|
+
from the lambda before clamping.)
|
|
797
|
+
|
|
798
|
+
This single change probably resolves several existing pain points
|
|
799
|
+
(MAX_BAUMGARTE_BIAS cap, restitution-vs-warm-start interaction in the
|
|
800
|
+
TGS attempt, mesh-contact phantom-depth launching).
|
|
801
|
+
|
|
802
|
+
### 3.3 Friction model
|
|
803
|
+
|
|
804
|
+
**Cannon's `FrictionEquation`:** one-dimensional, along a single
|
|
805
|
+
tangent direction. The slipForce passes to `Equation` as bounds
|
|
806
|
+
`[-slipForce, slipForce]` where `slipForce = μ * F_normal`.
|
|
807
|
+
Cannon generates **two** FrictionEquations per contact (two tangent
|
|
808
|
+
directions) so the friction is effectively a polyhedral cone, not a
|
|
809
|
+
disk.
|
|
810
|
+
|
|
811
|
+
**Ours `solve_contacts.js:566-571`:**
|
|
812
|
+
```js
|
|
813
|
+
const want_t1 = j_t1_accum + lambda_t1;
|
|
814
|
+
const want_t2 = j_t2_accum + lambda_t2;
|
|
815
|
+
const max_friction = mus[ci] * new_j_n;
|
|
816
|
+
friction_cone_clamp(scratch_clamp, 0, want_t1, want_t2, max_friction);
|
|
817
|
+
```
|
|
818
|
+
Disk clamp in the (t1, t2) tangent plane. Mathematically a true
|
|
819
|
+
circular friction cone — strictly more accurate than Cannon's box
|
|
820
|
+
clamp.
|
|
821
|
+
|
|
822
|
+
**Win for us.** The box-vs-disk distinction matters for objects
|
|
823
|
+
sliding in directions not aligned with the tangent basis — Cannon
|
|
824
|
+
allows up to √2 × μ tangential force on a 45°-sliding object, ours
|
|
825
|
+
correctly clamps to μ. The difference is small but observable on
|
|
826
|
+
spinning objects on a flat surface.
|
|
827
|
+
|
|
828
|
+
### 3.4 Restitution + warm-start interaction
|
|
829
|
+
|
|
830
|
+
**Ours `solve_contacts.js:452-456`:**
|
|
831
|
+
```js
|
|
832
|
+
if (vn_pre < -RESTITUTION_VELOCITY_THRESHOLD) {
|
|
833
|
+
bias += restitution_combined * vn_pre;
|
|
834
|
+
}
|
|
835
|
+
```
|
|
836
|
+
Restitution is **added to the velocity bias inside the iterative
|
|
837
|
+
loop**. Combined with the `j_n ≥ 0` clamp, this is what PLAN.md
|
|
838
|
+
identifies as one of three reasons the TGS substep attempt failed:
|
|
839
|
+
|
|
840
|
+
> "Restitution × warm-start clamp. The j_n ≥ 0 clamp lets substep 1+
|
|
841
|
+
> shrink the impulse applied in substep 0, so a restitution=0 ball
|
|
842
|
+
> that should stop instead picks up a phantom upward velocity."
|
|
843
|
+
|
|
844
|
+
**Cannon's `ContactEquation`:**
|
|
845
|
+
```js
|
|
846
|
+
GW = ePlusOne * vj.dot(n) - ePlusOne * vi.dot(n) + ...
|
|
847
|
+
B = -g * a - GW * b - h * GiMf
|
|
848
|
+
```
|
|
849
|
+
Restitution scales the **closing velocity** in `GW` before computing
|
|
850
|
+
the bias. Mathematically identical to ours in the simple case but
|
|
851
|
+
structurally different — Cannon's restitution is baked into
|
|
852
|
+
`computeB` and rebuilt every step, ours is added on top of
|
|
853
|
+
Baumgarte every step. Cannon's GS solver doesn't have the
|
|
854
|
+
warm-start-shrinkage problem because there's no warm-start.
|
|
855
|
+
|
|
856
|
+
**A genuine improvement opportunity:** the Box2D / Box2D-Lite
|
|
857
|
+
"split-impulse" pattern that PLAN.md mentions as a prerequisite for
|
|
858
|
+
TGS is also the right structure for clean restitution. Restitution
|
|
859
|
+
should be a **one-shot impulse at first contact**, not a per-iteration
|
|
860
|
+
bias. Bullet does this too. Cannon's approach is less correct (it
|
|
861
|
+
applies on every iteration, but starts cold each frame so the
|
|
862
|
+
warm-start interaction doesn't bite). When we add SPOOK, this is a
|
|
863
|
+
good time to switch to a real restitution model.
|
|
864
|
+
|
|
865
|
+
### 3.5 ConvexPolyhedron.clipFaceAgainstHull vs box_box_manifold
|
|
866
|
+
|
|
867
|
+
Both implement Sutherland-Hodgman polygon clipping against a series
|
|
868
|
+
of half-spaces. The structural difference:
|
|
869
|
+
|
|
870
|
+
Cannon's `clipFaceAgainstHull` is **N-gon → N-gon** (handles any
|
|
871
|
+
polygon size, output bounded by `numClipFaces × original_size`).
|
|
872
|
+
The clip buffer is a JavaScript `Array<Vec3>` that grows / shrinks
|
|
873
|
+
with `.push()` and `.shift()` — both allocate-and-copy.
|
|
874
|
+
|
|
875
|
+
Our `box_box_manifold.js` clips a quad against 4 edges, hard-coded.
|
|
876
|
+
Output buffer is a `Float64Array(8 * 3)` allocated once at module load
|
|
877
|
+
(`clip_in`, `clip_out` at `box_box_manifold.js:53-54`). Zero
|
|
878
|
+
allocation per call.
|
|
879
|
+
|
|
880
|
+
**Win for us on the box-box case.** For the future ConvexHullShape3D
|
|
881
|
+
we'll want to port Cannon's general case, but with our typed-array
|
|
882
|
+
allocation discipline rather than `Array.push/shift`. The
|
|
883
|
+
key insight from Cannon that we'd otherwise miss: **the inner clip
|
|
884
|
+
loop reuses two ping-pong buffers and a swap-on-output pattern**
|
|
885
|
+
that minimises the polygon-buffer reallocations to two fixed
|
|
886
|
+
`Vec3[]` arrays per clip pass. That pattern translates directly to
|
|
887
|
+
two fixed `Float64Array` slabs for us.
|
|
888
|
+
|
|
889
|
+
### 3.6 Sleep — atomic island vs per-body chatter
|
|
890
|
+
|
|
891
|
+
Already covered in §1 and §2. Concrete walk-through of the failure:
|
|
892
|
+
|
|
893
|
+
**Cannon's chatter (paraphrased trace):**
|
|
894
|
+
```
|
|
895
|
+
Frame N: bottom block sleepState=SLEEPY, timeLastSleepy=N*dt
|
|
896
|
+
Frame N+1: top block jitters; broadphase pair list includes block above it
|
|
897
|
+
both blocks `wakeUpAfterNarrowphase=true`
|
|
898
|
+
bottom wakes via velocity propagation
|
|
899
|
+
Frame N+2: bottom resleeps (now SLEEPY again)
|
|
900
|
+
Frame N+3: middle blocks similarly wake from a different jitter
|
|
901
|
+
...
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
A 100-block stack chatters at ~3-5 fps of sleep-state churn for
|
|
905
|
+
seconds before fully settling, if it ever does. The user-observable
|
|
906
|
+
effect is GC spikes from per-frame event dispatch plus visible
|
|
907
|
+
micro-jitter.
|
|
908
|
+
|
|
909
|
+
**Our atomic sleep (`ecs/PhysicsSystem.js:942-1006`):**
|
|
910
|
+
```js
|
|
911
|
+
if (max_v_sqr < threshold_sqr) {
|
|
912
|
+
let min_timer = Infinity;
|
|
913
|
+
for (let i = start; i < end; i++) {
|
|
914
|
+
rb.sleep_timer += dt;
|
|
915
|
+
if (rb.sleep_timer < min_timer) min_timer = rb.sleep_timer;
|
|
916
|
+
}
|
|
917
|
+
if (min_timer >= time_threshold) {
|
|
918
|
+
this.__atomic_sleep_island_range(body_data, start, end);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
```
|
|
922
|
+
The whole island either accumulates time or doesn't. When it crosses
|
|
923
|
+
the threshold, every member sleeps the same step, and the
|
|
924
|
+
sleep-group circular DLL connects them so a single wake event
|
|
925
|
+
walks the chain.
|
|
926
|
+
|
|
927
|
+
**Verbatim from PLAN.md's "Done" section:** "Per-island atomic sleep:
|
|
928
|
+
... Replaces the per-body chatter on weakly-connected piles." This
|
|
929
|
+
is one of the engineering claims most clearly validated by the
|
|
930
|
+
Cannon comparison — the algorithm Cannon ships has the exact failure
|
|
931
|
+
mode we explicitly engineered around.
|
|
932
|
+
|
|
933
|
+
### 3.7 Per-pair narrowphase dispatch
|
|
934
|
+
|
|
935
|
+
Both engines look superficially similar — a cascade of pair-type
|
|
936
|
+
checks dispatching to per-pair contact methods. The differences:
|
|
937
|
+
|
|
938
|
+
**Cannon `Narrowphase.getContacts`:** dispatch table keyed by shape
|
|
939
|
+
type enum (Sphere=1, Box=4, Plane=8, etc.). Lookup pattern:
|
|
940
|
+
```js
|
|
941
|
+
const resolverIndex = (si.type | sj.type);
|
|
942
|
+
// uses bit-OR of shape type enums as table key
|
|
943
|
+
```
|
|
944
|
+
Method-per-pair on the `Narrowphase` class (`sphereSphere`,
|
|
945
|
+
`sphereBox`, `sphereConvex`, `convexConvex`, `planeBox`,
|
|
946
|
+
`planeConvex`, `planeTrimesh`, `sphereTrimesh`, `convexTrimesh`,
|
|
947
|
+
`sphereHeightfield`, `convexHeightfield`, `sphereParticle`,
|
|
948
|
+
`planeParticle`, `convexParticle`, `boxConvex`, ...).
|
|
949
|
+
|
|
950
|
+
**Ours `narrowphase_step.js:dispatch_pair`:** cascade of
|
|
951
|
+
`if (isSphereA && isSphereB)`, `if ((isSphereA && isBoxB) || ...)`,
|
|
952
|
+
etc. — handled as boolean checks on `shape.isXxx === true` flags.
|
|
953
|
+
|
|
954
|
+
**Tradeoff:**
|
|
955
|
+
|
|
956
|
+
- Cannon's table is O(1) dispatch but allocates an integer combination
|
|
957
|
+
per call and has unmaintainable enum-OR collision risk. It also
|
|
958
|
+
forces all narrowphase to live on one class instance.
|
|
959
|
+
- Ours is O(K) cascade where K = pair categories handled (currently
|
|
960
|
+
~7). At small K cascade is faster than table lookup; the
|
|
961
|
+
isShapeType branch predicts well after the first hit. At large K
|
|
962
|
+
the table wins.
|
|
963
|
+
|
|
964
|
+
**Both lose to a real double-dispatch pattern**, but neither is bad
|
|
965
|
+
enough to fix yet.
|
|
966
|
+
|
|
967
|
+
**Improvement opportunity for us:** the cascade in `dispatch_pair`
|
|
968
|
+
checks `isSphereA && isBoxB || isBoxA && isSphereB` which always
|
|
969
|
+
runs both halves of the `||` even after a hit. Rearranging by
|
|
970
|
+
expected frequency (sphere-sphere first if that's the dominant pair,
|
|
971
|
+
which it is for ball-based games) gets us closer to one-branch fast
|
|
972
|
+
path for the common case. Probably <1% real-world win; not urgent.
|
|
973
|
+
|
|
974
|
+
### 3.8 Allocation patterns
|
|
975
|
+
|
|
976
|
+
**Cannon's allocation hot-spots that cannon-es fixed vs original
|
|
977
|
+
cannon.js:**
|
|
978
|
+
|
|
979
|
+
cannon-es is the result of years of de-allocation work compared to
|
|
980
|
+
the original. Notable fixes documented in the changelog / source
|
|
981
|
+
comments:
|
|
982
|
+
|
|
983
|
+
- Module-level `tmpVec`, `tmpQuat`, `Body_applyForce_rotForce`,
|
|
984
|
+
`Body_applyImpulse_velo` etc. scratch instances declared once per
|
|
985
|
+
module, reused everywhere. Original cannon.js allocated these
|
|
986
|
+
per-call.
|
|
987
|
+
- `World_step_oldContacts` and `World_step_oldFrictionEquations`
|
|
988
|
+
arrays reused frame-to-frame, with `.length = 0` + push pattern.
|
|
989
|
+
- `SAPBroadphase` insertion-sort with persistent `axisList`.
|
|
990
|
+
|
|
991
|
+
**Where Cannon STILL allocates per-step** (and we don't):
|
|
992
|
+
- `Narrowphase.getContacts` uses `Array.push` extensively for the
|
|
993
|
+
output contact list. Even with the `contactPointPool` recycling
|
|
994
|
+
the `ContactEquation` instances, the outer array grows via `push`
|
|
995
|
+
and resets via `.length = 0`. This is **the** cannon-es per-frame
|
|
996
|
+
allocation hot path. Our equivalent — writing into a fixed
|
|
997
|
+
`Float64Array` slab via `set_contact(slot, idx, ...)` — has zero
|
|
998
|
+
steady-state allocation.
|
|
999
|
+
- `SplitSolver` BFS over equations builds a fresh `nodes` /
|
|
1000
|
+
`children` graph each call. Pool exists but graph-shape changes
|
|
1001
|
+
defeat it.
|
|
1002
|
+
- `Heightfield._cachedPillars` uses string keys (`"i_j_isUpper"`) —
|
|
1003
|
+
every lookup allocates a string. Hot path on heightmap collision.
|
|
1004
|
+
|
|
1005
|
+
**JS-specific optimisation insights from cannon-es worth absorbing:**
|
|
1006
|
+
|
|
1007
|
+
1. **Module-level scratch instances declared in a uniform pattern.**
|
|
1008
|
+
The Cannon convention is `const Body_methodName_purpose = new
|
|
1009
|
+
Vec3()`. Naming makes the scratch's owner and intent obvious. We
|
|
1010
|
+
do the same with prefixed `scratch_*` names; cannon's discipline
|
|
1011
|
+
is more enforced. The convention is good — names like
|
|
1012
|
+
`scratch_inertia_a` / `scratch_inertia_b` in our solver follow
|
|
1013
|
+
the pattern correctly. A grep for `const scratch_` shows we're
|
|
1014
|
+
consistent.
|
|
1015
|
+
|
|
1016
|
+
2. **Body field initialisation order matters for V8 hidden classes.**
|
|
1017
|
+
Both engines initialise fields in the constructor (or as class
|
|
1018
|
+
fields) in a fixed order, ensuring all `RigidBody` / `Body`
|
|
1019
|
+
instances share the same hidden class. Our class-field syntax
|
|
1020
|
+
(`linearVelocity = new Vector3(0, 0, 0)` at class-body scope) is
|
|
1021
|
+
the modern equivalent of Cannon's constructor assignments. Both
|
|
1022
|
+
approaches achieve hidden-class consistency. **Risk:** if any
|
|
1023
|
+
call site does `body.somethingNew = value`, V8 transitions to a
|
|
1024
|
+
new hidden class. We should keep RigidBody field-set strictly
|
|
1025
|
+
closed; users wanting extra state should attach side components.
|
|
1026
|
+
|
|
1027
|
+
3. **Avoid Float32Array for hot constraint data.** cannon-es uses
|
|
1028
|
+
`Vec3` (3 numbers as object fields, not typed array). Vec3 access
|
|
1029
|
+
is faster than Float32Array indexing on V8 (no bounds check). But
|
|
1030
|
+
for **bulk solver state** that lives in slabs (our manifold
|
|
1031
|
+
`data_buffer`, our `scratch_pre` per-contact stride), Float64Array
|
|
1032
|
+
is correct — bulk reads / writes by offset compile to direct
|
|
1033
|
+
memory operations. The lesson is "use Vec3-like for individual
|
|
1034
|
+
vectors, TypedArray for bulk slabs". We follow this pattern
|
|
1035
|
+
correctly.
|
|
1036
|
+
|
|
1037
|
+
4. **Avoid object-spread / Object.assign on hot path.** Cannon has a
|
|
1038
|
+
couple of places (in Equation reset code) that do `Object.assign`
|
|
1039
|
+
on equation instances — measurable per-frame cost. We don't have
|
|
1040
|
+
any such patterns; worth a periodic audit.
|
|
1041
|
+
|
|
1042
|
+
5. **Use shift-by-N on bit-packed flags rather than separate
|
|
1043
|
+
booleans.** Our `ManifoldStore` packs `count | touched |
|
|
1044
|
+
prev_touched | grace` into one Uint32 word
|
|
1045
|
+
(`ManifoldStore.js:57-61`). Cannon uses separate boolean fields
|
|
1046
|
+
on `Body` (`allowSleep`, `wakeUpAfterNarrowphase`, etc.) — fine
|
|
1047
|
+
for code clarity but more memory per body. At million-body
|
|
1048
|
+
scale our bit-packing saves 4 bytes per slot × N slots.
|
|
1049
|
+
|
|
1050
|
+
### 3.9 Numerical stability — degenerate cases
|
|
1051
|
+
|
|
1052
|
+
**Cannon's degenerate-case handling:**
|
|
1053
|
+
|
|
1054
|
+
- `findSeparatingAxis` skips edge-cross axes where the cross is
|
|
1055
|
+
almost zero (`if (!Cross.almostZero())`). Prevents NaN normals on
|
|
1056
|
+
parallel edge configurations.
|
|
1057
|
+
- `ConvexPolyhedron` constructor warns if a face normal points
|
|
1058
|
+
inward (`console.error` — doesn't fix).
|
|
1059
|
+
- `Trimesh` validates index range when computing per-triangle
|
|
1060
|
+
normals.
|
|
1061
|
+
|
|
1062
|
+
**Ours:**
|
|
1063
|
+
|
|
1064
|
+
- `narrowphase_step.js:728`: `if (!(depth > 0) || !Number.isFinite(depth))
|
|
1065
|
+
return count;` — rejects NaN/Inf/zero depths from EPA. Good.
|
|
1066
|
+
- `narrowphase_step.js:620`: same check inside the concave loop.
|
|
1067
|
+
- `solve_contacts.js:151, 261`: `inv_mass_of` and
|
|
1068
|
+
`angular_jacobian_contribution` guard against zero mass /
|
|
1069
|
+
inertia. Good.
|
|
1070
|
+
- **Missing:** no zero-area triangle guard in `mesh_enumerate_triangles`
|
|
1071
|
+
or `heightmap_enumerate_triangles`. A degenerate triangle (collinear
|
|
1072
|
+
vertices) would feed a zero face normal into GJK / EPA and the
|
|
1073
|
+
one-sided rejection at `narrowphase_step.js:654` would silently
|
|
1074
|
+
skip it. **Probably fine in practice** — degenerate triangles in a
|
|
1075
|
+
source mesh are usually filtered at import — but worth a paranoid
|
|
1076
|
+
guard.
|
|
1077
|
+
- `solver/solve_contacts.js:418-420` guards K=0 with
|
|
1078
|
+
`k_n > 0 ? 1 / k_n : 0` — prevents NaN m_eff on two
|
|
1079
|
+
zero-mass bodies (kinematic-vs-kinematic, which the solver
|
|
1080
|
+
should never see, but defensively).
|
|
1081
|
+
|
|
1082
|
+
**Concrete improvement opportunity:** add `if (Math.abs(face_normal_sq) <
|
|
1083
|
+
EPS) continue;` at `narrowphase_step.js:599` (after computing
|
|
1084
|
+
`fnx_l/fny_l/fnz_l` in the triangle loop). This rejects degenerate
|
|
1085
|
+
triangles before they reach EPA.
|
|
1086
|
+
|
|
1087
|
+
---
|
|
1088
|
+
|
|
1089
|
+
## 4. Simplicity & uniformity
|
|
1090
|
+
|
|
1091
|
+
### Code organisation
|
|
1092
|
+
|
|
1093
|
+
| Aspect | meep | cannon-es |
|
|
1094
|
+
|---|---|---|
|
|
1095
|
+
| Module layout | One concept per file, hierarchical directories (`body/`, `broadphase/`, `solver/`, `gjk/`, `narrowphase/`, ...) | Mostly one class per file, flat-ish (`src/objects/`, `src/collision/`, `src/equations/`, `src/solver/`, `src/shapes/`) |
|
|
1096
|
+
| Class structure | Mostly free functions + a few classes (Storage, ManifoldStore, IslandBuilder, PhysicsSystem) | Class hierarchy everywhere (Body, Shape→Sphere/Box/..., Equation→ContactEquation/FrictionEquation, Constraint→PointToPoint/Distance/...) |
|
|
1097
|
+
| Public API surface | Concentrated on `PhysicsSystem` | Spread across World + Body + Shape + Constraint subclasses |
|
|
1098
|
+
| File count | ~50 files in `physics/` (excluding tests) | ~50 files in cannon-es `src/` |
|
|
1099
|
+
| LOC | Roughly comparable; we have more in the broadphase + decomposition machinery |
|
|
1100
|
+
|
|
1101
|
+
Both engines are flat and avoid deep hierarchies (unlike Bullet's
|
|
1102
|
+
`btCollisionObject < btRigidBody < btSoftBody` etc.). Cannon does
|
|
1103
|
+
have one inheritance chain that matters: `Shape` → `Sphere`, `Box`
|
|
1104
|
+
(which is a `ConvexPolyhedron` subclass), `Plane`, `Heightfield`,
|
|
1105
|
+
`Particle`, `ConvexPolyhedron`, `Trimesh`. The `Shape` base class
|
|
1106
|
+
exposes `volume()`, `boundingSphereRadius`, `updateBoundingSphereRadius()`,
|
|
1107
|
+
`calculateLocalInertia()`, `calculateWorldAABB()` — virtual methods
|
|
1108
|
+
that every shape overrides. Adding a new shape means subclassing
|
|
1109
|
+
`Shape` and implementing 5 virtuals.
|
|
1110
|
+
|
|
1111
|
+
Our `AbstractShape3D` plays the equivalent role. New shape = same
|
|
1112
|
+
contract.
|
|
1113
|
+
|
|
1114
|
+
### Adding a new shape pair — workflow
|
|
1115
|
+
|
|
1116
|
+
**Cannon:** add a new method on `Narrowphase`, register it in the
|
|
1117
|
+
dispatch table. Add the shape class as a `Shape` subclass.
|
|
1118
|
+
|
|
1119
|
+
**Ours:** add a new closed-form file in `narrowphase/`, add a branch
|
|
1120
|
+
to the `dispatch_pair` cascade in `narrowphase_step.js`. Add the
|
|
1121
|
+
shape class as an `AbstractShape3D` implementation.
|
|
1122
|
+
|
|
1123
|
+
**Equivalent friction.** Adding capsule-mesh would be the same
|
|
1124
|
+
amount of work in either engine.
|
|
1125
|
+
|
|
1126
|
+
### Adding a new constraint
|
|
1127
|
+
|
|
1128
|
+
**Cannon:** subclass `Constraint`, instantiate the Equations it
|
|
1129
|
+
owns, implement `update()` to recompute Jacobians. SPOOK + GSSolver
|
|
1130
|
+
handle the rest.
|
|
1131
|
+
|
|
1132
|
+
**Ours:** **not currently supported**. The solver knows about
|
|
1133
|
+
contacts only; adding joints requires extending `solve_island` to
|
|
1134
|
+
iterate a `joints` list parallel to contacts. PLAN.md says this is
|
|
1135
|
+
planned but not done.
|
|
1136
|
+
|
|
1137
|
+
**Cannon's design is the right reference.** The "constraints and
|
|
1138
|
+
contacts are both Equations" abstraction is what makes Cannon's
|
|
1139
|
+
joint library so flexible. Adopting SPOOK structurally enables
|
|
1140
|
+
copying this design.
|
|
1141
|
+
|
|
1142
|
+
### Adding a new query
|
|
1143
|
+
|
|
1144
|
+
**Cannon:** implement per-shape `raycast` method; `World.raycastClosest`
|
|
1145
|
+
iterates all bodies + shapes.
|
|
1146
|
+
|
|
1147
|
+
**Ours:** add a query function in `queries/`. Use the BVH for
|
|
1148
|
+
broadphase, dispatch per-shape narrowphase.
|
|
1149
|
+
|
|
1150
|
+
**We win on structure** — queries live in their own subsystem and
|
|
1151
|
+
share the broadphase. Cannon's per-shape `raycast` is duplicated
|
|
1152
|
+
across every shape class and bypasses the broadphase entirely.
|
|
1153
|
+
|
|
1154
|
+
### Where Cannon's `Body` god-class wins
|
|
1155
|
+
|
|
1156
|
+
Despite our decomposition argument, **Cannon's single-class `Body`
|
|
1157
|
+
makes some things simpler**:
|
|
1158
|
+
- One reference to pass around (`body`).
|
|
1159
|
+
- `addEventListener('sleep', ...)` etc. for application code.
|
|
1160
|
+
- `body.applyForce(force, worldPoint)` reads naturally.
|
|
1161
|
+
|
|
1162
|
+
Our `system.applyForceAt(rigidBody, transform, force, worldPoint)`
|
|
1163
|
+
needs four arguments because force application needs the transform
|
|
1164
|
+
(for the r = worldPoint - position calculation) and the system
|
|
1165
|
+
(for the wake call), and the rigid body itself. The system-level
|
|
1166
|
+
API is necessarily more verbose. **This is the unavoidable cost of
|
|
1167
|
+
ECS decomposition** — we picked it for good reasons (transform is
|
|
1168
|
+
shared with rendering, components are independently
|
|
1169
|
+
serialisable / observable) and the price is API verbosity.
|
|
1170
|
+
|
|
1171
|
+
### Where Cannon's API is a cautionary tale
|
|
1172
|
+
|
|
1173
|
+
1. **`world.bodies` is a public mutable array.** Users mutate it
|
|
1174
|
+
directly, which violates every encapsulation invariant. We
|
|
1175
|
+
correctly hide `__bodies` and expose only `entityOf(packed_id)`.
|
|
1176
|
+
|
|
1177
|
+
2. **Body events fire via `dispatchEvent` on the Body itself.** This
|
|
1178
|
+
means every Body has its own listener list. At 100k bodies that's
|
|
1179
|
+
100k tiny listener arrays. We use Signal-per-system + per-entity
|
|
1180
|
+
event channel (`PhysicsEvents`), which scales better.
|
|
1181
|
+
|
|
1182
|
+
3. **`World.contacts` rebuilt every frame as a fresh array.** Visible
|
|
1183
|
+
GC pressure. Our `ManifoldStore` is persistent and slab-based.
|
|
1184
|
+
|
|
1185
|
+
4. **`Heightfield._cachedPillars` string-key dict.** Allocates per
|
|
1186
|
+
lookup. Our `HeightMapShape3D` has no equivalent caching layer —
|
|
1187
|
+
it samples and emits triangles on-demand into a flyweight, zero
|
|
1188
|
+
allocation.
|
|
1189
|
+
|
|
1190
|
+
5. **`ConvexPolyhedron.findSeparatingAxis` allocates `Vec3`
|
|
1191
|
+
intermediates** despite cannon-es's de-allocation pass. Our
|
|
1192
|
+
`box_box_manifold.js` is entirely typed-array, zero allocation.
|
|
1193
|
+
|
|
1194
|
+
### Where we could simplify further
|
|
1195
|
+
|
|
1196
|
+
- The cascade of `is*A && is*B || is*A && is*B` in `dispatch_pair`
|
|
1197
|
+
has each pair-type check duplicated for ordering. A small enum +
|
|
1198
|
+
pair-table would compress this without sacrificing inlining. But
|
|
1199
|
+
it's not urgent — the cascade is still readable at 7 pair types.
|
|
1200
|
+
|
|
1201
|
+
- `narrowphase_step.js` has the SAT/clip box-box code linked but the
|
|
1202
|
+
general convex-hull case TODO. When we add `ConvexHullShape3D`,
|
|
1203
|
+
the box-box code wants to be subsumed into the general path
|
|
1204
|
+
rather than maintained as a parallel implementation. Don't add
|
|
1205
|
+
ConvexHull without refactoring box-box at the same time, or we'll
|
|
1206
|
+
have two copies of essentially the same algorithm.
|
|
1207
|
+
|
|
1208
|
+
- `solve_contacts.js` has separate friction and normal blocks that
|
|
1209
|
+
could be unified under SPOOK's "every equation is the same shape"
|
|
1210
|
+
model. This is the joint-system prerequisite anyway.
|
|
1211
|
+
|
|
1212
|
+
---
|
|
1213
|
+
|
|
1214
|
+
## Top recommendations
|
|
1215
|
+
|
|
1216
|
+
In rough priority order:
|
|
1217
|
+
|
|
1218
|
+
1. **Adopt SPOOK constraint regularisation** in the solver
|
|
1219
|
+
(`solver/solve_contacts.js`). Removes the `MAX_BAUMGARTE_BIAS`
|
|
1220
|
+
hack, improves tall-stack stability comparable to Cannon, and is
|
|
1221
|
+
the structural prerequisite for clean joint implementation.
|
|
1222
|
+
Estimated effort: medium (one solver rewrite, no public API
|
|
1223
|
+
change). Estimated payoff: high.
|
|
1224
|
+
|
|
1225
|
+
2. **Wire MPR as the EPA-non-convergence fallback** in
|
|
1226
|
+
`narrowphase_step.js`. Code exists at `gjk/mpr.js` per PLAN.md;
|
|
1227
|
+
one branch on EPA's NaN/zero-depth path swaps it in. Unblocks the
|
|
1228
|
+
torus-knot test and the smooth-shape contact stability issue.
|
|
1229
|
+
Effort: low. Payoff: high for smooth-shape cases.
|
|
1230
|
+
|
|
1231
|
+
3. **Add per-shape exact raycast refinement** after BVH broadphase
|
|
1232
|
+
(`queries/raycast.js`). Currently returns leaf-AABB hit which
|
|
1233
|
+
underestimates `t` for non-AABB shapes. Cannon's per-shape
|
|
1234
|
+
raycast methods are the reference. Effort: medium. Payoff:
|
|
1235
|
+
makes the raycast query genuinely useful for character
|
|
1236
|
+
controllers / weapon hit detection.
|
|
1237
|
+
|
|
1238
|
+
4. **Build per-mesh triangle BVH for `MeshShape3D`**. Cannon's
|
|
1239
|
+
`Trimesh.tree` is the reference (Octree there; BVH for us since
|
|
1240
|
+
we have the infrastructure). Replace the O(N) linear scan in
|
|
1241
|
+
`mesh_enumerate_triangles`. Effort: low (BVH is in
|
|
1242
|
+
`core/bvh2/`). Payoff: linear-to-log mesh scaling, opens the door
|
|
1243
|
+
to genuinely large static-mesh worlds.
|
|
1244
|
+
|
|
1245
|
+
5. **Closed-form triangle-vs-primitive** narrowphase (already on
|
|
1246
|
+
PLAN.md backlog — re-emphasising because it's the gating issue
|
|
1247
|
+
for several skipped tests). Cannon's "treat triangle as
|
|
1248
|
+
ConvexPolyhedron and SAT it" works but has the same internal-edge
|
|
1249
|
+
phantom contact issue we have. Catto's voronoi triangle
|
|
1250
|
+
classification is the standard fix.
|
|
1251
|
+
|
|
1252
|
+
6. **Add `onBodySleep` / `onBodyWake` signals** on `PhysicsSystem`.
|
|
1253
|
+
Cannon's per-body events are the wrong scale (event listener per
|
|
1254
|
+
body), but a system-level signal carrying island membership is
|
|
1255
|
+
useful for game code. Effort: trivial. Payoff: gameplay-quality
|
|
1256
|
+
of life.
|
|
1257
|
+
|
|
1258
|
+
7. **Consider lowering default solver iterations from 10 to 6 once
|
|
1259
|
+
warm-start is verified stable in production.** Cannon defaults
|
|
1260
|
+
to 10 because it can't warm-start. Ours can; the savings scale
|
|
1261
|
+
with warm-start hit rate (typically >80% in steady-state
|
|
1262
|
+
contacts). Effort: trivial (one constant). Validation: medium
|
|
1263
|
+
(need a stack-stability benchmark sweep).
|
|
1264
|
+
|
|
1265
|
+
8. **Audit `mesh_enumerate_triangles` for zero-area triangle
|
|
1266
|
+
handling.** Add an `Math.abs(face_normal_sq) < EPS` skip. Cheap
|
|
1267
|
+
defensive guard. Effort: trivial.
|
|
1268
|
+
|
|
1269
|
+
---
|
|
1270
|
+
|
|
1271
|
+
## Summary
|
|
1272
|
+
|
|
1273
|
+
Cannon is our closest peer on engineering constraints (pure JS,
|
|
1274
|
+
single-threaded, no SIMD) and the comparison is consistently
|
|
1275
|
+
favourable. **Where we are clearly ahead:**
|
|
1276
|
+
- Broadphase (BVH × 2 vs default NaiveBroadphase O(N²))
|
|
1277
|
+
- Body iteration scaling (O(awake) vs O(total))
|
|
1278
|
+
- Atomic island sleep + atomic wake (vs per-body chatter on weakly-connected piles)
|
|
1279
|
+
- Persistent manifold cache with cross-frame warm-start (vs frame-rebuilt contact list)
|
|
1280
|
+
- CCD floor via speculative margin (vs nothing)
|
|
1281
|
+
- Disk-clamped friction cone (vs polyhedral box clamp)
|
|
1282
|
+
- Zero-allocation hot paths in narrowphase + heightmap (vs string-key dict, Array.push)
|
|
1283
|
+
- shapeCast + overlap queries (Cannon has neither)
|
|
1284
|
+
- ECS decomposition + observability hooks
|
|
1285
|
+
|
|
1286
|
+
**Where Cannon is ahead or has the better idea:**
|
|
1287
|
+
- SPOOK constraint formulation (vs our Baumgarte + cap hack)
|
|
1288
|
+
- Joints / Constraints library (we have none)
|
|
1289
|
+
- General convex polyhedron shape (we have only `BoxShape3D`)
|
|
1290
|
+
- Per-shape exact raycast (vs our AABB-only result)
|
|
1291
|
+
- Per-mesh BVH (Octree) for large triangle meshes (vs our O(N) linear scan)
|
|
1292
|
+
- Per-body sleep/wake event listeners (we have signals at system level only)
|
|
1293
|
+
|
|
1294
|
+
**Headline structural insight:** Cannon is "what JS rigid-body
|
|
1295
|
+
physics looked like in 2014, slowly de-allocated by community
|
|
1296
|
+
maintenance to 2023." We are "what JS rigid-body physics could be in
|
|
1297
|
+
2026 if you start from the modern reference architectures
|
|
1298
|
+
(Jolt/Bullet/Box2D) and then constrain to pure JS." The
|
|
1299
|
+
ecosystem-position is genuinely interesting — there's no other
|
|
1300
|
+
pure-JS engine attempting our design ambitions.
|