reze-engine 0.15.0 → 0.16.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 +3 -3
  2. package/dist/camera.d.ts +2 -0
  3. package/dist/camera.d.ts.map +1 -1
  4. package/dist/camera.js +8 -3
  5. package/dist/engine.d.ts +19 -4
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +294 -27
  8. package/dist/gpu-profile.d.ts +19 -0
  9. package/dist/gpu-profile.d.ts.map +1 -0
  10. package/dist/gpu-profile.js +120 -0
  11. package/dist/ik-solver.d.ts.map +1 -1
  12. package/dist/ik-solver.js +13 -7
  13. package/dist/math.d.ts +2 -0
  14. package/dist/math.d.ts.map +1 -1
  15. package/dist/math.js +65 -46
  16. package/dist/model.d.ts +28 -1
  17. package/dist/model.d.ts.map +1 -1
  18. package/dist/model.js +259 -75
  19. package/dist/physics/body.d.ts +2 -0
  20. package/dist/physics/body.d.ts.map +1 -1
  21. package/dist/physics/body.js +30 -0
  22. package/dist/physics/constraint.d.ts +13 -0
  23. package/dist/physics/constraint.d.ts.map +1 -1
  24. package/dist/physics/constraint.js +13 -0
  25. package/dist/physics/contact.d.ts +28 -0
  26. package/dist/physics/contact.d.ts.map +1 -1
  27. package/dist/physics/contact.js +27 -32
  28. package/dist/physics/physics.d.ts +3 -0
  29. package/dist/physics/physics.d.ts.map +1 -1
  30. package/dist/physics/physics.js +57 -8
  31. package/dist/physics/profile.d.ts +18 -0
  32. package/dist/physics/profile.d.ts.map +1 -0
  33. package/dist/physics/profile.js +44 -0
  34. package/dist/physics/solver.d.ts.map +1 -1
  35. package/dist/physics/solver.js +436 -293
  36. package/dist/pmx-loader.d.ts.map +1 -1
  37. package/dist/pmx-loader.js +2 -30
  38. package/dist/shaders/materials/body.d.ts +1 -1
  39. package/dist/shaders/materials/body.d.ts.map +1 -1
  40. package/dist/shaders/materials/body.js +3 -1
  41. package/dist/shaders/materials/cloth_rough.d.ts +1 -1
  42. package/dist/shaders/materials/cloth_rough.d.ts.map +1 -1
  43. package/dist/shaders/materials/cloth_rough.js +3 -1
  44. package/dist/shaders/materials/cloth_smooth.d.ts +1 -1
  45. package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -1
  46. package/dist/shaders/materials/common.d.ts +2 -2
  47. package/dist/shaders/materials/common.d.ts.map +1 -1
  48. package/dist/shaders/materials/common.js +8 -0
  49. package/dist/shaders/materials/default.d.ts +1 -1
  50. package/dist/shaders/materials/default.d.ts.map +1 -1
  51. package/dist/shaders/materials/eye.d.ts +1 -1
  52. package/dist/shaders/materials/eye.d.ts.map +1 -1
  53. package/dist/shaders/materials/face.d.ts +1 -1
  54. package/dist/shaders/materials/face.d.ts.map +1 -1
  55. package/dist/shaders/materials/face.js +4 -1
  56. package/dist/shaders/materials/hair.d.ts +1 -1
  57. package/dist/shaders/materials/hair.d.ts.map +1 -1
  58. package/dist/shaders/materials/metal.d.ts +1 -1
  59. package/dist/shaders/materials/metal.d.ts.map +1 -1
  60. package/dist/shaders/materials/stockings.d.ts +1 -1
  61. package/dist/shaders/materials/stockings.d.ts.map +1 -1
  62. package/dist/shaders/materials/stockings.js +13 -9
  63. package/dist/shaders/passes/composite.d.ts +1 -1
  64. package/dist/shaders/passes/composite.d.ts.map +1 -1
  65. package/dist/shaders/passes/composite.js +18 -12
  66. package/dist/shaders/passes/morph.d.ts +2 -0
  67. package/dist/shaders/passes/morph.d.ts.map +1 -0
  68. package/dist/shaders/passes/morph.js +49 -0
  69. package/package.json +1 -1
  70. package/src/camera.ts +9 -3
  71. package/src/engine.ts +336 -29
  72. package/src/ik-solver.ts +14 -7
  73. package/src/math.ts +41 -51
  74. package/src/model.ts +288 -91
  75. package/src/physics/body.ts +29 -0
  76. package/src/physics/constraint.ts +30 -0
  77. package/src/physics/contact.ts +43 -30
  78. package/src/physics/physics.ts +58 -17
  79. package/src/physics/solver.ts +431 -284
  80. package/src/pmx-loader.ts +2 -35
  81. package/src/shaders/materials/body.ts +3 -1
  82. package/src/shaders/materials/cloth_rough.ts +3 -1
  83. package/src/shaders/materials/common.ts +8 -0
  84. package/src/shaders/materials/face.ts +4 -1
  85. package/src/shaders/materials/stockings.ts +13 -9
  86. package/src/shaders/passes/composite.ts +18 -12
  87. package/src/shaders/passes/morph.ts +50 -0
  88. package/dist/physics-debug.d.ts +0 -30
  89. package/dist/physics-debug.d.ts.map +0 -1
  90. package/dist/physics-debug.js +0 -526
  91. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  92. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  93. package/dist/shaders/passes/physics-debug.js +0 -69
package/src/model.ts CHANGED
@@ -19,6 +19,14 @@ import {
19
19
 
20
20
  const VERTEX_STRIDE = 8
21
21
 
22
+ // Animation-sampling scratch (applyPoseFromClip → convertVMDTranslationToLocal). These
23
+ // run sequentially and are not reentrant with the world-matrix scratch in math.ts, so
24
+ // plain module singletons are safe and let the per-bone sample path allocate nothing.
25
+ const _animSlerp = new Quat(0, 0, 0, 1)
26
+ const _animInterpT = new Vec3(0, 0, 0)
27
+ const _convOut = new Vec3(0, 0, 0)
28
+ const _convMat = new Float32Array(16)
29
+
22
30
  export interface Texture {
23
31
  path: string
24
32
  name: string
@@ -112,7 +120,17 @@ export interface Morph {
112
120
 
113
121
  export interface Morphing {
114
122
  morphs: Morph[]
115
- offsetsBuffer: Float32Array // Dense buffer: morphCount * vertexCount * 3 floats
123
+ }
124
+
125
+ // CSR inversion of vertex-morph offsets for the GPU compute pass (built once at load).
126
+ export interface MorphComputeData {
127
+ basePositions: Float32Array // vertexCount * 3
128
+ rowStart: Uint32Array // vertexCount + 1 (prefix offsets into the entry arrays)
129
+ colMorph: Uint32Array // entryCount (morph index per entry)
130
+ colOffset: Float32Array // entryCount * 3 (xyz offset per entry)
131
+ morphCount: number
132
+ vertexCount: number
133
+ entryCount: number
116
134
  }
117
135
 
118
136
  // Runtime skeleton pose state (updated each frame)
@@ -192,6 +210,18 @@ export class Model {
192
210
  private baseVertexData: Float32Array<ArrayBuffer> // Original vertex data before morphing
193
211
  private vertexCount: number
194
212
  private indexData: Uint32Array<ArrayBuffer>
213
+
214
+ // Morph state reused across frames (S1) + partial vertex-upload range tracking (S2, CPU path).
215
+ private morphEffectiveWeights?: Float32Array
216
+ private morphPrevMinVert = -1 // vertices touched by last applyMorphs (reset to base this pass)
217
+ private morphPrevMaxVert = -1
218
+ private morphPendingMinVert = -1 // accumulated range awaiting a GPU upload
219
+ private morphPendingMaxVert = -1
220
+ private morphUploadFull = true // first upload after load pushes the whole buffer once
221
+ // GPU morph path: when enabled (engine set up the compute buffers), applyMorphs only
222
+ // resolves effective weights and flags them dirty — the compute pass does the vertex work.
223
+ private gpuMorphEnabled = false
224
+ private morphWeightsDirty = false
195
225
  private textures: Texture[] = []
196
226
  private materials: Material[] = []
197
227
  // Static skeleton/skinning (not necessarily serialized yet)
@@ -225,6 +255,17 @@ export class Model {
225
255
  // Cached skin matrices array to avoid allocations in getSkinMatrices
226
256
  private skinMatricesArray?: Float32Array
227
257
 
258
+ // Static bone traversal order (parents before children), precomputed once at load.
259
+ // computeWorldMatrices replays this flat instead of recursing with a per-call
260
+ // visited-array + closure. Order depends only on parentIndex (static), so this
261
+ // reproduces the old recursion's finishing order exactly. See buildDeformOrder.
262
+ private deformOrder!: Int32Array
263
+
264
+ // Bind-pose absolute (world) position per bone, xyz packed. Static (bindTranslation
265
+ // accumulated down the hierarchy). Precomputed once so convertVMDTranslationToLocal
266
+ // stops re-deriving it recursively (with per-ancestor allocations) every frame.
267
+ private bindWorldPos!: Float32Array
268
+
228
269
  private tweenState!: TweenState
229
270
  private tweenTimeMs: number = 0 // Time tracking for tweens (milliseconds)
230
271
 
@@ -338,6 +379,59 @@ export class Model {
338
379
 
339
380
  this.runtimeSkeleton.ikChainInfo = ikChainInfo
340
381
  this.runtimeSkeleton.ikSolvers = ikSolvers
382
+
383
+ this.buildDeformOrder()
384
+ }
385
+
386
+ // Precompute the bone order that computeWorldMatrices iterates every frame: every
387
+ // bone appears after its parent. This is the exact finishing order the previous
388
+ // recursive computeWorld() produced (walk up the not-yet-emitted ancestor chain,
389
+ // then emit it top-down; ties broken by ascending index) — so behavior is identical,
390
+ // minus the per-frame closure + visited-array allocation and the recursion overhead.
391
+ private buildDeformOrder(): void {
392
+ const bones = this.skeleton.bones
393
+ const n = bones.length
394
+ const order = new Int32Array(n)
395
+ const done = new Uint8Array(n)
396
+ const stack: number[] = []
397
+ let k = 0
398
+ for (let i = 0; i < n; i++) {
399
+ if (done[i]) continue
400
+ stack.length = 0
401
+ let cur = i
402
+ // Collect the chain of not-yet-emitted ancestors up to the root (or a done one).
403
+ while (cur >= 0 && cur < n && !done[cur]) {
404
+ stack.push(cur)
405
+ cur = bones[cur].parentIndex
406
+ }
407
+ // Emit from the topmost ancestor down so parents precede children.
408
+ for (let s = stack.length - 1; s >= 0; s--) {
409
+ const b = stack[s]
410
+ done[b] = 1
411
+ order[k++] = b
412
+ }
413
+ }
414
+ this.deformOrder = order
415
+
416
+ // Accumulate bind-pose world positions in the same parent-before-child order and
417
+ // with the same add order as the old recursive computeBindPoseWorldPosition, so the
418
+ // downstream arithmetic stays bit-identical.
419
+ const bindWorld = new Float32Array(n * 3)
420
+ for (let idx = 0; idx < n; idx++) {
421
+ const i = order[idx]
422
+ const bt = bones[i].bindTranslation
423
+ const p = bones[i].parentIndex
424
+ if (p >= 0 && p < n) {
425
+ bindWorld[i * 3 + 0] = bindWorld[p * 3 + 0] + bt[0]
426
+ bindWorld[i * 3 + 1] = bindWorld[p * 3 + 1] + bt[1]
427
+ bindWorld[i * 3 + 2] = bindWorld[p * 3 + 2] + bt[2]
428
+ } else {
429
+ bindWorld[i * 3 + 0] = bt[0]
430
+ bindWorld[i * 3 + 1] = bt[1]
431
+ bindWorld[i * 3 + 2] = bt[2]
432
+ }
433
+ }
434
+ this.bindWorldPos = bindWorld
341
435
  }
342
436
 
343
437
  private initializeTweenBuffers(): void {
@@ -659,63 +753,49 @@ export class Model {
659
753
  }
660
754
 
661
755
  // VMD translation (world delta from bind pose) → bone local space; optional rotation for animation vs IK
756
+ // Returns a REUSED scratch Vec3 (_convOut) — callers must copy immediately (they do:
757
+ // .set() / destructure). Zero allocation; result is bit-identical to the previous
758
+ // recursive implementation (verified numerically).
662
759
  private convertVMDTranslationToLocal(boneIdx: number, vmdRelativeTranslation: Vec3, rotation?: Quat): Vec3 {
663
- const skeleton = this.skeleton
664
- const bones = skeleton.bones
665
- const localRot = this.runtimeSkeleton.localRotations
666
-
667
- // Compute bind pose world positions for all bones
668
- const computeBindPoseWorldPosition = (idx: number): Vec3 => {
669
- const bone = bones[idx]
670
- const bindPos = new Vec3(bone.bindTranslation[0], bone.bindTranslation[1], bone.bindTranslation[2])
671
- if (bone.parentIndex >= 0 && bone.parentIndex < bones.length) {
672
- const parentWorldPos = computeBindPoseWorldPosition(bone.parentIndex)
673
- return parentWorldPos.add(bindPos)
674
- } else {
675
- return bindPos
676
- }
677
- }
678
-
679
- const bone = bones[boneIdx]
680
-
681
- // VMD translation is relative to bind pose world position
682
- // targetWorldPos = bindPoseWorldPos + vmdRelativeTranslation
683
- const bindPoseWorldPos = computeBindPoseWorldPosition(boneIdx)
684
- const targetWorldPos = bindPoseWorldPos.add(vmdRelativeTranslation)
685
-
686
- // Convert target world position to local translation
687
- // We need parent's bind pose world position to transform to parent space
688
- let parentBindPoseWorldPos: Vec3
689
- if (bone.parentIndex >= 0) {
690
- parentBindPoseWorldPos = computeBindPoseWorldPosition(bone.parentIndex)
760
+ const bone = this.skeleton.bones[boneIdx]
761
+ const bindWorld = this.bindWorldPos
762
+ const bt = bone.bindTranslation
763
+ const p = bone.parentIndex
764
+
765
+ // afterBindTranslation = (bindWorld[bone] + vmd − bindWorld[parent]) bindTranslation.
766
+ // (Algebraically this reduces to vmd, since bindWorld[bone] = bindWorld[parent] +
767
+ // bindTranslation, but the explicit form keeps the result bit-identical to before.)
768
+ const bi3 = boneIdx * 3
769
+ const targetX = bindWorld[bi3 + 0] + vmdRelativeTranslation.x
770
+ const targetY = bindWorld[bi3 + 1] + vmdRelativeTranslation.y
771
+ const targetZ = bindWorld[bi3 + 2] + vmdRelativeTranslation.z
772
+ const px = p >= 0 ? bindWorld[p * 3 + 0] : 0
773
+ const py = p >= 0 ? bindWorld[p * 3 + 1] : 0
774
+ const pz = p >= 0 ? bindWorld[p * 3 + 2] : 0
775
+ const abx = targetX - px - bt[0]
776
+ const aby = targetY - py - bt[1]
777
+ const abz = targetZ - pz - bt[2]
778
+
779
+ // Inverse rotation = conjugate + normalize (matches localRotation.clone().conjugate()
780
+ // .normalize()), applied via a rotation matrix (matches Mat4.fromQuat). Uses animation
781
+ // rotation when provided so IK-modified localRot doesn't perturb the conversion.
782
+ const q = rotation ?? this.runtimeSkeleton.localRotations[boneIdx]
783
+ const qlen = Math.sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w)
784
+ let ix: number, iy: number, iz: number, iw: number
785
+ if (qlen === 0) {
786
+ ix = 0; iy = 0; iz = 0; iw = 1
691
787
  } else {
692
- parentBindPoseWorldPos = Vec3.zeros()
788
+ const inv = 1 / qlen
789
+ ix = -q.x * inv; iy = -q.y * inv; iz = -q.z * inv; iw = q.w * inv
693
790
  }
694
-
695
- // Transform target world position to parent's local space
696
- // In bind pose, parent's world matrix is just a translation
697
- const parentSpacePos = targetWorldPos.subtract(parentBindPoseWorldPos)
698
-
699
- // Subtract bindTranslation to get position after bind translation
700
- const afterBindTranslation = parentSpacePos.subtract(
701
- new Vec3(bone.bindTranslation[0], bone.bindTranslation[1], bone.bindTranslation[2])
702
- )
703
-
704
- // Apply inverse rotation to get local translation
705
- // Use provided rotation (animation rotation) or fall back to current localRotation
706
- // Using animation rotation prevents conflicts when IK modifies the rotation
707
- const localRotation = rotation ?? localRot[boneIdx]
708
- // Clone to avoid mutating, then conjugate and normalize
709
- const invRotation = localRotation.clone().conjugate().normalize()
710
- const rotationMat = Mat4.fromQuat(invRotation.x, invRotation.y, invRotation.z, invRotation.w)
711
- const rm = rotationMat.values
712
- const localTranslation = new Vec3(
713
- rm[0] * afterBindTranslation.x + rm[4] * afterBindTranslation.y + rm[8] * afterBindTranslation.z,
714
- rm[1] * afterBindTranslation.x + rm[5] * afterBindTranslation.y + rm[9] * afterBindTranslation.z,
715
- rm[2] * afterBindTranslation.x + rm[6] * afterBindTranslation.y + rm[10] * afterBindTranslation.z
791
+ Mat4.fromQuatInto(ix, iy, iz, iw, _convMat, 0)
792
+ const rm = _convMat
793
+ _convOut.setXYZ(
794
+ rm[0] * abx + rm[4] * aby + rm[8] * abz,
795
+ rm[1] * abx + rm[5] * aby + rm[9] * abz,
796
+ rm[2] * abx + rm[6] * aby + rm[10] * abz
716
797
  )
717
-
718
- return localTranslation
798
+ return _convOut
719
799
  }
720
800
 
721
801
  getWorldMatrices(): Mat4[] {
@@ -826,15 +906,16 @@ export class Model {
826
906
  }
827
907
 
828
908
  private applyMorphs(): void {
829
- // Reset vertex data to base positions
830
- this.vertexData.set(this.baseVertexData)
831
-
832
909
  const vertexCount = this.vertexCount
833
910
  const morphCount = this.morphing.morphs.length
834
911
  const weights = this.runtimeMorph.weights
835
912
 
836
- // First pass: Compute effective weights for all morphs (handling group morphs)
837
- const effectiveWeights = new Float32Array(morphCount)
913
+ // Effective weights (group-morph resolution + clamp). Both paths need these: the GPU
914
+ // path uploads them for the compute pass; the CPU path applies them below. Reused buffer.
915
+ if (!this.morphEffectiveWeights || this.morphEffectiveWeights.length !== morphCount) {
916
+ this.morphEffectiveWeights = new Float32Array(morphCount)
917
+ }
918
+ const effectiveWeights = this.morphEffectiveWeights
838
919
  effectiveWeights.set(weights) // Start with direct weights
839
920
 
840
921
  // Apply group morphs: group morph weight * ratio affects referenced morphs
@@ -845,20 +926,33 @@ export class Model {
845
926
  if (groupWeight > 0.0001) {
846
927
  for (const ref of morph.groupReferences) {
847
928
  if (ref.morphIndex >= 0 && ref.morphIndex < morphCount) {
848
- // Add group morph's contribution to the referenced morph
849
929
  effectiveWeights[ref.morphIndex] += groupWeight * ref.ratio
850
930
  }
851
931
  }
852
932
  }
853
933
  }
854
934
  }
855
-
856
- // Clamp effective weights to [0, 1]
857
935
  for (let i = 0; i < morphCount; i++) {
858
936
  effectiveWeights[i] = Math.max(0, Math.min(1, effectiveWeights[i]))
859
937
  }
860
938
 
861
- // Second pass: Apply vertex morphs with their effective weights
939
+ // GPU path: the compute pass applies the vertex offsets from these weights.
940
+ if (this.gpuMorphEnabled) {
941
+ this.morphWeightsDirty = true
942
+ return
943
+ }
944
+
945
+ // ── CPU path ── Reset only the vertices morphed by the previous pass back to base
946
+ // (targeted reset; vertexData never diverges from base outside that range).
947
+ if (this.morphPrevMaxVert >= 0) {
948
+ const s = this.morphPrevMinVert * VERTEX_STRIDE
949
+ const e = (this.morphPrevMaxVert + 1) * VERTEX_STRIDE
950
+ this.vertexData.set(this.baseVertexData.subarray(s, e), s)
951
+ }
952
+
953
+ // Apply vertex morphs, tracking the touched vertex-index range for partial upload.
954
+ let curMinVert = -1
955
+ let curMaxVert = -1
862
956
  for (let morphIdx = 0; morphIdx < morphCount; morphIdx++) {
863
957
  const effectiveWeight = effectiveWeights[morphIdx]
864
958
  if (effectiveWeight === 0 || effectiveWeight < 0.0001) continue
@@ -866,28 +960,140 @@ export class Model {
866
960
  const morph = this.morphing.morphs[morphIdx]
867
961
  if (morph.type !== 1) continue // Only process vertex morphs
868
962
 
869
- // For vertex morphs, iterate through vertices that have offsets
870
963
  for (const vertexOffset of morph.vertexOffsets) {
871
964
  const vIdx = vertexOffset.vertexIndex
872
965
  if (vIdx < 0 || vIdx >= vertexCount) continue
873
966
 
874
- // Get morph offset for this vertex
875
967
  const offsetX = vertexOffset.positionOffset[0]
876
968
  const offsetY = vertexOffset.positionOffset[1]
877
969
  const offsetZ = vertexOffset.positionOffset[2]
878
-
879
- // Skip if offset is zero
880
970
  if (Math.abs(offsetX) < 0.0001 && Math.abs(offsetY) < 0.0001 && Math.abs(offsetZ) < 0.0001) {
881
971
  continue
882
972
  }
883
973
 
884
- // Apply weighted offset to vertex position (positions are at stride 0, 8, 16, ...)
885
974
  const vertexIdx = vIdx * VERTEX_STRIDE
886
975
  this.vertexData[vertexIdx] += offsetX * effectiveWeight
887
976
  this.vertexData[vertexIdx + 1] += offsetY * effectiveWeight
888
977
  this.vertexData[vertexIdx + 2] += offsetZ * effectiveWeight
978
+
979
+ if (curMinVert < 0 || vIdx < curMinVert) curMinVert = vIdx
980
+ if (vIdx > curMaxVert) curMaxVert = vIdx
981
+ }
982
+ }
983
+
984
+ let dirtyMin = curMinVert
985
+ let dirtyMax = curMaxVert
986
+ if (this.morphPrevMaxVert >= 0) {
987
+ if (dirtyMin < 0 || this.morphPrevMinVert < dirtyMin) dirtyMin = this.morphPrevMinVert
988
+ if (this.morphPrevMaxVert > dirtyMax) dirtyMax = this.morphPrevMaxVert
989
+ }
990
+ if (dirtyMin >= 0 && dirtyMax >= 0) {
991
+ this.morphPendingMinVert =
992
+ this.morphPendingMinVert < 0 ? dirtyMin : Math.min(this.morphPendingMinVert, dirtyMin)
993
+ this.morphPendingMaxVert =
994
+ this.morphPendingMaxVert < 0 ? dirtyMax : Math.max(this.morphPendingMaxVert, dirtyMax)
995
+ }
996
+ this.morphPrevMinVert = curMinVert
997
+ this.morphPrevMaxVert = curMaxVert
998
+ }
999
+
1000
+ // ── GPU morph path support ──
1001
+ // Called by the engine once it has created the compute buffers for this model; switches
1002
+ // applyMorphs to the weights-only branch.
1003
+ enableGpuMorphs(): void {
1004
+ this.gpuMorphEnabled = true
1005
+ }
1006
+
1007
+ // True (once) when morph weights changed since the last check — the engine then uploads
1008
+ // getEffectiveMorphWeights() and dispatches the compute pass.
1009
+ consumeMorphWeightsDirty(): boolean {
1010
+ const d = this.morphWeightsDirty
1011
+ this.morphWeightsDirty = false
1012
+ return d
1013
+ }
1014
+
1015
+ // Effective (group-resolved, clamped) morph weights for GPU upload. Ensures they're
1016
+ // computed at least once even before the first weight change.
1017
+ getEffectiveMorphWeights(): Float32Array {
1018
+ if (!this.morphEffectiveWeights) this.applyMorphs()
1019
+ return this.morphEffectiveWeights ?? new Float32Array(0)
1020
+ }
1021
+
1022
+ // Build the CSR inversion of vertex-morph offsets for the GPU compute pass. Returns null
1023
+ // when the model has no vertex-morph offsets (no GPU path needed). Entries for each vertex
1024
+ // are emitted in ascending morph-index order, matching the CPU accumulation order.
1025
+ buildMorphComputeData(): MorphComputeData | null {
1026
+ const V = this.vertexCount
1027
+ const M = this.morphing.morphs.length
1028
+ const morphs = this.morphing.morphs
1029
+ const EPS = 0.0001
1030
+
1031
+ const isLive = (o: VertexMorphOffset): boolean =>
1032
+ o.vertexIndex >= 0 &&
1033
+ o.vertexIndex < V &&
1034
+ (Math.abs(o.positionOffset[0]) >= EPS ||
1035
+ Math.abs(o.positionOffset[1]) >= EPS ||
1036
+ Math.abs(o.positionOffset[2]) >= EPS)
1037
+
1038
+ const counts = new Uint32Array(V)
1039
+ for (let m = 0; m < M; m++) {
1040
+ const morph = morphs[m]
1041
+ if (morph.type !== 1) continue
1042
+ for (const o of morph.vertexOffsets) if (isLive(o)) counts[o.vertexIndex]++
1043
+ }
1044
+
1045
+ const rowStart = new Uint32Array(V + 1)
1046
+ let acc = 0
1047
+ for (let v = 0; v < V; v++) {
1048
+ rowStart[v] = acc
1049
+ acc += counts[v]
1050
+ }
1051
+ rowStart[V] = acc
1052
+ const E = acc
1053
+ if (E === 0) return null
1054
+
1055
+ const colMorph = new Uint32Array(E)
1056
+ const colOffset = new Float32Array(E * 3)
1057
+ const fill = new Uint32Array(V)
1058
+ for (let m = 0; m < M; m++) {
1059
+ const morph = morphs[m]
1060
+ if (morph.type !== 1) continue
1061
+ for (const o of morph.vertexOffsets) {
1062
+ if (!isLive(o)) continue
1063
+ const v = o.vertexIndex
1064
+ const p = rowStart[v] + fill[v]++
1065
+ colMorph[p] = m
1066
+ colOffset[p * 3] = o.positionOffset[0]
1067
+ colOffset[p * 3 + 1] = o.positionOffset[1]
1068
+ colOffset[p * 3 + 2] = o.positionOffset[2]
889
1069
  }
890
1070
  }
1071
+
1072
+ const basePositions = new Float32Array(V * 3)
1073
+ for (let v = 0; v < V; v++) {
1074
+ const vi = v * VERTEX_STRIDE
1075
+ basePositions[v * 3] = this.baseVertexData[vi]
1076
+ basePositions[v * 3 + 1] = this.baseVertexData[vi + 1]
1077
+ basePositions[v * 3 + 2] = this.baseVertexData[vi + 2]
1078
+ }
1079
+
1080
+ return { basePositions, rowStart, colMorph, colOffset, morphCount: M, vertexCount: V, entryCount: E }
1081
+ }
1082
+
1083
+ // Consume the pending morph vertex-upload range for the engine. Returns null when a
1084
+ // full upload is needed (first time after load, or nothing tracked), else the inclusive
1085
+ // [minVert, maxVert] slice that changed. Resets pending state.
1086
+ consumeVertexUploadRange(): { minVert: number; maxVert: number } | null {
1087
+ if (this.morphUploadFull || this.morphPendingMaxVert < 0) {
1088
+ this.morphUploadFull = false
1089
+ this.morphPendingMinVert = -1
1090
+ this.morphPendingMaxVert = -1
1091
+ return null
1092
+ }
1093
+ const range = { minVert: this.morphPendingMinVert, maxVert: this.morphPendingMaxVert }
1094
+ this.morphPendingMinVert = -1
1095
+ this.morphPendingMaxVert = -1
1096
+ return range
891
1097
  }
892
1098
 
893
1099
  private buildClipFromVmdKeyFrames(vmdKeyFrames: VMDKeyFrame[]): AnimationClip {
@@ -1143,13 +1349,13 @@ export class Model {
1143
1349
  const interp = frameB.interpolation
1144
1350
 
1145
1351
  const rotT = interpolateControlPoints(interp.rotation, gradient)
1146
- const rotation = Quat.slerp(frameA.rotation, frameB.rotation, rotT)
1352
+ const rotation = Quat.slerpInto(frameA.rotation, frameB.rotation, rotT, _animSlerp)
1147
1353
 
1148
1354
  const txWeight = interpolateControlPoints(interp.translationX, gradient)
1149
1355
  const tyWeight = interpolateControlPoints(interp.translationY, gradient)
1150
1356
  const tzWeight = interpolateControlPoints(interp.translationZ, gradient)
1151
1357
 
1152
- const interpolatedVMDTranslation = new Vec3(
1358
+ const interpolatedVMDTranslation = _animInterpT.setXYZ(
1153
1359
  frameA.translation.x + (frameB.translation.x - frameA.translation.x) * txWeight,
1154
1360
  frameA.translation.y + (frameB.translation.y - frameA.translation.y) * tyWeight,
1155
1361
  frameA.translation.z + (frameB.translation.z - frameA.translation.z) * tzWeight
@@ -1235,10 +1441,13 @@ export class Model {
1235
1441
  if (!ikChainInfo) return
1236
1442
 
1237
1443
  // Solve each IK solver sequentially, ensuring consistent state between solvers
1444
+ let firstSolver = true
1238
1445
  for (const solver of ikSolvers) {
1239
- // Recompute ALL world matrices before each solver starts
1240
- // This ensures each solver sees the effects of previous solvers on localRotations
1241
- this.computeWorldMatrices()
1446
+ // Each solver must see the effects of previous solvers on localRotations, so
1447
+ // recompute world matrices between solvers. The first solver is skipped: the
1448
+ // caller (update) already computed them and nothing has changed localRotations yet.
1449
+ if (!firstSolver) this.computeWorldMatrices()
1450
+ firstSolver = false
1242
1451
 
1243
1452
  // Clear computed set for this solver's pass
1244
1453
  this.ikComputedSet.clear()
@@ -1388,21 +1597,13 @@ export class Model {
1388
1597
 
1389
1598
  if (boneCount === 0) return
1390
1599
 
1391
- // Local computed array (avoids instance field overhead)
1392
- const computed = new Array<boolean>(boneCount).fill(false)
1393
-
1394
- const computeWorld = (i: number): void => {
1395
- if (computed[i]) return
1396
-
1600
+ // Flat traversal in precomputed order: every bone's parent is already done, so no
1601
+ // per-bone visited check, no recursion, and no per-call allocation. Same per-bone
1602
+ // math as before. Scratch slots are safe to reuse since there's no reentrancy now.
1603
+ const order = this.deformOrder
1604
+ for (let k = 0; k < boneCount; k++) {
1605
+ const i = order[k]
1397
1606
  const b = bones[i]
1398
- if (b.parentIndex >= boneCount) {
1399
- console.warn(`[RZM] bone ${i} parent out of range: ${b.parentIndex}`)
1400
- }
1401
-
1402
- // Ensure parent is computed FIRST, before we touch any scratch buffers.
1403
- // Recursion may itself use scratchMat4Values[0] / scratchQuat; doing it up
1404
- // front keeps the current frame's scratch slots untouched when we use them below.
1405
- if (b.parentIndex >= 0 && !computed[b.parentIndex]) computeWorld(b.parentIndex)
1406
1607
 
1407
1608
  const boneRot = localRot[i]
1408
1609
  let fx = boneRot.x, fy = boneRot.y, fz = boneRot.z, fw = boneRot.w
@@ -1467,10 +1668,6 @@ export class Model {
1467
1668
  } else {
1468
1669
  worldMat.values.set(localMVals)
1469
1670
  }
1470
- computed[i] = true
1471
1671
  }
1472
-
1473
- // Process all bones (recursion handles dependencies automatically)
1474
- for (let i = 0; i < boneCount; i++) computeWorld(i)
1475
1672
  }
1476
1673
  }
@@ -40,6 +40,12 @@ export class RigidBodyStore {
40
40
  readonly bodyOffsetInverse: Float32Array // 16*N column-major
41
41
  private boneOffsetsReady = false
42
42
 
43
+ // Flat list of (i, j) pairs that survive the static-static + group/mask
44
+ // filter. None of those inputs change after construction, so building this
45
+ // once collapses 60k pair tests/step (349 bodies) down to a few thousand.
46
+ // Built lazily on first access.
47
+ private collisionPairs: Uint16Array | null = null
48
+
43
49
  constructor(rigidbodies: Rigidbody[]) {
44
50
  const N = rigidbodies.length
45
51
  this.count = N
@@ -247,6 +253,29 @@ export class RigidBodyStore {
247
253
  isBoneOffsetsReady(): boolean {
248
254
  return this.boneOffsetsReady
249
255
  }
256
+
257
+ // Pair-filter inputs (invMass, group, mask) are immutable post-construction,
258
+ // so build the candidate-pair list once and reuse every step.
259
+ getCollisionPairs(): Uint16Array {
260
+ if (this.collisionPairs !== null) return this.collisionPairs
261
+ const N = this.count
262
+ const invMass = this.invMass
263
+ const group = this.collisionGroup
264
+ const mask = this.willCollideMask
265
+ const buf: number[] = []
266
+ for (let i = 0; i < N; i++) {
267
+ const gi = group[i]
268
+ const mi = mask[i]
269
+ const dynA = invMass[i] > 0
270
+ for (let j = i + 1; j < N; j++) {
271
+ if (!dynA && invMass[j] === 0) continue
272
+ if ((mi & group[j]) === 0 || (mask[j] & gi) === 0) continue
273
+ buf.push(i, j)
274
+ }
275
+ }
276
+ this.collisionPairs = new Uint16Array(buf)
277
+ return this.collisionPairs
278
+ }
250
279
  }
251
280
 
252
281
  const _scratchA = new Float32Array(16)
@@ -26,6 +26,23 @@ export interface SixDofSpringConstraint {
26
26
  springEnabled: Uint8Array // length 6
27
27
  springStiffness: Float32Array // length 6 (k)
28
28
  equilibriumPoint: Float32Array// length 6, baked at setup time
29
+
30
+ // Per-substep cache. Filled by solver's setup pass once before SI iters,
31
+ // read by the velocity-only iter loop. None of these depend on lv/av — only
32
+ // on pos/ori/inertia which are constant during solve.
33
+ cacheSkip: boolean // both bodies static — skip entirely
34
+ cacheLeverA: Float32Array // 3: rA = anchor − posA (world-space)
35
+ cacheLeverB: Float32Array // 3
36
+ cacheLinAxes: Float32Array // 9: 3 linear axes × xyz, world-space
37
+ cacheLinCrossA: Float32Array // 9: (rA × ax) per axis
38
+ cacheLinCrossB: Float32Array // 9
39
+ cacheLinJacInv: Float32Array // 3: 1/(im+im+cA²·ii+cB²·ii) per axis
40
+ cacheLinTargetVel: Float32Array // 3: limit ERP + spring drive, signed
41
+ cacheLinActive: Uint8Array // 3
42
+ cacheAngAxes: Float32Array // 9
43
+ cacheAngTargetVel: Float32Array // 3
44
+ cacheAngActive: Uint8Array // 3
45
+ cacheAngJacInv: number // 1 — same for all 3 angular axes
29
46
  }
30
47
 
31
48
  export const STOP_ERP = 0.475
@@ -102,6 +119,19 @@ export function buildConstraints(
102
119
  springEnabled,
103
120
  springStiffness,
104
121
  equilibriumPoint: new Float32Array(6),
122
+ cacheSkip: false,
123
+ cacheLeverA: new Float32Array(3),
124
+ cacheLeverB: new Float32Array(3),
125
+ cacheLinAxes: new Float32Array(9),
126
+ cacheLinCrossA: new Float32Array(9),
127
+ cacheLinCrossB: new Float32Array(9),
128
+ cacheLinJacInv: new Float32Array(3),
129
+ cacheLinTargetVel: new Float32Array(3),
130
+ cacheLinActive: new Uint8Array(3),
131
+ cacheAngAxes: new Float32Array(9),
132
+ cacheAngTargetVel: new Float32Array(3),
133
+ cacheAngActive: new Uint8Array(3),
134
+ cacheAngJacInv: 0,
105
135
  })
106
136
  }
107
137