@aura3d/animation 1.4.0 → 1.4.2

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 (46) hide show
  1. package/package.json +36 -3
  2. package/src/AnimationAction.ts +0 -166
  3. package/src/AnimationClip.ts +0 -63
  4. package/src/AnimationClipEvents.ts +0 -501
  5. package/src/AnimationClipRegistry.ts +0 -404
  6. package/src/AnimationController.ts +0 -1017
  7. package/src/AnimationEvents.ts +0 -34
  8. package/src/AnimationLayer.ts +0 -73
  9. package/src/AnimationMixer.ts +0 -402
  10. package/src/AnimationStateGraph.ts +0 -105
  11. package/src/AnimationStateMachine.ts +0 -265
  12. package/src/AnimationTrack.ts +0 -176
  13. package/src/BlendTree.ts +0 -89
  14. package/src/Bone.ts +0 -40
  15. package/src/CrowdAnimation.ts +0 -25
  16. package/src/ECSAnimationBridge.ts +0 -30
  17. package/src/FighterAnimationAdapter.ts +0 -124
  18. package/src/FootIk.ts +0 -251
  19. package/src/HumanoidBoneInference.ts +0 -323
  20. package/src/HumanoidRetargeting.ts +0 -700
  21. package/src/IK.ts +0 -117
  22. package/src/Inertialization.ts +0 -269
  23. package/src/Keyframe.ts +0 -159
  24. package/src/LocomotionController.ts +0 -155
  25. package/src/LocomotionKit.ts +0 -89
  26. package/src/MotionMatchingFixtures.ts +0 -254
  27. package/src/MotionQuality.ts +0 -182
  28. package/src/RigQuality.ts +0 -101
  29. package/src/RootMotion.ts +0 -124
  30. package/src/SceneAnimationBridge.ts +0 -80
  31. package/src/SecondaryAnimationFixtures.ts +0 -210
  32. package/src/Skeleton.ts +0 -78
  33. package/src/Skinning.ts +0 -18
  34. package/src/SpringBones.ts +0 -243
  35. package/src/browser-index.ts +0 -66
  36. package/src/index.ts +0 -66
  37. package/src/library/performanceStateGraph.ts +0 -173
  38. package/src/library/standardHumanoidClips.ts +0 -659
  39. package/src/threejs-compatibility/AnimationAction.ts +0 -27
  40. package/src/threejs-compatibility/AnimationClip.ts +0 -16
  41. package/src/threejs-compatibility/AnimationDiagnostics.ts +0 -42
  42. package/src/threejs-compatibility/AnimationMixer.ts +0 -24
  43. package/src/threejs-compatibility/MorphTargetMixer.ts +0 -16
  44. package/src/threejs-compatibility/Skeleton.ts +0 -9
  45. package/src/threejs-compatibility/SkinnedMesh.ts +0 -12
  46. package/src/threejs-compatibility/index.ts +0 -11
package/src/IK.ts DELETED
@@ -1,117 +0,0 @@
1
- import { lerpVec3, type Vec3 } from "./Keyframe.js";
2
-
3
- export interface TwoBoneIkInput {
4
- readonly root: Vec3;
5
- readonly mid: Vec3;
6
- readonly end: Vec3;
7
- readonly target: Vec3;
8
- readonly pole?: Vec3;
9
- readonly weight?: number;
10
- readonly allowStretch?: boolean;
11
- }
12
-
13
- export interface TwoBoneIkResult {
14
- readonly root: Vec3;
15
- readonly mid: Vec3;
16
- readonly end: Vec3;
17
- readonly reached: boolean;
18
- readonly stretched: boolean;
19
- readonly upperLength: number;
20
- readonly lowerLength: number;
21
- readonly targetDistance: number;
22
- readonly endDistanceToTarget: number;
23
- readonly poleInfluence: number;
24
- }
25
-
26
- export function solveTwoBoneIk(input: TwoBoneIkInput): TwoBoneIkResult {
27
- const weight = clamp(input.weight ?? 1, 0, 1);
28
- const upperLength = distance(input.root, input.mid);
29
- const lowerLength = distance(input.mid, input.end);
30
- if (upperLength <= 1e-6 || lowerLength <= 1e-6) {
31
- throw new Error("Two-bone IK requires non-zero upper and lower segment lengths.");
32
- }
33
- const targetDelta = subtract(input.target, input.root);
34
- const targetDistance = length(targetDelta);
35
- if (targetDistance <= 1e-6) {
36
- throw new Error("Two-bone IK target must be distinct from the root.");
37
- }
38
-
39
- const maxReach = upperLength + lowerLength;
40
- const minReach = Math.abs(upperLength - lowerLength) + 1e-5;
41
- const solveDistance = input.allowStretch ? targetDistance : clamp(targetDistance, minReach, maxReach - 1e-5);
42
- const axis = normalize(targetDelta);
43
- const poleDirection = projectedPoleDirection(input.root, axis, input.pole ?? input.mid);
44
- const adjacent = clamp((upperLength * upperLength + solveDistance * solveDistance - lowerLength * lowerLength) / (2 * solveDistance), 0, upperLength);
45
- const height = Math.sqrt(Math.max(0, upperLength * upperLength - adjacent * adjacent));
46
- const solvedEnd = add(input.root, scale(axis, solveDistance));
47
- const solvedMid = add(add(input.root, scale(axis, adjacent)), scale(poleDirection, height));
48
- const mid = lerpVec3(input.mid, solvedMid, weight);
49
- const end = lerpVec3(input.end, solvedEnd, weight);
50
- const endDistanceToTarget = distance(end, input.target);
51
- return {
52
- root: input.root,
53
- mid,
54
- end,
55
- reached: endDistanceToTarget <= 1e-3,
56
- stretched: targetDistance > maxReach && input.allowStretch === true,
57
- upperLength,
58
- lowerLength,
59
- targetDistance,
60
- endDistanceToTarget,
61
- poleInfluence: Math.abs(dot(normalize(subtract(mid, input.root)), poleDirection))
62
- };
63
- }
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
-
75
- function projectedPoleDirection(root: Vec3, axis: Vec3, pole: Vec3): Vec3 {
76
- const poleDelta = subtract(pole, root);
77
- const projected = subtract(poleDelta, scale(axis, dot(poleDelta, axis)));
78
- const projectedLength = length(projected);
79
- if (projectedLength > 1e-6) return scale(projected, 1 / projectedLength);
80
- const fallback = Math.abs(axis[1]) < 0.9 ? [0, 1, 0] as const : [1, 0, 0] as const;
81
- const fallbackProjected = subtract(fallback, scale(axis, dot(fallback, axis)));
82
- return normalize(fallbackProjected);
83
- }
84
-
85
- function add(left: Vec3, right: Vec3): Vec3 {
86
- return [left[0] + right[0], left[1] + right[1], left[2] + right[2]];
87
- }
88
-
89
- function subtract(left: Vec3, right: Vec3): Vec3 {
90
- return [left[0] - right[0], left[1] - right[1], left[2] - right[2]];
91
- }
92
-
93
- function scale(value: Vec3, scalar: number): Vec3 {
94
- return [value[0] * scalar, value[1] * scalar, value[2] * scalar];
95
- }
96
-
97
- function dot(left: Vec3, right: Vec3): number {
98
- return left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
99
- }
100
-
101
- function length(value: Vec3): number {
102
- return Math.hypot(value[0], value[1], value[2]);
103
- }
104
-
105
- function distance(left: Vec3, right: Vec3): number {
106
- return length(subtract(left, right));
107
- }
108
-
109
- function normalize(value: Vec3): Vec3 {
110
- const valueLength = length(value);
111
- if (valueLength <= 1e-6) return [0, 1, 0];
112
- return scale(value, 1 / valueLength);
113
- }
114
-
115
- function clamp(value: number, min: number, max: number): number {
116
- return Math.max(min, Math.min(max, value));
117
- }
@@ -1,269 +0,0 @@
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
- }
package/src/Keyframe.ts DELETED
@@ -1,159 +0,0 @@
1
- export type Vec3 = readonly [number, number, number];
2
- export type Quat = readonly [number, number, number, number];
3
- export type Mat4 = readonly [
4
- number, number, number, number,
5
- number, number, number, number,
6
- number, number, number, number,
7
- number, number, number, number
8
- ];
9
-
10
- export type NumberArray = readonly number[];
11
-
12
- export type AnimationValue = number | boolean | string | Vec3 | Quat | NumberArray;
13
-
14
- export type InterpolationMode = "step" | "linear" | "cubicspline";
15
-
16
- export type Keyframe<T extends AnimationValue = AnimationValue> = {
17
- readonly time: number;
18
- readonly value: T;
19
- readonly interpolation?: InterpolationMode;
20
- readonly inTangent?: T;
21
- readonly outTangent?: T;
22
- };
23
-
24
- export type SerializedKeyframe<T extends AnimationValue = AnimationValue> = {
25
- readonly time: number;
26
- readonly value: T;
27
- readonly interpolation?: InterpolationMode;
28
- readonly inTangent?: T;
29
- readonly outTangent?: T;
30
- };
31
-
32
- export function validateKeyframes<T extends AnimationValue>(keyframes: readonly Keyframe<T>[]): void {
33
- if (keyframes.length === 0) {
34
- throw new Error("Animation track requires at least one keyframe.");
35
- }
36
- let previous = -Number.MAX_VALUE;
37
- for (const keyframe of keyframes) {
38
- if (!Number.isFinite(keyframe.time) || keyframe.time < 0) {
39
- throw new Error("Keyframe time must be finite and non-negative.");
40
- }
41
- if (keyframe.time <= previous) {
42
- throw new Error("Keyframes must be sorted by strictly increasing time.");
43
- }
44
- previous = keyframe.time;
45
- }
46
- }
47
-
48
- export function serializeKeyframe<T extends AnimationValue>(keyframe: Keyframe<T>): SerializedKeyframe<T> {
49
- return {
50
- time: keyframe.time,
51
- value: cloneAnimationValue(keyframe.value),
52
- ...(keyframe.interpolation ? { interpolation: keyframe.interpolation } : {}),
53
- ...(keyframe.inTangent !== undefined ? { inTangent: cloneAnimationValue(keyframe.inTangent) } : {}),
54
- ...(keyframe.outTangent !== undefined ? { outTangent: cloneAnimationValue(keyframe.outTangent) } : {})
55
- };
56
- }
57
-
58
- export function deserializeKeyframe<T extends AnimationValue>(keyframe: SerializedKeyframe<T>): Keyframe<T> {
59
- return serializeKeyframe(keyframe);
60
- }
61
-
62
- export function cloneAnimationValue<T extends AnimationValue>(value: T): T {
63
- return Array.isArray(value) ? ([...value] as unknown as T) : value;
64
- }
65
-
66
- export function lerpNumber(a: number, b: number, t: number): number {
67
- return a + (b - a) * t;
68
- }
69
-
70
- export function lerpVec3(a: Vec3, b: Vec3, t: number): Vec3 {
71
- return [lerpNumber(a[0], b[0], t), lerpNumber(a[1], b[1], t), lerpNumber(a[2], b[2], t)];
72
- }
73
-
74
- export function normalizeQuat(q: Quat): Quat {
75
- const length = Math.hypot(q[0], q[1], q[2], q[3]);
76
- if (length <= 1e-9) {
77
- return [0, 0, 0, 1];
78
- }
79
- return [q[0] / length, q[1] / length, q[2] / length, q[3] / length];
80
- }
81
-
82
- export function slerpQuat(a: Quat, b: Quat, t: number): Quat {
83
- let bx = b[0];
84
- let by = b[1];
85
- let bz = b[2];
86
- let bw = b[3];
87
- let cos = a[0] * bx + a[1] * by + a[2] * bz + a[3] * bw;
88
- if (cos < 0) {
89
- cos = -cos;
90
- bx = -bx;
91
- by = -by;
92
- bz = -bz;
93
- bw = -bw;
94
- }
95
- if (cos > 0.9995) {
96
- return normalizeQuat([lerpNumber(a[0], bx, t), lerpNumber(a[1], by, t), lerpNumber(a[2], bz, t), lerpNumber(a[3], bw, t)]);
97
- }
98
- const theta = Math.acos(Math.max(-1, Math.min(1, cos)));
99
- const sinTheta = Math.sin(theta);
100
- const wa = Math.sin((1 - t) * theta) / sinTheta;
101
- const wb = Math.sin(t * theta) / sinTheta;
102
- return [a[0] * wa + bx * wb, a[1] * wa + by * wb, a[2] * wa + bz * wb, a[3] * wa + bw * wb];
103
- }
104
-
105
- export function identityMat4(): Mat4 {
106
- return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
107
- }
108
-
109
- export function composeMat4(translation: Vec3 = [0, 0, 0], rotation: Quat = [0, 0, 0, 1], scale: Vec3 = [1, 1, 1]): Mat4 {
110
- const [x, y, z, w] = normalizeQuat(rotation);
111
- const x2 = x + x;
112
- const y2 = y + y;
113
- const z2 = z + z;
114
- const xx = x * x2;
115
- const xy = x * y2;
116
- const xz = x * z2;
117
- const yy = y * y2;
118
- const yz = y * z2;
119
- const zz = z * z2;
120
- const wx = w * x2;
121
- const wy = w * y2;
122
- const wz = w * z2;
123
- return [
124
- (1 - (yy + zz)) * scale[0], (xy + wz) * scale[0], (xz - wy) * scale[0], 0,
125
- (xy - wz) * scale[1], (1 - (xx + zz)) * scale[1], (yz + wx) * scale[1], 0,
126
- (xz + wy) * scale[2], (yz - wx) * scale[2], (1 - (xx + yy)) * scale[2], 0,
127
- translation[0], translation[1], translation[2], 1
128
- ];
129
- }
130
-
131
- export function multiplyMat4(a: Mat4, b: Mat4): Mat4 {
132
- const out = new Array<number>(16).fill(0);
133
- for (let row = 0; row < 4; row += 1) {
134
- for (let col = 0; col < 4; col += 1) {
135
- const a0 = a[row * 4]!;
136
- const a1 = a[row * 4 + 1]!;
137
- const a2 = a[row * 4 + 2]!;
138
- const a3 = a[row * 4 + 3]!;
139
- out[col + row * 4] =
140
- a0 * b[col]! +
141
- a1 * b[col + 4]! +
142
- a2 * b[col + 8]! +
143
- a3 * b[col + 12]!;
144
- }
145
- }
146
- return out as unknown as Mat4;
147
- }
148
-
149
- export function invertTranslationMat4(matrix: Mat4): Mat4 {
150
- return [
151
- matrix[0], matrix[4], matrix[8], 0,
152
- matrix[1], matrix[5], matrix[9], 0,
153
- matrix[2], matrix[6], matrix[10], 0,
154
- -(matrix[12] * matrix[0] + matrix[13] * matrix[1] + matrix[14] * matrix[2]),
155
- -(matrix[12] * matrix[4] + matrix[13] * matrix[5] + matrix[14] * matrix[6]),
156
- -(matrix[12] * matrix[8] + matrix[13] * matrix[9] + matrix[14] * matrix[10]),
157
- 1
158
- ];
159
- }
@@ -1,155 +0,0 @@
1
- import { AnimationClip } from "./AnimationClip.js";
2
- import { AnimationTrack } from "./AnimationTrack.js";
3
- import type { FootIkRig } from "./FootIk.js";
4
- import { applyRootMotion, extractRootMotion } from "./RootMotion.js";
5
-
6
- export interface LocomotionControllerState {
7
- speed: number;
8
- inPlace: boolean;
9
- paused: boolean;
10
- pathRadius: number;
11
- }
12
-
13
- export interface LocomotionControllerSample {
14
- clipName: string;
15
- clipTime: number;
16
- worldX: number;
17
- worldZ: number;
18
- heading: number;
19
- stride: number;
20
- rootMotionDistance: number;
21
- }
22
-
23
- export interface LocomotionControllerOptions {
24
- readonly clip: AnimationClip;
25
- readonly rootMotionTrack?: string;
26
- readonly speed?: number;
27
- readonly inPlace?: boolean;
28
- readonly paused?: boolean;
29
- readonly pathRadius?: number;
30
- readonly strideAmplitude?: number;
31
- readonly rootMotionScale?: number;
32
- /** Optional foot-IK rig so locomotion can ground feet on uneven terrain (no foot sliding). */
33
- readonly footIkRig?: FootIkRig;
34
- }
35
-
36
- export interface ProceduralWalkClipOptions {
37
- readonly name?: string;
38
- readonly duration?: number;
39
- readonly distance?: number;
40
- readonly rootMotionTrack?: string;
41
- }
42
-
43
- export class LocomotionController {
44
- readonly clip: AnimationClip;
45
- readonly rootMotionTrack: string;
46
- readonly strideAmplitude: number;
47
- readonly rootMotionScale: number;
48
- readonly state: LocomotionControllerState;
49
- /** Optional foot-IK rig hook; call `footIk.solveFootPlacement(...)` to ground feet. */
50
- readonly footIk: FootIkRig | undefined;
51
-
52
- constructor(options: LocomotionControllerOptions) {
53
- if (!Number.isFinite(options.clip.duration) || options.clip.duration <= 0) {
54
- throw new Error("LocomotionController requires a clip with positive finite duration.");
55
- }
56
- this.clip = options.clip;
57
- this.rootMotionTrack = options.rootMotionTrack ?? "root.position";
58
- this.strideAmplitude = options.strideAmplitude ?? 1;
59
- this.rootMotionScale = options.rootMotionScale ?? 1;
60
- this.footIk = options.footIkRig;
61
- this.state = {
62
- speed: options.speed ?? 1,
63
- inPlace: options.inPlace ?? false,
64
- paused: options.paused ?? false,
65
- pathRadius: options.pathRadius ?? 1
66
- };
67
- }
68
-
69
- setSpeed(speed: number): this {
70
- if (!Number.isFinite(speed) || speed < 0) {
71
- throw new Error("LocomotionController speed must be finite and non-negative.");
72
- }
73
- this.state.speed = speed;
74
- return this;
75
- }
76
-
77
- setInPlace(inPlace: boolean): this {
78
- this.state.inPlace = inPlace;
79
- return this;
80
- }
81
-
82
- setPaused(paused: boolean): this {
83
- this.state.paused = paused;
84
- return this;
85
- }
86
-
87
- setPathRadius(pathRadius: number): this {
88
- if (!Number.isFinite(pathRadius) || pathRadius <= 0) {
89
- throw new Error("LocomotionController pathRadius must be positive and finite.");
90
- }
91
- this.state.pathRadius = pathRadius;
92
- return this;
93
- }
94
-
95
- sample(elapsedSeconds: number): LocomotionControllerSample {
96
- if (!Number.isFinite(elapsedSeconds) || elapsedSeconds < 0) {
97
- throw new Error("LocomotionController elapsed time must be finite and non-negative.");
98
- }
99
- const scaledTime = this.state.paused ? 0 : elapsedSeconds * this.state.speed;
100
- const previousTime = Math.max(0, scaledTime - 1 / 60);
101
- const clipTime = wrapTime(scaledTime, this.clip.duration);
102
- const previousClipTime = wrapTime(previousTime, this.clip.duration);
103
- const target = { position: [0, 0, 0] as [number, number, number] };
104
- const rootMotion = extractRootMotion(this.clip, {
105
- target: this.rootMotionTrack,
106
- fromTime: previousClipTime,
107
- toTime: clipTime,
108
- loop: true
109
- });
110
- applyRootMotion(target, rootMotion, this.state.inPlace ? 0 : this.rootMotionScale);
111
- const rootMotionDistance = this.state.inPlace ? 0 : scaledTime * this.rootMotionScale;
112
- const pathAngle = this.state.inPlace ? -0.9 : rootMotionDistance / Math.max(0.1, this.state.pathRadius);
113
- return {
114
- clipName: this.clip.name,
115
- clipTime,
116
- worldX: this.state.inPlace ? 0 : Math.sin(pathAngle) * this.state.pathRadius,
117
- worldZ: this.state.inPlace ? 0 : Math.cos(pathAngle) * this.state.pathRadius,
118
- heading: this.state.inPlace ? 0 : pathAngle + Math.PI / 2,
119
- stride: Math.sin((clipTime / this.clip.duration) * Math.PI * 2) * this.strideAmplitude,
120
- rootMotionDistance: Math.hypot(target.position[0], target.position[2]) + rootMotionDistance
121
- };
122
- }
123
- }
124
-
125
- export function createRootMotionWalkClip(options: ProceduralWalkClipOptions = {}): AnimationClip {
126
- const name = options.name ?? "a3d-procedural-root-walk";
127
- const duration = options.duration ?? 1.2;
128
- const distance = options.distance ?? 1.28;
129
- const target = options.rootMotionTrack ?? "root.position";
130
- if (!Number.isFinite(duration) || duration <= 0 || !Number.isFinite(distance) || distance <= 0) {
131
- throw new Error("Root-motion walk clip duration and distance must be positive finite values.");
132
- }
133
- return new AnimationClip({
134
- name,
135
- duration,
136
- tracks: [
137
- new AnimationTrack({
138
- target,
139
- valueType: "vector3",
140
- keyframes: [
141
- { time: 0, value: [0, 0, 0], interpolation: "linear" },
142
- { time: duration * 0.25, value: [0, 0, distance * 0.25], interpolation: "linear" },
143
- { time: duration * 0.5, value: [0, 0, distance * 0.5], interpolation: "linear" },
144
- { time: duration * 0.75, value: [0, 0, distance * 0.75], interpolation: "linear" },
145
- { time: duration, value: [0, 0, distance], interpolation: "linear" }
146
- ]
147
- })
148
- ]
149
- });
150
- }
151
-
152
- function wrapTime(time: number, duration: number): number {
153
- const wrapped = time % duration;
154
- return wrapped < 0 ? wrapped + duration : wrapped;
155
- }