@driftengine/physics 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +503 -0
  4. package/dist/bodies.d.ts +121 -0
  5. package/dist/bodies.js +298 -0
  6. package/dist/buoyancy.d.ts +46 -0
  7. package/dist/buoyancy.js +45 -0
  8. package/dist/cloth.d.ts +226 -0
  9. package/dist/cloth.js +587 -0
  10. package/dist/collide/aabb.d.ts +10 -0
  11. package/dist/collide/aabb.js +11 -0
  12. package/dist/collide/body.d.ts +202 -0
  13. package/dist/collide/body.js +142 -0
  14. package/dist/collide/eject.d.ts +39 -0
  15. package/dist/collide/eject.js +142 -0
  16. package/dist/collide/index.d.ts +18 -0
  17. package/dist/collide/index.js +5 -0
  18. package/dist/collide/scratch.d.ts +8 -0
  19. package/dist/collide/scratch.js +9 -0
  20. package/dist/collide/segment.d.ts +12 -0
  21. package/dist/collide/segment.js +109 -0
  22. package/dist/collide/sweep.d.ts +3 -0
  23. package/dist/collide/sweep.js +1036 -0
  24. package/dist/colliderSet.d.ts +239 -0
  25. package/dist/colliderSet.js +546 -0
  26. package/dist/compoundContact.d.ts +70 -0
  27. package/dist/compoundContact.js +221 -0
  28. package/dist/controller.d.ts +351 -0
  29. package/dist/controller.js +641 -0
  30. package/dist/cylinder.d.ts +78 -0
  31. package/dist/cylinder.js +294 -0
  32. package/dist/cylinderContact.d.ts +7 -0
  33. package/dist/cylinderContact.js +704 -0
  34. package/dist/decompose.d.ts +105 -0
  35. package/dist/decompose.js +979 -0
  36. package/dist/events.d.ts +43 -0
  37. package/dist/events.js +95 -0
  38. package/dist/executor.d.ts +147 -0
  39. package/dist/executor.js +173 -0
  40. package/dist/faces.d.ts +26 -0
  41. package/dist/faces.js +34 -0
  42. package/dist/fingerprint.d.ts +61 -0
  43. package/dist/fingerprint.js +139 -0
  44. package/dist/groundMotor.d.ts +34 -0
  45. package/dist/groundMotor.js +41 -0
  46. package/dist/heightfieldShape.d.ts +128 -0
  47. package/dist/heightfieldShape.js +328 -0
  48. package/dist/index.d.ts +73 -0
  49. package/dist/index.js +47 -0
  50. package/dist/island.d.ts +55 -0
  51. package/dist/island.js +227 -0
  52. package/dist/islandSolve.d.ts +70 -0
  53. package/dist/islandSolve.js +171 -0
  54. package/dist/islandWorker.d.ts +18 -0
  55. package/dist/islandWorker.js +125 -0
  56. package/dist/joints.d.ts +180 -0
  57. package/dist/joints.js +1094 -0
  58. package/dist/manifold.d.ts +89 -0
  59. package/dist/manifold.js +608 -0
  60. package/dist/mass.d.ts +53 -0
  61. package/dist/mass.js +353 -0
  62. package/dist/meshContact.d.ts +57 -0
  63. package/dist/meshContact.js +393 -0
  64. package/dist/meshShape.d.ts +76 -0
  65. package/dist/meshShape.js +175 -0
  66. package/dist/pairs.d.ts +62 -0
  67. package/dist/pairs.js +154 -0
  68. package/dist/query.d.ts +71 -0
  69. package/dist/query.js +683 -0
  70. package/dist/ragdoll.d.ts +135 -0
  71. package/dist/ragdoll.js +489 -0
  72. package/dist/shape.d.ts +177 -0
  73. package/dist/shape.js +533 -0
  74. package/dist/solver.d.ts +136 -0
  75. package/dist/solver.js +601 -0
  76. package/dist/stage.d.ts +167 -0
  77. package/dist/stage.js +350 -0
  78. package/dist/stallEscape.d.ts +141 -0
  79. package/dist/stallEscape.js +254 -0
  80. package/dist/sweptStep.d.ts +173 -0
  81. package/dist/sweptStep.js +472 -0
  82. package/dist/tree.d.ts +61 -0
  83. package/dist/tree.js +348 -0
  84. package/dist/vehicle.d.ts +154 -0
  85. package/dist/vehicle.js +343 -0
  86. package/dist/workerPool.d.ts +107 -0
  87. package/dist/workerPool.js +239 -0
  88. package/dist/workers.d.ts +37 -0
  89. package/dist/workers.js +35 -0
  90. package/dist/world.d.ts +298 -0
  91. package/dist/world.js +712 -0
  92. package/package.json +55 -0
  93. package/src/bodies.ts +369 -0
  94. package/src/buoyancy.ts +78 -0
  95. package/src/cloth.ts +734 -0
  96. package/src/collide/aabb.ts +28 -0
  97. package/src/collide/body.ts +336 -0
  98. package/src/collide/eject.ts +156 -0
  99. package/src/collide/index.ts +26 -0
  100. package/src/collide/scratch.ts +10 -0
  101. package/src/collide/segment.ts +127 -0
  102. package/src/collide/sweep.ts +1132 -0
  103. package/src/colliderSet.ts +637 -0
  104. package/src/compoundContact.ts +284 -0
  105. package/src/controller.ts +817 -0
  106. package/src/cylinder.ts +340 -0
  107. package/src/cylinderContact.ts +825 -0
  108. package/src/decompose.ts +1198 -0
  109. package/src/events.ts +108 -0
  110. package/src/executor.ts +224 -0
  111. package/src/faces.ts +37 -0
  112. package/src/fingerprint.ts +146 -0
  113. package/src/groundMotor.ts +53 -0
  114. package/src/heightfieldShape.ts +395 -0
  115. package/src/index.ts +164 -0
  116. package/src/island.ts +219 -0
  117. package/src/islandSolve.ts +218 -0
  118. package/src/islandWorker.ts +141 -0
  119. package/src/joints.ts +1367 -0
  120. package/src/manifold.ts +798 -0
  121. package/src/mass.ts +424 -0
  122. package/src/meshContact.ts +443 -0
  123. package/src/meshShape.ts +261 -0
  124. package/src/pairs.ts +169 -0
  125. package/src/query.ts +844 -0
  126. package/src/ragdoll.ts +662 -0
  127. package/src/shape.ts +656 -0
  128. package/src/solver.ts +873 -0
  129. package/src/stage.ts +406 -0
  130. package/src/stallEscape.ts +362 -0
  131. package/src/sweptStep.ts +609 -0
  132. package/src/tree.ts +366 -0
  133. package/src/vehicle.ts +489 -0
  134. package/src/workerPool.ts +283 -0
  135. package/src/workers.ts +38 -0
  136. package/src/world.ts +961 -0
package/README.md ADDED
@@ -0,0 +1,503 @@
1
+ # @driftengine/physics
2
+
3
+ Convex collision shapes, the spatial hash over them, ray and segment queries, and the swept
4
+ kinematic sweep that resolves a moving body against a static world one axis at a time.
5
+
6
+ **It imports no other engine package.** Together with `@driftengine/entities` that makes a
7
+ deterministic simulation you can run with no renderer in its module graph — in Node, in a worker,
8
+ or on a server, which is what an authoritative host needs. **45.0 KB gzipped** standalone, measured
9
+ by `scripts/size-gate.test.mjs` against `scripts/fixtures/size/physics-only.ts`.
10
+
11
+ **`@driftengine/core` re-exports every name here**, so reaching for collision through the core
12
+ barrel works exactly as it did before these files lived in a package of their own. Core _depends on_
13
+ this package rather than the other way round, which is the opposite direction from every other
14
+ optional package and is not a preference: the cinematic and third-person cameras, the contact probe
15
+ and the ribbon builder all need the sweep.
16
+
17
+ ```ts
18
+ import {
19
+ AXIS_X,
20
+ AXIS_Y,
21
+ ColliderSet,
22
+ boxCollider,
23
+ hullShape,
24
+ moveAxis,
25
+ } from '@driftengine/physics';
26
+ import type { Body } from '@driftengine/physics';
27
+
28
+ const world = new ColliderSet([boxCollider(0, -0.5, 0, 20, 0.5, 20)]);
29
+ const body: Body = { x: 0, y: 2, z: 0, hx: 0.35, hy: 0.9, hz: 0.35 };
30
+
31
+ moveAxis(body, world, AXIS_X, 0.05); // returns how far it actually travelled
32
+ moveAxis(body, world, AXIS_Y, -0.02);
33
+ ```
34
+
35
+ ## Rigid bodies
36
+
37
+ ```ts
38
+ import { BODY_DYNAMIC, BODY_STATIC, PhysicsWorld, boxShape } from '@driftengine/physics';
39
+
40
+ const world = new PhysicsWorld({ substeps: 4 });
41
+ world.addBody({ type: BODY_STATIC, shape: boxShape(50, 1, 50), y: -1, friction: 0.8 });
42
+ const crate = world.addBody({ type: BODY_DYNAMIC, shape: boxShape(0.5, 0.5, 0.5), y: 4 });
43
+
44
+ world.step(1 / 60); // fixed clock, always
45
+ world.bodies.posY[crate];
46
+ ```
47
+
48
+ Constraints are **soft**: each carries a stiffness in hertz and a damping ratio rather than a
49
+ fraction of penetration per step. That is what makes `substeps` a quality dial — raise it and a
50
+ stack rests at the same height and a ball bounces to the same height, so the option buys accuracy
51
+ without changing behaviour.
52
+
53
+ Contacts persist across ticks by **feature id**, never by proximity within a tolerance, which is
54
+ what makes warm starting correct rather than order-dependent.
55
+
56
+ Islands are connected components over **dynamic bodies only**, so two towers standing on one floor
57
+ are two islands rather than one. Sleeping is island-gated and counted in ticks. An executor seam
58
+ runs the islands, and a gate asserts a serial runner and a deliberately reordered one produce
59
+ identical state — no two islands share a body, so order cannot matter, and that is asserted rather
60
+ than argued.
61
+
62
+ ## Joints, queries and sensors
63
+
64
+ ```ts
65
+ import { JOINT_REVOLUTE, createRayHit } from '@driftengine/physics';
66
+
67
+ world.addJoint({
68
+ type: JOINT_REVOLUTE,
69
+ bodyA: post,
70
+ bodyB: door,
71
+ anchorAX: 0.1,
72
+ anchorBX: -0.8,
73
+ axisY: 1,
74
+ lower: -0.5,
75
+ upper: 0.5,
76
+ motorSpeed: 2,
77
+ motorMaxForce: 400,
78
+ });
79
+
80
+ const hit = createRayHit();
81
+ if (world.raycast(0, 2, 0, 0, -1, 0, 50, hit, { ignore: player })) hit.body;
82
+ ```
83
+
84
+ Seven types — distance, spherical, revolute, prismatic, fixed, cone-twist and a configurable
85
+ six-DOF — with motors, **one-sided** limits and a breaking threshold. `addJoint` captures the
86
+ relative pose the bodies are in now, so a rig can be posed and then jointed without anything
87
+ snapping on the first tick. No angle appears anywhere in the solve: limits compare cosines and
88
+ quaternion components, which is what a clamp wants and what the determinism gate permits.
89
+
90
+ ```ts
91
+ world.addJoint({
92
+ type: JOINT_SIX_DOF,
93
+ bodyA: hips,
94
+ bodyB: thigh,
95
+ /* linear x, y, z then angular x, y, z, in the hips' own frame */
96
+ dof: [DOF_LOCKED, DOF_LOCKED, DOF_LOCKED, DOF_LIMITED, DOF_FREE, DOF_LOCKED],
97
+ dofLower: [0, 0, 0, -Math.sin(Math.PI / 8), 0, 0],
98
+ dofUpper: [0, 0, 0, Math.sin(Math.PI / 8), 0, 0],
99
+ });
100
+ ```
101
+
102
+ Three things to know before reaching for it. **Nothing said is everything locked**, so a joint you
103
+ forgot to describe is rigid rather than one whose bodies fly apart. **Angular bounds are sines of
104
+ half angles**, the same unit `swingCos` and `twistSin` use, because no angle appears in this solver
105
+ — a quarter turn is `Math.sin(Math.PI / 4)`. And **an all-locked six-DOF joint is softer than
106
+ `JOINT_FIXED`**: it solves six independent scalar rows where the fixed joint solves two coupled 3x3
107
+ blocks, so use the fixed one when you want all six. It has no motor, and says so on the console
108
+ rather than accepting one and never driving.
109
+
110
+ **A cylinder is a shape here**, and the number that put it there is worth knowing
111
+ because it is what a prism costs you. `cylinderShape(radius, halfHeight)` stands along y and turns
112
+ with its body, so a wheel is a cylinder with a quarter turn on it. The workaround it replaces was an
113
+ n-gon prism through `hullShape`: a hull takes at most 64 points, a prism spends two a side, so 32
114
+ sides is the wall, and a 32-sided wheel of radius 1 rolling at 6 m/s **bobs 16 mm** with no sides
115
+ left to add. The same wheel as a cylinder bobs **under 2 mm**, which is inside the solver's own
116
+ 5 mm slop. `prismRoll.test.ts` measures both, side by side.
117
+
118
+ The difference is a second kind of rounding rather than a second kind of shape: `sideRadius` grows a
119
+ shape by a _disc_ perpendicular to the segment through its two points, where `radius` grows it by a
120
+ ball. Two flat caps, a curved side, a sharp rim between them. Everything reads both — contacts,
121
+ inertia, bounds, rays and cloth — and a cylinder's support across its axis is exactly its radius at
122
+ every angle, which is the property a prism cannot have and the reason a wheel stops bobbing.
123
+
124
+ **Friction is a box by default and an ellipse if you ask for one.** Each tangent row is clamped to
125
+ `mu * N` on its own, so a slide along the diagonal between the two axes meets `sqrt(2)` times the
126
+ grip it meets along an axis — measured, 0.737 m of travel against 1.064 m from the same 4 m/s.
127
+ `frictionModel: 'elliptical'` couples the rows and the distance is 1.06 m whatever the heading,
128
+ which is what friction physically is. It is not the default: every stacking result moves under it,
129
+ and a golden fingerprint or a stored replay is a thing consumers keep.
130
+
131
+ Queries fill caller-owned buffers and allocate nothing. A ray starting inside a body reports that
132
+ body at fraction zero, because a line-of-sight test that says a wall it started inside is not there
133
+ is worse than useless.
134
+
135
+ A **sensor** reports overlap and resolves none of it. Events drain into `world.events` after the
136
+ tick, ordered by pair key, rather than firing callbacks mid-solve — a callback that removes a body
137
+ during the solve invalidates every index the solver is holding.
138
+
139
+ ## Characters and ragdolls
140
+
141
+ ```ts
142
+ import { CharacterController, ragdollFromBones } from '@driftengine/physics';
143
+
144
+ const player = new CharacterController({ coyoteTicks: 6, jumpBufferTicks: 6 });
145
+ player.teleport(0, 2, 0);
146
+ player.move(world, 1 / 60, { moveX: 4, moveZ: 0, jump: held });
147
+
148
+ // A skeleton already has both of these; nothing is imported in either direction.
149
+ const doll = ragdollFromBones(
150
+ world,
151
+ skeleton.joints.map((j) => j.parent),
152
+ skeleton.world,
153
+ );
154
+ doll.drive(world, animatedPose, 0.6); // a hit reaction, not a body gone limp
155
+ doll.writePose(currentPose);
156
+ ```
157
+
158
+ The controller is **kinematic**, not a dynamic body: a dynamic character falls over and turns every
159
+ question about movement into a question about torque. Its feel parameters live on it — acceleration,
160
+ air control, variable jump height, slide boost — and **coyote time and jump buffering are counted in
161
+ ticks**, never accumulated in a float of seconds, so a replay matches after an hour of uptime.
162
+
163
+ The ragdoll takes a parent array and a flat array of joint world matrices, and writes back through a
164
+ structural target that an animation `Pose` satisfies. Neither package names the other. **`sync` puts
165
+ every body back on its bones and clears its velocities**, which is what a hit reaction needs and
166
+ what nothing had: a ragdoll is built once, so between reactions its bodies sit where the last one
167
+ left them while the character keeps moving — and blending toward _that_ snaps the character to a
168
+ stale shape somewhere else, carrying the speed of however long the bodies had been falling.
169
+
170
+ ## When the geometry has stopped cooperating
171
+
172
+ ```ts
173
+ import { ESCAPE_SEALED, StallEscape, applyBuoyancy, createBuoyancy } from '@driftengine/physics';
174
+
175
+ const escape = new StallEscape({ stalledTicks: 30, budgetM: 4 });
176
+ // Last in the tick, after the move has resolved: it reads where the body ended up.
177
+ const out = escape.step(body, colliders, { trying, refused, grounded, supported }, veto);
178
+ if (out === ESCAPE_SEALED) respawn(); // your decision, not the engine's
179
+
180
+ const sea = createBuoyancy({ level: 0, drag: 3, sinkSpeed: -1.2 });
181
+ const submerged = applyBuoyancy(sea, body.y, velocity, 1 / 60);
182
+ ```
183
+
184
+ **A controller resolves a move against geometry; it has no opinion about a body that has been asking
185
+ to move for half a second and going nowhere.** That gap is where players get stuck, and the rules
186
+ that close it only show up in a shipped game:
187
+
188
+ - **Progress is net travel over a window**, never this tick's displacement — a body jammed under a
189
+ low ceiling _jitters_ by thirty times any per-tick threshold while sitting at the same
190
+ coordinates for as long as the key is held.
191
+ - **Grounded, supported and headroom are three separate facts.** A body with all three can jump out
192
+ and turn and walk away, and is not stuck however blocked it is — being blocked is what a wall is
193
+ _for_. Without that gate this arms on somebody merely leaning on scenery and carries them sideways
194
+ with the controls doing nothing.
195
+ - **A direction the world would refuse is not an escape route.** `EscapeVeto` is taken structurally,
196
+ the same way `GroundProbe` is on the controller: a deck edge carrying no collider at all reports
197
+ clear air to a probe that asks only the colliders.
198
+ - **Sealed is reported, never acted on.** The engine says there is nowhere to put the body; what
199
+ that costs a player belongs to you. Pressing into a wall is allowed to achieve nothing, and is not
200
+ allowed to be fatal.
201
+
202
+ `applyBuoyancy` is the other half of a body meeting the world: drag on **every** axis, because
203
+ killing horizontal momentum is what makes falling in a setback rather than a shortcut, and a
204
+ terminal sink speed so a drop settles instead of accelerating into the seabed. `accelerateAlong` is
205
+ Quake's accelerate with its trap named — the amount added is clamped against the shortfall _along
206
+ the wish direction_, which is also what buys air-strafing.
207
+
208
+ ## Vehicles and cloth
209
+
210
+ ```ts
211
+ import { ClothBody, Vehicle, makeClothGrid } from '@driftengine/physics';
212
+
213
+ const car = new Vehicle(chassis, { wheels: [...], lateral: myTyreCurve });
214
+ car.update(world, 1 / 60, { throttle: 1, brake: 0, steer });
215
+
216
+ const grid = makeClothGrid(20, 20, 0.1);
217
+ const cape = new ClothBody(grid.positions, grid.links, grid.bendLinks);
218
+ cape.step(world, 1 / 60); // after world.step
219
+ ```
220
+
221
+ The vehicle is a **raycast** vehicle: wheels are rays, not bodies. A raycast wheel cannot wedge in
222
+ geometry or collide with another wheel, and in exchange it is stable at ordinary substep counts,
223
+ which rigid wheels on joints are not. Grip comes from a **table you supply** rather than a magic
224
+ formula, because the tick may not use `sin` or `atan` — a curve is edited by moving a point.
225
+
226
+ **Both the controller and the vehicle take a `ground` probe**, so a world whose floor is analytic
227
+ has one floor rather than one per thing that stands on it. Anything answering "how high is the
228
+ ground at this column, and which way does it face" satisfies it, and `@driftengine/core` has three
229
+ that do: `RibbonSurface` for a route, `BoxSurface` for pads, and `heightSurface(heightAt)` around a
230
+ function of your own. A wheel consults it only where its ray found no body, and the character does
231
+ the same: a body is a thing that is there, and a surface is a description of where the ground is.
232
+
233
+ **A road can pass under a road**, and this is worth saying plainly because it reads like something a
234
+ height field cannot do. `heightSurface` takes a **list** of fields as well as one:
235
+
236
+ ```ts
237
+ const ground = heightSurface([
238
+ (x, z) => terrainAt(x, z), // the carriageway, everywhere
239
+ (x, z) => deckAt(x, z), // the flyover, NaN off the structure
240
+ ]);
241
+ ```
242
+
243
+ **A layer answers `NaN` where it is not there**, so a deck is a floor exactly over its span and
244
+ nothing at all beside it. Which floor a query gets is decided by the height the asker is at, and
245
+ every caller already passes it — the character controller, the vehicle, the third-person camera and
246
+ the rain field all do. A caller with no position, a generator placing a lamp, gets the highest,
247
+ which is what it wants.
248
+
249
+ Order in the list carries no meaning: the rule is nearness to the asker, with a tie broken toward
250
+ the floor below, so a body on the lower road stays on it directly beneath a span.
251
+
252
+ **And a collider can be the ground, so a deck can be a slab instead of a height.** A collider set
253
+ was solid to walk _into_ and not to stand _on_: the sweep resolves a body against it laterally, and
254
+ the character controller senses ground through bodies and through a `GroundSurface`, which a
255
+ collider set was neither. Measured: a character dropped over a `ColliderSet` slab whose lid is at
256
+ 5.5 m fell to −84 m in three seconds, which is what it does with no slab at all.
257
+
258
+ ```ts
259
+ import { colliderSurface } from '@driftengine/core';
260
+
261
+ const ground = heightSurface([...]); // or any other surface
262
+ const decks = colliderSurface(set, { minY: -20, maxY: 60 });
263
+ const world = new CompositeSurface([ground, decks]);
264
+ ```
265
+
266
+ `minY` and `maxY` are the vertical extent the column search walks and they are **required**: the
267
+ spatial hash visits every cell in that column, so an unbounded one is half a million cells and a
268
+ frozen frame. You know how tall your world is; the engine does not.
269
+
270
+ **A sloped hull holds you up at the slope, not at its bounding box.** The lid is solved against the
271
+ solid's own face planes, so a ramp supports a body along its face and hands back a normal that tilts
272
+ with it — treating the broad-phase box as the answer would stand a body on thin air over the low
273
+ half of every ramp.
274
+
275
+ **Drawing cloth needs `updateMesh` in `@driftengine/core`**, which is what a deforming surface has
276
+ instead of a model matrix — a mesh is otherwise immutable, and recreating one per frame is a GPU
277
+ buffer allocation per frame. Create it with `{ dynamic: true }` and hand it positions and normals
278
+ each frame.
279
+
280
+ Cloth is XPBD. Bending resists **bowing** rather than orientation, so it will not hold a cantilever
281
+ out — that is a property of a skip-one distance constraint, and the alternative is noted where the
282
+ links are built.
283
+
284
+ **A sheet pushes what it lands on, if you ask it to.** `coupling` is 0 to 1 and 0 is the default; at
285
+ anything above, a particle pushed out of a body hands the body back the momentum it lost, at the
286
+ contact point, so a sheet landing on one end of a plank tips it. `particleMass` is what that
287
+ momentum is measured in, in kilograms, and is deliberately not `invMass` — that array is a relative
288
+ weight the compliance is tuned against. The exchange is explicit, so the body answers on the next
289
+ tick: a very heavy sheet on a very light body rings, and turning `coupling` down is the answer.
290
+ Merging cloth particles into the rigid solver's islands would converge in one solve and is still
291
+ refused, because merged islands are what the parallel executor is made of.
292
+
293
+ **A sheet stops passing through itself, if you ask it to.** `selfDistance` is in metres and 0 is the
294
+ default, because its tuning is the half that decides whether it looks right: a little under the grid
295
+ spacing is the honest starting point. Particles a link joins are exempt — a stretch link's rest
296
+ length _is_ the spacing, so anything else would have every neighbour fighting its own link and the
297
+ sheet would inflate rather than drape.
298
+
299
+ ## A level is a mesh, and everything in it is a hull
300
+
301
+ ```ts
302
+ import { meshShape } from '@driftengine/physics';
303
+
304
+ world.addBody({ type: BODY_STATIC, shape: meshShape(positions, indices) });
305
+ ```
306
+
307
+ `meshShape` takes triangles and builds their planes, an edge classification and a tree over them.
308
+ **Static only, and it is enforced**: `addBody` refuses one on a moving body and `colliderFromShape`
309
+ refuses one outright. A moving concave thing wants convex decomposition into hulls, which `decomposeConvex` produces and `BodyDesc.shapes` accepts; before 2026-09-03 that sentence named something the dynamics could not do, which the
310
+ runtime already accepts.
311
+
312
+ Every triangle goes through the same narrow phase everything else does, so a box, a sphere, a
313
+ capsule, a cylinder and a hull are all exact against a mesh with no second implementation of any of
314
+ them. A mesh pair produces **several** contact planes rather than one, because a body in a corner
315
+ has two or three and reducing them to one picks a wall and lets the body through the others.
316
+
317
+ **Contacts are one-sided and rays are two-sided.** A level mesh is a surface: a two-sided contact
318
+ fires anything that has sunk below it back out the wrong way, while a sight line that saw through a
319
+ wall from behind is a worse answer than one that did not.
320
+
321
+ **Interior edges are filtered**, which is the part that decides whether a mesh floor feels like a
322
+ floor. Without it a box sliding across two coplanar triangles catches on their seam — a character
323
+ stumbling on a flat floor once a metre. Measured: a box slid at 6 m/s across sixteen quads keeps
324
+ **80% of its speed with the filter and 1% without it**.
325
+
326
+ ## A world whose colliders stream
327
+
328
+ ```ts
329
+ import { ColliderSet } from '@driftengine/physics';
330
+
331
+ const world = new ColliderSet([]); // empty, because a streamer builds by group
332
+ const groups = new Map<number, number>(); // region -> handle
333
+
334
+ function crossInto(region: number): void {
335
+ for (const r of ring(region)) {
336
+ if (!groups.has(r)) groups.set(r, world.add(collidersFor(r)));
337
+ }
338
+ for (const [r, group] of [...groups]) {
339
+ if (inRing(r, region)) continue;
340
+ world.remove(group); // the grid is updated for this group and no other
341
+ groups.delete(r);
342
+ }
343
+ }
344
+ ```
345
+
346
+ **One set follows the player, and an index is an index.** That is the whole point of the shape:
347
+ with a set per region, a query result means nothing without the set that answered it, so every
348
+ consumer of a hit carries a pair where it once carry a number.
349
+
350
+ Measured by `npm run check:streaming`, driving a body across region boundaries at five frame rates:
351
+ **a crossing costs 0.090 ms** where rebuilding a set of the same size costs about 41 ms, and
352
+ **queries on the streamed set run at 1.25 to 1.31 times** a freshly built one's. The alternative of
353
+ keeping one set per region and asking the nine around the player measured a **7.5x** multiplier on
354
+ every collision query in the game.
355
+
356
+ `world.bytes()` answers what the resident ring costs, which is the question that decides a region
357
+ size: `slots` and `shapes` are exact, and `index` carries an estimate of per-object overhead a
358
+ JavaScript engine does not expose. `world.absorb(other)` folds one set into another by copying its
359
+ packed bounds and offsetting its buckets, with nothing rehashed.
360
+
361
+ **Three things worth knowing before you build on it.**
362
+
363
+ `count` is the live count and `capacity` is the slot count. They are equal until the first
364
+ `remove`; after that the live colliders are sparse, so a walk over `data` runs to `capacity` and
365
+ skips `!liveAt(i)`. Nothing that never removes has to change.
366
+
367
+ **A set built by the constructor cannot be streamed.** Those colliders belong to a reserved group
368
+ `remove` refuses, so build empty and put everything in through `add`.
369
+
370
+ **`fingerprintColliders` answers a narrower question on a set that has been mutated**: "the same set
371
+ with the same history", not "the same world". Replay is unaffected — the same inputs drive the same
372
+ path and so the same slots — but two sessions that reached the same world by different routes will
373
+ not agree.
374
+
375
+ ## Two kinds of rounding, and what they can and cannot be
376
+
377
+ A `ConvexShape` is **a point cloud plus two kinds of rounding**, with its separating features
378
+ enumerated once at build time. A box is its eight corners; a sphere is one point and its `radius`; a
379
+ capsule is two points and its `radius`; a cylinder is two points and its `sideRadius`. The face
380
+ normals and edge directions are the separating axes SAT tests, and a fixed, data-driven axis list
381
+ per shape pair is what lets two machines replay the same run bit for bit.
382
+
383
+ **A cylinder is the one shape a single radius cannot express**, which is why there are two: a
384
+ uniform radius grows around every feature and would round the rim along with the side, and the rim
385
+ is what a wheel rolls on. `sideRadius` grows the shape by a disc perpendicular to its axis instead.
386
+ Its curved side has no separating axis to enumerate, so a pair involving one builds its axis list
387
+ against the _other_ shape — `cylinderContact.ts` — and answers every axis exactly from the support
388
+ function. That costs about 5 KB gzipped and it is most of what the section header once called the
389
+ one representation.
390
+
391
+ `hullShape` is O(n⁴) in the point count and caps at 64 points, deliberately. Shapes come from the
392
+ few points that _define_ an element, never from a render mesh.
393
+
394
+ ## Islands on other threads, if your site can pay for it
395
+
396
+ No two islands share a body, so solving them in any order or all at once gives the same bits. The
397
+ `Executor` seam has been in this package since islands landed and `ShuffledExecutor` has been
398
+ asserting that property since. What kept a pool out was never the engine.
399
+
400
+ ```ts
401
+ import { PhysicsWorld } from '@driftengine/physics';
402
+ import { createIslandPool } from '@driftengine/physics/src/workers.ts';
403
+
404
+ const world = new PhysicsWorld({ workers: 4, pool: createIslandPool });
405
+ world.parallelism; // { requested: 4, running: 4, reason: '' }
406
+ world.dispose(); // terminates them; a world with a pool must be disposed
407
+ ```
408
+
409
+ **`pool` is passed in, and it lives behind its own specifier, and both are measurements.** A world
410
+ that reached the pool on its own put 2,629 bytes gzipped into every bundle, including everyone who
411
+ never asks for a worker. That was half of it. The other half cost 4.7 times as much: a bundler
412
+ rewrites `new Worker(new URL(...))` at transform time, before tree-shaking decides anything is
413
+ unreachable, so a barrel that merely re-exported the factory still wrote **12,259 gzipped bytes** of
414
+ worker to disk in builds where nothing could fetch it. Reported by one consumer and reproduced on a
415
+ second the same day.
416
+
417
+ So the factory is at `@driftengine/physics/src/workers.ts`. A caller already had to name it, so the
418
+ cost is the specifier and nothing else, and importing `PhysicsWorld` now names no worker at all.
419
+
420
+ **Read `parallelism` instead of assuming the option took.** Every path that does not start a pool
421
+ sets `reason` to a whole sentence: the page is not cross-origin isolated, the bundler could not build
422
+ the worker, no pool was passed. `running: 0` with correct serial physics is this design working, and
423
+ a silent fallback is the one outcome it refuses.
424
+
425
+ ### The headers, which are the actual price
426
+
427
+ `SharedArrayBuffer` does not exist on a page that is not cross-origin isolated. Two header pairs get
428
+ you there, and they cost very different things:
429
+
430
+ | | |
431
+ | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
432
+ | `Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp` | Every cross-origin resource that does not send CORP stops loading. Fonts, CDN images, embedded video, analytics. |
433
+ | `Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: credentialless` | Cross-origin resources still load, sent without credentials. |
434
+
435
+ **Both were measured in Chrome 151, not remembered**: `npm run check:pool` grants isolation under
436
+ either and runs the pool identically. `credentialless` is the one to reach for first. Safari has not
437
+ been tested here, and this sentence is the whole of what is known about it.
438
+
439
+ The headers go on the document. In Vite, `server.headers`; on Cloudflare Pages, a `_headers` file;
440
+ in nginx, `add_header`. `demo/dev/vite.isolated.config.ts` is a working example of the first.
441
+
442
+ ### What it is worth, and the two bounds it is held between
443
+
444
+ Measured on a 24-core desktop, 256 islands of ten boxes, by `npm run bench:islands`:
445
+
446
+ | workers | 1 | 2 | 4 | 8 |
447
+ | ------- | ----- | ----- | ----- | ----- |
448
+ | speedup | 1.05x | 1.22x | 1.26x | 1.39x |
449
+
450
+ **Neither bound is a promise and both belong to your scene.** Amdahl's is `1 / (1 - solve share)`,
451
+ and the bench prints that share. The other is `total island cost / largest island cost`: the tick
452
+ cannot end before its slowest island does, so a hundred jointed boxes are one island and gain
453
+ nothing at any worker count. The figures above are well under the first bound, and the staging copy
454
+ accounts for 1.7% of the gap and no more; what accounts for the rest has not been measured, so this
455
+ does not guess.
456
+
457
+ ### When your bundler cannot build the worker
458
+
459
+ The default is `new Worker(new URL('./islandWorker.ts', import.meta.url), { type: 'module' })`, which
460
+ Vite, webpack 5 and Parcel resolve. If yours does not, `parallelism.reason` says so and you pass your
461
+ own:
462
+
463
+ ```ts
464
+ // islandWorker.ts, in your app
465
+ import '@driftengine/physics/src/islandWorker.ts';
466
+ ```
467
+
468
+ ```ts
469
+ new PhysicsWorld({
470
+ workers: 4,
471
+ pool: createIslandPool,
472
+ spawn: () => new Worker(new URL('./islandWorker.ts', import.meta.url), { type: 'module' }),
473
+ });
474
+ ```
475
+
476
+ ### What makes any of this safe
477
+
478
+ `DisjointExecutor` snapshots the body lanes before each island and fails when two islands write one
479
+ of them. It needs no threads and no timing, so it cannot be flaky in either direction, and it is the
480
+ one test in this package that would notice the day the disjointness assumption stopped holding.
481
+ Every write inside the solve is guarded by `invMass > 0` or walks `bodyOrder`, which holds only
482
+ dynamic bodies, so a static floor under twenty towers is read by all of them and written by none.
483
+
484
+ ## Determinism is a construction here, not an aspiration
485
+
486
+ Everything on the path that feeds a tick uses `+ - * /` and `Math.sqrt` and nothing else. Those are
487
+ what ECMAScript specifies as IEEE-754 operations, and IEEE-754 requires `sqrt` correctly rounded.
488
+ The functions ECMAScript declines to specify — `hypot`, `pow`, `sin`, `atan2` and the rest — are
489
+ banned, and `scripts/determinism.test.mjs` fails on any of them.
490
+
491
+ That gate exists because the claim came first and was false. `hullShape`'s header promised the same
492
+ features in the same order on every machine while ten `Math.hypot` calls sat under it, two of them
493
+ deciding whether a plane becomes a face and how many separating axes exist. Nothing in the suite
494
+ could see it.
495
+
496
+ `fingerprintColliders` hashes the geometry a recording was made against, so a consumer can refuse a
497
+ replay rather than run it against a world that has since changed.
498
+
499
+ ## Documentation
500
+
501
+ This file covers the dynamics landing on top of the collision core: the solver, joints,
502
+ queries, a character controller, ragdolls, vehicles and cloth — and what each of them
503
+ refuses, with what would reverse it.
@@ -0,0 +1,121 @@
1
+ import type { ConvexShape } from './shape.ts';
2
+ /**
3
+ * Rigid body state, structure-of-arrays, indexed by a dense body index.
4
+ *
5
+ * **`Float32Array` for state and doubles for every intermediate.** A store rounds through
6
+ * `Math.fround`, which ECMAScript specifies exactly, while the arithmetic between stores stays in
7
+ * doubles — so the solver computes at higher precision than it keeps and halves its memory
8
+ * bandwidth. Both halves of that are deliberate, and JavaScript has no SIMD to spend the bandwidth
9
+ * on instead.
10
+ *
11
+ * **Indices are dense and removal swaps the last body in**, so a caller holding an index must be
12
+ * told when one moves. `remove` returns the index that moved, or −1, and every structure keyed by
13
+ * body index has to act on it.
14
+ */
15
+ export declare const BODY_STATIC = 0;
16
+ export declare const BODY_KINEMATIC = 1;
17
+ export declare const BODY_DYNAMIC = 2;
18
+ /** Static never moves, kinematic moves but is not pushed, dynamic is solved. */
19
+ export type BodyType = 0 | 1 | 2;
20
+ /** Set on a body whose island is asleep. Woken bodies clear it together. */
21
+ export declare const BODY_SLEEPING = 1;
22
+ /**
23
+ * A sensor reports overlap and resolves none of it.
24
+ *
25
+ * Narrowphase still runs — an overlap that is never computed cannot be reported — and the solver is
26
+ * what skips it. **What this costs** is a sensor being as dear as a solid body in the broadphase and
27
+ * narrowphase, which is where most of the cost is anyway.
28
+ */
29
+ export declare const BODY_SENSOR = 2;
30
+ export interface BodyDesc {
31
+ readonly type: BodyType;
32
+ /**
33
+ * The body's shape, or absent where it is made of several. Exactly one of these two.
34
+ *
35
+ * A body carrying both is refused at `addBody`, because there is no sensible reading of it: the
36
+ * narrow phase would have to decide which one it meant and either choice is a body that collides
37
+ * as something the caller did not describe.
38
+ */
39
+ readonly shape?: ConvexShape;
40
+ /**
41
+ * Several shapes, as one rigid body. What `decomposeConvex` produces, and where it goes.
42
+ *
43
+ * **No offset and no rotation per part**, because a `ConvexShape` is already a point cloud in the
44
+ * body's frame — see `vertices` above. A decomposition's parts are where they belong the moment
45
+ * they exist, and a field that would always be zero is a field somebody eventually sets wrong. The
46
+ * kinematic `Body.parts` carries offsets because it wraps shapes built at the origin and placed
47
+ * afterwards; nothing here does that.
48
+ *
49
+ * Mass is `combineMassProperties`: volumes added, the centre weighted, each tensor moved to the
50
+ * body's centre by the parallel axis theorem. Contacts are one manifold per touching part, with
51
+ * the part pair folded into every feature id so warm starting cannot confuse two of them.
52
+ *
53
+ * Refused above `MAX_BODY_PARTS`, at the call rather than in the tick.
54
+ */
55
+ readonly shapes?: readonly ConvexShape[];
56
+ readonly density?: number;
57
+ readonly x?: number;
58
+ readonly y?: number;
59
+ readonly z?: number;
60
+ /** Unit quaternion. Defaults to identity. */
61
+ readonly qx?: number;
62
+ readonly qy?: number;
63
+ readonly qz?: number;
64
+ readonly qw?: number;
65
+ readonly friction?: number;
66
+ readonly restitution?: number;
67
+ /** Which layers this body is on, and which it layersInteract with. 32 bits each. */
68
+ readonly layer?: number;
69
+ readonly mask?: number;
70
+ /** A sensor reports overlap and is never pushed out of it. */
71
+ readonly sensor?: boolean;
72
+ }
73
+ export declare class BodySet {
74
+ count: number;
75
+ posX: Float32Array;
76
+ posY: Float32Array;
77
+ posZ: Float32Array;
78
+ rotX: Float32Array;
79
+ rotY: Float32Array;
80
+ rotZ: Float32Array;
81
+ rotW: Float32Array;
82
+ velX: Float32Array;
83
+ velY: Float32Array;
84
+ velZ: Float32Array;
85
+ angX: Float32Array;
86
+ angY: Float32Array;
87
+ angZ: Float32Array;
88
+ invMass: Float32Array;
89
+ /** Body-frame inverse inertia, six symmetric terms per body: xx, yy, zz, xy, xz, yz. */
90
+ invInertia: Float32Array;
91
+ /** Centre of mass in body frame, which a shape built off-centre has. */
92
+ comX: Float32Array;
93
+ comY: Float32Array;
94
+ comZ: Float32Array;
95
+ friction: Float32Array;
96
+ restitution: Float32Array;
97
+ type: Uint8Array;
98
+ flags: Uint8Array;
99
+ sleepTicks: Uint16Array;
100
+ layer: Uint32Array;
101
+ mask: Uint32Array;
102
+ shape: (ConvexShape | undefined)[];
103
+ /** The parts of a compound body, or undefined where it has a single `shape`. */
104
+ shapes: (readonly ConvexShape[] | undefined)[];
105
+ private capacity;
106
+ constructor(capacity?: number);
107
+ add(desc: BodyDesc): number;
108
+ /** Swap-remove. Returns the index that moved into `index`, or −1 if it was the last. */
109
+ remove(index: number): number;
110
+ /**
111
+ * Fill `out` with the world-frame inverse inertia, six symmetric terms.
112
+ *
113
+ * `R · I⁻¹ · Rᵀ`, a congruence, rather than a diagonalisation. Storing three principal moments
114
+ * would need an eigen decomposition, and Jacobi is iterative — a fixed sweep count would be
115
+ * deterministic but is one more thing to get exactly right for no gain. **What this gives up** is
116
+ * six floats per body instead of three and a congruence per substep. **What would make it wrong**
117
+ * is a measurement showing that congruence dominates the tick.
118
+ */
119
+ worldInverseInertia(index: number, out: Float32Array, at?: number): void;
120
+ private grow;
121
+ }