@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
@@ -0,0 +1,139 @@
1
+ import { STRIDE_BYTES } from './colliderSet.js';
2
+ /**
3
+ * A stable hash of the static geometry a simulation ran against.
4
+ *
5
+ * A recording made of a deterministic simulation is only replayable against the
6
+ * world it was recorded in. When world generation changes — a fixed bug, a
7
+ * retuned curve, a new build — recordings from before it desync, and they do it
8
+ * *silently*: the run is reproduced faithfully against geometry that is no longer
9
+ * there, so what appears on screen is a body moving through empty space with
10
+ * nothing to explain it.
11
+ *
12
+ * Storing this alongside a recording lets a caller compare the world it has
13
+ * against the world the recording was made in, and refuse rather than guess.
14
+ *
15
+ * **Determinism is the whole contract.** The same colliders must give the same
16
+ * string on every machine and in every build, so this reads the raw bytes of the
17
+ * bounds array: a float's decimal rendering is a platform question and its bit
18
+ * pattern is not. Nothing here iterates a `Map` or a `Set`, both of which would
19
+ * make the answer depend on insertion order.
20
+ *
21
+ * **No tolerance, deliberately.** A sub-millimetre difference changes the answer.
22
+ * Whether a given difference *matters* is the caller's judgement, and a hash that
23
+ * quietly rounded would take that judgement away from every caller at once.
24
+ *
25
+ * FNV-1a over 64 bits, carried as two 32-bit halves because JavaScript's bitwise
26
+ * operators are 32-bit and a single accumulator would silently lose the top half.
27
+ * Not a cryptographic hash: this detects change, it does not resist forgery, and
28
+ * a caller that needs the second property needs `crypto.subtle` and an await.
29
+ *
30
+ * **On a set that has been mutated, this answers "the same set with the same history" and not
31
+ * "the same world".** A set can take and drop groups now, and a slot freed by one region is
32
+ * refilled by another, so the same geometry sits at different indices depending on the path
33
+ * driven to reach it.
34
+ *
35
+ * *Replay is not what that costs.* The same inputs drive the same path, which produces the same
36
+ * load order and so the same slots, and a recording replays against the set it was recorded
37
+ * against exactly as it always did. What it costs is comparing two *sessions* that arrived at the
38
+ * same world by different routes.
39
+ *
40
+ * **`fingerprintBodies`'s escape hatch does not transfer**, which is worth saying because the two
41
+ * functions are otherwise the same construction. There, a caller wanting the multiset sorts what
42
+ * it feeds in, because it hands over an array. Here a caller hands over an object whose slot order
43
+ * is a load history they neither chose nor can observe, so there is nothing for them to sort. A
44
+ * canonical variant hashing live slots sorted by their packed bytes would answer the cross-session
45
+ * question and is about fifteen lines; it is not here because nothing is asking it yet.
46
+ */
47
+ export function fingerprintColliders(set) {
48
+ // Two FNV-1a streams, offset-basis and prime per the 32-bit spec.
49
+ let low = 0x811c9dc5;
50
+ let high = 0x811c9dc5;
51
+ /*
52
+ * The count first, so two sets whose float payloads coincide still differ, and
53
+ * four bytes of it rather than one — a set of exactly 256 colliders must not
54
+ * hash as a set of none.
55
+ *
56
+ * The *live* count, which is what `count` has always meant. A set that has dropped a group
57
+ * holds slots it does not use, and hashing those would make the answer depend on how much room
58
+ * happened to be spare.
59
+ */
60
+ const count = set.count >>> 0;
61
+ mix(count & 0xff);
62
+ mix((count >>> 8) & 0xff);
63
+ mix((count >>> 16) & 0xff);
64
+ mix((count >>> 24) & 0xff);
65
+ /*
66
+ * **Live slots only, in index order, and this produces exactly the stream it always did for a
67
+ * set built by the constructor.** Such a set is dense — every slot live, `capacity` equal to
68
+ * `count` — so the walk covers the whole of `data` and skips nothing, which is why the golden
69
+ * strings in the tests beside this file did not move when liveness arrived. Only a set that has
70
+ * grown or dropped a group can differ here, and no recording predates those.
71
+ */
72
+ const bytes = new Uint8Array(set.data.buffer, set.data.byteOffset, set.data.byteLength);
73
+ for (let slot = 0; slot < set.capacity; slot++) {
74
+ if (!set.liveAt(slot))
75
+ continue;
76
+ const from = slot * STRIDE_BYTES;
77
+ for (let i = from; i < from + STRIDE_BYTES; i++)
78
+ mix(bytes[i] ?? 0);
79
+ }
80
+ return hex(high) + hex(low);
81
+ function mix(byte) {
82
+ low ^= byte & 0xff;
83
+ low = Math.imul(low, 0x01000193) >>> 0;
84
+ /*
85
+ * The high half consumes the low half's *current* state rather than the same
86
+ * input byte, so it cannot degenerate into a copy of it. Without this the
87
+ * string would be sixteen characters carrying thirty-two bits of information,
88
+ * which every equality test above would still pass.
89
+ */
90
+ high ^= low & 0xff;
91
+ high = Math.imul(high, 0x01000193) >>> 0;
92
+ }
93
+ }
94
+ function hex(value) {
95
+ return value.toString(16).padStart(8, '0');
96
+ }
97
+ /**
98
+ * A stable hash of every body's state, for comparing one run against another.
99
+ *
100
+ * The same FNV-1a construction and the same reasoning as `fingerprintColliders`: the raw bytes of a
101
+ * float, because its decimal rendering is a platform question and its bit pattern is not. No
102
+ * tolerance, deliberately — whether a difference *matters* is the caller's judgement, and a hash
103
+ * that quietly rounded would take that judgement away from every caller at once.
104
+ *
105
+ * **Order matters here and that is the point.** Two runs of the same scene must agree body for
106
+ * body. A caller comparing scenes *built* in different orders wants the multiset instead, and has
107
+ * to sort what it feeds in — see the permutation gate beside the solver baseline, which does.
108
+ */
109
+ export function fingerprintBodies(bodies) {
110
+ let low = 0x811c9dc5;
111
+ let high = 0x811c9dc5;
112
+ const channels = [
113
+ bodies.posX,
114
+ bodies.posY,
115
+ bodies.posZ,
116
+ bodies.rotX,
117
+ bodies.rotY,
118
+ bodies.rotZ,
119
+ bodies.rotW,
120
+ bodies.velX,
121
+ bodies.velY,
122
+ bodies.velZ,
123
+ bodies.angX,
124
+ bodies.angY,
125
+ bodies.angZ,
126
+ ];
127
+ const scratch = new Float32Array(1);
128
+ const view = new Uint8Array(scratch.buffer);
129
+ for (let i = 0; i < bodies.count; i++) {
130
+ for (const channel of channels) {
131
+ scratch[0] = channel[i] ?? 0;
132
+ for (let b = 0; b < 4; b++) {
133
+ low = Math.imul(low ^ (view[b] ?? 0), 0x01000193) >>> 0;
134
+ high = Math.imul(high ^ (view[3 - b] ?? 0), 0x01000193) >>> 0;
135
+ }
136
+ }
137
+ }
138
+ return `${low.toString(16).padStart(8, '0')}${high.toString(16).padStart(8, '0')}`;
139
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Accelerate along a wish direction, Quake's way.
3
+ *
4
+ * **Six lines, and the most reimplemented six lines in movement code.** Every kinematic character
5
+ * needs them and the trap is specific: the amount added is clamped against *how much of the wish
6
+ * speed the body does not already have along the wish direction*, not against the wish speed
7
+ * itself. Get that wrong and a body accelerates to `wishSpeed` in every direction at once, which
8
+ * is a body that cannot turn; leave the clamp out and it accelerates for ever.
9
+ *
10
+ * **What the projection buys is air-strafing.** `currentSpeed` is the *dot product*, so a body
11
+ * moving fast in one direction still has room to add speed at right angles to it — which is why
12
+ * turning while airborne gains speed in every game descended from Quake, and why it is a verb
13
+ * players learn rather than a bug. A consumer that wants that behaviour gets it; one that does not
14
+ * passes a small `accel` while airborne, which is what the parameter is for.
15
+ *
16
+ * **`accel` is what a consumer decides**, and it is where every game's own answer lives: a surface
17
+ * that grips or slips, a motor that is winded, a slope that foreshortens the thrust. This takes
18
+ * the number and applies it, which is the boundary this whole package draws.
19
+ */
20
+ /** Anything with a planar velocity. Declared structurally so nothing is imported either way. */
21
+ export interface PlanarState {
22
+ velX: number;
23
+ velZ: number;
24
+ }
25
+ /**
26
+ * @param wishX Unit direction the body is asking to travel in.
27
+ * @param wishZ
28
+ * @param wishSpeed How fast it is asking to go, in that direction.
29
+ * @param accel Metres per second squared this tick may deliver.
30
+ * @returns How much speed was actually added, so a consumer can tell a stalled motor from a
31
+ * satisfied one — a demand the ground refuses is exactly the interesting case for anything
32
+ * drawing wheelspin or smoke.
33
+ */
34
+ export declare function accelerateAlong(state: PlanarState, wishX: number, wishZ: number, wishSpeed: number, accel: number, dt: number): number;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Accelerate along a wish direction, Quake's way.
3
+ *
4
+ * **Six lines, and the most reimplemented six lines in movement code.** Every kinematic character
5
+ * needs them and the trap is specific: the amount added is clamped against *how much of the wish
6
+ * speed the body does not already have along the wish direction*, not against the wish speed
7
+ * itself. Get that wrong and a body accelerates to `wishSpeed` in every direction at once, which
8
+ * is a body that cannot turn; leave the clamp out and it accelerates for ever.
9
+ *
10
+ * **What the projection buys is air-strafing.** `currentSpeed` is the *dot product*, so a body
11
+ * moving fast in one direction still has room to add speed at right angles to it — which is why
12
+ * turning while airborne gains speed in every game descended from Quake, and why it is a verb
13
+ * players learn rather than a bug. A consumer that wants that behaviour gets it; one that does not
14
+ * passes a small `accel` while airborne, which is what the parameter is for.
15
+ *
16
+ * **`accel` is what a consumer decides**, and it is where every game's own answer lives: a surface
17
+ * that grips or slips, a motor that is winded, a slope that foreshortens the thrust. This takes
18
+ * the number and applies it, which is the boundary this whole package draws.
19
+ */
20
+ /**
21
+ * @param wishX Unit direction the body is asking to travel in.
22
+ * @param wishZ
23
+ * @param wishSpeed How fast it is asking to go, in that direction.
24
+ * @param accel Metres per second squared this tick may deliver.
25
+ * @returns How much speed was actually added, so a consumer can tell a stalled motor from a
26
+ * satisfied one — a demand the ground refuses is exactly the interesting case for anything
27
+ * drawing wheelspin or smoke.
28
+ */
29
+ export function accelerateAlong(state, wishX, wishZ, wishSpeed, accel, dt) {
30
+ if (wishSpeed < 1e-6)
31
+ return 0;
32
+ /* How much of the wish speed the body already has *along the wish direction*. */
33
+ const currentSpeed = state.velX * wishX + state.velZ * wishZ;
34
+ const addSpeed = wishSpeed - currentSpeed;
35
+ if (addSpeed <= 0)
36
+ return 0;
37
+ const added = Math.min(accel * dt, addSpeed);
38
+ state.velX += wishX * added;
39
+ state.velZ += wishZ * added;
40
+ return added;
41
+ }
@@ -0,0 +1,128 @@
1
+ import type { Aabb } from './collide/index.ts';
2
+ import type { ConvexShape } from './shape.ts';
3
+ /**
4
+ * A heightfield as a collision shape: the samples, and every triangle generated from them.
5
+ *
6
+ * **The whole of this is a memory argument.** Terrain already collides, as a static triangle mesh —
7
+ * `meshShape` takes exactly the arrays a heightfield renderer hands back, so the geometry drawn is
8
+ * the geometry collided with, which is the property that matters most and the reason that path is
9
+ * still the right one for a level. What it costs is the triangles: measured on a 129-square field,
10
+ * the positions and indices alone are more than five times the bytes of the heights they were built
11
+ * from, before the tree over them is counted at all.
12
+ *
13
+ * A heightfield needs none of it. The samples *are* the geometry, the cell under a body is an index
14
+ * rather than a tree query, and every triangle, plane and edge classification is arithmetic on four
15
+ * numbers. So this carries the heights and nothing else.
16
+ *
17
+ * **What it is not is a second narrow phase.** `meshContact.ts` owns the contact rules — many
18
+ * manifolds rather than one, one-sided triangles, the interior-edge filter that stops a box
19
+ * stumbling on a flat seam — and this changes none of them. It is a *source of triangles*, and the
20
+ * only thing it replaces is where they come from: an index range instead of a tree, and four
21
+ * samples instead of an index buffer. That split is the 2026-08-13 rule applied to the one file in
22
+ * this package it would be most expensive to have two of.
23
+ *
24
+ * **The triangulation is stated here and mirrored in `@driftengine/terrain`**, which draws the
25
+ * picture this collides with. It has to be: `@driftengine/physics` imports no other engine package,
26
+ * so the rule cannot be shared as code. What is shared instead is an assertion — that package's
27
+ * `terrainCollision.test.ts` compares the two — which is the same arrangement `glDepthFunc` has with
28
+ * `DEPTH_COMPARE` for the same reason. Track L already paid once for two copies of this rule
29
+ * disagreeing, when a mesh was split one way and its query read the other, so the mirror is guarded
30
+ * rather than trusted.
31
+ */
32
+ export interface Heightfield {
33
+ /** Samples across x. At least two, since one sample is not a cell. */
34
+ readonly width: number;
35
+ /** Samples across z. */
36
+ readonly depth: number;
37
+ /** Metres between samples, the same both ways. */
38
+ readonly spacingM: number;
39
+ /** `width * depth` heights, row-major with x running fastest. Kept, not copied. */
40
+ readonly heights: Float32Array;
41
+ /**
42
+ * World position of sample `(0, 0)`, or nothing for a field at the origin. Its own y is added to
43
+ * every height.
44
+ *
45
+ * **Three numbers in an array rather than three fields, and that is the boundary doing its work.**
46
+ * `@driftengine/physics` imports no other engine package, so it cannot know what a `Terrain` is —
47
+ * but a `Terrain` *is* one of these, structurally: same names, same meanings, an `origin` that is
48
+ * a `Float32Array`. So `heightfieldShape(terrain)` type-checks with no import in either
49
+ * direction, and the seam is a shape rather than a dependency.
50
+ */
51
+ readonly origin?: ArrayLike<number>;
52
+ }
53
+ /**
54
+ * The three corners of a triangle, in the field's own space.
55
+ *
56
+ * **The cell is split along `a`-`c`**, the diagonal from its own sample to the one across it, and
57
+ * the two halves are `a, d, c` and `a, c, b` — wound so the face normal points up. That is the same
58
+ * split `@driftengine/terrain`'s `heightfieldPatch` emits, and it has to be: a collider split the
59
+ * other way disagrees with the picture by up to the height of a cell everywhere but the
60
+ * anti-diagonal, and nothing about that failure is visible.
61
+ *
62
+ * `out` takes nine numbers.
63
+ */
64
+ export declare function fieldCorners(field: Heightfield, triangle: number, out: Float64Array): void;
65
+ /**
66
+ * The triangle's outward plane as `nx, ny, nz, d`, with `d = n · v0`.
67
+ *
68
+ * Normalised, and pointing up: a heightfield has no overhangs, so every face of it faces the sky.
69
+ * A degenerate triangle — two samples at the same place, which a spacing of zero would give and
70
+ * nothing else does — writes a zero normal, which the contact code skips exactly as it does for a
71
+ * degenerate mesh triangle.
72
+ */
73
+ export declare function fieldPlane(field: Heightfield, triangle: number, out: Float64Array): void;
74
+ /**
75
+ * Which of a triangle's three edges a contact normal may come off, as three bits.
76
+ *
77
+ * **Computed rather than precomputed, and that is the saving.** `meshShape` walks every edge of a
78
+ * level once at build time and stores three bits per triangle; a field's answer is four samples and
79
+ * a subtraction, so there is nothing to store. Edge `i` runs from local vertex `i` to `(i + 1) % 3`,
80
+ * matching `TriangleMesh.convexEdges` exactly, because `meshContact.ts` reads both the same way.
81
+ *
82
+ * The rule is `meshShape`'s: an edge is convex where the neighbouring triangle's opposite vertex
83
+ * sits *below* this triangle's plane, and where the edge has no neighbour at all. A coplanar seam is
84
+ * **not** convex, which is the whole of the interior-edge filter — a flat floor made of triangles
85
+ * must not push a body back along the joins.
86
+ *
87
+ * Each of the five edges a cell has meets its neighbour at a single lattice point, and the
88
+ * neighbour exists exactly when that point is inside the field:
89
+ *
90
+ * - half 0: edge 0 at `(cx - 1, cz)`, edge 1 at `(cx + 1, cz + 2)`, edge 2 — the diagonal — at
91
+ * `(cx + 1, cz)`, which is the other half's own third corner.
92
+ * - half 1: edge 0 — the same diagonal — at `(cx, cz + 1)`, edge 1 at `(cx + 2, cz + 1)`, edge 2 at
93
+ * `(cx, cz - 1)`.
94
+ */
95
+ export declare function fieldConvexEdges(field: Heightfield, triangle: number): number;
96
+ /**
97
+ * The triangles a box could touch, written into `out`, as a count.
98
+ *
99
+ * **An index range rather than a tree**, which is the other half of what a heightfield buys: the
100
+ * cells a box covers are `(minX - originX) / spacing` to `(maxX - originX) / spacing`, and the same
101
+ * in z. There is no structure to build, to keep, or to rebuild when the field changes.
102
+ *
103
+ * **And a height test per cell, which is most of the saving in practice.** A body on a hillside
104
+ * covers a wide footprint in x and z and touches almost none of it, the rest being metres above or
105
+ * below — so a cell whose four samples all sit outside the box's own y range is skipped before the
106
+ * narrow phase ever sees it.
107
+ *
108
+ * Fills to `out.length` and stops, exactly as `DynamicTree.query` does, so the caller's grow-and-
109
+ * retry loop works unchanged.
110
+ */
111
+ export declare function fieldCandidates(field: Heightfield, box: Aabb, out: Int32Array): number;
112
+ /**
113
+ * Build a heightfield collision shape.
114
+ *
115
+ * Returns a `ConvexShape` whose `triangles` names the field — so every existing test of "is this a
116
+ * mesh" answers yes and every rule that follows from it holds: refused as a kinematic collider,
117
+ * refused on anything but a static body, and given a static body's own zero mass tensor. Its
118
+ * `vertices` are the eight corners of the field's bounds, so the broad phase measures the right box,
119
+ * and its `faceNormals` and `edgeDirs` are empty for the reason `meshShape` gives: a field has no
120
+ * separating axes worth enumerating, and handing the kinematic sweep the box's three would make a
121
+ * hillside a solid brick.
122
+ *
123
+ * **The heights are kept and not copied**, the same bargain `meshShape` makes with a level's
124
+ * vertices: a field is the thing being avoided allocating twice. A caller must not go on mutating
125
+ * them, which is stated here because nothing enforces it — and unlike a mesh there is nothing to
126
+ * rebuild if they do, so a caller who *wants* a changing field may mutate and the collider follows.
127
+ */
128
+ export declare function heightfieldShape(field: Heightfield): ConvexShape;
@@ -0,0 +1,328 @@
1
+ /** How many cells a field has across x, which is one fewer than its samples. */
2
+ function cellsX(field) {
3
+ return field.width - 1;
4
+ }
5
+ function cellsZ(field) {
6
+ return field.depth - 1;
7
+ }
8
+ /** One sample, by grid index, clamped — a query at the rim reads the rim. */
9
+ function sample(field, ix, iz) {
10
+ const x = ix < 0 ? 0 : ix > field.width - 1 ? field.width - 1 : ix;
11
+ const z = iz < 0 ? 0 : iz > field.depth - 1 ? field.depth - 1 : iz;
12
+ return (field.origin?.[1] ?? 0) + (field.heights[z * field.width + x] ?? 0);
13
+ }
14
+ /** Where sample `(0, 0)` stands, along x and z. */
15
+ function originX(field) {
16
+ return field.origin?.[0] ?? 0;
17
+ }
18
+ function originZ(field) {
19
+ return field.origin?.[2] ?? 0;
20
+ }
21
+ /** Whether a lattice point is inside the field, which is what says an edge has a neighbour. */
22
+ function inside(field, ix, iz) {
23
+ return ix >= 0 && iz >= 0 && ix < field.width && iz < field.depth;
24
+ }
25
+ /**
26
+ * The three corners of a triangle, in the field's own space.
27
+ *
28
+ * **The cell is split along `a`-`c`**, the diagonal from its own sample to the one across it, and
29
+ * the two halves are `a, d, c` and `a, c, b` — wound so the face normal points up. That is the same
30
+ * split `@driftengine/terrain`'s `heightfieldPatch` emits, and it has to be: a collider split the
31
+ * other way disagrees with the picture by up to the height of a cell everywhere but the
32
+ * anti-diagonal, and nothing about that failure is visible.
33
+ *
34
+ * `out` takes nine numbers.
35
+ */
36
+ export function fieldCorners(field, triangle, out) {
37
+ const across = cellsX(field);
38
+ const cell = triangle >> 1;
39
+ const half = triangle & 1;
40
+ const cx = cell % across;
41
+ const cz = (cell - cx) / across;
42
+ const step = field.spacingM;
43
+ const x0 = originX(field) + cx * step;
44
+ const x1 = x0 + step;
45
+ const z0 = originZ(field) + cz * step;
46
+ const z1 = z0 + step;
47
+ if (half === 0) {
48
+ /* a, d, c */
49
+ out[0] = x0;
50
+ out[1] = sample(field, cx, cz);
51
+ out[2] = z0;
52
+ out[3] = x0;
53
+ out[4] = sample(field, cx, cz + 1);
54
+ out[5] = z1;
55
+ out[6] = x1;
56
+ out[7] = sample(field, cx + 1, cz + 1);
57
+ out[8] = z1;
58
+ return;
59
+ }
60
+ /* a, c, b */
61
+ out[0] = x0;
62
+ out[1] = sample(field, cx, cz);
63
+ out[2] = z0;
64
+ out[3] = x1;
65
+ out[4] = sample(field, cx + 1, cz + 1);
66
+ out[5] = z1;
67
+ out[6] = x1;
68
+ out[7] = sample(field, cx + 1, cz);
69
+ out[8] = z0;
70
+ }
71
+ /** Scratch for a plane derived from corners, so nothing here allocates in the tick. */
72
+ const CORNERS = new Float64Array(9);
73
+ /**
74
+ * The triangle's outward plane as `nx, ny, nz, d`, with `d = n · v0`.
75
+ *
76
+ * Normalised, and pointing up: a heightfield has no overhangs, so every face of it faces the sky.
77
+ * A degenerate triangle — two samples at the same place, which a spacing of zero would give and
78
+ * nothing else does — writes a zero normal, which the contact code skips exactly as it does for a
79
+ * degenerate mesh triangle.
80
+ */
81
+ export function fieldPlane(field, triangle, out) {
82
+ fieldCorners(field, triangle, CORNERS);
83
+ const ax = CORNERS[0] ?? 0;
84
+ const ay = CORNERS[1] ?? 0;
85
+ const az = CORNERS[2] ?? 0;
86
+ const ux = (CORNERS[3] ?? 0) - ax;
87
+ const uy = (CORNERS[4] ?? 0) - ay;
88
+ const uz = (CORNERS[5] ?? 0) - az;
89
+ const vx = (CORNERS[6] ?? 0) - ax;
90
+ const vy = (CORNERS[7] ?? 0) - ay;
91
+ const vz = (CORNERS[8] ?? 0) - az;
92
+ let nx = uy * vz - uz * vy;
93
+ let ny = uz * vx - ux * vz;
94
+ let nz = ux * vy - uy * vx;
95
+ const length = Math.sqrt(nx * nx + ny * ny + nz * nz);
96
+ if (length > 1e-12) {
97
+ nx /= length;
98
+ ny /= length;
99
+ nz /= length;
100
+ }
101
+ else {
102
+ nx = 0;
103
+ ny = 0;
104
+ nz = 0;
105
+ }
106
+ out[0] = nx;
107
+ out[1] = ny;
108
+ out[2] = nz;
109
+ out[3] = nx * ax + ny * ay + nz * az;
110
+ }
111
+ /** How far a neighbour's opposite sample must sit below the plane before the fold has a direction. */
112
+ const COPLANAR_M = 1e-5;
113
+ /** Scratch for the classification, which runs per candidate triangle inside the tick. */
114
+ const PLANE = new Float64Array(4);
115
+ /**
116
+ * Which of a triangle's three edges a contact normal may come off, as three bits.
117
+ *
118
+ * **Computed rather than precomputed, and that is the saving.** `meshShape` walks every edge of a
119
+ * level once at build time and stores three bits per triangle; a field's answer is four samples and
120
+ * a subtraction, so there is nothing to store. Edge `i` runs from local vertex `i` to `(i + 1) % 3`,
121
+ * matching `TriangleMesh.convexEdges` exactly, because `meshContact.ts` reads both the same way.
122
+ *
123
+ * The rule is `meshShape`'s: an edge is convex where the neighbouring triangle's opposite vertex
124
+ * sits *below* this triangle's plane, and where the edge has no neighbour at all. A coplanar seam is
125
+ * **not** convex, which is the whole of the interior-edge filter — a flat floor made of triangles
126
+ * must not push a body back along the joins.
127
+ *
128
+ * Each of the five edges a cell has meets its neighbour at a single lattice point, and the
129
+ * neighbour exists exactly when that point is inside the field:
130
+ *
131
+ * - half 0: edge 0 at `(cx - 1, cz)`, edge 1 at `(cx + 1, cz + 2)`, edge 2 — the diagonal — at
132
+ * `(cx + 1, cz)`, which is the other half's own third corner.
133
+ * - half 1: edge 0 — the same diagonal — at `(cx, cz + 1)`, edge 1 at `(cx + 2, cz + 1)`, edge 2 at
134
+ * `(cx, cz - 1)`.
135
+ */
136
+ export function fieldConvexEdges(field, triangle) {
137
+ const across = cellsX(field);
138
+ const cell = triangle >> 1;
139
+ const half = triangle & 1;
140
+ const cx = cell % across;
141
+ const cz = (cell - cx) / across;
142
+ fieldPlane(field, triangle, PLANE);
143
+ let bits = 0b111;
144
+ for (let e = 0; e < 3; e++) {
145
+ let ox = 0;
146
+ let oz = 0;
147
+ if (half === 0) {
148
+ if (e === 0) {
149
+ ox = cx - 1;
150
+ oz = cz;
151
+ }
152
+ else if (e === 1) {
153
+ ox = cx + 1;
154
+ oz = cz + 2;
155
+ }
156
+ else {
157
+ ox = cx + 1;
158
+ oz = cz;
159
+ }
160
+ }
161
+ else if (e === 0) {
162
+ ox = cx;
163
+ oz = cz + 1;
164
+ }
165
+ else if (e === 1) {
166
+ ox = cx + 2;
167
+ oz = cz + 1;
168
+ }
169
+ else {
170
+ ox = cx;
171
+ oz = cz - 1;
172
+ }
173
+ /* No neighbour is a boundary edge, and a boundary edge is convex: there is nothing on the other
174
+ side for a body to be caught between. */
175
+ if (!inside(field, ox, oz))
176
+ continue;
177
+ const px = originX(field) + ox * field.spacingM;
178
+ const py = sample(field, ox, oz);
179
+ const pz = originZ(field) + oz * field.spacingM;
180
+ const height = px * (PLANE[0] ?? 0) + py * (PLANE[1] ?? 0) + pz * (PLANE[2] ?? 0) - (PLANE[3] ?? 0);
181
+ if (!(height < -COPLANAR_M))
182
+ bits &= ~(1 << e);
183
+ }
184
+ return bits;
185
+ }
186
+ /**
187
+ * The triangles a box could touch, written into `out`, as a count.
188
+ *
189
+ * **An index range rather than a tree**, which is the other half of what a heightfield buys: the
190
+ * cells a box covers are `(minX - originX) / spacing` to `(maxX - originX) / spacing`, and the same
191
+ * in z. There is no structure to build, to keep, or to rebuild when the field changes.
192
+ *
193
+ * **And a height test per cell, which is most of the saving in practice.** A body on a hillside
194
+ * covers a wide footprint in x and z and touches almost none of it, the rest being metres above or
195
+ * below — so a cell whose four samples all sit outside the box's own y range is skipped before the
196
+ * narrow phase ever sees it.
197
+ *
198
+ * Fills to `out.length` and stops, exactly as `DynamicTree.query` does, so the caller's grow-and-
199
+ * retry loop works unchanged.
200
+ */
201
+ export function fieldCandidates(field, box, out) {
202
+ const step = field.spacingM;
203
+ const across = cellsX(field);
204
+ const down = cellsZ(field);
205
+ const ox = originX(field);
206
+ const oz = originZ(field);
207
+ const fromX = Math.max(0, Math.floor((box.minX - ox) / step));
208
+ const toX = Math.min(across - 1, Math.floor((box.maxX - ox) / step));
209
+ const fromZ = Math.max(0, Math.floor((box.minZ - oz) / step));
210
+ const toZ = Math.min(down - 1, Math.floor((box.maxZ - oz) / step));
211
+ let count = 0;
212
+ for (let cz = fromZ; cz <= toZ; cz++) {
213
+ for (let cx = fromX; cx <= toX; cx++) {
214
+ const a = sample(field, cx, cz);
215
+ const b = sample(field, cx + 1, cz);
216
+ const c = sample(field, cx + 1, cz + 1);
217
+ const d = sample(field, cx, cz + 1);
218
+ const low = Math.min(a, b, c, d);
219
+ const high = Math.max(a, b, c, d);
220
+ if (high < box.minY || low > box.maxY)
221
+ continue;
222
+ const cell = cz * across + cx;
223
+ if (count === out.length)
224
+ return count;
225
+ out[count++] = cell * 2;
226
+ if (count === out.length)
227
+ return count;
228
+ out[count++] = cell * 2 + 1;
229
+ }
230
+ }
231
+ return count;
232
+ }
233
+ /**
234
+ * Build a heightfield collision shape.
235
+ *
236
+ * Returns a `ConvexShape` whose `triangles` names the field — so every existing test of "is this a
237
+ * mesh" answers yes and every rule that follows from it holds: refused as a kinematic collider,
238
+ * refused on anything but a static body, and given a static body's own zero mass tensor. Its
239
+ * `vertices` are the eight corners of the field's bounds, so the broad phase measures the right box,
240
+ * and its `faceNormals` and `edgeDirs` are empty for the reason `meshShape` gives: a field has no
241
+ * separating axes worth enumerating, and handing the kinematic sweep the box's three would make a
242
+ * hillside a solid brick.
243
+ *
244
+ * **The heights are kept and not copied**, the same bargain `meshShape` makes with a level's
245
+ * vertices: a field is the thing being avoided allocating twice. A caller must not go on mutating
246
+ * them, which is stated here because nothing enforces it — and unlike a mesh there is nothing to
247
+ * rebuild if they do, so a caller who *wants* a changing field may mutate and the collider follows.
248
+ */
249
+ export function heightfieldShape(field) {
250
+ if (!(field.width >= 2)) {
251
+ throw new Error(`heightfieldShape: width must be at least 2, got ${field.width}`);
252
+ }
253
+ if (!(field.depth >= 2)) {
254
+ throw new Error(`heightfieldShape: depth must be at least 2, got ${field.depth}`);
255
+ }
256
+ if (!(field.spacingM > 0)) {
257
+ throw new Error(`heightfieldShape: spacing must be above zero, got ${field.spacingM}`);
258
+ }
259
+ if (field.heights.length !== field.width * field.depth) {
260
+ throw new Error(`heightfieldShape: ${field.width} by ${field.depth} needs ${field.width * field.depth} ` +
261
+ `heights, got ${field.heights.length}`);
262
+ }
263
+ let low = Infinity;
264
+ let high = -Infinity;
265
+ for (let i = 0; i < field.heights.length; i++) {
266
+ const h = field.heights[i] ?? 0;
267
+ if (h < low)
268
+ low = h;
269
+ if (h > high)
270
+ high = h;
271
+ }
272
+ const baseX = originX(field);
273
+ const baseY = field.origin?.[1] ?? 0;
274
+ const baseZ = originZ(field);
275
+ const minX = baseX;
276
+ const maxX = baseX + cellsX(field) * field.spacingM;
277
+ const minY = baseY + low;
278
+ const maxY = baseY + high;
279
+ const minZ = baseZ;
280
+ const maxZ = baseZ + cellsZ(field) * field.spacingM;
281
+ return {
282
+ vertices: new Float32Array([
283
+ minX,
284
+ minY,
285
+ minZ,
286
+ maxX,
287
+ minY,
288
+ minZ,
289
+ minX,
290
+ maxY,
291
+ minZ,
292
+ maxX,
293
+ maxY,
294
+ minZ,
295
+ minX,
296
+ minY,
297
+ maxZ,
298
+ maxX,
299
+ minY,
300
+ maxZ,
301
+ minX,
302
+ maxY,
303
+ maxZ,
304
+ maxX,
305
+ maxY,
306
+ maxZ,
307
+ ]),
308
+ faceNormals: new Float32Array(0),
309
+ edgeDirs: new Float32Array(0),
310
+ radius: 0,
311
+ sideRadius: 0,
312
+ boundRadius: Math.sqrt(Math.max(minX * minX, maxX * maxX) +
313
+ Math.max(minY * minY, maxY * maxY) +
314
+ Math.max(minZ * minZ, maxZ * maxZ)),
315
+ facePlanes: new Float32Array(0),
316
+ faceVertexStart: new Uint16Array(0),
317
+ faceVertexIndices: new Uint16Array(0),
318
+ triangles: {
319
+ positions: new Float32Array(0),
320
+ indices: new Uint32Array(0),
321
+ triangleCount: cellsX(field) * cellsZ(field) * 2,
322
+ planes: new Float32Array(0),
323
+ convexEdges: new Uint8Array(0),
324
+ tree: null,
325
+ field,
326
+ },
327
+ };
328
+ }