@aura3d/animation 1.1.0 → 1.3.1
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/dist/AnimationClipEvents.d.ts +55 -0
- package/dist/AnimationClipEvents.d.ts.map +1 -1
- package/dist/AnimationClipEvents.js +136 -0
- package/dist/AnimationClipEvents.js.map +1 -1
- package/dist/AnimationClipRegistry.d.ts +9 -3
- package/dist/AnimationClipRegistry.d.ts.map +1 -1
- package/dist/AnimationClipRegistry.js +52 -5
- package/dist/AnimationClipRegistry.js.map +1 -1
- package/dist/AnimationController.d.ts +6 -6
- package/dist/AnimationController.d.ts.map +1 -1
- package/dist/AnimationController.js +3 -3
- package/dist/AnimationController.js.map +1 -1
- package/dist/AnimationMixer.d.ts +10 -0
- package/dist/AnimationMixer.d.ts.map +1 -1
- package/dist/AnimationMixer.js +35 -0
- package/dist/AnimationMixer.js.map +1 -1
- package/dist/AnimationStateGraph.d.ts +14 -5
- package/dist/AnimationStateGraph.d.ts.map +1 -1
- package/dist/AnimationStateGraph.js +25 -2
- package/dist/AnimationStateGraph.js.map +1 -1
- package/dist/AnimationStateMachine.d.ts +26 -0
- package/dist/AnimationStateMachine.d.ts.map +1 -1
- package/dist/AnimationStateMachine.js +32 -0
- package/dist/AnimationStateMachine.js.map +1 -1
- package/dist/Bone.d.ts +4 -0
- package/dist/Bone.d.ts.map +1 -1
- package/dist/Bone.js +3 -0
- package/dist/Bone.js.map +1 -1
- package/dist/FighterAnimationAdapter.d.ts +49 -0
- package/dist/FighterAnimationAdapter.d.ts.map +1 -0
- package/dist/FighterAnimationAdapter.js +71 -0
- package/dist/FighterAnimationAdapter.js.map +1 -0
- package/dist/FootIk.d.ts +77 -0
- package/dist/FootIk.d.ts.map +1 -0
- package/dist/FootIk.js +153 -0
- package/dist/FootIk.js.map +1 -0
- package/dist/HumanoidBoneInference.d.ts +61 -0
- package/dist/HumanoidBoneInference.d.ts.map +1 -0
- package/dist/HumanoidBoneInference.js +239 -0
- package/dist/HumanoidBoneInference.js.map +1 -0
- package/dist/HumanoidRetargeting.d.ts +48 -4
- package/dist/HumanoidRetargeting.d.ts.map +1 -1
- package/dist/HumanoidRetargeting.js +149 -14
- package/dist/HumanoidRetargeting.js.map +1 -1
- package/dist/IK.d.ts +5 -0
- package/dist/IK.d.ts.map +1 -1
- package/dist/IK.js +9 -0
- package/dist/IK.js.map +1 -1
- package/dist/Inertialization.d.ts +65 -0
- package/dist/Inertialization.d.ts.map +1 -0
- package/dist/Inertialization.js +211 -0
- package/dist/Inertialization.js.map +1 -0
- package/dist/LocomotionController.d.ts +5 -0
- package/dist/LocomotionController.d.ts.map +1 -1
- package/dist/LocomotionController.js +3 -0
- package/dist/LocomotionController.js.map +1 -1
- package/dist/LocomotionKit.d.ts +52 -0
- package/dist/LocomotionKit.d.ts.map +1 -0
- package/dist/LocomotionKit.js +38 -0
- package/dist/LocomotionKit.js.map +1 -0
- package/dist/MotionQuality.d.ts +3 -3
- package/dist/MotionQuality.d.ts.map +1 -1
- package/dist/MotionQuality.js +2 -2
- package/dist/MotionQuality.js.map +1 -1
- package/dist/RigQuality.d.ts +31 -0
- package/dist/RigQuality.d.ts.map +1 -0
- package/dist/RigQuality.js +70 -0
- package/dist/RigQuality.js.map +1 -0
- package/dist/SecondaryAnimationFixtures.d.ts.map +1 -1
- package/dist/SecondaryAnimationFixtures.js +1 -3
- package/dist/SecondaryAnimationFixtures.js.map +1 -1
- package/dist/Skeleton.d.ts +16 -1
- package/dist/Skeleton.d.ts.map +1 -1
- package/dist/Skeleton.js +42 -0
- package/dist/Skeleton.js.map +1 -1
- package/dist/SpringBones.d.ts +60 -0
- package/dist/SpringBones.d.ts.map +1 -0
- package/dist/SpringBones.js +173 -0
- package/dist/SpringBones.js.map +1 -0
- package/dist/browser-index.d.ts +12 -3
- package/dist/browser-index.d.ts.map +1 -1
- package/dist/browser-index.js +11 -2
- package/dist/browser-index.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/library/performanceStateGraph.d.ts +42 -0
- package/dist/library/performanceStateGraph.d.ts.map +1 -0
- package/dist/library/performanceStateGraph.js +101 -0
- package/dist/library/performanceStateGraph.js.map +1 -0
- package/dist/library/standardHumanoidClips.d.ts +27 -0
- package/dist/library/standardHumanoidClips.d.ts.map +1 -0
- package/dist/library/standardHumanoidClips.js +357 -0
- package/dist/library/standardHumanoidClips.js.map +1 -0
- package/package.json +3 -3
- package/src/AnimationClipEvents.ts +181 -0
- package/src/AnimationClipRegistry.ts +65 -9
- package/src/AnimationController.ts +11 -11
- package/src/AnimationMixer.ts +36 -0
- package/src/AnimationStateGraph.ts +34 -5
- package/src/AnimationStateMachine.ts +48 -0
- package/src/Bone.ts +5 -0
- package/src/FighterAnimationAdapter.ts +124 -0
- package/src/FootIk.ts +251 -0
- package/src/HumanoidBoneInference.ts +323 -0
- package/src/HumanoidRetargeting.ts +180 -19
- package/src/IK.ts +10 -0
- package/src/Inertialization.ts +269 -0
- package/src/LocomotionController.ts +6 -0
- package/src/LocomotionKit.ts +89 -0
- package/src/MotionQuality.ts +5 -5
- package/src/RigQuality.ts +101 -0
- package/src/SecondaryAnimationFixtures.ts +1 -3
- package/src/Skeleton.ts +45 -1
- package/src/SpringBones.ts +243 -0
- package/src/browser-index.ts +13 -4
- package/src/index.ts +12 -4
- package/src/library/performanceStateGraph.ts +117 -0
- package/src/library/standardHumanoidClips.ts +442 -0
- package/src/Retargeting.ts +0 -29
|
@@ -108,6 +108,14 @@ export interface HumanoidBoneRetargetBinding {
|
|
|
108
108
|
readonly source: HumanoidBoneBinding;
|
|
109
109
|
readonly target: HumanoidBoneBinding;
|
|
110
110
|
readonly scale: number;
|
|
111
|
+
/**
|
|
112
|
+
* Source rig rest-pose local rotation for this bone (identity when unknown). Captured at
|
|
113
|
+
* map-build time from `source.restPose[bone].rotation` or the binding's own `rotation` so the
|
|
114
|
+
* pose retargeter can reconcile differing bind orientations without re-reading the rigs.
|
|
115
|
+
*/
|
|
116
|
+
readonly sourceRest?: AnimationQuaternion;
|
|
117
|
+
/** Target rig rest-pose local rotation for this bone (identity when unknown). */
|
|
118
|
+
readonly targetRest?: AnimationQuaternion;
|
|
111
119
|
}
|
|
112
120
|
|
|
113
121
|
export interface HumanoidRetargetingMap {
|
|
@@ -118,6 +126,10 @@ export interface HumanoidRetargetingMap {
|
|
|
118
126
|
readonly requiredCoverage: number;
|
|
119
127
|
readonly bindings: Partial<Record<HumanoidBoneName, HumanoidBoneRetargetBinding>>;
|
|
120
128
|
readonly diagnostics: readonly HumanoidRetargetingDiagnostic[];
|
|
129
|
+
/** Source rig facing axis (defaults to `"z"`) used for hip/root facing reconciliation. */
|
|
130
|
+
readonly sourceFacingAxis?: HumanoidAxis;
|
|
131
|
+
/** Target rig facing axis (defaults to `"z"`) used for hip/root facing reconciliation. */
|
|
132
|
+
readonly targetFacingAxis?: HumanoidAxis;
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
export interface RetargetHumanoidPoseOptions {
|
|
@@ -126,15 +138,15 @@ export interface RetargetHumanoidPoseOptions {
|
|
|
126
138
|
readonly scaleRootMotion?: boolean;
|
|
127
139
|
}
|
|
128
140
|
|
|
129
|
-
export interface
|
|
141
|
+
export interface AnimationHumanoidRetargetingOptions extends HumanoidRetargetingOptions {
|
|
130
142
|
readonly requiredClips?: readonly string[];
|
|
131
143
|
readonly availableClips?: readonly string[];
|
|
132
144
|
readonly mouthBlendshapeNames?: readonly string[];
|
|
133
145
|
readonly retargetMapProvided?: boolean;
|
|
134
146
|
}
|
|
135
147
|
|
|
136
|
-
export interface
|
|
137
|
-
readonly kind: "
|
|
148
|
+
export interface AnimationHumanoidRetargetingDiagnostics extends HumanoidRigDiagnostics {
|
|
149
|
+
readonly kind: "animation-humanoid-retargeting-diagnostics";
|
|
138
150
|
readonly mouthReady: boolean;
|
|
139
151
|
readonly clipReady: boolean;
|
|
140
152
|
readonly retargetMapProvided: boolean;
|
|
@@ -327,7 +339,9 @@ export function createHumanoidRetargetingMap(
|
|
|
327
339
|
bone,
|
|
328
340
|
source: sourceBinding,
|
|
329
341
|
target: targetBinding,
|
|
330
|
-
scale: estimateBoneScale(sourceBinding, targetBinding, source.scale, target.scale)
|
|
342
|
+
scale: estimateBoneScale(sourceBinding, targetBinding, source.scale, target.scale),
|
|
343
|
+
sourceRest: restRotationFor(source, bone, sourceBinding),
|
|
344
|
+
targetRest: restRotationFor(target, bone, targetBinding)
|
|
331
345
|
};
|
|
332
346
|
}
|
|
333
347
|
|
|
@@ -351,10 +365,44 @@ export function createHumanoidRetargetingMap(
|
|
|
351
365
|
coverage,
|
|
352
366
|
requiredCoverage,
|
|
353
367
|
bindings,
|
|
354
|
-
diagnostics
|
|
368
|
+
diagnostics,
|
|
369
|
+
sourceFacingAxis: source.facingAxis ?? "z",
|
|
370
|
+
targetFacingAxis: target.facingAxis ?? "z"
|
|
355
371
|
};
|
|
356
372
|
}
|
|
357
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Retarget a source-rig {@link AnimationPose} onto the target rig described by `map`.
|
|
376
|
+
*
|
|
377
|
+
* This is NOT a 1:1 rotation copy. For each bone we reconcile differing rest/bind orientations and
|
|
378
|
+
* a differing facing axis, and scale translations by the per-bone length ratio:
|
|
379
|
+
*
|
|
380
|
+
* 1. Rest-pose reconciliation (per bone, local space). Let `Rs0` be the source rest local
|
|
381
|
+
* rotation and `Rt0` the target rest local rotation (both from the rigs' `restPose`/binding
|
|
382
|
+
* `rotation`, identity when unknown). An animation expresses a source local rotation `Ra`.
|
|
383
|
+
* The motion *away from rest* in the source's local frame is the delta `D = Rs0⁻¹ · Ra`. We
|
|
384
|
+
* re-anchor that delta onto the target rest: `Rt = Rt0 · D = Rt0 · Rs0⁻¹ · Ra`.
|
|
385
|
+
* When both rests are identity (or equal) this collapses to a straight copy, and an identity
|
|
386
|
+
* map (source rig === target rig) is therefore a no-op.
|
|
387
|
+
*
|
|
388
|
+
* 2. Facing-axis / coordinate-frame conversion. If the source faces a different cardinal axis
|
|
389
|
+
* than the target, the hips (root) rotation is pre-multiplied by the frame-alignment rotation
|
|
390
|
+
* `F` that carries the source facing axis onto the target facing axis: `Rt_hips = F · Rt_hips`.
|
|
391
|
+
* This only touches the root so child bones inherit the reframe through the hierarchy.
|
|
392
|
+
*
|
|
393
|
+
* 3. Position / hip-height scaling (unchanged from before): translations are scaled by the
|
|
394
|
+
* bone's length ratio (`binding.scale`), and optional root motion by the average bone scale.
|
|
395
|
+
*
|
|
396
|
+
* The signature and return type are unchanged.
|
|
397
|
+
*
|
|
398
|
+
* Honesty note: step 1 is the well-known "rest-delta" reconciliation. It is exact when the source
|
|
399
|
+
* and target rest orientations are the bones' true local bind rotations and the bones are mapped
|
|
400
|
+
* one-to-one (the common case for humanoid GLBs). It does NOT model differing bone *roll* beyond
|
|
401
|
+
* what the rest rotations capture, nor parent-chain reparametrization when intermediate bones
|
|
402
|
+
* (e.g. chest/upperChest) are present on one rig and absent on the other — those remain
|
|
403
|
+
* approximate. Step 2 corrects whole-body facing only via the root; per-limb axis remaps that
|
|
404
|
+
* aren't expressible as a single root rotation are not handled.
|
|
405
|
+
*/
|
|
358
406
|
export function retargetHumanoidPose(
|
|
359
407
|
sourcePose: AnimationPose,
|
|
360
408
|
map: HumanoidRetargetingMap,
|
|
@@ -368,6 +416,8 @@ export function retargetHumanoidPose(
|
|
|
368
416
|
}
|
|
369
417
|
}
|
|
370
418
|
|
|
419
|
+
const facing = facingAlignmentQuat(map.sourceFacingAxis ?? "z", map.targetFacingAxis ?? "z");
|
|
420
|
+
|
|
371
421
|
for (const bone of HUMANOID_BONES) {
|
|
372
422
|
const binding = map.bindings[bone];
|
|
373
423
|
if (!binding) continue;
|
|
@@ -375,7 +425,7 @@ export function retargetHumanoidPose(
|
|
|
375
425
|
const sourceTransform = sourcePose.bones[binding.source.name] ?? sourcePose.bones[bone];
|
|
376
426
|
if (!sourceTransform) continue;
|
|
377
427
|
|
|
378
|
-
const retargeted =
|
|
428
|
+
const retargeted = retargetBoneTransform(sourceTransform, binding, bone === "hips" ? facing : undefined);
|
|
379
429
|
bones[binding.target.name] = retargeted;
|
|
380
430
|
|
|
381
431
|
if (options.includeSemanticBoneNames) {
|
|
@@ -411,10 +461,10 @@ export function humanoidRetargetingDiagnostics(
|
|
|
411
461
|
return createHumanoidRetargetingMap(source, target, options).diagnostics;
|
|
412
462
|
}
|
|
413
463
|
|
|
414
|
-
export function
|
|
464
|
+
export function analyzeAnimationHumanoidRetargeting(
|
|
415
465
|
rig: HumanoidRigDefinition,
|
|
416
|
-
options:
|
|
417
|
-
):
|
|
466
|
+
options: AnimationHumanoidRetargetingOptions = {}
|
|
467
|
+
): AnimationHumanoidRetargetingDiagnostics {
|
|
418
468
|
const base = analyzeHumanoidRig(rig, { minRequiredCoverage: 0.9, requireRestPose: true, ...options });
|
|
419
469
|
const diagnostics: HumanoidRetargetingDiagnostic[] = [...base.diagnostics];
|
|
420
470
|
const requiredClips = options.requiredClips ?? ["Idle", "Talk", "Gesture", "Walk"];
|
|
@@ -427,30 +477,30 @@ export function analyzeCartoonHumanoidRetargeting(
|
|
|
427
477
|
if (!mouthReady) {
|
|
428
478
|
diagnostics.push({
|
|
429
479
|
severity: "error",
|
|
430
|
-
code: "
|
|
431
|
-
message: "
|
|
480
|
+
code: "ANIMATION_MOUTH_METADATA_MISSING",
|
|
481
|
+
message: "Animation retargeting requires mouth blendshape or mouth-card metadata.",
|
|
432
482
|
rigId: rig.id
|
|
433
483
|
});
|
|
434
484
|
}
|
|
435
485
|
for (const clip of missingClips) {
|
|
436
486
|
diagnostics.push({
|
|
437
487
|
severity: "error",
|
|
438
|
-
code: "
|
|
439
|
-
message: `
|
|
488
|
+
code: "ANIMATION_REQUIRED_CLIP_MISSING",
|
|
489
|
+
message: `Animation retargeting requires clip "${clip}".`,
|
|
440
490
|
rigId: rig.id
|
|
441
491
|
});
|
|
442
492
|
}
|
|
443
493
|
if (!retargetMapProvided) {
|
|
444
494
|
diagnostics.push({
|
|
445
495
|
severity: "error",
|
|
446
|
-
code: "
|
|
447
|
-
message: "
|
|
496
|
+
code: "ANIMATION_RETARGET_MAP_MISSING",
|
|
497
|
+
message: "Animation retargeting requires external bone-map/retarget metadata.",
|
|
448
498
|
rigId: rig.id
|
|
449
499
|
});
|
|
450
500
|
}
|
|
451
501
|
|
|
452
502
|
return {
|
|
453
|
-
kind: "
|
|
503
|
+
kind: "animation-humanoid-retargeting-diagnostics",
|
|
454
504
|
...base,
|
|
455
505
|
ok: !diagnostics.some((diagnostic) => diagnostic.severity === "error"),
|
|
456
506
|
diagnostics,
|
|
@@ -510,14 +560,125 @@ function averageBindingScale(map: HumanoidRetargetingMap): number {
|
|
|
510
560
|
return scales.reduce((sum, scale) => sum + scale, 0) / scales.length;
|
|
511
561
|
}
|
|
512
562
|
|
|
513
|
-
|
|
563
|
+
const IDENTITY_QUAT: AnimationQuaternion = { x: 0, y: 0, z: 0, w: 1 };
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Extract the rest local rotation for a bone, preferring the rig's explicit `restPose` entry and
|
|
567
|
+
* falling back to the binding's own `rotation`. Returns identity when neither is present.
|
|
568
|
+
*/
|
|
569
|
+
function restRotationFor(
|
|
570
|
+
rig: HumanoidRigDefinition,
|
|
571
|
+
bone: HumanoidBoneName,
|
|
572
|
+
binding: HumanoidBoneBinding
|
|
573
|
+
): AnimationQuaternion {
|
|
574
|
+
const fromRestPose = rig.restPose?.[bone]?.rotation;
|
|
575
|
+
if (fromRestPose) return normalizeQuatObj(fromRestPose);
|
|
576
|
+
if (binding.rotation) return normalizeQuatObj(binding.rotation);
|
|
577
|
+
return IDENTITY_QUAT;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Apply rest-pose reconciliation + (root-only) facing alignment + length scaling to one bone.
|
|
582
|
+
* See {@link retargetHumanoidPose} for the math derivation.
|
|
583
|
+
*/
|
|
584
|
+
function retargetBoneTransform(
|
|
585
|
+
transform: AnimationPoseTransform,
|
|
586
|
+
binding: HumanoidBoneRetargetBinding,
|
|
587
|
+
facing?: AnimationQuaternion
|
|
588
|
+
): AnimationPoseTransform {
|
|
589
|
+
const sourceRest = binding.sourceRest ?? IDENTITY_QUAT;
|
|
590
|
+
const targetRest = binding.targetRest ?? IDENTITY_QUAT;
|
|
591
|
+
|
|
592
|
+
let rotation = transform.rotation;
|
|
593
|
+
if (rotation) {
|
|
594
|
+
// Rt = Rt0 · Rs0⁻¹ · Ra
|
|
595
|
+
const delta = multiplyQuat(invertQuat(sourceRest), normalizeQuatObj(rotation));
|
|
596
|
+
rotation = multiplyQuat(targetRest, delta);
|
|
597
|
+
if (facing) {
|
|
598
|
+
// Whole-body facing correction, applied on the root before its local rotation.
|
|
599
|
+
rotation = multiplyQuat(facing, rotation);
|
|
600
|
+
}
|
|
601
|
+
rotation = normalizeQuatObj(rotation);
|
|
602
|
+
} else if (facing) {
|
|
603
|
+
// No animated rotation but a facing delta still needs to reframe the root.
|
|
604
|
+
rotation = normalizeQuatObj(multiplyQuat(facing, targetRest));
|
|
605
|
+
}
|
|
606
|
+
|
|
514
607
|
return {
|
|
515
|
-
position: transform.position ? scaleVector(transform.position, scale) : undefined,
|
|
516
|
-
rotation
|
|
608
|
+
position: transform.position ? scaleVector(transform.position, binding.scale) : undefined,
|
|
609
|
+
rotation,
|
|
517
610
|
scale: transform.scale
|
|
518
611
|
};
|
|
519
612
|
}
|
|
520
613
|
|
|
614
|
+
/**
|
|
615
|
+
* Rotation that carries `from` cardinal axis onto `to` cardinal axis (shortest arc). Used to
|
|
616
|
+
* reconcile rigs that face different world axes (e.g. +Z forward vs -Z forward, or +X vs +Z).
|
|
617
|
+
*/
|
|
618
|
+
function facingAlignmentQuat(from: HumanoidAxis, to: HumanoidAxis): AnimationQuaternion | undefined {
|
|
619
|
+
if (from === to) return undefined;
|
|
620
|
+
const a = axisVector(from);
|
|
621
|
+
const b = axisVector(to);
|
|
622
|
+
return shortestArcQuat(a, b);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function axisVector(axis: HumanoidAxis): AnimationVector3 {
|
|
626
|
+
switch (axis) {
|
|
627
|
+
case "x": return { x: 1, y: 0, z: 0 };
|
|
628
|
+
case "-x": return { x: -1, y: 0, z: 0 };
|
|
629
|
+
case "y": return { x: 0, y: 1, z: 0 };
|
|
630
|
+
case "-y": return { x: 0, y: -1, z: 0 };
|
|
631
|
+
case "z": return { x: 0, y: 0, z: 1 };
|
|
632
|
+
case "-z": return { x: 0, y: 0, z: -1 };
|
|
633
|
+
default: return { x: 0, y: 0, z: 1 };
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Quaternion of the shortest rotation from unit vector `a` to unit vector `b`. */
|
|
638
|
+
function shortestArcQuat(a: AnimationVector3, b: AnimationVector3): AnimationQuaternion {
|
|
639
|
+
const dot = a.x * b.x + a.y * b.y + a.z * b.z;
|
|
640
|
+
if (dot >= 1 - 1e-8) return IDENTITY_QUAT;
|
|
641
|
+
if (dot <= -1 + 1e-8) {
|
|
642
|
+
// Opposite vectors: rotate 180° about any axis orthogonal to a.
|
|
643
|
+
let ortho: AnimationVector3 = Math.abs(a.x) < 0.9 ? { x: 1, y: 0, z: 0 } : { x: 0, y: 1, z: 0 };
|
|
644
|
+
ortho = crossVec(a, ortho);
|
|
645
|
+
const len = Math.hypot(ortho.x, ortho.y, ortho.z) || 1;
|
|
646
|
+
return normalizeQuatObj({ x: ortho.x / len, y: ortho.y / len, z: ortho.z / len, w: 0 });
|
|
647
|
+
}
|
|
648
|
+
const c = crossVec(a, b);
|
|
649
|
+
const w = 1 + dot;
|
|
650
|
+
return normalizeQuatObj({ x: c.x, y: c.y, z: c.z, w });
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function crossVec(a: AnimationVector3, b: AnimationVector3): AnimationVector3 {
|
|
654
|
+
return {
|
|
655
|
+
x: a.y * b.z - a.z * b.y,
|
|
656
|
+
y: a.z * b.x - a.x * b.z,
|
|
657
|
+
z: a.x * b.y - a.y * b.x
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function multiplyQuat(a: AnimationQuaternion, b: AnimationQuaternion): AnimationQuaternion {
|
|
662
|
+
return {
|
|
663
|
+
x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
|
|
664
|
+
y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
|
|
665
|
+
z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
|
|
666
|
+
w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function invertQuat(q: AnimationQuaternion): AnimationQuaternion {
|
|
671
|
+
// Unit quaternion inverse is its conjugate; normalize defensively first.
|
|
672
|
+
const n = normalizeQuatObj(q);
|
|
673
|
+
return { x: -n.x, y: -n.y, z: -n.z, w: n.w };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function normalizeQuatObj(q: AnimationQuaternion): AnimationQuaternion {
|
|
677
|
+
const len = Math.hypot(q.x, q.y, q.z, q.w);
|
|
678
|
+
if (len <= 1e-9) return IDENTITY_QUAT;
|
|
679
|
+
return { x: q.x / len, y: q.y / len, z: q.z / len, w: q.w / len };
|
|
680
|
+
}
|
|
681
|
+
|
|
521
682
|
function cloneTransform(transform: AnimationPoseTransform): AnimationPoseTransform {
|
|
522
683
|
return {
|
|
523
684
|
position: transform.position ? { ...transform.position } : undefined,
|
package/src/IK.ts
CHANGED
|
@@ -62,6 +62,16 @@ export function solveTwoBoneIk(input: TwoBoneIkInput): TwoBoneIkResult {
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Convenience knee/elbow pole hint: a point offset from the mid-joint along a forward direction,
|
|
67
|
+
* giving {@link solveTwoBoneIk} a stable bend plane (knees point forward, elbows back). Pure.
|
|
68
|
+
*/
|
|
69
|
+
export function kneePoleHint(root: Vec3, mid: Vec3, forward: Vec3, distance = 1): Vec3 {
|
|
70
|
+
const dir = normalize(forward);
|
|
71
|
+
const center: Vec3 = [(root[0] + mid[0]) / 2, (root[1] + mid[1]) / 2, (root[2] + mid[2]) / 2];
|
|
72
|
+
return [center[0] + dir[0] * distance, center[1] + dir[1] * distance, center[2] + dir[2] * distance];
|
|
73
|
+
}
|
|
74
|
+
|
|
65
75
|
function projectedPoleDirection(root: Vec3, axis: Vec3, pole: Vec3): Vec3 {
|
|
66
76
|
const poleDelta = subtract(pole, root);
|
|
67
77
|
const projected = subtract(poleDelta, scale(axis, dot(poleDelta, axis)));
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// Inertialization: a critically-damped decay of the *pose offset* captured at the moment a
|
|
2
|
+
// transition begins, added on top of the destination pose. Unlike a linear crossfade (which
|
|
3
|
+
// interpolates two clips by a straight ramp and visibly "snaps" its slope at both ends), an
|
|
4
|
+
// inertialized transition starts the destination pose already offset by the source pose's
|
|
5
|
+
// difference (and optionally its velocity), then lets that offset decay smoothly to zero. The
|
|
6
|
+
// result is C1-continuous at the transition boundary: no velocity discontinuity, so motion
|
|
7
|
+
// "carries through" the swap instead of dissolving.
|
|
8
|
+
//
|
|
9
|
+
// This module is pure and deterministic: no `Date.now()`, no `Math.random()`, no engine/render
|
|
10
|
+
// imports. Given the same inputs and the same `t` it always returns the same result, so it is
|
|
11
|
+
// safe inside deterministic replay (combat sims) and reproducible tests.
|
|
12
|
+
//
|
|
13
|
+
// Two layers are provided:
|
|
14
|
+
// 1. Scalar / vector / quaternion offset primitives (`inertializedScalar`, ...).
|
|
15
|
+
// 2. A pose-space `Inertializer` that records a transition between two `AnimationValue` poses
|
|
16
|
+
// and samples the inertialized pose at a given time.
|
|
17
|
+
// Plus a weight-domain helper (`inertializedTransitionWeight`) for consumers that blend two
|
|
18
|
+
// clips by weight (e.g. the fighter adapter / `applyClips` call sites) rather than by pose.
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
cloneAnimationValue,
|
|
22
|
+
normalizeQuat,
|
|
23
|
+
type AnimationValue,
|
|
24
|
+
type Quat,
|
|
25
|
+
type Vec3
|
|
26
|
+
} from "./Keyframe.js";
|
|
27
|
+
|
|
28
|
+
const LN2 = Math.log(2);
|
|
29
|
+
|
|
30
|
+
/** Default transition half-life in seconds. ~0.12s reads as a snappy-but-smooth swap. */
|
|
31
|
+
export const DEFAULT_INERTIALIZATION_HALF_LIFE = 0.12;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decay rate `k` (1/seconds) for a given half-life. The critically-damped envelope used here is
|
|
35
|
+
* `(1 + k·t)·e^(−k·t)`; because of the `(1 + k·t)` term the offset is not exactly halved at
|
|
36
|
+
* `t = halfLife` (it crosses 0.5 a little later) — `halfLife` parameterizes the e-folding decay
|
|
37
|
+
* rate, not a literal 50%-crossing time. A non-positive half-life means an instant snap (rate ∞).
|
|
38
|
+
*/
|
|
39
|
+
export function inertializationDecayRate(halfLife: number): number {
|
|
40
|
+
if (!Number.isFinite(halfLife) || halfLife <= 0) return Number.POSITIVE_INFINITY;
|
|
41
|
+
return LN2 / halfLife;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Critically-damped decay of a scalar offset `offset0` with initial velocity `velocity0`,
|
|
46
|
+
* sampled at time `t` (seconds) since the transition. Decays smoothly to zero; preserves the
|
|
47
|
+
* initial velocity at `t = 0` (so momentum carries through). Deterministic.
|
|
48
|
+
*/
|
|
49
|
+
export function inertializedScalar(offset0: number, velocity0: number, t: number, halfLife: number): number {
|
|
50
|
+
if (!(t > 0)) return offset0;
|
|
51
|
+
const k = inertializationDecayRate(halfLife);
|
|
52
|
+
if (!Number.isFinite(k)) return 0; // instant snap
|
|
53
|
+
// x(t) = (x0 + (v0 + k·x0)·t)·e^(−k·t); x(0)=x0, x'(0)=v0, x(∞)=0.
|
|
54
|
+
return (offset0 + (velocity0 + k * offset0) * t) * Math.exp(-k * t);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Source-clip weight for an inertialized transition, decaying from 1 → 0 with zero initial slope
|
|
59
|
+
* (critically damped, monotonic). The destination weight is `1 − thisWeight`. A drop-in,
|
|
60
|
+
* smoother replacement for a linear `1 − t/duration` ramp. `elapsed`/`halfLife` in seconds.
|
|
61
|
+
*/
|
|
62
|
+
export function inertializedTransitionWeight(elapsed: number, halfLife: number): number {
|
|
63
|
+
if (!(elapsed > 0)) return 1;
|
|
64
|
+
const k = inertializationDecayRate(halfLife);
|
|
65
|
+
if (!Number.isFinite(k)) return 0; // instant snap
|
|
66
|
+
const w = (1 + k * elapsed) * Math.exp(-k * elapsed);
|
|
67
|
+
return w < 0 ? 0 : w > 1 ? 1 : w;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Vector3 offset decay (per-component critically-damped). `velocity0` defaults to zero. */
|
|
71
|
+
export function inertializedVec3(offset0: Vec3, t: number, halfLife: number, velocity0: Vec3 = [0, 0, 0]): Vec3 {
|
|
72
|
+
return [
|
|
73
|
+
inertializedScalar(offset0[0], velocity0[0], t, halfLife),
|
|
74
|
+
inertializedScalar(offset0[1], velocity0[1], t, halfLife),
|
|
75
|
+
inertializedScalar(offset0[2], velocity0[2], t, halfLife)
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- quaternion helpers (local; unit-quaternion algebra) -------------------------------------
|
|
80
|
+
|
|
81
|
+
function quatConjugate(q: Quat): Quat {
|
|
82
|
+
return [-q[0], -q[1], -q[2], q[3]];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function quatMultiply(a: Quat, b: Quat): Quat {
|
|
86
|
+
return [
|
|
87
|
+
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
|
88
|
+
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
|
89
|
+
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
|
90
|
+
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Shortest-arc axis/angle of a unit quaternion; angle in [0, π], axis normalized (or +X). */
|
|
95
|
+
function quatToAxisAngle(q: Quat): { axis: Vec3; angle: number } {
|
|
96
|
+
const n = normalizeQuat(q);
|
|
97
|
+
// Force the shorter arc (w >= 0) so the decayed rotation never takes the long way around.
|
|
98
|
+
const w = n[3] < 0 ? -n[3] : n[3];
|
|
99
|
+
const sign = n[3] < 0 ? -1 : 1;
|
|
100
|
+
const angle = 2 * Math.acos(Math.min(1, w));
|
|
101
|
+
const s = Math.sqrt(Math.max(0, 1 - w * w));
|
|
102
|
+
if (s < 1e-8) return { axis: [1, 0, 0], angle: 0 };
|
|
103
|
+
return { axis: [(sign * n[0]) / s, (sign * n[1]) / s, (sign * n[2]) / s], angle };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function quatFromAxisAngle(axis: Vec3, angle: number): Quat {
|
|
107
|
+
const half = angle / 2;
|
|
108
|
+
const s = Math.sin(half);
|
|
109
|
+
return [axis[0] * s, axis[1] * s, axis[2] * s, Math.cos(half)];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Critically-damped decay of the rotational offset `source · target⁻¹`, re-applied to `target`.
|
|
114
|
+
* At `t = 0` returns `source`; as `t → ∞` returns `target`. `angularSpeed0` (rad/s, about the
|
|
115
|
+
* offset axis) optionally carries rotational momentum through the transition. Deterministic.
|
|
116
|
+
*/
|
|
117
|
+
export function inertializedQuat(source: Quat, target: Quat, t: number, halfLife: number, angularSpeed0 = 0): Quat {
|
|
118
|
+
const offset = quatMultiply(normalizeQuat(source), quatConjugate(normalizeQuat(target)));
|
|
119
|
+
const { axis, angle } = quatToAxisAngle(offset);
|
|
120
|
+
if (angle === 0 && angularSpeed0 === 0) return normalizeQuat(target);
|
|
121
|
+
const decayedAngle = inertializedScalar(angle, angularSpeed0, t, halfLife);
|
|
122
|
+
const decayedOffset = quatFromAxisAngle(axis, decayedAngle);
|
|
123
|
+
return normalizeQuat(quatMultiply(decayedOffset, normalizeQuat(target)));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- pose-space inertializer ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/** A pose is a map of animation target → value (scalar / vec3 / quaternion supported for decay). */
|
|
129
|
+
export type InertializationPose = Readonly<Record<string, AnimationValue>>;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Optional per-target velocities at transition time. Scalars → number (units/s), vec3 → Vec3
|
|
133
|
+
* (units/s), quaternions → number (rad/s about the offset axis). Targets absent here decay from
|
|
134
|
+
* rest (velocity 0).
|
|
135
|
+
*/
|
|
136
|
+
export type InertializationVelocities = Readonly<Record<string, number | Vec3>>;
|
|
137
|
+
|
|
138
|
+
export interface InertializerOptions {
|
|
139
|
+
/** Transition half-life in seconds (default {@link DEFAULT_INERTIALIZATION_HALF_LIFE}). */
|
|
140
|
+
readonly halfLife?: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface RecordedOffset {
|
|
144
|
+
readonly type: "scalar" | "vector3" | "quaternion" | "snap";
|
|
145
|
+
readonly source: AnimationValue;
|
|
146
|
+
readonly target: AnimationValue;
|
|
147
|
+
readonly velocity: number | Vec3;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface Inertializer {
|
|
151
|
+
readonly halfLife: number;
|
|
152
|
+
/**
|
|
153
|
+
* Record a transition from `previousPose` to `targetPose` (the destination clip's pose at the
|
|
154
|
+
* transition instant), with optional per-target velocities. Resets the transition clock.
|
|
155
|
+
*/
|
|
156
|
+
recordTransition(previousPose: InertializationPose, targetPose: InertializationPose, velocities?: InertializationVelocities): void;
|
|
157
|
+
/**
|
|
158
|
+
* Sample the inertialized pose `t` seconds after the recorded transition. At `t = 0` equals the
|
|
159
|
+
* source pose for every recorded target; as `t` grows it converges to the target pose.
|
|
160
|
+
*/
|
|
161
|
+
sampleInertialized(t: number): InertializationPose;
|
|
162
|
+
/** True once the offset has decayed to within `epsilon` of the target across all targets. */
|
|
163
|
+
settled(t: number, epsilon?: number): boolean;
|
|
164
|
+
/** Clear the recorded transition. */
|
|
165
|
+
reset(): void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function classify(value: AnimationValue): RecordedOffset["type"] {
|
|
169
|
+
if (typeof value === "number") return "scalar";
|
|
170
|
+
if (Array.isArray(value)) {
|
|
171
|
+
if (value.length === 3) return "vector3";
|
|
172
|
+
if (value.length === 4) return "quaternion";
|
|
173
|
+
}
|
|
174
|
+
return "snap";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Create a pose-space inertializer. Pure/deterministic given fixed `t`. Use it to replace a
|
|
179
|
+
* linear state-transition crossfade with a momentum-preserving decay of the pose difference.
|
|
180
|
+
*/
|
|
181
|
+
export function createInertializer(options: InertializerOptions = {}): Inertializer {
|
|
182
|
+
const halfLife = options.halfLife ?? DEFAULT_INERTIALIZATION_HALF_LIFE;
|
|
183
|
+
let offsets = new Map<string, RecordedOffset>();
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
halfLife,
|
|
187
|
+
recordTransition(previousPose, targetPose, velocities) {
|
|
188
|
+
const next = new Map<string, RecordedOffset>();
|
|
189
|
+
for (const [target, targetValue] of Object.entries(targetPose)) {
|
|
190
|
+
const sourceValue = previousPose[target];
|
|
191
|
+
if (sourceValue === undefined) continue;
|
|
192
|
+
const type = classify(targetValue);
|
|
193
|
+
const velocity = velocities?.[target] ?? (type === "vector3" ? ([0, 0, 0] as Vec3) : 0);
|
|
194
|
+
next.set(target, {
|
|
195
|
+
type,
|
|
196
|
+
source: cloneAnimationValue(sourceValue),
|
|
197
|
+
target: cloneAnimationValue(targetValue),
|
|
198
|
+
velocity
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
offsets = next;
|
|
202
|
+
},
|
|
203
|
+
sampleInertialized(t) {
|
|
204
|
+
const pose: Record<string, AnimationValue> = {};
|
|
205
|
+
for (const [target, rec] of offsets) {
|
|
206
|
+
pose[target] = sampleOffset(rec, t, halfLife);
|
|
207
|
+
}
|
|
208
|
+
return pose;
|
|
209
|
+
},
|
|
210
|
+
settled(t, epsilon = 1e-3) {
|
|
211
|
+
for (const [, rec] of offsets) {
|
|
212
|
+
if (!offsetSettled(rec, t, halfLife, epsilon)) return false;
|
|
213
|
+
}
|
|
214
|
+
return true;
|
|
215
|
+
},
|
|
216
|
+
reset() {
|
|
217
|
+
offsets = new Map<string, RecordedOffset>();
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sampleOffset(rec: RecordedOffset, t: number, halfLife: number): AnimationValue {
|
|
223
|
+
switch (rec.type) {
|
|
224
|
+
case "scalar": {
|
|
225
|
+
const target = rec.target as number;
|
|
226
|
+
const offset0 = (rec.source as number) - target;
|
|
227
|
+
return target + inertializedScalar(offset0, rec.velocity as number, t, halfLife);
|
|
228
|
+
}
|
|
229
|
+
case "vector3": {
|
|
230
|
+
const target = rec.target as Vec3;
|
|
231
|
+
const source = rec.source as Vec3;
|
|
232
|
+
const offset0: Vec3 = [source[0] - target[0], source[1] - target[1], source[2] - target[2]];
|
|
233
|
+
const decayed = inertializedVec3(offset0, t, halfLife, rec.velocity as Vec3);
|
|
234
|
+
return [target[0] + decayed[0], target[1] + decayed[1], target[2] + decayed[2]];
|
|
235
|
+
}
|
|
236
|
+
case "quaternion":
|
|
237
|
+
return inertializedQuat(rec.source as Quat, rec.target as Quat, t, halfLife, rec.velocity as number);
|
|
238
|
+
default:
|
|
239
|
+
// Non-interpolable (boolean/string/other array length): snap to target immediately.
|
|
240
|
+
return cloneAnimationValue(rec.target);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function offsetSettled(rec: RecordedOffset, t: number, halfLife: number, epsilon: number): boolean {
|
|
245
|
+
switch (rec.type) {
|
|
246
|
+
case "scalar": {
|
|
247
|
+
const offset0 = (rec.source as number) - (rec.target as number);
|
|
248
|
+
return Math.abs(inertializedScalar(offset0, rec.velocity as number, t, halfLife)) <= epsilon;
|
|
249
|
+
}
|
|
250
|
+
case "vector3": {
|
|
251
|
+
const target = rec.target as Vec3;
|
|
252
|
+
const source = rec.source as Vec3;
|
|
253
|
+
const decayed = inertializedVec3(
|
|
254
|
+
[source[0] - target[0], source[1] - target[1], source[2] - target[2]],
|
|
255
|
+
t,
|
|
256
|
+
halfLife,
|
|
257
|
+
rec.velocity as Vec3
|
|
258
|
+
);
|
|
259
|
+
return Math.hypot(decayed[0], decayed[1], decayed[2]) <= epsilon;
|
|
260
|
+
}
|
|
261
|
+
case "quaternion": {
|
|
262
|
+
const offset = quatMultiply(normalizeQuat(rec.source as Quat), quatConjugate(normalizeQuat(rec.target as Quat)));
|
|
263
|
+
const { angle } = quatToAxisAngle(offset);
|
|
264
|
+
return Math.abs(inertializedScalar(angle, rec.velocity as number, t, halfLife)) <= epsilon;
|
|
265
|
+
}
|
|
266
|
+
default:
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AnimationClip } from "./AnimationClip.js";
|
|
2
2
|
import { AnimationTrack } from "./AnimationTrack.js";
|
|
3
|
+
import type { FootIkRig } from "./FootIk.js";
|
|
3
4
|
import { applyRootMotion, extractRootMotion } from "./RootMotion.js";
|
|
4
5
|
|
|
5
6
|
export interface LocomotionControllerState {
|
|
@@ -28,6 +29,8 @@ export interface LocomotionControllerOptions {
|
|
|
28
29
|
readonly pathRadius?: number;
|
|
29
30
|
readonly strideAmplitude?: number;
|
|
30
31
|
readonly rootMotionScale?: number;
|
|
32
|
+
/** Optional foot-IK rig so locomotion can ground feet on uneven terrain (no foot sliding). */
|
|
33
|
+
readonly footIkRig?: FootIkRig;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export interface ProceduralWalkClipOptions {
|
|
@@ -43,6 +46,8 @@ export class LocomotionController {
|
|
|
43
46
|
readonly strideAmplitude: number;
|
|
44
47
|
readonly rootMotionScale: number;
|
|
45
48
|
readonly state: LocomotionControllerState;
|
|
49
|
+
/** Optional foot-IK rig hook; call `footIk.solveFootPlacement(...)` to ground feet. */
|
|
50
|
+
readonly footIk: FootIkRig | undefined;
|
|
46
51
|
|
|
47
52
|
constructor(options: LocomotionControllerOptions) {
|
|
48
53
|
if (!Number.isFinite(options.clip.duration) || options.clip.duration <= 0) {
|
|
@@ -52,6 +57,7 @@ export class LocomotionController {
|
|
|
52
57
|
this.rootMotionTrack = options.rootMotionTrack ?? "root.position";
|
|
53
58
|
this.strideAmplitude = options.strideAmplitude ?? 1;
|
|
54
59
|
this.rootMotionScale = options.rootMotionScale ?? 1;
|
|
60
|
+
this.footIk = options.footIkRig;
|
|
55
61
|
this.state = {
|
|
56
62
|
speed: options.speed ?? 1,
|
|
57
63
|
inPlace: options.inPlace ?? false,
|