reze-engine 0.16.1 → 0.16.3

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 (88) hide show
  1. package/README.md +139 -284
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +5 -5
  4. package/dist/model.d.ts +4 -1
  5. package/dist/model.d.ts.map +1 -1
  6. package/dist/model.js +14 -1
  7. package/dist/physics/body.d.ts +1 -0
  8. package/dist/physics/body.d.ts.map +1 -1
  9. package/dist/physics/body.js +2 -0
  10. package/dist/physics/constraint.d.ts +8 -1
  11. package/dist/physics/constraint.d.ts.map +1 -1
  12. package/dist/physics/constraint.js +13 -1
  13. package/dist/physics/physics.d.ts +15 -1
  14. package/dist/physics/physics.d.ts.map +1 -1
  15. package/dist/physics/physics.js +413 -48
  16. package/dist/physics/solver.d.ts.map +1 -1
  17. package/dist/physics/solver.js +223 -39
  18. package/dist/physics/types.d.ts +2 -1
  19. package/dist/physics/types.d.ts.map +1 -1
  20. package/dist/physics/types.js +5 -0
  21. package/dist/physics/world.d.ts +3 -0
  22. package/dist/physics/world.d.ts.map +1 -1
  23. package/dist/physics/world.js +31 -4
  24. package/dist/physics-debug.d.ts +30 -0
  25. package/dist/physics-debug.d.ts.map +1 -0
  26. package/dist/physics-debug.js +526 -0
  27. package/dist/pmx-loader.d.ts +2 -0
  28. package/dist/pmx-loader.d.ts.map +1 -1
  29. package/dist/pmx-loader.js +37 -20
  30. package/dist/shaders/materials/body.d.ts +1 -1
  31. package/dist/shaders/materials/body.d.ts.map +1 -1
  32. package/dist/shaders/materials/body.js +90 -85
  33. package/dist/shaders/materials/cloth_rough.d.ts +1 -1
  34. package/dist/shaders/materials/cloth_rough.d.ts.map +1 -1
  35. package/dist/shaders/materials/cloth_rough.js +1 -1
  36. package/dist/shaders/materials/cloth_smooth.d.ts +1 -1
  37. package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -1
  38. package/dist/shaders/materials/cloth_smooth.js +2 -2
  39. package/dist/shaders/materials/common.d.ts +1 -1
  40. package/dist/shaders/materials/common.d.ts.map +1 -1
  41. package/dist/shaders/materials/common.js +10 -0
  42. package/dist/shaders/materials/default.d.ts +1 -1
  43. package/dist/shaders/materials/default.d.ts.map +1 -1
  44. package/dist/shaders/materials/default.js +37 -32
  45. package/dist/shaders/materials/eye.d.ts +1 -1
  46. package/dist/shaders/materials/eye.d.ts.map +1 -1
  47. package/dist/shaders/materials/eye.js +40 -35
  48. package/dist/shaders/materials/face.d.ts +1 -1
  49. package/dist/shaders/materials/face.d.ts.map +1 -1
  50. package/dist/shaders/materials/face.js +90 -85
  51. package/dist/shaders/materials/hair.d.ts +1 -1
  52. package/dist/shaders/materials/hair.d.ts.map +1 -1
  53. package/dist/shaders/materials/hair.js +85 -75
  54. package/dist/shaders/materials/metal.d.ts +1 -1
  55. package/dist/shaders/materials/metal.d.ts.map +1 -1
  56. package/dist/shaders/materials/metal.js +1 -1
  57. package/dist/shaders/materials/stockings.d.ts +1 -1
  58. package/dist/shaders/materials/stockings.d.ts.map +1 -1
  59. package/dist/shaders/materials/stockings.js +1 -1
  60. package/dist/shaders/passes/physics-debug.d.ts +2 -0
  61. package/dist/shaders/passes/physics-debug.d.ts.map +1 -0
  62. package/dist/shaders/passes/physics-debug.js +69 -0
  63. package/package.json +1 -1
  64. package/src/engine.ts +36 -21
  65. package/src/model.ts +18 -1
  66. package/src/physics/body.ts +5 -0
  67. package/src/physics/constraint.ts +35 -6
  68. package/src/physics/physics.ts +423 -56
  69. package/src/physics/solver.ts +213 -38
  70. package/src/physics/types.ts +10 -2
  71. package/src/physics/world.ts +33 -4
  72. package/src/pmx-loader.ts +38 -20
  73. package/src/shaders/materials/body.ts +97 -92
  74. package/src/shaders/materials/cloth_rough.ts +1 -1
  75. package/src/shaders/materials/cloth_smooth.ts +2 -2
  76. package/src/shaders/materials/common.ts +10 -0
  77. package/src/shaders/materials/default.ts +45 -40
  78. package/src/shaders/materials/eye.ts +48 -43
  79. package/src/shaders/materials/face.ts +97 -92
  80. package/src/shaders/materials/hair.ts +92 -82
  81. package/src/shaders/materials/metal.ts +1 -1
  82. package/src/shaders/materials/stockings.ts +1 -1
  83. package/dist/gpu-profile.d.ts +0 -19
  84. package/dist/gpu-profile.d.ts.map +0 -1
  85. package/dist/gpu-profile.js +0 -120
  86. package/dist/physics/profile.d.ts +0 -18
  87. package/dist/physics/profile.d.ts.map +0 -1
  88. package/dist/physics/profile.js +0 -44
@@ -19,12 +19,30 @@ import type { Contact, ContactPool } from "./contact"
19
19
 
20
20
  const BOUNCE_THRESHOLD = 2.0
21
21
 
22
+ // Ceilings on limit-correction velocity. In normal operation limit errors are
23
+ // tiny; a large error only appears after a discontinuity (teleport, stall,
24
+ // deep penetration), and feeding err·ERP/dt to the solver unclamped then
25
+ // injects explosion-scale impulses into the chain.
26
+ const MAX_LINEAR_CORRECTION_VEL = 120 // units/s
27
+ const MAX_ANGULAR_CORRECTION_VEL = 30 // rad/s
28
+
29
+ // Bullet's limit-motor softness defaults (0.7 translational, 0.5 rotational):
30
+ // scale each iteration's limit impulse so the stop engages progressively
31
+ // instead of as a hard velocity snap.
32
+ const LIMIT_SOFTNESS_LINEAR = 0.7
33
+ const LIMIT_SOFTNESS_ANGULAR = 0.5
34
+ // Angular limit violations below this switch to per-axis euler rows; above
35
+ // it, the single geodesic row takes over (see setupConstraint).
36
+ const GEODESIC_THRESHOLD = 0.5 // rad
37
+
22
38
  // Module-level scratch (no per-iter allocations).
23
39
  const _TA = new Float32Array(16)
24
40
  const _TB = new Float32Array(16)
25
41
  const _bodyMatA = new Float32Array(16)
26
42
  const _bodyMatB = new Float32Array(16)
27
43
  const _angDiffScratch = new Float32Array(3)
44
+ const _quatScratchA = new Float32Array(4)
45
+ const _quatScratchB = new Float32Array(4)
28
46
 
29
47
  export function solveConstraints(
30
48
  store: RigidBodyStore,
@@ -82,21 +100,23 @@ function setupConstraint(
82
100
  Mat4.multiplyArrays(_bodyMatA, 0, con.frameA, 0, _TA, 0)
83
101
  Mat4.multiplyArrays(_bodyMatB, 0, con.frameB, 0, _TB, 0)
84
102
 
85
- // Mass-weighted shared anchor (Bullet 2.75 m_AnchorPos).
103
+ // Per-body pivots at each body's own joint-frame origin (Spring2-style).
104
+ // Bullet 2.7x's shared mass-weighted anchor (m_AnchorPos) degenerates when
105
+ // the joint is violated by a large distance: the midpoint sits far from
106
+ // both bodies, the lever arms grow with the separation, the Jacobian
107
+ // denominator blows up as err²·invInertia, and the row applies torque
108
+ // instead of closing velocity — the joint "breaks" and the error runs
109
+ // away. Per-body pivots keep the levers bounded by the frame offsets, so
110
+ // the row stays effective no matter how large the violation is.
86
111
  const pos = store.positions
87
112
  const ai = a * 3
88
113
  const bi = b * 3
89
- const weightA = imB === 0 ? 1 : imA / (imA + imB)
90
- const weightB = 1 - weightA
91
- const anchorX = _TA[12] * weightA + _TB[12] * weightB
92
- const anchorY = _TA[13] * weightA + _TB[13] * weightB
93
- const anchorZ = _TA[14] * weightA + _TB[14] * weightB
94
- const rAx = anchorX - pos[ai + 0]
95
- const rAy = anchorY - pos[ai + 1]
96
- const rAz = anchorZ - pos[ai + 2]
97
- const rBx = anchorX - pos[bi + 0]
98
- const rBy = anchorY - pos[bi + 1]
99
- const rBz = anchorZ - pos[bi + 2]
114
+ const rAx = _TA[12] - pos[ai + 0]
115
+ const rAy = _TA[13] - pos[ai + 1]
116
+ const rAz = _TA[14] - pos[ai + 2]
117
+ const rBx = _TB[12] - pos[bi + 0]
118
+ const rBy = _TB[13] - pos[bi + 1]
119
+ const rBz = _TB[14] - pos[bi + 2]
100
120
  const lA = con.cacheLeverA
101
121
  const lB = con.cacheLeverB
102
122
  lA[0] = rAx; lA[1] = rAy; lA[2] = rAz
@@ -143,23 +163,40 @@ function setupConstraint(
143
163
  const lo = con.linearMin[i]
144
164
  const hi = con.linearMax[i]
145
165
  const curr = i === 0 ? linDiff0 : i === 1 ? linDiff1 : linDiff2
166
+ // active: 1 = bilateral equality (locked axis — a joint, always on),
167
+ // 2 = unilateral stop (ranged axis in violation).
146
168
  let target = 0
147
169
  let active = 0
148
170
  if (lo <= hi) {
149
171
  let err = 0
150
172
  if (curr < lo) err = curr - lo
151
173
  else if (curr > hi) err = curr - hi
174
+ if (lo === hi) active = 1
175
+ else if (err !== 0) active = 2
152
176
  if (err !== 0) {
153
177
  target = -err * STOP_ERP * invDt
154
- active = 1
178
+ if (target > MAX_LINEAR_CORRECTION_VEL) target = MAX_LINEAR_CORRECTION_VEL
179
+ else if (target < -MAX_LINEAR_CORRECTION_VEL) target = -MAX_LINEAR_CORRECTION_VEL
155
180
  }
156
181
  }
157
- if (con.springEnabled[i]) {
158
- target += -con.springStiffness[i] * (curr - con.equilibriumPoint[i]) * dt
159
- active = 1
160
- }
161
182
  tgt[i] = target
162
183
  act[i] = denom > 0 ? active : 0
184
+ con.cacheLinLimitImp[i] = 0
185
+ // A spring on a locked axis is redundant — the bilateral limit row
186
+ // already welds the DOF, and driving it twice overshoots every
187
+ // iteration (PMX rigs routinely put k=100000 springs on locked axes,
188
+ // which turned welded weight-bodies into energy pumps).
189
+ if (con.springEnabled[i] && denom > 0 && lo !== hi) {
190
+ // Clamp k to the deadbeat limit: an explicit spring with k·dt² > 1
191
+ // overshoots equilibrium every step and pumps energy — the classic
192
+ // pre-Spring2 Bullet 6dof instability this port inherited.
193
+ const k = Math.min(con.springStiffness[i], invDt * invDt)
194
+ const serr = curr - con.equilibriumPoint[i]
195
+ con.cacheLinSpringTarget[i] = -k * serr * dt
196
+ con.cacheLinSpringActive[i] = 1
197
+ } else {
198
+ con.cacheLinSpringActive[i] = 0
199
+ }
163
200
  }
164
201
 
165
202
  // Angular: TA^T · TB → Euler XYZ; axes from TA.col2 × TB.col0.
@@ -198,32 +235,93 @@ function setupConstraint(
198
235
  const angDenom = iiA + iiB
199
236
  con.cacheAngJacInv = angDenom > 0 ? 1 / angDenom : 0
200
237
 
238
+ // Per-axis rows carry only the springs. Sign flip vs linear:
239
+ // d(angDiff)/dt = −(ω_B − ω_A)·ax.
201
240
  const angTgt = con.cacheAngTargetVel
202
241
  const angAct = con.cacheAngActive
203
242
  for (let i = 0; i < 3; i++) {
204
243
  const idx = i + 3
205
- const lo = con.angularMin[i]
206
- const hi = con.angularMax[i]
207
- const curr = _angDiffScratch[i]
208
- let target = 0
209
- let active = 0
210
- if (lo <= hi) {
211
- let err = 0
212
- if (curr < lo) err = curr - lo
213
- else if (curr > hi) err = curr - hi
214
- // Sign flip vs linear: d(angDiff)/dt = −(ω_B − ω_A)·ax.
215
- if (err !== 0) {
216
- target = err * STOP_ERP * invDt
217
- active = 1
218
- }
244
+ // Springs on locked axes are skipped — the limit row welds those, and
245
+ // double-driving a DOF overshoots every iteration (see the linear loop).
246
+ if (con.springEnabled[idx] && angDenom > 0 && con.angularMin[i] !== con.angularMax[i]) {
247
+ // Same deadbeat clamp as the linear springs.
248
+ const k = Math.min(con.springStiffness[idx], invDt * invDt)
249
+ const serr = _angDiffScratch[i] - con.equilibriumPoint[idx]
250
+ angTgt[i] = k * serr * dt
251
+ angAct[i] = 1
252
+ } else {
253
+ angTgt[i] = 0
254
+ angAct[i] = 0
219
255
  }
220
- if (con.springEnabled[idx]) {
221
- target += con.springStiffness[idx] * (curr - con.equilibriumPoint[idx]) * dt
222
- active = 1
256
+ }
257
+
258
+ // Angular limit handling is hybrid. Small violations (the resting-cloth
259
+ // regime) use per-axis euler rows — they converge cleanly and keep resting
260
+ // cloth dead still. Large violations switch to a single geodesic row toward
261
+ // the euler-clamped target: per-axis euler rows (the Bullet-2.7x approach
262
+ // this port used) become geometrically inconsistent for large errors — near
263
+ // the asin singularity they chase phantom errors and pump angular velocity
264
+ // into the chain instead of converging.
265
+ con.cacheAngLimActive = 0
266
+ if (angDenom > 0) {
267
+ const ex = _angDiffScratch[0], ey = _angDiffScratch[1], ez = _angDiffScratch[2]
268
+ // Free axes (min > max) follow the current angle, i.e. no correction.
269
+ let tx = ex, ty = ey, tz = ez
270
+ if (con.angularMin[0] <= con.angularMax[0]) tx = ex < con.angularMin[0] ? con.angularMin[0] : ex > con.angularMax[0] ? con.angularMax[0] : ex
271
+ if (con.angularMin[1] <= con.angularMax[1]) ty = ey < con.angularMin[1] ? con.angularMin[1] : ey > con.angularMax[1] ? con.angularMax[1] : ey
272
+ if (con.angularMin[2] <= con.angularMax[2]) tz = ez < con.angularMin[2] ? con.angularMin[2] : ez > con.angularMax[2] ? con.angularMax[2] : ez
273
+ const errX = ex - tx, errY = ey - ty, errZ = ez - tz
274
+ const maxErr = Math.max(Math.abs(errX), Math.abs(errY), Math.abs(errZ))
275
+ if (maxErr > 0 && maxErr < GEODESIC_THRESHOLD) {
276
+ // Per-axis euler limit rows, folded into the per-axis row set (the
277
+ // spring impulse clamp must not bound a limit row — lift it).
278
+ for (let i = 0; i < 3; i++) {
279
+ const err = i === 0 ? errX : i === 1 ? errY : errZ
280
+ if (err === 0) continue
281
+ let target = err * STOP_ERP * invDt
282
+ if (target > MAX_ANGULAR_CORRECTION_VEL) target = MAX_ANGULAR_CORRECTION_VEL
283
+ else if (target < -MAX_ANGULAR_CORRECTION_VEL) target = -MAX_ANGULAR_CORRECTION_VEL
284
+ angTgt[i] += target
285
+ angAct[i] = 1
286
+ }
287
+ } else if (maxErr > 0) {
288
+ // Bilateral (equality) if any violated axis is locked — a locked axis
289
+ // is a joint, not a stop. Unilateral otherwise.
290
+ const bilateral =
291
+ (tx !== ex && con.angularMin[0] === con.angularMax[0]) ||
292
+ (ty !== ey && con.angularMin[1] === con.angularMax[1]) ||
293
+ (tz !== ez && con.angularMin[2] === con.angularMax[2])
294
+ // The decomposition above satisfies R_rel^T = Rx(x)·Ry(y)·Rz(z), so
295
+ // u = qx·qy·qz is conj(q_rel) and the error rotation (current →
296
+ // clamped target, expressed in TA's frame) is q_E = conj(u_t) ⊗ u.
297
+ eulerXYZQuatInto(ex, ey, ez, _quatScratchA)
298
+ eulerXYZQuatInto(tx, ty, tz, _quatScratchB)
299
+ const ux = _quatScratchA[0], uy = _quatScratchA[1], uz = _quatScratchA[2], uw = _quatScratchA[3]
300
+ const vx = _quatScratchB[0], vy = _quatScratchB[1], vz = _quatScratchB[2], vw = _quatScratchB[3]
301
+ // q_E = conj(v) ⊗ u
302
+ let qex = vw * ux - vx * uw - vy * uz + vz * uy
303
+ let qey = vw * uy + vx * uz - vy * uw - vz * ux
304
+ let qez = vw * uz - vx * uy + vy * ux - vz * uw
305
+ let qew = vw * uw + vx * ux + vy * uy + vz * uz
306
+ if (qew < 0) { qex = -qex; qey = -qey; qez = -qez; qew = -qew }
307
+ const sinHalf = Math.sqrt(qex * qex + qey * qey + qez * qez)
308
+ if (sinHalf > 1e-6) {
309
+ const angle = 2 * Math.atan2(sinHalf, qew)
310
+ const invS = 1 / sinHalf
311
+ const axx = qex * invS, axy = qey * invS, axz = qez * invS
312
+ // Axis lives in TA's frame; TA's basis columns map it to world.
313
+ const lim = con.cacheAngLimAxis
314
+ lim[0] = _TA[0] * axx + _TA[4] * axy + _TA[8] * axz
315
+ lim[1] = _TA[1] * axx + _TA[5] * axy + _TA[9] * axz
316
+ lim[2] = _TA[2] * axx + _TA[6] * axy + _TA[10] * axz
317
+ let target = angle * STOP_ERP * invDt
318
+ if (target > MAX_ANGULAR_CORRECTION_VEL) target = MAX_ANGULAR_CORRECTION_VEL
319
+ con.cacheAngLimTarget = target
320
+ con.cacheAngLimActive = bilateral ? 1 : 2
321
+ }
223
322
  }
224
- angTgt[i] = target
225
- angAct[i] = angDenom > 0 ? active : 0
226
323
  }
324
+ con.cacheAngLimImp = 0
227
325
  }
228
326
 
229
327
  // ITER: read cache, compute relVel from current lv/av, apply impulse.
@@ -266,12 +364,43 @@ function iterateConstraint(
266
364
  const dvy = vBy - vAy
267
365
  const dvz = vBz - vAz
268
366
 
367
+ const sprAct = con.cacheLinSpringActive
368
+ const sprTgt = con.cacheLinSpringTarget
369
+ const limImp = con.cacheLinLimitImp
269
370
  for (let i = 0; i < 3; i++) {
270
- if (!act[i]) continue
371
+ if (!act[i] && !sprAct[i]) continue
271
372
  const o = i * 3
272
373
  const axx = axes[o + 0], axy = axes[o + 1], axz = axes[o + 2]
273
374
  const relVel = dvx * axx + dvy * axy + dvz * axz
274
- const j = (tgt[i] - relVel) * jac[i]
375
+ let j = 0
376
+
377
+ // Limit row. Locked axes (act 1) are bilateral equality joints; ranged
378
+ // axes in violation (act 2) are unilateral stops — accumulated impulse
379
+ // clamped to the corrective sign, so the stop pushes back into range but
380
+ // never pulls deeper or brakes natural recovery (a bilateral stop acts
381
+ // as a motor and pumps energy into swinging cloth).
382
+ if (act[i]) {
383
+ const target = tgt[i]
384
+ let dImp = LIMIT_SOFTNESS_LINEAR * (target - relVel) * jac[i]
385
+ if (act[i] === 2) {
386
+ const old = limImp[i]
387
+ let next = old + dImp
388
+ if (target > 0 ? next < 0 : next > 0) next = 0
389
+ dImp = next - old
390
+ limImp[i] = next
391
+ }
392
+ j += dImp
393
+ }
394
+
395
+ // Spring row: velocity-target drive; the deadbeat k-clamp at setup
396
+ // bounds its aggression. relVel is refreshed with the limit impulse
397
+ // applied just above (j·denom = j / jac) — driving the spring off the
398
+ // stale value double-corrects the DOF and overshoots every iteration.
399
+ if (sprAct[i]) {
400
+ const relVelNow = j !== 0 ? relVel + j / jac[i] : relVel
401
+ j += (sprTgt[i] - relVelNow) * jac[i]
402
+ }
403
+
275
404
  if (j === 0) continue
276
405
  if (imA > 0) {
277
406
  lv[ai + 0] -= j * imA * axx
@@ -305,6 +434,8 @@ function iterateConstraint(
305
434
  const o = i * 3
306
435
  const axx = angAxes[o + 0], axy = angAxes[o + 1], axz = angAxes[o + 2]
307
436
  const relAv = dax * axx + day * axy + daz * axz
437
+ // Spring drive (deadbeat-clamped at setup) plus, for small violations,
438
+ // the folded per-axis limit correction.
308
439
  const j = (angTgt[i] - relAv) * angJacInv
309
440
  if (j === 0) continue
310
441
  if (iiA > 0) {
@@ -318,6 +449,39 @@ function iterateConstraint(
318
449
  av[bi + 2] += j * iiB * axz
319
450
  }
320
451
  }
452
+
453
+ // Geodesic limit row: drive (ω_B − ω_A)·axis toward the correction target.
454
+ // Unilateral — the accumulated impulse can only push toward the legal
455
+ // region (target is always ≥ 0 along the corrective axis).
456
+ if (con.cacheAngLimActive) {
457
+ const lim = con.cacheAngLimAxis
458
+ const nx = lim[0], ny = lim[1], nz = lim[2]
459
+ // Re-read relAv — the spring rows above may have changed av.
460
+ const relAv =
461
+ (av[bi + 0] - av[ai + 0]) * nx +
462
+ (av[bi + 1] - av[ai + 1]) * ny +
463
+ (av[bi + 2] - av[ai + 2]) * nz
464
+ let j = LIMIT_SOFTNESS_ANGULAR * (con.cacheAngLimTarget - relAv) * angJacInv
465
+ if (con.cacheAngLimActive === 2) {
466
+ const old = con.cacheAngLimImp
467
+ let next = old + j
468
+ if (next < 0) next = 0
469
+ j = next - old
470
+ con.cacheAngLimImp = next
471
+ }
472
+ if (j !== 0) {
473
+ if (iiA > 0) {
474
+ av[ai + 0] -= j * iiA * nx
475
+ av[ai + 1] -= j * iiA * ny
476
+ av[ai + 2] -= j * iiA * nz
477
+ }
478
+ if (iiB > 0) {
479
+ av[bi + 0] += j * iiB * nx
480
+ av[bi + 1] += j * iiB * ny
481
+ av[bi + 2] += j * iiB * nz
482
+ }
483
+ }
484
+ }
321
485
  }
322
486
 
323
487
  // SETUP: pre-compute Jacobians, friction basis, and the bounce reference
@@ -552,6 +716,17 @@ function buildBodyMat(store: RigidBodyStore, i: number, out: Float32Array): void
552
716
  )
553
717
  }
554
718
 
719
+ // Quaternion of qx(x) ⊗ qy(y) ⊗ qz(z) (three.js 'XYZ' order).
720
+ function eulerXYZQuatInto(x: number, y: number, z: number, out: Float32Array): void {
721
+ const sx = Math.sin(x * 0.5), cx = Math.cos(x * 0.5)
722
+ const sy = Math.sin(y * 0.5), cy = Math.cos(y * 0.5)
723
+ const sz = Math.sin(z * 0.5), cz = Math.cos(z * 0.5)
724
+ out[0] = sx * cy * cz + cx * sy * sz
725
+ out[1] = cx * sy * cz - sx * cy * sz
726
+ out[2] = cx * cy * sz + sx * sy * cz
727
+ out[3] = cx * cy * cz - sx * sy * sz
728
+ }
729
+
555
730
  // Euler XYZ from a 3×3 rotation matrix (row-major elements).
556
731
  function matrixToEulerXYZ(
557
732
  r00: number, r01: number,
@@ -6,10 +6,15 @@ export enum RigidbodyShape {
6
6
  Capsule = 2,
7
7
  }
8
8
 
9
+ // PMX physics mode: 0 = follow bone, 1 = physics, 2 = physics + bone-position
10
+ // alignment. Mode 2 is DYNAMIC — the loader maps it to Dynamic with
11
+ // `aligned: true` (bone gets rotation only, body position re-pins to the
12
+ // animated bone each frame). Mapping the raw byte 1:1 onto this enum froze
13
+ // mode-2 bodies (most modern cloth rigs, and every 胸_回転 breast body).
9
14
  export enum RigidbodyType {
10
- Static = 0,
15
+ Static = 0, // follows bone (anchor)
11
16
  Dynamic = 1,
12
- Kinematic = 2,
17
+ Kinematic = 2, // follows bone (legacy alias; loader no longer emits it)
13
18
  }
14
19
 
15
20
  export interface Rigidbody {
@@ -28,6 +33,9 @@ export interface Rigidbody {
28
33
  restitution: number
29
34
  friction: number
30
35
  type: RigidbodyType
36
+ // PMX mode 2: dynamic body whose bone takes rotation only; the body's
37
+ // position re-pins to the animated bone each frame.
38
+ aligned?: boolean
31
39
  bodyOffsetMatrixInverse: Mat4 // Inverse of body offset matrix, used to sync rigidbody to bone
32
40
  bodyOffsetMatrix?: Mat4 // Cached non-inverse for performance (computed once during initialization)
33
41
  }
@@ -14,6 +14,12 @@ export class World {
14
14
  readonly gravity: Vec3
15
15
  solverIterations = 10
16
16
 
17
+ // Per-body damping factors pow(1−damping, dt), cached because damping and
18
+ // the fixed dt never change — two Math.pow per body per substep otherwise.
19
+ private dampCacheDt = -1
20
+ private linDampFactor: Float32Array | null = null
21
+ private angDampFactor: Float32Array | null = null
22
+
17
23
  constructor(gravity: Vec3) {
18
24
  this.gravity = new Vec3(gravity.x, gravity.y, gravity.z)
19
25
  }
@@ -43,15 +49,26 @@ export class World {
43
49
 
44
50
  // 1. Predict — gravity + damping. The pow form (vs the linear
45
51
  // 1−damping·dt approximation) stays stable at high PMX damping
46
- // values like 0.99.
52
+ // values like 0.99. Factors are cached (damping and dt are constant).
53
+ if (this.dampCacheDt !== dt || !this.linDampFactor || this.linDampFactor.length !== N) {
54
+ this.dampCacheDt = dt
55
+ this.linDampFactor = new Float32Array(N)
56
+ this.angDampFactor = new Float32Array(N)
57
+ for (let i = 0; i < N; i++) {
58
+ this.linDampFactor[i] = Math.pow(Math.max(0, 1 - ldamp[i]), dt)
59
+ this.angDampFactor[i] = Math.pow(Math.max(0, 1 - adamp[i]), dt)
60
+ }
61
+ }
62
+ const linDamp = this.linDampFactor
63
+ const angDamp = this.angDampFactor!
47
64
  for (let i = 0; i < N; i++) {
48
65
  if (types[i] !== RigidbodyType.Dynamic || invMass[i] <= 0) continue
49
66
  const i3 = i * 3
50
67
  lv[i3 + 0] += gx * dt
51
68
  lv[i3 + 1] += gy * dt
52
69
  lv[i3 + 2] += gz * dt
53
- const ld = Math.pow(Math.max(0, 1 - ldamp[i]), dt)
54
- const ad = Math.pow(Math.max(0, 1 - adamp[i]), dt)
70
+ const ld = linDamp[i]
71
+ const ad = angDamp[i]
55
72
  lv[i3 + 0] *= ld; lv[i3 + 1] *= ld; lv[i3 + 2] *= ld
56
73
  av[i3 + 0] *= ad; av[i3 + 1] *= ad; av[i3 + 2] *= ad
57
74
  }
@@ -101,13 +118,25 @@ export class World {
101
118
 
102
119
  // 5. Integrate. Cap angular velocity at π/2 per step — a high-impulse
103
120
  // contact spike on a low-inertia body would otherwise spin past π
104
- // in one step and trash the quaternion integration.
121
+ // in one step and trash the quaternion integration. Linear velocity
122
+ // is capped at 5 units per step for the same reason: an explosion
123
+ // guard far above what legit cloth motion ever reaches.
105
124
  const MAX_ANGVEL_DT = Math.PI * 0.5
125
+ const MAX_LINVEL_DT = 5
106
126
  for (let i = 0; i < N; i++) {
107
127
  if (types[i] !== RigidbodyType.Dynamic || invMass[i] <= 0) continue
108
128
  const i3 = i * 3
109
129
  const i4 = i * 4
110
130
 
131
+ const vx = lv[i3 + 0], vy = lv[i3 + 1], vz = lv[i3 + 2]
132
+ const vmag = Math.sqrt(vx * vx + vy * vy + vz * vz)
133
+ if (vmag * dt > MAX_LINVEL_DT) {
134
+ const scale = MAX_LINVEL_DT / (vmag * dt)
135
+ lv[i3 + 0] = vx * scale
136
+ lv[i3 + 1] = vy * scale
137
+ lv[i3 + 2] = vz * scale
138
+ }
139
+
111
140
  pos[i3 + 0] += lv[i3 + 0] * dt
112
141
  pos[i3 + 1] += lv[i3 + 1] * dt
113
142
  pos[i3 + 2] += lv[i3 + 2] * dt
package/src/pmx-loader.ts CHANGED
@@ -37,11 +37,22 @@ export class PmxLoader {
37
37
  private vertexCount: number = 0
38
38
  private rigidbodies: Rigidbody[] = []
39
39
  private joints: Joint[] = []
40
+ private warnings: string[] = []
40
41
 
41
42
  private constructor(buffer: ArrayBuffer) {
42
43
  this.view = new DataView(buffer)
43
44
  }
44
45
 
46
+ // Parse problems are non-fatal (the mesh may still render), but they must
47
+ // be queryable afterwards — a rig whose rigidbody section failed to parse
48
+ // silently loses physics, and console.warn alone drowns in browser log
49
+ // limits. Collected warnings ship on the Model (getLoadWarnings()).
50
+ private warn(message: string, error?: unknown): void {
51
+ const full = error instanceof Error ? `${message}: ${error.message}` : message
52
+ this.warnings.push(full)
53
+ console.warn(message, error ?? "")
54
+ }
55
+
45
56
  static async load(url: string): Promise<Model> {
46
57
  return PmxLoader.loadFromReader(createFetchAssetReader(), url)
47
58
  }
@@ -82,7 +93,7 @@ export class PmxLoader {
82
93
  const version = this.getFloat32()
83
94
  if (version < 2.0 || version > 2.2) {
84
95
  // Continue, but warn for unexpected version
85
- console.warn(`PMX version ${version} may not be fully supported`)
96
+ this.warn(`PMX version ${version} may not be fully supported`)
86
97
  }
87
98
 
88
99
  // PMX: globals count (uint8) followed by that many bytes describing encoding and index sizes
@@ -472,7 +483,7 @@ export class PmxLoader {
472
483
  }
473
484
  this.bones = bones
474
485
  } catch (e) {
475
- console.warn("Error parsing bones:", e)
486
+ this.warn("Error parsing bones:", e)
476
487
  this.bones = []
477
488
  }
478
489
  }
@@ -487,7 +498,7 @@ export class PmxLoader {
487
498
  const count = this.getInt32()
488
499
  if (count < 0 || count > 100000) {
489
500
  // Suspicious count, likely corrupted
490
- console.warn(`Suspicious morph count: ${count}`)
501
+ this.warn(`Suspicious morph count: ${count}`)
491
502
  this.morphs = []
492
503
  return
493
504
  }
@@ -623,11 +634,11 @@ export class PmxLoader {
623
634
  this.morphs.push(morph)
624
635
  } catch (e) {
625
636
  // If we fail to read a morph, skip it
626
- console.warn(`Error reading morph ${i}:`, e)
637
+ this.warn(`Error reading morph ${i}:`, e)
627
638
  }
628
639
  }
629
640
  } catch (e) {
630
- console.warn("Error parsing morphs:", e)
641
+ this.warn("Error parsing morphs:", e)
631
642
  this.morphs = []
632
643
  }
633
644
  }
@@ -669,13 +680,13 @@ export class PmxLoader {
669
680
  }
670
681
  } catch (e) {
671
682
  // If we fail to read a frame, stop skipping
672
- console.warn(`Error reading display frame ${i}:`, e)
683
+ this.warn(`Error reading display frame ${i}:`, e)
673
684
  return false
674
685
  }
675
686
  }
676
687
  return true
677
688
  } catch (e) {
678
- console.warn("Error skipping display frames:", e)
689
+ this.warn("Error skipping display frames:", e)
679
690
  return false
680
691
  }
681
692
  }
@@ -685,7 +696,7 @@ export class PmxLoader {
685
696
  // Note: morphs and display frames are already skipped in parse() before calling this
686
697
  // Check bounds before reading rigidbody count
687
698
  if (this.offset + 4 > this.view.buffer.byteLength) {
688
- console.warn("Not enough bytes for rigidbody count")
699
+ this.warn("Not enough bytes for rigidbody count")
689
700
  this.rigidbodies = []
690
701
  return
691
702
  }
@@ -693,7 +704,7 @@ export class PmxLoader {
693
704
  const count = this.getInt32()
694
705
  if (count < 0 || count > 10000) {
695
706
  // Suspicious count
696
- console.warn(`Suspicious rigidbody count: ${count}`)
707
+ this.warn(`Suspicious rigidbody count: ${count}`)
697
708
  this.rigidbodies = []
698
709
  return
699
710
  }
@@ -704,7 +715,7 @@ export class PmxLoader {
704
715
  try {
705
716
  // Check bounds before reading each rigidbody
706
717
  if (this.offset >= this.view.buffer.byteLength) {
707
- console.warn(`Reached end of buffer while reading rigidbody ${i} of ${count}`)
718
+ this.warn(`Reached end of buffer while reading rigidbody ${i} of ${count}`)
708
719
  break
709
720
  }
710
721
 
@@ -737,7 +748,12 @@ export class PmxLoader {
737
748
  const angularDamping = this.getFloat32()
738
749
  const restitution = this.getFloat32()
739
750
  const friction = this.getFloat32()
740
- const type = this.getUint8() // 0=static, 1=dynamic, 2=kinematic
751
+ // PMX physics mode: 0 = follow bone, 1 = physics,
752
+ // 2 = physics + bone-position alignment. Mode 2 is dynamic — it
753
+ // must NOT be cast onto RigidbodyType (2 = Kinematic there, which
754
+ // freezes the body to its bone and kills physics for most modern
755
+ // cloth rigs and every 胸_回転 breast body).
756
+ const mode = this.getUint8()
741
757
 
742
758
  this.rigidbodies.push({
743
759
  name,
@@ -754,17 +770,18 @@ export class PmxLoader {
754
770
  angularDamping,
755
771
  restitution,
756
772
  friction,
757
- type: type as RigidbodyType,
773
+ type: mode === 0 ? RigidbodyType.Static : RigidbodyType.Dynamic,
774
+ aligned: mode === 2,
758
775
  bodyOffsetMatrixInverse: Mat4.identity(),
759
776
  })
760
777
  } catch (e) {
761
- console.warn(`Error reading rigidbody ${i} of ${count}:`, e)
778
+ this.warn(`Error reading rigidbody ${i} of ${count}:`, e)
762
779
  // Stop parsing if we encounter an error
763
780
  break
764
781
  }
765
782
  }
766
783
  } catch (e) {
767
- console.warn("Error parsing rigidbodies:", e)
784
+ this.warn("Error parsing rigidbodies:", e)
768
785
  this.rigidbodies = []
769
786
  }
770
787
  }
@@ -773,14 +790,14 @@ export class PmxLoader {
773
790
  try {
774
791
  // Check bounds before reading joint count
775
792
  if (this.offset + 4 > this.view.buffer.byteLength) {
776
- console.warn("Not enough bytes for joint count")
793
+ this.warn("Not enough bytes for joint count")
777
794
  this.joints = []
778
795
  return
779
796
  }
780
797
 
781
798
  const count = this.getInt32()
782
799
  if (count < 0 || count > 10000) {
783
- console.warn(`Suspicious joint count: ${count}`)
800
+ this.warn(`Suspicious joint count: ${count}`)
784
801
  this.joints = []
785
802
  return
786
803
  }
@@ -791,7 +808,7 @@ export class PmxLoader {
791
808
  try {
792
809
  // Check bounds before reading each joint
793
810
  if (this.offset >= this.view.buffer.byteLength) {
794
- console.warn(`Reached end of buffer while reading joint ${i} of ${count}`)
811
+ this.warn(`Reached end of buffer while reading joint ${i} of ${count}`)
795
812
  break
796
813
  }
797
814
 
@@ -857,13 +874,13 @@ export class PmxLoader {
857
874
  springRotation: new Vec3(springRotX, springRotY, springRotZ), // Store as Vec3 (stiffness values)
858
875
  })
859
876
  } catch (e) {
860
- console.warn(`Error reading joint ${i} of ${count}:`, e)
877
+ this.warn(`Error reading joint ${i} of ${count}:`, e)
861
878
  // Stop parsing if we encounter an error
862
879
  break
863
880
  }
864
881
  }
865
882
  } catch (e) {
866
- console.warn("Error parsing joints:", e)
883
+ this.warn("Error parsing joints:", e)
867
884
  this.joints = []
868
885
  }
869
886
  }
@@ -1044,7 +1061,8 @@ export class PmxLoader {
1044
1061
  skinning,
1045
1062
  morphing,
1046
1063
  this.rigidbodies,
1047
- this.joints
1064
+ this.joints,
1065
+ this.warnings
1048
1066
  )
1049
1067
  }
1050
1068