# Bullet vs. meep rigid-body physics — deep technical review

Subject: `engine/physics/` (pure-JS rigid-body engine, ECS-attached) vs.
[Bullet Physics 3](https://github.com/bulletphysics/bullet3) (C++).

Bullet is a mature, multi-platform physics SDK with a notoriously deep class
hierarchy and broad simulation feature set. meep is a deliberately scoped
pure-JS engine: deterministic same-runtime, no WASM/SIMD, no shared-memory
workers, designed around an active-list / mostly-sleeping model with up to
millions of bodies. The two projects share core algorithmic ideas (Catto-style
sequential impulse, `btPersistentManifold`-style 4-point manifold cache,
SAT+clipping for box-box, GJK+EPA for general convex pairs) but diverge sharply
on dispatch shape, hierarchy depth, and feature surface.

This review is organised in four sections, per the original brief.

---

## 1. Overall architecture

### 1.1 Pipeline shape

| Phase | meep (`PhysicsSystem.fixedUpdate`) | Bullet (`btDiscreteDynamicsWorld::stepSimulation`) |
|---|---|---|
| Integrate velocity | `integrate_velocity` — semi-implicit Euler, gravity + accum force/torque, implicit damping | `predictUnconstrainMotion` — same scheme, explicit damping with `exp` |
| Broadphase refit | `compute_fat_world_aabb` + `node_move_aabb`, per active collider leaf | `updateAabbs` + `btDbvtBroadphase::setAabb`, deferred update queue |
| Pair generation | `generate_pairs` — leaf-by-leaf query of both BVHs, dedup via manifold "touched" bit | `calculateOverlappingPairs` — incremental, persists pair cache across frames, percentage-revalidated each tick |
| Narrowphase | `narrowphase_step` — closed-form fast paths then GJK+EPA fallback | `btCollisionDispatcher` — virtual algorithm matrix per `(shape_type_A, shape_type_B)` lookup |
| Island build | `IslandBuilder` — union-find rebuilt every frame from touched non-sensor contacts | `btSimulationIslandManager` — also union-find based, but on persistent pairs |
| Constraint solve | `solve_contacts` (Catto SI, 10 iters, in-place per-island) | `btSequentialImpulseConstraintSolver`, 10 iters default, optional split-impulse position iters |
| Position integrate | `integrate_position` + quaternion integrator | `integrateTransforms`, with optional CCD shape-cast for fast objects |
| Sleep test | `__sleep_test` — per-island atomic | Per-body `m_deactivationTime` accumulator with `gDeactivationTime` (default 2 s) |
| Manifold diff → events | `diff_manifolds` + `__dispatch_contact_events` (Begin / Stay / End signals) | `internalTickCallback` user-driven, or `contactStartedCallback` / `contactEndedCallback` globals |
| Frame roll | `manifolds.advance_frame` — touched → prev, evict on grace | `btPersistentManifold::refreshContactPoints` — per-manifold, by world-distance |

**Where the two are most similar**: both run a velocity-only solver iteration
sweep with warm-starting on a persistent manifold cache keyed on body-pair, and
both build islands via union-find so the solver iterates one connected
component at a time.

**Where meep diverges deliberately**:

1. **No split-impulse architecture.** Bullet's solver runs separate position-
   correction iterations (`solveGroupCacheFriendlySplitImpulseIterations`)
   over a *pseudo-velocity* that does not contaminate the real velocity.
   meep folds position correction into the velocity bias via Baumgarte
   (`solve_contacts.js:444`). `PLAN.md` already lists this as the
   architectural blocker for TGS substepping; the trade-off is documented
   and intentional.
2. **Stateless pair regeneration vs. Bullet's persistent overlapping-pair
   cache.** Bullet keeps `m_overlappingPairCache` between frames and only
   re-validates a percentage of pairs each step (`gOverlappingPairsPercentageToUpdate`).
   meep regenerates every pair every step from the BVH leaves
   (`generate_pairs.js:50-100`), relying on the `ManifoldStore`'s touched-bit
   for dedup. Simpler, less per-frame state, slightly higher recurrent cost.
3. **Two BVHs (static + dynamic) rather than Bullet's two-stage single
   structure with "moving objects → fixed set" promotion.** meep classifies
   at insert (`PhysicsSystem.js:325`); Bullet shuffles between stages over
   time. meep's two-tree model is cleaner; Bullet's is closer to Jolt.
4. **Atomic per-island sleep with sleep-group chain wakeup.** Bullet sleeps
   per body and bodies in the same island wake one-by-one as contacts
   propagate via the broadphase over multiple frames. meep threads a
   circular `sleep_group_next` / `sleep_group_prev` doubly-linked list
   through all members of an atomically-sleeping island
   (`PhysicsSystem.js:754-786`) so any wake on any member wakes the whole
   chain in one call. This is meep's most distinctive architectural choice
   and the PLAN.md rationale (the 100-block-stack wake wave) is well-founded.

### 1.2 Data layout

meep uses a deliberate Structure-of-Arrays (`BodyStorage` — six typed arrays
indexed by body slot) and an external sparse `__bodies[]` / `__transforms[]` /
`__body_collider_lists[]` indexed the same way. The hot iteration is

```
for (let i = 0; i < count; i++) {
  const idx = this.storage.awake_at(i);
  const rb = this.__bodies[idx];
  ...
}
```

— sequential awake-list, sparse sidetable dereference. The manifold store has
its own SoA `Float64Array` plus a `Uint32Array` meta buffer (`ManifoldStore.js`,
lines 96-115). Solver scratch is a per-call typed array
(`solver/solve_contacts.js:88-91`).

Bullet uses standard AoS objects (`btRigidBody`, `btCollisionObject`,
`btPersistentManifold`) plus a separate "solver body" SoA (`btSolverBody`,
`btSolverConstraint`) inside the constraint solver only. The
narrowphase / broadphase paths are AoS.

meep is **more uniformly SoA** end-to-end. That's the right call for JS — V8
treats typed arrays as backing for off-heap buffers and the hot loops here
avoid the property-load cost that would dominate equivalent Bullet-style AoS
JS code.

### 1.3 Broadphase

| Aspect | meep | Bullet (`btDbvtBroadphase`) |
|---|---|---|
| Tree count | 2 (`staticBvh`, `dynamicBvh`) | 2 (`m_sets[0]` dynamic / `m_sets[1]` fixed) |
| Fat-AABB constant | `FAT_LINEAR = 0.05` + 2 × velocity × dt | `gDbvtMargin = 0.05`, velocity-scaled internally |
| Pair generation | Re-query every active leaf every frame | Incremental: only re-validate a percentage |
| Pair store | `PairUint32Map` (Robin Hood, Fibonacci hash) → manifold slot | `btHashedOverlappingPairCache` |

Bullet's two-set design migrates bodies between sets as they go inactive — a
true two-stage tree. meep's two-tree split is by *body kind* (static or
dynamic) at insert. The Bullet model is closer to Jolt's static/dynamic split
than to meep's, but in practice meep's is simpler and matches PLAN.md's stated
Jolt-inspired rationale.

**Fat-AABB strategy** in `broadphase/compute_fat_world_aabb.js:34`:

```js
const speed = Math.sqrt(vx * vx + vy * vy + vz * vz);
const pad = FAT_LINEAR + FAT_VELOCITY_MULTIPLIER * speed * dt;
```

This is Box2D-style padding (constant + velocity look-ahead). Bullet's
equivalent is `setAabbForceUpdate` with `gDbvtVelocityScale = 1.0` and
`gDbvtExtraMargin = 0.05`. Both engines bound the fat AABB by *predicted next
position* + slack — same idea, slightly different constants.

### 1.4 Island structure

meep's `IslandBuilder` is union-find with deterministic union-by-min-index +
path halving (`island/union_find.js:65-76`), producing CSR-style output sorted
ascending within and across islands. Static / kinematic bodies are **constraint
anchors only** — they do not enlarge islands, so two disjoint piles on the
same floor remain separate islands. This is identical to Bullet's
`btSimulationIslandManager` (which also marks static bodies as
`ISLAND_SLEEPING` and treats them as graph-cut points), and to Jolt.

The CSR output design (`body_offsets[]` + `body_data[]`,
`contact_offsets[]` + `contact_data[]`) is cleaner than Bullet's, which uses
a per-body `m_islandTag1` field and a `getIslandId()` post-pass — meep's is
both more cache-friendly to iterate and simpler to reason about for parallel
solving in the future.

**Determinism guarantee**: meep's "smallest-body-index wins as root" gives a
canonical island id that's independent of pair encounter order. Bullet's
island ids depend on `findUnion()` call order, so the same scene can produce
different island indices across runs — fine for Bullet's use case, but
problematic for a deterministic replay engine. PLAN.md is right to call this
out explicitly.

### 1.5 Solver architecture

| | meep | Bullet |
|---|---|---|
| Solver | Sequential Impulse (Catto / Box2D) | Sequential Impulse (Catto / Box2D) |
| Iterations | 10 (`DEFAULT_VELOCITY_ITERATIONS`) | 10 (`m_numIterations`) |
| Warm-start | Yes — `j_n / j_t1 / j_t2` stored per contact, replayed pre-iter | Yes — `cp.m_appliedImpulse` scaled by `m_warmstartingFactor = 0.85` |
| Warm-start factor | 1.0 (no scale-down) | 0.85 |
| Friction model | 2-axis tangent + **disk-cone clamp** (`friction_cone_clamp`) | 2-axis (optional) with **box clamp** (`m_lowerLimit = -mu * j_n`) |
| Restitution | Velocity-bias inside the iteration loop, clamped above threshold (1.0 m/s) | One-shot velocity target injected into iter-0 RHS |
| Position correction | Baumgarte velocity bias `β/dt * pen`, capped at 3 m/s | Optional split-impulse pseudo-velocity pass (default on) |
| Per-island iteration | Yes (`solve_contacts` walks `islands.contact_offsets`) | Yes (per-island group inside `solveGroupCacheFriendly...`) |

**Notable substantive differences:**

- meep's friction model is **better** than Bullet's default. Bullet's
  `m_lowerLimit = -mu * j_n` independent-axis clamp produces the classic
  "friction stronger along one tangent than the other" anisotropy on
  diagonally-sliding bodies. meep's 2-D disk projection
  (`solver/friction_cone.js:25-36`) is the correct Coulomb cone projection
  and matches Box2D Lite's recipe.

- meep's **restitution is a velocity bias inside the iter loop**, which is
  what Box2D Lite does. Bullet runs it as a one-shot target velocity in
  iter-0's RHS. meep's approach is documented in `PLAN.md` as one of the
  three problems blocking TGS, because the inside-the-loop bias interacts
  badly with the `j_n ≥ 0` warm-start clamp on second and subsequent
  substeps. Bullet's one-shot approach is robust under substepping; meep's
  isn't. This is the right call-out — PLAN.md is fully aware of the
  consequence.

- meep **does not have split-impulse**. Bullet's
  `solveGroupCacheFriendlySplitImpulseIterations` runs a separate
  pseudo-velocity pass for position correction. meep's Baumgarte bias is
  capped at `MAX_BAUMGARTE_BIAS = 3` m/s (`solve_contacts.js:62-63`) as a
  guard against EPA-degenerate-depth blow-ups, which is sound but does mean
  deep penetrations resolve more slowly. The cap is conservative — Bullet's
  default `erp` is also 0.2 but it gets applied via split impulse so
  saturation is less common.

### 1.6 Sleep system

- **meep**: per-island atomic. `__sleep_test` (`PhysicsSystem.js:942-1006`)
  computes `max(|v|² + |ω|²)` across an island; when all members have been
  below the threshold for `sleepTimeThreshold` (default 0.5 s), the entire
  island sleeps in one frame via `__atomic_sleep_island_range`, which threads
  the circular sleep-group chain.
- **Bullet**: per-body. `btRigidBody::updateDeactivation` increments
  `m_deactivationTime` per body; sleeps when ≥ `gDeactivationTime` (default
  2 s). Islands wake one body at a time as contacts propagate.

meep's atomic sleep is **architecturally superior for stacks** — a 100-block
stack hit at the base wakes the whole stack in one frame, where Bullet would
take ~100 frames of broadphase propagation. The cost is more bookkeeping (the
sleep-group chain), and the precondition is the per-island design described
above. PLAN.md explains the chain mechanism well.

### 1.7 Threading model

Both engines are single-threaded by default. Bullet has the optional
`btMultiThreadedDynamicsWorld` + worker-pool wrapper; meep is explicitly
single-threaded with `SharedArrayBuffer` cited in PLAN.md as a future seam.
This is in scope of the documented design bets and not in scope for review.

---

## 2. Specific algorithms and tradeoffs

For each touchpoint: meep file:line | Bullet header/cpp | tradeoff.

### 2.1 Broadphase (DBVT)

- **meep**: `core/bvh2/bvh3/BVH.js` (external to physics) + `broadphase/generate_pairs.js:50-100`. Two BVHs, per-leaf re-query each frame, manifold-store dedup.
- **Bullet**: `BulletCollision/BroadphaseCollision/btDbvtBroadphase.{h,cpp}` + `btDbvt.{h,cpp}`. One DBVT with two `m_sets` (dynamic + fixed), incremental update queue, `m_deferedcollide`.

| Trade-off | meep | Bullet |
|---|---|---|
| Per-frame cost | O(active leaves × log N) every frame | O(active leaves × log N) amortised over multiple frames |
| State complexity | Low — just two trees + manifold store | Higher — deferred queue, stage promotion, pair cache |
| Determinism | Strong — leaf order is deterministic | Weaker — depends on internal queue eviction order |

meep gives up Bullet's amortised cost to gain determinism and simplicity.
Reasonable trade-off given PLAN.md's design priorities.

### 2.2 GJK

- **meep**: `gjk/gjk.js`, 64 iterations, simplex stored as Float64Array slots.
- **Bullet**: `BulletCollision/NarrowPhaseCollision/btGjkPairDetector.cpp` + `btVoronoiSimplexSolver.cpp`. 1000-iteration cap. Cached separating axis across frames (`m_cachedSeparatingAxis`).

Major differences:

1. **Frame coherence.** Bullet caches the last separating axis per
   `btGjkPairDetector` instance and uses it as the next step's initial search
   direction — typically converging in 1-3 iterations on quiescent pairs.
   meep starts every call with `(1, 0, 0)` (`gjk/gjk.js:41`) regardless of
   prior frames' result. **This is a real performance gap on stable
   contacts** — Bullet's cached-axis trick gives roughly an order of
   magnitude fewer support calls per frame in steady state.
2. **Simplex management.** meep uses Kevin Moran's slot-shuffling simplex
   (`gjk.js:163-265`), which is direct and readable. Bullet uses
   `btVoronoiSimplexSolver` which computes the closest point to the origin
   within the simplex and prunes by Voronoi region — more complex but more
   numerically robust.
3. **Convergence criterion.** Bullet's "previousSquaredDistance −
   squaredDistance ≤ ε × previousSquaredDistance" is a relative-improvement
   check. meep uses absolute fall-off (the new support point's dot product
   with the search direction is < 0, i.e. it didn't pass the origin). Both
   correct; Bullet's is slightly more conservative on near-touching pairs.

### 2.3 EPA

- **meep**: `gjk/expanding_polytope_algorithm.js`, 64 iterations, 64-face cap, 32-loose-edge cap. Linear scan to find closest face each iter.
- **Bullet**: `BulletCollision/NarrowPhaseCollision/btGjkEpa2.cpp`. 255-iteration cap, doubly-linked-list hull with neighbour pointers per face, recursive horizon expansion.

Comparison points:

| | meep | Bullet |
|---|---|---|
| Face data structure | Flat Float64Array `[ax,ay,az, bx,by,bz, cx,cy,cz, nx,ny,nz]` × 64 | Linked list of `sFace` (3 verts, 3 neighbour pointers) |
| Closest-face lookup | Linear scan over `num_faces` every iter | Linear scan over hull list every iter |
| Horizon edge handling | Loose-edge list — for each removed face, three edges added; matching edges in the list cancel | Recursive `expand()` walks neighbour pointers — O(visible-face count), no extra storage |
| Iteration cap | 64 | 255 |
| Smooth-shape degenerate handling | Documented in code, returns closest-face approximation | `eStatus::Degenerated` set, fallback returns best-so-far |

meep's flat-array approach is **easier to read and trivially deterministic**.
Bullet's linked-list-with-neighbours is asymptotically better when the
horizon is large (visible-face traversal is O(neighbours) instead of O(faces))
but the constant factor difference is negligible at the face counts we
actually see (≤ 64).

**The biggest issue in meep's EPA** is the same one Bullet has — neither
engine converges cleanly on smooth shapes, because both rely on the polytope
having flat faces to expand onto. meep mitigates with closed-form fast paths
for sphere/box/capsule and the documented MPR alternative; Bullet does the
same with closed-form pair handlers and `btMprPenetration`.

### 2.4 Box-box manifold

- **meep**: `narrowphase/box_box_manifold.js`. SAT over 15 axes, Sutherland-Hodgman face clip, deepest-then-spread reduction to 4 contacts.
- **Bullet**: `BulletCollision/CollisionDispatch/btBoxBoxDetector.cpp`. SAT over 15 axes (Open Dynamics Engine port), custom `intersectRectQuad2()` polygon-clip, `cullPoints2()` reduction (deepest + angular distribution to 4).

Both engines:
- Use SAT with the canonical 15 axes (3 face normals A, 3 face normals B, 9
  edge-cross pairs).
- Project the incident face into the reference face's 2-D frame and clip.
- Reduce to ≤ 4 contacts.

Differences:

| | meep | Bullet |
|---|---|---|
| Clipping | Sutherland-Hodgman, 4 successive axis-aligned half-planes | `intersectRectQuad2` — same idea, hand-unrolled |
| Reduction | Deepest at slot 0, then greedy maximise-min-distance | Deepest at slot 0, then 3 by angular position around the centroid |
| Edge-edge case | Single midpoint contact (PLAN.md known limitation) | Single closest-edge-point contact via `dLineClosestApproach` |

meep's edge-edge fallback is **less accurate** than Bullet's. PLAN.md lists
"edge-edge multi-point manifold" as a backlog item; the cheaper improvement
would be to first match Bullet's "closest points on the two edges" formula —
a few lines of segment-segment math — rather than the midpoint
(`box_box_manifold.js:221-230`). This would close most of the gap without
needing the full multi-point edge-pair contact.

meep's spread-by-max-min-distance reduction is a *better* default than
Bullet's angular distribution when the clipped polygon is convex but not
near-circular — both work, but meep's is theoretically slightly more
solver-stable for elongated contact regions. The two are practically
indistinguishable on cube-on-cube.

### 2.5 Sphere-sphere, sphere-box, capsule pairs

- meep has closed-form solvers for each (`sphere_sphere_contact.js`,
  `sphere_box_contact.js`, `capsule_contacts.js`).
- Bullet, surprisingly, **does not** dispatch sphere-sphere and sphere-box to
  closed-form paths in the default `btConvexConvexAlgorithm` — they go
  through GJK ([source](https://github.com/bulletphysics/bullet3/blob/master/src/BulletCollision/CollisionDispatch/btConvexConvexAlgorithm.cpp)).
  Bullet only specialises capsule-capsule and capsule-sphere
  (`btCapsuleCapsuleAlgorithm`, added in Bullet 2.75 for ragdoll
  performance).

This is a **real advantage** for meep: GJK for sphere-sphere does maybe 10
iterations of cross-products + dot-products to compute what one
`d² < (rA+rB)²` test gets you in closed form. meep's `sphere_sphere_contact`
is fewer than 20 numeric ops; Bullet's GJK route is closer to 200. The same
factor-of-10 gap applies to sphere-box.

meep's `sphere_box_contact` (`narrowphase/sphere_box_contact.js`) correctly
handles the centre-inside-box singular case with smallest-overlap face
tie-breaking, with X > Y > Z deterministic tie-break — robust and
deterministic.

### 2.6 Sequential impulse solver

- **meep**: `solver/solve_contacts.js`, 10 iters default, Baumgarte bias inside the velocity loop, disk-cone friction clamp, warm-start factor = 1.0.
- **Bullet**: `BulletDynamics/ConstraintSolver/btSequentialImpulseConstraintSolver.cpp`, 10 iters default, split-impulse pseudo-velocity pass for position, box-clamp friction (default) or 2-direction with optional cone, warm-start factor = 0.85.

Side-by-side:

| Concern | meep | Bullet |
|---|---|---|
| Warm-start `j ≥ 0` clamp | Yes (`solve_contacts.js:533`) | Yes |
| Tangent basis construction | Least-aligned-axis (`build_tangents`, lines 115-143) | `btPlaneSpace1` (essentially the same thing) OR velocity-aligned if `SOLVER_DISABLE_VELOCITY_DEPENDENT_FRICTION_DIRECTION` is off |
| Bias clamp | `MAX_BAUMGARTE_BIAS = 3` m/s | `m_maxErrorReduction = 20` per Bullet defaults |
| Restitution threshold | 1.0 m/s | 2.0 m/s (`m_restingContactRestitutionThreshold = 2`) |
| Friction coefficient combine | Geometric mean | Default: `mat0.friction * mat1.friction` (product) |
| Restitution combine | Max | Default: `mat0.restitution * mat1.restitution` (product) |

meep's combine functions are **closer to Unity / PhysX defaults** (geometric
mean for friction, max for restitution). Bullet's products are a quirk that
produces low-friction surfaces from `(0.5, 0.5)` pairs (gives 0.25). meep's
choice is more conventional.

### 2.7 Manifold caching

- **meep**: `contact/ManifoldStore.js`. Stride-13 contact record (positions A, positions B, normal, depth, three impulses). 4 contacts max. Grace-2 frame eviction. Per-frame touched flag. PairUint32Map for `(idA, idB) → slot`.
- **Bullet**: `btPersistentManifold.h/cpp`. Stride is larger (local/world positions on both bodies, normal, two lateral friction directions, two lateral impulses, distance, applied impulses, lifetime counter, partIds, friction-anchor flag). 4 contacts max. Distance-threshold eviction via `validContactDistance()`. No grace.

meep's manifold record is **leaner**, intentionally — it stores only what's
needed for the solver (world positions, normal, depth, accumulated impulses).
Bullet's adds:

1. **Local-space contact positions** on each body. Bullet uses these to
   re-validate contacts as bodies rotate without rerunning narrowphase
   (`refreshContactPoints` projects local-to-world and tests world-distance).
   meep doesn't do incremental refresh — narrowphase runs every frame for
   every touched manifold. This is fine for meep's pipeline because the
   broadphase is also rerun every frame; Bullet's persistence is what lets
   it amortise narrowphase cost over multiple frames.

2. **Cached lateral friction directions** per contact. Bullet's tangent
   basis is constructed once and persisted; meep recomputes via
   `build_tangents` every iter per contact. The recompute cost is ~6
   multiplies + 1 normalise per tangent → negligible for low contact counts;
   becomes measurable at 1000+ active contacts.

3. **Lifetime counter** per contact. Bullet uses it for `m_frictionAnchor`
   handling. meep doesn't have friction anchors yet.

meep's eviction strategy (`DEFAULT_GRACE = 2`, in `ManifoldStore.js:59`) lets
a one-frame separation survive — jitter-friendly. Bullet's distance-threshold
eviction (`m_contactBreakingThreshold`, world-space) is more physically
grounded but requires tuning. meep's frame-count grace is simpler.

### 2.8 Sleep

- **meep**: per-island atomic, threshold `0.01` on `|v|² + |ω|²`, time `0.5 s`. Sleep-group chain for atomic wake.
- **Bullet**: per-body, threshold `gLinearSleepingThreshold = 0.8`, `gAngularSleepingThreshold = 1.0`, time `gDeactivationTime = 2.0`.

meep's threshold (0.01) is *much* tighter than Bullet's (0.8 and 1.0). Both
are reasonable — Bullet uses 0.8 as a wide noise floor; meep is tuned for
clean game-physics inputs. Bullet's deactivation time (2 s) is also longer
than meep's (0.5 s).

### 2.9 CCD

- **meep**: speculative margin via fat AABB only (PLAN.md known limitation).
- **Bullet**: optional per-body `m_ccdMotionThreshold` + `m_ccdSweptSphereRadius` triggers `btSubsimplexConvexCast` conservative advancement.

This is a real gap (PLAN.md acknowledges) but the documented use case — 1km
drop onto a 1cm floor losing 180/1000 bodies to tunnelling — is exactly the
scenario where shape-cast CCD would help. Backlog item is well-scoped.

### 2.10 Concave / triangle-mesh narrowphase

- **meep**: `narrowphase/narrowphase_step.js:368-578` + `narrowphase/decomposition/`. Decompose concave shape into triangles in convex's AABB → per-triangle GJK + EPA → one-sided face-normal rejection → contact-normal dedup. **Known limitation: `Triangle3D`'s support function is degenerate along its face-normal axis.**
- **Bullet**: `BulletCollision/CollisionShapes/btBvhTriangleMeshShape` + `btBvhTriangleMeshShape::processAllTriangles` callback → per-triangle GJK via `btConvexTriangleCallback`. Same per-triangle approach in spirit; Bullet doesn't suffer the degenerate-support issue because `btTriangleShape` extends `btPolyhedralConvexShape` with a thin extruded depth, not a true zero-volume primitive.

meep's documented `Triangle3D` degenerate support is a real correctness
issue — and the PLAN.md "closed-form triangle-vs-primitive solvers" backlog
item is the right fix. The half-space pre-test in `compute_penetration`
(noted in PLAN.md) is a clean workaround for that one entry point.

A subtle observation about meep's concave dispatch: the sign-check at
`narrowphase_step.js:506` validates the MTV against `(convex_centre −
triangle_centroid)`, then rejects if the MTV opposes the triangle's
outward face normal (one-sided). This is a **better-than-Bullet** rejection
strategy. Bullet's per-triangle GJK has no such face-normal sanity check, so
a body pushed *through* a wall by external forces can resolve as if it's
contacting from the inside, producing the classic "popping out the wrong
side" artifact. meep explicitly avoids this. Strong design.

### 2.11 MPR

- **meep**: `gjk/mpr.js` — XenoCollide / Snethen GDC 2009 implementation, MTV output format matches EPA (drop-in compatible). 64-iter cap. Not wired into the dispatch.
- **Bullet**: `btMprPenetration.h` — also XenoCollide / Snethen. 1000-iter cap (`BT_MPR_MAX_ITERATIONS`), 1e-6 tolerance.

Both implementations are textbook XenoCollide. Bullet's portal-discovery
loop handles `BT_MPR_TOLERANCE` (1e-6) more aggressively than meep's
`MPR_TOLERANCE` (1e-4) — meep's tolerance is the order of typical
narrowphase noise, which is fine. meep's also handles the
`MPR_EPSILON = 1e-10` collinearity check inline.

The MPR result-convention match-EPA design choice in meep is *excellent* —
it means the swap from EPA to MPR is a one-line change at any call site.
Bullet's MPR is wired into a separate code path with its own result format,
so adopting it as a primary narrowphase would be a bigger refactor.

### 2.12 Queries (raycast / shape-cast / overlap)

- **meep**: `queries/raycast.js` (broadphase-only), `queries/shape_cast.js`
  (broadphase swept-AABB + GJK bisection ToI + EPA normal at kiss point),
  `queries/overlap_shape.js` (broadphase + per-candidate GJK with concave
  triangle-decomposition routing).
- **Bullet**: `btCollisionWorld::rayTest` (broadphase + per-shape narrowphase
  ray test), `btCollisionWorld::convexSweepTest`
  (`btSubsimplexConvexCast` / `btContinuousConvexCollision` per candidate),
  `btCollisionWorld::contactTest` (broadphase + per-pair manifold collection).

meep's shape-cast implementation uses **GJK bisection on the time interval**
plus EPA at the kiss point to recover the surface normal. Bullet's
`btSubsimplexConvexCast` uses Brian Mirtich's conservative advancement,
which is faster (no bisection) but produces less precise normals at the
moment of contact. meep's approach is more conservative; the bisection's
extra GJK calls per cast are negligible for the use case (player movement,
AOE casts).

The `shape_cast` "rerun EPA at best_t on the winning candidate to recover
true normal" trick is a clean way to get accurate contact normals — better
than what most game engines bother with. The fallback to `-ray.direction`
only on EPA degeneracies (NaN / zero depth) is sound.

---

## 3. In-depth comparison: 8 consequential touchpoints

For each: same quantity? bug or simplification? specific opportunity.

### 3.1 GJK — frame coherence

**meep** (`gjk/gjk.js:41`): every call seeds the initial direction with `(1, 0, 0)`:

```js
minkowski_support(simplex, 6, shape_a, shape_b, 1, 0, 0);
```

**Bullet** (`btGjkPairDetector.cpp`): caches the converged separating axis
per-pair:

```cpp
m_cachedSeparatingAxis.setValue(0, 1, 0);  // first frame
// ... after convergence:
m_cachedSeparatingAxis = newCachedSeparatingAxis;
```

**Diagnosis**: not a bug — meep is computing the same answer. But a
**performance opportunity worth pursuing**: caching the last successful
search direction (or, equivalently, the EPA-converged MTV normal) on the
manifold cache slot and seeding the next frame's GJK with it would reduce
GJK iterations from 10-20 down to 3-5 on quiescent contacts. Storage would
be 3 floats per manifold slot — bumping `CONTACT_STRIDE` from 13 to 16, or
adding a per-slot meta field.

**Improvement**: store last separating axis on manifold acquire/touch path,
seed `simplex_buf` from it on next call. Estimated 30-50% reduction in GJK
support calls in stable scenes.

### 3.2 Box-box edge-edge contact

**meep** (`narrowphase/box_box_manifold.js:221-230`):

```js
if (best_source >= 6) {
    const mx = (ax + bx) * 0.5;
    const my = (ay + by) * 0.5;
    const mz = (az + bz) * 0.5;
    out[3] = 1;
    out[4] = mx; out[5] = my;  out[6] = mz;
    out[7] = mx; out[8] = my;  out[9] = mz;
    out[10] = best_overlap;
    return true;
}
```

**Bullet** (`btBoxBoxDetector.cpp`): finds the closest points on the two
edges via `dLineClosestApproach` and uses their midpoint as the contact —
much more accurate.

**Diagnosis**: simplification. Both engines emit one contact for edge-edge,
but Bullet's at the actual closest-edge-point pair; meep's at the body
centres' midpoint. For a stack of skewed cubes (45° offset), meep's contact
point is consistently off by ~0.5 × half-extent — solver still resolves but
with a wrong lever arm.

**Improvement**: replace the midpoint with the segment-segment closest-pair
formula on the two contributing edges (`best_source - 6` gives the
edge-pair index; the edges are `(ta[i], length 2*ahx/y/z)` and
`(tb[j], length 2*bhx/y/z)`). The helper already exists in the codebase as
`core/geom/3d/line/line3_closest_points_segment_segment.js` per PLAN.md
"Bonus utilities". This is a 10-line change that closes the documented
limitation.

### 3.3 EPA — closest-face linear scan

**meep** (`gjk/expanding_polytope_algorithm.js:115-127`):

```js
let min_dist = v3_dot_array_array(faces, 0, faces, 3 * 3);
closest_face = 0;
for (let i = 1; i < num_faces; i++) {
    const dist = v3_dot_array_array(faces, i * FACE_ELEMENT_COUNT, faces, i * FACE_ELEMENT_COUNT + 3 * 3);
    if (dist < min_dist) { min_dist = dist; closest_face = i; }
}
```

**Bullet** (`btGjkEpa2.cpp::findbest()`): same linear scan over hull faces.

**Diagnosis**: both engines scan linearly. This is fine for 64-face caps;
a priority queue would only pay off above ~200 faces. No improvement
needed.

Minor: meep also re-computes the face's distance-to-origin (`v3_dot` of the
face's vertex against its own normal) on every iteration. Caching it on
the face record would skip an N-multiply pass. Not significant at 64 faces.

### 3.4 EPA — `debugger` statement on zero-normal degeneracy

**meep** (`gjk/expanding_polytope_algorithm.js:135-137`):

```js
if (search_dir_x === 0 && search_dir_y === 0 && search_dir_z === 0) {
    debugger;
}
```

**Diagnosis**: **bug / inappropriate left-in debug aid**. A live `debugger;`
statement in a hot path will halt the entire app under devtools and is dead
code under production V8 (no observable effect, but signals "this code
shouldn't reach here, but didn't decide what to do if it does"). The
function continues with `(0, 0, 0)` as search direction, which produces a
support point identical to whatever the support function returns for a
zero direction — undefined behaviour per the `support()` contract.

**Improvement**: replace with a defensive bail — return whatever the current
`closest_face` approximation says (mirror the iteration-cap exit at line 338).
The closest-face approximation is what the narrowphase consumer already
expects on degenerate EPA, and the upstream `if (!(depth > 0))` check filters
it out anyway.

### 3.5 Solver bias clamp

**meep** (`solver/solve_contacts.js:62-63, 444-449`):

```js
const MAX_BAUMGARTE_BIAS = 3;
// ...
if (depth > PENETRATION_SLOP) {
    bias = -BAUMGARTE_BETA / dt * (depth - PENETRATION_SLOP);
    if (bias < -MAX_BAUMGARTE_BIAS) bias = -MAX_BAUMGARTE_BIAS;
}
```

**Bullet** (no equivalent absolute bias clamp): Bullet relies on
**split-impulse** to prevent position correction contaminating real
velocity. The bias-cap meep applies is a workaround for not having split
impulse.

**Diagnosis**: deliberate simplification. The cap (3 m/s) is well-motivated
(prevents EPA-degenerate-depth blow-ups from launching bodies) but it does
cap real depth resolution at `cap × dt / β = 3 × 0.016 / 0.2 = 0.24 m/step`
worth of penetration. Past that, the body has to wait for narrowphase to
keep producing a contact across multiple steps. For normal game-physics
inputs this is fine.

**Improvement**: this becomes moot when split-impulse / TGS lands per the
backlog. Until then, the cap is the right choice.

### 3.6 Warm-start factor

**meep** (`solver/solve_contacts.js`): warm-start replays the cached impulse
verbatim:

```js
const j_n  = data[off + 10];
const j_t1 = data[off + 11];
const j_t2 = data[off + 12];
// ... applied as-is
```

**Bullet**: scales by `m_warmstartingFactor = 0.85`:

```cpp
solverConstraint.m_appliedImpulse = cp.m_appliedImpulse * infoGlobal.m_warmstartingFactor;
```

**Diagnosis**: trade-off. Bullet's 0.85 factor is a well-known empirical
choice — it slightly under-shoots so the first iteration of the solver
*adds* impulse rather than potentially overshooting and having to subtract.
meep's 1.0 (full replay) is more aggressive — gives faster steady-state
convergence on stable stacks but can briefly over-impulse on transient
contacts that have changed normal/depth significantly between frames.

**Improvement candidate**: parameterise the factor (default still 1.0, or
move to 0.85 as a softer recommendation). Low-priority; both choices are
defensible. Worth measuring on the existing benchmark scenes.

### 3.7 Tangent basis construction

**meep** (`solver/solve_contacts.js:115-143`):

```js
function build_tangents(out, off, nx, ny, nz) {
    // Pick the world axis least aligned with n.
    const ax = nx < 0 ? -nx : nx;
    // ...
    if (ax <= ay && ax <= az) { rx = 1; ry = 0; rz = 0; }
    else if (ay <= az) { rx = 0; ry = 1; rz = 0; }
    else { rx = 0; ry = 0; rz = 1; }
    // t1 = normalize(n × r)
    // ...
}
```

Recomputed every iter per contact.

**Bullet**: `btPlaneSpace1(n, t1, t2)` — equivalent algorithm, but
**cached on the contact point** between solver iterations (and between
frames). Optional velocity-aligned variant
(`SOLVER_DISABLE_VELOCITY_DEPENDENT_FRICTION_DIRECTION`).

**Diagnosis**: meep recomputes tangents per contact per iteration. The
algorithm itself is identical and correct, but the recompute is wasted
work. At 10 iterations × N contacts per island, the recompute cost is
10× higher than necessary.

**Improvement**: hoist `build_tangents` to the pre-step loop
(`solve_contacts.js` lines 379-465 are already the pre-step), store the
two tangents in the `pre[]` scratch (slots 6-11 already do this — verify
they aren't being overwritten per iter; reading the code, slots 6-11 are
indeed pre-step-only, so the data IS hoisted; the velocity loop reads
`pre[pre_off + 6..11]`). **Actually, this is already correctly factored.**
Withdraw this improvement.

Re-read confirms: the tangents are computed once per contact in the
pre-step pass (line 402) and read from `pre[]` in the iteration loop
(lines 501-502). Good.

### 3.8 Manifold contact persistence

**meep** (`narrowphase/narrowphase_step.js:687-700`): `clear_contacts(slot)`
called every frame, then contacts re-written from this frame's narrowphase.
The accumulated impulses (`j_n`, `j_t1`, `j_t2`) are zeroed by
`clear_contacts` (lines 232-239 in `ManifoldStore.js`).

```js
clear_contacts(slot) {
    const meta_off = slot * SLOT_META_STRIDE;
    this.__meta[meta_off + 2] = this.__meta[meta_off + 2] & ~COUNT_MASK;
    const data_off = slot * SLOT_DATA_STRIDE;
    this.__data.fill(0, data_off, data_off + SLOT_DATA_STRIDE);
}
```

**Bullet** (`btPersistentManifold::addManifoldPoint`): looks up an existing
contact within a threshold of the new one (`getCacheEntry`), and
**preserves the cached impulse** for the matching contact. Adds new
contacts only when no match. Replaces contacts via `replaceContactPoint`
which keeps `m_appliedImpulse`.

**Diagnosis**: **meep is throwing away warm-start data every frame.** Look
at `narrowphase_step.js:687-700`:

```js
manifolds.clear_contacts(slot);                    // ← wipes impulses
if (cand_count === 0) continue;
const kept = reduce_candidates(cand_count);
for (let k = 0; k < kept; k++) {
    // writes the new candidate's positions, normal, depth
    // — but j_n / j_t1 / j_t2 stay at 0 because clear_contacts zeroed them
    manifolds.set_contact(slot, k, ...);
}
```

The `clear_contacts` call zeros the *entire slot data*, including all
twelve `j_*` impulse fields. Then `set_contact` writes only the first ten
floats per contact (positions × 2, normal, depth), so `j_n`, `j_t1`,
`j_t2` are left at the zeroed values.

**This means warm-starting is effectively disabled across frames.** Look at
the pre-step in `solve_contacts.js:468-476`:

```js
const j_n  = data[off + 10];
const j_t1 = data[off + 11];
const j_t2 = data[off + 12];
const Px = nx * j_n + t1x * j_t1 + t2x * j_t2;
// ...
apply_impulse_to_body(rbA, trA, invMA, rax, ray, raz, Px, Py, Pz, +1, scratch_inertia_a);
```

Within a single frame, the iteration loop accumulates impulse correctly
(line 535 updates `data[off + 10]`). But across frames, every `set_contact`
call has wiped the previous frame's `j_n / j_t1 / j_t2`, so the warm-start
on this frame reads zeros, applies a zero impulse delta, and the solver
starts cold every step.

**This is a substantive correctness/performance bug.** Warm-start in a
Box2D-style SI solver typically buys ~30% iteration count reduction at
equivalent stability; without it, 10 iterations may be insufficient for
tall stacks. The 16-cube short-window test PLAN.md references probably
hides this because (a) short tower, (b) atomic-island sleep takes them out
of the solver quickly, but it would explain any "jitter on settle" the
team has seen.

**Improvement** (this is the highest-priority finding in the review):
either

1. Don't call `clear_contacts` — instead, match incoming candidates to
   existing manifold contacts by world-distance (Bullet's
   `getCacheEntry()` approach: keep the contact slot if a new candidate is
   within ~0.02 m), preserving their `j_*`. New contacts get fresh slots.
   This is the proper Bullet-style persistence and would also unlock
   `local_a / local_b` storage for cross-frame refresh.

2. Alternatively, change `clear_contacts` to wipe only positions / normal /
   depth and preserve `j_n / j_t1 / j_t2` (a 10-float overwrite instead of
   13). Combined with re-pairing the new candidates to the *closest old
   contact's impulse* by world-position, this gives warm-start without the
   full Bullet refresh mechanism. Simpler delta.

Either way, the comment in `ManifoldStore.js:266-270` is consistent with
the intent ("`// j_n, j_t1, j_t2 are warm-start; preserved across calls.`")
— the `set_contact` API correctly does NOT touch them. But the upstream
`clear_contacts` zero pass at line 238 wipes them anyway. **This is a bug
that contradicts the documented invariant.**

---

## 4. Simplicity & uniformity

### 4.1 Code reuse and uniform abstractions

meep's code is markedly more uniform than Bullet's at every level:

| Aspect | meep | Bullet |
|---|---|---|
| Shape hierarchy | `AbstractShape3D` flat-ish; subclasses implement `support`, `compute_bounding_box`, `signed_distance`, `is_convex`. ~6 levels deep. | `btCollisionShape` → `btConvexShape` → `btPolyhedralConvexShape` → `btConvexHullShape` / `btBoxShape` / `btTriangleShape` ... + soft-body shapes + compound shapes. 4+ levels with multiple inheritance. |
| Narrowphase dispatch | Linear `if` ladder in `narrowphase_step.js:204-353`, ~10 cases | Virtual `getCollisionAlgorithm(...)` matrix lookup + per-cell dispatch class (`btSphereSphereCollisionAlgorithm`, `btSphereBoxCollisionAlgorithm`, ...) — `btCollisionDispatcher` holds a `m_doubleDispatch[NUM_SHAPE_TYPES][NUM_SHAPE_TYPES]` array of algorithm factories |
| Solver | One file, one function, three nested loops | `btSequentialImpulseConstraintSolver` ~3000 lines + helpers, virtual `btConstraintSolver` base, optional MLCP variant (`btMLCPSolver`) |
| Manifold | One class, ~500 lines, all in one file | `btPersistentManifold` + `btManifoldResult` + `btCollisionWorld` event hooks + ContactProcessedCallback / ContactDestroyedCallback ... |
| Broadphase | Two BVHs + one pair generator | `btBroadphaseInterface` virtual base + `btDbvtBroadphase` + `btSimpleBroadphase` + `btAxisSweep3` + `btMultiSapBroadphase`. With separate pair caches: `btHashedOverlappingPairCache` / `btSortedOverlappingPairCache`. |

**meep wins handily** on simplicity. The classic Bullet pain points:

- **Virtual call per support.** `btConvexShape::localGetSupportingVertex` is
  virtual; every GJK iteration pays a vtable indirection. meep uses
  duck-typed `shape.support(...)` which V8 monomorphises if the shape type
  is hot.

- **Algorithm dispatch matrix.** Bullet looks up
  `m_doubleDispatch[shape_a_type][shape_b_type]`, instantiates an
  `btCollisionAlgorithm` (which may allocate), invokes a virtual method.
  meep's `if (isSphereA && isSphereB) ... else if ...` is straight-line
  and trivially profile-able.

- **AoS + virtual everything**: most C++ engines' style; meep's typed-array
  SoA is JS-native and cache-coherent without trying.

### 4.2 Ease of adding a new shape pair

In **meep**, to add (say) `cylinder ↔ box` closed-form:

1. Add a `Cylinder3D` shape with `support` / `compute_bounding_box`.
2. Write a `cylinder_box_contact.js` closed-form solver.
3. Add the `isCylinderA && isBoxB` (and symmetric) branch to
   `narrowphase_step.js`'s dispatch.

Three files. The dispatch entry is ~20 lines. Total work fits in one
commit.

In **Bullet**, the same task requires:

1. Add a `btCylinderShape` (subclass of `btPolyhedralConvexShape`).
2. Implement `btConvexShape::localGetSupportingVertex` + `getAabb`.
3. Register a `btCylinderBoxCollisionAlgorithm` factory with
   `btCollisionDispatcher::registerCollisionCreateFunc(SHAPE_CYLINDER,
   SHAPE_BOX, ...)`.
4. Implement the algorithm class extending `btCollisionAlgorithm` with
   `processCollision`, `calculateTimeOfImpact`, and `getAllContactManifolds`
   virtual overrides.

Five or six files, two or three classes, registration boilerplate. Bullet
is well-known for this overhead.

### 4.3 Ease of adding a new constraint

**meep**: not yet supported (joints are backlog). The solver loop is
already written to iterate `contacts ∪ joints` in PLAN.md's words; adding
a joint type would mean adding a pre-step builder, an iter applicator,
and a position-correction hook. With the existing `pre[]` scratch design,
this is reasonable.

**Bullet**: requires extending `btTypedConstraint` and implementing
`getInfo1`, `getInfo2`, `solveConstraintObsolete`, plus registering with
the constraint solver. More machinery, more constraints come pre-built
(distance, hinge, generic-6dof, slider, cone-twist, gear...).

For meep's target use case (game physics with simple joints), the simpler
add path is right. For Bullet's audience (robotics, complex articulated
mechanisms), the abstraction earns its weight.

### 4.4 Ease of adding a new query

**meep**: `queries/` is three independent files
(`raycast.js`, `shape_cast.js`, `overlap_shape.js`), each ~100-300 lines.
Adding a new query is "write a new file + add a method to
`PhysicsSystem`". Each query has its own BVH traversal — no shared
infrastructure beyond the BVH's leaf-iterator API. This is good — Bullet
in contrast has a complex `btCollisionWorld` callback interface
(`RayResultCallback`, `ConvexResultCallback`, `ContactResultCallback`)
that callers have to implement to consume results. meep's "fill a buffer,
return a count" idiom is simpler and JS-native.

### 4.5 Where to simplify further

The codebase is generally clean. Specific spots worth flagging:

1. **The `debugger;` statement in EPA** (`expanding_polytope_algorithm.js:135-137`)
   is the single piece of debug-leftover code in the package. Already
   covered above.

2. **`narrowphase_step.js`'s `dispatch_pair`** is a 350-line function with
   ten if/else-if branches. Could be split into `dispatch_convex_convex`,
   `dispatch_concave`, `dispatch_gjk_fallback` private functions — but
   the current shape is readable and JIT-friendly (a single function with
   monomorphic call sites is the inlining sweet-spot in V8). Leaving as-is
   is defensible.

3. **`narrowphase_step.js`'s concave-side quaternion rotation** (lines
   468-501) re-computes the q · v · q⁻¹ rotation inline for face-normal
   and centroid. The helper `world_inverse_inertia_apply` already
   demonstrates the same identity factored cleanly. A `v3_rotate_by_quat`
   helper would make the concave dispatch ~30 lines shorter and easier to
   audit. Low-priority readability win.

4. **Body / solver coupling via `__primary_collider` and friction-on-first-
   collider** (`solver/solve_contacts.js:364`) — known limitation, backlog
   has it. The clean way is per-contact source-collider tracking in the
   manifold's stride. Mentioned in PLAN.md.

### 4.6 Where Bullet is a cautionary tale

- **Narrowphase dispatch matrix**: 14×14 grid of algorithm classes per
  shape-type pair. Adding a new shape multiplies the algorithm count by
  N. meep's linear if-ladder scales as N+1 per new shape — much better.

- **Virtual support()**: every iteration of GJK / EPA / MPR pays a virtual
  call. meep's monomorphic-when-hot dispatch is a real performance
  advantage.

- **The btIDebugDraw class hierarchy + debug-rendering hooks**: Bullet
  threads debug-draw calls through every collision algorithm. meep doesn't
  do this — debug rendering is a separate system that reads the physics
  state, not a callback into it. Right architectural choice.

- **The compound-shape vs. compound-body confusion**: Bullet has both
  `btCompoundShape` (multi-shape single-body) and the option of a
  multi-collider rigid body. meep collapses this into "compound body" with
  N attached colliders — simpler and arguably more game-physics-correct
  (separate transforms on child entities, no nested-shape transform stack).

---

## Headline findings

1. **Warm-start is effectively disabled across frames** because
   `clear_contacts` in `ManifoldStore.js:233-239` wipes `j_n / j_t1 / j_t2`
   along with positions. Documented invariant contradicted by upstream
   wipe. **Highest-priority fix.** (Section 3.8.)

2. **`debugger;` statement** in `expanding_polytope_algorithm.js:136` is a
   bug — leftover debug aid in a hot path. Replace with the same
   closest-face-approximation fallback used at the iteration-cap exit.
   (Section 3.4.)

3. **No GJK separating-axis cache.** Significant per-frame performance gap
   vs Bullet on quiescent contacts. Store the converged direction on the
   manifold slot, seed next frame's GJK with it. (Section 3.1.)

4. **Box-box edge-edge contact uses body-centre midpoint** rather than
   closest-edge-pair. Simple ~10-line improvement using the existing
   `line3_closest_points_segment_segment` helper. (Section 3.2.)

5. **Multi-sided strengths over Bullet**: closed-form fast paths for
   sphere-sphere and sphere-box (Bullet routes both through GJK), better
   friction-cone disk clamp (Bullet defaults to box clamp), atomic-island
   sleep with sleep-group chains (Bullet wakes one body per frame),
   one-sided face-normal rejection on concave dispatch (Bullet doesn't
   sanity-check MTV direction against face normal), deterministic union-
   by-min-index island roots (Bullet's island ids depend on call order).

6. **meep's documented simplifications are mostly well-reasoned**: missing
   split-impulse, missing per-body CCD, GJK on triangle-with-degenerate-
   support — all enumerated in PLAN.md, all blocking specific test cases,
   all with clear architectural paths to resolution.

7. **Code organisation is dramatically simpler than Bullet's** — flat
   shape hierarchy, linear narrowphase dispatch, SoA throughout, no
   virtual-per-support dispatch. The trade-off vs. Bullet's algorithm
   matrix is real and meep is on the right side of it for game physics.
