reze-engine 0.2.18 → 0.3.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.
package/src/model.ts CHANGED
@@ -1,421 +1,930 @@
1
- import { Mat4, Quat, easeInOut } from "./math"
2
- import { Rigidbody, Joint } from "./physics"
3
-
4
- const VERTEX_STRIDE = 8
5
-
6
- export interface Texture {
7
- path: string
8
- name: string
9
- }
10
-
11
- export interface Material {
12
- name: string
13
- diffuse: [number, number, number, number]
14
- specular: [number, number, number]
15
- ambient: [number, number, number]
16
- shininess: number
17
- diffuseTextureIndex: number
18
- normalTextureIndex: number
19
- sphereTextureIndex: number
20
- sphereMode: number
21
- toonTextureIndex: number
22
- edgeFlag: number
23
- edgeColor: [number, number, number, number]
24
- edgeSize: number
25
- vertexCount: number
26
- isEye?: boolean // New: marks eye materials
27
- isFace?: boolean // New: marks face/skin materials
28
- isHair?: boolean // New: marks hair materials
29
- }
30
-
31
- export interface Bone {
32
- name: string
33
- parentIndex: number // -1 if no parent
34
- bindTranslation: [number, number, number]
35
- children: number[] // child bone indices (built on skeleton creation)
36
- appendParentIndex?: number // index of the bone to inherit from
37
- appendRatio?: number // 0..1
38
- appendRotate?: boolean
39
- appendMove?: boolean
40
- }
41
-
42
- export interface Skeleton {
43
- bones: Bone[]
44
- inverseBindMatrices: Float32Array // One inverse-bind matrix per bone (column-major mat4, 16 floats per bone)
45
- }
46
-
47
- export interface Skinning {
48
- joints: Uint16Array // length = vertexCount * 4, bone indices per vertex
49
- weights: Uint8Array // UNORM8, length = vertexCount * 4, sums ~ 255 per-vertex
50
- }
51
-
52
- // Runtime skeleton pose state (updated each frame)
53
- export interface SkeletonRuntime {
54
- nameIndex: Record<string, number> // Cached lookup: bone name -> bone index (built on initialization)
55
- localRotations: Float32Array // quat per bone (x,y,z,w) length = boneCount*4
56
- localTranslations: Float32Array // vec3 per bone length = boneCount*3
57
- worldMatrices: Float32Array // mat4 per bone length = boneCount*16
58
- computedBones: boolean[] // length = boneCount
59
- }
60
-
61
- // Rotation tween state per bone
62
- interface RotationTweenState {
63
- active: Uint8Array // 0/1 per bone
64
- startQuat: Float32Array // quat per bone (x,y,z,w)
65
- targetQuat: Float32Array // quat per bone (x,y,z,w)
66
- startTimeMs: Float32Array // one float per bone (ms)
67
- durationMs: Float32Array // one float per bone (ms)
68
- }
69
-
70
- export class Model {
71
- private vertexData: Float32Array<ArrayBuffer>
72
- private vertexCount: number
73
- private indexData: Uint32Array<ArrayBuffer>
74
- private textures: Texture[] = []
75
- private materials: Material[] = []
76
- // Static skeleton/skinning (not necessarily serialized yet)
77
- private skeleton: Skeleton
78
- private skinning: Skinning
79
-
80
- // Physics data from PMX
81
- private rigidbodies: Rigidbody[] = []
82
- private joints: Joint[] = []
83
-
84
- // Runtime skeleton pose state (updated each frame)
85
- private runtimeSkeleton!: SkeletonRuntime
86
-
87
- // Cached identity matrices to avoid allocations in computeWorldMatrices
88
- private cachedIdentityMat1 = Mat4.identity()
89
- private cachedIdentityMat2 = Mat4.identity()
90
-
91
- private rotTweenState!: RotationTweenState
92
-
93
- constructor(
94
- vertexData: Float32Array<ArrayBuffer>,
95
- indexData: Uint32Array<ArrayBuffer>,
96
- textures: Texture[],
97
- materials: Material[],
98
- skeleton: Skeleton,
99
- skinning: Skinning,
100
- rigidbodies: Rigidbody[] = [],
101
- joints: Joint[] = []
102
- ) {
103
- this.vertexData = vertexData
104
- this.vertexCount = vertexData.length / VERTEX_STRIDE
105
- this.indexData = indexData
106
- this.textures = textures
107
- this.materials = materials
108
- this.skeleton = skeleton
109
- this.skinning = skinning
110
- this.rigidbodies = rigidbodies
111
- this.joints = joints
112
-
113
- if (this.skeleton.bones.length == 0) {
114
- throw new Error("Model has no bones")
115
- }
116
-
117
- this.initializeRuntimeSkeleton()
118
- this.initializeRotTweenBuffers()
119
- }
120
-
121
- private initializeRuntimeSkeleton(): void {
122
- const boneCount = this.skeleton.bones.length
123
-
124
- this.runtimeSkeleton = {
125
- localRotations: new Float32Array(boneCount * 4),
126
- localTranslations: new Float32Array(boneCount * 3),
127
- worldMatrices: new Float32Array(boneCount * 16),
128
- nameIndex: this.skeleton.bones.reduce((acc, bone, index) => {
129
- acc[bone.name] = index
130
- return acc
131
- }, {} as Record<string, number>),
132
- computedBones: new Array(boneCount).fill(false),
133
- }
134
-
135
- const rotations = this.runtimeSkeleton.localRotations
136
- for (let i = 0; i < this.skeleton.bones.length; i++) {
137
- const qi = i * 4
138
- if (rotations[qi + 3] === 0) {
139
- rotations[qi] = 0
140
- rotations[qi + 1] = 0
141
- rotations[qi + 2] = 0
142
- rotations[qi + 3] = 1
143
- }
144
- }
145
- }
146
-
147
- private initializeRotTweenBuffers(): void {
148
- const n = this.skeleton.bones.length
149
- this.rotTweenState = {
150
- active: new Uint8Array(n),
151
- startQuat: new Float32Array(n * 4),
152
- targetQuat: new Float32Array(n * 4),
153
- startTimeMs: new Float32Array(n),
154
- durationMs: new Float32Array(n),
155
- }
156
- }
157
-
158
- private updateRotationTweens(): void {
159
- const state = this.rotTweenState
160
- const now = performance.now()
161
- const rotations = this.runtimeSkeleton.localRotations
162
- const boneCount = this.skeleton.bones.length
163
-
164
- for (let i = 0; i < boneCount; i++) {
165
- if (state.active[i] !== 1) continue
166
-
167
- const startMs = state.startTimeMs[i]
168
- const durMs = Math.max(1, state.durationMs[i])
169
- const t = Math.max(0, Math.min(1, (now - startMs) / durMs))
170
- const e = easeInOut(t)
171
-
172
- const qi = i * 4
173
- const startQuat = new Quat(
174
- state.startQuat[qi],
175
- state.startQuat[qi + 1],
176
- state.startQuat[qi + 2],
177
- state.startQuat[qi + 3]
178
- )
179
- const targetQuat = new Quat(
180
- state.targetQuat[qi],
181
- state.targetQuat[qi + 1],
182
- state.targetQuat[qi + 2],
183
- state.targetQuat[qi + 3]
184
- )
185
- const result = Quat.slerp(startQuat, targetQuat, e)
186
-
187
- rotations[qi] = result.x
188
- rotations[qi + 1] = result.y
189
- rotations[qi + 2] = result.z
190
- rotations[qi + 3] = result.w
191
-
192
- if (t >= 1) state.active[i] = 0
193
- }
194
- }
195
-
196
- // Get interleaved vertex data for GPU upload
197
- // Format: [x,y,z, nx,ny,nz, u,v, x,y,z, nx,ny,nz, u,v, ...]
198
- getVertices(): Float32Array<ArrayBuffer> {
199
- return this.vertexData
200
- }
201
-
202
- // Get texture information
203
- getTextures(): Texture[] {
204
- return this.textures
205
- }
206
-
207
- // Get material information
208
- getMaterials(): Material[] {
209
- return this.materials
210
- }
211
-
212
- // Get vertex count
213
- getVertexCount(): number {
214
- return this.vertexCount
215
- }
216
-
217
- // Get index data for GPU upload
218
- getIndices(): Uint32Array<ArrayBuffer> {
219
- return this.indexData
220
- }
221
-
222
- // Accessors for skeleton/skinning
223
- getSkeleton(): Skeleton {
224
- return this.skeleton
225
- }
226
-
227
- getSkinning(): Skinning {
228
- return this.skinning
229
- }
230
-
231
- // Accessors for physics data
232
- getRigidbodies(): Rigidbody[] {
233
- return this.rigidbodies
234
- }
235
-
236
- getJoints(): Joint[] {
237
- return this.joints
238
- }
239
-
240
- // ------- Bone helpers (public API) -------
241
-
242
- getBoneNames(): string[] {
243
- return this.skeleton.bones.map((b) => b.name)
244
- }
245
-
246
- rotateBones(names: string[], quats: Quat[], durationMs?: number): void {
247
- const state = this.rotTweenState
248
- const normalized = quats.map((q) => q.normalize())
249
- const now = performance.now()
250
- const dur = durationMs && durationMs > 0 ? durationMs : 0
251
-
252
- for (let i = 0; i < names.length; i++) {
253
- const name = names[i]
254
- const idx = this.runtimeSkeleton.nameIndex[name] ?? -1
255
- if (idx < 0 || idx >= this.skeleton.bones.length) continue
256
-
257
- const qi = idx * 4
258
- const rotations = this.runtimeSkeleton.localRotations
259
- const [tx, ty, tz, tw] = normalized[i].toArray()
260
-
261
- if (dur === 0) {
262
- rotations[qi] = tx
263
- rotations[qi + 1] = ty
264
- rotations[qi + 2] = tz
265
- rotations[qi + 3] = tw
266
- state.active[idx] = 0
267
- continue
268
- }
269
-
270
- let sx = rotations[qi]
271
- let sy = rotations[qi + 1]
272
- let sz = rotations[qi + 2]
273
- let sw = rotations[qi + 3]
274
-
275
- if (state.active[idx] === 1) {
276
- const startMs = state.startTimeMs[idx]
277
- const prevDur = Math.max(1, state.durationMs[idx])
278
- const t = Math.max(0, Math.min(1, (now - startMs) / prevDur))
279
- const e = easeInOut(t)
280
- const startQuat = new Quat(
281
- state.startQuat[qi],
282
- state.startQuat[qi + 1],
283
- state.startQuat[qi + 2],
284
- state.startQuat[qi + 3]
285
- )
286
- const targetQuat = new Quat(
287
- state.targetQuat[qi],
288
- state.targetQuat[qi + 1],
289
- state.targetQuat[qi + 2],
290
- state.targetQuat[qi + 3]
291
- )
292
- const result = Quat.slerp(startQuat, targetQuat, e)
293
- const cx = result.x
294
- const cy = result.y
295
- const cz = result.z
296
- const cw = result.w
297
- sx = cx
298
- sy = cy
299
- sz = cz
300
- sw = cw
301
- }
302
-
303
- state.startQuat[qi] = sx
304
- state.startQuat[qi + 1] = sy
305
- state.startQuat[qi + 2] = sz
306
- state.startQuat[qi + 3] = sw
307
- state.targetQuat[qi] = tx
308
- state.targetQuat[qi + 1] = ty
309
- state.targetQuat[qi + 2] = tz
310
- state.targetQuat[qi + 3] = tw
311
- state.startTimeMs[idx] = now
312
- state.durationMs[idx] = dur
313
- state.active[idx] = 1
314
- }
315
- }
316
-
317
- getBoneWorldMatrices(): Float32Array {
318
- return this.runtimeSkeleton.worldMatrices
319
- }
320
-
321
- getBoneInverseBindMatrices(): Float32Array {
322
- return this.skeleton.inverseBindMatrices
323
- }
324
-
325
- evaluatePose(): void {
326
- this.updateRotationTweens()
327
- this.computeWorldMatrices()
328
- }
329
-
330
- private computeWorldMatrices(): void {
331
- const bones = this.skeleton.bones
332
- const localRot = this.runtimeSkeleton.localRotations
333
- const localTrans = this.runtimeSkeleton.localTranslations
334
- const worldBuf = this.runtimeSkeleton.worldMatrices
335
- const computed = this.runtimeSkeleton.computedBones.fill(false)
336
- const boneCount = bones.length
337
-
338
- if (boneCount === 0) return
339
-
340
- const computeWorld = (i: number): void => {
341
- if (computed[i]) return
342
-
343
- const b = bones[i]
344
- if (b.parentIndex >= boneCount) {
345
- console.warn(`[RZM] bone ${i} parent out of range: ${b.parentIndex}`)
346
- }
347
-
348
- const qi = i * 4
349
- let rotateM = Mat4.fromQuat(localRot[qi], localRot[qi + 1], localRot[qi + 2], localRot[qi + 3])
350
- let addLocalTx = 0,
351
- addLocalTy = 0,
352
- addLocalTz = 0
353
-
354
- // Optimized append rotation check - only check necessary conditions
355
- const appendParentIdx = b.appendParentIndex
356
- const hasAppend =
357
- b.appendRotate && appendParentIdx !== undefined && appendParentIdx >= 0 && appendParentIdx < boneCount
358
-
359
- if (hasAppend) {
360
- const ratio = b.appendRatio === undefined ? 1 : Math.max(-1, Math.min(1, b.appendRatio))
361
- const hasRatio = Math.abs(ratio) > 1e-6
362
-
363
- if (hasRatio) {
364
- const apQi = appendParentIdx * 4
365
- const apTi = appendParentIdx * 3
366
-
367
- if (b.appendRotate) {
368
- let ax = localRot[apQi]
369
- let ay = localRot[apQi + 1]
370
- let az = localRot[apQi + 2]
371
- const aw = localRot[apQi + 3]
372
- const absRatio = ratio < 0 ? -ratio : ratio
373
- if (ratio < 0) {
374
- ax = -ax
375
- ay = -ay
376
- az = -az
377
- }
378
- const identityQuat = new Quat(0, 0, 0, 1)
379
- const appendQuat = new Quat(ax, ay, az, aw)
380
- const result = Quat.slerp(identityQuat, appendQuat, absRatio)
381
- const rx = result.x
382
- const ry = result.y
383
- const rz = result.z
384
- const rw = result.w
385
- rotateM = Mat4.fromQuat(rx, ry, rz, rw).multiply(rotateM)
386
- }
387
-
388
- if (b.appendMove) {
389
- const appendRatio = b.appendRatio ?? 1
390
- addLocalTx = localTrans[apTi] * appendRatio
391
- addLocalTy = localTrans[apTi + 1] * appendRatio
392
- addLocalTz = localTrans[apTi + 2] * appendRatio
393
- }
394
- }
395
- }
396
-
397
- // Build local matrix: identity + bind translation, then rotation, then append translation
398
- this.cachedIdentityMat1
399
- .setIdentity()
400
- .translateInPlace(b.bindTranslation[0], b.bindTranslation[1], b.bindTranslation[2])
401
- this.cachedIdentityMat2.setIdentity().translateInPlace(addLocalTx, addLocalTy, addLocalTz)
402
- const localM = this.cachedIdentityMat1.multiply(rotateM).multiply(this.cachedIdentityMat2)
403
-
404
- const worldOffset = i * 16
405
- if (b.parentIndex >= 0) {
406
- const p = b.parentIndex
407
- if (!computed[p]) computeWorld(p)
408
- const parentOffset = p * 16
409
- // Use cachedIdentityMat2 as temporary buffer for parent * local multiplication
410
- Mat4.multiplyArrays(worldBuf, parentOffset, localM.values, 0, this.cachedIdentityMat2.values, 0)
411
- worldBuf.subarray(worldOffset, worldOffset + 16).set(this.cachedIdentityMat2.values)
412
- } else {
413
- worldBuf.subarray(worldOffset, worldOffset + 16).set(localM.values)
414
- }
415
- computed[i] = true
416
- }
417
-
418
- // Process all bones (recursion handles dependencies automatically)
419
- for (let i = 0; i < boneCount; i++) computeWorld(i)
420
- }
421
- }
1
+ import { Mat4, Quat, Vec3, easeInOut } from "./math"
2
+ import { Rigidbody, Joint } from "./physics"
3
+ import { IKSolverSystem } from "./ik-solver"
4
+
5
+ const VERTEX_STRIDE = 8
6
+
7
+ export interface Texture {
8
+ path: string
9
+ name: string
10
+ }
11
+
12
+ export interface Material {
13
+ name: string
14
+ diffuse: [number, number, number, number]
15
+ specular: [number, number, number]
16
+ ambient: [number, number, number]
17
+ shininess: number
18
+ diffuseTextureIndex: number
19
+ normalTextureIndex: number
20
+ sphereTextureIndex: number
21
+ sphereMode: number
22
+ toonTextureIndex: number
23
+ edgeFlag: number
24
+ edgeColor: [number, number, number, number]
25
+ edgeSize: number
26
+ vertexCount: number
27
+ isEye?: boolean // New: marks eye materials
28
+ isFace?: boolean // New: marks face/skin materials
29
+ isHair?: boolean // New: marks hair materials
30
+ }
31
+
32
+ export interface Bone {
33
+ name: string
34
+ parentIndex: number // -1 if no parent
35
+ bindTranslation: [number, number, number]
36
+ children: number[] // child bone indices (built on skeleton creation)
37
+ appendParentIndex?: number // index of the bone to inherit from
38
+ appendRatio?: number // 0..1
39
+ appendRotate?: boolean
40
+ appendMove?: boolean
41
+ ikTargetIndex?: number // IK target bone index (if this bone is an IK effector)
42
+ ikIteration?: number // IK iteration count
43
+ ikLimitAngle?: number // IK rotation constraint (radians)
44
+ ikLinks?: IKLink[] // IK chain links
45
+ }
46
+
47
+ // IK link with angle constraints
48
+ export interface IKLink {
49
+ boneIndex: number
50
+ hasLimit: boolean
51
+ minAngle?: Vec3 // Minimum Euler angles (radians)
52
+ maxAngle?: Vec3 // Maximum Euler angles (radians)
53
+ rotationOrder?: EulerRotationOrder // YXZ, ZYX, or XZY
54
+ solveAxis?: SolveAxis // None, Fixed, X, Y, or Z
55
+ }
56
+
57
+ // Euler rotation order for angle constraints
58
+ export enum EulerRotationOrder {
59
+ YXZ = 0,
60
+ ZYX = 1,
61
+ XZY = 2,
62
+ }
63
+
64
+ // Solve axis optimization
65
+ export enum SolveAxis {
66
+ None = 0,
67
+ Fixed = 1,
68
+ X = 2,
69
+ Y = 3,
70
+ Z = 4,
71
+ }
72
+
73
+ // IK solver definition
74
+ export interface IKSolver {
75
+ index: number
76
+ ikBoneIndex: number // Effector bone (the bone that should reach the target)
77
+ targetBoneIndex: number // Target bone
78
+ iterationCount: number
79
+ limitAngle: number // Max rotation per iteration (radians)
80
+ links: IKLink[] // Chain bones from effector to root
81
+ canSkipWhenPhysicsEnabled: boolean
82
+ }
83
+
84
+ // IK chain info per bone (runtime state)
85
+ export interface IKChainInfo {
86
+ ikRotation: Quat // Accumulated IK rotation
87
+ localRotation: Quat // Cached local rotation before IK
88
+ }
89
+
90
+ export interface Skeleton {
91
+ bones: Bone[]
92
+ inverseBindMatrices: Float32Array // One inverse-bind matrix per bone (column-major mat4, 16 floats per bone)
93
+ }
94
+
95
+ export interface Skinning {
96
+ joints: Uint16Array // length = vertexCount * 4, bone indices per vertex
97
+ weights: Uint8Array // UNORM8, length = vertexCount * 4, sums ~ 255 per-vertex
98
+ }
99
+
100
+ // Vertex morph offset data
101
+ export interface VertexMorphOffset {
102
+ vertexIndex: number
103
+ positionOffset: [number, number, number]
104
+ }
105
+
106
+ // Group morph reference (for type 0)
107
+ export interface GroupMorphReference {
108
+ morphIndex: number
109
+ ratio: number
110
+ }
111
+
112
+ // Morph definition
113
+ export interface Morph {
114
+ name: string
115
+ type: number // 0=group, 1=vertex, 2=bone, 3=UV, 8=material
116
+ vertexOffsets: VertexMorphOffset[] // Only for type 1 (vertex morph)
117
+ groupReferences?: GroupMorphReference[] // Only for type 0 (group morph)
118
+ }
119
+
120
+ export interface Morphing {
121
+ morphs: Morph[]
122
+ offsetsBuffer: Float32Array // Dense buffer: morphCount * vertexCount * 3 floats
123
+ }
124
+
125
+ // Runtime skeleton pose state (updated each frame)
126
+ export interface SkeletonRuntime {
127
+ nameIndex: Record<string, number> // Cached lookup: bone name -> bone index (built on initialization)
128
+ localRotations: Float32Array // quat per bone (x,y,z,w) length = boneCount*4
129
+ localTranslations: Float32Array // vec3 per bone length = boneCount*3
130
+ worldMatrices: Float32Array // mat4 per bone length = boneCount*16
131
+ computedBones: boolean[] // length = boneCount
132
+ ikChainInfo?: IKChainInfo[] // IK chain info per bone (only for IK chain bones)
133
+ ikSolvers?: IKSolver[] // All IK solvers in the model
134
+ }
135
+
136
+ // Runtime morph state
137
+ export interface MorphRuntime {
138
+ nameIndex: Record<string, number> // Cached lookup: morph name -> morph index
139
+ weights: Float32Array // One weight per morph (0.0 to 1.0)
140
+ }
141
+
142
+ // Rotation tween state per bone
143
+ interface RotationTweenState {
144
+ active: Uint8Array // 0/1 per bone
145
+ startQuat: Float32Array // quat per bone (x,y,z,w)
146
+ targetQuat: Float32Array // quat per bone (x,y,z,w)
147
+ startTimeMs: Float32Array // one float per bone (ms)
148
+ durationMs: Float32Array // one float per bone (ms)
149
+ }
150
+
151
+ // Morph weight tween state per morph
152
+ interface MorphWeightTweenState {
153
+ active: Uint8Array // 0/1 per morph
154
+ startWeight: Float32Array // one float per morph
155
+ targetWeight: Float32Array // one float per morph
156
+ startTimeMs: Float32Array // one float per morph (ms)
157
+ durationMs: Float32Array // one float per morph (ms)
158
+ }
159
+
160
+ // Translation tween state per bone
161
+ interface TranslationTweenState {
162
+ active: Uint8Array // 0/1 per bone
163
+ startVec: Float32Array // vec3 per bone (x,y,z)
164
+ targetVec: Float32Array // vec3 per bone (x,y,z)
165
+ startTimeMs: Float32Array // one float per bone (ms)
166
+ durationMs: Float32Array // one float per bone (ms)
167
+ }
168
+
169
+ export class Model {
170
+ private vertexData: Float32Array<ArrayBuffer>
171
+ private baseVertexData: Float32Array<ArrayBuffer> // Original vertex data before morphing
172
+ private vertexCount: number
173
+ private indexData: Uint32Array<ArrayBuffer>
174
+ private textures: Texture[] = []
175
+ private materials: Material[] = []
176
+ // Static skeleton/skinning (not necessarily serialized yet)
177
+ private skeleton: Skeleton
178
+ private skinning: Skinning
179
+
180
+ // Static morph data (from PMX)
181
+ private morphing: Morphing
182
+
183
+ // Physics data from PMX
184
+ private rigidbodies: Rigidbody[] = []
185
+ private joints: Joint[] = []
186
+
187
+ // Runtime skeleton pose state (updated each frame)
188
+ private runtimeSkeleton!: SkeletonRuntime
189
+
190
+ // Runtime morph state
191
+ private runtimeMorph!: MorphRuntime
192
+
193
+ // Cached identity matrices to avoid allocations in computeWorldMatrices
194
+ private cachedIdentityMat1 = Mat4.identity()
195
+ private cachedIdentityMat2 = Mat4.identity()
196
+
197
+ private rotTweenState!: RotationTweenState
198
+ private transTweenState!: TranslationTweenState
199
+ private morphTweenState!: MorphWeightTweenState
200
+
201
+ constructor(
202
+ vertexData: Float32Array<ArrayBuffer>,
203
+ indexData: Uint32Array<ArrayBuffer>,
204
+ textures: Texture[],
205
+ materials: Material[],
206
+ skeleton: Skeleton,
207
+ skinning: Skinning,
208
+ morphing: Morphing,
209
+ rigidbodies: Rigidbody[] = [],
210
+ joints: Joint[] = []
211
+ ) {
212
+ // Store base vertex data (original positions before morphing)
213
+ this.baseVertexData = new Float32Array(vertexData)
214
+ this.vertexData = vertexData
215
+ this.vertexCount = vertexData.length / VERTEX_STRIDE
216
+ this.indexData = indexData
217
+ this.textures = textures
218
+ this.materials = materials
219
+ this.skeleton = skeleton
220
+ this.skinning = skinning
221
+ this.morphing = morphing
222
+ this.rigidbodies = rigidbodies
223
+ this.joints = joints
224
+
225
+ if (this.skeleton.bones.length == 0) {
226
+ throw new Error("Model has no bones")
227
+ }
228
+
229
+ this.initializeRuntimeSkeleton()
230
+ this.initializeRotTweenBuffers()
231
+ this.initializeTransTweenBuffers()
232
+ this.initializeRuntimeMorph()
233
+ this.initializeMorphTweenBuffers()
234
+ this.applyMorphs() // Apply initial morphs (all weights are 0, so no change)
235
+ }
236
+
237
+ private initializeRuntimeSkeleton(): void {
238
+ const boneCount = this.skeleton.bones.length
239
+
240
+ this.runtimeSkeleton = {
241
+ localRotations: new Float32Array(boneCount * 4),
242
+ localTranslations: new Float32Array(boneCount * 3),
243
+ worldMatrices: new Float32Array(boneCount * 16),
244
+ nameIndex: this.skeleton.bones.reduce((acc, bone, index) => {
245
+ acc[bone.name] = index
246
+ return acc
247
+ }, {} as Record<string, number>),
248
+ computedBones: new Array(boneCount).fill(false),
249
+ }
250
+
251
+ const rotations = this.runtimeSkeleton.localRotations
252
+ for (let i = 0; i < this.skeleton.bones.length; i++) {
253
+ const qi = i * 4
254
+ if (rotations[qi + 3] === 0) {
255
+ rotations[qi] = 0
256
+ rotations[qi + 1] = 0
257
+ rotations[qi + 2] = 0
258
+ rotations[qi + 3] = 1
259
+ }
260
+ }
261
+
262
+ // Initialize IK runtime state
263
+ this.initializeIKRuntime()
264
+ }
265
+
266
+ private initializeIKRuntime(): void {
267
+ const boneCount = this.skeleton.bones.length
268
+ const bones = this.skeleton.bones
269
+
270
+ // Initialize IK chain info for all bones (will be populated for IK chain bones)
271
+ const ikChainInfo: IKChainInfo[] = new Array(boneCount)
272
+ for (let i = 0; i < boneCount; i++) {
273
+ ikChainInfo[i] = {
274
+ ikRotation: new Quat(0, 0, 0, 1),
275
+ localRotation: new Quat(0, 0, 0, 1),
276
+ }
277
+ }
278
+
279
+ // Build IK solvers from bone data
280
+ const ikSolvers: IKSolver[] = []
281
+ let solverIndex = 0
282
+
283
+ for (let i = 0; i < boneCount; i++) {
284
+ const bone = bones[i]
285
+ if (bone.ikTargetIndex !== undefined && bone.ikLinks && bone.ikLinks.length > 0) {
286
+ // Check if all links are affected by physics (for optimization)
287
+ let canSkipWhenPhysicsEnabled = true
288
+ for (const link of bone.ikLinks) {
289
+ // For now, assume no bones are physics-controlled (can be enhanced later)
290
+ // If a bone has a rigidbody attached, it's physics-controlled
291
+ const hasPhysics = this.rigidbodies.some((rb) => rb.boneIndex === link.boneIndex)
292
+ if (!hasPhysics) {
293
+ canSkipWhenPhysicsEnabled = false
294
+ break
295
+ }
296
+ }
297
+
298
+ const solver: IKSolver = {
299
+ index: solverIndex++,
300
+ ikBoneIndex: i,
301
+ targetBoneIndex: bone.ikTargetIndex,
302
+ iterationCount: bone.ikIteration ?? 1,
303
+ limitAngle: bone.ikLimitAngle ?? Math.PI,
304
+ links: bone.ikLinks,
305
+ canSkipWhenPhysicsEnabled,
306
+ }
307
+ ikSolvers.push(solver)
308
+ }
309
+ }
310
+
311
+ this.runtimeSkeleton.ikChainInfo = ikChainInfo
312
+ this.runtimeSkeleton.ikSolvers = ikSolvers
313
+ }
314
+
315
+ private initializeRotTweenBuffers(): void {
316
+ const n = this.skeleton.bones.length
317
+ this.rotTweenState = {
318
+ active: new Uint8Array(n),
319
+ startQuat: new Float32Array(n * 4),
320
+ targetQuat: new Float32Array(n * 4),
321
+ startTimeMs: new Float32Array(n),
322
+ durationMs: new Float32Array(n),
323
+ }
324
+ }
325
+
326
+ private initializeTransTweenBuffers(): void {
327
+ const n = this.skeleton.bones.length
328
+ this.transTweenState = {
329
+ active: new Uint8Array(n),
330
+ startVec: new Float32Array(n * 3),
331
+ targetVec: new Float32Array(n * 3),
332
+ startTimeMs: new Float32Array(n),
333
+ durationMs: new Float32Array(n),
334
+ }
335
+ }
336
+
337
+ private initializeMorphTweenBuffers(): void {
338
+ const n = this.morphing.morphs.length
339
+ this.morphTweenState = {
340
+ active: new Uint8Array(n),
341
+ startWeight: new Float32Array(n),
342
+ targetWeight: new Float32Array(n),
343
+ startTimeMs: new Float32Array(n),
344
+ durationMs: new Float32Array(n),
345
+ }
346
+ }
347
+
348
+ private initializeRuntimeMorph(): void {
349
+ const morphCount = this.morphing.morphs.length
350
+ this.runtimeMorph = {
351
+ nameIndex: this.morphing.morphs.reduce((acc, morph, index) => {
352
+ acc[morph.name] = index
353
+ return acc
354
+ }, {} as Record<string, number>),
355
+ weights: new Float32Array(morphCount),
356
+ }
357
+ }
358
+
359
+ private updateRotationTweens(): void {
360
+ const state = this.rotTweenState
361
+ const now = performance.now()
362
+ const rotations = this.runtimeSkeleton.localRotations
363
+ const boneCount = this.skeleton.bones.length
364
+
365
+ for (let i = 0; i < boneCount; i++) {
366
+ if (state.active[i] !== 1) continue
367
+
368
+ const startMs = state.startTimeMs[i]
369
+ const durMs = Math.max(1, state.durationMs[i])
370
+ const t = Math.max(0, Math.min(1, (now - startMs) / durMs))
371
+ const e = easeInOut(t)
372
+
373
+ const qi = i * 4
374
+ const startQuat = new Quat(
375
+ state.startQuat[qi],
376
+ state.startQuat[qi + 1],
377
+ state.startQuat[qi + 2],
378
+ state.startQuat[qi + 3]
379
+ )
380
+ const targetQuat = new Quat(
381
+ state.targetQuat[qi],
382
+ state.targetQuat[qi + 1],
383
+ state.targetQuat[qi + 2],
384
+ state.targetQuat[qi + 3]
385
+ )
386
+ const result = Quat.slerp(startQuat, targetQuat, e)
387
+
388
+ rotations[qi] = result.x
389
+ rotations[qi + 1] = result.y
390
+ rotations[qi + 2] = result.z
391
+ rotations[qi + 3] = result.w
392
+
393
+ if (t >= 1) state.active[i] = 0
394
+ }
395
+ }
396
+
397
+ private updateTranslationTweens(): void {
398
+ const state = this.transTweenState
399
+ const now = performance.now()
400
+ const translations = this.runtimeSkeleton.localTranslations
401
+ const boneCount = this.skeleton.bones.length
402
+
403
+ for (let i = 0; i < boneCount; i++) {
404
+ if (state.active[i] !== 1) continue
405
+
406
+ const startMs = state.startTimeMs[i]
407
+ const durMs = Math.max(1, state.durationMs[i])
408
+ const t = Math.max(0, Math.min(1, (now - startMs) / durMs))
409
+ const e = easeInOut(t)
410
+
411
+ const ti = i * 3
412
+ translations[ti] = state.startVec[ti] + (state.targetVec[ti] - state.startVec[ti]) * e
413
+ translations[ti + 1] = state.startVec[ti + 1] + (state.targetVec[ti + 1] - state.startVec[ti + 1]) * e
414
+ translations[ti + 2] = state.startVec[ti + 2] + (state.targetVec[ti + 2] - state.startVec[ti + 2]) * e
415
+
416
+ if (t >= 1) state.active[i] = 0
417
+ }
418
+ }
419
+
420
+ private updateMorphWeightTweens(): boolean {
421
+ const state = this.morphTweenState
422
+ const now = performance.now()
423
+ const weights = this.runtimeMorph.weights
424
+ const morphCount = this.morphing.morphs.length
425
+ let hasActiveTweens = false
426
+
427
+ for (let i = 0; i < morphCount; i++) {
428
+ if (state.active[i] !== 1) continue
429
+
430
+ hasActiveTweens = true
431
+ const startMs = state.startTimeMs[i]
432
+ const durMs = Math.max(1, state.durationMs[i])
433
+ const t = Math.max(0, Math.min(1, (now - startMs) / durMs))
434
+ const e = easeInOut(t)
435
+
436
+ weights[i] = state.startWeight[i] + (state.targetWeight[i] - state.startWeight[i]) * e
437
+
438
+ if (t >= 1) {
439
+ weights[i] = state.targetWeight[i]
440
+ state.active[i] = 0
441
+ }
442
+ }
443
+
444
+ return hasActiveTweens
445
+ }
446
+
447
+ // Get interleaved vertex data for GPU upload
448
+ // Format: [x,y,z, nx,ny,nz, u,v, x,y,z, nx,ny,nz, u,v, ...]
449
+ getVertices(): Float32Array<ArrayBuffer> {
450
+ return this.vertexData
451
+ }
452
+
453
+ // Get texture information
454
+ getTextures(): Texture[] {
455
+ return this.textures
456
+ }
457
+
458
+ // Get material information
459
+ getMaterials(): Material[] {
460
+ return this.materials
461
+ }
462
+
463
+ // Get vertex count
464
+ getVertexCount(): number {
465
+ return this.vertexCount
466
+ }
467
+
468
+ // Get index data for GPU upload
469
+ getIndices(): Uint32Array<ArrayBuffer> {
470
+ return this.indexData
471
+ }
472
+
473
+ // Accessors for skeleton/skinning
474
+ getSkeleton(): Skeleton {
475
+ return this.skeleton
476
+ }
477
+
478
+ getSkinning(): Skinning {
479
+ return this.skinning
480
+ }
481
+
482
+ // Accessors for physics data
483
+ getRigidbodies(): Rigidbody[] {
484
+ return this.rigidbodies
485
+ }
486
+
487
+ getJoints(): Joint[] {
488
+ return this.joints
489
+ }
490
+
491
+ // Accessors for morphing
492
+ getMorphing(): Morphing {
493
+ return this.morphing
494
+ }
495
+
496
+ getMorphWeights(): Float32Array {
497
+ return this.runtimeMorph.weights
498
+ }
499
+
500
+ // ------- Bone helpers (public API) -------
501
+
502
+ getBoneNames(): string[] {
503
+ return this.skeleton.bones.map((b) => b.name)
504
+ }
505
+
506
+ rotateBones(names: string[], quats: Quat[], durationMs?: number): void {
507
+ const state = this.rotTweenState
508
+ const normalized = quats.map((q) => q.normalize())
509
+ const now = performance.now()
510
+ const dur = durationMs && durationMs > 0 ? durationMs : 0
511
+
512
+ for (let i = 0; i < names.length; i++) {
513
+ const name = names[i]
514
+ const idx = this.runtimeSkeleton.nameIndex[name] ?? -1
515
+ if (idx < 0 || idx >= this.skeleton.bones.length) continue
516
+
517
+ const qi = idx * 4
518
+ const rotations = this.runtimeSkeleton.localRotations
519
+ const [tx, ty, tz, tw] = normalized[i].toArray()
520
+
521
+ if (dur === 0) {
522
+ rotations[qi] = tx
523
+ rotations[qi + 1] = ty
524
+ rotations[qi + 2] = tz
525
+ rotations[qi + 3] = tw
526
+ state.active[idx] = 0
527
+ continue
528
+ }
529
+
530
+ let sx = rotations[qi]
531
+ let sy = rotations[qi + 1]
532
+ let sz = rotations[qi + 2]
533
+ let sw = rotations[qi + 3]
534
+
535
+ if (state.active[idx] === 1) {
536
+ const startMs = state.startTimeMs[idx]
537
+ const prevDur = Math.max(1, state.durationMs[idx])
538
+ const t = Math.max(0, Math.min(1, (now - startMs) / prevDur))
539
+ const e = easeInOut(t)
540
+ const startQuat = new Quat(
541
+ state.startQuat[qi],
542
+ state.startQuat[qi + 1],
543
+ state.startQuat[qi + 2],
544
+ state.startQuat[qi + 3]
545
+ )
546
+ const targetQuat = new Quat(
547
+ state.targetQuat[qi],
548
+ state.targetQuat[qi + 1],
549
+ state.targetQuat[qi + 2],
550
+ state.targetQuat[qi + 3]
551
+ )
552
+ const result = Quat.slerp(startQuat, targetQuat, e)
553
+ const cx = result.x
554
+ const cy = result.y
555
+ const cz = result.z
556
+ const cw = result.w
557
+ sx = cx
558
+ sy = cy
559
+ sz = cz
560
+ sw = cw
561
+ }
562
+
563
+ state.startQuat[qi] = sx
564
+ state.startQuat[qi + 1] = sy
565
+ state.startQuat[qi + 2] = sz
566
+ state.startQuat[qi + 3] = sw
567
+ state.targetQuat[qi] = tx
568
+ state.targetQuat[qi + 1] = ty
569
+ state.targetQuat[qi + 2] = tz
570
+ state.targetQuat[qi + 3] = tw
571
+ state.startTimeMs[idx] = now
572
+ state.durationMs[idx] = dur
573
+ state.active[idx] = 1
574
+ }
575
+ }
576
+
577
+ // Move bones using VMD-style relative translations (relative to bind pose world position)
578
+ // This is the default behavior for VMD animations
579
+ moveBones(names: string[], relativeTranslations: Vec3[], durationMs?: number): void {
580
+ const state = this.transTweenState
581
+ const now = performance.now()
582
+ const dur = durationMs && durationMs > 0 ? durationMs : 0
583
+ const localRot = this.runtimeSkeleton.localRotations
584
+
585
+ // Compute bind pose world positions for all bones
586
+ const skeleton = this.skeleton
587
+ const computeBindPoseWorldPosition = (idx: number): Vec3 => {
588
+ const bone = skeleton.bones[idx]
589
+ const bindPos = new Vec3(bone.bindTranslation[0], bone.bindTranslation[1], bone.bindTranslation[2])
590
+ if (bone.parentIndex >= 0 && bone.parentIndex < skeleton.bones.length) {
591
+ const parentWorldPos = computeBindPoseWorldPosition(bone.parentIndex)
592
+ return parentWorldPos.add(bindPos)
593
+ } else {
594
+ return bindPos
595
+ }
596
+ }
597
+
598
+ for (let i = 0; i < names.length; i++) {
599
+ const name = names[i]
600
+ const idx = this.runtimeSkeleton.nameIndex[name] ?? -1
601
+ if (idx < 0 || idx >= this.skeleton.bones.length) continue
602
+
603
+ const bone = this.skeleton.bones[idx]
604
+ const ti = idx * 3
605
+ const qi = idx * 4
606
+ const translations = this.runtimeSkeleton.localTranslations
607
+ const vmdRelativeTranslation = relativeTranslations[i]
608
+
609
+ // VMD translation is relative to bind pose world position
610
+ // targetWorldPos = bindPoseWorldPos + vmdRelativeTranslation
611
+ const bindPoseWorldPos = computeBindPoseWorldPosition(idx)
612
+ const targetWorldPos = bindPoseWorldPos.add(vmdRelativeTranslation)
613
+
614
+ // Convert target world position to local translation
615
+ // We need parent's bind pose world position to transform to parent space
616
+ let parentBindPoseWorldPos: Vec3
617
+ if (bone.parentIndex >= 0) {
618
+ parentBindPoseWorldPos = computeBindPoseWorldPosition(bone.parentIndex)
619
+ } else {
620
+ parentBindPoseWorldPos = new Vec3(0, 0, 0)
621
+ }
622
+
623
+ // Transform target world position to parent's local space
624
+ // In bind pose, parent's world matrix is just a translation
625
+ const parentSpacePos = targetWorldPos.subtract(parentBindPoseWorldPos)
626
+
627
+ // Subtract bindTranslation to get position after bind translation
628
+ const afterBindTranslation = parentSpacePos.subtract(
629
+ new Vec3(bone.bindTranslation[0], bone.bindTranslation[1], bone.bindTranslation[2])
630
+ )
631
+
632
+ // Apply inverse rotation to get local translation
633
+ const localRotation = new Quat(localRot[qi], localRot[qi + 1], localRot[qi + 2], localRot[qi + 3])
634
+ const invRotation = localRotation.conjugate().normalize()
635
+ const rotationMat = Mat4.fromQuat(invRotation.x, invRotation.y, invRotation.z, invRotation.w)
636
+ const rm = rotationMat.values
637
+ const localTranslation = new Vec3(
638
+ rm[0] * afterBindTranslation.x + rm[4] * afterBindTranslation.y + rm[8] * afterBindTranslation.z,
639
+ rm[1] * afterBindTranslation.x + rm[5] * afterBindTranslation.y + rm[9] * afterBindTranslation.z,
640
+ rm[2] * afterBindTranslation.x + rm[6] * afterBindTranslation.y + rm[10] * afterBindTranslation.z
641
+ )
642
+
643
+ const [tx, ty, tz] = [localTranslation.x, localTranslation.y, localTranslation.z]
644
+
645
+ if (dur === 0) {
646
+ translations[ti] = tx
647
+ translations[ti + 1] = ty
648
+ translations[ti + 2] = tz
649
+ state.active[idx] = 0
650
+ continue
651
+ }
652
+
653
+ let sx = translations[ti]
654
+ let sy = translations[ti + 1]
655
+ let sz = translations[ti + 2]
656
+
657
+ if (state.active[idx] === 1) {
658
+ const startMs = state.startTimeMs[idx]
659
+ const prevDur = Math.max(1, state.durationMs[idx])
660
+ const t = Math.max(0, Math.min(1, (now - startMs) / prevDur))
661
+ const e = easeInOut(t)
662
+ sx = state.startVec[ti] + (state.targetVec[ti] - state.startVec[ti]) * e
663
+ sy = state.startVec[ti + 1] + (state.targetVec[ti + 1] - state.startVec[ti + 1]) * e
664
+ sz = state.startVec[ti + 2] + (state.targetVec[ti + 2] - state.startVec[ti + 2]) * e
665
+ }
666
+
667
+ state.startVec[ti] = sx
668
+ state.startVec[ti + 1] = sy
669
+ state.startVec[ti + 2] = sz
670
+ state.targetVec[ti] = tx
671
+ state.targetVec[ti + 1] = ty
672
+ state.targetVec[ti + 2] = tz
673
+ state.startTimeMs[idx] = now
674
+ state.durationMs[idx] = dur
675
+ state.active[idx] = 1
676
+ }
677
+ }
678
+
679
+ getBoneWorldMatrices(): Float32Array {
680
+ return this.runtimeSkeleton.worldMatrices
681
+ }
682
+
683
+ getBoneInverseBindMatrices(): Float32Array {
684
+ return this.skeleton.inverseBindMatrices
685
+ }
686
+
687
+ getMorphNames(): string[] {
688
+ return this.morphing.morphs.map((m) => m.name)
689
+ }
690
+
691
+ setMorphWeight(name: string, weight: number, durationMs?: number): void {
692
+ const idx = this.runtimeMorph.nameIndex[name] ?? -1
693
+ if (idx < 0 || idx >= this.runtimeMorph.weights.length) return
694
+
695
+ const clampedWeight = Math.max(0, Math.min(1, weight))
696
+ const dur = durationMs && durationMs > 0 ? durationMs : 0
697
+
698
+ if (dur === 0) {
699
+ // Instant change
700
+ this.runtimeMorph.weights[idx] = clampedWeight
701
+ this.morphTweenState.active[idx] = 0
702
+ this.applyMorphs()
703
+ return
704
+ }
705
+
706
+ // Animated change
707
+ const state = this.morphTweenState
708
+ const now = performance.now()
709
+ const currentWeight = this.runtimeMorph.weights[idx]
710
+
711
+ // If already tweening, start from current interpolated value
712
+ let startWeight = currentWeight
713
+ if (state.active[idx] === 1) {
714
+ const startMs = state.startTimeMs[idx]
715
+ const prevDur = Math.max(1, state.durationMs[idx])
716
+ const t = Math.max(0, Math.min(1, (now - startMs) / prevDur))
717
+ const e = easeInOut(t)
718
+ startWeight = state.startWeight[idx] + (state.targetWeight[idx] - state.startWeight[idx]) * e
719
+ }
720
+
721
+ state.startWeight[idx] = startWeight
722
+ state.targetWeight[idx] = clampedWeight
723
+ state.startTimeMs[idx] = now
724
+ state.durationMs[idx] = dur
725
+ state.active[idx] = 1
726
+
727
+ // Immediately apply morphs with current weight
728
+ this.runtimeMorph.weights[idx] = startWeight
729
+ this.applyMorphs()
730
+ }
731
+
732
+ private applyMorphs(): void {
733
+ // Reset vertex data to base positions
734
+ this.vertexData.set(this.baseVertexData)
735
+
736
+ const vertexCount = this.vertexCount
737
+ const morphCount = this.morphing.morphs.length
738
+ const weights = this.runtimeMorph.weights
739
+
740
+ // First pass: Compute effective weights for all morphs (handling group morphs)
741
+ const effectiveWeights = new Float32Array(morphCount)
742
+ effectiveWeights.set(weights) // Start with direct weights
743
+
744
+ // Apply group morphs: group morph weight * ratio affects referenced morphs
745
+ for (let morphIdx = 0; morphIdx < morphCount; morphIdx++) {
746
+ const morph = this.morphing.morphs[morphIdx]
747
+ if (morph.type === 0 && morph.groupReferences) {
748
+ const groupWeight = weights[morphIdx]
749
+ if (groupWeight > 0.0001) {
750
+ for (const ref of morph.groupReferences) {
751
+ if (ref.morphIndex >= 0 && ref.morphIndex < morphCount) {
752
+ // Add group morph's contribution to the referenced morph
753
+ effectiveWeights[ref.morphIndex] += groupWeight * ref.ratio
754
+ }
755
+ }
756
+ }
757
+ }
758
+ }
759
+
760
+ // Clamp effective weights to [0, 1]
761
+ for (let i = 0; i < morphCount; i++) {
762
+ effectiveWeights[i] = Math.max(0, Math.min(1, effectiveWeights[i]))
763
+ }
764
+
765
+ // Second pass: Apply vertex morphs with their effective weights
766
+ for (let morphIdx = 0; morphIdx < morphCount; morphIdx++) {
767
+ const effectiveWeight = effectiveWeights[morphIdx]
768
+ if (effectiveWeight === 0 || effectiveWeight < 0.0001) continue
769
+
770
+ const morph = this.morphing.morphs[morphIdx]
771
+ if (morph.type !== 1) continue // Only process vertex morphs
772
+
773
+ // For vertex morphs, iterate through vertices that have offsets
774
+ for (const vertexOffset of morph.vertexOffsets) {
775
+ const vIdx = vertexOffset.vertexIndex
776
+ if (vIdx < 0 || vIdx >= vertexCount) continue
777
+
778
+ // Get morph offset for this vertex
779
+ const offsetX = vertexOffset.positionOffset[0]
780
+ const offsetY = vertexOffset.positionOffset[1]
781
+ const offsetZ = vertexOffset.positionOffset[2]
782
+
783
+ // Skip if offset is zero
784
+ if (Math.abs(offsetX) < 0.0001 && Math.abs(offsetY) < 0.0001 && Math.abs(offsetZ) < 0.0001) {
785
+ continue
786
+ }
787
+
788
+ // Apply weighted offset to vertex position (positions are at stride 0, 8, 16, ...)
789
+ const vertexIdx = vIdx * VERTEX_STRIDE
790
+ this.vertexData[vertexIdx] += offsetX * effectiveWeight
791
+ this.vertexData[vertexIdx + 1] += offsetY * effectiveWeight
792
+ this.vertexData[vertexIdx + 2] += offsetZ * effectiveWeight
793
+ }
794
+ }
795
+ }
796
+
797
+ evaluatePose(): boolean {
798
+ this.updateRotationTweens()
799
+ this.updateTranslationTweens()
800
+ const hasActiveMorphTweens = this.updateMorphWeightTweens()
801
+ if (hasActiveMorphTweens) {
802
+ this.applyMorphs()
803
+ }
804
+
805
+ // Compute initial world matrices (needed for IK solving)
806
+ this.computeWorldMatrices()
807
+
808
+ // Solve IK chains (modifies localRotations)
809
+ this.solveIKChains()
810
+
811
+ // Recompute world matrices with IK rotations applied
812
+ this.computeWorldMatrices()
813
+
814
+ return hasActiveMorphTweens
815
+ }
816
+
817
+ private solveIKChains(): void {
818
+ const ikSolvers = this.runtimeSkeleton.ikSolvers
819
+ if (!ikSolvers || ikSolvers.length === 0) return
820
+
821
+ const ikChainInfo = this.runtimeSkeleton.ikChainInfo
822
+ if (!ikChainInfo) return
823
+
824
+ IKSolverSystem.solve(
825
+ ikSolvers,
826
+ this.skeleton.bones,
827
+ this.runtimeSkeleton.localRotations,
828
+ this.runtimeSkeleton.localTranslations,
829
+ this.runtimeSkeleton.worldMatrices,
830
+ ikChainInfo,
831
+ false // usePhysics - can be enhanced later
832
+ )
833
+ }
834
+
835
+ private computeWorldMatrices(): void {
836
+ const bones = this.skeleton.bones
837
+ const localRot = this.runtimeSkeleton.localRotations
838
+ const localTrans = this.runtimeSkeleton.localTranslations
839
+ const worldBuf = this.runtimeSkeleton.worldMatrices
840
+ const computed = this.runtimeSkeleton.computedBones.fill(false)
841
+ const boneCount = bones.length
842
+
843
+ if (boneCount === 0) return
844
+
845
+ const computeWorld = (i: number): void => {
846
+ if (computed[i]) return
847
+
848
+ const b = bones[i]
849
+ if (b.parentIndex >= boneCount) {
850
+ console.warn(`[RZM] bone ${i} parent out of range: ${b.parentIndex}`)
851
+ }
852
+
853
+ const qi = i * 4
854
+ let rotateM = Mat4.fromQuat(localRot[qi], localRot[qi + 1], localRot[qi + 2], localRot[qi + 3])
855
+ let addLocalTx = 0,
856
+ addLocalTy = 0,
857
+ addLocalTz = 0
858
+
859
+ // Optimized append rotation check - only check necessary conditions
860
+ const appendParentIdx = b.appendParentIndex
861
+ const hasAppend =
862
+ b.appendRotate && appendParentIdx !== undefined && appendParentIdx >= 0 && appendParentIdx < boneCount
863
+
864
+ if (hasAppend) {
865
+ const ratio = b.appendRatio === undefined ? 1 : Math.max(-1, Math.min(1, b.appendRatio))
866
+ const hasRatio = Math.abs(ratio) > 1e-6
867
+
868
+ if (hasRatio) {
869
+ const apQi = appendParentIdx * 4
870
+ const apTi = appendParentIdx * 3
871
+
872
+ if (b.appendRotate) {
873
+ let ax = localRot[apQi]
874
+ let ay = localRot[apQi + 1]
875
+ let az = localRot[apQi + 2]
876
+ const aw = localRot[apQi + 3]
877
+ const absRatio = ratio < 0 ? -ratio : ratio
878
+ if (ratio < 0) {
879
+ ax = -ax
880
+ ay = -ay
881
+ az = -az
882
+ }
883
+ const identityQuat = new Quat(0, 0, 0, 1)
884
+ const appendQuat = new Quat(ax, ay, az, aw)
885
+ const result = Quat.slerp(identityQuat, appendQuat, absRatio)
886
+ const rx = result.x
887
+ const ry = result.y
888
+ const rz = result.z
889
+ const rw = result.w
890
+ rotateM = Mat4.fromQuat(rx, ry, rz, rw).multiply(rotateM)
891
+ }
892
+
893
+ if (b.appendMove) {
894
+ const appendRatio = b.appendRatio ?? 1
895
+ addLocalTx = localTrans[apTi] * appendRatio
896
+ addLocalTy = localTrans[apTi + 1] * appendRatio
897
+ addLocalTz = localTrans[apTi + 2] * appendRatio
898
+ }
899
+ }
900
+ }
901
+
902
+ // Build local matrix: identity + bind translation, then rotation, then local translation, then append translation
903
+ const ti = i * 3
904
+ const localTx = localTrans[ti] + addLocalTx
905
+ const localTy = localTrans[ti + 1] + addLocalTy
906
+ const localTz = localTrans[ti + 2] + addLocalTz
907
+ this.cachedIdentityMat1
908
+ .setIdentity()
909
+ .translateInPlace(b.bindTranslation[0], b.bindTranslation[1], b.bindTranslation[2])
910
+ this.cachedIdentityMat2.setIdentity().translateInPlace(localTx, localTy, localTz)
911
+ const localM = this.cachedIdentityMat1.multiply(rotateM).multiply(this.cachedIdentityMat2)
912
+
913
+ const worldOffset = i * 16
914
+ if (b.parentIndex >= 0) {
915
+ const p = b.parentIndex
916
+ if (!computed[p]) computeWorld(p)
917
+ const parentOffset = p * 16
918
+ // Use cachedIdentityMat2 as temporary buffer for parent * local multiplication
919
+ Mat4.multiplyArrays(worldBuf, parentOffset, localM.values, 0, this.cachedIdentityMat2.values, 0)
920
+ worldBuf.subarray(worldOffset, worldOffset + 16).set(this.cachedIdentityMat2.values)
921
+ } else {
922
+ worldBuf.subarray(worldOffset, worldOffset + 16).set(localM.values)
923
+ }
924
+ computed[i] = true
925
+ }
926
+
927
+ // Process all bones (recursion handles dependencies automatically)
928
+ for (let i = 0; i < boneCount; i++) computeWorld(i)
929
+ }
930
+ }