@driftengine/animation 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ import { blendPoses } from './blend.js';
2
+ import { createPose } from './pose.js';
3
+ export class AnimationStateMachine {
4
+ transitions;
5
+ states = new Map();
6
+ parameters = {};
7
+ active;
8
+ /** The state being left while a fade runs, or null when none is. */
9
+ leaving = null;
10
+ fadeElapsed = 0;
11
+ fadeDuration = 0;
12
+ /** One per side of a fade, claimed at construction so `evaluate` allocates nothing. */
13
+ fromPose;
14
+ toPose;
15
+ /**
16
+ * Each state's own elapsed time.
17
+ *
18
+ * Per state rather than one for the machine, so a looping clip is not restarted every time
19
+ * something else changes — and so a state re-entered later resumes where its own clip was rather
20
+ * than wherever the machine happened to be.
21
+ */
22
+ elapsed = new Map();
23
+ /**
24
+ * @param bind The bind pose, or omitted for one whose channels start at rest.
25
+ *
26
+ * The same reason `BlendTree` takes one: a crossfade calls `blendPoses`, which interpolates
27
+ * every channel, so without a bind pose the two sides of a fade start at zero translation and
28
+ * the figure folds toward its own origin for the length of the transition.
29
+ */
30
+ constructor(states, transitions, jointCount, bind) {
31
+ this.transitions = transitions;
32
+ const first = states[0];
33
+ if (first === undefined)
34
+ throw new Error('AnimationStateMachine: needs at least one state');
35
+ for (const state of states) {
36
+ this.states.set(state.name, state);
37
+ this.elapsed.set(state.name, 0);
38
+ }
39
+ /*
40
+ * Checked here rather than met at the moment a transition fires. A transition naming a state
41
+ * that does not exist is a configuration error with exactly one correct outcome, and finding
42
+ * it at construction is the difference between a message naming the state and a character that
43
+ * silently never leaves an animation.
44
+ */
45
+ for (const transition of transitions) {
46
+ for (const name of [transition.from, transition.to]) {
47
+ if (!this.states.has(name)) {
48
+ throw new Error(`AnimationStateMachine: a transition names state "${name}", which was not given`);
49
+ }
50
+ }
51
+ }
52
+ this.active = first;
53
+ this.fromPose = createPose(jointCount);
54
+ this.toPose = createPose(jointCount);
55
+ if (bind !== undefined) {
56
+ for (const pose of [this.fromPose, this.toPose]) {
57
+ pose.translation.set(bind.translation.subarray(0, pose.translation.length));
58
+ pose.rotation.set(bind.rotation.subarray(0, pose.rotation.length));
59
+ pose.scale.set(bind.scale.subarray(0, pose.scale.length));
60
+ }
61
+ }
62
+ }
63
+ get current() {
64
+ return this.active.name;
65
+ }
66
+ /** Whether a crossfade is running. A caller wanting to gate input on one asks this. */
67
+ get transitioning() {
68
+ return this.leaving !== null;
69
+ }
70
+ /**
71
+ * Set a parameter the transitions read.
72
+ *
73
+ * Unlike `BlendTree.set` this accepts any name: the predicates are the consumer's own functions
74
+ * and this class cannot know which keys they read. The tree below it still refuses a name no node
75
+ * declares, which is where a typo that matters is caught.
76
+ */
77
+ set(parameter, value) {
78
+ this.parameters[parameter] = value;
79
+ }
80
+ /**
81
+ * Advance by a caller-supplied step: the fade, the active state's clock, and any transition
82
+ * whose condition now holds.
83
+ */
84
+ advance(dtSec) {
85
+ if (this.leaving === null)
86
+ this.start();
87
+ this.elapsed.set(this.active.name, (this.elapsed.get(this.active.name) ?? 0) + dtSec);
88
+ if (this.leaving === null)
89
+ return;
90
+ this.elapsed.set(this.leaving.name, (this.elapsed.get(this.leaving.name) ?? 0) + dtSec);
91
+ /*
92
+ * **The frame that triggers a transition advances its fade too.** Starting the fade at zero
93
+ * and leaving it there until the next call is a one-frame stall at the top of every
94
+ * transition — invisible at 60 Hz on a long fade and a whole transition on a short one, which
95
+ * is the case a fade is shortest for.
96
+ */
97
+ this.fadeElapsed += dtSec;
98
+ /*
99
+ * A running fade completes; it is not re-evaluated against the condition that started it. A
100
+ * transition interruptible by its own trigger would stutter whenever that parameter sat on its
101
+ * threshold — which is exactly where a speed parameter spends its time.
102
+ */
103
+ if (this.fadeElapsed >= this.fadeDuration)
104
+ this.leaving = null;
105
+ }
106
+ /** Take the first transition out of the active state whose condition holds. */
107
+ start() {
108
+ for (const transition of this.transitions) {
109
+ if (transition.from !== this.active.name)
110
+ continue;
111
+ if (!transition.when(this.parameters))
112
+ continue;
113
+ const next = this.states.get(transition.to);
114
+ if (next === undefined)
115
+ continue;
116
+ this.leaving = this.active;
117
+ this.active = next;
118
+ this.elapsed.set(next.name, 0);
119
+ this.fadeElapsed = 0;
120
+ this.fadeDuration = transition.durationSec;
121
+ /*
122
+ * A zero-duration transition is a switch. Ended here rather than divided by below, because
123
+ * the division is what would produce the NaN — and a NaN weight reaches the palette and
124
+ * takes every vertex it touches.
125
+ */
126
+ if (this.fadeDuration <= 0)
127
+ this.leaving = null;
128
+ return;
129
+ }
130
+ }
131
+ /** The current pose, crossfaded if a transition is running. Allocates nothing. */
132
+ evaluate(out) {
133
+ const activeTime = this.elapsed.get(this.active.name) ?? 0;
134
+ if (this.leaving === null) {
135
+ this.active.tree.evaluate(activeTime, out);
136
+ return;
137
+ }
138
+ this.leaving.tree.evaluate(this.elapsed.get(this.leaving.name) ?? 0, this.fromPose);
139
+ this.active.tree.evaluate(activeTime, this.toPose);
140
+ blendPoses(this.fromPose, this.toPose, this.fadeElapsed / this.fadeDuration, out);
141
+ }
142
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@driftengine/animation",
3
+ "version": "3.61.0",
4
+ "description": "Skeletons, clips, poses and the graphs over them: sampling as a pure function of a caller-supplied time",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "drift-source": "./src/index.ts",
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json",
16
+ "./*": "./*"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "!src/**/*.test.mjs",
23
+ "!src/**/__snapshots__",
24
+ "README.md",
25
+ "LICENSE",
26
+ "NOTICE"
27
+ ],
28
+ "sideEffects": false,
29
+ "dependencies": {
30
+ "@driftengine/drft": "3.61.0"
31
+ },
32
+ "peerDependencies": {
33
+ "@driftengine/core": "3.61.0"
34
+ },
35
+ "author": "Drift Technologies",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/drftrun/driftengine.git",
39
+ "directory": "packages/animation"
40
+ },
41
+ "homepage": "https://github.com/drftrun/driftengine#readme",
42
+ "bugs": "https://github.com/drftrun/driftengine/issues",
43
+ "keywords": [
44
+ "driftengine",
45
+ "3d",
46
+ "webgl",
47
+ "webgpu",
48
+ "typescript",
49
+ "skeletal-animation",
50
+ "blend-tree",
51
+ "inverse-kinematics",
52
+ "morph-targets",
53
+ "skinning"
54
+ ],
55
+ "engines": {
56
+ "node": ">=22.12.0"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
60
+ }
61
+ }
package/src/blend.ts ADDED
@@ -0,0 +1,191 @@
1
+ import type { Pose } from './pose.ts';
2
+
3
+ /**
4
+ * Combining poses: a blend between two, an additive layer over one, and a joint written directly.
5
+ *
6
+ * All three write into a caller-owned pose and allocate nothing, because every one of them runs
7
+ * per character per frame. All three are pure functions of their inputs — no clock, no RNG — which
8
+ * is the determinism contract `sampleClip` establishes and which a graph over clips has to keep.
9
+ */
10
+
11
+ /**
12
+ * Blend `a` toward `b` by `t`, into `out`.
13
+ *
14
+ * **Clamped rather than extrapolated.** A weight past the ends puts limbs outside their range,
15
+ * which reads as a broken rig rather than as a weight out of bounds — the same reasoning
16
+ * `sampleClip` holds at a track's ends for. What it costs is that a caller cannot deliberately
17
+ * overshoot; what would make it wrong is somebody wanting to, and the honest answer then is a
18
+ * function that says so in its name.
19
+ *
20
+ * Safe when `out` is also `a` or `b`: every component is read before it is written.
21
+ */
22
+ export function blendPoses(a: Pose, b: Pose, t: number, out: Pose): void {
23
+ const weight = t < 0 ? 0 : t > 1 ? 1 : t;
24
+ const joints = out.rotation.length / 4;
25
+
26
+ for (let j = 0; j < joints; j++) {
27
+ const v = j * 3;
28
+ for (let c = 0; c < 3; c++) {
29
+ const from = a.translation[v + c] as number;
30
+ out.translation[v + c] = from + ((b.translation[v + c] as number) - from) * weight;
31
+ const scaleFrom = a.scale[v + c] as number;
32
+ out.scale[v + c] = scaleFrom + ((b.scale[v + c] as number) - scaleFrom) * weight;
33
+ }
34
+ slerpInto(a.rotation, j * 4, b.rotation, j * 4, weight, out.rotation, j * 4);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Apply `delta` on top of `base`, scaled by `weight`, into `out`.
40
+ *
41
+ * **A delta, not a target**, and the difference is the whole of what additive is for: a wave laid
42
+ * over a walk has to move the arm relative to wherever the walk put it rather than replace it. So
43
+ * translation adds, rotation composes, and **scale multiplies** — a scale delta of 1 means
44
+ * unchanged, where adding would make it double.
45
+ *
46
+ * Weight zero is the base untouched, which is what lets a layer fade in from nothing.
47
+ */
48
+ export function addPose(base: Pose, delta: Pose, weight: number, out: Pose): void {
49
+ const w = weight < 0 ? 0 : weight;
50
+ const joints = out.rotation.length / 4;
51
+
52
+ for (let j = 0; j < joints; j++) {
53
+ const v = j * 3;
54
+ for (let c = 0; c < 3; c++) {
55
+ out.translation[v + c] =
56
+ (base.translation[v + c] as number) + (delta.translation[v + c] as number) * w;
57
+ /* One at weight zero, the delta's own factor at weight one, interpolated between. */
58
+ const factor = 1 + ((delta.scale[v + c] as number) - 1) * w;
59
+ out.scale[v + c] = (base.scale[v + c] as number) * factor;
60
+ }
61
+
62
+ /*
63
+ * The delta's rotation is scaled by sliding it away from identity along the shorter arc, then
64
+ * composed onto the base. Scaling by slerping from identity rather than by multiplying the
65
+ * components is what keeps a half-weight delta a half *rotation* instead of a shortened
66
+ * quaternion that renormalises to the whole of it.
67
+ */
68
+ slerpInto(IDENTITY, 0, delta.rotation, j * 4, w > 1 ? 1 : w, SCALED, 0);
69
+ multiplyInto(base.rotation, j * 4, SCALED, 0, out.rotation, j * 4);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Write one joint's transform straight into a pose.
75
+ *
76
+ * What a solver drives. Track B's ragdolls need a pose to be *data* rather than only the result of
77
+ * sampling, and this is the whole of that requirement — stated here so the seam exists before the
78
+ * track that needs it, rather than being retrofitted around a closed type.
79
+ */
80
+ export function setJoint(
81
+ pose: Pose,
82
+ joint: number,
83
+ translation: ArrayLike<number>,
84
+ rotation: ArrayLike<number>,
85
+ scale: ArrayLike<number>,
86
+ ): void {
87
+ const v = joint * 3;
88
+ const r = joint * 4;
89
+ for (let c = 0; c < 3; c++) {
90
+ pose.translation[v + c] = translation[c] as number;
91
+ pose.scale[v + c] = scale[c] as number;
92
+ }
93
+ for (let c = 0; c < 4; c++) pose.rotation[r + c] = rotation[c] as number;
94
+ }
95
+
96
+ /**
97
+ * Spherical interpolation between two quaternions held in flat arrays, along the shorter arc.
98
+ *
99
+ * The same arithmetic `clip.ts` performs between two keys, over two poses instead — and with the
100
+ * same hemisphere correction, for the same reason: a quaternion and its negation are one
101
+ * orientation, so two poses can be numerically far apart while being geometrically close, and
102
+ * without the flip a limb rotates the long way through the body.
103
+ *
104
+ * Not shared with `clip.ts` because that one reads a keyframe stride and this one reads a joint
105
+ * stride; the arithmetic between them is eight lines, and a shared function taking four offsets
106
+ * would be harder to read than either. What would make that wrong is a third caller.
107
+ */
108
+ function slerpInto(
109
+ a: ArrayLike<number>,
110
+ aAt: number,
111
+ b: ArrayLike<number>,
112
+ bAt: number,
113
+ t: number,
114
+ out: Float32Array,
115
+ outAt: number,
116
+ ): void {
117
+ let ax = a[aAt] as number;
118
+ let ay = a[aAt + 1] as number;
119
+ let az = a[aAt + 2] as number;
120
+ let aw = a[aAt + 3] as number;
121
+ const bx = b[bAt] as number;
122
+ const by = b[bAt + 1] as number;
123
+ const bz = b[bAt + 2] as number;
124
+ const bw = b[bAt + 3] as number;
125
+
126
+ let dot = ax * bx + ay * by + az * bz + aw * bw;
127
+ if (dot < 0) {
128
+ ax = -ax;
129
+ ay = -ay;
130
+ az = -az;
131
+ aw = -aw;
132
+ dot = -dot;
133
+ }
134
+
135
+ let s0: number;
136
+ let s1: number;
137
+ /* Nearly parallel falls back to a straight blend: `acos` at or past 1 is 0 or NaN, and dividing
138
+ by `sin(0)` is the NaN that reaches the palette and takes every vertex the joint touches. */
139
+ if (dot > 0.9995) {
140
+ s0 = 1 - t;
141
+ s1 = t;
142
+ } else {
143
+ const theta = Math.acos(dot);
144
+ const sinTheta = Math.sin(theta);
145
+ s0 = Math.sin((1 - t) * theta) / sinTheta;
146
+ s1 = Math.sin(t * theta) / sinTheta;
147
+ }
148
+
149
+ let x = s0 * ax + s1 * bx;
150
+ let y = s0 * ay + s1 * by;
151
+ let z = s0 * az + s1 * bz;
152
+ let w = s0 * aw + s1 * bw;
153
+ const length = Math.hypot(x, y, z, w);
154
+ if (length > 0) {
155
+ x /= length;
156
+ y /= length;
157
+ z /= length;
158
+ w /= length;
159
+ }
160
+ out[outAt] = x;
161
+ out[outAt + 1] = y;
162
+ out[outAt + 2] = z;
163
+ out[outAt + 3] = w;
164
+ }
165
+
166
+ /** Quaternion product, in flat arrays. Safe when `out` overlaps either input. */
167
+ function multiplyInto(
168
+ a: ArrayLike<number>,
169
+ aAt: number,
170
+ b: ArrayLike<number>,
171
+ bAt: number,
172
+ out: Float32Array,
173
+ outAt: number,
174
+ ): void {
175
+ const ax = a[aAt] as number;
176
+ const ay = a[aAt + 1] as number;
177
+ const az = a[aAt + 2] as number;
178
+ const aw = a[aAt + 3] as number;
179
+ const bx = b[bAt] as number;
180
+ const by = b[bAt + 1] as number;
181
+ const bz = b[bAt + 2] as number;
182
+ const bw = b[bAt + 3] as number;
183
+ out[outAt] = aw * bx + ax * bw + ay * bz - az * by;
184
+ out[outAt + 1] = aw * by - ax * bz + ay * bw + az * bx;
185
+ out[outAt + 2] = aw * bz + ax * by - ay * bx + az * bw;
186
+ out[outAt + 3] = aw * bw - ax * bx - ay * by - az * bz;
187
+ }
188
+
189
+ /* Module scope, claimed once: `addPose` runs per character per frame. */
190
+ const IDENTITY = new Float32Array([0, 0, 0, 1]);
191
+ const SCALED = new Float32Array(4);
@@ -0,0 +1,253 @@
1
+ import type { AnimationClip } from './clip.ts';
2
+ import { sampleClip } from './clip.ts';
3
+ import { blendPoses } from './blend.ts';
4
+ import type { Pose } from './pose.ts';
5
+ import { createPose } from './pose.ts';
6
+
7
+ /**
8
+ * A tree of clips blended by parameters the game names.
9
+ *
10
+ * **The parameters are the consumer's own strings, never an engine enumeration**, for the reason
11
+ * the input action maps give: a game's verbs are a game's business, and an engine that enumerated
12
+ * them would be deciding what a character can be doing.
13
+ *
14
+ * Evaluation is a pure function of a caller-supplied time and the current parameters. Nothing here
15
+ * reads a clock, which is the contract `sampleClip` establishes and which a graph over clips has
16
+ * to keep or the replay story ends one layer up.
17
+ *
18
+ * **A parameter is one of two things, and the tree keeps them apart.** A *blend* parameter is a
19
+ * weight: which children a set brackets, how far a lerp has gone. A *clock* is a time: which
20
+ * instant of its own clip a node is sampled at, named by a `clip` node's `clock` and defaulting to
21
+ * the time `evaluate` was given. A name may be one or the other and never both — see `clocks` for
22
+ * why that is refused rather than allowed.
23
+ */
24
+
25
+ export type BlendNode =
26
+ | {
27
+ readonly kind: 'clip';
28
+ readonly clip: AnimationClip;
29
+ /**
30
+ * The parameter whose value is this clip's own time, or omitted for the frame clock.
31
+ *
32
+ * **A tree had one clock for every node until 2026-08-28, and that cannot express the
33
+ * canonical blend space.** A locomotion set is stand, walk, run over one speed parameter —
34
+ * and a stride has to advance with **distance travelled** or the foot slides while the body
35
+ * passes over it, which is the fact `rootMotion` exists for, while an idle has to advance
36
+ * with **time**, because somebody standing still is still breathing. On one clock one of the
37
+ * two is wrong: share the distance and the idle freezes whenever nobody moves, share the time
38
+ * and the walk skates. Reported from outside, where the workaround was two clocks kept by
39
+ * hand and a `blendPoses` per overlay — which gives up the declared graph, the scratch poses
40
+ * claimed at construction, and a state machine's crossfades on top.
41
+ *
42
+ * **The value is a time on the clip's own axis, in seconds**, and what advances it is the
43
+ * caller's business: a stride measured in metres is divided by the metres a cycle covers and
44
+ * multiplied by the cycle's duration, and an angle turned is the same arithmetic. The engine
45
+ * does not know about metres, and a parameter that meant metres here would be the engine
46
+ * deciding what a character is doing.
47
+ *
48
+ * **What it gives up** is inheritance: a clock names one clip, so a subtree of four gait
49
+ * clips names it four times. **What would change that** is a consumer with a subtree deep
50
+ * enough for the repetition to hide a mistake, and the answer then is a clock on an interior
51
+ * node that its children inherit — which is a rule about scope, so it is worth having a
52
+ * reason for rather than adding now.
53
+ */
54
+ readonly clock?: string;
55
+ }
56
+ | {
57
+ readonly kind: 'lerp';
58
+ readonly a: BlendNode;
59
+ readonly b: BlendNode;
60
+ readonly parameter: string;
61
+ }
62
+ | {
63
+ readonly kind: 'oneDimensional';
64
+ readonly children: readonly { readonly at: number; readonly node: BlendNode }[];
65
+ readonly parameter: string;
66
+ };
67
+
68
+ export class BlendTree {
69
+ private readonly parameters = new Map<string, number>();
70
+ /**
71
+ * One scratch pose per interior node, claimed at construction.
72
+ *
73
+ * A tree evaluates depth-first and every interior node needs somewhere to put its two operands,
74
+ * so without these `evaluate` would allocate per node per frame. Keyed by the node object
75
+ * itself, which is stable because a tree is immutable.
76
+ */
77
+ private readonly scratch = new Map<BlendNode, [Pose, Pose]>();
78
+ /**
79
+ * Which names are clocks, so one cannot quietly be both.
80
+ *
81
+ * A parameter is a blend input and a clock is a time; one number doing both jobs is a rig that
82
+ * responds to the wrong dial, which reads as a broken tree rather than as a name used twice.
83
+ * `clock: 'speed'` written while meaning "the speed drives the blend" is the slip this catches.
84
+ */
85
+ private readonly clocks = new Set<string>();
86
+
87
+ /**
88
+ * @param bind The bind pose, or omitted for one whose channels start at rest.
89
+ *
90
+ * **Supply it whenever the clips are rotation-only, which is most of them.** `sampleClip` leaves
91
+ * a channel no track mentions exactly as it found it, so a rotation-only clip preserves whatever
92
+ * translations the pose already held. `blendPoses` cannot do that — it interpolates *every*
93
+ * channel of two poses — so without a bind pose here the scratch poses start at zero translation
94
+ * and every joint collapses onto its parent's origin the moment a tree or a transition is
95
+ * involved. Found by building a demo scene with it: one figure folded in on itself and the other,
96
+ * which happened to go through `retargetPose` instead, did not.
97
+ */
98
+ constructor(
99
+ private readonly root: BlendNode,
100
+ jointCount: number,
101
+ private readonly bind?: Pose,
102
+ ) {
103
+ this.prepare(root, jointCount);
104
+ }
105
+
106
+ /**
107
+ * Set a parameter, refusing a name no node declares.
108
+ *
109
+ * **Loud rather than ignored.** A typo silently does nothing, and what a consumer then sees is
110
+ * an animation that will not respond — a symptom a long way from the misspelling that caused it,
111
+ * and one no test of theirs would catch. What it costs is that a caller cannot set a parameter
112
+ * ahead of building the tree that uses it.
113
+ */
114
+ set(parameter: string, value: number): void {
115
+ if (!this.parameters.has(parameter)) {
116
+ throw new Error(`BlendTree: no node in this tree takes a parameter called "${parameter}"`);
117
+ }
118
+ this.parameters.set(parameter, value);
119
+ }
120
+
121
+ /**
122
+ * Sample the whole tree at `timeSec` into `out`. Allocates nothing.
123
+ *
124
+ * `timeSec` is the clock for every node that did not name one of its own, so a tree of ordinary
125
+ * clips behaves exactly as it did before clocks existed.
126
+ */
127
+ evaluate(timeSec: number, out: Pose): void {
128
+ this.walk(this.root, timeSec, out);
129
+ }
130
+
131
+ /** A scratch pose starting from the bind pose where one was given, or at rest where none was. */
132
+ private blank(jointCount: number): Pose {
133
+ const pose = createPose(jointCount);
134
+ if (this.bind !== undefined) {
135
+ pose.translation.set(this.bind.translation.subarray(0, pose.translation.length));
136
+ pose.rotation.set(this.bind.rotation.subarray(0, pose.rotation.length));
137
+ pose.scale.set(this.bind.scale.subarray(0, pose.scale.length));
138
+ }
139
+ return pose;
140
+ }
141
+
142
+ /** Collect parameter names and claim scratch, once, so evaluation allocates nothing. */
143
+ private prepare(node: BlendNode, jointCount: number): void {
144
+ if (node.kind === 'clip') {
145
+ if (node.clock !== undefined) {
146
+ if (this.parameters.has(node.clock) && !this.clocks.has(node.clock)) {
147
+ throw new Error(
148
+ `BlendTree: "${node.clock}" is a blend parameter in this tree and cannot also be a ` +
149
+ `clock; one number cannot be both a weight and a time`,
150
+ );
151
+ }
152
+ this.clocks.add(node.clock);
153
+ /* Zero, so a tree evaluates before a caller has set anything — the same start every
154
+ blend parameter gets. */
155
+ this.parameters.set(node.clock, this.parameters.get(node.clock) ?? 0);
156
+ }
157
+ return;
158
+ }
159
+
160
+ if (this.clocks.has(node.parameter)) {
161
+ throw new Error(
162
+ `BlendTree: "${node.parameter}" is a clock in this tree and cannot also be a blend ` +
163
+ `parameter; one number cannot be both a time and a weight`,
164
+ );
165
+ }
166
+ this.parameters.set(node.parameter, this.parameters.get(node.parameter) ?? 0);
167
+ this.scratch.set(node, [this.blank(jointCount), this.blank(jointCount)]);
168
+
169
+ if (node.kind === 'lerp') {
170
+ this.prepare(node.a, jointCount);
171
+ this.prepare(node.b, jointCount);
172
+ return;
173
+ }
174
+
175
+ if (node.children.length === 0) {
176
+ throw new Error(
177
+ `BlendTree: the set on "${node.parameter}" has no children; it needs at least one`,
178
+ );
179
+ }
180
+ for (let i = 1; i < node.children.length; i++) {
181
+ const previous = node.children[i - 1]?.at ?? 0;
182
+ const current = node.children[i]?.at ?? 0;
183
+ /*
184
+ * Ascending stops are what makes the bracketing search a walk rather than a sort, and an
185
+ * unsorted set does not fail — it picks a neighbouring pair that is not the nearest one and
186
+ * blends between the wrong two clips, which reads as a rig that responds oddly to a
187
+ * parameter rather than as a misconfigured tree.
188
+ */
189
+ if (current <= previous) {
190
+ throw new Error(
191
+ `BlendTree: the set on "${node.parameter}" has stops ${previous} then ${current}; ` +
192
+ `they must be ascending`,
193
+ );
194
+ }
195
+ }
196
+ for (const child of node.children) this.prepare(child.node, jointCount);
197
+ }
198
+
199
+ private walk(node: BlendNode, timeSec: number, out: Pose): void {
200
+ if (node.kind === 'clip') {
201
+ /* Its own clock where it named one, and the frame clock where it did not — which is what
202
+ every tree written before clocks existed keeps doing, unchanged. */
203
+ const at = node.clock === undefined ? timeSec : (this.parameters.get(node.clock) ?? 0);
204
+ sampleClip(node.clip, at, out);
205
+ return;
206
+ }
207
+
208
+ const scratch = this.scratch.get(node);
209
+ if (scratch === undefined) throw new Error('BlendTree: a node was not prepared');
210
+ const [left, right] = scratch;
211
+ const value = this.parameters.get(node.parameter) ?? 0;
212
+
213
+ if (node.kind === 'lerp') {
214
+ this.walk(node.a, timeSec, left);
215
+ this.walk(node.b, timeSec, right);
216
+ blendPoses(left, right, value, out);
217
+ return;
218
+ }
219
+
220
+ const stops = node.children;
221
+ const first = stops[0];
222
+ const last = stops[stops.length - 1];
223
+ if (first === undefined || last === undefined) throw new Error('BlendTree: an empty set');
224
+
225
+ /*
226
+ * **A shortcut, not the clamp.** Outside the set these walk one child instead of two, which
227
+ * halves the work for a parameter parked at an extreme — a character standing still, which is
228
+ * the common case. The *correctness* is `blendPoses`, which clamps its own weight: deleting
229
+ * these two returns changes no output, only the work done, and perturbing them proved exactly
230
+ * that by leaving every assertion green. The comment said they were the clamp until then.
231
+ */
232
+ if (value <= first.at) {
233
+ this.walk(first.node, timeSec, out);
234
+ return;
235
+ }
236
+ if (value >= last.at) {
237
+ this.walk(last.node, timeSec, out);
238
+ return;
239
+ }
240
+
241
+ let upper = 1;
242
+ while (upper < stops.length - 1 && (stops[upper]?.at ?? 0) < value) upper += 1;
243
+ const lower = stops[upper - 1];
244
+ const higher = stops[upper];
245
+ if (lower === undefined || higher === undefined) throw new Error('BlendTree: a gap in a set');
246
+
247
+ const span = higher.at - lower.at;
248
+ const t = span > 0 ? (value - lower.at) / span : 0;
249
+ this.walk(lower.node, timeSec, left);
250
+ this.walk(higher.node, timeSec, right);
251
+ blendPoses(left, right, t, out);
252
+ }
253
+ }