reze-engine 0.28.0 → 0.29.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,4 +1,4 @@
1
- import { Mat4, Quat, Vec3, scratchMat4Values, scratchQuat } from "./math"
1
+ import { Mat4, Quat, Vec3, easeInOut, scratchMat4Values, scratchQuat } from "./math"
2
2
  import { Engine } from "./engine"
3
3
  import { joinAssetPath, type AssetReader } from "./asset-reader"
4
4
  import { Rigidbody, Joint } from "./physics"
@@ -10,8 +10,10 @@ import {
10
10
  AnimationPlayOptions,
11
11
  AnimationProgress,
12
12
  AnimationState,
13
+ BlendEntry,
13
14
  BoneInterpolation,
14
15
  BoneKeyframe,
16
+ FPS,
15
17
  IkKeyframe,
16
18
  MorphKeyframe,
17
19
  interpolateControlPoints,
@@ -27,6 +29,13 @@ const _animSlerp = new Quat(0, 0, 0, 1)
27
29
  const _animInterpT = new Vec3(0, 0, 0)
28
30
  const _convOut = new Vec3(0, 0, 0)
29
31
  const _convMat = new Float32Array(16)
32
+ // Blend-path scratch: per-entry sample target and the crossfade's two fixed entries.
33
+ const _blendQ = new Quat(0, 0, 0, 1)
34
+ const _blendT = new Vec3(0, 0, 0)
35
+ const _fadeEntries: BlendEntry[] = [
36
+ { name: "", time: 0, weight: 0 },
37
+ { name: "", time: 0, weight: 0 },
38
+ ]
30
39
 
31
40
  export interface Texture {
32
41
  path: string
@@ -304,6 +313,23 @@ export class Model {
304
313
  private morphTrackIndices: Map<string, number> = new Map()
305
314
  private lastAppliedClip: AnimationClip | null = null
306
315
 
316
+ // Blended pose: declarative N-clip mix (setBlendPose) or a running crossfade.
317
+ // Cursor caches are per clip so sampling several clips in one frame doesn't
318
+ // thrash the single-clip caches above; WeakMap so removed clips can collect.
319
+ private blendEntries: BlendEntry[] | null = null
320
+ private crossfade: { fromName: string | null; fromFrame: number; fromLoop: boolean; elapsed: number; duration: number } | null = null
321
+ private readonly blendBoneCursors = new WeakMap<AnimationClip, Map<string, number>>()
322
+ private readonly blendMorphCursors = new WeakMap<AnimationClip, Map<string, number>>()
323
+ // Per-bone accumulators (lazy — sized to the skeleton on first blend). Generation
324
+ // marks avoid a clear pass: a slot is live this frame only when gen matches.
325
+ private blendRotAcc: Quat[] | null = null
326
+ private blendTransAcc: Vec3[] | null = null
327
+ private blendWeightAcc: Float32Array | null = null
328
+ private blendBoneGen: Int32Array | null = null
329
+ private blendMorphAcc: Float32Array | null = null
330
+ private blendMorphGen: Int32Array | null = null
331
+ private blendGenCounter = 0
332
+
307
333
  private assetReader: AssetReader | null = null
308
334
  private assetBasePath = ""
309
335
 
@@ -674,6 +700,21 @@ export class Model {
674
700
  return this.loadWarnings
675
701
  }
676
702
 
703
+ /** Post-pose local rotation offsets by bone index (see setBoneRotationOffset). */
704
+ private readonly boneRotationOffsets = new Map<number, Quat>()
705
+
706
+ /** Compose a constant local rotation onto a bone AFTER every pose source (clip,
707
+ * blend, tween) each frame — the classic MMD "heel correction": pitch the 足首
708
+ * bones so a motion authored for flat shoes grounds a heeled model. Persists
709
+ * across play()/show(); pass null to clear. */
710
+ setBoneRotationOffset(boneName: string, rotation: Quat | null): boolean {
711
+ const idx = this.runtimeSkeleton.nameIndex[boneName]
712
+ if (idx === undefined || idx < 0) return false
713
+ if (rotation === null) this.boneRotationOffsets.delete(idx)
714
+ else this.boneRotationOffsets.set(idx, rotation.clone())
715
+ return true
716
+ }
717
+
677
718
  getRigidbodies(): Rigidbody[] {
678
719
  return this.rigidbodies
679
720
  }
@@ -1282,20 +1323,63 @@ export class Model {
1282
1323
  play(name?: string, options?: AnimationPlayOptions): void | boolean {
1283
1324
  this.clipApplySuspended = false
1284
1325
  if (name === undefined) {
1326
+ // Resume: a paused crossfade continues from where it held.
1285
1327
  this.animationState.play()
1286
1328
  return
1287
1329
  }
1330
+ this.blendEntries = null
1331
+ this.crossfade = null
1288
1332
  this.resetAllBones()
1289
1333
  this.resetAllMorphs()
1290
1334
  return this.animationState.play(name, options)
1291
1335
  }
1292
1336
 
1293
1337
  show(name: string): void {
1338
+ this.blendEntries = null
1339
+ this.crossfade = null
1294
1340
  this.resetAllBones()
1295
1341
  this.resetAllMorphs()
1296
1342
  this.animationState.show(name)
1297
1343
  }
1298
1344
 
1345
+ /** Drive the pose from N weighted clips; the caller owns every clock (see BlendEntry).
1346
+ * The entries array is held by reference and read each update, so a per-frame driver
1347
+ * can mutate it in place without re-calling. Cleared by play(name)/show()/stop()/
1348
+ * clearAnimation()/crossfadeTo(). The single-clip player keeps ticking underneath
1349
+ * but stops writing the pose while a blend is set. */
1350
+ setBlendPose(entries: BlendEntry[]): void {
1351
+ this.blendEntries = entries
1352
+ this.crossfade = null
1353
+ this.clipApplySuspended = false
1354
+ }
1355
+
1356
+ clearBlendPose(): void {
1357
+ this.blendEntries = null
1358
+ }
1359
+
1360
+ /** Fade from the currently playing clip (or from the rest pose when nothing plays)
1361
+ * into `name` over `seconds`. The target starts at frame 0 and becomes the current
1362
+ * clip immediately — progress, looping and the camera clock report the target for
1363
+ * the whole fade. Bones only the outgoing clip animates ease back to rest. */
1364
+ crossfadeTo(name: string, seconds: number, options?: { loop?: boolean }): boolean {
1365
+ if (!this.animationState.hasAnimation(name)) return false
1366
+ this.blendEntries = null
1367
+ this.clipApplySuspended = false
1368
+
1369
+ const fromName = this.animationState.getCurrentAnimation()
1370
+ const fromFrame = this.animationState.getCurrentFrame()
1371
+ const fromLoop = this.animationState.getProgress().looping
1372
+ this.animationState.forcePlay(name, options?.loop ?? false)
1373
+
1374
+ // Fading a clip into itself has no second pose to hold — just restart.
1375
+ if (seconds <= 0 || fromName === name) {
1376
+ this.crossfade = null
1377
+ return true
1378
+ }
1379
+ this.crossfade = { fromName, fromFrame, fromLoop, elapsed: 0, duration: seconds }
1380
+ return true
1381
+ }
1382
+
1299
1383
  // @deprecated Use model.play()
1300
1384
  playAnimation(): void {
1301
1385
  this.animationState.play()
@@ -1311,6 +1395,8 @@ export class Model {
1311
1395
  }
1312
1396
 
1313
1397
  stop(): void {
1398
+ this.blendEntries = null
1399
+ this.crossfade = null
1314
1400
  this.animationState.stop()
1315
1401
  }
1316
1402
 
@@ -1323,12 +1409,15 @@ export class Model {
1323
1409
  * pose is no longer re-applied each frame afterwards — follow with
1324
1410
  * resetAllBones()/resetAllMorphs() to return to the bind pose. */
1325
1411
  clearAnimation(): void {
1412
+ this.blendEntries = null
1413
+ this.crossfade = null
1326
1414
  this.animationState.clear()
1327
1415
  }
1328
1416
 
1329
1417
  // Seek by absolute timeline seconds, not frame index.
1330
1418
  seek(seconds: number): void {
1331
1419
  this.clipApplySuspended = false
1420
+ this.crossfade = null // a timeline jump mid-fade snaps to the target clip
1332
1421
  this.animationState.seek(seconds)
1333
1422
  }
1334
1423
 
@@ -1401,6 +1490,73 @@ export class Model {
1401
1490
  }
1402
1491
  }
1403
1492
 
1493
+ /** Sample one bone track at `frame` into outRot + outVmdTrans. The translation is
1494
+ * VMD-space (bind-relative), NOT yet converted to bone-local — callers convert with
1495
+ * convertVMDTranslationToLocal once they know the final rotation. The cursor cache
1496
+ * belongs to the caller (single-clip path vs per-clip blend caches). Returns false
1497
+ * for an empty track. */
1498
+ private sampleBoneTrackInto(
1499
+ boneName: string,
1500
+ keyFrames: BoneKeyframe[],
1501
+ frame: number,
1502
+ cursors: Map<string, number>,
1503
+ outRot: Quat,
1504
+ outVmdTrans: Vec3
1505
+ ): boolean {
1506
+ if (keyFrames.length === 0) return false
1507
+
1508
+ const cachedIdx = cursors.get(boneName) ?? -1
1509
+ const clampedFrame = Math.max(keyFrames[0].frame, Math.min(keyFrames[keyFrames.length - 1].frame, frame))
1510
+ const idx = this.findKeyframeIndex(clampedFrame, keyFrames, cachedIdx)
1511
+ if (idx < 0) return false
1512
+ cursors.set(boneName, idx)
1513
+
1514
+ const frameA = keyFrames[idx]
1515
+ const frameB = keyFrames[idx + 1]
1516
+
1517
+ if (!frameB) {
1518
+ outRot.set(frameA.rotation)
1519
+ outVmdTrans.set(frameA.translation)
1520
+ } else {
1521
+ const frameDelta = frameB.frame - frameA.frame
1522
+ const gradient = frameDelta > 0 ? (clampedFrame - frameA.frame) / frameDelta : 0
1523
+ const interp = frameB.interpolation
1524
+
1525
+ const rotT = interpolateControlPoints(interp.rotation, gradient)
1526
+ Quat.slerpInto(frameA.rotation, frameB.rotation, rotT, outRot)
1527
+
1528
+ const txWeight = interpolateControlPoints(interp.translationX, gradient)
1529
+ const tyWeight = interpolateControlPoints(interp.translationY, gradient)
1530
+ const tzWeight = interpolateControlPoints(interp.translationZ, gradient)
1531
+
1532
+ outVmdTrans.setXYZ(
1533
+ frameA.translation.x + (frameB.translation.x - frameA.translation.x) * txWeight,
1534
+ frameA.translation.y + (frameB.translation.y - frameA.translation.y) * tyWeight,
1535
+ frameA.translation.z + (frameB.translation.z - frameA.translation.z) * tzWeight
1536
+ )
1537
+ }
1538
+ return true
1539
+ }
1540
+
1541
+ /** Sample one morph track at `frame` (linear weight lerp). Returns NaN for an empty track. */
1542
+ private sampleMorphTrack(morphName: string, keyFrames: MorphKeyframe[], frame: number, cursors: Map<string, number>): number {
1543
+ if (keyFrames.length === 0) return NaN
1544
+
1545
+ const cachedIdx = cursors.get(morphName) ?? -1
1546
+ const clampedFrame = Math.max(keyFrames[0].frame, Math.min(keyFrames[keyFrames.length - 1].frame, frame))
1547
+ const idx = this.findKeyframeIndex(clampedFrame, keyFrames, cachedIdx)
1548
+ if (idx < 0) return NaN
1549
+ cursors.set(morphName, idx)
1550
+
1551
+ const frameA = keyFrames[idx]
1552
+ const frameB = keyFrames[idx + 1]
1553
+ return frameB
1554
+ ? frameA.weight +
1555
+ (frameB.weight - frameA.weight) *
1556
+ (frameB.frame > frameA.frame ? (clampedFrame - frameA.frame) / (frameB.frame - frameA.frame) : 0)
1557
+ : frameA.weight
1558
+ }
1559
+
1404
1560
  private applyPoseFromClip(clip: AnimationClip | null, frame: number): void {
1405
1561
  if (!clip) return
1406
1562
  this.applyIkFromClip(clip, frame)
@@ -1411,83 +1567,204 @@ export class Model {
1411
1567
  }
1412
1568
 
1413
1569
  for (const [boneName, keyFrames] of clip.boneTracks.entries()) {
1414
- if (keyFrames.length === 0) continue
1415
-
1416
- const cachedIdx = this.boneTrackIndices.get(boneName) ?? -1
1417
- const clampedFrame = Math.max(keyFrames[0].frame, Math.min(keyFrames[keyFrames.length - 1].frame, frame))
1418
- const idx = this.findKeyframeIndex(clampedFrame, keyFrames, cachedIdx)
1419
-
1420
- if (idx < 0) continue
1421
-
1422
- this.boneTrackIndices.set(boneName, idx)
1423
-
1424
- const frameA = keyFrames[idx]
1425
- const frameB = keyFrames[idx + 1]
1570
+ if (!this.sampleBoneTrackInto(boneName, keyFrames, frame, this.boneTrackIndices, _animSlerp, _animInterpT)) continue
1426
1571
 
1427
1572
  const boneIdx = this.runtimeSkeleton.nameIndex[boneName]
1428
1573
  if (boneIdx === undefined) continue
1429
1574
 
1430
- const localRot = this.runtimeSkeleton.localRotations[boneIdx]
1431
- const localTrans = this.runtimeSkeleton.localTranslations[boneIdx]
1432
-
1433
- if (!frameB) {
1434
- const frameRotation = frameA.rotation
1435
- localRot.set(frameRotation)
1436
- const localTranslation = this.convertVMDTranslationToLocal(boneIdx, frameA.translation, frameRotation)
1437
- localTrans.set(localTranslation)
1438
- } else {
1439
- const frameDelta = frameB.frame - frameA.frame
1440
- const gradient = frameDelta > 0 ? (clampedFrame - frameA.frame) / frameDelta : 0
1441
- const interp = frameB.interpolation
1442
-
1443
- const rotT = interpolateControlPoints(interp.rotation, gradient)
1444
- const rotation = Quat.slerpInto(frameA.rotation, frameB.rotation, rotT, _animSlerp)
1575
+ const localTranslation = this.convertVMDTranslationToLocal(boneIdx, _animInterpT, _animSlerp)
1576
+ this.runtimeSkeleton.localRotations[boneIdx].set(_animSlerp)
1577
+ this.runtimeSkeleton.localTranslations[boneIdx].set(localTranslation)
1578
+ }
1445
1579
 
1446
- const txWeight = interpolateControlPoints(interp.translationX, gradient)
1447
- const tyWeight = interpolateControlPoints(interp.translationY, gradient)
1448
- const tzWeight = interpolateControlPoints(interp.translationZ, gradient)
1580
+ for (const [morphName, keyFrames] of clip.morphTracks.entries()) {
1581
+ const weight = this.sampleMorphTrack(morphName, keyFrames, frame, this.morphTrackIndices)
1582
+ if (Number.isNaN(weight)) continue
1449
1583
 
1450
- const interpolatedVMDTranslation = _animInterpT.setXYZ(
1451
- frameA.translation.x + (frameB.translation.x - frameA.translation.x) * txWeight,
1452
- frameA.translation.y + (frameB.translation.y - frameA.translation.y) * tyWeight,
1453
- frameA.translation.z + (frameB.translation.z - frameA.translation.z) * tzWeight
1454
- )
1584
+ const morphIdx = this.runtimeMorph.nameIndex[morphName]
1585
+ if (morphIdx === undefined) continue
1455
1586
 
1456
- const localTranslation = this.convertVMDTranslationToLocal(boneIdx, interpolatedVMDTranslation, rotation)
1587
+ this.runtimeMorph.weights[morphIdx] = weight
1588
+ this.morphsDirty = true // Mark as dirty when animation sets morph weights
1589
+ }
1590
+ }
1457
1591
 
1458
- localRot.set(rotation)
1459
- localTrans.set(localTranslation)
1592
+ /** Weighted N-clip pose blend. Rotations accumulate by hemisphere-aligned weighted
1593
+ * sum then normalize (nlerp generalized to N inputs); VMD-space translations lerp.
1594
+ * A bone missing from a clip contributes that clip's share of the REST pose, so a
1595
+ * weight sum below 1 fades toward rest — that is also how crossfading from "no
1596
+ * clip" works. IK on/off state follows the highest-weight entry. */
1597
+ private applyBlendedPose(entries: BlendEntry[]): void {
1598
+ // Resolve entries; find the total weight and the dominant entry (drives IK state).
1599
+ let total = 0
1600
+ let live = 0
1601
+ let domClip: AnimationClip | null = null
1602
+ let domFrame = 0
1603
+ let domWeight = 0
1604
+ for (let i = 0; i < entries.length; i++) {
1605
+ const e = entries[i]
1606
+ if (!(e.weight > 1e-6)) continue
1607
+ const clip = this.animationState.getAnimationClip(e.name)
1608
+ if (!clip) continue
1609
+ total += e.weight
1610
+ live++
1611
+ if (e.weight > domWeight) {
1612
+ domWeight = e.weight
1613
+ domClip = clip
1614
+ domFrame = e.time * FPS
1460
1615
  }
1461
1616
  }
1617
+ if (live === 0 || domClip === null) return
1618
+ // A sum above 1 normalizes; below 1 stays — the remainder is the rest pose's share.
1619
+ const norm = total > 1 ? 1 / total : 1
1620
+
1621
+ this.applyIkFromClip(domClip, domFrame)
1622
+
1623
+ if (this.blendRotAcc === null) {
1624
+ const boneCount = this.runtimeSkeleton.localRotations.length
1625
+ this.blendRotAcc = Array.from({ length: boneCount }, () => new Quat(0, 0, 0, 0))
1626
+ this.blendTransAcc = Array.from({ length: boneCount }, () => new Vec3(0, 0, 0))
1627
+ this.blendWeightAcc = new Float32Array(boneCount)
1628
+ this.blendBoneGen = new Int32Array(boneCount).fill(-1)
1629
+ this.blendMorphAcc = new Float32Array(this.runtimeMorph.weights.length)
1630
+ this.blendMorphGen = new Int32Array(this.runtimeMorph.weights.length).fill(-1)
1631
+ }
1632
+ const rotAcc = this.blendRotAcc
1633
+ const transAcc = this.blendTransAcc!
1634
+ const weightAcc = this.blendWeightAcc!
1635
+ const boneGen = this.blendBoneGen!
1636
+ const morphAcc = this.blendMorphAcc!
1637
+ const morphGen = this.blendMorphGen!
1638
+ const gen = ++this.blendGenCounter
1639
+
1640
+ for (let i = 0; i < entries.length; i++) {
1641
+ const e = entries[i]
1642
+ if (!(e.weight > 1e-6)) continue
1643
+ const clip = this.animationState.getAnimationClip(e.name)
1644
+ if (!clip) continue
1645
+ const w = e.weight * norm
1646
+ const frame = e.time * FPS
1647
+
1648
+ let cursors = this.blendBoneCursors.get(clip)
1649
+ if (!cursors) {
1650
+ cursors = new Map()
1651
+ this.blendBoneCursors.set(clip, cursors)
1652
+ }
1653
+ for (const [boneName, keyFrames] of clip.boneTracks.entries()) {
1654
+ if (!this.sampleBoneTrackInto(boneName, keyFrames, frame, cursors, _blendQ, _blendT)) continue
1655
+ const boneIdx = this.runtimeSkeleton.nameIndex[boneName]
1656
+ if (boneIdx === undefined) continue
1657
+
1658
+ const r = rotAcc[boneIdx]
1659
+ const t = transAcc[boneIdx]
1660
+ if (boneGen[boneIdx] !== gen) {
1661
+ boneGen[boneIdx] = gen
1662
+ r.setXYZW(_blendQ.x * w, _blendQ.y * w, _blendQ.z * w, _blendQ.w * w)
1663
+ t.setXYZ(_blendT.x * w, _blendT.y * w, _blendT.z * w)
1664
+ weightAcc[boneIdx] = w
1665
+ } else {
1666
+ // Hemisphere-align this sample against the accumulated sum before adding.
1667
+ const d = r.x * _blendQ.x + r.y * _blendQ.y + r.z * _blendQ.z + r.w * _blendQ.w
1668
+ const s = d < 0 ? -w : w
1669
+ r.x += _blendQ.x * s
1670
+ r.y += _blendQ.y * s
1671
+ r.z += _blendQ.z * s
1672
+ r.w += _blendQ.w * s
1673
+ t.x += _blendT.x * w
1674
+ t.y += _blendT.y * w
1675
+ t.z += _blendT.z * w
1676
+ weightAcc[boneIdx] += w
1677
+ }
1678
+ }
1462
1679
 
1463
- for (const [morphName, keyFrames] of clip.morphTracks.entries()) {
1464
- if (keyFrames.length === 0) continue
1465
-
1466
- const cachedIdx = this.morphTrackIndices.get(morphName) ?? -1
1467
- const clampedFrame = Math.max(keyFrames[0].frame, Math.min(keyFrames[keyFrames.length - 1].frame, frame))
1468
- const idx = this.findKeyframeIndex(clampedFrame, keyFrames, cachedIdx)
1680
+ let morphCursors = this.blendMorphCursors.get(clip)
1681
+ if (!morphCursors) {
1682
+ morphCursors = new Map()
1683
+ this.blendMorphCursors.set(clip, morphCursors)
1684
+ }
1685
+ for (const [morphName, keyFrames] of clip.morphTracks.entries()) {
1686
+ const weight = this.sampleMorphTrack(morphName, keyFrames, frame, morphCursors)
1687
+ if (Number.isNaN(weight)) continue
1688
+ const morphIdx = this.runtimeMorph.nameIndex[morphName]
1689
+ if (morphIdx === undefined) continue
1690
+ if (morphGen[morphIdx] !== gen) {
1691
+ morphGen[morphIdx] = gen
1692
+ morphAcc[morphIdx] = weight * w
1693
+ } else {
1694
+ morphAcc[morphIdx] += weight * w
1695
+ }
1696
+ }
1697
+ }
1469
1698
 
1470
- if (idx < 0) continue
1699
+ // Finalize touched bones: fold the rest-pose remainder in, normalize, convert.
1700
+ const boneCount = rotAcc.length
1701
+ for (let i = 0; i < boneCount; i++) {
1702
+ if (boneGen[i] !== gen) continue
1703
+ const r = rotAcc[i]
1704
+ const wSum = weightAcc[i]
1705
+ if (wSum < 1) {
1706
+ // Rest local rotation is identity (0,0,0,1); translation contribution is zero.
1707
+ const rest = 1 - wSum
1708
+ r.w += r.w < 0 ? -rest : rest
1709
+ }
1710
+ const len = Math.sqrt(r.x * r.x + r.y * r.y + r.z * r.z + r.w * r.w)
1711
+ if (len > 1e-8) {
1712
+ const inv = 1 / len
1713
+ _blendQ.setXYZW(r.x * inv, r.y * inv, r.z * inv, r.w * inv)
1714
+ } else {
1715
+ _blendQ.setIdentity()
1716
+ }
1717
+ const localTranslation = this.convertVMDTranslationToLocal(i, transAcc[i], _blendQ)
1718
+ this.runtimeSkeleton.localRotations[i].set(_blendQ)
1719
+ this.runtimeSkeleton.localTranslations[i].set(localTranslation)
1720
+ }
1471
1721
 
1472
- this.morphTrackIndices.set(morphName, idx)
1722
+ const morphCount = morphAcc.length
1723
+ for (let i = 0; i < morphCount; i++) {
1724
+ if (morphGen[i] !== gen) continue
1725
+ this.runtimeMorph.weights[i] = morphAcc[i]
1726
+ this.morphsDirty = true
1727
+ }
1728
+ }
1473
1729
 
1474
- const frameA = keyFrames[idx]
1475
- const frameB = keyFrames[idx + 1]
1730
+ /** One crossfade step: advance the outgoing clock (the target's clock lives in
1731
+ * animationState, already ticked by update), shape the weight with easeInOut,
1732
+ * and hand both to the blend sampler. Holds in place while paused. */
1733
+ private applyCrossfade(deltaTime: number): void {
1734
+ const fade = this.crossfade
1735
+ if (fade === null) return
1736
+ const playing = this.animationState.getProgress().playing
1737
+
1738
+ if (playing) {
1739
+ fade.elapsed += deltaTime
1740
+ if (fade.fromName !== null) {
1741
+ const fromClip = this.animationState.getAnimationClip(fade.fromName)
1742
+ if (fromClip && fromClip.frameCount > 0 && Number.isFinite(fromClip.frameCount)) {
1743
+ fade.fromFrame += deltaTime * FPS
1744
+ if (fade.fromLoop) {
1745
+ while (fade.fromFrame >= fromClip.frameCount) fade.fromFrame -= fromClip.frameCount
1746
+ } else if (fade.fromFrame > fromClip.frameCount) {
1747
+ fade.fromFrame = fromClip.frameCount
1748
+ }
1749
+ } else {
1750
+ fade.fromName = null // outgoing clip was removed mid-fade: fade from rest
1751
+ }
1752
+ }
1753
+ }
1476
1754
 
1477
- const morphIdx = this.runtimeMorph.nameIndex[morphName]
1478
- if (morphIdx === undefined) continue
1755
+ const t = fade.duration > 0 ? Math.min(1, fade.elapsed / fade.duration) : 1
1756
+ const w = easeInOut(t)
1757
+ const toName = this.animationState.getCurrentAnimation()
1479
1758
 
1480
- const weight = frameB
1481
- ? frameA.weight +
1482
- (frameB.weight - frameA.weight) *
1483
- (keyFrames[idx + 1].frame > keyFrames[idx].frame
1484
- ? (clampedFrame - keyFrames[idx].frame) / (keyFrames[idx + 1].frame - keyFrames[idx].frame)
1485
- : 0)
1486
- : frameA.weight
1759
+ _fadeEntries[0].name = toName ?? ""
1760
+ _fadeEntries[0].time = this.animationState.getCurrentFrame() / FPS
1761
+ _fadeEntries[0].weight = toName !== null ? w : 0
1762
+ _fadeEntries[1].name = fade.fromName ?? ""
1763
+ _fadeEntries[1].time = fade.fromFrame / FPS
1764
+ _fadeEntries[1].weight = fade.fromName !== null ? 1 - w : 0
1765
+ this.applyBlendedPose(_fadeEntries)
1487
1766
 
1488
- this.runtimeMorph.weights[morphIdx] = weight
1489
- this.morphsDirty = true // Mark as dirty when animation sets morph weights
1490
- }
1767
+ if (t >= 1) this.crossfade = null
1491
1768
  }
1492
1769
 
1493
1770
  // Returns true when morphs changed (vertex buffer may need upload). `ikEnabled`
@@ -1503,10 +1780,22 @@ export class Model {
1503
1780
  const tweensChangedMorphs = this.updateTweens()
1504
1781
 
1505
1782
  this.animationState.update(deltaTime)
1506
- const clip = this.animationState.getCurrentClip()
1507
- const frame = this.animationState.getCurrentFrame()
1508
- if (clip !== null && !this.clipApplySuspended) {
1509
- this.applyPoseFromClip(clip, frame)
1783
+ if (!this.clipApplySuspended) {
1784
+ if (this.blendEntries !== null && this.blendEntries.length > 0) {
1785
+ this.applyBlendedPose(this.blendEntries)
1786
+ } else if (this.crossfade !== null) {
1787
+ this.applyCrossfade(deltaTime)
1788
+ } else {
1789
+ const clip = this.animationState.getCurrentClip()
1790
+ if (clip !== null) this.applyPoseFromClip(clip, this.animationState.getCurrentFrame())
1791
+ }
1792
+ }
1793
+
1794
+ // Constant per-bone offsets compose after every pose source, before the
1795
+ // world/IK passes (ankle offsets survive IK — the solver moves thigh/knee).
1796
+ for (const [idx, offset] of this.boneRotationOffsets) {
1797
+ const r = this.runtimeSkeleton.localRotations[idx]
1798
+ Quat.multiplyInto(r, offset, r)
1510
1799
  }
1511
1800
 
1512
1801
  // Apply morphs if tweens changed morphs or animation changed morphs