reze-engine 0.13.0 → 0.13.2

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.
package/src/math.ts CHANGED
@@ -69,6 +69,54 @@ export class Vec3 {
69
69
  this.z = other.z
70
70
  return this
71
71
  }
72
+
73
+ setXYZ(x: number, y: number, z: number): Vec3 {
74
+ this.x = x
75
+ this.y = y
76
+ this.z = z
77
+ return this
78
+ }
79
+
80
+ // out = a - b (no allocation)
81
+ static subtractInto(a: Vec3, b: Vec3, out: Vec3): Vec3 {
82
+ out.x = a.x - b.x
83
+ out.y = a.y - b.y
84
+ out.z = a.z - b.z
85
+ return out
86
+ }
87
+
88
+ // out = a × b (no allocation). Safe when out === a or out === b.
89
+ static crossInto(a: Vec3, b: Vec3, out: Vec3): Vec3 {
90
+ const ax = a.x, ay = a.y, az = a.z
91
+ const bx = b.x, by = b.y, bz = b.z
92
+ out.x = ay * bz - az * by
93
+ out.y = az * bx - ax * bz
94
+ out.z = ax * by - ay * bx
95
+ return out
96
+ }
97
+
98
+ // Read translation from Mat4 values array (column-major) into out.
99
+ static setFromMat4Translation(m: Float32Array, out: Vec3): Vec3 {
100
+ out.x = m[12]
101
+ out.y = m[13]
102
+ out.z = m[14]
103
+ return out
104
+ }
105
+
106
+ // Transform normal by the upper-left 3x3 of a Mat4 (column-major) into out.
107
+ // Safe when out === normal.
108
+ static transformMat4RotationInto(normal: Vec3, m: Float32Array, out: Vec3): Vec3 {
109
+ const nx = normal.x, ny = normal.y, nz = normal.z
110
+ out.x = m[0] * nx + m[4] * ny + m[8] * nz
111
+ out.y = m[1] * nx + m[5] * ny + m[9] * nz
112
+ out.z = m[2] * nx + m[6] * ny + m[10] * nz
113
+ return out
114
+ }
115
+
116
+ // In-place normalize returning length squared info via Vec3. Alias for normalize() but explicit.
117
+ normalizeInPlace(): Vec3 {
118
+ return this.normalize()
119
+ }
72
120
  }
73
121
 
74
122
  export class Quat {
@@ -204,6 +252,71 @@ export class Quat {
204
252
  return new Quat(s0 * a.x + s1 * bx, s0 * a.y + s1 * by, s0 * a.z + s1 * bz, s0 * a.w + s1 * bw)
205
253
  }
206
254
 
255
+ // out = a * b (quaternion multiplication, rotation composition).
256
+ // Safe when out === a or out === b.
257
+ static multiplyInto(a: Quat, b: Quat, out: Quat): Quat {
258
+ const ax = a.x, ay = a.y, az = a.z, aw = a.w
259
+ const bx = b.x, by = b.y, bz = b.z, bw = b.w
260
+ out.x = aw * bx + ax * bw + ay * bz - az * by
261
+ out.y = aw * by - ax * bz + ay * bw + az * bx
262
+ out.z = aw * bz + ax * by - ay * bx + az * bw
263
+ out.w = aw * bw - ax * bx - ay * by - az * bz
264
+ return out
265
+ }
266
+
267
+ // out = quat from axis (unnormalized) and angle.
268
+ static fromAxisAngleInto(ax: number, ay: number, az: number, angle: number, out: Quat): Quat {
269
+ const len = Math.sqrt(ax * ax + ay * ay + az * az)
270
+ const invLen = len > 0 ? 1 / len : 0
271
+ const nx = ax * invLen, ny = ay * invLen, nz = az * invLen
272
+ const half = angle * 0.5
273
+ const s = Math.sin(half), c = Math.cos(half)
274
+ out.x = nx * s
275
+ out.y = ny * s
276
+ out.z = nz * s
277
+ out.w = c
278
+ return out
279
+ }
280
+
281
+ // out = slerp(a, b, t). Safe when out === a or out === b.
282
+ static slerpInto(a: Quat, b: Quat, t: number, out: Quat): Quat {
283
+ let cos = a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w
284
+ let bx = b.x, by = b.y, bz = b.z, bw = b.w
285
+ if (cos < 0) {
286
+ cos = -cos
287
+ bx = -bx; by = -by; bz = -bz; bw = -bw
288
+ }
289
+ if (cos > 0.9995) {
290
+ const x = a.x + t * (bx - a.x)
291
+ const y = a.y + t * (by - a.y)
292
+ const z = a.z + t * (bz - a.z)
293
+ const w = a.w + t * (bw - a.w)
294
+ const invLen = 1 / Math.hypot(x, y, z, w)
295
+ out.x = x * invLen; out.y = y * invLen; out.z = z * invLen; out.w = w * invLen
296
+ return out
297
+ }
298
+ const theta0 = Math.acos(cos)
299
+ const sinTheta0 = Math.sin(theta0)
300
+ const theta = theta0 * t
301
+ const s0 = Math.sin(theta0 - theta) / sinTheta0
302
+ const s1 = Math.sin(theta) / sinTheta0
303
+ out.x = s0 * a.x + s1 * bx
304
+ out.y = s0 * a.y + s1 * by
305
+ out.z = s0 * a.z + s1 * bz
306
+ out.w = s0 * a.w + s1 * bw
307
+ return out
308
+ }
309
+
310
+ setXYZW(x: number, y: number, z: number, w: number): Quat {
311
+ this.x = x; this.y = y; this.z = z; this.w = w
312
+ return this
313
+ }
314
+
315
+ setIdentity(): Quat {
316
+ this.x = 0; this.y = 0; this.z = 0; this.w = 1
317
+ return this
318
+ }
319
+
207
320
  // Convert Euler angles to quaternion (ZXY order, left-handed, PMX format)
208
321
  static fromEuler(rotX: number, rotY: number, rotZ: number): Quat {
209
322
  const cx = Math.cos(rotX * 0.5)
@@ -371,6 +484,116 @@ export class Mat4 {
371
484
  return new Mat4(this.values.slice())
372
485
  }
373
486
 
487
+ // Write rotation matrix from quaternion into existing Float32Array (column-major).
488
+ static fromQuatInto(x: number, y: number, z: number, w: number, out: Float32Array, offset: number = 0): void {
489
+ const x2 = x + x, y2 = y + y, z2 = z + z
490
+ const xx = x * x2, xy = x * y2, xz = x * z2
491
+ const yy = y * y2, yz = y * z2, zz = z * z2
492
+ const wx = w * x2, wy = w * y2, wz = w * z2
493
+ out[offset + 0] = 1 - (yy + zz)
494
+ out[offset + 1] = xy + wz
495
+ out[offset + 2] = xz - wy
496
+ out[offset + 3] = 0
497
+ out[offset + 4] = xy - wz
498
+ out[offset + 5] = 1 - (xx + zz)
499
+ out[offset + 6] = yz + wx
500
+ out[offset + 7] = 0
501
+ out[offset + 8] = xz + wy
502
+ out[offset + 9] = yz - wx
503
+ out[offset + 10] = 1 - (xx + yy)
504
+ out[offset + 11] = 0
505
+ out[offset + 12] = 0
506
+ out[offset + 13] = 0
507
+ out[offset + 14] = 0
508
+ out[offset + 15] = 1
509
+ }
510
+
511
+ // Fused local transform: out = T(bindT) · R(quat) · T(localT).
512
+ // Result translation = bindT + R * localT; rotation column block = R.
513
+ // Column-major. Zero allocations.
514
+ static localTransformInto(
515
+ bx: number, by: number, bz: number,
516
+ qx: number, qy: number, qz: number, qw: number,
517
+ lx: number, ly: number, lz: number,
518
+ out: Float32Array
519
+ ): void {
520
+ const x2 = qx + qx, y2 = qy + qy, z2 = qz + qz
521
+ const xx = qx * x2, xy = qx * y2, xz = qx * z2
522
+ const yy = qy * y2, yz = qy * z2, zz = qz * z2
523
+ const wx = qw * x2, wy = qw * y2, wz = qw * z2
524
+ const m00 = 1 - (yy + zz), m01 = xy + wz, m02 = xz - wy
525
+ const m10 = xy - wz, m11 = 1 - (xx + zz), m12 = yz + wx
526
+ const m20 = xz + wy, m21 = yz - wx, m22 = 1 - (xx + yy)
527
+ out[0] = m00; out[1] = m01; out[2] = m02; out[3] = 0
528
+ out[4] = m10; out[5] = m11; out[6] = m12; out[7] = 0
529
+ out[8] = m20; out[9] = m21; out[10] = m22; out[11] = 0
530
+ out[12] = bx + m00 * lx + m10 * ly + m20 * lz
531
+ out[13] = by + m01 * lx + m11 * ly + m21 * lz
532
+ out[14] = bz + m02 * lx + m12 * ly + m22 * lz
533
+ out[15] = 1
534
+ }
535
+
536
+ // Write position+rotation transform into existing Float32Array.
537
+ static fromPositionRotationInto(
538
+ px: number, py: number, pz: number,
539
+ qx: number, qy: number, qz: number, qw: number,
540
+ out: Float32Array
541
+ ): void {
542
+ Mat4.fromQuatInto(qx, qy, qz, qw, out, 0)
543
+ out[12] = px
544
+ out[13] = py
545
+ out[14] = pz
546
+ }
547
+
548
+ // In-place 4x4 inverse into out array. Returns true on success, false if singular (out untouched).
549
+ static inverseInto(m: Float32Array, out: Float32Array): boolean {
550
+ const a00 = m[0], a01 = m[1], a02 = m[2], a03 = m[3]
551
+ const a10 = m[4], a11 = m[5], a12 = m[6], a13 = m[7]
552
+ const a20 = m[8], a21 = m[9], a22 = m[10], a23 = m[11]
553
+ const a30 = m[12], a31 = m[13], a32 = m[14], a33 = m[15]
554
+ const b00 = a00 * a11 - a01 * a10
555
+ const b01 = a00 * a12 - a02 * a10
556
+ const b02 = a00 * a13 - a03 * a10
557
+ const b03 = a01 * a12 - a02 * a11
558
+ const b04 = a01 * a13 - a03 * a11
559
+ const b05 = a02 * a13 - a03 * a12
560
+ const b06 = a20 * a31 - a21 * a30
561
+ const b07 = a20 * a32 - a22 * a30
562
+ const b08 = a20 * a33 - a23 * a30
563
+ const b09 = a21 * a32 - a22 * a31
564
+ const b10 = a21 * a33 - a23 * a31
565
+ const b11 = a22 * a33 - a23 * a32
566
+ let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06
567
+ if (Math.abs(det) < 1e-10) return false
568
+ det = 1.0 / det
569
+ out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det
570
+ out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det
571
+ out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det
572
+ out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det
573
+ out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det
574
+ out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det
575
+ out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det
576
+ out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det
577
+ out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det
578
+ out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det
579
+ out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det
580
+ out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det
581
+ out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det
582
+ out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det
583
+ out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det
584
+ out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det
585
+ return true
586
+ }
587
+
588
+ // Copy only the rotation (upper-left 3x3) of src into dst, zero out translation, identity w.
589
+ // Column-major in both.
590
+ static copyRotationInto(src: Float32Array, dst: Float32Array): void {
591
+ dst[0] = src[0]; dst[1] = src[1]; dst[2] = src[2]; dst[3] = 0
592
+ dst[4] = src[4]; dst[5] = src[5]; dst[6] = src[6]; dst[7] = 0
593
+ dst[8] = src[8]; dst[9] = src[9]; dst[10] = src[10]; dst[11] = 0
594
+ dst[12] = 0; dst[13] = 0; dst[14] = 0; dst[15] = 1
595
+ }
596
+
374
597
  static fromQuat(x: number, y: number, z: number, w: number): Mat4 {
375
598
  // Column-major rotation matrix from quaternion (matches glMatrix/WGSL)
376
599
  const out = new Float32Array(16)
@@ -424,6 +647,46 @@ export class Mat4 {
424
647
  return Mat4.toQuatFromArray(this.values, 0)
425
648
  }
426
649
 
650
+ // Extract quaternion from matrix array into an existing Quat (no allocation).
651
+ static toQuatFromArrayInto(m: Float32Array, offset: number, out: Quat): Quat {
652
+ const m00 = m[offset + 0], m01 = m[offset + 4], m02 = m[offset + 8]
653
+ const m10 = m[offset + 1], m11 = m[offset + 5], m12 = m[offset + 9]
654
+ const m20 = m[offset + 2], m21 = m[offset + 6], m22 = m[offset + 10]
655
+ const trace = m00 + m11 + m22
656
+ let x = 0, y = 0, z = 0, w = 1
657
+ if (trace > 0) {
658
+ const s = Math.sqrt(trace + 1.0) * 2
659
+ w = 0.25 * s
660
+ x = (m21 - m12) / s
661
+ y = (m02 - m20) / s
662
+ z = (m10 - m01) / s
663
+ } else if (m00 > m11 && m00 > m22) {
664
+ const s = Math.sqrt(1.0 + m00 - m11 - m22) * 2
665
+ w = (m21 - m12) / s
666
+ x = 0.25 * s
667
+ y = (m01 + m10) / s
668
+ z = (m02 + m20) / s
669
+ } else if (m11 > m22) {
670
+ const s = Math.sqrt(1.0 + m11 - m00 - m22) * 2
671
+ w = (m02 - m20) / s
672
+ x = (m01 + m10) / s
673
+ y = 0.25 * s
674
+ z = (m12 + m21) / s
675
+ } else {
676
+ const s = Math.sqrt(1.0 + m22 - m00 - m11) * 2
677
+ w = (m10 - m01) / s
678
+ x = (m02 + m20) / s
679
+ y = (m12 + m21) / s
680
+ z = 0.25 * s
681
+ }
682
+ const invLen = 1 / Math.hypot(x, y, z, w)
683
+ out.x = x * invLen
684
+ out.y = y * invLen
685
+ out.z = z * invLen
686
+ out.w = w * invLen
687
+ return out
688
+ }
689
+
427
690
  // Static method to extract quaternion from matrix array (avoids creating Mat4 object)
428
691
  static toQuatFromArray(m: Float32Array, offset: number): Quat {
429
692
  const m00 = m[offset + 0],
@@ -567,3 +830,31 @@ export class Mat4 {
567
830
  }
568
831
  }
569
832
 
833
+ // Preallocated scratch instances for hot paths. Each subsystem should use its own
834
+ // slot to avoid cross-call stomping. Bump the count if more call sites need scratch.
835
+ export const scratchMat4Values: Float32Array[] = [
836
+ new Float32Array(16),
837
+ new Float32Array(16),
838
+ new Float32Array(16),
839
+ new Float32Array(16),
840
+ new Float32Array(16),
841
+ new Float32Array(16),
842
+ ]
843
+
844
+ export const scratchVec3: Vec3[] = [
845
+ new Vec3(0, 0, 0),
846
+ new Vec3(0, 0, 0),
847
+ new Vec3(0, 0, 0),
848
+ new Vec3(0, 0, 0),
849
+ new Vec3(0, 0, 0),
850
+ new Vec3(0, 0, 0),
851
+ new Vec3(0, 0, 0),
852
+ new Vec3(0, 0, 0),
853
+ ]
854
+
855
+ export const scratchQuat: Quat[] = [
856
+ new Quat(0, 0, 0, 1),
857
+ new Quat(0, 0, 0, 1),
858
+ new Quat(0, 0, 0, 1),
859
+ new Quat(0, 0, 0, 1),
860
+ ]
package/src/model.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Mat4, Quat, Vec3 } from "./math"
1
+ import { Mat4, Quat, Vec3, scratchMat4Values, scratchQuat } from "./math"
2
2
  import { Engine } from "./engine"
3
3
  import { joinAssetPath, type AssetReader } from "./asset-reader"
4
4
  import { Rigidbody, Joint } from "./physics"
@@ -167,6 +167,27 @@ export class Model {
167
167
  this._name = value
168
168
  }
169
169
 
170
+ // Root transform public API. Instant setters — no tween baked in; wrap in
171
+ // your own lerp if you need smoothing. Changes are applied on the next
172
+ // getSkinMatrices() call (once per frame during rendering).
173
+ get position(): Vec3 {
174
+ return this._position
175
+ }
176
+
177
+ get rotation(): Quat {
178
+ return this._rotation
179
+ }
180
+
181
+ setPosition(position: Vec3): void {
182
+ this._position.set(position)
183
+ this.rootMatrixDirty = true
184
+ }
185
+
186
+ setRotation(rotation: Quat): void {
187
+ this._rotation.set(rotation)
188
+ this.rootMatrixDirty = true
189
+ }
190
+
170
191
  private vertexData: Float32Array<ArrayBuffer>
171
192
  private baseVertexData: Float32Array<ArrayBuffer> // Original vertex data before morphing
172
193
  private vertexCount: number
@@ -191,9 +212,15 @@ export class Model {
191
212
  private runtimeMorph!: MorphRuntime
192
213
  private morphsDirty: boolean = false // Flag indicating if morphs need to be applied
193
214
 
194
- // Cached identity matrices to avoid allocations in computeWorldMatrices
195
- private cachedIdentityMat1 = Mat4.identity()
196
- private cachedIdentityMat2 = Mat4.identity()
215
+ // Root transform model's placement in world space, independent of bones.
216
+ // Folded into skin matrices (see getSkinMatrices) so every pass (main VS,
217
+ // shadow VS, any future skinned pass) sees it without per-shader plumbing.
218
+ // Skip-when-identity flag avoids the extra mat mul per bone when unused.
219
+ private _position: Vec3 = Vec3.zeros()
220
+ private _rotation: Quat = Quat.identity()
221
+ private rootMatrixValues: Float32Array = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1])
222
+ private rootMatrixDirty: boolean = false
223
+ private rootIsIdentity: boolean = true
197
224
 
198
225
  // Cached skin matrices array to avoid allocations in getSkinMatrices
199
226
  private skinMatricesArray?: Float32Array
@@ -687,12 +714,32 @@ export class Model {
687
714
 
688
715
  const skinMatrices = this.skinMatricesArray
689
716
 
690
- // Compute skin matrices: skinMatrix = worldMatrix × inverseBindMatrix
691
- for (let i = 0; i < boneCount; i++) {
692
- const worldMat = worldMats[i]
693
- const invBindOffset = i * 16
694
- const skinOffset = i * 16
695
- Mat4.multiplyArrays(worldMat.values, 0, invBindMats, invBindOffset, skinMatrices, skinOffset)
717
+ // Rebuild root matrix + cache identity-shortcut flag only when pos/rot changed.
718
+ if (this.rootMatrixDirty) {
719
+ const p = this._position, r = this._rotation
720
+ Mat4.fromPositionRotationInto(p.x, p.y, p.z, r.x, r.y, r.z, r.w, this.rootMatrixValues)
721
+ this.rootIsIdentity =
722
+ p.x === 0 && p.y === 0 && p.z === 0 &&
723
+ r.x === 0 && r.y === 0 && r.z === 0 && r.w === 1
724
+ this.rootMatrixDirty = false
725
+ }
726
+
727
+ if (this.rootIsIdentity) {
728
+ // skinMatrix = worldMatrix × inverseBindMatrix
729
+ for (let i = 0; i < boneCount; i++) {
730
+ const off = i * 16
731
+ Mat4.multiplyArrays(worldMats[i].values, 0, invBindMats, off, skinMatrices, off)
732
+ }
733
+ } else {
734
+ // skinMatrix = rootMatrix × worldMatrix × inverseBindMatrix
735
+ // Two-mul path. scratchMat4Values[1] — [0] is owned by computeWorldMatrices.
736
+ const rootVals = this.rootMatrixValues
737
+ const tmp = scratchMat4Values[1]
738
+ for (let i = 0; i < boneCount; i++) {
739
+ const off = i * 16
740
+ Mat4.multiplyArrays(rootVals, 0, worldMats[i].values, 0, tmp, 0)
741
+ Mat4.multiplyArrays(tmp, 0, invBindMats, off, skinMatrices, off)
742
+ }
696
743
  }
697
744
 
698
745
  return skinMatrices
@@ -1205,17 +1252,24 @@ export class Model {
1205
1252
  }
1206
1253
 
1207
1254
  // Get base rotation
1208
- let boneRot = localRot[boneIndex]
1255
+ const baseRot = localRot[boneIndex]
1256
+ let fx = baseRot.x, fy = baseRot.y, fz = baseRot.z, fw = baseRot.w
1209
1257
 
1210
- // Apply IK rotation if requested
1258
+ // Apply IK rotation if requested: finalRot = ik * base, then normalize
1211
1259
  if (applyIK && ikChainInfo) {
1212
1260
  const chainInfo = ikChainInfo[boneIndex]
1213
1261
  if (chainInfo?.ikRotation) {
1214
- boneRot = chainInfo.ikRotation.multiply(boneRot).normalize()
1262
+ const ik = chainInfo.ikRotation
1263
+ const nx = ik.w * fx + ik.x * fw + ik.y * fz - ik.z * fy
1264
+ const ny = ik.w * fy - ik.x * fz + ik.y * fw + ik.z * fx
1265
+ const nz = ik.w * fz + ik.x * fy - ik.y * fx + ik.z * fw
1266
+ const nw = ik.w * fw - ik.x * fx - ik.y * fy - ik.z * fz
1267
+ const len = Math.sqrt(nx * nx + ny * ny + nz * nz + nw * nw)
1268
+ const inv = len > 0 ? 1 / len : 0
1269
+ fx = nx * inv; fy = ny * inv; fz = nz * inv; fw = nw * inv
1215
1270
  }
1216
1271
  }
1217
1272
 
1218
- let rotateM = Mat4.fromQuat(boneRot.x, boneRot.y, boneRot.z, boneRot.w)
1219
1273
  let addLocalTx = 0, addLocalTy = 0, addLocalTz = 0
1220
1274
 
1221
1275
  // Handle append transformations (same logic as computeWorldMatrices)
@@ -1231,26 +1285,29 @@ export class Model {
1231
1285
 
1232
1286
  if (hasRatio) {
1233
1287
  if (b.appendRotate) {
1234
- // Get append parent's rotation
1235
- // During IK solving, use only base local rotation (not IK rotations) to avoid
1236
- // conflicts with IK rotations that are still being computed incrementally
1237
- // IK rotations will be applied to localRotations after IK solving completes
1288
+ // Recurse first (may touch scratch); all scratch use below happens after it unwinds
1238
1289
  if (appendParentIdx >= 0) {
1239
- // Compute append parent's world matrix for dependency order, but use base rotation for append
1240
1290
  this.computeSingleBoneWorldMatrix(appendParentIdx, applyIK)
1241
1291
  }
1242
1292
 
1243
- // Use append parent's base local rotation only (IK rotations are applied after solving)
1244
- let appendRot = localRot[appendParentIdx]
1245
-
1293
+ const appendRot = localRot[appendParentIdx]
1246
1294
  let ax = appendRot.x, ay = appendRot.y, az = appendRot.z
1247
1295
  const aw = appendRot.w
1248
1296
  const absRatio = ratio < 0 ? -ratio : ratio
1249
1297
  if (ratio < 0) { ax = -ax; ay = -ay; az = -az }
1250
1298
 
1251
- const appendQuat = new Quat(ax, ay, az, aw)
1252
- const result = Quat.slerp(Quat.identity(), appendQuat, absRatio)
1253
- rotateM = Mat4.fromQuat(result.x, result.y, result.z, result.w).multiply(rotateM)
1299
+ // slerp(identity, appendQuat, absRatio) into scratchQuat[1]
1300
+ scratchQuat[0].setXYZW(ax, ay, az, aw)
1301
+ scratchQuat[2].setIdentity()
1302
+ Quat.slerpInto(scratchQuat[2], scratchQuat[0], absRatio, scratchQuat[1])
1303
+
1304
+ // finalRot = slerpResult * finalRot (rotation composition as quat mul)
1305
+ const sx = scratchQuat[1].x, sy = scratchQuat[1].y, sz = scratchQuat[1].z, sw = scratchQuat[1].w
1306
+ const nx = sw * fx + sx * fw + sy * fz - sz * fy
1307
+ const ny = sw * fy - sx * fz + sy * fw + sz * fx
1308
+ const nz = sw * fz + sx * fy - sy * fx + sz * fw
1309
+ const nw = sw * fw - sx * fx - sy * fy - sz * fz
1310
+ fx = nx; fy = ny; fz = nz; fw = nw
1254
1311
  }
1255
1312
 
1256
1313
  if (b.appendMove) {
@@ -1267,18 +1324,21 @@ export class Model {
1267
1324
  const localTy = boneTrans.y + addLocalTy
1268
1325
  const localTz = boneTrans.z + addLocalTz
1269
1326
 
1270
- this.cachedIdentityMat1
1271
- .setIdentity()
1272
- .translateInPlace(b.bindTranslation[0], b.bindTranslation[1], b.bindTranslation[2])
1273
- this.cachedIdentityMat2.setIdentity().translateInPlace(localTx, localTy, localTz)
1274
- const localM = this.cachedIdentityMat1.multiply(rotateM).multiply(this.cachedIdentityMat2)
1327
+ // Fused local transform: T_bind · R(finalRot) · T_local → scratchMat4Values[0]
1328
+ const localMVals = scratchMat4Values[0]
1329
+ Mat4.localTransformInto(
1330
+ b.bindTranslation[0], b.bindTranslation[1], b.bindTranslation[2],
1331
+ fx, fy, fz, fw,
1332
+ localTx, localTy, localTz,
1333
+ localMVals
1334
+ )
1275
1335
 
1276
1336
  const worldMat = worldMats[boneIndex]
1277
1337
  if (b.parentIndex >= 0) {
1278
1338
  const parentMat = worldMats[b.parentIndex]
1279
- Mat4.multiplyArrays(parentMat.values, 0, localM.values, 0, worldMat.values, 0)
1339
+ Mat4.multiplyArrays(parentMat.values, 0, localMVals, 0, worldMat.values, 0)
1280
1340
  } else {
1281
- worldMat.values.set(localM.values)
1341
+ worldMat.values.set(localMVals)
1282
1342
  }
1283
1343
  }
1284
1344
 
@@ -1302,13 +1362,15 @@ export class Model {
1302
1362
  console.warn(`[RZM] bone ${i} parent out of range: ${b.parentIndex}`)
1303
1363
  }
1304
1364
 
1365
+ // Ensure parent is computed FIRST, before we touch any scratch buffers.
1366
+ // Recursion may itself use scratchMat4Values[0] / scratchQuat; doing it up
1367
+ // front keeps the current frame's scratch slots untouched when we use them below.
1368
+ if (b.parentIndex >= 0 && !computed[b.parentIndex]) computeWorld(b.parentIndex)
1369
+
1305
1370
  const boneRot = localRot[i]
1306
- let rotateM = Mat4.fromQuat(boneRot.x, boneRot.y, boneRot.z, boneRot.w)
1307
- let addLocalTx = 0,
1308
- addLocalTy = 0,
1309
- addLocalTz = 0
1371
+ let fx = boneRot.x, fy = boneRot.y, fz = boneRot.z, fw = boneRot.w
1372
+ let addLocalTx = 0, addLocalTy = 0, addLocalTz = 0
1310
1373
 
1311
- // Optimized append rotation check - only check necessary conditions
1312
1374
  const appendParentIdx = b.appendParentIndex
1313
1375
  const hasAppend =
1314
1376
  b.appendRotate && appendParentIdx !== undefined && appendParentIdx >= 0 && appendParentIdx < boneCount
@@ -1320,19 +1382,22 @@ export class Model {
1320
1382
  if (hasRatio) {
1321
1383
  if (b.appendRotate) {
1322
1384
  const appendRot = localRot[appendParentIdx]
1323
- let ax = appendRot.x
1324
- let ay = appendRot.y
1325
- let az = appendRot.z
1385
+ let ax = appendRot.x, ay = appendRot.y, az = appendRot.z
1326
1386
  const aw = appendRot.w
1327
1387
  const absRatio = ratio < 0 ? -ratio : ratio
1328
- if (ratio < 0) {
1329
- ax = -ax
1330
- ay = -ay
1331
- az = -az
1332
- }
1333
- const appendQuat = new Quat(ax, ay, az, aw)
1334
- const result = Quat.slerp(Quat.identity(), appendQuat, absRatio)
1335
- rotateM = Mat4.fromQuat(result.x, result.y, result.z, result.w).multiply(rotateM)
1388
+ if (ratio < 0) { ax = -ax; ay = -ay; az = -az }
1389
+
1390
+ scratchQuat[0].setXYZW(ax, ay, az, aw)
1391
+ scratchQuat[2].setIdentity()
1392
+ Quat.slerpInto(scratchQuat[2], scratchQuat[0], absRatio, scratchQuat[1])
1393
+
1394
+ // finalRot = slerpResult * finalRot (quat mul)
1395
+ const sx = scratchQuat[1].x, sy = scratchQuat[1].y, sz = scratchQuat[1].z, sw = scratchQuat[1].w
1396
+ const nx = sw * fx + sx * fw + sy * fz - sz * fy
1397
+ const ny = sw * fy - sx * fz + sy * fw + sz * fx
1398
+ const nz = sw * fz + sx * fy - sy * fx + sz * fw
1399
+ const nw = sw * fw - sx * fx - sy * fy - sz * fz
1400
+ fx = nx; fy = ny; fz = nz; fw = nw
1336
1401
  }
1337
1402
 
1338
1403
  if (b.appendMove) {
@@ -1345,26 +1410,25 @@ export class Model {
1345
1410
  }
1346
1411
  }
1347
1412
 
1348
- // Build local matrix: identity + bind translation, then rotation, then local translation, then append translation
1349
1413
  const boneTrans = localTrans[i]
1350
1414
  const localTx = boneTrans.x + addLocalTx
1351
1415
  const localTy = boneTrans.y + addLocalTy
1352
1416
  const localTz = boneTrans.z + addLocalTz
1353
- this.cachedIdentityMat1
1354
- .setIdentity()
1355
- .translateInPlace(b.bindTranslation[0], b.bindTranslation[1], b.bindTranslation[2])
1356
- this.cachedIdentityMat2.setIdentity().translateInPlace(localTx, localTy, localTz)
1357
- const localM = this.cachedIdentityMat1.multiply(rotateM).multiply(this.cachedIdentityMat2)
1417
+
1418
+ const localMVals = scratchMat4Values[0]
1419
+ Mat4.localTransformInto(
1420
+ b.bindTranslation[0], b.bindTranslation[1], b.bindTranslation[2],
1421
+ fx, fy, fz, fw,
1422
+ localTx, localTy, localTz,
1423
+ localMVals
1424
+ )
1358
1425
 
1359
1426
  const worldMat = worldMats[i]
1360
1427
  if (b.parentIndex >= 0) {
1361
- const p = b.parentIndex
1362
- if (!computed[p]) computeWorld(p)
1363
- const parentMat = worldMats[p]
1364
- // Multiply parent world matrix by local matrix
1365
- Mat4.multiplyArrays(parentMat.values, 0, localM.values, 0, worldMat.values, 0)
1428
+ const parentMat = worldMats[b.parentIndex]
1429
+ Mat4.multiplyArrays(parentMat.values, 0, localMVals, 0, worldMat.values, 0)
1366
1430
  } else {
1367
- worldMat.values.set(localM.values)
1431
+ worldMat.values.set(localMVals)
1368
1432
  }
1369
1433
  computed[i] = true
1370
1434
  }