@aura3d/animation 1.0.6 → 1.3.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.
Files changed (121) hide show
  1. package/dist/AnimationClipEvents.d.ts +55 -0
  2. package/dist/AnimationClipEvents.d.ts.map +1 -1
  3. package/dist/AnimationClipEvents.js +136 -0
  4. package/dist/AnimationClipEvents.js.map +1 -1
  5. package/dist/AnimationClipRegistry.d.ts +22 -0
  6. package/dist/AnimationClipRegistry.d.ts.map +1 -1
  7. package/dist/AnimationClipRegistry.js +89 -0
  8. package/dist/AnimationClipRegistry.js.map +1 -1
  9. package/dist/AnimationController.d.ts +15 -0
  10. package/dist/AnimationController.d.ts.map +1 -1
  11. package/dist/AnimationController.js +28 -0
  12. package/dist/AnimationController.js.map +1 -1
  13. package/dist/AnimationMixer.d.ts +10 -0
  14. package/dist/AnimationMixer.d.ts.map +1 -1
  15. package/dist/AnimationMixer.js +35 -0
  16. package/dist/AnimationMixer.js.map +1 -1
  17. package/dist/AnimationStateGraph.d.ts +20 -1
  18. package/dist/AnimationStateGraph.d.ts.map +1 -1
  19. package/dist/AnimationStateGraph.js +73 -1
  20. package/dist/AnimationStateGraph.js.map +1 -1
  21. package/dist/AnimationStateMachine.d.ts +26 -0
  22. package/dist/AnimationStateMachine.d.ts.map +1 -1
  23. package/dist/AnimationStateMachine.js +32 -0
  24. package/dist/AnimationStateMachine.js.map +1 -1
  25. package/dist/Bone.d.ts +4 -0
  26. package/dist/Bone.d.ts.map +1 -1
  27. package/dist/Bone.js +3 -0
  28. package/dist/Bone.js.map +1 -1
  29. package/dist/FighterAnimationAdapter.d.ts +49 -0
  30. package/dist/FighterAnimationAdapter.d.ts.map +1 -0
  31. package/dist/FighterAnimationAdapter.js +71 -0
  32. package/dist/FighterAnimationAdapter.js.map +1 -0
  33. package/dist/FootIk.d.ts +77 -0
  34. package/dist/FootIk.d.ts.map +1 -0
  35. package/dist/FootIk.js +153 -0
  36. package/dist/FootIk.js.map +1 -0
  37. package/dist/HumanoidBoneInference.d.ts +61 -0
  38. package/dist/HumanoidBoneInference.d.ts.map +1 -0
  39. package/dist/HumanoidBoneInference.js +239 -0
  40. package/dist/HumanoidBoneInference.js.map +1 -0
  41. package/dist/HumanoidRetargeting.d.ts +57 -0
  42. package/dist/HumanoidRetargeting.d.ts.map +1 -1
  43. package/dist/HumanoidRetargeting.js +184 -6
  44. package/dist/HumanoidRetargeting.js.map +1 -1
  45. package/dist/IK.d.ts +5 -0
  46. package/dist/IK.d.ts.map +1 -1
  47. package/dist/IK.js +9 -0
  48. package/dist/IK.js.map +1 -1
  49. package/dist/Inertialization.d.ts +65 -0
  50. package/dist/Inertialization.d.ts.map +1 -0
  51. package/dist/Inertialization.js +211 -0
  52. package/dist/Inertialization.js.map +1 -0
  53. package/dist/LocomotionController.d.ts +5 -0
  54. package/dist/LocomotionController.d.ts.map +1 -1
  55. package/dist/LocomotionController.js +3 -0
  56. package/dist/LocomotionController.js.map +1 -1
  57. package/dist/LocomotionKit.d.ts +52 -0
  58. package/dist/LocomotionKit.d.ts.map +1 -0
  59. package/dist/LocomotionKit.js +38 -0
  60. package/dist/LocomotionKit.js.map +1 -0
  61. package/dist/MotionQuality.d.ts +6 -0
  62. package/dist/MotionQuality.d.ts.map +1 -1
  63. package/dist/MotionQuality.js +16 -0
  64. package/dist/MotionQuality.js.map +1 -1
  65. package/dist/RigQuality.d.ts +31 -0
  66. package/dist/RigQuality.d.ts.map +1 -0
  67. package/dist/RigQuality.js +70 -0
  68. package/dist/RigQuality.js.map +1 -0
  69. package/dist/SecondaryAnimationFixtures.d.ts.map +1 -1
  70. package/dist/SecondaryAnimationFixtures.js +1 -3
  71. package/dist/SecondaryAnimationFixtures.js.map +1 -1
  72. package/dist/Skeleton.d.ts +16 -1
  73. package/dist/Skeleton.d.ts.map +1 -1
  74. package/dist/Skeleton.js +42 -0
  75. package/dist/Skeleton.js.map +1 -1
  76. package/dist/SpringBones.d.ts +60 -0
  77. package/dist/SpringBones.d.ts.map +1 -0
  78. package/dist/SpringBones.js +173 -0
  79. package/dist/SpringBones.js.map +1 -0
  80. package/dist/browser-index.d.ts +12 -3
  81. package/dist/browser-index.d.ts.map +1 -1
  82. package/dist/browser-index.js +11 -2
  83. package/dist/browser-index.js.map +1 -1
  84. package/dist/index.d.ts +11 -3
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +10 -2
  87. package/dist/index.js.map +1 -1
  88. package/dist/library/performanceStateGraph.d.ts +42 -0
  89. package/dist/library/performanceStateGraph.d.ts.map +1 -0
  90. package/dist/library/performanceStateGraph.js +101 -0
  91. package/dist/library/performanceStateGraph.js.map +1 -0
  92. package/dist/library/standardHumanoidClips.d.ts +27 -0
  93. package/dist/library/standardHumanoidClips.d.ts.map +1 -0
  94. package/dist/library/standardHumanoidClips.js +357 -0
  95. package/dist/library/standardHumanoidClips.js.map +1 -0
  96. package/package.json +3 -3
  97. package/src/AnimationClipEvents.ts +181 -0
  98. package/src/AnimationClipRegistry.ts +124 -0
  99. package/src/AnimationController.ts +50 -0
  100. package/src/AnimationMixer.ts +36 -0
  101. package/src/AnimationStateGraph.ts +97 -0
  102. package/src/AnimationStateMachine.ts +48 -0
  103. package/src/Bone.ts +5 -0
  104. package/src/FighterAnimationAdapter.ts +124 -0
  105. package/src/FootIk.ts +251 -0
  106. package/src/HumanoidBoneInference.ts +323 -0
  107. package/src/HumanoidRetargeting.ts +230 -6
  108. package/src/IK.ts +10 -0
  109. package/src/Inertialization.ts +269 -0
  110. package/src/LocomotionController.ts +6 -0
  111. package/src/LocomotionKit.ts +89 -0
  112. package/src/MotionQuality.ts +26 -0
  113. package/src/RigQuality.ts +101 -0
  114. package/src/SecondaryAnimationFixtures.ts +1 -3
  115. package/src/Skeleton.ts +45 -1
  116. package/src/SpringBones.ts +243 -0
  117. package/src/browser-index.ts +13 -2
  118. package/src/index.ts +12 -2
  119. package/src/library/performanceStateGraph.ts +117 -0
  120. package/src/library/standardHumanoidClips.ts +442 -0
  121. 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,6 +138,20 @@ export interface RetargetHumanoidPoseOptions {
126
138
  readonly scaleRootMotion?: boolean;
127
139
  }
128
140
 
141
+ export interface AnimationHumanoidRetargetingOptions extends HumanoidRetargetingOptions {
142
+ readonly requiredClips?: readonly string[];
143
+ readonly availableClips?: readonly string[];
144
+ readonly mouthBlendshapeNames?: readonly string[];
145
+ readonly retargetMapProvided?: boolean;
146
+ }
147
+
148
+ export interface AnimationHumanoidRetargetingDiagnostics extends HumanoidRigDiagnostics {
149
+ readonly kind: "animation-humanoid-retargeting-diagnostics";
150
+ readonly mouthReady: boolean;
151
+ readonly clipReady: boolean;
152
+ readonly retargetMapProvided: boolean;
153
+ }
154
+
129
155
  export function analyzeHumanoidRig(
130
156
  rig: HumanoidRigDefinition,
131
157
  options: HumanoidRetargetingOptions = {}
@@ -313,7 +339,9 @@ export function createHumanoidRetargetingMap(
313
339
  bone,
314
340
  source: sourceBinding,
315
341
  target: targetBinding,
316
- 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)
317
345
  };
318
346
  }
319
347
 
@@ -337,10 +365,44 @@ export function createHumanoidRetargetingMap(
337
365
  coverage,
338
366
  requiredCoverage,
339
367
  bindings,
340
- diagnostics
368
+ diagnostics,
369
+ sourceFacingAxis: source.facingAxis ?? "z",
370
+ targetFacingAxis: target.facingAxis ?? "z"
341
371
  };
342
372
  }
343
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
+ */
344
406
  export function retargetHumanoidPose(
345
407
  sourcePose: AnimationPose,
346
408
  map: HumanoidRetargetingMap,
@@ -354,6 +416,8 @@ export function retargetHumanoidPose(
354
416
  }
355
417
  }
356
418
 
419
+ const facing = facingAlignmentQuat(map.sourceFacingAxis ?? "z", map.targetFacingAxis ?? "z");
420
+
357
421
  for (const bone of HUMANOID_BONES) {
358
422
  const binding = map.bindings[bone];
359
423
  if (!binding) continue;
@@ -361,7 +425,7 @@ export function retargetHumanoidPose(
361
425
  const sourceTransform = sourcePose.bones[binding.source.name] ?? sourcePose.bones[bone];
362
426
  if (!sourceTransform) continue;
363
427
 
364
- const retargeted = scaleTransform(sourceTransform, binding.scale);
428
+ const retargeted = retargetBoneTransform(sourceTransform, binding, bone === "hips" ? facing : undefined);
365
429
  bones[binding.target.name] = retargeted;
366
430
 
367
431
  if (options.includeSemanticBoneNames) {
@@ -397,6 +461,55 @@ export function humanoidRetargetingDiagnostics(
397
461
  return createHumanoidRetargetingMap(source, target, options).diagnostics;
398
462
  }
399
463
 
464
+ export function analyzeAnimationHumanoidRetargeting(
465
+ rig: HumanoidRigDefinition,
466
+ options: AnimationHumanoidRetargetingOptions = {}
467
+ ): AnimationHumanoidRetargetingDiagnostics {
468
+ const base = analyzeHumanoidRig(rig, { minRequiredCoverage: 0.9, requireRestPose: true, ...options });
469
+ const diagnostics: HumanoidRetargetingDiagnostic[] = [...base.diagnostics];
470
+ const requiredClips = options.requiredClips ?? ["Idle", "Talk", "Gesture", "Walk"];
471
+ const availableClips = new Set(options.availableClips ?? []);
472
+ const missingClips = requiredClips.filter((clip) => !availableClips.has(clip));
473
+ const mouthReady = (options.mouthBlendshapeNames?.length ?? 0) > 0
474
+ || Array.isArray(rig.metadata?.mouthBlendshapeNames) && (rig.metadata.mouthBlendshapeNames as unknown[]).length > 0;
475
+ const retargetMapProvided = options.retargetMapProvided ?? Boolean(rig.metadata?.retargetMapProvided);
476
+
477
+ if (!mouthReady) {
478
+ diagnostics.push({
479
+ severity: "error",
480
+ code: "ANIMATION_MOUTH_METADATA_MISSING",
481
+ message: "Animation retargeting requires mouth blendshape or mouth-card metadata.",
482
+ rigId: rig.id
483
+ });
484
+ }
485
+ for (const clip of missingClips) {
486
+ diagnostics.push({
487
+ severity: "error",
488
+ code: "ANIMATION_REQUIRED_CLIP_MISSING",
489
+ message: `Animation retargeting requires clip "${clip}".`,
490
+ rigId: rig.id
491
+ });
492
+ }
493
+ if (!retargetMapProvided) {
494
+ diagnostics.push({
495
+ severity: "error",
496
+ code: "ANIMATION_RETARGET_MAP_MISSING",
497
+ message: "Animation retargeting requires external bone-map/retarget metadata.",
498
+ rigId: rig.id
499
+ });
500
+ }
501
+
502
+ return {
503
+ kind: "animation-humanoid-retargeting-diagnostics",
504
+ ...base,
505
+ ok: !diagnostics.some((diagnostic) => diagnostic.severity === "error"),
506
+ diagnostics,
507
+ mouthReady,
508
+ clipReady: missingClips.length === 0,
509
+ retargetMapProvided
510
+ };
511
+ }
512
+
400
513
  const HUMANOID_SIDE_PAIRS: readonly { readonly left: HumanoidBoneName; readonly right: HumanoidBoneName }[] = [
401
514
  { left: "leftShoulder", right: "rightShoulder" },
402
515
  { left: "leftUpperArm", right: "rightUpperArm" },
@@ -447,14 +560,125 @@ function averageBindingScale(map: HumanoidRetargetingMap): number {
447
560
  return scales.reduce((sum, scale) => sum + scale, 0) / scales.length;
448
561
  }
449
562
 
450
- function scaleTransform(transform: AnimationPoseTransform, scale: number): AnimationPoseTransform {
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
+
451
607
  return {
452
- position: transform.position ? scaleVector(transform.position, scale) : undefined,
453
- rotation: transform.rotation,
608
+ position: transform.position ? scaleVector(transform.position, binding.scale) : undefined,
609
+ rotation,
454
610
  scale: transform.scale
455
611
  };
456
612
  }
457
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
+
458
682
  function cloneTransform(transform: AnimationPoseTransform): AnimationPoseTransform {
459
683
  return {
460
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,