@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.
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 +9 -3
  6. package/dist/AnimationClipRegistry.d.ts.map +1 -1
  7. package/dist/AnimationClipRegistry.js +52 -5
  8. package/dist/AnimationClipRegistry.js.map +1 -1
  9. package/dist/AnimationController.d.ts +6 -6
  10. package/dist/AnimationController.d.ts.map +1 -1
  11. package/dist/AnimationController.js +3 -3
  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 +14 -5
  18. package/dist/AnimationStateGraph.d.ts.map +1 -1
  19. package/dist/AnimationStateGraph.js +25 -2
  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 +48 -4
  42. package/dist/HumanoidRetargeting.d.ts.map +1 -1
  43. package/dist/HumanoidRetargeting.js +149 -14
  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 +3 -3
  62. package/dist/MotionQuality.d.ts.map +1 -1
  63. package/dist/MotionQuality.js +2 -2
  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 +65 -9
  99. package/src/AnimationController.ts +11 -11
  100. package/src/AnimationMixer.ts +36 -0
  101. package/src/AnimationStateGraph.ts +34 -5
  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 +180 -19
  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 +5 -5
  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 -4
  118. package/src/index.ts +12 -4
  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,15 +138,15 @@ export interface RetargetHumanoidPoseOptions {
126
138
  readonly scaleRootMotion?: boolean;
127
139
  }
128
140
 
129
- export interface CartoonHumanoidRetargetingOptions extends HumanoidRetargetingOptions {
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 CartoonHumanoidRetargetingDiagnostics extends HumanoidRigDiagnostics {
137
- readonly kind: "cartoon-humanoid-retargeting-diagnostics";
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 = scaleTransform(sourceTransform, binding.scale);
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 analyzeCartoonHumanoidRetargeting(
464
+ export function analyzeAnimationHumanoidRetargeting(
415
465
  rig: HumanoidRigDefinition,
416
- options: CartoonHumanoidRetargetingOptions = {}
417
- ): CartoonHumanoidRetargetingDiagnostics {
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: "CARTOON_MOUTH_METADATA_MISSING",
431
- message: "Cartoon retargeting requires mouth blendshape or mouth-card metadata.",
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: "CARTOON_REQUIRED_CLIP_MISSING",
439
- message: `Cartoon retargeting requires clip "${clip}".`,
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: "CARTOON_RETARGET_MAP_MISSING",
447
- message: "Cartoon retargeting requires external bone-map/retarget metadata.",
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: "cartoon-humanoid-retargeting-diagnostics",
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
- 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
+
514
607
  return {
515
- position: transform.position ? scaleVector(transform.position, scale) : undefined,
516
- rotation: transform.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,