@driftengine/physics 3.62.0 → 4.0.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/NOTICE CHANGED
@@ -7,3 +7,23 @@ This product includes software developed at Drift Technologies
7
7
  Licensed under the Apache License, Version 2.0. Section 4(d) of that licence
8
8
  requires this NOTICE to be reproduced in any derivative work you distribute,
9
9
  in the source, the documentation, or a display generated by the work.
10
+
11
+ Models run by DriftCapture
12
+ --------------------------
13
+ DriftCapture runs the models below. Their weights are not in this repository
14
+ and are not distributed with it: tools/capture-weights/ fetches each at the
15
+ revision its manifest pins, verifies it, and converts it, and a game that
16
+ ships one ships it under that model's own licence. Each model's definition in
17
+ this engine is written from its project's published code.
18
+
19
+ - Depth Anything 3 Small and Depth Anything 3 Base. Apache License 2.0.
20
+ Copyright (c) 2025 ByteDance Ltd. and/or its affiliates.
21
+ - Depth Anything V2 Small. Apache License 2.0. The University of Hong Kong
22
+ and TikTok; converted to the Transformers layout by The HuggingFace Team.
23
+ - MobileSAM. Apache License 2.0. Its image encoder is TinyViT, Copyright (c)
24
+ 2022 Microsoft; its prompt encoder and mask decoder are Segment Anything's,
25
+ Copyright (c) Meta Platforms, Inc. and affiliates.
26
+ - SAM 2.1 tiny. Apache License 2.0. Copyright (c) Meta Platforms, Inc. and
27
+ affiliates.
28
+ - OWLv2 base patch16. Apache License 2.0. Copyright 2023 Google AI and The
29
+ HuggingFace Team.
package/README.md CHANGED
@@ -5,7 +5,7 @@ kinematic sweep that resolves a moving body against a static world one axis at a
5
5
 
6
6
  **It imports no other engine package.** Together with `@driftengine/entities` that makes a
7
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
8
+ or on a server, which is what an authoritative host needs. **45.1 KB gzipped** standalone, measured
9
9
  by `scripts/size-gate.test.mjs` against `scripts/fixtures/size/physics-only.ts`.
10
10
 
11
11
  **`@driftengine/core` re-exports every name here**, so reaching for collision through the core
package/dist/query.js CHANGED
@@ -21,6 +21,36 @@ const manifold = createManifold();
21
21
  * The full set is `PhysicsWorld`'s business, where every plane becomes a constraint.
22
22
  */
23
23
  const MESH_ONE = [createManifold()];
24
+ /**
25
+ * Manifolds a sweep against a mesh gathers before it decides how far it may advance.
26
+ *
27
+ * **Which triangle stopped a sweep is not a question one manifold can answer.** `collideMesh` fills
28
+ * its output in the order the tree hands triangles over, and the sweep read one of them — so near a
29
+ * crease, where a wall meets a floor, a downward sweep was answered with the wall's horizontal
30
+ * normal and nothing stopped the fall. Found by the first test of `CharacterController` against a
31
+ * triangle mesh: a capsule on a captured room's floor sank straight through it, while the same
32
+ * floor **on its own** held it, because adding the walls changed which triangle came back first.
33
+ * Sixteen is far more than a sweep meets at once, and the one facing back along the travel is the
34
+ * one in the way.
35
+ */
36
+ const MESH_SET = [
37
+ createManifold(),
38
+ createManifold(),
39
+ createManifold(),
40
+ createManifold(),
41
+ createManifold(),
42
+ createManifold(),
43
+ createManifold(),
44
+ createManifold(),
45
+ createManifold(),
46
+ createManifold(),
47
+ createManifold(),
48
+ createManifold(),
49
+ createManifold(),
50
+ createManifold(),
51
+ createManifold(),
52
+ createManifold(),
53
+ ];
24
54
  function copyManifold(from, to) {
25
55
  to.nx = from.nx;
26
56
  to.ny = from.ny;
@@ -626,12 +656,44 @@ export function shapecastWorld(bodies, tree, shape, pose, dx, dy, dz, out, filte
626
656
  * since a step short of the true one converges rather than missing.
627
657
  */
628
658
  if (isMesh(shape) || isMesh(other)) {
629
- if (collideMesh(shape, poseA, other, poseB, 1e9, MESH_ONE) === 0)
659
+ /*
660
+ * **Asked for within the travel that is left, not within everything.** The margin inflates
661
+ * the tree query's box, so a margin of a billion metres makes every triangle of the mesh a
662
+ * candidate — which is both the whole mesh's cost per step and the reason the set that came
663
+ * back was arbitrary. Nothing further away than the sweep can reach can stop it.
664
+ */
665
+ const reach = travel * (1 - t) + 1e-4;
666
+ const met = collideMesh(shape, poseA, other, poseB, reach, MESH_SET);
667
+ if (met === 0)
630
668
  break;
631
- const first = MESH_ONE[0];
632
- if (first === undefined)
669
+ /*
670
+ * **Two different questions, and one manifold cannot answer both.** How far the sweep may
671
+ * advance is bounded by the *nearest* surface, whichever way it faces. Which surface
672
+ * *stopped* it is the nearest one facing back along the travel — and near a crease, where a
673
+ * wall meets a floor, those are not the same triangle. Answering the first for both is what
674
+ * made a capsule walking at a captured step sink through the floor: the wall beside it was
675
+ * always the nearer contact, so every downward sweep reported a horizontal normal and
676
+ * nothing ever stopped the fall. On box bodies, which have one manifold, the same walk is
677
+ * fine — which is why this only ever showed on a mesh.
678
+ */
679
+ let blocking = -1;
680
+ let into = 1e-9;
681
+ for (let m = 0; m < met; m++) {
682
+ const held = MESH_SET[m];
683
+ if (held === undefined)
684
+ continue;
685
+ if ((held.separations[0] ?? 0) >= 1e-4)
686
+ continue;
687
+ const opposes = dx * held.nx + dy * held.ny + dz * held.nz;
688
+ if (opposes > into) {
689
+ into = opposes;
690
+ blocking = m;
691
+ }
692
+ }
693
+ const chosen = MESH_SET[blocking >= 0 ? blocking : 0];
694
+ if (chosen === undefined)
633
695
  break;
634
- copyManifold(first, manifold);
696
+ copyManifold(chosen, manifold);
635
697
  }
636
698
  else if (!collideShapes(shape, poseA, other, poseB, 1e9, manifold))
637
699
  break;
@@ -107,9 +107,13 @@ export function createIslandPool(request) {
107
107
  * A build that does not throws, which becomes a reason and serial physics — and `spawn` is the way
108
108
  * out, which is why this is a default and not the only path. Null where there is no `Worker` at all.
109
109
  */
110
- const defaultSpawn = typeof Worker === 'undefined'
110
+ const defaultSpawn =
111
+ // platform: feature probe — no `Worker` means serial physics, with a reason
112
+ typeof Worker === 'undefined'
111
113
  ? null
112
- : () => new Worker(new URL('./islandWorker.ts', import.meta.url), { type: 'module' });
114
+ : () =>
115
+ // platform: browser default — `spawn` is the seam a native host supplies
116
+ new Worker(new URL('./islandWorker.js', import.meta.url), { type: 'module' });
113
117
  /**
114
118
  * The pool itself: `StagedExecutor` with the serial solve replaced by a fork and a join.
115
119
  *
@@ -184,12 +188,15 @@ export class WorkerPoolExecutor extends StagedExecutor {
184
188
  join() {
185
189
  const ctl = this.control;
186
190
  const wanted = this.workers.length;
187
- const deadline = Date.now() + JOIN_DEADLINE_MS;
191
+ /* Monotonic, because this is a spin against a deadline and the wall clock moves. A backward
192
+ step means the deadline never arrives and the join spins until the tab is closed — which is
193
+ the exact failure the deadline above was added to prevent. */
194
+ const deadline = performance.now() + JOIN_DEADLINE_MS;
188
195
  for (;;) {
189
196
  const done = Atomics.load(ctl, CTL_DONE);
190
197
  if (done >= wanted)
191
198
  return true;
192
- if (Date.now() > deadline) {
199
+ if (performance.now() > deadline) {
193
200
  this.failure =
194
201
  'a worker stopped answering, so island solving went back to this thread. The world is ' +
195
202
  'correct and serial from here.';
@@ -223,9 +230,9 @@ export class WorkerPoolExecutor extends StagedExecutor {
223
230
  plan: stage.plan,
224
231
  });
225
232
  }
226
- const deadline = Date.now() + JOIN_DEADLINE_MS;
233
+ const deadline = performance.now() + JOIN_DEADLINE_MS;
227
234
  while (Atomics.load(ctl, CTL_ACK) < this.workers.length) {
228
- if (Date.now() > deadline) {
235
+ if (performance.now() > deadline) {
229
236
  this.failure =
230
237
  'the workers did not take the shared buffer, so island solving stayed on this thread.';
231
238
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/physics",
3
- "version": "3.62.0",
3
+ "version": "4.0.0",
4
4
  "description": "Collision shapes, spatial queries and swept kinematic collision",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/query.ts CHANGED
@@ -71,6 +71,37 @@ const manifold = createManifold();
71
71
  */
72
72
  const MESH_ONE = [createManifold()] as const;
73
73
 
74
+ /**
75
+ * Manifolds a sweep against a mesh gathers before it decides how far it may advance.
76
+ *
77
+ * **Which triangle stopped a sweep is not a question one manifold can answer.** `collideMesh` fills
78
+ * its output in the order the tree hands triangles over, and the sweep read one of them — so near a
79
+ * crease, where a wall meets a floor, a downward sweep was answered with the wall's horizontal
80
+ * normal and nothing stopped the fall. Found by the first test of `CharacterController` against a
81
+ * triangle mesh: a capsule on a captured room's floor sank straight through it, while the same
82
+ * floor **on its own** held it, because adding the walls changed which triangle came back first.
83
+ * Sixteen is far more than a sweep meets at once, and the one facing back along the travel is the
84
+ * one in the way.
85
+ */
86
+ const MESH_SET = [
87
+ createManifold(),
88
+ createManifold(),
89
+ createManifold(),
90
+ createManifold(),
91
+ createManifold(),
92
+ createManifold(),
93
+ createManifold(),
94
+ createManifold(),
95
+ createManifold(),
96
+ createManifold(),
97
+ createManifold(),
98
+ createManifold(),
99
+ createManifold(),
100
+ createManifold(),
101
+ createManifold(),
102
+ createManifold(),
103
+ ] as const;
104
+
74
105
  function copyManifold(from: typeof manifold, to: typeof manifold): void {
75
106
  to.nx = from.nx;
76
107
  to.ny = from.ny;
@@ -792,10 +823,40 @@ export function shapecastWorld(
792
823
  * since a step short of the true one converges rather than missing.
793
824
  */
794
825
  if (isMesh(shape) || isMesh(other)) {
795
- if (collideMesh(shape, poseA, other, poseB, 1e9, MESH_ONE) === 0) break;
796
- const first = MESH_ONE[0];
797
- if (first === undefined) break;
798
- copyManifold(first, manifold);
826
+ /*
827
+ * **Asked for within the travel that is left, not within everything.** The margin inflates
828
+ * the tree query's box, so a margin of a billion metres makes every triangle of the mesh a
829
+ * candidate — which is both the whole mesh's cost per step and the reason the set that came
830
+ * back was arbitrary. Nothing further away than the sweep can reach can stop it.
831
+ */
832
+ const reach = travel * (1 - t) + 1e-4;
833
+ const met = collideMesh(shape, poseA, other, poseB, reach, MESH_SET);
834
+ if (met === 0) break;
835
+ /*
836
+ * **Two different questions, and one manifold cannot answer both.** How far the sweep may
837
+ * advance is bounded by the *nearest* surface, whichever way it faces. Which surface
838
+ * *stopped* it is the nearest one facing back along the travel — and near a crease, where a
839
+ * wall meets a floor, those are not the same triangle. Answering the first for both is what
840
+ * made a capsule walking at a captured step sink through the floor: the wall beside it was
841
+ * always the nearer contact, so every downward sweep reported a horizontal normal and
842
+ * nothing ever stopped the fall. On box bodies, which have one manifold, the same walk is
843
+ * fine — which is why this only ever showed on a mesh.
844
+ */
845
+ let blocking = -1;
846
+ let into = 1e-9;
847
+ for (let m = 0; m < met; m++) {
848
+ const held = MESH_SET[m];
849
+ if (held === undefined) continue;
850
+ if ((held.separations[0] ?? 0) >= 1e-4) continue;
851
+ const opposes = dx * held.nx + dy * held.ny + dz * held.nz;
852
+ if (opposes > into) {
853
+ into = opposes;
854
+ blocking = m;
855
+ }
856
+ }
857
+ const chosen = MESH_SET[blocking >= 0 ? blocking : 0];
858
+ if (chosen === undefined) break;
859
+ copyManifold(chosen, manifold);
799
860
  } else if (!collideShapes(shape, poseA, other, poseB, 1e9, manifold)) break;
800
861
  const gap = manifold.separations[0] ?? 0;
801
862
  if (gap < 1e-4) {
package/src/workerPool.ts CHANGED
@@ -141,9 +141,11 @@ export function createIslandPool(request: PoolRequest): PoolOutcome {
141
141
  * out, which is why this is a default and not the only path. Null where there is no `Worker` at all.
142
142
  */
143
143
  const defaultSpawn: (() => PoolWorker) | null =
144
+ // platform: feature probe — no `Worker` means serial physics, with a reason
144
145
  typeof Worker === 'undefined'
145
146
  ? null
146
147
  : (): PoolWorker =>
148
+ // platform: browser default — `spawn` is the seam a native host supplies
147
149
  new Worker(new URL('./islandWorker.ts', import.meta.url), { type: 'module' }) as PoolWorker;
148
150
 
149
151
  /**
@@ -227,11 +229,14 @@ export class WorkerPoolExecutor extends StagedExecutor implements Executor {
227
229
  private join(): boolean {
228
230
  const ctl = this.control;
229
231
  const wanted = this.workers.length;
230
- const deadline = Date.now() + JOIN_DEADLINE_MS;
232
+ /* Monotonic, because this is a spin against a deadline and the wall clock moves. A backward
233
+ step means the deadline never arrives and the join spins until the tab is closed — which is
234
+ the exact failure the deadline above was added to prevent. */
235
+ const deadline = performance.now() + JOIN_DEADLINE_MS;
231
236
  for (;;) {
232
237
  const done = Atomics.load(ctl, CTL_DONE);
233
238
  if (done >= wanted) return true;
234
- if (Date.now() > deadline) {
239
+ if (performance.now() > deadline) {
235
240
  this.failure =
236
241
  'a worker stopped answering, so island solving went back to this thread. The world is ' +
237
242
  'correct and serial from here.';
@@ -267,9 +272,9 @@ export class WorkerPoolExecutor extends StagedExecutor implements Executor {
267
272
  });
268
273
  }
269
274
 
270
- const deadline = Date.now() + JOIN_DEADLINE_MS;
275
+ const deadline = performance.now() + JOIN_DEADLINE_MS;
271
276
  while (Atomics.load(ctl, CTL_ACK) < this.workers.length) {
272
- if (Date.now() > deadline) {
277
+ if (performance.now() > deadline) {
273
278
  this.failure =
274
279
  'the workers did not take the shared buffer, so island solving stayed on this thread.';
275
280
  return false;