reze-engine 0.3.6 → 0.3.8

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,25 +298,31 @@ export class Model {
278
298
  this.runtimeSkeleton.ikSolvers = ikSolvers
279
299
  }
280
300
 
281
- private initializeRotTweenBuffers(): void {
282
- this.rotTweenState = this.createTweenState(this.skeleton.bones.length, 4, 4)
283
- }
284
-
285
- private initializeTransTweenBuffers(): void {
286
- this.transTweenState = this.createTweenState(this.skeleton.bones.length, 3, 3)
287
- }
288
-
289
- private initializeMorphTweenBuffers(): void {
290
- this.morphTweenState = this.createTweenState(this.morphing.morphs.length, 1, 1)
291
- }
301
+ private initializeTweenBuffers(): void {
302
+ const boneCount = this.skeleton.bones.length
303
+ const morphCount = this.morphing.morphs.length
292
304
 
293
- private createTweenState(count: number, startSize: number, targetSize: number): any {
294
- return {
295
- active: new Uint8Array(count),
296
- startQuat: new Float32Array(count * startSize),
297
- targetQuat: new Float32Array(count * targetSize),
298
- startTimeMs: new Float32Array(count),
299
- durationMs: new Float32Array(count),
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),
300
326
  }
301
327
  }
302
328
 
@@ -311,32 +337,37 @@ export class Model {
311
337
  }
312
338
  }
313
339
 
314
- private updateRotationTweens(): void {
315
- const state = this.rotTweenState
316
- 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
317
349
  const rotations = this.runtimeSkeleton.localRotations
318
350
  const boneCount = this.skeleton.bones.length
319
-
320
351
  for (let i = 0; i < boneCount; i++) {
321
- if (state.active[i] !== 1) continue
352
+ if (state.rotActive[i] !== 1) continue
322
353
 
323
- const startMs = state.startTimeMs[i]
324
- const durMs = Math.max(1, state.durationMs[i])
354
+ const startMs = state.rotStartTimeMs[i]
355
+ const durMs = Math.max(1, state.rotDurationMs[i])
325
356
  const t = Math.max(0, Math.min(1, (now - startMs) / durMs))
326
357
  const e = easeInOut(t)
327
358
 
328
359
  const qi = i * 4
329
360
  const startQuat = new Quat(
330
- state.startQuat[qi],
331
- state.startQuat[qi + 1],
332
- state.startQuat[qi + 2],
333
- state.startQuat[qi + 3]
361
+ state.rotStartQuat[qi],
362
+ state.rotStartQuat[qi + 1],
363
+ state.rotStartQuat[qi + 2],
364
+ state.rotStartQuat[qi + 3]
334
365
  )
335
366
  const targetQuat = new Quat(
336
- state.targetQuat[qi],
337
- state.targetQuat[qi + 1],
338
- state.targetQuat[qi + 2],
339
- state.targetQuat[qi + 3]
367
+ state.rotTargetQuat[qi],
368
+ state.rotTargetQuat[qi + 1],
369
+ state.rotTargetQuat[qi + 2],
370
+ state.rotTargetQuat[qi + 3]
340
371
  )
341
372
  const result = Quat.slerp(startQuat, targetQuat, e)
342
373
 
@@ -345,58 +376,63 @@ export class Model {
345
376
  rotations[qi + 2] = result.z
346
377
  rotations[qi + 3] = result.w
347
378
 
348
- if (t >= 1) state.active[i] = 0
379
+ if (t >= 1) {
380
+ state.rotActive[i] = 0
381
+ }
349
382
  }
350
- }
351
383
 
352
- private updateTranslationTweens(): void {
353
- const state = this.transTweenState
354
- const now = performance.now()
384
+ // Update bone translation tweens
355
385
  const translations = this.runtimeSkeleton.localTranslations
356
- const boneCount = this.skeleton.bones.length
357
-
358
386
  for (let i = 0; i < boneCount; i++) {
359
- if (state.active[i] !== 1) continue
387
+ if (state.transActive[i] !== 1) continue
360
388
 
361
- const startMs = state.startTimeMs[i]
362
- const durMs = Math.max(1, state.durationMs[i])
389
+ const startMs = state.transStartTimeMs[i]
390
+ const durMs = Math.max(1, state.transDurationMs[i])
363
391
  const t = Math.max(0, Math.min(1, (now - startMs) / durMs))
364
392
  const e = easeInOut(t)
365
393
 
366
394
  const ti = i * 3
367
- translations[ti] = state.startVec[ti] + (state.targetVec[ti] - state.startVec[ti]) * e
368
- translations[ti + 1] = state.startVec[ti + 1] + (state.targetVec[ti + 1] - state.startVec[ti + 1]) * e
369
- 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
370
400
 
371
- if (t >= 1) state.active[i] = 0
401
+ if (t >= 1) {
402
+ state.transActive[i] = 0
403
+ }
372
404
  }
373
- }
374
405
 
375
- private updateMorphWeightTweens(): boolean {
376
- const state = this.morphTweenState
377
- const now = performance.now()
406
+ // Update morph weight tweens
378
407
  const weights = this.runtimeMorph.weights
379
408
  const morphCount = this.morphing.morphs.length
380
- let hasActiveTweens = false
381
-
382
409
  for (let i = 0; i < morphCount; i++) {
383
- if (state.active[i] !== 1) continue
410
+ if (state.morphActive[i] !== 1) continue
384
411
 
385
- hasActiveTweens = true
386
- const startMs = state.startTimeMs[i]
387
- const durMs = Math.max(1, state.durationMs[i])
412
+ const startMs = state.morphStartTimeMs[i]
413
+ const durMs = Math.max(1, state.morphDurationMs[i])
388
414
  const t = Math.max(0, Math.min(1, (now - startMs) / durMs))
389
415
  const e = easeInOut(t)
390
416
 
391
- 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
+ }
392
424
 
393
425
  if (t >= 1) {
394
- weights[i] = state.targetWeight[i]
395
- 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
+ }
396
432
  }
397
433
  }
398
434
 
399
- return hasActiveTweens
435
+ return morphChanged
400
436
  }
401
437
 
402
438
  getVertices(): Float32Array<ArrayBuffer> {
@@ -442,9 +478,9 @@ export class Model {
442
478
  // ------- Bone helpers (public API) -------
443
479
 
444
480
  rotateBones(names: string[], quats: Quat[], durationMs?: number): void {
445
- const state = this.rotTweenState
481
+ const state = this.tweenState
446
482
  const normalized = quats.map((q) => q.normalize())
447
- const now = performance.now()
483
+ const now = this.tweenTimeMs
448
484
  const dur = durationMs && durationMs > 0 ? durationMs : 0
449
485
 
450
486
  for (let i = 0; i < names.length; i++) {
@@ -461,7 +497,7 @@ export class Model {
461
497
  rotations[qi + 1] = ty
462
498
  rotations[qi + 2] = tz
463
499
  rotations[qi + 3] = tw
464
- state.active[idx] = 0
500
+ state.rotActive[idx] = 0
465
501
  continue
466
502
  }
467
503
 
@@ -470,22 +506,22 @@ export class Model {
470
506
  let sz = rotations[qi + 2]
471
507
  let sw = rotations[qi + 3]
472
508
 
473
- if (state.active[idx] === 1) {
474
- const startMs = state.startTimeMs[idx]
475
- 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])
476
512
  const t = Math.max(0, Math.min(1, (now - startMs) / prevDur))
477
513
  const e = easeInOut(t)
478
514
  const startQuat = new Quat(
479
- state.startQuat[qi],
480
- state.startQuat[qi + 1],
481
- state.startQuat[qi + 2],
482
- state.startQuat[qi + 3]
515
+ state.rotStartQuat[qi],
516
+ state.rotStartQuat[qi + 1],
517
+ state.rotStartQuat[qi + 2],
518
+ state.rotStartQuat[qi + 3]
483
519
  )
484
520
  const targetQuat = new Quat(
485
- state.targetQuat[qi],
486
- state.targetQuat[qi + 1],
487
- state.targetQuat[qi + 2],
488
- state.targetQuat[qi + 3]
521
+ state.rotTargetQuat[qi],
522
+ state.rotTargetQuat[qi + 1],
523
+ state.rotTargetQuat[qi + 2],
524
+ state.rotTargetQuat[qi + 3]
489
525
  )
490
526
  const result = Quat.slerp(startQuat, targetQuat, e)
491
527
  sx = result.x
@@ -494,25 +530,25 @@ export class Model {
494
530
  sw = result.w
495
531
  }
496
532
 
497
- state.startQuat[qi] = sx
498
- state.startQuat[qi + 1] = sy
499
- state.startQuat[qi + 2] = sz
500
- state.startQuat[qi + 3] = sw
501
- state.targetQuat[qi] = tx
502
- state.targetQuat[qi + 1] = ty
503
- state.targetQuat[qi + 2] = tz
504
- state.targetQuat[qi + 3] = tw
505
- state.startTimeMs[idx] = now
506
- state.durationMs[idx] = dur
507
- 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
508
544
  }
509
545
  }
510
546
 
511
547
  // Move bones using VMD-style relative translations (relative to bind pose world position)
512
548
  // This is the default behavior for VMD animations
513
549
  moveBones(names: string[], relativeTranslations: Vec3[], durationMs?: number): void {
514
- const state = this.transTweenState
515
- const now = performance.now()
550
+ const state = this.tweenState
551
+ const now = this.tweenTimeMs
516
552
  const dur = durationMs && durationMs > 0 ? durationMs : 0
517
553
  const localRot = this.runtimeSkeleton.localRotations
518
554
 
@@ -580,7 +616,7 @@ export class Model {
580
616
  translations[ti] = tx
581
617
  translations[ti + 1] = ty
582
618
  translations[ti + 2] = tz
583
- state.active[idx] = 0
619
+ state.transActive[idx] = 0
584
620
  continue
585
621
  }
586
622
 
@@ -588,25 +624,25 @@ export class Model {
588
624
  let sy = translations[ti + 1]
589
625
  let sz = translations[ti + 2]
590
626
 
591
- if (state.active[idx] === 1) {
592
- const startMs = state.startTimeMs[idx]
593
- 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])
594
630
  const t = Math.max(0, Math.min(1, (now - startMs) / prevDur))
595
631
  const e = easeInOut(t)
596
- sx = state.startVec[ti] + (state.targetVec[ti] - state.startVec[ti]) * e
597
- sy = state.startVec[ti + 1] + (state.targetVec[ti + 1] - state.startVec[ti + 1]) * e
598
- 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
599
635
  }
600
636
 
601
- state.startVec[ti] = sx
602
- state.startVec[ti + 1] = sy
603
- state.startVec[ti + 2] = sz
604
- state.targetVec[ti] = tx
605
- state.targetVec[ti + 1] = ty
606
- state.targetVec[ti + 2] = tz
607
- state.startTimeMs[idx] = now
608
- state.durationMs[idx] = dur
609
- 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
610
646
  }
611
647
  }
612
648
 
@@ -618,6 +654,30 @@ export class Model {
618
654
  return this.skeleton.inverseBindMatrices
619
655
  }
620
656
 
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
679
+ }
680
+
621
681
  setMorphWeight(name: string, weight: number, durationMs?: number): void {
622
682
  const idx = this.runtimeMorph.nameIndex[name] ?? -1
623
683
  if (idx < 0 || idx >= this.runtimeMorph.weights.length) return
@@ -628,30 +688,30 @@ export class Model {
628
688
  if (dur === 0) {
629
689
  // Instant change
630
690
  this.runtimeMorph.weights[idx] = clampedWeight
631
- this.morphTweenState.active[idx] = 0
691
+ this.tweenState.morphActive[idx] = 0
632
692
  this.applyMorphs()
633
693
  return
634
694
  }
635
695
 
636
696
  // Animated change
637
- const state = this.morphTweenState
638
- const now = performance.now()
697
+ const state = this.tweenState
698
+ const now = this.tweenTimeMs
639
699
 
640
700
  // If already tweening, start from current interpolated value
641
701
  let startWeight = this.runtimeMorph.weights[idx]
642
- if (state.active[idx] === 1) {
643
- const startMs = state.startTimeMs[idx]
644
- 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])
645
705
  const t = Math.max(0, Math.min(1, (now - startMs) / prevDur))
646
706
  const e = easeInOut(t)
647
- startWeight = state.startWeight[idx] + (state.targetWeight[idx] - state.startWeight[idx]) * e
707
+ startWeight = state.morphStartWeight[idx] + (state.morphTargetWeight[idx] - state.morphStartWeight[idx]) * e
648
708
  }
649
709
 
650
- state.startWeight[idx] = startWeight
651
- state.targetWeight[idx] = clampedWeight
652
- state.startTimeMs[idx] = now
653
- state.durationMs[idx] = dur
654
- 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
655
715
 
656
716
  // Immediately apply morphs with current weight
657
717
  this.runtimeMorph.weights[idx] = startWeight
@@ -723,15 +783,313 @@ export class Model {
723
783
  }
724
784
  }
725
785
 
726
- evaluatePose(): boolean {
727
- this.updateRotationTweens()
728
- this.updateTranslationTweens()
729
- const hasActiveMorphTweens = this.updateMorphWeightTweens()
730
- 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) {
731
798
  this.applyMorphs()
799
+ this.morphsDirty = false
732
800
  }
733
801
 
734
- // 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 && this.animationTime === 0) {
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)
735
1093
  this.computeWorldMatrices()
736
1094
 
737
1095
  // Solve IK chains (modifies localRotations with final IK rotations)
@@ -740,7 +1098,11 @@ export class Model {
740
1098
  // Recompute world matrices with final IK rotations applied to localRotations
741
1099
  this.computeWorldMatrices()
742
1100
 
743
- return hasActiveMorphTweens
1101
+ if (this.physics) {
1102
+ this.physics.step(deltaTime, this.runtimeSkeleton.worldMatrices, this.skeleton.inverseBindMatrices)
1103
+ }
1104
+
1105
+ return verticesChanged
744
1106
  }
745
1107
 
746
1108
  private solveIKChains(): void {