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/README.md +18 -1
- package/dist/animation.d.ts +14 -0
- package/dist/animation.d.ts.map +1 -1
- package/dist/animation.js +15 -0
- package/dist/engine.d.ts +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +37 -8
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/locomotion.d.ts +90 -0
- package/dist/locomotion.d.ts.map +1 -0
- package/dist/locomotion.js +179 -0
- package/dist/model.d.ts +51 -1
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +343 -53
- package/package.json +1 -1
- package/src/animation.ts +26 -0
- package/src/engine.ts +46 -8
- package/src/index.ts +9 -0
- package/src/locomotion.ts +254 -0
- package/src/model.ts +354 -65
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.
|
|
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
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
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
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
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
|
-
|
|
1451
|
-
|
|
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
|
-
|
|
1587
|
+
this.runtimeMorph.weights[morphIdx] = weight
|
|
1588
|
+
this.morphsDirty = true // Mark as dirty when animation sets morph weights
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1457
1591
|
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
-
|
|
1464
|
-
if (
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1475
|
-
|
|
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
|
-
|
|
1478
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
this.
|
|
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
|