reze-engine 0.3.5 → 0.3.7

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,6 +1,7 @@
1
- import { Mat4, Quat, Vec3, easeInOut } from "./math"
2
- import { Rigidbody, Joint } from "./physics"
1
+ import { Mat4, Quat, Vec3, easeInOut, bezierInterpolate } from "./math"
2
+ import { Rigidbody, Joint, Physics } from "./physics"
3
3
  import { IKSolverSystem } from "./ik-solver"
4
+ import { VMDKeyFrame, VMDLoader, BoneFrame, MorphFrame } from "./vmd-loader"
4
5
 
5
6
  const VERTEX_STRIDE = 8
6
7
 
@@ -119,31 +120,29 @@ export interface MorphRuntime {
119
120
  weights: Float32Array // One weight per morph (0.0 to 1.0)
120
121
  }
121
122
 
122
- // Rotation tween state per bone
123
- interface RotationTweenState {
124
- active: Uint8Array // 0/1 per bone
125
- startQuat: Float32Array // quat per bone (x,y,z,w)
126
- targetQuat: Float32Array // quat per bone (x,y,z,w)
127
- startTimeMs: Float32Array // one float per bone (ms)
128
- durationMs: Float32Array // one float per bone (ms)
129
- }
130
-
131
- // Morph weight tween state per morph
132
- interface MorphWeightTweenState {
133
- active: Uint8Array // 0/1 per morph
134
- startWeight: Float32Array // one float per morph
135
- targetWeight: Float32Array // one float per morph
136
- startTimeMs: Float32Array // one float per morph (ms)
137
- durationMs: Float32Array // one float per morph (ms)
138
- }
139
-
140
- // Translation tween state per bone
141
- interface TranslationTweenState {
142
- active: Uint8Array // 0/1 per bone
143
- startVec: Float32Array // vec3 per bone (x,y,z)
144
- targetVec: Float32Array // vec3 per bone (x,y,z)
145
- startTimeMs: Float32Array // one float per bone (ms)
146
- durationMs: Float32Array // one float per bone (ms)
123
+ // Tween state - combines rotation, translation, and morph tweens
124
+ // All tweens share the same time reference to avoid conflicts
125
+ interface TweenState {
126
+ // Bone rotation tweens
127
+ rotActive: Uint8Array // 0/1 per bone
128
+ rotStartQuat: Float32Array // quat per bone (x,y,z,w)
129
+ rotTargetQuat: Float32Array // quat per bone (x,y,z,w)
130
+ rotStartTimeMs: Float32Array // one float per bone (ms)
131
+ rotDurationMs: Float32Array // one float per bone (ms)
132
+
133
+ // Bone translation tweens
134
+ transActive: Uint8Array // 0/1 per bone
135
+ transStartVec: Float32Array // vec3 per bone (x,y,z)
136
+ transTargetVec: Float32Array // vec3 per bone (x,y,z)
137
+ transStartTimeMs: Float32Array // one float per bone (ms)
138
+ transDurationMs: Float32Array // one float per bone (ms)
139
+
140
+ // Morph weight tweens
141
+ morphActive: Uint8Array // 0/1 per morph
142
+ morphStartWeight: Float32Array // one float per morph
143
+ morphTargetWeight: Float32Array // one float per morph
144
+ morphStartTimeMs: Float32Array // one float per morph (ms)
145
+ morphDurationMs: Float32Array // one float per morph (ms)
147
146
  }
148
147
 
149
148
  export class Model {
@@ -169,14 +168,29 @@ export class Model {
169
168
 
170
169
  // Runtime morph state
171
170
  private runtimeMorph!: MorphRuntime
171
+ private morphsDirty: boolean = false // Flag indicating if morphs need to be applied
172
172
 
173
173
  // Cached identity matrices to avoid allocations in computeWorldMatrices
174
174
  private cachedIdentityMat1 = Mat4.identity()
175
175
  private cachedIdentityMat2 = Mat4.identity()
176
176
 
177
- private rotTweenState!: RotationTweenState
178
- private transTweenState!: TranslationTweenState
179
- private morphTweenState!: MorphWeightTweenState
177
+ // Cached skin matrices array to avoid allocations in getSkinMatrices
178
+ private cachedSkinMatrices?: Float32Array
179
+
180
+ private tweenState!: TweenState
181
+ private tweenTimeMs: number = 0 // Time tracking for tweens (milliseconds)
182
+
183
+ // Animation runtime
184
+ private animationData: VMDKeyFrame[] | null = null
185
+ private boneTracks: Map<string, Array<{ boneFrame: BoneFrame; time: number }>> = new Map()
186
+ private morphTracks: Map<string, Array<{ morphFrame: MorphFrame; time: number }>> = new Map()
187
+ private animationDuration: number = 0
188
+ private isPlaying: boolean = false
189
+ private isPaused: boolean = false
190
+ private animationTime: number = 0 // Current time in animation (seconds)
191
+
192
+ // Physics runtime
193
+ private physics: Physics | null = null
180
194
 
181
195
  constructor(
182
196
  vertexData: Float32Array<ArrayBuffer>,
@@ -207,11 +221,17 @@ export class Model {
207
221
  }
208
222
 
209
223
  this.initializeRuntimeSkeleton()
210
- this.initializeRotTweenBuffers()
211
- this.initializeTransTweenBuffers()
212
224
  this.initializeRuntimeMorph()
213
- this.initializeMorphTweenBuffers()
225
+ this.initializeTweenBuffers()
214
226
  this.applyMorphs() // Apply initial morphs (all weights are 0, so no change)
227
+
228
+ // Initialize physics if rigidbodies exist
229
+ if (rigidbodies.length > 0) {
230
+ this.physics = new Physics(rigidbodies, joints)
231
+ console.log(`[Model] Physics initialized with ${rigidbodies.length} rigidbodies`)
232
+ } else {
233
+ console.log("[Model] No rigidbodies found, physics disabled")
234
+ }
215
235
  }
216
236
 
217
237
  private initializeRuntimeSkeleton(): void {
@@ -278,36 +298,31 @@ export class Model {
278
298
  this.runtimeSkeleton.ikSolvers = ikSolvers
279
299
  }
280
300
 
281
- private initializeRotTweenBuffers(): void {
282
- const n = this.skeleton.bones.length
283
- this.rotTweenState = {
284
- active: new Uint8Array(n),
285
- startQuat: new Float32Array(n * 4),
286
- targetQuat: new Float32Array(n * 4),
287
- startTimeMs: new Float32Array(n),
288
- durationMs: new Float32Array(n),
289
- }
290
- }
291
-
292
- private initializeTransTweenBuffers(): void {
293
- const n = this.skeleton.bones.length
294
- this.transTweenState = {
295
- active: new Uint8Array(n),
296
- startVec: new Float32Array(n * 3),
297
- targetVec: new Float32Array(n * 3),
298
- startTimeMs: new Float32Array(n),
299
- durationMs: new Float32Array(n),
300
- }
301
- }
301
+ private initializeTweenBuffers(): void {
302
+ const boneCount = this.skeleton.bones.length
303
+ const morphCount = this.morphing.morphs.length
302
304
 
303
- private initializeMorphTweenBuffers(): void {
304
- const n = this.morphing.morphs.length
305
- this.morphTweenState = {
306
- active: new Uint8Array(n),
307
- startWeight: new Float32Array(n),
308
- targetWeight: new Float32Array(n),
309
- startTimeMs: new Float32Array(n),
310
- durationMs: new Float32Array(n),
305
+ this.tweenState = {
306
+ // Bone rotation tweens
307
+ rotActive: new Uint8Array(boneCount),
308
+ rotStartQuat: new Float32Array(boneCount * 4),
309
+ rotTargetQuat: new Float32Array(boneCount * 4),
310
+ rotStartTimeMs: new Float32Array(boneCount),
311
+ rotDurationMs: new Float32Array(boneCount),
312
+
313
+ // Bone translation tweens
314
+ transActive: new Uint8Array(boneCount),
315
+ transStartVec: new Float32Array(boneCount * 3),
316
+ transTargetVec: new Float32Array(boneCount * 3),
317
+ transStartTimeMs: new Float32Array(boneCount),
318
+ transDurationMs: new Float32Array(boneCount),
319
+
320
+ // Morph weight tweens
321
+ morphActive: new Uint8Array(morphCount),
322
+ morphStartWeight: new Float32Array(morphCount),
323
+ morphTargetWeight: new Float32Array(morphCount),
324
+ morphStartTimeMs: new Float32Array(morphCount),
325
+ morphDurationMs: new Float32Array(morphCount),
311
326
  }
312
327
  }
313
328
 
@@ -322,32 +337,37 @@ export class Model {
322
337
  }
323
338
  }
324
339
 
325
- private updateRotationTweens(): void {
326
- const state = this.rotTweenState
327
- const now = performance.now()
340
+ // Tween update - processes all tweens together with a single time reference
341
+ // This avoids conflicts and ensures consistent timing across all tween types
342
+ // Returns true if morph weights changed (needed for vertex buffer updates)
343
+ private updateTweens(): boolean {
344
+ const state = this.tweenState
345
+ const now = this.tweenTimeMs // Single time reference for all tweens
346
+ let morphChanged = false
347
+
348
+ // Update bone rotation tweens
328
349
  const rotations = this.runtimeSkeleton.localRotations
329
350
  const boneCount = this.skeleton.bones.length
330
-
331
351
  for (let i = 0; i < boneCount; i++) {
332
- if (state.active[i] !== 1) continue
352
+ if (state.rotActive[i] !== 1) continue
333
353
 
334
- const startMs = state.startTimeMs[i]
335
- const durMs = Math.max(1, state.durationMs[i])
354
+ const startMs = state.rotStartTimeMs[i]
355
+ const durMs = Math.max(1, state.rotDurationMs[i])
336
356
  const t = Math.max(0, Math.min(1, (now - startMs) / durMs))
337
357
  const e = easeInOut(t)
338
358
 
339
359
  const qi = i * 4
340
360
  const startQuat = new Quat(
341
- state.startQuat[qi],
342
- state.startQuat[qi + 1],
343
- state.startQuat[qi + 2],
344
- state.startQuat[qi + 3]
361
+ state.rotStartQuat[qi],
362
+ state.rotStartQuat[qi + 1],
363
+ state.rotStartQuat[qi + 2],
364
+ state.rotStartQuat[qi + 3]
345
365
  )
346
366
  const targetQuat = new Quat(
347
- state.targetQuat[qi],
348
- state.targetQuat[qi + 1],
349
- state.targetQuat[qi + 2],
350
- state.targetQuat[qi + 3]
367
+ state.rotTargetQuat[qi],
368
+ state.rotTargetQuat[qi + 1],
369
+ state.rotTargetQuat[qi + 2],
370
+ state.rotTargetQuat[qi + 3]
351
371
  )
352
372
  const result = Quat.slerp(startQuat, targetQuat, e)
353
373
 
@@ -356,87 +376,81 @@ export class Model {
356
376
  rotations[qi + 2] = result.z
357
377
  rotations[qi + 3] = result.w
358
378
 
359
- if (t >= 1) state.active[i] = 0
379
+ if (t >= 1) {
380
+ state.rotActive[i] = 0
381
+ }
360
382
  }
361
- }
362
383
 
363
- private updateTranslationTweens(): void {
364
- const state = this.transTweenState
365
- const now = performance.now()
384
+ // Update bone translation tweens
366
385
  const translations = this.runtimeSkeleton.localTranslations
367
- const boneCount = this.skeleton.bones.length
368
-
369
386
  for (let i = 0; i < boneCount; i++) {
370
- if (state.active[i] !== 1) continue
387
+ if (state.transActive[i] !== 1) continue
371
388
 
372
- const startMs = state.startTimeMs[i]
373
- const durMs = Math.max(1, state.durationMs[i])
389
+ const startMs = state.transStartTimeMs[i]
390
+ const durMs = Math.max(1, state.transDurationMs[i])
374
391
  const t = Math.max(0, Math.min(1, (now - startMs) / durMs))
375
392
  const e = easeInOut(t)
376
393
 
377
394
  const ti = i * 3
378
- translations[ti] = state.startVec[ti] + (state.targetVec[ti] - state.startVec[ti]) * e
379
- translations[ti + 1] = state.startVec[ti + 1] + (state.targetVec[ti + 1] - state.startVec[ti + 1]) * e
380
- translations[ti + 2] = state.startVec[ti + 2] + (state.targetVec[ti + 2] - state.startVec[ti + 2]) * e
395
+ translations[ti] = state.transStartVec[ti] + (state.transTargetVec[ti] - state.transStartVec[ti]) * e
396
+ translations[ti + 1] =
397
+ state.transStartVec[ti + 1] + (state.transTargetVec[ti + 1] - state.transStartVec[ti + 1]) * e
398
+ translations[ti + 2] =
399
+ state.transStartVec[ti + 2] + (state.transTargetVec[ti + 2] - state.transStartVec[ti + 2]) * e
381
400
 
382
- if (t >= 1) state.active[i] = 0
401
+ if (t >= 1) {
402
+ state.transActive[i] = 0
403
+ }
383
404
  }
384
- }
385
405
 
386
- private updateMorphWeightTweens(): boolean {
387
- const state = this.morphTweenState
388
- const now = performance.now()
406
+ // Update morph weight tweens
389
407
  const weights = this.runtimeMorph.weights
390
408
  const morphCount = this.morphing.morphs.length
391
- let hasActiveTweens = false
392
-
393
409
  for (let i = 0; i < morphCount; i++) {
394
- if (state.active[i] !== 1) continue
410
+ if (state.morphActive[i] !== 1) continue
395
411
 
396
- hasActiveTweens = true
397
- const startMs = state.startTimeMs[i]
398
- const durMs = Math.max(1, state.durationMs[i])
412
+ const startMs = state.morphStartTimeMs[i]
413
+ const durMs = Math.max(1, state.morphDurationMs[i])
399
414
  const t = Math.max(0, Math.min(1, (now - startMs) / durMs))
400
415
  const e = easeInOut(t)
401
416
 
402
- weights[i] = state.startWeight[i] + (state.targetWeight[i] - state.startWeight[i]) * e
417
+ const oldWeight = weights[i]
418
+ weights[i] = state.morphStartWeight[i] + (state.morphTargetWeight[i] - state.morphStartWeight[i]) * e
419
+
420
+ // Check if weight actually changed (accounting for floating point precision)
421
+ if (Math.abs(weights[i] - oldWeight) > 1e-6) {
422
+ morphChanged = true
423
+ }
403
424
 
404
425
  if (t >= 1) {
405
- weights[i] = state.targetWeight[i]
406
- state.active[i] = 0
426
+ weights[i] = state.morphTargetWeight[i]
427
+ state.morphActive[i] = 0
428
+ // Check if final weight is different from old weight
429
+ if (Math.abs(weights[i] - oldWeight) > 1e-6) {
430
+ morphChanged = true
431
+ }
407
432
  }
408
433
  }
409
434
 
410
- return hasActiveTweens
435
+ return morphChanged
411
436
  }
412
437
 
413
- // Get interleaved vertex data for GPU upload
414
- // Format: [x,y,z, nx,ny,nz, u,v, x,y,z, nx,ny,nz, u,v, ...]
415
438
  getVertices(): Float32Array<ArrayBuffer> {
416
439
  return this.vertexData
417
440
  }
418
441
 
419
- // Get texture information
420
442
  getTextures(): Texture[] {
421
443
  return this.textures
422
444
  }
423
445
 
424
- // Get material information
425
446
  getMaterials(): Material[] {
426
447
  return this.materials
427
448
  }
428
449
 
429
- // Get vertex count
430
- getVertexCount(): number {
431
- return this.vertexCount
432
- }
433
-
434
- // Get index data for GPU upload
435
450
  getIndices(): Uint32Array<ArrayBuffer> {
436
451
  return this.indexData
437
452
  }
438
453
 
439
- // Accessors for skeleton/skinning
440
454
  getSkeleton(): Skeleton {
441
455
  return this.skeleton
442
456
  }
@@ -445,7 +459,6 @@ export class Model {
445
459
  return this.skinning
446
460
  }
447
461
 
448
- // Accessors for physics data
449
462
  getRigidbodies(): Rigidbody[] {
450
463
  return this.rigidbodies
451
464
  }
@@ -454,7 +467,6 @@ export class Model {
454
467
  return this.joints
455
468
  }
456
469
 
457
- // Accessors for morphing
458
470
  getMorphing(): Morphing {
459
471
  return this.morphing
460
472
  }
@@ -465,14 +477,10 @@ export class Model {
465
477
 
466
478
  // ------- Bone helpers (public API) -------
467
479
 
468
- getBoneNames(): string[] {
469
- return this.skeleton.bones.map((b) => b.name)
470
- }
471
-
472
480
  rotateBones(names: string[], quats: Quat[], durationMs?: number): void {
473
- const state = this.rotTweenState
481
+ const state = this.tweenState
474
482
  const normalized = quats.map((q) => q.normalize())
475
- const now = performance.now()
483
+ const now = this.tweenTimeMs
476
484
  const dur = durationMs && durationMs > 0 ? durationMs : 0
477
485
 
478
486
  for (let i = 0; i < names.length; i++) {
@@ -489,7 +497,7 @@ export class Model {
489
497
  rotations[qi + 1] = ty
490
498
  rotations[qi + 2] = tz
491
499
  rotations[qi + 3] = tw
492
- state.active[idx] = 0
500
+ state.rotActive[idx] = 0
493
501
  continue
494
502
  }
495
503
 
@@ -498,22 +506,22 @@ export class Model {
498
506
  let sz = rotations[qi + 2]
499
507
  let sw = rotations[qi + 3]
500
508
 
501
- if (state.active[idx] === 1) {
502
- const startMs = state.startTimeMs[idx]
503
- const prevDur = Math.max(1, state.durationMs[idx])
509
+ if (state.rotActive[idx] === 1) {
510
+ const startMs = state.rotStartTimeMs[idx]
511
+ const prevDur = Math.max(1, state.rotDurationMs[idx])
504
512
  const t = Math.max(0, Math.min(1, (now - startMs) / prevDur))
505
513
  const e = easeInOut(t)
506
514
  const startQuat = new Quat(
507
- state.startQuat[qi],
508
- state.startQuat[qi + 1],
509
- state.startQuat[qi + 2],
510
- state.startQuat[qi + 3]
515
+ state.rotStartQuat[qi],
516
+ state.rotStartQuat[qi + 1],
517
+ state.rotStartQuat[qi + 2],
518
+ state.rotStartQuat[qi + 3]
511
519
  )
512
520
  const targetQuat = new Quat(
513
- state.targetQuat[qi],
514
- state.targetQuat[qi + 1],
515
- state.targetQuat[qi + 2],
516
- state.targetQuat[qi + 3]
521
+ state.rotTargetQuat[qi],
522
+ state.rotTargetQuat[qi + 1],
523
+ state.rotTargetQuat[qi + 2],
524
+ state.rotTargetQuat[qi + 3]
517
525
  )
518
526
  const result = Quat.slerp(startQuat, targetQuat, e)
519
527
  sx = result.x
@@ -522,25 +530,25 @@ export class Model {
522
530
  sw = result.w
523
531
  }
524
532
 
525
- state.startQuat[qi] = sx
526
- state.startQuat[qi + 1] = sy
527
- state.startQuat[qi + 2] = sz
528
- state.startQuat[qi + 3] = sw
529
- state.targetQuat[qi] = tx
530
- state.targetQuat[qi + 1] = ty
531
- state.targetQuat[qi + 2] = tz
532
- state.targetQuat[qi + 3] = tw
533
- state.startTimeMs[idx] = now
534
- state.durationMs[idx] = dur
535
- state.active[idx] = 1
533
+ state.rotStartQuat[qi] = sx
534
+ state.rotStartQuat[qi + 1] = sy
535
+ state.rotStartQuat[qi + 2] = sz
536
+ state.rotStartQuat[qi + 3] = sw
537
+ state.rotTargetQuat[qi] = tx
538
+ state.rotTargetQuat[qi + 1] = ty
539
+ state.rotTargetQuat[qi + 2] = tz
540
+ state.rotTargetQuat[qi + 3] = tw
541
+ state.rotStartTimeMs[idx] = now
542
+ state.rotDurationMs[idx] = dur
543
+ state.rotActive[idx] = 1
536
544
  }
537
545
  }
538
546
 
539
547
  // Move bones using VMD-style relative translations (relative to bind pose world position)
540
548
  // This is the default behavior for VMD animations
541
549
  moveBones(names: string[], relativeTranslations: Vec3[], durationMs?: number): void {
542
- const state = this.transTweenState
543
- const now = performance.now()
550
+ const state = this.tweenState
551
+ const now = this.tweenTimeMs
544
552
  const dur = durationMs && durationMs > 0 ? durationMs : 0
545
553
  const localRot = this.runtimeSkeleton.localRotations
546
554
 
@@ -608,7 +616,7 @@ export class Model {
608
616
  translations[ti] = tx
609
617
  translations[ti + 1] = ty
610
618
  translations[ti + 2] = tz
611
- state.active[idx] = 0
619
+ state.transActive[idx] = 0
612
620
  continue
613
621
  }
614
622
 
@@ -616,25 +624,25 @@ export class Model {
616
624
  let sy = translations[ti + 1]
617
625
  let sz = translations[ti + 2]
618
626
 
619
- if (state.active[idx] === 1) {
620
- const startMs = state.startTimeMs[idx]
621
- const prevDur = Math.max(1, state.durationMs[idx])
627
+ if (state.transActive[idx] === 1) {
628
+ const startMs = state.transStartTimeMs[idx]
629
+ const prevDur = Math.max(1, state.transDurationMs[idx])
622
630
  const t = Math.max(0, Math.min(1, (now - startMs) / prevDur))
623
631
  const e = easeInOut(t)
624
- sx = state.startVec[ti] + (state.targetVec[ti] - state.startVec[ti]) * e
625
- sy = state.startVec[ti + 1] + (state.targetVec[ti + 1] - state.startVec[ti + 1]) * e
626
- sz = state.startVec[ti + 2] + (state.targetVec[ti + 2] - state.startVec[ti + 2]) * e
632
+ sx = state.transStartVec[ti] + (state.transTargetVec[ti] - state.transStartVec[ti]) * e
633
+ sy = state.transStartVec[ti + 1] + (state.transTargetVec[ti + 1] - state.transStartVec[ti + 1]) * e
634
+ sz = state.transStartVec[ti + 2] + (state.transTargetVec[ti + 2] - state.transStartVec[ti + 2]) * e
627
635
  }
628
636
 
629
- state.startVec[ti] = sx
630
- state.startVec[ti + 1] = sy
631
- state.startVec[ti + 2] = sz
632
- state.targetVec[ti] = tx
633
- state.targetVec[ti + 1] = ty
634
- state.targetVec[ti + 2] = tz
635
- state.startTimeMs[idx] = now
636
- state.durationMs[idx] = dur
637
- state.active[idx] = 1
637
+ state.transStartVec[ti] = sx
638
+ state.transStartVec[ti + 1] = sy
639
+ state.transStartVec[ti + 2] = sz
640
+ state.transTargetVec[ti] = tx
641
+ state.transTargetVec[ti + 1] = ty
642
+ state.transTargetVec[ti + 2] = tz
643
+ state.transStartTimeMs[idx] = now
644
+ state.transDurationMs[idx] = dur
645
+ state.transActive[idx] = 1
638
646
  }
639
647
  }
640
648
 
@@ -646,8 +654,28 @@ export class Model {
646
654
  return this.skeleton.inverseBindMatrices
647
655
  }
648
656
 
649
- getMorphNames(): string[] {
650
- return this.morphing.morphs.map((m) => m.name)
657
+ getSkinMatrices(): Float32Array {
658
+ const boneCount = this.skeleton.bones.length
659
+ const worldMats = this.runtimeSkeleton.worldMatrices
660
+ const invBindMats = this.skeleton.inverseBindMatrices
661
+
662
+ // Initialize cached array if needed or if bone count changed
663
+ if (!this.cachedSkinMatrices || this.cachedSkinMatrices.length !== boneCount * 16) {
664
+ this.cachedSkinMatrices = new Float32Array(boneCount * 16)
665
+ }
666
+
667
+ const skinMatrices = this.cachedSkinMatrices
668
+
669
+ // Compute skin matrices: skinMatrix = worldMatrix × inverseBindMatrix
670
+ // Use Mat4.multiplyArrays to avoid creating Mat4 objects
671
+ for (let i = 0; i < boneCount; i++) {
672
+ const worldOffset = i * 16
673
+ const invBindOffset = i * 16
674
+ const skinOffset = i * 16
675
+ Mat4.multiplyArrays(worldMats, worldOffset, invBindMats, invBindOffset, skinMatrices, skinOffset)
676
+ }
677
+
678
+ return skinMatrices
651
679
  }
652
680
 
653
681
  setMorphWeight(name: string, weight: number, durationMs?: number): void {
@@ -660,30 +688,30 @@ export class Model {
660
688
  if (dur === 0) {
661
689
  // Instant change
662
690
  this.runtimeMorph.weights[idx] = clampedWeight
663
- this.morphTweenState.active[idx] = 0
691
+ this.tweenState.morphActive[idx] = 0
664
692
  this.applyMorphs()
665
693
  return
666
694
  }
667
695
 
668
696
  // Animated change
669
- const state = this.morphTweenState
670
- const now = performance.now()
697
+ const state = this.tweenState
698
+ const now = this.tweenTimeMs
671
699
 
672
700
  // If already tweening, start from current interpolated value
673
701
  let startWeight = this.runtimeMorph.weights[idx]
674
- if (state.active[idx] === 1) {
675
- const startMs = state.startTimeMs[idx]
676
- const prevDur = Math.max(1, state.durationMs[idx])
702
+ if (state.morphActive[idx] === 1) {
703
+ const startMs = state.morphStartTimeMs[idx]
704
+ const prevDur = Math.max(1, state.morphDurationMs[idx])
677
705
  const t = Math.max(0, Math.min(1, (now - startMs) / prevDur))
678
706
  const e = easeInOut(t)
679
- startWeight = state.startWeight[idx] + (state.targetWeight[idx] - state.startWeight[idx]) * e
707
+ startWeight = state.morphStartWeight[idx] + (state.morphTargetWeight[idx] - state.morphStartWeight[idx]) * e
680
708
  }
681
709
 
682
- state.startWeight[idx] = startWeight
683
- state.targetWeight[idx] = clampedWeight
684
- state.startTimeMs[idx] = now
685
- state.durationMs[idx] = dur
686
- state.active[idx] = 1
710
+ state.morphStartWeight[idx] = startWeight
711
+ state.morphTargetWeight[idx] = clampedWeight
712
+ state.morphStartTimeMs[idx] = now
713
+ state.morphDurationMs[idx] = dur
714
+ state.morphActive[idx] = 1
687
715
 
688
716
  // Immediately apply morphs with current weight
689
717
  this.runtimeMorph.weights[idx] = startWeight
@@ -755,15 +783,313 @@ export class Model {
755
783
  }
756
784
  }
757
785
 
758
- evaluatePose(): boolean {
759
- this.updateRotationTweens()
760
- this.updateTranslationTweens()
761
- const hasActiveMorphTweens = this.updateMorphWeightTweens()
762
- if (hasActiveMorphTweens) {
786
+ /**
787
+ * Load VMD animation file
788
+ */
789
+ async loadVmd(vmdUrl: string): Promise<void> {
790
+ this.animationData = await VMDLoader.load(vmdUrl)
791
+ this.processFrames()
792
+ // Apply initial pose at time 0
793
+ this.animationTime = 0
794
+ this.getPoseAtTime(0)
795
+
796
+ // Apply morphs if animation changed them
797
+ if (this.morphsDirty) {
763
798
  this.applyMorphs()
799
+ this.morphsDirty = false
764
800
  }
765
801
 
766
- // Compute initial world matrices (needed for IK solving to read bone positions)
802
+ // Compute world matrices after applying initial pose
803
+ this.computeWorldMatrices()
804
+ }
805
+
806
+ /**
807
+ * Process frames into tracks
808
+ */
809
+ private processFrames(): void {
810
+ if (!this.animationData) return
811
+
812
+ // Helper to group frames by name and sort by time
813
+ const groupFrames = <T>(
814
+ items: Array<{ item: T; name: string; time: number }>
815
+ ): Map<string, Array<{ item: T; time: number }>> => {
816
+ const tracks = new Map<string, Array<{ item: T; time: number }>>()
817
+ for (const { item, name, time } of items) {
818
+ if (!tracks.has(name)) tracks.set(name, [])
819
+ tracks.get(name)!.push({ item, time })
820
+ }
821
+ for (const keyFrames of tracks.values()) {
822
+ keyFrames.sort((a, b) => a.time - b.time)
823
+ }
824
+ return tracks
825
+ }
826
+
827
+ // Collect all bone and morph frames
828
+ const boneItems: Array<{ item: BoneFrame; name: string; time: number }> = []
829
+ const morphItems: Array<{ item: MorphFrame; name: string; time: number }> = []
830
+
831
+ for (const keyFrame of this.animationData) {
832
+ for (const boneFrame of keyFrame.boneFrames) {
833
+ boneItems.push({ item: boneFrame, name: boneFrame.boneName, time: keyFrame.time })
834
+ }
835
+ for (const morphFrame of keyFrame.morphFrames) {
836
+ morphItems.push({ item: morphFrame, name: morphFrame.morphName, time: keyFrame.time })
837
+ }
838
+ }
839
+
840
+ // Transform to expected format
841
+ this.boneTracks = new Map()
842
+ for (const [name, frames] of groupFrames(boneItems).entries()) {
843
+ this.boneTracks.set(
844
+ name,
845
+ frames.map((f) => ({ boneFrame: f.item, time: f.time }))
846
+ )
847
+ }
848
+
849
+ this.morphTracks = new Map()
850
+ for (const [name, frames] of groupFrames(morphItems).entries()) {
851
+ this.morphTracks.set(
852
+ name,
853
+ frames.map((f) => ({ morphFrame: f.item, time: f.time }))
854
+ )
855
+ }
856
+
857
+ // Calculate duration from all tracks
858
+ const allTracks = [...this.boneTracks.values(), ...this.morphTracks.values()]
859
+ this.animationDuration = allTracks.reduce((max, keyFrames) => {
860
+ const lastTime = keyFrames[keyFrames.length - 1]?.time ?? 0
861
+ return Math.max(max, lastTime)
862
+ }, 0)
863
+ }
864
+
865
+ /**
866
+ * Start or resume playback
867
+ */
868
+ playAnimation(): void {
869
+ if (!this.animationData) {
870
+ console.warn("[Model] Cannot play animation: no animation data loaded")
871
+ return
872
+ }
873
+
874
+ this.isPaused = false
875
+ this.isPlaying = true
876
+
877
+ // Apply initial pose at current animation time
878
+ this.getPoseAtTime(this.animationTime)
879
+
880
+ // Apply morphs if animation changed them
881
+ if (this.morphsDirty) {
882
+ this.applyMorphs()
883
+ this.morphsDirty = false
884
+ }
885
+
886
+ // Compute world matrices after applying pose
887
+ this.computeWorldMatrices()
888
+
889
+ // Reset physics when starting animation (prevents instability from sudden pose changes)
890
+ if (this.physics) {
891
+ this.physics.reset(this.runtimeSkeleton.worldMatrices, this.skeleton.inverseBindMatrices)
892
+ }
893
+ }
894
+
895
+ /**
896
+ * Pause playback
897
+ */
898
+ pauseAnimation(): void {
899
+ if (!this.isPlaying || this.isPaused) return
900
+ this.isPaused = true
901
+ }
902
+
903
+ /**
904
+ * Stop playback and reset to beginning
905
+ */
906
+ stopAnimation(): void {
907
+ this.isPlaying = false
908
+ this.isPaused = false
909
+ this.animationTime = 0
910
+
911
+ // Reset physics state when stopping animation (prevents instability from sudden pose changes)
912
+ if (this.physics) {
913
+ this.computeWorldMatrices()
914
+ this.physics.reset(this.runtimeSkeleton.worldMatrices, this.skeleton.inverseBindMatrices)
915
+ }
916
+ }
917
+
918
+ /**
919
+ * Seek to specific time
920
+ * Immediately applies pose at the seeked time
921
+ */
922
+ seekAnimation(time: number): void {
923
+ if (!this.animationData) return
924
+
925
+ const clampedTime = Math.max(0, Math.min(time, this.animationDuration))
926
+ this.animationTime = clampedTime
927
+ // Immediately apply pose at seeked time
928
+ this.getPoseAtTime(clampedTime)
929
+
930
+ // Apply morphs if animation changed them
931
+ if (this.morphsDirty) {
932
+ this.applyMorphs()
933
+ this.morphsDirty = false
934
+ }
935
+
936
+ // Compute world matrices after applying pose
937
+ this.computeWorldMatrices()
938
+ }
939
+
940
+ /**
941
+ * Get current animation progress
942
+ */
943
+ getAnimationProgress(): { current: number; duration: number; percentage: number } {
944
+ const duration = this.animationDuration
945
+ const percentage = duration > 0 ? (this.animationTime / duration) * 100 : 0
946
+ return {
947
+ current: this.animationTime,
948
+ duration,
949
+ percentage,
950
+ }
951
+ }
952
+
953
+ /**
954
+ * Get pose at specific time (internal helper)
955
+ */
956
+ private getPoseAtTime(time: number): void {
957
+ if (!this.animationData) return
958
+
959
+ // Helper for binary search upper bound
960
+ const upperBound = <T extends { time: number }>(time: number, keyFrames: T[]): number => {
961
+ let left = 0,
962
+ right = keyFrames.length
963
+ while (left < right) {
964
+ const mid = Math.floor((left + right) / 2)
965
+ if (keyFrames[mid].time <= time) left = mid + 1
966
+ else right = mid
967
+ }
968
+ return left
969
+ }
970
+
971
+ // Process bone tracks
972
+ for (const [boneName, keyFrames] of this.boneTracks.entries()) {
973
+ if (keyFrames.length === 0) continue
974
+
975
+ const clampedTime = Math.max(keyFrames[0].time, Math.min(keyFrames[keyFrames.length - 1].time, time))
976
+ const idx = upperBound(clampedTime, keyFrames) - 1
977
+ if (idx < 0) continue
978
+
979
+ const frameA = keyFrames[idx].boneFrame
980
+ const frameB = keyFrames[idx + 1]?.boneFrame
981
+
982
+ const boneIdx = this.runtimeSkeleton.nameIndex[boneName]
983
+ if (boneIdx === undefined) continue
984
+
985
+ if (!frameB) {
986
+ this.runtimeSkeleton.localRotations[boneIdx * 4] = frameA.rotation.x
987
+ this.runtimeSkeleton.localRotations[boneIdx * 4 + 1] = frameA.rotation.y
988
+ this.runtimeSkeleton.localRotations[boneIdx * 4 + 2] = frameA.rotation.z
989
+ this.runtimeSkeleton.localRotations[boneIdx * 4 + 3] = frameA.rotation.w
990
+ this.runtimeSkeleton.localTranslations[boneIdx * 3] = frameA.translation.x
991
+ this.runtimeSkeleton.localTranslations[boneIdx * 3 + 1] = frameA.translation.y
992
+ this.runtimeSkeleton.localTranslations[boneIdx * 3 + 2] = frameA.translation.z
993
+ } else {
994
+ const timeA = keyFrames[idx].time
995
+ const timeB = keyFrames[idx + 1].time
996
+ const gradient = (clampedTime - timeA) / (timeB - timeA)
997
+ const interp = frameB.interpolation
998
+
999
+ // Interpolate rotation using SLERP with bezier
1000
+ const rotT = bezierInterpolate(interp[0] / 127, interp[1] / 127, interp[2] / 127, interp[3] / 127, gradient)
1001
+ const rotation = Quat.slerp(frameA.rotation, frameB.rotation, rotT)
1002
+
1003
+ // Interpolate translation using bezier for each component
1004
+ const getWeight = (offset: number) =>
1005
+ bezierInterpolate(
1006
+ interp[offset] / 127,
1007
+ interp[offset + 8] / 127,
1008
+ interp[offset + 4] / 127,
1009
+ interp[offset + 12] / 127,
1010
+ gradient
1011
+ )
1012
+
1013
+ const lerp = (a: number, b: number, w: number) => a + (b - a) * w
1014
+ const translation = new Vec3(
1015
+ lerp(frameA.translation.x, frameB.translation.x, getWeight(0)),
1016
+ lerp(frameA.translation.y, frameB.translation.y, getWeight(16)),
1017
+ lerp(frameA.translation.z, frameB.translation.z, getWeight(32))
1018
+ )
1019
+
1020
+ this.runtimeSkeleton.localRotations[boneIdx * 4] = rotation.x
1021
+ this.runtimeSkeleton.localRotations[boneIdx * 4 + 1] = rotation.y
1022
+ this.runtimeSkeleton.localRotations[boneIdx * 4 + 2] = rotation.z
1023
+ this.runtimeSkeleton.localRotations[boneIdx * 4 + 3] = rotation.w
1024
+ this.runtimeSkeleton.localTranslations[boneIdx * 3] = translation.x
1025
+ this.runtimeSkeleton.localTranslations[boneIdx * 3 + 1] = translation.y
1026
+ this.runtimeSkeleton.localTranslations[boneIdx * 3 + 2] = translation.z
1027
+ }
1028
+ }
1029
+
1030
+ // Process morph tracks
1031
+ for (const [morphName, keyFrames] of this.morphTracks.entries()) {
1032
+ if (keyFrames.length === 0) continue
1033
+
1034
+ const clampedTime = Math.max(keyFrames[0].time, Math.min(keyFrames[keyFrames.length - 1].time, time))
1035
+ const idx = upperBound(clampedTime, keyFrames) - 1
1036
+ if (idx < 0) continue
1037
+
1038
+ const frameA = keyFrames[idx].morphFrame
1039
+ const frameB = keyFrames[idx + 1]?.morphFrame
1040
+
1041
+ const morphIdx = this.runtimeMorph.nameIndex[morphName]
1042
+ if (morphIdx === undefined) continue
1043
+
1044
+ const weight = frameB
1045
+ ? frameA.weight +
1046
+ (frameB.weight - frameA.weight) *
1047
+ ((clampedTime - keyFrames[idx].time) / (keyFrames[idx + 1].time - keyFrames[idx].time))
1048
+ : frameA.weight
1049
+
1050
+ this.runtimeMorph.weights[morphIdx] = weight
1051
+ this.morphsDirty = true // Mark as dirty when animation sets morph weights
1052
+ }
1053
+ }
1054
+
1055
+ /**
1056
+ * Updates the model pose by recomputing all matrices.
1057
+ * If animation is playing, applies animation pose first.
1058
+ * deltaTime: Time elapsed since last update in seconds
1059
+ * Returns true if vertices were modified (morphs changed)
1060
+ */
1061
+ update(deltaTime: number): boolean {
1062
+ // Update tween time (in milliseconds)
1063
+ this.tweenTimeMs += deltaTime * 1000
1064
+
1065
+ // Update all active tweens (rotations, translations, morphs)
1066
+ const tweensChangedMorphs = this.updateTweens()
1067
+
1068
+ // Apply animation if playing or paused (always apply pose if animation data exists and we have a time set)
1069
+ if (this.animationData) {
1070
+ if (this.isPlaying && !this.isPaused) {
1071
+ this.animationTime += deltaTime
1072
+
1073
+ if (this.animationTime >= this.animationDuration) {
1074
+ this.animationTime = this.animationDuration
1075
+ this.pauseAnimation() // Auto-pause at end
1076
+ }
1077
+
1078
+ this.getPoseAtTime(this.animationTime)
1079
+ } else if (this.isPaused || (!this.isPlaying && this.animationTime >= 0)) {
1080
+ // Apply pose at paused time or if we have a seeked time but not playing
1081
+ this.getPoseAtTime(this.animationTime)
1082
+ }
1083
+ }
1084
+
1085
+ // Apply morphs if tweens changed morphs or animation changed morphs
1086
+ const verticesChanged = this.morphsDirty || tweensChangedMorphs
1087
+ if (this.morphsDirty || tweensChangedMorphs) {
1088
+ this.applyMorphs()
1089
+ this.morphsDirty = false
1090
+ }
1091
+
1092
+ // Compute world matrices (needed for IK solving to read bone positions)
767
1093
  this.computeWorldMatrices()
768
1094
 
769
1095
  // Solve IK chains (modifies localRotations with final IK rotations)
@@ -772,7 +1098,11 @@ export class Model {
772
1098
  // Recompute world matrices with final IK rotations applied to localRotations
773
1099
  this.computeWorldMatrices()
774
1100
 
775
- return hasActiveMorphTweens
1101
+ if (this.physics) {
1102
+ this.physics.step(deltaTime, this.runtimeSkeleton.worldMatrices, this.skeleton.inverseBindMatrices)
1103
+ }
1104
+
1105
+ return verticesChanged
776
1106
  }
777
1107
 
778
1108
  private solveIKChains(): void {