@woosh/meep-engine 2.153.0 → 2.155.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 (107) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/shape/ConvexHullShape3D.d.ts +112 -0
  3. package/src/core/geom/3d/shape/ConvexHullShape3D.d.ts.map +1 -0
  4. package/src/core/geom/3d/shape/ConvexHullShape3D.js +325 -0
  5. package/src/core/geom/vec3/v3_array_copy.d.ts +3 -3
  6. package/src/core/geom/vec3/v3_array_copy.d.ts.map +1 -1
  7. package/src/core/geom/vec3/v3_array_copy.js +2 -2
  8. package/src/core/geom/vec3/v3_cross.d.ts +17 -0
  9. package/src/core/geom/vec3/v3_cross.d.ts.map +1 -0
  10. package/src/core/geom/vec3/v3_cross.js +20 -0
  11. package/src/core/geom/vec3/v3_subtract.d.ts +16 -0
  12. package/src/core/geom/vec3/v3_subtract.d.ts.map +1 -0
  13. package/src/core/geom/vec3/v3_subtract.js +19 -0
  14. package/src/engine/graphics/ecs/decal/v2/FPDecalSystem.d.ts.map +1 -1
  15. package/src/engine/graphics/ecs/decal/v2/FPDecalSystem.js +8 -0
  16. package/src/engine/graphics/ecs/trail2d/Trail2D.d.ts +4 -0
  17. package/src/engine/graphics/ecs/trail2d/Trail2D.d.ts.map +1 -1
  18. package/src/engine/graphics/ecs/trail2d/Trail2D.js +21 -0
  19. package/src/engine/physics/PLAN.md +4 -4
  20. package/src/engine/physics/body/BodyStorage.d.ts +3 -1
  21. package/src/engine/physics/body/BodyStorage.d.ts.map +1 -1
  22. package/src/engine/physics/body/BodyStorage.js +452 -450
  23. package/src/engine/physics/body/SolverBodyState.d.ts.map +1 -1
  24. package/src/engine/physics/body/SolverBodyState.js +6 -5
  25. package/src/engine/physics/broadphase/generate_pairs.d.ts.map +1 -1
  26. package/src/engine/physics/broadphase/generate_pairs.js +9 -1
  27. package/src/engine/physics/ccd/linear_sweep.d.ts.map +1 -1
  28. package/src/engine/physics/ccd/linear_sweep.js +237 -238
  29. package/src/engine/physics/computeInterceptPoint.d.ts.map +1 -1
  30. package/src/engine/physics/computeInterceptPoint.js +8 -3
  31. package/src/engine/physics/contact/ManifoldStore.d.ts +0 -16
  32. package/src/engine/physics/contact/ManifoldStore.d.ts.map +1 -1
  33. package/src/engine/physics/contact/ManifoldStore.js +1 -38
  34. package/src/engine/physics/ecs/BodyKind.d.ts +3 -2
  35. package/src/engine/physics/ecs/BodyKind.d.ts.map +1 -1
  36. package/src/engine/physics/ecs/BodyKind.js +25 -24
  37. package/src/engine/physics/ecs/PhysicsEvents.d.ts +4 -5
  38. package/src/engine/physics/ecs/PhysicsEvents.d.ts.map +1 -1
  39. package/src/engine/physics/ecs/PhysicsEvents.js +15 -16
  40. package/src/engine/physics/ecs/PhysicsSystem.d.ts +5 -30
  41. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  42. package/src/engine/physics/ecs/PhysicsSystem.js +13 -45
  43. package/src/engine/physics/ecs/RigidBodySerializationAdapter.d.ts.map +1 -1
  44. package/src/engine/physics/ecs/RigidBodySerializationAdapter.js +85 -81
  45. package/src/engine/physics/ecs/is_sensor.d.ts +18 -0
  46. package/src/engine/physics/ecs/is_sensor.d.ts.map +1 -0
  47. package/src/engine/physics/ecs/is_sensor.js +27 -0
  48. package/src/engine/physics/events/ContactEventBuffer.d.ts +2 -1
  49. package/src/engine/physics/events/ContactEventBuffer.d.ts.map +1 -1
  50. package/src/engine/physics/events/ContactEventBuffer.js +84 -83
  51. package/src/engine/physics/gjk/gjk.d.ts +0 -26
  52. package/src/engine/physics/gjk/gjk.d.ts.map +1 -1
  53. package/src/engine/physics/gjk/gjk.js +3 -52
  54. package/src/engine/physics/gjk/gjk_epa_penetration.d.ts +20 -0
  55. package/src/engine/physics/gjk/gjk_epa_penetration.d.ts.map +1 -0
  56. package/src/engine/physics/gjk/gjk_epa_penetration.js +548 -0
  57. package/src/engine/physics/gjk/minkowski_support.d.ts +4 -9
  58. package/src/engine/physics/gjk/minkowski_support.d.ts.map +1 -1
  59. package/src/engine/physics/gjk/minkowski_support.js +70 -75
  60. package/src/engine/physics/gjk/mpr.d.ts +1 -1
  61. package/src/engine/physics/gjk/mpr.d.ts.map +1 -1
  62. package/src/engine/physics/gjk/mpr.js +362 -344
  63. package/src/engine/physics/island/IslandBuilder.d.ts.map +1 -1
  64. package/src/engine/physics/island/IslandBuilder.js +431 -428
  65. package/src/engine/physics/narrowphase/box_box_manifold.d.ts.map +1 -1
  66. package/src/engine/physics/narrowphase/box_box_manifold.js +4 -81
  67. package/src/engine/physics/narrowphase/box_triangle_contact.d.ts.map +1 -1
  68. package/src/engine/physics/narrowphase/box_triangle_contact.js +4 -39
  69. package/src/engine/physics/narrowphase/capsule_contacts.d.ts.map +1 -1
  70. package/src/engine/physics/narrowphase/capsule_contacts.js +459 -462
  71. package/src/engine/physics/narrowphase/clip_against_axis_uv.d.ts.map +1 -1
  72. package/src/engine/physics/narrowphase/clip_against_axis_uv.js +4 -1
  73. package/src/engine/physics/narrowphase/convex_convex_manifold.d.ts +83 -0
  74. package/src/engine/physics/narrowphase/convex_convex_manifold.d.ts.map +1 -0
  75. package/src/engine/physics/narrowphase/convex_convex_manifold.js +425 -0
  76. package/src/engine/physics/narrowphase/convex_decomposition.d.ts +32 -0
  77. package/src/engine/physics/narrowphase/convex_decomposition.d.ts.map +1 -0
  78. package/src/engine/physics/narrowphase/convex_decomposition.js +293 -0
  79. package/src/engine/physics/narrowphase/mesh_convex_hull.d.ts +41 -0
  80. package/src/engine/physics/narrowphase/mesh_convex_hull.d.ts.map +1 -0
  81. package/src/engine/physics/narrowphase/mesh_convex_hull.js +106 -0
  82. package/src/engine/physics/narrowphase/mesh_mesh_tet_manifold.d.ts +8 -0
  83. package/src/engine/physics/narrowphase/mesh_mesh_tet_manifold.d.ts.map +1 -0
  84. package/src/engine/physics/narrowphase/mesh_mesh_tet_manifold.js +117 -0
  85. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  86. package/src/engine/physics/narrowphase/narrowphase_step.js +105 -102
  87. package/src/engine/physics/narrowphase/reduce_manifold_contacts.d.ts +29 -0
  88. package/src/engine/physics/narrowphase/reduce_manifold_contacts.d.ts.map +1 -0
  89. package/src/engine/physics/narrowphase/reduce_manifold_contacts.js +69 -0
  90. package/src/engine/physics/narrowphase/refine_ray_concave.d.ts.map +1 -1
  91. package/src/engine/physics/narrowphase/refine_ray_concave.js +152 -145
  92. package/src/engine/physics/narrowphase/sphere_box_contact.d.ts.map +1 -1
  93. package/src/engine/physics/narrowphase/sphere_box_contact.js +132 -123
  94. package/src/engine/physics/queries/overlap_shape.d.ts.map +1 -1
  95. package/src/engine/physics/queries/overlap_shape.js +16 -17
  96. package/src/engine/physics/queries/raycast.d.ts +5 -0
  97. package/src/engine/physics/queries/raycast.d.ts.map +1 -1
  98. package/src/engine/physics/queries/raycast.js +16 -8
  99. package/src/engine/physics/queries/shape_cast.d.ts.map +1 -1
  100. package/src/engine/physics/queries/shape_cast.js +13 -7
  101. package/src/engine/physics/solver/solve_contacts.d.ts.map +1 -1
  102. package/src/engine/physics/solver/solve_contacts.js +8 -11
  103. package/src/engine/physics/vehicle/RaycastVehicle.d.ts.map +1 -1
  104. package/src/engine/physics/vehicle/RaycastVehicle.js +339 -333
  105. package/src/engine/physics/gjk/expanding_polytope_algorithm.d.ts +0 -13
  106. package/src/engine/physics/gjk/expanding_polytope_algorithm.d.ts.map +0 -1
  107. package/src/engine/physics/gjk/expanding_polytope_algorithm.js +0 -399
@@ -1,333 +1,339 @@
1
- import { Ray3 } from "../../../core/geom/3d/ray/Ray3.js";
2
- import { PhysicsSurfacePoint } from "../queries/PhysicsSurfacePoint.js";
3
- import { v3_quat3_apply } from "../../../core/geom/vec3/v3_quat3_apply.js";
4
- import { world_inverse_inertia_apply } from "../inertia/world_inverse_inertia.js";
5
- import { BodyKind } from "../ecs/BodyKind.js";
6
-
7
- /**
8
- * # Raycast vehicle controller
9
- *
10
- * The vehicle model most games ship: a single rigid-body **chassis** plus a set
11
- * of **raycast wheels**. Each wheel is not a body it is a downward ray from a
12
- * chassis-local mount point. Every frame, for each wheel, the controller:
13
- *
14
- * 1. casts the suspension ray and, on a ground hit, measures the spring
15
- * compression;
16
- * 2. applies a spring + damper **suspension force** along the contact normal
17
- * (this is what holds the chassis up);
18
- * 3. applies **tyre friction** at the contact patch — a lateral grip impulse
19
- * that resists sideways slide, plus a longitudinal drive / brake impulse,
20
- * the two clamped together to a friction circle `μ·N`.
21
- *
22
- * Steering rotates the steered wheels' forward direction; a drive force is split
23
- * across the driven wheels. It is a *controller on top of the public physics
24
- * API* (`raycast` + `applyForceAt` + `applyImpulseAt`), not a new constraint
25
- * call {@link update} once per frame **before** `PhysicsSystem.fixedUpdate`, so
26
- * the suspension force it accumulates is integrated by that step and the
27
- * friction impulses act on the current velocity.
28
- *
29
- * The 6-DOF joint (spring linear + motor angular) is the alternative
30
- * "simulated wheel" path; this raycast controller is the lighter, more robust
31
- * default. Suspension-ray accuracy follows `PhysicsSystem.raycast`, which is
32
- * narrowphase-exact for sphere / box / capsule / mesh / heightmap ground.
33
- *
34
- * @author Alex Goldring
35
- * @copyright Company Named Limited (c) 2026
36
- */
37
-
38
- const scratch_v = new Float64Array(3);
39
- const scratch_i = new Float64Array(3);
40
-
41
- /**
42
- * Effective mass denominator for a unit-direction velocity constraint at a
43
- * body-relative point: `invM + (r×d)·Iw⁻¹·(r×d)`.
44
- * @returns {number}
45
- */
46
- function effective_mass(rb, transform, invM, rx, ry, rz, dx, dy, dz) {
47
- let k = invM;
48
- const ii = rb.inverseInertiaLocal;
49
- if (ii.x !== 0 || ii.y !== 0 || ii.z !== 0) {
50
- const cx = ry * dz - rz * dy;
51
- const cy = rz * dx - rx * dz;
52
- const cz = rx * dy - ry * dx;
53
- world_inverse_inertia_apply(scratch_i, 0, ii, transform.rotation, cx, cy, cz);
54
- k += cx * scratch_i[0] + cy * scratch_i[1] + cz * scratch_i[2];
55
- }
56
- return k;
57
- }
58
-
59
- /** Rotate vector `(vx,vy,vz)` about unit axis `(ux,uy,uz)` by `angle`, into `out`. */
60
- function rotate_about(out, vx, vy, vz, ux, uy, uz, angle) {
61
- const c = Math.cos(angle), s = Math.sin(angle);
62
- const dot = ux * vx + uy * vy + uz * vz;
63
- // v·c + (k×v)·s + k·(k·v)·(1−c)
64
- const kxx = uy * vz - uz * vy;
65
- const kxy = uz * vx - ux * vz;
66
- const kxz = ux * vy - uy * vx;
67
- out[0] = vx * c + kxx * s + ux * dot * (1 - c);
68
- out[1] = vy * c + kxy * s + uy * dot * (1 - c);
69
- out[2] = vz * c + kxz * s + uz * dot * (1 - c);
70
- }
71
-
72
- function v3len(x, y, z) { return Math.sqrt(x * x + y * y + z * z); }
73
-
74
- /**
75
- * One wheel's config + per-frame runtime state. The runtime fields
76
- * (`inContact`, `compression`, contact point/normal, `rotation`, …) are read by
77
- * rendering / gameplay; do not write them.
78
- */
79
- export class Wheel {
80
- constructor() {
81
- // --- config ---
82
- /** Mount point on the chassis, local. @type {Float64Array} */
83
- this.localPosition = new Float64Array(3);
84
- /** Suspension direction (local, unit, points "down"). @type {Float64Array} */
85
- this.localSuspensionDir = Float64Array.from([0, -1, 0]);
86
- /** Forward / rolling direction (local, unit). @type {Float64Array} */
87
- this.localForward = Float64Array.from([0, 0, 1]);
88
- this.suspensionRestLength = 0.5;
89
- this.suspensionStiffness = 100;
90
- this.suspensionDamping = 10;
91
- this.suspensionMaxForce = Infinity;
92
- this.radius = 0.3;
93
- this.friction = 1.2;
94
- this.steered = false;
95
- this.driven = false;
96
-
97
- // --- runtime ---
98
- this.inContact = false;
99
- this.suspensionLength = this.suspensionRestLength;
100
- this.compression = 0;
101
- /** World contact point. @type {Float64Array} */
102
- this.contactPoint = new Float64Array(3);
103
- /** World contact normal. @type {Float64Array} */
104
- this.contactNormal = Float64Array.from([0, 1, 0]);
105
- /** Body id of the surface under the wheel, or −1. */
106
- this.contactBodyId = -1;
107
- /** Current steering angle (rad), set via {@link RaycastVehicle#setSteering}. */
108
- this.steering = 0;
109
- /** Accumulated spin angle (rad) for rendering. */
110
- this.rotation = 0;
111
- /** Forward ground speed at the contact (m/s). */
112
- this.forwardSpeed = 0;
113
- /** Suspension force magnitude applied this frame (N). */
114
- this.suspensionForce = 0;
115
- }
116
- }
117
-
118
- export class RaycastVehicle {
119
- /**
120
- * @param {PhysicsSystem} system
121
- * @param {RigidBody} chassisBody the chassis rigid body (Dynamic)
122
- * @param {Transform} chassisTransform its world transform
123
- */
124
- constructor(system, chassisBody, chassisTransform) {
125
- this.system = system;
126
- this.chassisBody = chassisBody;
127
- this.chassisTransform = chassisTransform;
128
-
129
- /** @type {Wheel[]} */
130
- this.wheels = [];
131
-
132
- this.__driveForce = 0;
133
- this.__brakeForce = 0;
134
- this.__drivenCount = 0;
135
-
136
- this.__ray = new Ray3();
137
- this.__hit = new PhysicsSurfacePoint();
138
- // Exclude the chassis' own colliders from the suspension raycast.
139
- this.__filter = (entity, collider) => collider._bodyId !== this.chassisBody._bodyId;
140
-
141
- // Reused force / impulse / point payloads for the public API calls.
142
- this.__force = { x: 0, y: 0, z: 0 };
143
- this.__point = { x: 0, y: 0, z: 0 };
144
- }
145
-
146
- /**
147
- * Add a wheel. Returns the created {@link Wheel} (also pushed to
148
- * {@link wheels}); set any further config fields on it before stepping.
149
- *
150
- * @param {object} opts
151
- * @param {number[]} opts.localPosition mount point on the chassis, local.
152
- * @param {number[]} [opts.localSuspensionDir] unit "down", default (0,−1,0).
153
- * @param {number[]} [opts.localForward] unit rolling dir, default (0,0,1).
154
- * @param {number} [opts.suspensionRestLength]
155
- * @param {number} [opts.suspensionStiffness] N/m.
156
- * @param {number} [opts.suspensionDamping] N·s/m.
157
- * @param {number} [opts.suspensionMaxForce] N (clamp).
158
- * @param {number} [opts.radius]
159
- * @param {number} [opts.friction] tyre friction coefficient μ.
160
- * @param {boolean} [opts.steered]
161
- * @param {boolean} [opts.driven]
162
- * @returns {Wheel}
163
- */
164
- addWheel(opts) {
165
- const w = new Wheel();
166
- const p = opts.localPosition;
167
- w.localPosition[0] = p[0]; w.localPosition[1] = p[1]; w.localPosition[2] = p[2];
168
- if (opts.localSuspensionDir) {
169
- const d = opts.localSuspensionDir, L = v3len(d[0], d[1], d[2]) || 1;
170
- w.localSuspensionDir[0] = d[0] / L; w.localSuspensionDir[1] = d[1] / L; w.localSuspensionDir[2] = d[2] / L;
171
- }
172
- if (opts.localForward) {
173
- const f = opts.localForward, L = v3len(f[0], f[1], f[2]) || 1;
174
- w.localForward[0] = f[0] / L; w.localForward[1] = f[1] / L; w.localForward[2] = f[2] / L;
175
- }
176
- if (opts.suspensionRestLength !== undefined) w.suspensionRestLength = opts.suspensionRestLength;
177
- if (opts.suspensionStiffness !== undefined) w.suspensionStiffness = opts.suspensionStiffness;
178
- if (opts.suspensionDamping !== undefined) w.suspensionDamping = opts.suspensionDamping;
179
- if (opts.suspensionMaxForce !== undefined) w.suspensionMaxForce = opts.suspensionMaxForce;
180
- if (opts.radius !== undefined) w.radius = opts.radius;
181
- if (opts.friction !== undefined) w.friction = opts.friction;
182
- if (opts.steered !== undefined) w.steered = opts.steered;
183
- if (opts.driven !== undefined) w.driven = opts.driven;
184
- w.suspensionLength = w.suspensionRestLength;
185
- this.wheels.push(w);
186
- if (w.driven) this.__drivenCount++;
187
- return w;
188
- }
189
-
190
- /** Set the steering angle (rad) on every steered wheel. */
191
- setSteering(angle) {
192
- for (let i = 0; i < this.wheels.length; i++) {
193
- if (this.wheels[i].steered) this.wheels[i].steering = angle;
194
- }
195
- }
196
-
197
- /** Total engine drive force (N), split evenly across the driven wheels. */
198
- setDriveForce(force) { this.__driveForce = force; }
199
-
200
- /** Brake force (N) per wheel, opposing each wheel's forward motion. */
201
- setBrake(force) { this.__brakeForce = force; }
202
-
203
- /**
204
- * Step the vehicle: cast suspension rays, apply suspension + tyre forces.
205
- * Call once per frame **before** `PhysicsSystem.fixedUpdate(dt)` with the
206
- * same `dt`.
207
- * @param {number} dt
208
- */
209
- update(dt) {
210
- const rb = this.chassisBody;
211
- if (rb.kind !== BodyKind.Dynamic || dt <= 0) return;
212
- const tr = this.chassisTransform;
213
- const invMass = rb.mass > 0 ? 1 / rb.mass : 0;
214
- const q = tr.rotation;
215
- const px = tr.position.x, py = tr.position.y, pz = tr.position.z;
216
- const lv = rb.linearVelocity, av = rb.angularVelocity;
217
- const drivePer = this.__drivenCount > 0 ? this.__driveForce / this.__drivenCount : 0;
218
-
219
- for (let wi = 0; wi < this.wheels.length; wi++) {
220
- const w = this.wheels[wi];
221
-
222
- // World mount point and suspension axis.
223
- v3_quat3_apply(scratch_v, 0, w.localPosition[0], w.localPosition[1], w.localPosition[2], q[0], q[1], q[2], q[3]);
224
- const mx = px + scratch_v[0], my = py + scratch_v[1], mz = pz + scratch_v[2];
225
- v3_quat3_apply(scratch_v, 0, w.localSuspensionDir[0], w.localSuspensionDir[1], w.localSuspensionDir[2], q[0], q[1], q[2], q[3]);
226
- const sdx = scratch_v[0], sdy = scratch_v[1], sdz = scratch_v[2]; // suspension down (unit)
227
-
228
- const maxLen = w.suspensionRestLength + w.radius;
229
- this.__ray.setOrigin(mx, my, mz);
230
- this.__ray.setDirection(sdx, sdy, sdz);
231
- this.__ray.tMax = maxLen;
232
-
233
- const hit = this.system.raycast(this.__ray, this.__hit, this.__filter);
234
- if (!hit || this.__hit.t > maxLen) {
235
- // Airborne: suspension fully extended, no force.
236
- w.inContact = false;
237
- w.suspensionForce = 0;
238
- w.suspensionLength = w.suspensionRestLength;
239
- w.compression = 0;
240
- continue;
241
- }
242
-
243
- const t = this.__hit.t;
244
- w.inContact = true;
245
- w.contactBodyId = this.__hit.body_id;
246
- w.suspensionLength = t - w.radius;
247
- if (w.suspensionLength < 0) w.suspensionLength = 0;
248
- w.compression = w.suspensionRestLength - w.suspensionLength;
249
-
250
- // Contact point along the ray; normal from the hit.
251
- const cx = mx + sdx * t, cy = my + sdy * t, cz = mz + sdz * t;
252
- let nx = this.__hit.normal.x, ny = this.__hit.normal.y, nz = this.__hit.normal.z;
253
- const nlen = v3len(nx, ny, nz) || 1;
254
- nx /= nlen; ny /= nlen; nz /= nlen;
255
- w.contactPoint[0] = cx; w.contactPoint[1] = cy; w.contactPoint[2] = cz;
256
- w.contactNormal[0] = nx; w.contactNormal[1] = ny; w.contactNormal[2] = nz;
257
-
258
- // Velocity of the chassis at the contact point.
259
- const rx = cx - px, ry = cy - py, rz = cz - pz;
260
- const vptx = lv.x + av.y * rz - av.z * ry;
261
- const vpty = lv.y + av.z * rx - av.x * rz;
262
- const vptz = lv.z + av.x * ry - av.y * rx;
263
-
264
- // --- Suspension: spring + damper along the contact normal ---
265
- const projVel = vptx * nx + vpty * ny + vptz * nz; // closing speed along normal
266
- let susForce = w.suspensionStiffness * w.compression - w.suspensionDamping * projVel;
267
- if (susForce < 0) susForce = 0;
268
- else if (susForce > w.suspensionMaxForce) susForce = w.suspensionMaxForce;
269
- w.suspensionForce = susForce;
270
-
271
- if (susForce !== 0) {
272
- this.__force.x = nx * susForce; this.__force.y = ny * susForce; this.__force.z = nz * susForce;
273
- this.__point.x = cx; this.__point.y = cy; this.__point.z = cz;
274
- this.system.applyForceAt(rb, tr, this.__force, this.__point);
275
- }
276
-
277
- // --- Tyre friction: forward / side basis in the contact plane ---
278
- v3_quat3_apply(scratch_v, 0, w.localForward[0], w.localForward[1], w.localForward[2], q[0], q[1], q[2], q[3]);
279
- let fwx = scratch_v[0], fwy = scratch_v[1], fwz = scratch_v[2];
280
- if (w.steering !== 0) {
281
- // Steer about the suspension-up axis (−suspension dir).
282
- rotate_about(scratch_v, fwx, fwy, fwz, -sdx, -sdy, -sdz, w.steering);
283
- fwx = scratch_v[0]; fwy = scratch_v[1]; fwz = scratch_v[2];
284
- }
285
- // Project forward onto the contact plane, normalise.
286
- let fdotn = fwx * nx + fwy * ny + fwz * nz;
287
- fwx -= fdotn * nx; fwy -= fdotn * ny; fwz -= fdotn * nz;
288
- let fl = v3len(fwx, fwy, fwz);
289
- if (fl < 1e-6) continue; // degenerate (forward parallel to normal)
290
- fwx /= fl; fwy /= fl; fwz /= fl;
291
- // Side = normal × forward (already unit & in-plane).
292
- const sx = ny * fwz - nz * fwy;
293
- const sy = nz * fwx - nx * fwz;
294
- const sz = nx * fwy - ny * fwx;
295
-
296
- const fwdVel = vptx * fwx + vpty * fwy + vptz * fwz;
297
- const sideVel = vptx * sx + vpty * sy + vptz * sz;
298
- w.forwardSpeed = fwdVel;
299
-
300
- // Lateral grip: impulse that cancels the side velocity.
301
- const denomSide = effective_mass(rb, tr, invMass, rx, ry, rz, sx, sy, sz);
302
- let jSide = denomSide > 0 ? -sideVel / denomSide : 0;
303
-
304
- // Longitudinal: engine drive minus brake (brake never reverses).
305
- let jFwd = w.driven ? drivePer * dt : 0;
306
- if (this.__brakeForce > 0 && fwdVel !== 0) {
307
- const denomFwd = effective_mass(rb, tr, invMass, rx, ry, rz, fwx, fwy, fwz);
308
- const stopImpulse = denomFwd > 0 ? Math.abs(fwdVel) / denomFwd : 0;
309
- const brakeImpulse = Math.min(this.__brakeForce * dt, stopImpulse);
310
- jFwd -= Math.sign(fwdVel) * brakeImpulse;
311
- }
312
-
313
- // Friction circle: clamp the combined tyre impulse to μ·N·dt.
314
- const budget = w.friction * susForce * dt;
315
- const mag = Math.sqrt(jFwd * jFwd + jSide * jSide);
316
- if (mag > budget && mag > 0) {
317
- const scale = budget / mag;
318
- jFwd *= scale; jSide *= scale;
319
- }
320
-
321
- if (jFwd !== 0 || jSide !== 0) {
322
- this.__force.x = fwx * jFwd + sx * jSide;
323
- this.__force.y = fwy * jFwd + sy * jSide;
324
- this.__force.z = fwz * jFwd + sz * jSide;
325
- this.__point.x = cx; this.__point.y = cy; this.__point.z = cz;
326
- this.system.applyImpulseAt(rb, tr, this.__force, this.__point);
327
- }
328
-
329
- // Visual wheel spin from forward ground speed.
330
- if (w.radius > 0) w.rotation += (fwdVel / w.radius) * dt;
331
- }
332
- }
333
- }
1
+ import { Ray3 } from "../../../core/geom/3d/ray/Ray3.js";
2
+ import { PhysicsSurfacePoint } from "../queries/PhysicsSurfacePoint.js";
3
+ import { v3_quat3_apply } from "../../../core/geom/vec3/v3_quat3_apply.js";
4
+ import { v3_length } from "../../../core/geom/vec3/v3_length.js";
5
+ import { world_inverse_inertia_apply } from "../inertia/world_inverse_inertia.js";
6
+ import { BodyKind } from "../ecs/BodyKind.js";
7
+
8
+ /**
9
+ * # Raycast vehicle controller
10
+ *
11
+ * The vehicle model most games ship: a single rigid-body **chassis** plus a set
12
+ * of **raycast wheels**. Each wheel is not a body — it is a downward ray from a
13
+ * chassis-local mount point. Every frame, for each wheel, the controller:
14
+ *
15
+ * 1. casts the suspension ray and, on a ground hit, measures the spring
16
+ * compression;
17
+ * 2. applies a spring + damper **suspension force** along the contact normal
18
+ * (this is what holds the chassis up);
19
+ * 3. applies **tyre friction** at the contact patch a lateral grip impulse
20
+ * that resists sideways slide, plus a longitudinal drive / brake impulse,
21
+ * the two clamped together to a friction circle `μ·N`.
22
+ *
23
+ * Steering rotates the steered wheels' forward direction; a drive force is split
24
+ * across the driven wheels. It is a *controller on top of the public physics
25
+ * API* (`raycast` + `applyForceAt` + `applyImpulseAt`), not a new constraint —
26
+ * call {@link update} once per frame **before** `PhysicsSystem.fixedUpdate`, so
27
+ * the suspension force it accumulates is integrated by that step and the
28
+ * friction impulses act on the current velocity.
29
+ *
30
+ * The 6-DOF joint (spring linear + motor angular) is the alternative
31
+ * "simulated wheel" path; this raycast controller is the lighter, more robust
32
+ * default. Suspension-ray accuracy follows `PhysicsSystem.raycast`, which is
33
+ * narrowphase-exact for sphere / box / capsule / mesh / heightmap ground.
34
+ *
35
+ * @author Alex Goldring
36
+ * @copyright Company Named Limited (c) 2026
37
+ */
38
+
39
+ const scratch_v = new Float64Array(3);
40
+ const scratch_i = new Float64Array(3);
41
+
42
+ /**
43
+ * Effective mass denominator for a unit-direction velocity constraint at a
44
+ * body-relative point: `invM + (r×d)·Iw⁻¹·(r×d)`.
45
+ * @returns {number}
46
+ */
47
+ function effective_mass(rb, transform, invM, rx, ry, rz, dx, dy, dz) {
48
+ let k = invM;
49
+ const ii = rb.inverseInertiaLocal;
50
+ if (ii.x !== 0 || ii.y !== 0 || ii.z !== 0) {
51
+ const cx = ry * dz - rz * dy;
52
+ const cy = rz * dx - rx * dz;
53
+ const cz = rx * dy - ry * dx;
54
+ world_inverse_inertia_apply(scratch_i, 0, ii, transform.rotation, cx, cy, cz);
55
+ k += cx * scratch_i[0] + cy * scratch_i[1] + cz * scratch_i[2];
56
+ }
57
+ return k;
58
+ }
59
+
60
+ /** Rotate vector `(vx,vy,vz)` about unit axis `(ux,uy,uz)` by `angle`, into `out`. */
61
+ function rotate_about(out, vx, vy, vz, ux, uy, uz, angle) {
62
+ const c = Math.cos(angle), s = Math.sin(angle);
63
+ const dot = ux * vx + uy * vy + uz * vz;
64
+ // v·c + (k×v)·s + k·(k·v)·(1−c)
65
+ const kxx = uy * vz - uz * vy;
66
+ const kxy = uz * vx - ux * vz;
67
+ const kxz = ux * vy - uy * vx;
68
+ out[0] = vx * c + kxx * s + ux * dot * (1 - c);
69
+ out[1] = vy * c + kxy * s + uy * dot * (1 - c);
70
+ out[2] = vz * c + kxz * s + uz * dot * (1 - c);
71
+ }
72
+
73
+
74
+ /**
75
+ * One wheel's config + per-frame runtime state. The runtime fields
76
+ * (`inContact`, `compression`, contact point/normal, `rotation`, …) are read by
77
+ * rendering / gameplay; do not write them.
78
+ */
79
+ export class Wheel {
80
+ constructor() {
81
+ // --- config ---
82
+ /** Mount point on the chassis, local. @type {Float64Array} */
83
+ this.localPosition = new Float64Array(3);
84
+ /** Suspension direction (local, unit, points "down"). @type {Float64Array} */
85
+ this.localSuspensionDir = Float64Array.from([0, -1, 0]);
86
+ /** Forward / rolling direction (local, unit). @type {Float64Array} */
87
+ this.localForward = Float64Array.from([0, 0, 1]);
88
+ this.suspensionRestLength = 0.5;
89
+ this.suspensionStiffness = 100;
90
+ this.suspensionDamping = 10;
91
+ this.suspensionMaxForce = Infinity;
92
+ this.radius = 0.3;
93
+ this.friction = 1.2;
94
+ this.steered = false;
95
+ this.driven = false;
96
+
97
+ // --- runtime ---
98
+ this.inContact = false;
99
+ this.suspensionLength = this.suspensionRestLength;
100
+ this.compression = 0;
101
+ /** World contact point. @type {Float64Array} */
102
+ this.contactPoint = new Float64Array(3);
103
+ /** World contact normal. @type {Float64Array} */
104
+ this.contactNormal = Float64Array.from([0, 1, 0]);
105
+ /** Body id of the surface under the wheel, or −1. */
106
+ this.contactBodyId = -1;
107
+ /** Current steering angle (rad), set via {@link RaycastVehicle#setSteering}. */
108
+ this.steering = 0;
109
+ /** Accumulated spin angle (rad) for rendering. */
110
+ this.rotation = 0;
111
+ /** Forward ground speed at the contact (m/s). */
112
+ this.forwardSpeed = 0;
113
+ /** Suspension force magnitude applied this frame (N). */
114
+ this.suspensionForce = 0;
115
+ }
116
+ }
117
+
118
+ export class RaycastVehicle {
119
+ /**
120
+ * @param {PhysicsSystem} system
121
+ * @param {RigidBody} chassisBody the chassis rigid body (Dynamic)
122
+ * @param {Transform} chassisTransform its world transform
123
+ */
124
+ constructor(system, chassisBody, chassisTransform) {
125
+ this.system = system;
126
+ this.chassisBody = chassisBody;
127
+ this.chassisTransform = chassisTransform;
128
+
129
+ /** @type {Wheel[]} */
130
+ this.wheels = [];
131
+
132
+ this.__driveForce = 0;
133
+ this.__brakeForce = 0;
134
+ this.__drivenCount = 0;
135
+
136
+ this.__ray = new Ray3();
137
+ this.__hit = new PhysicsSurfacePoint();
138
+ // Exclude the chassis' own colliders from the suspension raycast.
139
+ this.__filter = (entity, collider) => collider._bodyId !== this.chassisBody._bodyId;
140
+
141
+ // Reused force / impulse / point payloads for the public API calls.
142
+ this.__force = { x: 0, y: 0, z: 0 };
143
+ this.__point = { x: 0, y: 0, z: 0 };
144
+ }
145
+
146
+ /**
147
+ * Add a wheel. Returns the created {@link Wheel} (also pushed to
148
+ * {@link wheels}); set any further config fields on it before stepping.
149
+ *
150
+ * @param {object} opts
151
+ * @param {number[]} opts.localPosition mount point on the chassis, local.
152
+ * @param {number[]} [opts.localSuspensionDir] unit "down", default (0,−1,0).
153
+ * @param {number[]} [opts.localForward] unit rolling dir, default (0,0,1).
154
+ * @param {number} [opts.suspensionRestLength]
155
+ * @param {number} [opts.suspensionStiffness] N/m.
156
+ * @param {number} [opts.suspensionDamping] N·s/m.
157
+ * @param {number} [opts.suspensionMaxForce] N (clamp).
158
+ * @param {number} [opts.radius]
159
+ * @param {number} [opts.friction] tyre friction coefficient μ.
160
+ * @param {boolean} [opts.steered]
161
+ * @param {boolean} [opts.driven]
162
+ * @returns {Wheel}
163
+ */
164
+ addWheel(opts) {
165
+ const w = new Wheel();
166
+ const p = opts.localPosition;
167
+ w.localPosition[0] = p[0]; w.localPosition[1] = p[1]; w.localPosition[2] = p[2];
168
+ if (opts.localSuspensionDir) {
169
+ const d = opts.localSuspensionDir, L = v3_length(d[0], d[1], d[2]) || 1;
170
+ w.localSuspensionDir[0] = d[0] / L; w.localSuspensionDir[1] = d[1] / L; w.localSuspensionDir[2] = d[2] / L;
171
+ }
172
+ if (opts.localForward) {
173
+ const f = opts.localForward, L = v3_length(f[0], f[1], f[2]) || 1;
174
+ w.localForward[0] = f[0] / L; w.localForward[1] = f[1] / L; w.localForward[2] = f[2] / L;
175
+ }
176
+ if (opts.suspensionRestLength !== undefined) w.suspensionRestLength = opts.suspensionRestLength;
177
+ if (opts.suspensionStiffness !== undefined) w.suspensionStiffness = opts.suspensionStiffness;
178
+ if (opts.suspensionDamping !== undefined) w.suspensionDamping = opts.suspensionDamping;
179
+ if (opts.suspensionMaxForce !== undefined) w.suspensionMaxForce = opts.suspensionMaxForce;
180
+ if (opts.radius !== undefined) w.radius = opts.radius;
181
+ if (opts.friction !== undefined) w.friction = opts.friction;
182
+ if (opts.steered !== undefined) w.steered = opts.steered;
183
+ if (opts.driven !== undefined) w.driven = opts.driven;
184
+ w.suspensionLength = w.suspensionRestLength;
185
+ this.wheels.push(w);
186
+ if (w.driven) this.__drivenCount++;
187
+ return w;
188
+ }
189
+
190
+ /** Set the steering angle (rad) on every steered wheel. */
191
+ setSteering(angle) {
192
+ for (let i = 0; i < this.wheels.length; i++) {
193
+ if (this.wheels[i].steered) this.wheels[i].steering = angle;
194
+ }
195
+ }
196
+
197
+ /** Total engine drive force (N), split evenly across the driven wheels. */
198
+ setDriveForce(force) { this.__driveForce = force; }
199
+
200
+ /** Brake force (N) per wheel, opposing each wheel's forward motion. */
201
+ setBrake(force) { this.__brakeForce = force; }
202
+
203
+ /**
204
+ * Step the vehicle: cast suspension rays, apply suspension + tyre forces.
205
+ * Call once per frame **before** `PhysicsSystem.fixedUpdate(dt)` with the
206
+ * same `dt`.
207
+ * @param {number} dt
208
+ */
209
+ update(dt) {
210
+ const rb = this.chassisBody;
211
+ if (rb.kind !== BodyKind.Dynamic || dt <= 0) return;
212
+ const tr = this.chassisTransform;
213
+ const invMass = rb.mass > 0 ? 1 / rb.mass : 0;
214
+ const q = tr.rotation;
215
+ const px = tr.position.x, py = tr.position.y, pz = tr.position.z;
216
+ const lv = rb.linearVelocity, av = rb.angularVelocity;
217
+ const drivePer = this.__drivenCount > 0 ? this.__driveForce / this.__drivenCount : 0;
218
+
219
+ for (let wi = 0; wi < this.wheels.length; wi++) {
220
+ const w = this.wheels[wi];
221
+
222
+ // World mount point and suspension axis.
223
+ v3_quat3_apply(scratch_v, 0, w.localPosition[0], w.localPosition[1], w.localPosition[2], q[0], q[1], q[2], q[3]);
224
+ const mx = px + scratch_v[0], my = py + scratch_v[1], mz = pz + scratch_v[2];
225
+ v3_quat3_apply(scratch_v, 0, w.localSuspensionDir[0], w.localSuspensionDir[1], w.localSuspensionDir[2], q[0], q[1], q[2], q[3]);
226
+ const sdx = scratch_v[0], sdy = scratch_v[1], sdz = scratch_v[2]; // suspension down (unit)
227
+
228
+ const maxLen = w.suspensionRestLength + w.radius;
229
+ this.__ray.setOrigin(mx, my, mz);
230
+ this.__ray.setDirection(sdx, sdy, sdz);
231
+ this.__ray.tMax = maxLen;
232
+
233
+ const hit = this.system.raycast(this.__ray, this.__hit, this.__filter);
234
+ // `raycast` already bounds the hit to ray.tMax (= maxLen) and only
235
+ // returns true for t strictly < maxLen, so `__hit.t > maxLen` was dead.
236
+ if (!hit) {
237
+ // Airborne: suspension fully extended, no force. Reset ALL the
238
+ // contact-frame fields — including contactBodyId and forwardSpeed,
239
+ // which otherwise retain stale last-grounded-frame values.
240
+ w.inContact = false;
241
+ w.suspensionForce = 0;
242
+ w.suspensionLength = w.suspensionRestLength;
243
+ w.compression = 0;
244
+ w.contactBodyId = -1; // documented no-contact sentinel
245
+ w.forwardSpeed = 0;
246
+ continue;
247
+ }
248
+
249
+ const t = this.__hit.t;
250
+ w.inContact = true;
251
+ w.contactBodyId = this.__hit.body_id;
252
+ w.suspensionLength = t - w.radius;
253
+ if (w.suspensionLength < 0) w.suspensionLength = 0;
254
+ w.compression = w.suspensionRestLength - w.suspensionLength;
255
+
256
+ // Contact point along the ray; normal from the hit.
257
+ const cx = mx + sdx * t, cy = my + sdy * t, cz = mz + sdz * t;
258
+ let nx = this.__hit.normal.x, ny = this.__hit.normal.y, nz = this.__hit.normal.z;
259
+ const nlen = v3_length(nx, ny, nz) || 1;
260
+ nx /= nlen; ny /= nlen; nz /= nlen;
261
+ w.contactPoint[0] = cx; w.contactPoint[1] = cy; w.contactPoint[2] = cz;
262
+ w.contactNormal[0] = nx; w.contactNormal[1] = ny; w.contactNormal[2] = nz;
263
+
264
+ // Velocity of the chassis at the contact point.
265
+ const rx = cx - px, ry = cy - py, rz = cz - pz;
266
+ const vptx = lv.x + av.y * rz - av.z * ry;
267
+ const vpty = lv.y + av.z * rx - av.x * rz;
268
+ const vptz = lv.z + av.x * ry - av.y * rx;
269
+
270
+ // --- Suspension: spring + damper along the contact normal ---
271
+ const projVel = vptx * nx + vpty * ny + vptz * nz; // closing speed along normal
272
+ let susForce = w.suspensionStiffness * w.compression - w.suspensionDamping * projVel;
273
+ if (susForce < 0) susForce = 0;
274
+ else if (susForce > w.suspensionMaxForce) susForce = w.suspensionMaxForce;
275
+ w.suspensionForce = susForce;
276
+
277
+ if (susForce !== 0) {
278
+ this.__force.x = nx * susForce; this.__force.y = ny * susForce; this.__force.z = nz * susForce;
279
+ this.__point.x = cx; this.__point.y = cy; this.__point.z = cz;
280
+ this.system.applyForceAt(rb, tr, this.__force, this.__point);
281
+ }
282
+
283
+ // --- Tyre friction: forward / side basis in the contact plane ---
284
+ v3_quat3_apply(scratch_v, 0, w.localForward[0], w.localForward[1], w.localForward[2], q[0], q[1], q[2], q[3]);
285
+ let fwx = scratch_v[0], fwy = scratch_v[1], fwz = scratch_v[2];
286
+ if (w.steering !== 0) {
287
+ // Steer about the suspension-up axis (−suspension dir).
288
+ rotate_about(scratch_v, fwx, fwy, fwz, -sdx, -sdy, -sdz, w.steering);
289
+ fwx = scratch_v[0]; fwy = scratch_v[1]; fwz = scratch_v[2];
290
+ }
291
+ // Project forward onto the contact plane, normalise.
292
+ let fdotn = fwx * nx + fwy * ny + fwz * nz;
293
+ fwx -= fdotn * nx; fwy -= fdotn * ny; fwz -= fdotn * nz;
294
+ let fl = v3_length(fwx, fwy, fwz);
295
+ if (fl < 1e-6) continue; // degenerate (forward parallel to normal)
296
+ fwx /= fl; fwy /= fl; fwz /= fl;
297
+ // Side = normal × forward (already unit & in-plane).
298
+ const sx = ny * fwz - nz * fwy;
299
+ const sy = nz * fwx - nx * fwz;
300
+ const sz = nx * fwy - ny * fwx;
301
+
302
+ const fwdVel = vptx * fwx + vpty * fwy + vptz * fwz;
303
+ const sideVel = vptx * sx + vpty * sy + vptz * sz;
304
+ w.forwardSpeed = fwdVel;
305
+
306
+ // Lateral grip: impulse that cancels the side velocity.
307
+ const denomSide = effective_mass(rb, tr, invMass, rx, ry, rz, sx, sy, sz);
308
+ let jSide = denomSide > 0 ? -sideVel / denomSide : 0;
309
+
310
+ // Longitudinal: engine drive minus brake (brake never reverses).
311
+ let jFwd = w.driven ? drivePer * dt : 0;
312
+ if (this.__brakeForce > 0 && fwdVel !== 0) {
313
+ const denomFwd = effective_mass(rb, tr, invMass, rx, ry, rz, fwx, fwy, fwz);
314
+ const stopImpulse = denomFwd > 0 ? Math.abs(fwdVel) / denomFwd : 0;
315
+ const brakeImpulse = Math.min(this.__brakeForce * dt, stopImpulse);
316
+ jFwd -= Math.sign(fwdVel) * brakeImpulse;
317
+ }
318
+
319
+ // Friction circle: clamp the combined tyre impulse to μ·N·dt.
320
+ const budget = w.friction * susForce * dt;
321
+ const mag = Math.sqrt(jFwd * jFwd + jSide * jSide);
322
+ if (mag > budget && mag > 0) {
323
+ const scale = budget / mag;
324
+ jFwd *= scale; jSide *= scale;
325
+ }
326
+
327
+ if (jFwd !== 0 || jSide !== 0) {
328
+ this.__force.x = fwx * jFwd + sx * jSide;
329
+ this.__force.y = fwy * jFwd + sy * jSide;
330
+ this.__force.z = fwz * jFwd + sz * jSide;
331
+ this.__point.x = cx; this.__point.y = cy; this.__point.z = cz;
332
+ this.system.applyImpulseAt(rb, tr, this.__force, this.__point);
333
+ }
334
+
335
+ // Visual wheel spin from forward ground speed.
336
+ if (w.radius > 0) w.rotation += (fwdVel / w.radius) * dt;
337
+ }
338
+ }
339
+ }