@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.
- package/package.json +36 -3
- package/src/AnimationAction.ts +0 -166
- package/src/AnimationClip.ts +0 -63
- package/src/AnimationClipEvents.ts +0 -501
- package/src/AnimationClipRegistry.ts +0 -404
- package/src/AnimationController.ts +0 -1017
- package/src/AnimationEvents.ts +0 -34
- package/src/AnimationLayer.ts +0 -73
- package/src/AnimationMixer.ts +0 -402
- package/src/AnimationStateGraph.ts +0 -105
- package/src/AnimationStateMachine.ts +0 -265
- package/src/AnimationTrack.ts +0 -176
- package/src/BlendTree.ts +0 -89
- package/src/Bone.ts +0 -40
- package/src/CrowdAnimation.ts +0 -25
- package/src/ECSAnimationBridge.ts +0 -30
- package/src/FighterAnimationAdapter.ts +0 -124
- package/src/FootIk.ts +0 -251
- package/src/HumanoidBoneInference.ts +0 -323
- package/src/HumanoidRetargeting.ts +0 -700
- package/src/IK.ts +0 -117
- package/src/Inertialization.ts +0 -269
- package/src/Keyframe.ts +0 -159
- package/src/LocomotionController.ts +0 -155
- package/src/LocomotionKit.ts +0 -89
- package/src/MotionMatchingFixtures.ts +0 -254
- package/src/MotionQuality.ts +0 -182
- package/src/RigQuality.ts +0 -101
- package/src/RootMotion.ts +0 -124
- package/src/SceneAnimationBridge.ts +0 -80
- package/src/SecondaryAnimationFixtures.ts +0 -210
- package/src/Skeleton.ts +0 -78
- package/src/Skinning.ts +0 -18
- package/src/SpringBones.ts +0 -243
- package/src/browser-index.ts +0 -66
- package/src/index.ts +0 -66
- package/src/library/performanceStateGraph.ts +0 -173
- package/src/library/standardHumanoidClips.ts +0 -659
- package/src/threejs-compatibility/AnimationAction.ts +0 -27
- package/src/threejs-compatibility/AnimationClip.ts +0 -16
- package/src/threejs-compatibility/AnimationDiagnostics.ts +0 -42
- package/src/threejs-compatibility/AnimationMixer.ts +0 -24
- package/src/threejs-compatibility/MorphTargetMixer.ts +0 -16
- package/src/threejs-compatibility/Skeleton.ts +0 -9
- package/src/threejs-compatibility/SkinnedMesh.ts +0 -12
- package/src/threejs-compatibility/index.ts +0 -11
package/src/LocomotionKit.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { BlendTree1D, type BlendTreeWeight } from "./BlendTree.js";
|
|
2
|
-
import { createLocomotionAnimationStateGraph } from "./AnimationStateGraph.js";
|
|
3
|
-
import type { AnimationStateBlend, AnimationStateMachine } from "./AnimationStateMachine.js";
|
|
4
|
-
import type { FootIkRig } from "./FootIk.js";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Composable locomotion kit: maps a scalar movement speed into a discrete locomotion state
|
|
8
|
-
* (idle/walk/run) plus blended clip weights, using the real `BlendTree1D` and
|
|
9
|
-
* `createLocomotionAnimationStateGraph` primitives. This is the deterministic, browser-free
|
|
10
|
-
* core a `character-controller` template wires to a physics `CharacterController`, input, and
|
|
11
|
-
* an `AnimationController` crossfade.
|
|
12
|
-
*/
|
|
13
|
-
export interface LocomotionKitOptions<TClipId extends string = string> {
|
|
14
|
-
readonly idleClip: TClipId;
|
|
15
|
-
readonly walkClip: TClipId;
|
|
16
|
-
readonly runClip: TClipId;
|
|
17
|
-
/** Speed at which the walk clip reaches full weight (default 1). */
|
|
18
|
-
readonly walkSpeed?: number;
|
|
19
|
-
/** Speed at which the run clip reaches full weight (default 4). */
|
|
20
|
-
readonly runSpeed?: number;
|
|
21
|
-
/** Speed above which the character is considered moving (default 0.05). */
|
|
22
|
-
readonly movingThreshold?: number;
|
|
23
|
-
/** Speed at/above which the character is considered running (default = runSpeed, so state stays coherent with the blend). */
|
|
24
|
-
readonly runningThreshold?: number;
|
|
25
|
-
/** Optional foot-IK rig so locomotion can ground feet on uneven terrain (no foot sliding). */
|
|
26
|
-
readonly footIkRig?: FootIkRig;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface LocomotionKitSample<TClipId extends string = string> {
|
|
30
|
-
readonly speed: number;
|
|
31
|
-
readonly state: string;
|
|
32
|
-
readonly moving: boolean;
|
|
33
|
-
readonly running: boolean;
|
|
34
|
-
readonly clipWeights: ReadonlyArray<{ readonly clip: TClipId; readonly weight: number }>;
|
|
35
|
-
/**
|
|
36
|
-
* Inertialized blend between the previously-active discrete state and the current one. When the
|
|
37
|
-
* state graph switches (e.g. walk→run), the previous state's clip fades out on a critically-damped
|
|
38
|
-
* curve instead of snapping — momentum-preserving idle↔walk↔run transitions.
|
|
39
|
-
*/
|
|
40
|
-
readonly stateTransition: AnimationStateBlend;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface LocomotionKit<TClipId extends string = string> {
|
|
44
|
-
readonly graph: AnimationStateMachine;
|
|
45
|
-
/** Optional foot-IK rig hook; call `footIk.solveFootPlacement(...)` to ground feet. */
|
|
46
|
-
readonly footIk: FootIkRig | undefined;
|
|
47
|
-
/** Blend clip weights for a speed (sum ~= 1), independent of the discrete state graph. */
|
|
48
|
-
blendWeights(speed: number): ReadonlyArray<BlendTreeWeight<TClipId>>;
|
|
49
|
-
/** Advance the state graph and produce the full locomotion sample for a speed. */
|
|
50
|
-
sample(speed: number, delta?: number): LocomotionKitSample<TClipId>;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function createLocomotionKit<TClipId extends string = string>(options: LocomotionKitOptions<TClipId>): LocomotionKit<TClipId> {
|
|
54
|
-
const walkSpeed = options.walkSpeed ?? 1;
|
|
55
|
-
const runSpeed = options.runSpeed ?? 4;
|
|
56
|
-
const movingThreshold = options.movingThreshold ?? 0.05;
|
|
57
|
-
const runningThreshold = options.runningThreshold ?? runSpeed;
|
|
58
|
-
|
|
59
|
-
const tree = new BlendTree1D<TClipId>([
|
|
60
|
-
{ value: options.idleClip, threshold: 0 },
|
|
61
|
-
{ value: options.walkClip, threshold: walkSpeed },
|
|
62
|
-
{ value: options.runClip, threshold: runSpeed }
|
|
63
|
-
]);
|
|
64
|
-
const graph = createLocomotionAnimationStateGraph();
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
graph,
|
|
68
|
-
footIk: options.footIkRig,
|
|
69
|
-
blendWeights(speed: number) {
|
|
70
|
-
return tree.weights(Math.max(0, speed));
|
|
71
|
-
},
|
|
72
|
-
sample(speed: number, delta = 1 / 30): LocomotionKitSample<TClipId> {
|
|
73
|
-
const clamped = Math.max(0, speed);
|
|
74
|
-
const moving = clamped > movingThreshold;
|
|
75
|
-
const running = clamped >= runningThreshold;
|
|
76
|
-
graph.setParameter("isMoving", moving);
|
|
77
|
-
graph.setParameter("isRunning", running);
|
|
78
|
-
const state = graph.update(delta);
|
|
79
|
-
return {
|
|
80
|
-
speed: clamped,
|
|
81
|
-
state,
|
|
82
|
-
moving,
|
|
83
|
-
running,
|
|
84
|
-
clipWeights: tree.weights(clamped).map((weight) => ({ clip: weight.value, weight: weight.weight })),
|
|
85
|
-
stateTransition: graph.stateBlend()
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
};
|
|
89
|
-
}
|
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
import type { Vec3 } from "./Keyframe.js";
|
|
2
|
-
|
|
3
|
-
export interface MotionMatchingFixtureOptions {
|
|
4
|
-
readonly currentPosition?: Vec3;
|
|
5
|
-
readonly moveDirection?: Vec3;
|
|
6
|
-
readonly facingDirection?: Vec3;
|
|
7
|
-
readonly speed?: number;
|
|
8
|
-
readonly elapsedSeconds?: number;
|
|
9
|
-
readonly previousPoseId?: string;
|
|
10
|
-
readonly seed?: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface MotionMatchingTrajectorySample {
|
|
14
|
-
readonly time: number;
|
|
15
|
-
readonly position: Vec3;
|
|
16
|
-
readonly direction: Vec3;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface MotionMatchingPoseCandidate {
|
|
20
|
-
readonly id: string;
|
|
21
|
-
readonly clip: "idle" | "walk" | "run" | "strafe" | "turn" | "jump";
|
|
22
|
-
readonly time: number;
|
|
23
|
-
readonly tags: readonly string[];
|
|
24
|
-
readonly rootVelocity: Vec3;
|
|
25
|
-
readonly footLeft: Vec3;
|
|
26
|
-
readonly footRight: Vec3;
|
|
27
|
-
readonly trajectory: readonly MotionMatchingTrajectorySample[];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface MotionMatchingCandidateScore {
|
|
31
|
-
readonly poseId: string;
|
|
32
|
-
readonly clip: MotionMatchingPoseCandidate["clip"];
|
|
33
|
-
readonly tags: readonly string[];
|
|
34
|
-
readonly velocityCost: number;
|
|
35
|
-
readonly facingCost: number;
|
|
36
|
-
readonly trajectoryCost: number;
|
|
37
|
-
readonly continuityCost: number;
|
|
38
|
-
readonly totalCost: number;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface MotionMatchingFixtureSample {
|
|
42
|
-
readonly id: "external-parity-old-branch-motion-matching-fixture";
|
|
43
|
-
readonly source: "origin-master-motion-matching-system-adapted";
|
|
44
|
-
readonly databasePoseCount: number;
|
|
45
|
-
readonly candidateScores: readonly MotionMatchingCandidateScore[];
|
|
46
|
-
readonly selectedPoseId: string;
|
|
47
|
-
readonly selectedClip: MotionMatchingPoseCandidate["clip"];
|
|
48
|
-
readonly selectedTags: readonly string[];
|
|
49
|
-
readonly selectedTime: number;
|
|
50
|
-
readonly transitioned: boolean;
|
|
51
|
-
readonly blendWeight: number;
|
|
52
|
-
readonly transitionDurationSeconds: number;
|
|
53
|
-
readonly queryTrajectory: readonly MotionMatchingTrajectorySample[];
|
|
54
|
-
readonly querySpeed: number;
|
|
55
|
-
readonly queryFacingAlignment: number;
|
|
56
|
-
readonly bestCost: number;
|
|
57
|
-
readonly secondBestCost: number;
|
|
58
|
-
readonly costMargin: number;
|
|
59
|
-
readonly hash: string;
|
|
60
|
-
readonly claimBoundary: string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const predictionTimes = [0.2, 0.45, 0.75] as const;
|
|
64
|
-
|
|
65
|
-
export function sampleMotionMatchingFixture(options: MotionMatchingFixtureOptions = {}): MotionMatchingFixtureSample {
|
|
66
|
-
const seed = options.seed ?? 0x3d2025;
|
|
67
|
-
if (!Number.isInteger(seed)) throw new RangeError("Motion matching fixture seed must be an integer.");
|
|
68
|
-
const currentPosition = vec3(options.currentPosition ?? [0, 0, 0], "currentPosition");
|
|
69
|
-
const moveDirection = normalize(vec3(options.moveDirection ?? [1, 0, 0.18], "moveDirection"));
|
|
70
|
-
const facingDirection = normalize(vec3(options.facingDirection ?? [1, 0, 0], "facingDirection"));
|
|
71
|
-
const speed = clamp(finite(options.speed ?? 0.74, "speed"), 0, 1);
|
|
72
|
-
const elapsedSeconds = Math.max(0, finite(options.elapsedSeconds ?? 0, "elapsedSeconds"));
|
|
73
|
-
const maxSpeed = 6.2;
|
|
74
|
-
const queryVelocity = scale(moveDirection, speed * maxSpeed);
|
|
75
|
-
const queryTrajectory = predictionTimes.map((time) => ({
|
|
76
|
-
time,
|
|
77
|
-
position: roundVec3(add(currentPosition, scale(queryVelocity, time))),
|
|
78
|
-
direction: roundVec3(normalize(lerpVec3(moveDirection, facingDirection, Math.min(1, time * 0.8))))
|
|
79
|
-
}));
|
|
80
|
-
const database = createMotionDatabase(seed);
|
|
81
|
-
const previousPoseId = options.previousPoseId ?? database[0]?.id ?? "idle-0";
|
|
82
|
-
const scored = database
|
|
83
|
-
.map((candidate) => scoreCandidate(candidate, queryVelocity, facingDirection, queryTrajectory, previousPoseId))
|
|
84
|
-
.sort((left, right) => left.totalCost - right.totalCost);
|
|
85
|
-
const selected = scored[0];
|
|
86
|
-
if (!selected) throw new Error("Motion matching fixture database is empty.");
|
|
87
|
-
const selectedPose = database.find((candidate) => candidate.id === selected.poseId);
|
|
88
|
-
if (!selectedPose) throw new Error("Motion matching fixture selected an unknown pose.");
|
|
89
|
-
const secondBestCost = scored[1]?.totalCost ?? selected.totalCost;
|
|
90
|
-
const transitioned = selected.poseId !== previousPoseId;
|
|
91
|
-
const transitionDurationSeconds = 0.18;
|
|
92
|
-
const blendWeight = transitioned ? clamp(elapsedSeconds / transitionDurationSeconds, 0, 1) : 1;
|
|
93
|
-
return {
|
|
94
|
-
id: "external-parity-old-branch-motion-matching-fixture",
|
|
95
|
-
source: "origin-master-motion-matching-system-adapted",
|
|
96
|
-
databasePoseCount: database.length,
|
|
97
|
-
candidateScores: scored.slice(0, 6),
|
|
98
|
-
selectedPoseId: selected.poseId,
|
|
99
|
-
selectedClip: selected.clip,
|
|
100
|
-
selectedTags: selected.tags,
|
|
101
|
-
selectedTime: selectedPose.time,
|
|
102
|
-
transitioned,
|
|
103
|
-
blendWeight: Number(blendWeight.toFixed(4)),
|
|
104
|
-
transitionDurationSeconds,
|
|
105
|
-
queryTrajectory,
|
|
106
|
-
querySpeed: Number(length(queryVelocity).toFixed(4)),
|
|
107
|
-
queryFacingAlignment: Number(dot(moveDirection, facingDirection).toFixed(4)),
|
|
108
|
-
bestCost: selected.totalCost,
|
|
109
|
-
secondBestCost,
|
|
110
|
-
costMargin: Number((secondBestCost - selected.totalCost).toFixed(5)),
|
|
111
|
-
hash: hashMotionFixture(scored, queryTrajectory),
|
|
112
|
-
claimBoundary: "Deterministic pose-database scoring, trajectory prediction, tag filtering, continuity cost, and transition telemetry adapted from the old motion-matching system; this is bounded runtime evidence, not a full animation database, inertialization, pose application, foot locking, or Unity/Unreal animation middleware parity."
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function createMotionDatabase(seed: number): readonly MotionMatchingPoseCandidate[] {
|
|
117
|
-
const clips: readonly MotionMatchingPoseCandidate["clip"][] = ["idle", "walk", "run", "strafe", "turn", "jump"];
|
|
118
|
-
return clips.flatMap((clip, clipIndex) => {
|
|
119
|
-
const speed = clip === "idle" ? 0 : clip === "walk" ? 2.1 : clip === "run" ? 5.8 : clip === "strafe" ? 3.2 : clip === "turn" ? 0.9 : 3.8;
|
|
120
|
-
const angle = clip === "strafe" ? Math.PI / 2 : clip === "turn" ? 0.62 : seeded01(seed + clipIndex * 41) * 0.14 - 0.07;
|
|
121
|
-
const direction: Vec3 = [Math.cos(angle), 0, Math.sin(angle)];
|
|
122
|
-
return [0, 1, 2].map((poseIndex) => {
|
|
123
|
-
const time = Number((poseIndex * 0.18 + seeded01(seed + clipIndex * 101 + poseIndex) * 0.04).toFixed(4));
|
|
124
|
-
const phase = poseIndex / 3;
|
|
125
|
-
return {
|
|
126
|
-
id: `${clip}-${poseIndex}`,
|
|
127
|
-
clip,
|
|
128
|
-
time,
|
|
129
|
-
tags: clip === "idle" ? ["idle"] : clip === "jump" ? ["action", "airborne"] : ["locomotion", clip],
|
|
130
|
-
rootVelocity: roundVec3(scale(direction, speed * (0.92 + phase * 0.12))),
|
|
131
|
-
footLeft: roundVec3([-0.16, Math.sin(phase * Math.PI * 2) * 0.08, 0.08]),
|
|
132
|
-
footRight: roundVec3([0.16, Math.sin((phase + 0.5) * Math.PI * 2) * 0.08, -0.08]),
|
|
133
|
-
trajectory: predictionTimes.map((predictionTime) => ({
|
|
134
|
-
time: predictionTime,
|
|
135
|
-
position: roundVec3(scale(direction, speed * predictionTime)),
|
|
136
|
-
direction: roundVec3(direction)
|
|
137
|
-
}))
|
|
138
|
-
};
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function scoreCandidate(
|
|
144
|
-
candidate: MotionMatchingPoseCandidate,
|
|
145
|
-
queryVelocity: Vec3,
|
|
146
|
-
facingDirection: Vec3,
|
|
147
|
-
queryTrajectory: readonly MotionMatchingTrajectorySample[],
|
|
148
|
-
previousPoseId: string
|
|
149
|
-
): MotionMatchingCandidateScore {
|
|
150
|
-
const velocityCost = distance(candidate.rootVelocity, queryVelocity);
|
|
151
|
-
const facingCost = 1 - clamp(dot(normalize(candidate.trajectory[0]?.direction ?? [1, 0, 0]), facingDirection), -1, 1);
|
|
152
|
-
const trajectoryCost = queryTrajectory.reduce((sum, query, index) => {
|
|
153
|
-
const candidateSample = candidate.trajectory[index] ?? candidate.trajectory[candidate.trajectory.length - 1]!;
|
|
154
|
-
return sum + distance(candidateSample.position, subtract(query.position, queryTrajectory[0]?.position ?? [0, 0, 0]));
|
|
155
|
-
}, 0) / Math.max(1, queryTrajectory.length);
|
|
156
|
-
const continuityCost = candidate.id === previousPoseId ? 0 : candidate.clip === previousPoseId.split("-")[0] ? 0.12 : 0.34;
|
|
157
|
-
const totalCost = velocityCost * 0.46 + facingCost * 1.2 + trajectoryCost * 0.38 + continuityCost;
|
|
158
|
-
return {
|
|
159
|
-
poseId: candidate.id,
|
|
160
|
-
clip: candidate.clip,
|
|
161
|
-
tags: candidate.tags,
|
|
162
|
-
velocityCost: Number(velocityCost.toFixed(5)),
|
|
163
|
-
facingCost: Number(facingCost.toFixed(5)),
|
|
164
|
-
trajectoryCost: Number(trajectoryCost.toFixed(5)),
|
|
165
|
-
continuityCost: Number(continuityCost.toFixed(5)),
|
|
166
|
-
totalCost: Number(totalCost.toFixed(5))
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function hashMotionFixture(scores: readonly MotionMatchingCandidateScore[], trajectory: readonly MotionMatchingTrajectorySample[]): string {
|
|
171
|
-
let hash = 0x811c9dc5;
|
|
172
|
-
for (const score of scores) {
|
|
173
|
-
for (const char of `${score.poseId}:${score.totalCost}:${score.velocityCost}`) {
|
|
174
|
-
hash ^= char.charCodeAt(0);
|
|
175
|
-
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
for (const sample of trajectory) {
|
|
179
|
-
for (const value of [sample.time, ...sample.position, ...sample.direction]) {
|
|
180
|
-
const scaled = Math.round(value * 10_000);
|
|
181
|
-
hash ^= scaled & 0xff;
|
|
182
|
-
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
183
|
-
hash ^= (scaled >>> 8) & 0xff;
|
|
184
|
-
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return hash.toString(16).padStart(8, "0");
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function vec3(value: readonly number[], label: string): Vec3 {
|
|
191
|
-
if (value.length !== 3 || value.some((entry) => !Number.isFinite(entry))) {
|
|
192
|
-
throw new RangeError(`Motion matching fixture ${label} must contain three finite numbers.`);
|
|
193
|
-
}
|
|
194
|
-
return [value[0]!, value[1]!, value[2]!];
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function roundVec3(value: Vec3): Vec3 {
|
|
198
|
-
return [Number(value[0].toFixed(4)), Number(value[1].toFixed(4)), Number(value[2].toFixed(4))];
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function add(left: Vec3, right: Vec3): Vec3 {
|
|
202
|
-
return [left[0] + right[0], left[1] + right[1], left[2] + right[2]];
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function subtract(left: Vec3, right: Vec3): Vec3 {
|
|
206
|
-
return [left[0] - right[0], left[1] - right[1], left[2] - right[2]];
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function scale(value: Vec3, scalar: number): Vec3 {
|
|
210
|
-
return [value[0] * scalar, value[1] * scalar, value[2] * scalar];
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function lerpVec3(left: Vec3, right: Vec3, weight: number): Vec3 {
|
|
214
|
-
return [
|
|
215
|
-
left[0] + (right[0] - left[0]) * weight,
|
|
216
|
-
left[1] + (right[1] - left[1]) * weight,
|
|
217
|
-
left[2] + (right[2] - left[2]) * weight
|
|
218
|
-
];
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function dot(left: Vec3, right: Vec3): number {
|
|
222
|
-
return left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function length(value: Vec3): number {
|
|
226
|
-
return Math.hypot(value[0], value[1], value[2]);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function distance(left: Vec3, right: Vec3): number {
|
|
230
|
-
return length(subtract(left, right));
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function normalize(value: Vec3): Vec3 {
|
|
234
|
-
const valueLength = length(value);
|
|
235
|
-
if (valueLength <= 1e-6) return [1, 0, 0];
|
|
236
|
-
return scale(value, 1 / valueLength);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function finite(value: number, label: string): number {
|
|
240
|
-
if (!Number.isFinite(value)) throw new RangeError(`Motion matching fixture ${label} must be finite.`);
|
|
241
|
-
return value;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function clamp(value: number, min: number, max: number): number {
|
|
245
|
-
return Math.max(min, Math.min(max, value));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function seeded01(seed: number): number {
|
|
249
|
-
let value = seed >>> 0;
|
|
250
|
-
value ^= value << 13;
|
|
251
|
-
value ^= value >>> 17;
|
|
252
|
-
value ^= value << 5;
|
|
253
|
-
return ((value >>> 0) % 10_000) / 10_000;
|
|
254
|
-
}
|
package/src/MotionQuality.ts
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
export interface AnimationMotionSample {
|
|
2
|
-
readonly timeSeconds: number;
|
|
3
|
-
readonly tracksApplied?: number;
|
|
4
|
-
readonly skinningPalettesUpdated?: number;
|
|
5
|
-
readonly stride?: number;
|
|
6
|
-
readonly animatedSubjects?: number;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface AnimationMotionQualityReport {
|
|
10
|
-
readonly sampleCount: number;
|
|
11
|
-
readonly timeRangeSeconds: number;
|
|
12
|
-
readonly activeTrackFrames: number;
|
|
13
|
-
readonly activeSkinningFrames: number;
|
|
14
|
-
readonly animatedSubjectFrames: number;
|
|
15
|
-
readonly averageStride: number;
|
|
16
|
-
readonly poseDiversityScore: number;
|
|
17
|
-
readonly healthy: boolean;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface AnimationAnimationMotionQualityReport extends AnimationMotionQualityReport {
|
|
21
|
-
readonly kind: "animation-animation-motion-quality";
|
|
22
|
-
readonly staticPoseRejected: boolean;
|
|
23
|
-
readonly issues: readonly string[];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface AnimationMotionQualityOptions {
|
|
27
|
-
readonly maxSamples?: number;
|
|
28
|
-
readonly minimumSamples?: number;
|
|
29
|
-
readonly minimumTimeRangeSeconds?: number;
|
|
30
|
-
readonly minimumPoseDiversityScore?: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export class AnimationMotionQualityTracker {
|
|
34
|
-
private readonly maxSamples: number;
|
|
35
|
-
private readonly minimumSamples: number;
|
|
36
|
-
private readonly minimumTimeRangeSeconds: number;
|
|
37
|
-
private readonly minimumPoseDiversityScore: number;
|
|
38
|
-
private readonly samples: AnimationMotionSample[] = [];
|
|
39
|
-
|
|
40
|
-
constructor(options: AnimationMotionQualityOptions = {}) {
|
|
41
|
-
this.maxSamples = positiveInteger(options.maxSamples ?? 180, "maxSamples");
|
|
42
|
-
this.minimumSamples = positiveInteger(options.minimumSamples ?? 8, "minimumSamples");
|
|
43
|
-
this.minimumTimeRangeSeconds = nonNegativeFinite(options.minimumTimeRangeSeconds ?? 0.18, "minimumTimeRangeSeconds");
|
|
44
|
-
this.minimumPoseDiversityScore = nonNegativeFinite(options.minimumPoseDiversityScore ?? 0.08, "minimumPoseDiversityScore");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
record(sample: AnimationMotionSample): AnimationMotionQualityReport {
|
|
48
|
-
validateSample(sample);
|
|
49
|
-
this.samples.push(sample);
|
|
50
|
-
if (this.samples.length > this.maxSamples) {
|
|
51
|
-
this.samples.shift();
|
|
52
|
-
}
|
|
53
|
-
return this.report();
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
report(): AnimationMotionQualityReport {
|
|
57
|
-
return summarizeAnimationMotion(this.samples, {
|
|
58
|
-
minimumSamples: this.minimumSamples,
|
|
59
|
-
minimumTimeRangeSeconds: this.minimumTimeRangeSeconds,
|
|
60
|
-
minimumPoseDiversityScore: this.minimumPoseDiversityScore
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
reset(): void {
|
|
65
|
-
this.samples.length = 0;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function summarizeAnimationMotion(
|
|
70
|
-
samples: readonly AnimationMotionSample[],
|
|
71
|
-
options: Pick<AnimationMotionQualityOptions, "minimumSamples" | "minimumTimeRangeSeconds" | "minimumPoseDiversityScore"> = {}
|
|
72
|
-
): AnimationMotionQualityReport {
|
|
73
|
-
const minimumSamples = positiveInteger(options.minimumSamples ?? 8, "minimumSamples");
|
|
74
|
-
const minimumTimeRangeSeconds = nonNegativeFinite(options.minimumTimeRangeSeconds ?? 0.18, "minimumTimeRangeSeconds");
|
|
75
|
-
const minimumPoseDiversityScore = nonNegativeFinite(options.minimumPoseDiversityScore ?? 0.08, "minimumPoseDiversityScore");
|
|
76
|
-
const ordered = samples.slice().sort((left, right) => left.timeSeconds - right.timeSeconds);
|
|
77
|
-
const sampleCount = ordered.length;
|
|
78
|
-
if (sampleCount === 0) {
|
|
79
|
-
return emptyReport();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const firstTime = ordered[0]!.timeSeconds;
|
|
83
|
-
const lastTime = ordered[sampleCount - 1]!.timeSeconds;
|
|
84
|
-
const timeRangeSeconds = Math.max(0, lastTime - firstTime);
|
|
85
|
-
const activeTrackFrames = ordered.filter((sample) => (sample.tracksApplied ?? 0) > 0).length;
|
|
86
|
-
const activeSkinningFrames = ordered.filter((sample) => (sample.skinningPalettesUpdated ?? 0) > 0).length;
|
|
87
|
-
const animatedSubjectFrames = ordered.filter((sample) => (sample.animatedSubjects ?? 0) > 0).length;
|
|
88
|
-
const strides = ordered.map((sample) => Math.abs(sample.stride ?? 0));
|
|
89
|
-
const averageStride = strides.reduce((sum, value) => sum + value, 0) / sampleCount;
|
|
90
|
-
const poseDiversityScore = computePoseDiversity(ordered);
|
|
91
|
-
return {
|
|
92
|
-
sampleCount,
|
|
93
|
-
timeRangeSeconds: round(timeRangeSeconds),
|
|
94
|
-
activeTrackFrames,
|
|
95
|
-
activeSkinningFrames,
|
|
96
|
-
animatedSubjectFrames,
|
|
97
|
-
averageStride: round(averageStride),
|
|
98
|
-
poseDiversityScore: round(poseDiversityScore),
|
|
99
|
-
healthy: sampleCount >= minimumSamples
|
|
100
|
-
&& timeRangeSeconds >= minimumTimeRangeSeconds
|
|
101
|
-
&& poseDiversityScore >= minimumPoseDiversityScore
|
|
102
|
-
&& (activeTrackFrames > 0 || activeSkinningFrames > 0 || animatedSubjectFrames > 0)
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function summarizeAnimationAnimationMotion(
|
|
107
|
-
samples: readonly AnimationMotionSample[],
|
|
108
|
-
options: Pick<AnimationMotionQualityOptions, "minimumSamples" | "minimumTimeRangeSeconds" | "minimumPoseDiversityScore"> = {}
|
|
109
|
-
): AnimationAnimationMotionQualityReport {
|
|
110
|
-
const base = summarizeAnimationMotion(samples, options);
|
|
111
|
-
const issues = [
|
|
112
|
-
base.sampleCount < (options.minimumSamples ?? 8) ? "not enough motion samples" : undefined,
|
|
113
|
-
base.timeRangeSeconds < (options.minimumTimeRangeSeconds ?? 0.18) ? "motion time range is too short" : undefined,
|
|
114
|
-
base.poseDiversityScore < (options.minimumPoseDiversityScore ?? 0.08) ? "pose diversity is too low" : undefined,
|
|
115
|
-
base.activeTrackFrames === 0 && base.activeSkinningFrames === 0 && base.animatedSubjectFrames === 0 ? "no active track, skinning, or subject motion" : undefined
|
|
116
|
-
].filter((issue): issue is string => Boolean(issue));
|
|
117
|
-
return {
|
|
118
|
-
...base,
|
|
119
|
-
kind: "animation-animation-motion-quality",
|
|
120
|
-
staticPoseRejected: issues.length > 0,
|
|
121
|
-
issues,
|
|
122
|
-
healthy: issues.length === 0
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function computePoseDiversity(samples: readonly AnimationMotionSample[]): number {
|
|
127
|
-
if (samples.length < 2) return 0;
|
|
128
|
-
let diversity = 0;
|
|
129
|
-
for (let index = 1; index < samples.length; index += 1) {
|
|
130
|
-
const previous = samples[index - 1]!;
|
|
131
|
-
const current = samples[index]!;
|
|
132
|
-
diversity += Math.abs(current.timeSeconds - previous.timeSeconds);
|
|
133
|
-
diversity += Math.abs((current.tracksApplied ?? 0) - (previous.tracksApplied ?? 0)) * 0.01;
|
|
134
|
-
diversity += Math.abs((current.skinningPalettesUpdated ?? 0) - (previous.skinningPalettesUpdated ?? 0)) * 0.01;
|
|
135
|
-
diversity += Math.abs((current.stride ?? 0) - (previous.stride ?? 0));
|
|
136
|
-
diversity += Math.abs((current.animatedSubjects ?? 0) - (previous.animatedSubjects ?? 0)) * 0.02;
|
|
137
|
-
}
|
|
138
|
-
return diversity;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function validateSample(sample: AnimationMotionSample): void {
|
|
142
|
-
nonNegativeFinite(sample.timeSeconds, "timeSeconds");
|
|
143
|
-
optionalNonNegativeFinite(sample.tracksApplied, "tracksApplied");
|
|
144
|
-
optionalNonNegativeFinite(sample.skinningPalettesUpdated, "skinningPalettesUpdated");
|
|
145
|
-
optionalNonNegativeFinite(sample.stride, "stride");
|
|
146
|
-
optionalNonNegativeFinite(sample.animatedSubjects, "animatedSubjects");
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function optionalNonNegativeFinite(value: number | undefined, label: string): void {
|
|
150
|
-
if (value !== undefined) nonNegativeFinite(value, label);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function nonNegativeFinite(value: number, label: string): number {
|
|
154
|
-
if (!Number.isFinite(value) || value < 0) {
|
|
155
|
-
throw new RangeError(`Animation motion ${label} must be finite and non-negative.`);
|
|
156
|
-
}
|
|
157
|
-
return value;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function positiveInteger(value: number, label: string): number {
|
|
161
|
-
if (!Number.isInteger(value) || value <= 0) {
|
|
162
|
-
throw new RangeError(`Animation motion ${label} must be a positive integer.`);
|
|
163
|
-
}
|
|
164
|
-
return value;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function emptyReport(): AnimationMotionQualityReport {
|
|
168
|
-
return {
|
|
169
|
-
sampleCount: 0,
|
|
170
|
-
timeRangeSeconds: 0,
|
|
171
|
-
activeTrackFrames: 0,
|
|
172
|
-
activeSkinningFrames: 0,
|
|
173
|
-
animatedSubjectFrames: 0,
|
|
174
|
-
averageStride: 0,
|
|
175
|
-
poseDiversityScore: 0,
|
|
176
|
-
healthy: false
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function round(value: number): number {
|
|
181
|
-
return Number(value.toFixed(4));
|
|
182
|
-
}
|
package/src/RigQuality.ts
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
HUMANOID_BONES,
|
|
3
|
-
type HumanoidBoneName,
|
|
4
|
-
type HumanoidRigDefinition
|
|
5
|
-
} from "./HumanoidRetargeting.js";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Body-acting suitability grade for an (inferred) humanoid rig.
|
|
9
|
-
*
|
|
10
|
-
* - **A** — full humanoid: spine/neck/head, both arms with forearms + hands, both legs with lower
|
|
11
|
-
* legs + feet. Safe for the full performance vocabulary (gesture, point, walk/run, foot work).
|
|
12
|
-
* - **B** — partial humanoid: enough of the body to act (spine/head + both arms or both legs) but
|
|
13
|
-
* missing limbs/extremities; the director should avoid the motions that need the missing chain.
|
|
14
|
-
* - **C** — mascot / sparse (~8 mappable nodes): a torso + head + stub arms; head/torso acting only,
|
|
15
|
-
* no real limb motion.
|
|
16
|
-
* - **D** — not suitable for body acting: no usable skeleton (root/props only).
|
|
17
|
-
*/
|
|
18
|
-
export type RigGrade = "A" | "B" | "C" | "D";
|
|
19
|
-
|
|
20
|
-
export interface RigQualityReport {
|
|
21
|
-
readonly grade: RigGrade;
|
|
22
|
-
/** Human-readable justifications for the grade (what was present / missing). */
|
|
23
|
-
readonly reasons: string[];
|
|
24
|
-
readonly hasLegs: boolean;
|
|
25
|
-
readonly hasKnees: boolean;
|
|
26
|
-
readonly hasAnkles: boolean;
|
|
27
|
-
readonly hasFeet: boolean;
|
|
28
|
-
/** Count of canonical humanoid bones that resolve to a real node. */
|
|
29
|
-
readonly mappedBoneCount: number;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** A bone counts as mapped when it resolves to a non-empty target node name. */
|
|
33
|
-
function isMapped(rig: HumanoidRigDefinition, bone: HumanoidBoneName): boolean {
|
|
34
|
-
const binding = rig.bones[bone];
|
|
35
|
-
return Boolean(binding && typeof binding.name === "string" && binding.name.length > 0);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Grade a rig for body-acting suitability. Pure + deterministic — driven only by which canonical
|
|
40
|
-
* {@link HUMANOID_BONES} resolve to nodes, so it works on the output of `inferHumanoidRig` for any
|
|
41
|
-
* skeleton naming convention (Mixamo / Blender / UE / sparse mascot / rich humanoid).
|
|
42
|
-
*/
|
|
43
|
-
export function gradeRig(rig: HumanoidRigDefinition): RigQualityReport {
|
|
44
|
-
const has = (bone: HumanoidBoneName): boolean => isMapped(rig, bone);
|
|
45
|
-
const mappedBoneCount = HUMANOID_BONES.reduce((n, bone) => (has(bone) ? n + 1 : n), 0);
|
|
46
|
-
|
|
47
|
-
// Limb-chain capability (mirrors FootIk's notion of a usable leg chain).
|
|
48
|
-
const hasLegs = (has("leftUpperLeg") || has("leftLowerLeg")) && (has("rightUpperLeg") || has("rightLowerLeg"));
|
|
49
|
-
const hasKnees = has("leftLowerLeg") && has("rightLowerLeg");
|
|
50
|
-
const hasAnkles = has("leftFoot") && has("rightFoot");
|
|
51
|
-
const hasFeet = hasAnkles; // foot node == ankle/foot joint in the canonical skeleton.
|
|
52
|
-
|
|
53
|
-
// Spine / head axis.
|
|
54
|
-
const hasSpine = has("spine") || has("chest") || has("upperChest");
|
|
55
|
-
const hasHead = has("head");
|
|
56
|
-
const hasNeck = has("neck");
|
|
57
|
-
|
|
58
|
-
// Arm chains.
|
|
59
|
-
const hasBothUpperArms = has("leftUpperArm") && has("rightUpperArm");
|
|
60
|
-
const hasBothForearms = has("leftLowerArm") && has("rightLowerArm");
|
|
61
|
-
const hasBothHands = has("leftHand") && has("rightHand");
|
|
62
|
-
const hasBothLowerLegs = has("leftLowerLeg") && has("rightLowerLeg");
|
|
63
|
-
|
|
64
|
-
const reasons: string[] = [];
|
|
65
|
-
reasons.push(`${mappedBoneCount} of ${HUMANOID_BONES.length} canonical humanoid bones mapped`);
|
|
66
|
-
|
|
67
|
-
// --- D: no usable skeleton --------------------------------------------------------------------
|
|
68
|
-
// Without a spine/head axis OR both upper arms there is nothing to act with.
|
|
69
|
-
if (mappedBoneCount < 5 || (!hasSpine && !hasHead) || (!hasBothUpperArms && !hasLegs)) {
|
|
70
|
-
reasons.push("not enough body structure to drive body acting (D)");
|
|
71
|
-
if (!hasSpine && !hasHead) reasons.push("missing spine/head axis");
|
|
72
|
-
if (!hasBothUpperArms) reasons.push("missing a pair of upper arms");
|
|
73
|
-
if (!hasLegs) reasons.push("missing a usable pair of legs");
|
|
74
|
-
return { grade: "D", reasons, hasLegs, hasKnees, hasAnkles, hasFeet, mappedBoneCount };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// --- A: full humanoid -------------------------------------------------------------------------
|
|
78
|
-
const fullUpperBody = hasSpine && hasNeck && hasHead && hasBothUpperArms && hasBothForearms && hasBothHands;
|
|
79
|
-
const fullLowerBody = hasLegs && hasBothLowerLegs && hasFeet;
|
|
80
|
-
if (mappedBoneCount >= 18 && fullUpperBody && fullLowerBody) {
|
|
81
|
-
reasons.push("full humanoid: spine/neck/head + both arms (forearms+hands) + both legs (lower legs+feet) (A)");
|
|
82
|
-
return { grade: "A", reasons, hasLegs, hasKnees, hasAnkles, hasFeet, mappedBoneCount };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// --- B: partial humanoid ----------------------------------------------------------------------
|
|
86
|
-
// Enough body to act: a spine/head axis plus at least a full pair of arms OR a full pair of legs.
|
|
87
|
-
const usableArms = hasBothUpperArms && hasBothForearms;
|
|
88
|
-
const usableLegs = hasLegs && hasBothLowerLegs;
|
|
89
|
-
if ((hasSpine || hasHead) && (usableArms || usableLegs) && mappedBoneCount >= 10) {
|
|
90
|
-
reasons.push("partial humanoid: body acting possible but a limb chain or extremities are missing (B)");
|
|
91
|
-
if (!fullUpperBody) reasons.push("incomplete upper body (forearms/hands/neck)");
|
|
92
|
-
if (!fullLowerBody) reasons.push("incomplete lower body (lower legs/feet)");
|
|
93
|
-
return { grade: "B", reasons, hasLegs, hasKnees, hasAnkles, hasFeet, mappedBoneCount };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// --- C: mascot / sparse -----------------------------------------------------------------------
|
|
97
|
-
reasons.push("mascot/sparse rig: head + torso acting only, no real limb chains (C)");
|
|
98
|
-
if (!usableArms) reasons.push("no usable forearm chain on both arms");
|
|
99
|
-
if (!usableLegs) reasons.push("no usable lower-leg chain on both legs");
|
|
100
|
-
return { grade: "C", reasons, hasLegs, hasKnees, hasAnkles, hasFeet, mappedBoneCount };
|
|
101
|
-
}
|