reze-engine 0.14.0 → 0.15.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 (93) hide show
  1. package/README.md +81 -108
  2. package/dist/engine.d.ts +1 -7
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +4 -7
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/physics/body.d.ts +30 -0
  9. package/dist/physics/body.d.ts.map +1 -0
  10. package/dist/physics/body.js +215 -0
  11. package/dist/physics/constraint.d.ts +17 -0
  12. package/dist/physics/constraint.d.ts.map +1 -0
  13. package/dist/physics/constraint.js +102 -0
  14. package/dist/physics/contact.d.ts +32 -0
  15. package/dist/physics/contact.d.ts.map +1 -0
  16. package/dist/physics/contact.js +728 -0
  17. package/dist/physics/index.d.ts +4 -0
  18. package/dist/physics/index.d.ts.map +1 -0
  19. package/dist/physics/index.js +3 -0
  20. package/dist/physics/physics.d.ts +31 -0
  21. package/dist/physics/physics.d.ts.map +1 -0
  22. package/dist/physics/physics.js +211 -0
  23. package/dist/physics/solver.d.ts +5 -0
  24. package/dist/physics/solver.d.ts.map +1 -0
  25. package/dist/physics/solver.js +416 -0
  26. package/dist/physics/types.d.ts +46 -0
  27. package/dist/physics/types.d.ts.map +1 -0
  28. package/dist/physics/types.js +12 -0
  29. package/dist/physics/world.d.ts +12 -0
  30. package/dist/physics/world.d.ts.map +1 -0
  31. package/dist/physics/world.js +146 -0
  32. package/dist/physics-debug.d.ts +30 -0
  33. package/dist/physics-debug.d.ts.map +1 -0
  34. package/dist/physics-debug.js +526 -0
  35. package/dist/shaders/materials/hair.d.ts +1 -1
  36. package/dist/shaders/materials/hair.d.ts.map +1 -1
  37. package/dist/shaders/materials/hair.js +2 -2
  38. package/dist/shaders/passes/physics-debug.d.ts +2 -0
  39. package/dist/shaders/passes/physics-debug.d.ts.map +1 -0
  40. package/dist/shaders/passes/physics-debug.js +69 -0
  41. package/package.json +3 -6
  42. package/src/engine.ts +5 -9
  43. package/src/index.ts +1 -1
  44. package/src/physics/body.ts +305 -0
  45. package/src/physics/constraint.ts +151 -0
  46. package/src/physics/contact.ts +983 -0
  47. package/src/physics/index.ts +8 -0
  48. package/src/physics/physics.ts +255 -0
  49. package/src/physics/solver.ts +430 -0
  50. package/src/physics/types.ts +50 -0
  51. package/src/physics/world.ts +152 -0
  52. package/src/shaders/materials/hair.ts +2 -2
  53. package/dist/ammo-loader.d.ts +0 -3
  54. package/dist/ammo-loader.d.ts.map +0 -1
  55. package/dist/ammo-loader.js +0 -26
  56. package/dist/physics.d.ts +0 -86
  57. package/dist/physics.d.ts.map +0 -1
  58. package/dist/physics.js +0 -527
  59. package/dist/shaders/body.d.ts +0 -2
  60. package/dist/shaders/body.d.ts.map +0 -1
  61. package/dist/shaders/body.js +0 -199
  62. package/dist/shaders/classify.d.ts +0 -4
  63. package/dist/shaders/classify.d.ts.map +0 -1
  64. package/dist/shaders/classify.js +0 -12
  65. package/dist/shaders/cloth_rough.d.ts +0 -2
  66. package/dist/shaders/cloth_rough.d.ts.map +0 -1
  67. package/dist/shaders/cloth_rough.js +0 -178
  68. package/dist/shaders/cloth_smooth.d.ts +0 -2
  69. package/dist/shaders/cloth_smooth.d.ts.map +0 -1
  70. package/dist/shaders/cloth_smooth.js +0 -174
  71. package/dist/shaders/default.d.ts +0 -2
  72. package/dist/shaders/default.d.ts.map +0 -1
  73. package/dist/shaders/default.js +0 -171
  74. package/dist/shaders/eye.d.ts +0 -2
  75. package/dist/shaders/eye.d.ts.map +0 -1
  76. package/dist/shaders/eye.js +0 -146
  77. package/dist/shaders/face.d.ts +0 -2
  78. package/dist/shaders/face.d.ts.map +0 -1
  79. package/dist/shaders/face.js +0 -199
  80. package/dist/shaders/hair.d.ts +0 -2
  81. package/dist/shaders/hair.d.ts.map +0 -1
  82. package/dist/shaders/hair.js +0 -176
  83. package/dist/shaders/metal.d.ts +0 -2
  84. package/dist/shaders/metal.d.ts.map +0 -1
  85. package/dist/shaders/metal.js +0 -174
  86. package/dist/shaders/nodes.d.ts +0 -2
  87. package/dist/shaders/nodes.d.ts.map +0 -1
  88. package/dist/shaders/nodes.js +0 -456
  89. package/dist/shaders/stockings.d.ts +0 -2
  90. package/dist/shaders/stockings.d.ts.map +0 -1
  91. package/dist/shaders/stockings.js +0 -244
  92. package/src/ammo-loader.ts +0 -31
  93. package/src/physics.ts +0 -706
@@ -0,0 +1,728 @@
1
+ // Narrowphase contact generation for sphere/box/capsule pairs.
2
+ //
3
+ // Contact convention: `normal` points from body A toward body B, so a
4
+ // positive normal impulse pushes B away from A. `rA` / `rB` are world-space
5
+ // lever arms from each CG to the contact point. Depth is positive when
6
+ // shapes overlap, ≤ 0 for speculative contacts inside the margin band.
7
+ // Box-box not implemented (PMX rigs rarely use it and it needs SAT + clipping).
8
+ import { RigidbodyShape } from "./types";
9
+ // Speculative contact range. Depth is reported relative to the un-inflated
10
+ // surface, so values 0 ≥ depth ≥ −CONTACT_MARGIN cover the "near touch but
11
+ // not overlapping yet" case. The push-only impulse clamp keeps these inert
12
+ // until actual overlap, but they prevent fast bodies from crossing a thin
13
+ // surface in one substep without ever generating a contact.
14
+ export const CONTACT_MARGIN = 0.04;
15
+ function makeContact() {
16
+ return {
17
+ bodyA: 0,
18
+ bodyB: 0,
19
+ rAx: 0,
20
+ rAy: 0,
21
+ rAz: 0,
22
+ rBx: 0,
23
+ rBy: 0,
24
+ rBz: 0,
25
+ nx: 0,
26
+ ny: 0,
27
+ nz: 0,
28
+ depth: 0,
29
+ friction: 0,
30
+ restitution: 0,
31
+ appliedNormalImpulse: 0,
32
+ appliedFrictionImpulse1: 0,
33
+ appliedFrictionImpulse2: 0,
34
+ };
35
+ }
36
+ // Pool of reusable Contact objects.
37
+ export class ContactPool {
38
+ constructor() {
39
+ this.pool = [];
40
+ this.count = 0;
41
+ }
42
+ acquire() {
43
+ if (this.count < this.pool.length) {
44
+ const c = this.pool[this.count];
45
+ c.appliedNormalImpulse = 0;
46
+ c.appliedFrictionImpulse1 = 0;
47
+ c.appliedFrictionImpulse2 = 0;
48
+ this.count++;
49
+ return c;
50
+ }
51
+ const c = makeContact();
52
+ this.pool.push(c);
53
+ this.count++;
54
+ return c;
55
+ }
56
+ reset() {
57
+ this.count = 0;
58
+ }
59
+ get(i) {
60
+ return this.pool[i];
61
+ }
62
+ }
63
+ // Geometric mean for friction, arithmetic for restitution.
64
+ function combineMaterials(store, a, b, out) {
65
+ out.friction = Math.sqrt(store.friction[a] * store.friction[b]);
66
+ out.restitution = (store.restitution[a] + store.restitution[b]) * 0.5;
67
+ }
68
+ // --- AABB overlap (broadphase reuses this) ---------------------------------
69
+ export function aabbOverlap(store, a, b) {
70
+ const a3 = a * 3, b3 = b * 3;
71
+ const minA = store.aabbMin, maxA = store.aabbMax;
72
+ return (minA[a3 + 0] <= maxA[b3 + 0] &&
73
+ maxA[a3 + 0] >= minA[b3 + 0] &&
74
+ minA[a3 + 1] <= maxA[b3 + 1] &&
75
+ maxA[a3 + 1] >= minA[b3 + 1] &&
76
+ minA[a3 + 2] <= maxA[b3 + 2] &&
77
+ maxA[a3 + 2] >= minA[b3 + 2]);
78
+ }
79
+ // --- Sphere–sphere ---------------------------------------------------------
80
+ function detectSphereSphere(store, a, b, pool) {
81
+ const ai = a * 3, bi = b * 3;
82
+ const pos = store.positions, sz = store.size;
83
+ const dx = pos[bi + 0] - pos[ai + 0];
84
+ const dy = pos[bi + 1] - pos[ai + 1];
85
+ const dz = pos[bi + 2] - pos[ai + 2];
86
+ const rA = sz[ai + 0];
87
+ const rB = sz[bi + 0];
88
+ const sumR = rA + rB;
89
+ const sumExt = sumR + CONTACT_MARGIN;
90
+ const d2 = dx * dx + dy * dy + dz * dz;
91
+ if (d2 > sumExt * sumExt)
92
+ return;
93
+ const d = Math.sqrt(d2);
94
+ let nx, ny, nz;
95
+ if (d > 1e-6) {
96
+ nx = dx / d;
97
+ ny = dy / d;
98
+ nz = dz / d;
99
+ }
100
+ else {
101
+ nx = 0;
102
+ ny = 1;
103
+ nz = 0;
104
+ } // arbitrary axis when fully co-located
105
+ const c = pool.acquire();
106
+ c.bodyA = a;
107
+ c.bodyB = b;
108
+ c.nx = nx;
109
+ c.ny = ny;
110
+ c.nz = nz;
111
+ c.depth = sumR - d; // signed: > 0 overlapping, ≤ 0 within margin
112
+ c.rAx = nx * rA;
113
+ c.rAy = ny * rA;
114
+ c.rAz = nz * rA;
115
+ c.rBx = -nx * rB;
116
+ c.rBy = -ny * rB;
117
+ c.rBz = -nz * rB;
118
+ combineMaterials(store, a, b, c);
119
+ }
120
+ // --- Sphere–capsule helper -------------------------------------------------
121
+ // Returns closest point on capsule's line segment (centered at cBody, axis=R·ŷ,
122
+ // half-height halfH) to the sphere center sx,sy,sz. Out is (cx,cy,cz).
123
+ function closestPointOnCapsuleSegment(cx, cy, cz, ax, ay, az, halfH, sx, sy, sz, out) {
124
+ const dx = sx - cx, dy = sy - cy, dz = sz - cz;
125
+ let t = dx * ax + dy * ay + dz * az;
126
+ if (t > halfH)
127
+ t = halfH;
128
+ else if (t < -halfH)
129
+ t = -halfH;
130
+ out[0] = cx + ax * t;
131
+ out[1] = cy + ay * t;
132
+ out[2] = cz + az * t;
133
+ }
134
+ const _capPoint = new Float32Array(3);
135
+ const _capPointB = new Float32Array(3);
136
+ function capsuleAxis(store, i, out) {
137
+ const i4 = i * 4;
138
+ const qx = store.orientations[i4 + 0];
139
+ const qy = store.orientations[i4 + 1];
140
+ const qz = store.orientations[i4 + 2];
141
+ const qw = store.orientations[i4 + 3];
142
+ // R · (0,1,0)
143
+ out[0] = 2 * (qx * qy - qw * qz);
144
+ out[1] = 1 - 2 * (qx * qx + qz * qz);
145
+ out[2] = 2 * (qy * qz + qw * qx);
146
+ }
147
+ // --- Sphere–capsule (sphere = a, capsule = b) ------------------------------
148
+ function detectSphereCapsule(store, a, b, pool) {
149
+ const pos = store.positions, sz = store.size;
150
+ const ai = a * 3, bi = b * 3;
151
+ const sx = pos[ai + 0], sy = pos[ai + 1], sz_ = pos[ai + 2];
152
+ const cx = pos[bi + 0], cy = pos[bi + 1], cz = pos[bi + 2];
153
+ const rA = sz[ai + 0];
154
+ const rB = sz[bi + 0];
155
+ const halfH = sz[bi + 1] * 0.5;
156
+ const axis = _capPoint;
157
+ capsuleAxis(store, b, axis);
158
+ const closest = _capPointB;
159
+ closestPointOnCapsuleSegment(cx, cy, cz, axis[0], axis[1], axis[2], halfH, sx, sy, sz_, closest);
160
+ const dx = closest[0] - sx;
161
+ const dy = closest[1] - sy;
162
+ const dz = closest[2] - sz_;
163
+ const sumR = rA + rB;
164
+ const sumExt = sumR + CONTACT_MARGIN;
165
+ const d2 = dx * dx + dy * dy + dz * dz;
166
+ if (d2 > sumExt * sumExt)
167
+ return;
168
+ const d = Math.sqrt(d2);
169
+ let nx, ny, nz;
170
+ if (d > 1e-6) {
171
+ nx = dx / d;
172
+ ny = dy / d;
173
+ nz = dz / d;
174
+ }
175
+ else {
176
+ nx = 0;
177
+ ny = 1;
178
+ nz = 0;
179
+ }
180
+ const c = pool.acquire();
181
+ c.bodyA = a;
182
+ c.bodyB = b;
183
+ c.nx = nx;
184
+ c.ny = ny;
185
+ c.nz = nz;
186
+ c.depth = sumR - d;
187
+ // Contact point on A's surface: sphere center + n * rA. Lever arm rA = that
188
+ // offset since A's CG = sphere center.
189
+ c.rAx = nx * rA;
190
+ c.rAy = ny * rA;
191
+ c.rAz = nz * rA;
192
+ // Contact point on B's surface: closest_on_segment − n * rB, lever from B's CG.
193
+ c.rBx = closest[0] - nx * rB - cx;
194
+ c.rBy = closest[1] - ny * rB - cy;
195
+ c.rBz = closest[2] - nz * rB - cz;
196
+ combineMaterials(store, a, b, c);
197
+ }
198
+ // --- Capsule–capsule -------------------------------------------------------
199
+ const _cpA = new Float32Array(3);
200
+ const _cpB = new Float32Array(3);
201
+ // Closest pair on two segments. Adapted from Real-Time Collision Detection §5.1.9.
202
+ function closestPointsTwoSegments(p1x, p1y, p1z, q1x, q1y, q1z, p2x, p2y, p2z, q2x, q2y, q2z, outA, outB) {
203
+ const d1x = q1x - p1x, d1y = q1y - p1y, d1z = q1z - p1z;
204
+ const d2x = q2x - p2x, d2y = q2y - p2y, d2z = q2z - p2z;
205
+ const rx = p1x - p2x, ry = p1y - p2y, rz = p1z - p2z;
206
+ const a = d1x * d1x + d1y * d1y + d1z * d1z;
207
+ const e = d2x * d2x + d2y * d2y + d2z * d2z;
208
+ const f = d2x * rx + d2y * ry + d2z * rz;
209
+ let s = 0, t = 0;
210
+ const EPS = 1e-8;
211
+ if (a <= EPS && e <= EPS) {
212
+ outA[0] = p1x;
213
+ outA[1] = p1y;
214
+ outA[2] = p1z;
215
+ outB[0] = p2x;
216
+ outB[1] = p2y;
217
+ outB[2] = p2z;
218
+ return;
219
+ }
220
+ if (a <= EPS) {
221
+ s = 0;
222
+ t = clamp01(f / e);
223
+ }
224
+ else {
225
+ const c = d1x * rx + d1y * ry + d1z * rz;
226
+ if (e <= EPS) {
227
+ t = 0;
228
+ s = clamp01(-c / a);
229
+ }
230
+ else {
231
+ const b = d1x * d2x + d1y * d2y + d1z * d2z;
232
+ const denom = a * e - b * b;
233
+ if (denom !== 0)
234
+ s = clamp01((b * f - c * e) / denom);
235
+ t = (b * s + f) / e;
236
+ if (t < 0) {
237
+ t = 0;
238
+ s = clamp01(-c / a);
239
+ }
240
+ else if (t > 1) {
241
+ t = 1;
242
+ s = clamp01((b - c) / a);
243
+ }
244
+ }
245
+ }
246
+ outA[0] = p1x + d1x * s;
247
+ outA[1] = p1y + d1y * s;
248
+ outA[2] = p1z + d1z * s;
249
+ outB[0] = p2x + d2x * t;
250
+ outB[1] = p2y + d2y * t;
251
+ outB[2] = p2z + d2z * t;
252
+ }
253
+ function clamp01(x) {
254
+ return x < 0 ? 0 : x > 1 ? 1 : x;
255
+ }
256
+ // Closest point on segment p1→q1 to a free point (sx,sy,sz). Out gets the
257
+ // projected point clamped to the segment.
258
+ function closestPointOnSegment(p1x, p1y, p1z, q1x, q1y, q1z, sx, sy, sz, out) {
259
+ const dx = q1x - p1x, dy = q1y - p1y, dz = q1z - p1z;
260
+ const segLen2 = dx * dx + dy * dy + dz * dz;
261
+ let t = 0;
262
+ if (segLen2 > 1e-8) {
263
+ t = ((sx - p1x) * dx + (sy - p1y) * dy + (sz - p1z) * dz) / segLen2;
264
+ if (t < 0)
265
+ t = 0;
266
+ else if (t > 1)
267
+ t = 1;
268
+ }
269
+ out[0] = p1x + dx * t;
270
+ out[1] = p1y + dy * t;
271
+ out[2] = p1z + dz * t;
272
+ }
273
+ // Emit one capsule-vs-capsule contact given a pair of points (pA on A's
274
+ // segment, pB on B's segment). Skips silently if outside speculative range.
275
+ function emitCapsuleContact(store, a, b, pool, pAx, pAy, pAz, pBx, pBy, pBz, rA, rB, sumR, sumExt, cAx, cAy, cAz, cBx, cBy, cBz) {
276
+ const dx = pBx - pAx, dy = pBy - pAy, dz = pBz - pAz;
277
+ const d2 = dx * dx + dy * dy + dz * dz;
278
+ if (d2 > sumExt * sumExt)
279
+ return;
280
+ const d = Math.sqrt(d2);
281
+ let nx, ny, nz;
282
+ if (d > 1e-6) {
283
+ nx = dx / d;
284
+ ny = dy / d;
285
+ nz = dz / d;
286
+ }
287
+ else {
288
+ nx = 0;
289
+ ny = 1;
290
+ nz = 0;
291
+ }
292
+ const c = pool.acquire();
293
+ c.bodyA = a;
294
+ c.bodyB = b;
295
+ c.nx = nx;
296
+ c.ny = ny;
297
+ c.nz = nz;
298
+ c.depth = sumR - d;
299
+ c.rAx = pAx + nx * rA - cAx;
300
+ c.rAy = pAy + ny * rA - cAy;
301
+ c.rAz = pAz + nz * rA - cAz;
302
+ c.rBx = pBx - nx * rB - cBx;
303
+ c.rBy = pBy - ny * rB - cBy;
304
+ c.rBz = pBz - nz * rB - cBz;
305
+ combineMaterials(store, a, b, c);
306
+ }
307
+ function detectCapsuleCapsule(store, a, b, pool) {
308
+ const pos = store.positions, sz = store.size;
309
+ const ai = a * 3, bi = b * 3;
310
+ const cAx = pos[ai + 0], cAy = pos[ai + 1], cAz = pos[ai + 2];
311
+ const cBx = pos[bi + 0], cBy = pos[bi + 1], cBz = pos[bi + 2];
312
+ const rA = sz[ai + 0], hA = sz[ai + 1] * 0.5;
313
+ const rB = sz[bi + 0], hB = sz[bi + 1] * 0.5;
314
+ const aAx = _capPoint;
315
+ const aBx = _capPointB;
316
+ capsuleAxis(store, a, aAx);
317
+ capsuleAxis(store, b, aBx);
318
+ const p1x = cAx - aAx[0] * hA, p1y = cAy - aAx[1] * hA, p1z = cAz - aAx[2] * hA;
319
+ const q1x = cAx + aAx[0] * hA, q1y = cAy + aAx[1] * hA, q1z = cAz + aAx[2] * hA;
320
+ const p2x = cBx - aBx[0] * hB, p2y = cBy - aBx[1] * hB, p2z = cBz - aBx[2] * hB;
321
+ const q2x = cBx + aBx[0] * hB, q2y = cBy + aBx[1] * hB, q2z = cBz + aBx[2] * hB;
322
+ const sumR = rA + rB;
323
+ const sumExt = sumR + CONTACT_MARGIN;
324
+ // Primary contact: closest-pair on the two segments.
325
+ closestPointsTwoSegments(p1x, p1y, p1z, q1x, q1y, q1z, p2x, p2y, p2z, q2x, q2y, q2z, _cpA, _cpB);
326
+ emitCapsuleContact(store, a, b, pool, _cpA[0], _cpA[1], _cpA[2], _cpB[0], _cpB[1], _cpB[2], rA, rB, sumR, sumExt, cAx, cAy, cAz, cBx, cBy, cBz);
327
+ // For nearly-parallel axes the closest-pair algorithm is degenerate
328
+ // (denom = a·e − b² ≈ 0) and returns one arbitrary point. Sampling A's
329
+ // endpoints adds two contacts that pin both rotation and length-wise push.
330
+ const cosA = Math.abs(aAx[0] * aBx[0] + aAx[1] * aBx[1] + aAx[2] * aBx[2]);
331
+ if (cosA > 0.9) {
332
+ closestPointOnSegment(p2x, p2y, p2z, q2x, q2y, q2z, p1x, p1y, p1z, _cpB);
333
+ emitCapsuleContact(store, a, b, pool, p1x, p1y, p1z, _cpB[0], _cpB[1], _cpB[2], rA, rB, sumR, sumExt, cAx, cAy, cAz, cBx, cBy, cBz);
334
+ closestPointOnSegment(p2x, p2y, p2z, q2x, q2y, q2z, q1x, q1y, q1z, _cpB);
335
+ emitCapsuleContact(store, a, b, pool, q1x, q1y, q1z, _cpB[0], _cpB[1], _cpB[2], rA, rB, sumR, sumExt, cAx, cAy, cAz, cBx, cBy, cBz);
336
+ }
337
+ }
338
+ // --- Sphere–box (sphere = a, box = b) --------------------------------------
339
+ const _localPt = new Float32Array(3);
340
+ // 3×3 row-major rotation matrix for body i (xx = 2·qx·qx etc.).
341
+ const _rot = new Float32Array(9);
342
+ function loadBodyRot(store, i) {
343
+ const i4 = i * 4;
344
+ const qx = store.orientations[i4 + 0];
345
+ const qy = store.orientations[i4 + 1];
346
+ const qz = store.orientations[i4 + 2];
347
+ const qw = store.orientations[i4 + 3];
348
+ const x2 = qx + qx, y2 = qy + qy, z2 = qz + qz;
349
+ const xx = qx * x2, yy = qy * y2, zz = qz * z2;
350
+ const xy = qx * y2, xz = qx * z2, yz = qy * z2;
351
+ const wx = qw * x2, wy = qw * y2, wz = qw * z2;
352
+ _rot[0] = 1 - (yy + zz);
353
+ _rot[1] = xy - wz;
354
+ _rot[2] = xz + wy;
355
+ _rot[3] = xy + wz;
356
+ _rot[4] = 1 - (xx + zz);
357
+ _rot[5] = yz - wx;
358
+ _rot[6] = xz - wy;
359
+ _rot[7] = yz + wx;
360
+ _rot[8] = 1 - (xx + yy);
361
+ }
362
+ // Transform world point into body i's local frame: v_local = R^T · (p − bodyPos).
363
+ function worldToBodyLocal(store, i, px, py, pz, out) {
364
+ const i3 = i * 3;
365
+ const dx = px - store.positions[i3 + 0];
366
+ const dy = py - store.positions[i3 + 1];
367
+ const dz = pz - store.positions[i3 + 2];
368
+ loadBodyRot(store, i);
369
+ // R^T · v = (col k of R) · v.
370
+ out[0] = _rot[0] * dx + _rot[3] * dy + _rot[6] * dz;
371
+ out[1] = _rot[1] * dx + _rot[4] * dy + _rot[7] * dz;
372
+ out[2] = _rot[2] * dx + _rot[5] * dy + _rot[8] * dz;
373
+ }
374
+ // Rotate a body-local direction into world space: v_world = R · v_local.
375
+ function bodyLocalToWorldDir(store, i, lx, ly, lz, out) {
376
+ loadBodyRot(store, i);
377
+ out[0] = _rot[0] * lx + _rot[1] * ly + _rot[2] * lz;
378
+ out[1] = _rot[3] * lx + _rot[4] * ly + _rot[5] * lz;
379
+ out[2] = _rot[6] * lx + _rot[7] * ly + _rot[8] * lz;
380
+ }
381
+ function detectSphereBox(store, a, b, pool) {
382
+ const ai = a * 3, bi = b * 3;
383
+ const sx = store.positions[ai + 0];
384
+ const sy = store.positions[ai + 1];
385
+ const sz_ = store.positions[ai + 2];
386
+ const rA = store.size[ai + 0];
387
+ const hx = store.size[bi + 0];
388
+ const hy = store.size[bi + 1];
389
+ const hz = store.size[bi + 2];
390
+ // Sphere center in box-local frame.
391
+ worldToBodyLocal(store, b, sx, sy, sz_, _localPt);
392
+ const lx = _localPt[0], ly = _localPt[1], lz = _localPt[2];
393
+ // Closest point on box (clamp to half-extents).
394
+ let qx = lx, qy = ly, qz = lz;
395
+ if (qx > hx)
396
+ qx = hx;
397
+ else if (qx < -hx)
398
+ qx = -hx;
399
+ if (qy > hy)
400
+ qy = hy;
401
+ else if (qy < -hy)
402
+ qy = -hy;
403
+ if (qz > hz)
404
+ qz = hz;
405
+ else if (qz < -hz)
406
+ qz = -hz;
407
+ let dx = lx - qx, dy = ly - qy, dz = lz - qz;
408
+ let d2 = dx * dx + dy * dy + dz * dz;
409
+ let nLocalX, nLocalY, nLocalZ;
410
+ let depth;
411
+ const rExt = rA + CONTACT_MARGIN;
412
+ if (d2 > rExt * rExt)
413
+ return; // outside speculative range
414
+ if (d2 > 1e-12) {
415
+ const d = Math.sqrt(d2);
416
+ nLocalX = dx / d;
417
+ nLocalY = dy / d;
418
+ nLocalZ = dz / d;
419
+ depth = rA - d; // signed: > 0 overlapping, ≤ 0 within margin
420
+ }
421
+ else {
422
+ // Sphere center inside box — pick shortest axis to escape.
423
+ const px = hx - Math.abs(lx), py = hy - Math.abs(ly), pz = hz - Math.abs(lz);
424
+ if (px < py && px < pz) {
425
+ nLocalX = lx > 0 ? 1 : -1;
426
+ nLocalY = 0;
427
+ nLocalZ = 0;
428
+ depth = rA + px;
429
+ qx = lx > 0 ? hx : -hx;
430
+ qy = ly;
431
+ qz = lz;
432
+ }
433
+ else if (py < pz) {
434
+ nLocalX = 0;
435
+ nLocalY = ly > 0 ? 1 : -1;
436
+ nLocalZ = 0;
437
+ depth = rA + py;
438
+ qx = lx;
439
+ qy = ly > 0 ? hy : -hy;
440
+ qz = lz;
441
+ }
442
+ else {
443
+ nLocalX = 0;
444
+ nLocalY = 0;
445
+ nLocalZ = lz > 0 ? 1 : -1;
446
+ depth = rA + pz;
447
+ qx = lx;
448
+ qy = ly;
449
+ qz = lz > 0 ? hz : -hz;
450
+ }
451
+ }
452
+ // Rotate local normal back to world. Convention: normal points A→B, but we
453
+ // computed n = (lx − qx) which goes "from box surface toward sphere center"
454
+ // (= B → A). Flip sign.
455
+ const out = _capPoint;
456
+ bodyLocalToWorldDir(store, b, -nLocalX, -nLocalY, -nLocalZ, out);
457
+ const nx = out[0], ny = out[1], nz = out[2];
458
+ // Box's contact point in world: rotate (qx,qy,qz) and translate by box pos.
459
+ const bp = _capPointB;
460
+ bodyLocalToWorldDir(store, b, qx, qy, qz, bp);
461
+ const bpx = bp[0] + store.positions[bi + 0];
462
+ const bpy = bp[1] + store.positions[bi + 1];
463
+ const bpz = bp[2] + store.positions[bi + 2];
464
+ const c = pool.acquire();
465
+ c.bodyA = a;
466
+ c.bodyB = b;
467
+ c.nx = nx;
468
+ c.ny = ny;
469
+ c.nz = nz;
470
+ c.depth = depth;
471
+ c.rAx = nx * rA;
472
+ c.rAy = ny * rA;
473
+ c.rAz = nz * rA;
474
+ c.rBx = bpx - store.positions[bi + 0];
475
+ c.rBy = bpy - store.positions[bi + 1];
476
+ c.rBz = bpz - store.positions[bi + 2];
477
+ combineMaterials(store, a, b, c);
478
+ }
479
+ // --- Capsule–box -----------------------------------------------------------
480
+ // Walk the capsule's segment (in box-local space) toward the box, sample
481
+ // sphere-box at the converged parameter plus both endpoints. Endpoint
482
+ // samples catch caps grazing a face when the closest-point parameter sits
483
+ // at one end of the segment.
484
+ function detectCapsuleBox(store, a, b, pool) {
485
+ const pos = store.positions, sz = store.size;
486
+ const ai = a * 3, bi = b * 3;
487
+ const cx = pos[ai + 0], cy = pos[ai + 1], cz = pos[ai + 2];
488
+ const rA = sz[ai + 0];
489
+ const hA = sz[ai + 1] * 0.5;
490
+ const ax = _capPoint;
491
+ capsuleAxis(store, a, ax);
492
+ // Endpoints in world space.
493
+ const p1wx = cx - ax[0] * hA, p1wy = cy - ax[1] * hA, p1wz = cz - ax[2] * hA;
494
+ const p2wx = cx + ax[0] * hA, p2wy = cy + ax[1] * hA, p2wz = cz + ax[2] * hA;
495
+ // Endpoints in box-local space.
496
+ worldToBodyLocal(store, b, p1wx, p1wy, p1wz, _localPt);
497
+ const p1lx = _localPt[0], p1ly = _localPt[1], p1lz = _localPt[2];
498
+ worldToBodyLocal(store, b, p2wx, p2wy, p2wz, _localPt);
499
+ const p2lx = _localPt[0], p2ly = _localPt[1], p2lz = _localPt[2];
500
+ const hx = sz[bi + 0], hy = sz[bi + 1], hz = sz[bi + 2];
501
+ // Closest point on segment to box (in box-local). Iterate a few times to
502
+ // converge — clamp each component, recompute t, repeat. Two passes is
503
+ // enough for our use case (capsule modestly larger than box).
504
+ let t = 0.5;
505
+ for (let iter = 0; iter < 4; iter++) {
506
+ const px = p1lx + (p2lx - p1lx) * t;
507
+ const py = p1ly + (p2ly - p1ly) * t;
508
+ const pz = p1lz + (p2lz - p1lz) * t;
509
+ let qx = px, qy = py, qz = pz;
510
+ if (qx > hx)
511
+ qx = hx;
512
+ else if (qx < -hx)
513
+ qx = -hx;
514
+ if (qy > hy)
515
+ qy = hy;
516
+ else if (qy < -hy)
517
+ qy = -hy;
518
+ if (qz > hz)
519
+ qz = hz;
520
+ else if (qz < -hz)
521
+ qz = -hz;
522
+ // Project clamped point back onto the segment to refine t.
523
+ const dx = p2lx - p1lx, dy = p2ly - p1ly, dz = p2lz - p1lz;
524
+ const segLen2 = dx * dx + dy * dy + dz * dz;
525
+ if (segLen2 < 1e-8)
526
+ break;
527
+ t = ((qx - p1lx) * dx + (qy - p1ly) * dy + (qz - p1lz) * dz) / segLen2;
528
+ if (t < 0) {
529
+ t = 0;
530
+ break;
531
+ }
532
+ if (t > 1) {
533
+ t = 1;
534
+ break;
535
+ }
536
+ }
537
+ // Sample at the converged t plus both endpoints — endpoints catch capsule
538
+ // caps grazing the box surface where the closest-point loop sits at one
539
+ // segment end.
540
+ let bestDepth = -Infinity;
541
+ let bestNX = 0, bestNY = 0, bestNZ = 0;
542
+ let bestRAX = 0, bestRAY = 0, bestRAZ = 0;
543
+ let bestRBX = 0, bestRBY = 0, bestRBZ = 0;
544
+ let found = false;
545
+ const samples = [t, 0, 1];
546
+ for (const s of samples) {
547
+ const sx = p1wx + (p2wx - p1wx) * s;
548
+ const sy = p1wy + (p2wy - p1wy) * s;
549
+ const sz_ = p1wz + (p2wz - p1wz) * s;
550
+ worldToBodyLocal(store, b, sx, sy, sz_, _localPt);
551
+ const lx = _localPt[0], ly = _localPt[1], lz = _localPt[2];
552
+ let qx = lx, qy = ly, qz = lz;
553
+ if (qx > hx)
554
+ qx = hx;
555
+ else if (qx < -hx)
556
+ qx = -hx;
557
+ if (qy > hy)
558
+ qy = hy;
559
+ else if (qy < -hy)
560
+ qy = -hy;
561
+ if (qz > hz)
562
+ qz = hz;
563
+ else if (qz < -hz)
564
+ qz = -hz;
565
+ const dx = lx - qx, dy = ly - qy, dz = lz - qz;
566
+ const d2 = dx * dx + dy * dy + dz * dz;
567
+ const rExt = rA + CONTACT_MARGIN;
568
+ if (d2 > rExt * rExt)
569
+ continue;
570
+ let nLocalX = 0, nLocalY = 0, nLocalZ = 0;
571
+ let depth;
572
+ if (d2 > 1e-12) {
573
+ const d = Math.sqrt(d2);
574
+ nLocalX = dx / d;
575
+ nLocalY = dy / d;
576
+ nLocalZ = dz / d;
577
+ depth = rA - d; // signed: > 0 overlapping, ≤ 0 within margin
578
+ }
579
+ else {
580
+ const px = hx - Math.abs(lx), py = hy - Math.abs(ly), pz = hz - Math.abs(lz);
581
+ if (px < py && px < pz) {
582
+ nLocalX = lx > 0 ? 1 : -1;
583
+ depth = rA + px;
584
+ qx = lx > 0 ? hx : -hx;
585
+ qy = ly;
586
+ qz = lz;
587
+ }
588
+ else if (py < pz) {
589
+ nLocalY = ly > 0 ? 1 : -1;
590
+ depth = rA + py;
591
+ qx = lx;
592
+ qy = ly > 0 ? hy : -hy;
593
+ qz = lz;
594
+ }
595
+ else {
596
+ nLocalZ = lz > 0 ? 1 : -1;
597
+ depth = rA + pz;
598
+ qx = lx;
599
+ qy = ly;
600
+ qz = lz > 0 ? hz : -hz;
601
+ }
602
+ }
603
+ if (depth <= bestDepth)
604
+ continue;
605
+ bestDepth = depth;
606
+ found = true;
607
+ const dirOut = _localPt;
608
+ bodyLocalToWorldDir(store, b, -nLocalX, -nLocalY, -nLocalZ, dirOut);
609
+ bestNX = dirOut[0];
610
+ bestNY = dirOut[1];
611
+ bestNZ = dirOut[2];
612
+ const bpOut = _localPt;
613
+ bodyLocalToWorldDir(store, b, qx, qy, qz, bpOut);
614
+ const bpx = bpOut[0] + pos[bi + 0];
615
+ const bpy = bpOut[1] + pos[bi + 1];
616
+ const bpz = bpOut[2] + pos[bi + 2];
617
+ bestRAX = sx + bestNX * rA - cx;
618
+ bestRAY = sy + bestNY * rA - cy;
619
+ bestRAZ = sz_ + bestNZ * rA - cz;
620
+ bestRBX = bpx - pos[bi + 0];
621
+ bestRBY = bpy - pos[bi + 1];
622
+ bestRBZ = bpz - pos[bi + 2];
623
+ }
624
+ if (!found)
625
+ return;
626
+ const c = pool.acquire();
627
+ c.bodyA = a;
628
+ c.bodyB = b;
629
+ c.nx = bestNX;
630
+ c.ny = bestNY;
631
+ c.nz = bestNZ;
632
+ c.depth = bestDepth;
633
+ c.rAx = bestRAX;
634
+ c.rAy = bestRAY;
635
+ c.rAz = bestRAZ;
636
+ c.rBx = bestRBX;
637
+ c.rBy = bestRBY;
638
+ c.rBz = bestRBZ;
639
+ combineMaterials(store, a, b, c);
640
+ }
641
+ // Dispatch a pair to the matching narrowphase. Caller has already done
642
+ // broadphase + group/mask filtering. Some shape pairs (sphere-A capsule-B
643
+ // etc.) reuse a canonical implementation via swap + flipLastNormal.
644
+ export function generateContacts(store, a, b, pool) {
645
+ const sA = store.shape[a];
646
+ const sB = store.shape[b];
647
+ if (sA === RigidbodyShape.Sphere && sB === RigidbodyShape.Sphere) {
648
+ detectSphereSphere(store, a, b, pool);
649
+ return;
650
+ }
651
+ if (sA === RigidbodyShape.Sphere && sB === RigidbodyShape.Capsule) {
652
+ detectSphereCapsule(store, a, b, pool);
653
+ return;
654
+ }
655
+ if (sA === RigidbodyShape.Capsule && sB === RigidbodyShape.Sphere) {
656
+ detectSphereCapsule(store, b, a, pool);
657
+ flipLastNormal(pool);
658
+ return;
659
+ }
660
+ if (sA === RigidbodyShape.Capsule && sB === RigidbodyShape.Capsule) {
661
+ detectCapsuleCapsule(store, a, b, pool);
662
+ return;
663
+ }
664
+ if (sA === RigidbodyShape.Sphere && sB === RigidbodyShape.Box) {
665
+ detectSphereBox(store, a, b, pool);
666
+ return;
667
+ }
668
+ if (sA === RigidbodyShape.Box && sB === RigidbodyShape.Sphere) {
669
+ detectSphereBox(store, b, a, pool);
670
+ flipLastNormal(pool);
671
+ return;
672
+ }
673
+ if (sA === RigidbodyShape.Capsule && sB === RigidbodyShape.Box) {
674
+ detectCapsuleBox(store, a, b, pool);
675
+ return;
676
+ }
677
+ if (sA === RigidbodyShape.Box && sB === RigidbodyShape.Capsule) {
678
+ detectCapsuleBox(store, b, a, pool);
679
+ flipLastNormal(pool);
680
+ return;
681
+ }
682
+ // Box-box left unimplemented.
683
+ }
684
+ // After a swapped detect* call, the last contact's normal points the wrong
685
+ // way and lever arms are mismatched. Flip and re-anchor.
686
+ function flipLastNormal(pool) {
687
+ if (pool.count === 0)
688
+ return;
689
+ const c = pool.get(pool.count - 1);
690
+ const ta = c.bodyA;
691
+ c.bodyA = c.bodyB;
692
+ c.bodyB = ta;
693
+ const trAx = c.rAx, trAy = c.rAy, trAz = c.rAz;
694
+ c.rAx = c.rBx;
695
+ c.rAy = c.rBy;
696
+ c.rAz = c.rBz;
697
+ c.rBx = trAx;
698
+ c.rBy = trAy;
699
+ c.rBz = trAz;
700
+ c.nx = -c.nx;
701
+ c.ny = -c.ny;
702
+ c.nz = -c.nz;
703
+ }
704
+ // O(N²) AABB pair test. For 30–100 PMX bodies that's <5000 checks per
705
+ // step; SAP / dynamic AABB tree pay off above ~500 bodies. The pair filter
706
+ // uses the AND form `(maskA & groupB) && (maskB & groupA)` — both sides
707
+ // must agree, which is what PMX rigs are tuned against.
708
+ export function findContacts(store, pool) {
709
+ store.updateAabbs();
710
+ const N = store.count;
711
+ const invMass = store.invMass;
712
+ const group = store.collisionGroup;
713
+ const mask = store.willCollideMask;
714
+ for (let i = 0; i < N; i++) {
715
+ const gi = group[i];
716
+ const mi = mask[i];
717
+ const dynA = invMass[i] > 0;
718
+ for (let j = i + 1; j < N; j++) {
719
+ if (!dynA && invMass[j] === 0)
720
+ continue; // both static — no resolution
721
+ if ((mi & group[j]) === 0 || (mask[j] & gi) === 0)
722
+ continue;
723
+ if (!aabbOverlap(store, i, j))
724
+ continue;
725
+ generateContacts(store, i, j, pool);
726
+ }
727
+ }
728
+ }