@woosh/meep-engine 2.144.0 → 2.146.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 (60) hide show
  1. package/package.json +1 -1
  2. package/src/core/bvh2/bvh3/BVH.d.ts.map +1 -1
  3. package/src/core/bvh2/bvh3/BVH.js +158 -4
  4. package/src/core/geom/3d/shape/CylinderShape3D.d.ts +56 -0
  5. package/src/core/geom/3d/shape/CylinderShape3D.d.ts.map +1 -0
  6. package/src/core/geom/3d/shape/CylinderShape3D.js +223 -0
  7. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts +33 -3
  8. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -1
  9. package/src/core/geom/3d/shape/HeightMapShape3D.js +486 -451
  10. package/src/core/geom/3d/shape/json/shape_to_type.d.ts.map +1 -1
  11. package/src/core/geom/3d/shape/json/shape_to_type.js +3 -0
  12. package/src/core/geom/3d/shape/json/type_adapters.d.ts +15 -0
  13. package/src/core/geom/3d/shape/json/type_adapters.d.ts.map +1 -1
  14. package/src/core/geom/3d/shape/json/type_adapters.js +16 -0
  15. package/src/engine/control/first-person/DESIGN_COLLISION.md +365 -302
  16. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts +1 -3
  17. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts.map +1 -1
  18. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts +12 -2
  19. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts.map +1 -1
  20. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js +7 -2
  21. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts +13 -0
  22. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts.map +1 -1
  23. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js +16 -2
  24. package/src/engine/control/first-person/TODO.md +13 -11
  25. package/src/engine/control/first-person/abilities/WallJump.d.ts.map +1 -1
  26. package/src/engine/control/first-person/abilities/WallJump.js +11 -3
  27. package/src/engine/control/first-person/abilities/WallRun.d.ts.map +1 -1
  28. package/src/engine/control/first-person/abilities/WallRun.js +30 -35
  29. package/src/engine/control/first-person/collision/KinematicMover.d.ts +35 -5
  30. package/src/engine/control/first-person/collision/KinematicMover.d.ts.map +1 -1
  31. package/src/engine/control/first-person/collision/KinematicMover.js +634 -424
  32. package/src/engine/control/first-person/prototype_first_person_controller.js +1003 -901
  33. package/src/engine/physics/PLAN.md +943 -767
  34. package/src/engine/physics/body/BodyStorage.d.ts +9 -0
  35. package/src/engine/physics/body/BodyStorage.d.ts.map +1 -1
  36. package/src/engine/physics/body/BodyStorage.js +23 -0
  37. package/src/engine/physics/broadphase/generate_pairs.d.ts.map +1 -1
  38. package/src/engine/physics/broadphase/generate_pairs.js +7 -0
  39. package/src/engine/physics/ccd/linear_sweep.d.ts +97 -0
  40. package/src/engine/physics/ccd/linear_sweep.d.ts.map +1 -0
  41. package/src/engine/physics/ccd/linear_sweep.js +238 -0
  42. package/src/engine/physics/ecs/PhysicsSystem.d.ts +18 -3
  43. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  44. package/src/engine/physics/ecs/PhysicsSystem.js +59 -8
  45. package/src/engine/physics/ecs/RigidBodyFlags.d.ts +6 -0
  46. package/src/engine/physics/ecs/RigidBodyFlags.d.ts.map +1 -1
  47. package/src/engine/physics/ecs/RigidBodyFlags.js +6 -0
  48. package/src/engine/physics/narrowphase/box_triangle_contact.js +811 -811
  49. package/src/engine/physics/narrowphase/compute_penetration.d.ts.map +1 -1
  50. package/src/engine/physics/narrowphase/compute_penetration.js +325 -323
  51. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts +27 -8
  52. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts.map +1 -1
  53. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.js +235 -204
  54. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  55. package/src/engine/physics/narrowphase/narrowphase_step.js +70 -13
  56. package/src/engine/physics/queries/overlap_shape.d.ts.map +1 -1
  57. package/src/engine/physics/queries/overlap_shape.js +185 -183
  58. package/src/engine/simulation/Ticker.d.ts +14 -0
  59. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  60. package/src/engine/simulation/Ticker.js +136 -1
@@ -1,451 +1,486 @@
1
- import { assert } from "../../../assert.js";
2
- import { clamp } from "../../../math/clamp.js";
3
- import { v3_length } from "../../vec3/v3_length.js";
4
- import { Vector3 } from "../../Vector3.js";
5
- import { AbstractShape3D } from "./AbstractShape3D.js";
6
-
7
- /**
8
- * Heightmap shape, intended primarily for terrain.
9
- *
10
- * The shape is a closed solid bounded below by the plane perpendicular to
11
- * {@link orientation} (the "floor") and above by a height-field surface
12
- * defined by a {@link Sampler2D}. Heights are sampled with Catmull-Rom
13
- * filtering ({@link Sampler2D#sampleChannelCatmullRomUV}), matching what
14
- * the terrain system uses for geometry construction.
15
- *
16
- * Local frame layout:
17
- * - The orientation vector defines the local "up" axis (unit).
18
- * - An orthonormal basis (u, v, n=orientation) is built from it.
19
- * - Footprint extends along the basis-u axis over [-size.x/2, +size.x/2]
20
- * and along the basis-v axis over [-size.z/2, +size.z/2].
21
- * - The surface height (along orientation) at heightmap-UV (u01, v01) is
22
- * `sampler.sampleChannelCatmullRomUV(u01, v01, 0)`.
23
- * - The solid volume occupies `h [0, sampledHeight(u01, v01)]` along the
24
- * orientation axis, with `0` being the floor at body-local origin.
25
- * - `size.y` is the maximum height value of the heightfield (used for the
26
- * bounding box). Sampler values are NOT clamped to it.
27
- *
28
- * NON-CONVEX. {@link support} throws GJK/EPA cannot be run against a
29
- * heightmap directly. The physics narrowphase must dispatch a dedicated
30
- * grid-traversal path when one of the colliders is a heightmap.
31
- *
32
- * @author Alex Goldring
33
- * @copyright Company Named Limited (c) 2026
34
- */
35
- export class HeightMapShape3D extends AbstractShape3D {
36
- constructor() {
37
- super();
38
-
39
- /**
40
- * Unit vector defining the local "up" axis (the direction the
41
- * heightmap's surface faces). Default is +Y.
42
- * @readonly
43
- * @type {Vector3}
44
- */
45
- this.orientation = new Vector3(0, 1, 0);
46
-
47
- /**
48
- * Bounding-box extents in the heightmap-local (u, height, v) frame.
49
- * size.x footprint extent along basis-u (perpendicular to orientation)
50
- * size.y maximum heightmap height (extent along orientation)
51
- * size.z — footprint extent along basis-v (perpendicular to orientation)
52
- * @readonly
53
- * @type {Vector3}
54
- */
55
- this.size = new Vector3(1, 1, 1);
56
-
57
- /**
58
- * Sampler holding height values. Float32 backing recommended so the
59
- * Terrain system's height texture plugs in directly. Single-channel
60
- * sampler is the common case; only channel 0 is read.
61
- * @type {Sampler2D | null}
62
- */
63
- this.sampler = null;
64
-
65
- /**
66
- * Cached orthonormal basis [u_x,u_y,u_z, v_x,v_y,v_z, n_x,n_y,n_z]
67
- * built from {@link orientation}. Updated lazily by {@link _ensure_basis}.
68
- * @private
69
- * @type {Float64Array}
70
- */
71
- this._basis = new Float64Array(9);
72
-
73
- // last-seen orientation components, used to detect dirty basis
74
- this._basis_orientation_x = NaN;
75
- this._basis_orientation_y = NaN;
76
- this._basis_orientation_z = NaN;
77
- }
78
-
79
- /**
80
- * Convenience constructor.
81
- * @param {Sampler2D} sampler
82
- * @param {number} size_x footprint extent along basis-u
83
- * @param {number} size_y maximum heightmap height (along orientation)
84
- * @param {number} size_z footprint extent along basis-v
85
- * @param {Vector3} [orientation] defaults to +Y
86
- * @returns {HeightMapShape3D}
87
- */
88
- static from(sampler, size_x, size_y, size_z, orientation) {
89
- assert.isNumber(size_x, "size_x");
90
- assert.isNumber(size_y, "size_y");
91
- assert.isNumber(size_z, "size_z");
92
- assert.greaterThanOrEqual(size_x, 0, "size_x");
93
- assert.greaterThanOrEqual(size_y, 0, "size_y");
94
- assert.greaterThanOrEqual(size_z, 0, "size_z");
95
-
96
- const r = new HeightMapShape3D();
97
- r.sampler = sampler;
98
- r.size.set(size_x, size_y, size_z);
99
-
100
- if (orientation !== undefined) {
101
- r.orientation.set(orientation.x, orientation.y, orientation.z);
102
- }
103
-
104
- return r;
105
- }
106
-
107
- /**
108
- * Recompute the orthonormal basis (u, v, n) if {@link orientation} changed.
109
- *
110
- * Construction chosen so that the default orientation +Y produces the
111
- * intuitive mapping `u = +X, v = +Z, n = +Y` — i.e. size.x runs along
112
- * body X, size.z along body Z. We project body +X onto the plane
113
- * perpendicular to n (the orientation), with a fall-back to projecting
114
- * body +Z when n is too close to colinear with +X.
115
- * @private
116
- */
117
- _ensure_basis() {
118
- const nx = this.orientation[0];
119
- const ny = this.orientation[1];
120
- const nz = this.orientation[2];
121
-
122
- if (
123
- nx === this._basis_orientation_x
124
- && ny === this._basis_orientation_y
125
- && nz === this._basis_orientation_z
126
- ) {
127
- return;
128
- }
129
-
130
- // tangent u = normalize(project body +X onto plane perpendicular to n)
131
- // fall back to body +Z if n is too colinear with +X
132
- let u_x, u_y, u_z;
133
-
134
- if (Math.abs(nx) < 0.9) {
135
- // u = (+X) - (n . +X) * n = (1 - nx*nx, -nx*ny, -nx*nz)
136
- u_x = 1 - nx * nx;
137
- u_y = -nx * ny;
138
- u_z = -nx * nz;
139
- } else {
140
- // u = (+Z) - (n . +Z) * n = (-nz*nx, -nz*ny, 1 - nz*nz)
141
- u_x = -nz * nx;
142
- u_y = -nz * ny;
143
- u_z = 1 - nz * nz;
144
- }
145
-
146
- const u_inv = 1 / v3_length(u_x, u_y, u_z);
147
- u_x *= u_inv;
148
- u_y *= u_inv;
149
- u_z *= u_inv;
150
-
151
- // v = u × n (for n=+Y, u=+X this gives v=+Z, the intuitive choice)
152
- const v_x = u_y * nz - u_z * ny;
153
- const v_y = u_z * nx - u_x * nz;
154
- const v_z = u_x * ny - u_y * nx;
155
-
156
- const b = this._basis;
157
- b[0] = u_x; b[1] = u_y; b[2] = u_z;
158
- b[3] = v_x; b[4] = v_y; b[5] = v_z;
159
- b[6] = nx; b[7] = ny; b[8] = nz;
160
-
161
- this._basis_orientation_x = nx;
162
- this._basis_orientation_y = ny;
163
- this._basis_orientation_z = nz;
164
- }
165
-
166
- /**
167
- * Sample the surface height at heightmap UV coordinates.
168
- * Uses Catmull-Rom filtering to match the terrain system's geometry construction.
169
- * @param {number} u01 horizontal UV, in [0, 1]
170
- * @param {number} v01 vertical UV, in [0, 1]
171
- * @returns {number} height along orientation axis
172
- */
173
- sample_height_at_uv(u01, v01) {
174
- return this.sampler.sampleChannelCatmullRomUV(u01, v01, 0);
175
- }
176
-
177
- /**
178
- * Project a body-local position to the surface and sample the height there.
179
- * The returned height is in the orientation-axis direction.
180
- * Positions outside the footprint sample the clamped UV (sampler clamps).
181
- * @param {number} px body-local x
182
- * @param {number} py body-local y
183
- * @param {number} pz body-local z
184
- * @returns {number}
185
- */
186
- sample_height_at_position(px, py, pz) {
187
- this._ensure_basis();
188
-
189
- const b = this._basis;
190
-
191
- // project onto basis u and basis v
192
- const u_coord = b[0] * px + b[1] * py + b[2] * pz;
193
- const v_coord = b[3] * px + b[4] * py + b[5] * pz;
194
-
195
- const u01 = u_coord / this.size[0] + 0.5;
196
- const v01 = v_coord / this.size[2] + 0.5;
197
-
198
- return this.sample_height_at_uv(u01, v01);
199
- }
200
-
201
- compute_bounding_box(result) {
202
- this._ensure_basis();
203
-
204
- const b = this._basis;
205
-
206
- const sx = this.size[0];
207
- const sy = this.size[1];
208
- const sz = this.size[2];
209
-
210
- const half_u = sx * 0.5;
211
- const half_v = sz * 0.5;
212
-
213
- // For each body axis k (0=x, 1=y, 2=z):
214
- // body[k] = b[0+k]*u + b[3+k]*v + b[6+k]*h
215
- //
216
- // The (u,v) footprint contribution is symmetric (u ∈ [-half_u, +half_u], v ∈ [-half_v, +half_v])
217
- // The height contribution is asymmetric (h ∈ [0, sy])
218
- for (let k = 0; k < 3; k++) {
219
- const cu = b[k];
220
- const cv = b[3 + k];
221
- const ch = b[6 + k];
222
-
223
- const uv_extent = Math.abs(cu) * half_u + Math.abs(cv) * half_v;
224
-
225
- const h_lo = ch < 0 ? ch * sy : 0;
226
- const h_hi = ch > 0 ? ch * sy : 0;
227
-
228
- result[k] = -uv_extent + h_lo;
229
- result[k + 3] = uv_extent + h_hi;
230
- }
231
- }
232
-
233
- contains_point(point) {
234
- const px = point[0];
235
- const py = point[1];
236
- const pz = point[2];
237
-
238
- this._ensure_basis();
239
-
240
- const b = this._basis;
241
-
242
- // project into heightmap-local frame (u, v, h)
243
- const u_coord = b[0] * px + b[1] * py + b[2] * pz;
244
- const v_coord = b[3] * px + b[4] * py + b[5] * pz;
245
- const h_coord = b[6] * px + b[7] * py + b[8] * pz;
246
-
247
- const half_u = this.size[0] * 0.5;
248
- const half_v = this.size[2] * 0.5;
249
-
250
- if (u_coord <= -half_u || u_coord >= half_u) return false;
251
- if (v_coord <= -half_v || v_coord >= half_v) return false;
252
- if (h_coord <= 0 || h_coord >= this.size[1]) return false;
253
-
254
- const u01 = u_coord / this.size[0] + 0.5;
255
- const v01 = v_coord / this.size[2] + 0.5;
256
-
257
- const surface_h = this.sample_height_at_uv(u01, v01);
258
-
259
- return h_coord < surface_h;
260
- }
261
-
262
- /**
263
- * Approximate signed distance: difference between the point's height
264
- * (along orientation) and the surface height sampled at the point's
265
- * footprint UV. POSITIVE = above surface, NEGATIVE = below.
266
- *
267
- * Locally correct when the surface is flat; biased when the surface
268
- * has significant slope. Sufficient for cling/raycast queries that
269
- * walk down to the surface.
270
- */
271
- signed_distance_at_point(point) {
272
- const px = point[0];
273
- const py = point[1];
274
- const pz = point[2];
275
-
276
- this._ensure_basis();
277
-
278
- const b = this._basis;
279
-
280
- const u_coord = b[0] * px + b[1] * py + b[2] * pz;
281
- const v_coord = b[3] * px + b[4] * py + b[5] * pz;
282
- const h_coord = b[6] * px + b[7] * py + b[8] * pz;
283
-
284
- const u01 = u_coord / this.size[0] + 0.5;
285
- const v01 = v_coord / this.size[2] + 0.5;
286
-
287
- const surface_h = this.sample_height_at_uv(u01, v01);
288
-
289
- return h_coord - surface_h;
290
- }
291
-
292
- /**
293
- * Project a reference point onto the surface along the orientation axis.
294
- * The footprint UV is clamped, so points outside the footprint produce
295
- * the nearest edge-of-footprint surface sample (approximate).
296
- */
297
- nearest_point_on_surface(result, reference) {
298
- const rx = reference[0];
299
- const ry = reference[1];
300
- const rz = reference[2];
301
-
302
- this._ensure_basis();
303
-
304
- const b = this._basis;
305
-
306
- const u_coord = b[0] * rx + b[1] * ry + b[2] * rz;
307
- const v_coord = b[3] * rx + b[4] * ry + b[5] * rz;
308
-
309
- const half_u = this.size[0] * 0.5;
310
- const half_v = this.size[2] * 0.5;
311
-
312
- const u_clamped = clamp(u_coord, -half_u, half_u);
313
- const v_clamped = clamp(v_coord, -half_v, half_v);
314
-
315
- const u01 = u_clamped / this.size[0] + 0.5;
316
- const v01 = v_clamped / this.size[2] + 0.5;
317
-
318
- const surface_h = this.sample_height_at_uv(u01, v01);
319
-
320
- // compose body-local point: u_axis*u_clamped + v_axis*v_clamped + n_axis*surface_h
321
- result[0] = b[0] * u_clamped + b[3] * v_clamped + b[6] * surface_h;
322
- result[1] = b[1] * u_clamped + b[4] * v_clamped + b[7] * surface_h;
323
- result[2] = b[2] * u_clamped + b[5] * v_clamped + b[8] * surface_h;
324
- }
325
-
326
- /**
327
- * Heightmaps are non-convex; GJK/EPA cannot work against them directly.
328
- * The physics narrowphase must dispatch a grid-traversal path that
329
- * decomposes the heightmap into per-cell triangle pairs and tests each
330
- * against the other shape (analogous to Bullet's btHeightfieldTerrainShape
331
- * × btConcaveShape interface).
332
- *
333
- * This throws rather than returning a degenerate result so the call
334
- * site is forced to handle heightmaps explicitly.
335
- */
336
- support(result, result_offset, direction_x, direction_y, direction_z) {
337
- throw new Error("HeightMapShape3D.support: heightmaps are non-convex; the narrowphase must dispatch grid-traversal instead.");
338
- }
339
-
340
- sample_random_point_in_volume(result, result_offset, random) {
341
- const u01 = random();
342
- const v01 = random();
343
-
344
- const u_coord = (u01 - 0.5) * this.size[0];
345
- const v_coord = (v01 - 0.5) * this.size[2];
346
-
347
- const surface_h = this.sample_height_at_uv(u01, v01);
348
- const h_coord = random() * surface_h;
349
-
350
- this._ensure_basis();
351
-
352
- const b = this._basis;
353
-
354
- result[result_offset] = b[0] * u_coord + b[3] * v_coord + b[6] * h_coord;
355
- result[result_offset + 1] = b[1] * u_coord + b[4] * v_coord + b[7] * h_coord;
356
- result[result_offset + 2] = b[2] * u_coord + b[5] * v_coord + b[8] * h_coord;
357
- }
358
-
359
- /**
360
- * Sum of sampler heights × per-cell footprint area. This is the
361
- * piecewise-constant approximation of the integral ∫h(u,v) dA over
362
- * the footprint exact when h is constant per cell, biased when
363
- * h is smooth.
364
- */
365
- get volume() {
366
- const sampler = this.sampler;
367
-
368
- if (sampler === null) {
369
- return 0;
370
- }
371
-
372
- const w = sampler.width;
373
- const h = sampler.height;
374
-
375
- if (w === 0 || h === 0) {
376
- return 0;
377
- }
378
-
379
- const cell_area = (this.size[0] / w) * (this.size[2] / h);
380
-
381
- let total = 0;
382
-
383
- for (let y = 0; y < h; y++) {
384
- for (let x = 0; x < w; x++) {
385
- total += sampler.readChannel(x, y, 0);
386
- }
387
- }
388
-
389
- return total * cell_area;
390
- }
391
-
392
- /**
393
- * Returns just the footprint area. A true heightmap surface area
394
- * requires integrating sqrt(1 + (∂h/∂u)² + (∂h/∂v)²) over the grid;
395
- * the footprint area is a lower bound and is sufficient for the
396
- * physics inertia-tensor seam (heightmaps are static anyway).
397
- */
398
- get surface_area() {
399
- return this.size[0] * this.size[2];
400
- }
401
-
402
- /**
403
- * @param {HeightMapShape3D} other
404
- * @returns {boolean}
405
- */
406
- equals(other) {
407
- if (!super.equals(other)) return false;
408
-
409
- if (!this.orientation.equals(other.orientation)) return false;
410
- if (!this.size.equals(other.size)) return false;
411
-
412
- // strict identity is enough for sampler equality in the common case;
413
- // fall through to value equality so two shapes built from independent
414
- // but identical sampler instances still compare equal
415
- if (this.sampler === other.sampler) return true;
416
-
417
- if (this.sampler === null || other.sampler === null) return false;
418
-
419
- return this.sampler.equals(other.sampler);
420
- }
421
-
422
- hash() {
423
- const a = this.orientation.hash();
424
- const b = this.size.hash();
425
- const c = this.sampler !== null ? this.sampler.hash() : 0;
426
-
427
- let h = (a * 31 + b) | 0;
428
- h = (h * 31 + c) | 0;
429
-
430
- return h;
431
- }
432
- }
433
-
434
- /**
435
- * Fast type-check marker, matching the pattern on every other concrete
436
- * AbstractShape3D subclass. The physics narrowphase reads this to dispatch
437
- * the heightmap-vs-X grid-traversal path.
438
- * @readonly
439
- * @type {boolean}
440
- */
441
- HeightMapShape3D.prototype.isHeightMapShape3D = true;
442
-
443
- /**
444
- * Heightmaps are non-convex: the solid volume bounded by an arbitrary
445
- * height-field has valleys and overhangs that break GJK's convex-Minkowski
446
- * precondition. The narrowphase must use grid traversal + per-triangle
447
- * GJK instead of feeding this shape's {@link support} into pair tests.
448
- * @readonly
449
- * @type {boolean}
450
- */
451
- HeightMapShape3D.prototype.is_convex = false;
1
+ import { assert } from "../../../assert.js";
2
+ import { clamp } from "../../../math/clamp.js";
3
+ import { v3_length } from "../../vec3/v3_length.js";
4
+ import { Vector3 } from "../../Vector3.js";
5
+ import { AbstractShape3D } from "./AbstractShape3D.js";
6
+
7
+ /**
8
+ * Heightmap shape, intended primarily for terrain.
9
+ *
10
+ * The shape is a closed solid bounded below by the plane perpendicular to
11
+ * {@link orientation} (the "floor") and above by a height-field surface
12
+ * defined by a {@link Sampler2D}. Heights are sampled with Catmull-Rom
13
+ * filtering ({@link Sampler2D#sampleChannelCatmullRomUV}) the *same* cubic
14
+ * the terrain renderer uses (`sampleChannelBicubicUV` expands to the
15
+ * identical Catmull-Rom weights, with the matching `u·width − 0.5` UV
16
+ * convention), so the collision and render surfaces coincide at every point
17
+ * they both sample.
18
+ *
19
+ * They differ only in *tessellation density*: the renderer lays out
20
+ * `size × resolution` segments per side, while collision defaults to one
21
+ * quad per sampler cell. {@link tessellation} closes that gap — set it > 1
22
+ * to subdivide each sampler cell into N×N sub-cells so the contact surface
23
+ * approaches render fidelity even when the sampler is deliberately coarse.
24
+ *
25
+ * Local frame layout:
26
+ * - The orientation vector defines the local "up" axis (unit).
27
+ * - An orthonormal basis (u, v, n=orientation) is built from it.
28
+ * - Footprint extends along the basis-u axis over [-size.x/2, +size.x/2]
29
+ * and along the basis-v axis over [-size.z/2, +size.z/2].
30
+ * - The surface height (along orientation) at heightmap-UV (u01, v01) is
31
+ * `sampler.sampleChannelCatmullRomUV(u01, v01, 0)`.
32
+ * - The solid volume occupies `h ∈ [0, sampledHeight(u01, v01)]` along the
33
+ * orientation axis, with `0` being the floor at body-local origin.
34
+ * - `size.y` is the maximum height value of the heightfield (used for the
35
+ * bounding box). Sampler values are NOT clamped to it.
36
+ *
37
+ * NON-CONVEX. {@link support} throws — GJK/EPA cannot be run against a
38
+ * heightmap directly. The physics narrowphase must dispatch a dedicated
39
+ * grid-traversal path when one of the colliders is a heightmap.
40
+ *
41
+ * @author Alex Goldring
42
+ * @copyright Company Named Limited (c) 2026
43
+ */
44
+ export class HeightMapShape3D extends AbstractShape3D {
45
+ constructor() {
46
+ super();
47
+
48
+ /**
49
+ * Unit vector defining the local "up" axis (the direction the
50
+ * heightmap's surface faces). Default is +Y.
51
+ * @readonly
52
+ * @type {Vector3}
53
+ */
54
+ this.orientation = new Vector3(0, 1, 0);
55
+
56
+ /**
57
+ * Bounding-box extents in the heightmap-local (u, height, v) frame.
58
+ * size.x footprint extent along basis-u (perpendicular to orientation)
59
+ * size.y maximum heightmap height (extent along orientation)
60
+ * size.z footprint extent along basis-v (perpendicular to orientation)
61
+ * @readonly
62
+ * @type {Vector3}
63
+ */
64
+ this.size = new Vector3(1, 1, 1);
65
+
66
+ /**
67
+ * Sampler holding height values. Float32 backing recommended so the
68
+ * Terrain system's height texture plugs in directly. Single-channel
69
+ * sampler is the common case; only channel 0 is read.
70
+ * @type {Sampler2D | null}
71
+ */
72
+ this.sampler = null;
73
+
74
+ /**
75
+ * Collision tessellation factor: the narrowphase splits each sampler
76
+ * cell into `tessellation × tessellation` sub-cells before emitting
77
+ * collision triangles, sampling the same Catmull-Rom filter at the
78
+ * finer sub-cell corners. `1` (the default) is one quad per sampler
79
+ * cell — the legacy behaviour. Larger values let a coarse sampler's
80
+ * contact surface approach the rendered mesh's fidelity.
81
+ *
82
+ * Non-negative integer (validated by {@link HeightMapShape3D.from});
83
+ * cost is O(N²) per cell, so the caller owns the fidelity/cost
84
+ * trade-off. `0` yields no collision triangles — the degenerate empty
85
+ * case, mirroring a zero footprint size. Affects only triangle
86
+ * enumeration — the continuous queries ({@link sample_height_at_uv},
87
+ * {@link signed_distance_at_point}, {@link nearest_point_on_surface})
88
+ * read the smooth filter directly and are unaffected.
89
+ * @type {number}
90
+ */
91
+ this.tessellation = 1;
92
+
93
+ /**
94
+ * Cached orthonormal basis [u_x,u_y,u_z, v_x,v_y,v_z, n_x,n_y,n_z]
95
+ * built from {@link orientation}. Updated lazily by {@link _ensure_basis}.
96
+ * @private
97
+ * @type {Float64Array}
98
+ */
99
+ this._basis = new Float64Array(9);
100
+
101
+ // last-seen orientation components, used to detect dirty basis
102
+ this._basis_orientation_x = NaN;
103
+ this._basis_orientation_y = NaN;
104
+ this._basis_orientation_z = NaN;
105
+ }
106
+
107
+ /**
108
+ * Convenience constructor.
109
+ * @param {Sampler2D} sampler
110
+ * @param {number} size_x footprint extent along basis-u
111
+ * @param {number} size_y maximum heightmap height (along orientation)
112
+ * @param {number} size_z footprint extent along basis-v
113
+ * @param {Vector3} [orientation] defaults to +Y
114
+ * @param {number} [tessellation] collision sub-cells per sampler cell per
115
+ * axis; non-negative integer, defaults to 1 (one quad per sampler cell).
116
+ * See {@link HeightMapShape3D#tessellation}.
117
+ * @returns {HeightMapShape3D}
118
+ */
119
+ static from(sampler, size_x, size_y, size_z, orientation, tessellation = 1) {
120
+ assert.isNumber(size_x, "size_x");
121
+ assert.isNumber(size_y, "size_y");
122
+ assert.isNumber(size_z, "size_z");
123
+ assert.greaterThanOrEqual(size_x, 0, "size_x");
124
+ assert.greaterThanOrEqual(size_y, 0, "size_y");
125
+ assert.greaterThanOrEqual(size_z, 0, "size_z");
126
+ assert.isNonNegativeInteger(tessellation, "tessellation");
127
+
128
+ const r = new HeightMapShape3D();
129
+ r.sampler = sampler;
130
+ r.size.set(size_x, size_y, size_z);
131
+ r.tessellation = tessellation;
132
+
133
+ if (orientation !== undefined) {
134
+ r.orientation.set(orientation.x, orientation.y, orientation.z);
135
+ }
136
+
137
+ return r;
138
+ }
139
+
140
+ /**
141
+ * Recompute the orthonormal basis (u, v, n) if {@link orientation} changed.
142
+ *
143
+ * Construction chosen so that the default orientation +Y produces the
144
+ * intuitive mapping `u = +X, v = +Z, n = +Y` — i.e. size.x runs along
145
+ * body X, size.z along body Z. We project body +X onto the plane
146
+ * perpendicular to n (the orientation), with a fall-back to projecting
147
+ * body +Z when n is too close to colinear with +X.
148
+ * @private
149
+ */
150
+ _ensure_basis() {
151
+ const nx = this.orientation[0];
152
+ const ny = this.orientation[1];
153
+ const nz = this.orientation[2];
154
+
155
+ if (
156
+ nx === this._basis_orientation_x
157
+ && ny === this._basis_orientation_y
158
+ && nz === this._basis_orientation_z
159
+ ) {
160
+ return;
161
+ }
162
+
163
+ // tangent u = normalize(project body +X onto plane perpendicular to n)
164
+ // fall back to body +Z if n is too colinear with +X
165
+ let u_x, u_y, u_z;
166
+
167
+ if (Math.abs(nx) < 0.9) {
168
+ // u = (+X) - (n . +X) * n = (1 - nx*nx, -nx*ny, -nx*nz)
169
+ u_x = 1 - nx * nx;
170
+ u_y = -nx * ny;
171
+ u_z = -nx * nz;
172
+ } else {
173
+ // u = (+Z) - (n . +Z) * n = (-nz*nx, -nz*ny, 1 - nz*nz)
174
+ u_x = -nz * nx;
175
+ u_y = -nz * ny;
176
+ u_z = 1 - nz * nz;
177
+ }
178
+
179
+ const u_inv = 1 / v3_length(u_x, u_y, u_z);
180
+ u_x *= u_inv;
181
+ u_y *= u_inv;
182
+ u_z *= u_inv;
183
+
184
+ // v = u × n (for n=+Y, u=+X this gives v=+Z, the intuitive choice)
185
+ const v_x = u_y * nz - u_z * ny;
186
+ const v_y = u_z * nx - u_x * nz;
187
+ const v_z = u_x * ny - u_y * nx;
188
+
189
+ const b = this._basis;
190
+ b[0] = u_x; b[1] = u_y; b[2] = u_z;
191
+ b[3] = v_x; b[4] = v_y; b[5] = v_z;
192
+ b[6] = nx; b[7] = ny; b[8] = nz;
193
+
194
+ this._basis_orientation_x = nx;
195
+ this._basis_orientation_y = ny;
196
+ this._basis_orientation_z = nz;
197
+ }
198
+
199
+ /**
200
+ * Sample the surface height at heightmap UV coordinates.
201
+ * Uses Catmull-Rom filtering to match the terrain system's geometry construction.
202
+ * @param {number} u01 horizontal UV, in [0, 1]
203
+ * @param {number} v01 vertical UV, in [0, 1]
204
+ * @returns {number} height along orientation axis
205
+ */
206
+ sample_height_at_uv(u01, v01) {
207
+ return this.sampler.sampleChannelCatmullRomUV(u01, v01, 0);
208
+ }
209
+
210
+ /**
211
+ * Project a body-local position to the surface and sample the height there.
212
+ * The returned height is in the orientation-axis direction.
213
+ * Positions outside the footprint sample the clamped UV (sampler clamps).
214
+ * @param {number} px body-local x
215
+ * @param {number} py body-local y
216
+ * @param {number} pz body-local z
217
+ * @returns {number}
218
+ */
219
+ sample_height_at_position(px, py, pz) {
220
+ this._ensure_basis();
221
+
222
+ const b = this._basis;
223
+
224
+ // project onto basis u and basis v
225
+ const u_coord = b[0] * px + b[1] * py + b[2] * pz;
226
+ const v_coord = b[3] * px + b[4] * py + b[5] * pz;
227
+
228
+ const u01 = u_coord / this.size[0] + 0.5;
229
+ const v01 = v_coord / this.size[2] + 0.5;
230
+
231
+ return this.sample_height_at_uv(u01, v01);
232
+ }
233
+
234
+ compute_bounding_box(result) {
235
+ this._ensure_basis();
236
+
237
+ const b = this._basis;
238
+
239
+ const sx = this.size[0];
240
+ const sy = this.size[1];
241
+ const sz = this.size[2];
242
+
243
+ const half_u = sx * 0.5;
244
+ const half_v = sz * 0.5;
245
+
246
+ // For each body axis k (0=x, 1=y, 2=z):
247
+ // body[k] = b[0+k]*u + b[3+k]*v + b[6+k]*h
248
+ //
249
+ // The (u,v) footprint contribution is symmetric (u ∈ [-half_u, +half_u], v ∈ [-half_v, +half_v])
250
+ // The height contribution is asymmetric (h [0, sy])
251
+ for (let k = 0; k < 3; k++) {
252
+ const cu = b[k];
253
+ const cv = b[3 + k];
254
+ const ch = b[6 + k];
255
+
256
+ const uv_extent = Math.abs(cu) * half_u + Math.abs(cv) * half_v;
257
+
258
+ const h_lo = ch < 0 ? ch * sy : 0;
259
+ const h_hi = ch > 0 ? ch * sy : 0;
260
+
261
+ result[k] = -uv_extent + h_lo;
262
+ result[k + 3] = uv_extent + h_hi;
263
+ }
264
+ }
265
+
266
+ contains_point(point) {
267
+ const px = point[0];
268
+ const py = point[1];
269
+ const pz = point[2];
270
+
271
+ this._ensure_basis();
272
+
273
+ const b = this._basis;
274
+
275
+ // project into heightmap-local frame (u, v, h)
276
+ const u_coord = b[0] * px + b[1] * py + b[2] * pz;
277
+ const v_coord = b[3] * px + b[4] * py + b[5] * pz;
278
+ const h_coord = b[6] * px + b[7] * py + b[8] * pz;
279
+
280
+ const half_u = this.size[0] * 0.5;
281
+ const half_v = this.size[2] * 0.5;
282
+
283
+ if (u_coord <= -half_u || u_coord >= half_u) return false;
284
+ if (v_coord <= -half_v || v_coord >= half_v) return false;
285
+ if (h_coord <= 0 || h_coord >= this.size[1]) return false;
286
+
287
+ const u01 = u_coord / this.size[0] + 0.5;
288
+ const v01 = v_coord / this.size[2] + 0.5;
289
+
290
+ const surface_h = this.sample_height_at_uv(u01, v01);
291
+
292
+ return h_coord < surface_h;
293
+ }
294
+
295
+ /**
296
+ * Approximate signed distance: difference between the point's height
297
+ * (along orientation) and the surface height sampled at the point's
298
+ * footprint UV. POSITIVE = above surface, NEGATIVE = below.
299
+ *
300
+ * Locally correct when the surface is flat; biased when the surface
301
+ * has significant slope. Sufficient for cling/raycast queries that
302
+ * walk down to the surface.
303
+ */
304
+ signed_distance_at_point(point) {
305
+ const px = point[0];
306
+ const py = point[1];
307
+ const pz = point[2];
308
+
309
+ this._ensure_basis();
310
+
311
+ const b = this._basis;
312
+
313
+ const u_coord = b[0] * px + b[1] * py + b[2] * pz;
314
+ const v_coord = b[3] * px + b[4] * py + b[5] * pz;
315
+ const h_coord = b[6] * px + b[7] * py + b[8] * pz;
316
+
317
+ const u01 = u_coord / this.size[0] + 0.5;
318
+ const v01 = v_coord / this.size[2] + 0.5;
319
+
320
+ const surface_h = this.sample_height_at_uv(u01, v01);
321
+
322
+ return h_coord - surface_h;
323
+ }
324
+
325
+ /**
326
+ * Project a reference point onto the surface along the orientation axis.
327
+ * The footprint UV is clamped, so points outside the footprint produce
328
+ * the nearest edge-of-footprint surface sample (approximate).
329
+ */
330
+ nearest_point_on_surface(result, reference) {
331
+ const rx = reference[0];
332
+ const ry = reference[1];
333
+ const rz = reference[2];
334
+
335
+ this._ensure_basis();
336
+
337
+ const b = this._basis;
338
+
339
+ const u_coord = b[0] * rx + b[1] * ry + b[2] * rz;
340
+ const v_coord = b[3] * rx + b[4] * ry + b[5] * rz;
341
+
342
+ const half_u = this.size[0] * 0.5;
343
+ const half_v = this.size[2] * 0.5;
344
+
345
+ const u_clamped = clamp(u_coord, -half_u, half_u);
346
+ const v_clamped = clamp(v_coord, -half_v, half_v);
347
+
348
+ const u01 = u_clamped / this.size[0] + 0.5;
349
+ const v01 = v_clamped / this.size[2] + 0.5;
350
+
351
+ const surface_h = this.sample_height_at_uv(u01, v01);
352
+
353
+ // compose body-local point: u_axis*u_clamped + v_axis*v_clamped + n_axis*surface_h
354
+ result[0] = b[0] * u_clamped + b[3] * v_clamped + b[6] * surface_h;
355
+ result[1] = b[1] * u_clamped + b[4] * v_clamped + b[7] * surface_h;
356
+ result[2] = b[2] * u_clamped + b[5] * v_clamped + b[8] * surface_h;
357
+ }
358
+
359
+ /**
360
+ * Heightmaps are non-convex; GJK/EPA cannot work against them directly.
361
+ * The physics narrowphase must dispatch a grid-traversal path that
362
+ * decomposes the heightmap into per-cell triangle pairs and tests each
363
+ * against the other shape (analogous to Bullet's btHeightfieldTerrainShape
364
+ * × btConcaveShape interface).
365
+ *
366
+ * This throws rather than returning a degenerate result so the call
367
+ * site is forced to handle heightmaps explicitly.
368
+ */
369
+ support(result, result_offset, direction_x, direction_y, direction_z) {
370
+ throw new Error("HeightMapShape3D.support: heightmaps are non-convex; the narrowphase must dispatch grid-traversal instead.");
371
+ }
372
+
373
+ sample_random_point_in_volume(result, result_offset, random) {
374
+ const u01 = random();
375
+ const v01 = random();
376
+
377
+ const u_coord = (u01 - 0.5) * this.size[0];
378
+ const v_coord = (v01 - 0.5) * this.size[2];
379
+
380
+ const surface_h = this.sample_height_at_uv(u01, v01);
381
+ const h_coord = random() * surface_h;
382
+
383
+ this._ensure_basis();
384
+
385
+ const b = this._basis;
386
+
387
+ result[result_offset] = b[0] * u_coord + b[3] * v_coord + b[6] * h_coord;
388
+ result[result_offset + 1] = b[1] * u_coord + b[4] * v_coord + b[7] * h_coord;
389
+ result[result_offset + 2] = b[2] * u_coord + b[5] * v_coord + b[8] * h_coord;
390
+ }
391
+
392
+ /**
393
+ * Sum of sampler heights × per-cell footprint area. This is the
394
+ * piecewise-constant approximation of the integral ∫h(u,v) dA over
395
+ * the footprint exact when h is constant per cell, biased when
396
+ * h is smooth.
397
+ */
398
+ get volume() {
399
+ const sampler = this.sampler;
400
+
401
+ if (sampler === null) {
402
+ return 0;
403
+ }
404
+
405
+ const w = sampler.width;
406
+ const h = sampler.height;
407
+
408
+ if (w === 0 || h === 0) {
409
+ return 0;
410
+ }
411
+
412
+ const cell_area = (this.size[0] / w) * (this.size[2] / h);
413
+
414
+ let total = 0;
415
+
416
+ for (let y = 0; y < h; y++) {
417
+ for (let x = 0; x < w; x++) {
418
+ total += sampler.readChannel(x, y, 0);
419
+ }
420
+ }
421
+
422
+ return total * cell_area;
423
+ }
424
+
425
+ /**
426
+ * Returns just the footprint area. A true heightmap surface area
427
+ * requires integrating sqrt(1 + (∂h/∂u)² + (∂h/∂v)²) over the grid;
428
+ * the footprint area is a lower bound and is sufficient for the
429
+ * physics inertia-tensor seam (heightmaps are static anyway).
430
+ */
431
+ get surface_area() {
432
+ return this.size[0] * this.size[2];
433
+ }
434
+
435
+ /**
436
+ * @param {HeightMapShape3D} other
437
+ * @returns {boolean}
438
+ */
439
+ equals(other) {
440
+ if (!super.equals(other)) return false;
441
+
442
+ if (!this.orientation.equals(other.orientation)) return false;
443
+ if (!this.size.equals(other.size)) return false;
444
+ if (this.tessellation !== other.tessellation) return false;
445
+
446
+ // strict identity is enough for sampler equality in the common case;
447
+ // fall through to value equality so two shapes built from independent
448
+ // but identical sampler instances still compare equal
449
+ if (this.sampler === other.sampler) return true;
450
+
451
+ if (this.sampler === null || other.sampler === null) return false;
452
+
453
+ return this.sampler.equals(other.sampler);
454
+ }
455
+
456
+ hash() {
457
+ const a = this.orientation.hash();
458
+ const b = this.size.hash();
459
+ const c = this.sampler !== null ? this.sampler.hash() : 0;
460
+
461
+ let h = (a * 31 + b) | 0;
462
+ h = (h * 31 + c) | 0;
463
+ h = (h * 31 + this.tessellation) | 0;
464
+
465
+ return h;
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Fast type-check marker, matching the pattern on every other concrete
471
+ * AbstractShape3D subclass. The physics narrowphase reads this to dispatch
472
+ * the heightmap-vs-X grid-traversal path.
473
+ * @readonly
474
+ * @type {boolean}
475
+ */
476
+ HeightMapShape3D.prototype.isHeightMapShape3D = true;
477
+
478
+ /**
479
+ * Heightmaps are non-convex: the solid volume bounded by an arbitrary
480
+ * height-field has valleys and overhangs that break GJK's convex-Minkowski
481
+ * precondition. The narrowphase must use grid traversal + per-triangle
482
+ * GJK instead of feeding this shape's {@link support} into pair tests.
483
+ * @readonly
484
+ * @type {boolean}
485
+ */
486
+ HeightMapShape3D.prototype.is_convex = false;