@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,171 @@
1
+ import { sampleClip } from './clip.js';
2
+ import { blendPoses } from './blend.js';
3
+ import { createPose } from './pose.js';
4
+ export class BlendTree {
5
+ root;
6
+ bind;
7
+ parameters = new Map();
8
+ /**
9
+ * One scratch pose per interior node, claimed at construction.
10
+ *
11
+ * A tree evaluates depth-first and every interior node needs somewhere to put its two operands,
12
+ * so without these `evaluate` would allocate per node per frame. Keyed by the node object
13
+ * itself, which is stable because a tree is immutable.
14
+ */
15
+ scratch = new Map();
16
+ /**
17
+ * Which names are clocks, so one cannot quietly be both.
18
+ *
19
+ * A parameter is a blend input and a clock is a time; one number doing both jobs is a rig that
20
+ * responds to the wrong dial, which reads as a broken tree rather than as a name used twice.
21
+ * `clock: 'speed'` written while meaning "the speed drives the blend" is the slip this catches.
22
+ */
23
+ clocks = new Set();
24
+ /**
25
+ * @param bind The bind pose, or omitted for one whose channels start at rest.
26
+ *
27
+ * **Supply it whenever the clips are rotation-only, which is most of them.** `sampleClip` leaves
28
+ * a channel no track mentions exactly as it found it, so a rotation-only clip preserves whatever
29
+ * translations the pose already held. `blendPoses` cannot do that — it interpolates *every*
30
+ * channel of two poses — so without a bind pose here the scratch poses start at zero translation
31
+ * and every joint collapses onto its parent's origin the moment a tree or a transition is
32
+ * involved. Found by building a demo scene with it: one figure folded in on itself and the other,
33
+ * which happened to go through `retargetPose` instead, did not.
34
+ */
35
+ constructor(root, jointCount, bind) {
36
+ this.root = root;
37
+ this.bind = bind;
38
+ this.prepare(root, jointCount);
39
+ }
40
+ /**
41
+ * Set a parameter, refusing a name no node declares.
42
+ *
43
+ * **Loud rather than ignored.** A typo silently does nothing, and what a consumer then sees is
44
+ * an animation that will not respond — a symptom a long way from the misspelling that caused it,
45
+ * and one no test of theirs would catch. What it costs is that a caller cannot set a parameter
46
+ * ahead of building the tree that uses it.
47
+ */
48
+ set(parameter, value) {
49
+ if (!this.parameters.has(parameter)) {
50
+ throw new Error(`BlendTree: no node in this tree takes a parameter called "${parameter}"`);
51
+ }
52
+ this.parameters.set(parameter, value);
53
+ }
54
+ /**
55
+ * Sample the whole tree at `timeSec` into `out`. Allocates nothing.
56
+ *
57
+ * `timeSec` is the clock for every node that did not name one of its own, so a tree of ordinary
58
+ * clips behaves exactly as it did before clocks existed.
59
+ */
60
+ evaluate(timeSec, out) {
61
+ this.walk(this.root, timeSec, out);
62
+ }
63
+ /** A scratch pose starting from the bind pose where one was given, or at rest where none was. */
64
+ blank(jointCount) {
65
+ const pose = createPose(jointCount);
66
+ if (this.bind !== undefined) {
67
+ pose.translation.set(this.bind.translation.subarray(0, pose.translation.length));
68
+ pose.rotation.set(this.bind.rotation.subarray(0, pose.rotation.length));
69
+ pose.scale.set(this.bind.scale.subarray(0, pose.scale.length));
70
+ }
71
+ return pose;
72
+ }
73
+ /** Collect parameter names and claim scratch, once, so evaluation allocates nothing. */
74
+ prepare(node, jointCount) {
75
+ if (node.kind === 'clip') {
76
+ if (node.clock !== undefined) {
77
+ if (this.parameters.has(node.clock) && !this.clocks.has(node.clock)) {
78
+ throw new Error(`BlendTree: "${node.clock}" is a blend parameter in this tree and cannot also be a ` +
79
+ `clock; one number cannot be both a weight and a time`);
80
+ }
81
+ this.clocks.add(node.clock);
82
+ /* Zero, so a tree evaluates before a caller has set anything — the same start every
83
+ blend parameter gets. */
84
+ this.parameters.set(node.clock, this.parameters.get(node.clock) ?? 0);
85
+ }
86
+ return;
87
+ }
88
+ if (this.clocks.has(node.parameter)) {
89
+ throw new Error(`BlendTree: "${node.parameter}" is a clock in this tree and cannot also be a blend ` +
90
+ `parameter; one number cannot be both a time and a weight`);
91
+ }
92
+ this.parameters.set(node.parameter, this.parameters.get(node.parameter) ?? 0);
93
+ this.scratch.set(node, [this.blank(jointCount), this.blank(jointCount)]);
94
+ if (node.kind === 'lerp') {
95
+ this.prepare(node.a, jointCount);
96
+ this.prepare(node.b, jointCount);
97
+ return;
98
+ }
99
+ if (node.children.length === 0) {
100
+ throw new Error(`BlendTree: the set on "${node.parameter}" has no children; it needs at least one`);
101
+ }
102
+ for (let i = 1; i < node.children.length; i++) {
103
+ const previous = node.children[i - 1]?.at ?? 0;
104
+ const current = node.children[i]?.at ?? 0;
105
+ /*
106
+ * Ascending stops are what makes the bracketing search a walk rather than a sort, and an
107
+ * unsorted set does not fail — it picks a neighbouring pair that is not the nearest one and
108
+ * blends between the wrong two clips, which reads as a rig that responds oddly to a
109
+ * parameter rather than as a misconfigured tree.
110
+ */
111
+ if (current <= previous) {
112
+ throw new Error(`BlendTree: the set on "${node.parameter}" has stops ${previous} then ${current}; ` +
113
+ `they must be ascending`);
114
+ }
115
+ }
116
+ for (const child of node.children)
117
+ this.prepare(child.node, jointCount);
118
+ }
119
+ walk(node, timeSec, out) {
120
+ if (node.kind === 'clip') {
121
+ /* Its own clock where it named one, and the frame clock where it did not — which is what
122
+ every tree written before clocks existed keeps doing, unchanged. */
123
+ const at = node.clock === undefined ? timeSec : (this.parameters.get(node.clock) ?? 0);
124
+ sampleClip(node.clip, at, out);
125
+ return;
126
+ }
127
+ const scratch = this.scratch.get(node);
128
+ if (scratch === undefined)
129
+ throw new Error('BlendTree: a node was not prepared');
130
+ const [left, right] = scratch;
131
+ const value = this.parameters.get(node.parameter) ?? 0;
132
+ if (node.kind === 'lerp') {
133
+ this.walk(node.a, timeSec, left);
134
+ this.walk(node.b, timeSec, right);
135
+ blendPoses(left, right, value, out);
136
+ return;
137
+ }
138
+ const stops = node.children;
139
+ const first = stops[0];
140
+ const last = stops[stops.length - 1];
141
+ if (first === undefined || last === undefined)
142
+ throw new Error('BlendTree: an empty set');
143
+ /*
144
+ * **A shortcut, not the clamp.** Outside the set these walk one child instead of two, which
145
+ * halves the work for a parameter parked at an extreme — a character standing still, which is
146
+ * the common case. The *correctness* is `blendPoses`, which clamps its own weight: deleting
147
+ * these two returns changes no output, only the work done, and perturbing them proved exactly
148
+ * that by leaving every assertion green. The comment said they were the clamp until then.
149
+ */
150
+ if (value <= first.at) {
151
+ this.walk(first.node, timeSec, out);
152
+ return;
153
+ }
154
+ if (value >= last.at) {
155
+ this.walk(last.node, timeSec, out);
156
+ return;
157
+ }
158
+ let upper = 1;
159
+ while (upper < stops.length - 1 && (stops[upper]?.at ?? 0) < value)
160
+ upper += 1;
161
+ const lower = stops[upper - 1];
162
+ const higher = stops[upper];
163
+ if (lower === undefined || higher === undefined)
164
+ throw new Error('BlendTree: a gap in a set');
165
+ const span = higher.at - lower.at;
166
+ const t = span > 0 ? (value - lower.at) / span : 0;
167
+ this.walk(lower.node, timeSec, left);
168
+ this.walk(higher.node, timeSec, right);
169
+ blendPoses(left, right, t, out);
170
+ }
171
+ }
package/dist/clip.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { AnimationClip, JointTrack } from '@driftengine/drft';
2
+ import type { Pose } from './pose.ts';
3
+ /**
4
+ * Keyframed clips, and sampling one into a pose.
5
+ *
6
+ * **Sampling is a pure function of a time the caller supplies, and that is a contract rather than
7
+ * a style.** Nothing on this path reads `performance.now`, `Date.now` or `Math.random`, which is
8
+ * what lets a recorded intent stream replay bit-identically on another machine. `AGENTS.md` calls
9
+ * this the determinism boundary and `docs/ARCHITECTURE.md` §5 names animation specifically,
10
+ * because it is the subsystem where every other engine reaches for a clock.
11
+ *
12
+ * What it costs is that a caller has to carry its own time. What would make it wrong is nothing —
13
+ * a clip that wants wall-clock time can be handed one; the reverse is not recoverable.
14
+ */
15
+ export type { AnimationClip, JointTrack, TrackPath } from '@driftengine/drft';
16
+ /**
17
+ * Sample every track into `out`. Pure, allocation-free, and it reads no clock.
18
+ *
19
+ * **A joint no track mentions is left exactly as it was found**, which is what makes layering
20
+ * work: a clip animating one arm can be sampled over a pose already holding the rest of the body.
21
+ * The alternative — resting untouched joints — would make every partial clip a full-body clip and
22
+ * silently overwrite whatever it was layered onto.
23
+ */
24
+ export declare function sampleClip(clip: AnimationClip, timeSec: number, out: Pose): void;
25
+ /**
26
+ * Sample one track at a time already brought into range, into `target` at `at`.
27
+ *
28
+ * **Split out of `sampleClip` so root motion reads the same interpolation rather than a second
29
+ * copy of it.** Two implementations of one decision drift, and they drift invisibly when they
30
+ * start identical — which is why this is a shared function and not eight lines repeated in
31
+ * `rootMotion.ts`. Root motion needs one joint's two tracks at four different times a frame, and
32
+ * sampling a whole rig four times to read one joint would be the alternative.
33
+ *
34
+ * `time` is **not** wrapped here: a caller asking for the value at exactly `durationSec` wants
35
+ * the last key held, and wrapping would answer the first instead. `sampleClip` wraps before it
36
+ * calls this; `rootMotion.ts` splits an interval at the loop boundary and asks for both ends.
37
+ */
38
+ export declare function sampleTrack(track: JointTrack, time: number, target: Float32Array, at: number): void;
39
+ /**
40
+ * Bring a time into `[0, duration)`.
41
+ *
42
+ * `%` is not enough: it answers a negative for a negative input, which would read the wrong pair
43
+ * of keys rather than looping. A transition running backwards hands in a negative time, so this
44
+ * is not hypothetical.
45
+ *
46
+ * Exported for `rootMotion.ts`, which needs the same wrap and the whole-cycle count that goes
47
+ * with it — a second definition of "which instant of the clip is this" would be the drift this
48
+ * repository has been bitten by twice.
49
+ */
50
+ export declare function wrapTime(timeSec: number, durationSec: number): number;
package/dist/clip.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Sample every track into `out`. Pure, allocation-free, and it reads no clock.
3
+ *
4
+ * **A joint no track mentions is left exactly as it was found**, which is what makes layering
5
+ * work: a clip animating one arm can be sampled over a pose already holding the rest of the body.
6
+ * The alternative — resting untouched joints — would make every partial clip a full-body clip and
7
+ * silently overwrite whatever it was layered onto.
8
+ */
9
+ export function sampleClip(clip, timeSec, out) {
10
+ const time = wrapTime(timeSec, clip.durationSec);
11
+ for (const track of clip.tracks) {
12
+ const width = track.path === 'rotation' ? 4 : 3;
13
+ const target = track.path === 'translation'
14
+ ? out.translation
15
+ : track.path === 'rotation'
16
+ ? out.rotation
17
+ : out.scale;
18
+ sampleTrack(track, time, target, track.joint * width);
19
+ }
20
+ }
21
+ /**
22
+ * Sample one track at a time already brought into range, into `target` at `at`.
23
+ *
24
+ * **Split out of `sampleClip` so root motion reads the same interpolation rather than a second
25
+ * copy of it.** Two implementations of one decision drift, and they drift invisibly when they
26
+ * start identical — which is why this is a shared function and not eight lines repeated in
27
+ * `rootMotion.ts`. Root motion needs one joint's two tracks at four different times a frame, and
28
+ * sampling a whole rig four times to read one joint would be the alternative.
29
+ *
30
+ * `time` is **not** wrapped here: a caller asking for the value at exactly `durationSec` wants
31
+ * the last key held, and wrapping would answer the first instead. `sampleClip` wraps before it
32
+ * calls this; `rootMotion.ts` splits an interval at the loop boundary and asks for both ends.
33
+ */
34
+ export function sampleTrack(track, time, target, at) {
35
+ const keys = track.times.length;
36
+ if (keys === 0)
37
+ return;
38
+ const width = track.path === 'rotation' ? 4 : 3;
39
+ /*
40
+ * A single key is a constant, handled before the search so nothing divides by a zero interval.
41
+ * Held at both ends rather than extrapolated, for the same reason a blend tree clamps: a pose
42
+ * extrapolated past its last key puts limbs outside their range, which reads as a broken rig
43
+ * rather than as a time out of bounds.
44
+ */
45
+ if (keys === 1 || time <= track.times[0]) {
46
+ copyKey(track, 0, width, target, at);
47
+ return;
48
+ }
49
+ if (time >= track.times[keys - 1]) {
50
+ copyKey(track, keys - 1, width, target, at);
51
+ return;
52
+ }
53
+ const upper = upperBound(track.times, time);
54
+ const lower = upper - 1;
55
+ const t0 = track.times[lower];
56
+ const t1 = track.times[upper];
57
+ /* t1 > t0 here: equal adjacent times would have been caught by one of the two holds above
58
+ only at the ends, so guard the interior case rather than assuming a well-formed clip. */
59
+ const span = t1 - t0;
60
+ const alpha = span > 0 ? (time - t0) / span : 0;
61
+ if (track.path === 'rotation') {
62
+ slerpKeys(track, lower, upper, alpha, target, at);
63
+ return;
64
+ }
65
+ const a = lower * width;
66
+ const b = upper * width;
67
+ for (let c = 0; c < width; c++) {
68
+ const from = track.values[a + c];
69
+ const to = track.values[b + c];
70
+ target[at + c] = from + (to - from) * alpha;
71
+ }
72
+ }
73
+ /**
74
+ * Bring a time into `[0, duration)`.
75
+ *
76
+ * `%` is not enough: it answers a negative for a negative input, which would read the wrong pair
77
+ * of keys rather than looping. A transition running backwards hands in a negative time, so this
78
+ * is not hypothetical.
79
+ *
80
+ * Exported for `rootMotion.ts`, which needs the same wrap and the whole-cycle count that goes
81
+ * with it — a second definition of "which instant of the clip is this" would be the drift this
82
+ * repository has been bitten by twice.
83
+ */
84
+ export function wrapTime(timeSec, durationSec) {
85
+ if (!(durationSec > 0))
86
+ return 0;
87
+ return timeSec - Math.floor(timeSec / durationSec) * durationSec;
88
+ }
89
+ /** The index of the first key strictly after `time`. Binary, because a clip can be long. */
90
+ function upperBound(times, time) {
91
+ let low = 0;
92
+ let high = times.length - 1;
93
+ while (low < high) {
94
+ const mid = (low + high) >> 1;
95
+ if (times[mid] > time)
96
+ high = mid;
97
+ else
98
+ low = mid + 1;
99
+ }
100
+ return low;
101
+ }
102
+ function copyKey(track, key, width, target, at) {
103
+ const from = key * width;
104
+ for (let c = 0; c < width; c++)
105
+ target[at + c] = track.values[from + c];
106
+ }
107
+ /**
108
+ * Spherical interpolation between two rotation keys, along the **shorter** arc.
109
+ *
110
+ * The hemisphere correction is the whole of it: a quaternion and its negation are the same
111
+ * orientation, so two keys can be numerically far apart while being geometrically close. Without
112
+ * the flip a limb rotates the long way through the body, which is the most recognisable animation
113
+ * defect there is.
114
+ *
115
+ * Written out rather than calling `quat.slerp` so nothing has to be copied into a scratch
116
+ * quaternion and back: this runs per track per joint per frame, and the values live in a flat
117
+ * array the caller owns.
118
+ */
119
+ function slerpKeys(track, lower, upper, alpha, target, at) {
120
+ const a = lower * 4;
121
+ const b = upper * 4;
122
+ let ax = track.values[a];
123
+ let ay = track.values[a + 1];
124
+ let az = track.values[a + 2];
125
+ let aw = track.values[a + 3];
126
+ const bx = track.values[b];
127
+ const by = track.values[b + 1];
128
+ const bz = track.values[b + 2];
129
+ const bw = track.values[b + 3];
130
+ let dot = ax * bx + ay * by + az * bz + aw * bw;
131
+ if (dot < 0) {
132
+ ax = -ax;
133
+ ay = -ay;
134
+ az = -az;
135
+ aw = -aw;
136
+ dot = -dot;
137
+ }
138
+ let s0;
139
+ let s1;
140
+ /*
141
+ * Nearly parallel keys fall back to a straight blend, because `Math.acos` of a value at or past
142
+ * 1 is 0 or NaN and dividing by `sin(0)` is the NaN that reaches the palette and takes every
143
+ * vertex the joint touches. The threshold is where the two agree to well within float precision.
144
+ */
145
+ if (dot > 0.9995) {
146
+ s0 = 1 - alpha;
147
+ s1 = alpha;
148
+ }
149
+ else {
150
+ const theta = Math.acos(dot);
151
+ const sinTheta = Math.sin(theta);
152
+ s0 = Math.sin((1 - alpha) * theta) / sinTheta;
153
+ s1 = Math.sin(alpha * theta) / sinTheta;
154
+ }
155
+ let x = s0 * ax + s1 * bx;
156
+ let y = s0 * ay + s1 * by;
157
+ let z = s0 * az + s1 * bz;
158
+ let w = s0 * aw + s1 * bw;
159
+ /* The straight-blend arm above shortens the quaternion, so normalise rather than assume. */
160
+ const length = Math.hypot(x, y, z, w);
161
+ if (length > 0) {
162
+ x /= length;
163
+ y /= length;
164
+ z /= length;
165
+ w /= length;
166
+ }
167
+ target[at] = x;
168
+ target[at + 1] = y;
169
+ target[at + 2] = z;
170
+ target[at + 3] = w;
171
+ }
package/dist/ik.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { Pose } from './pose.ts';
2
+ import type { Skeleton } from './skeleton.ts';
3
+ /**
4
+ * Two-bone inverse kinematics: put a chain's tip on a target, closed form.
5
+ *
6
+ * **Closed form rather than iterative**, because two bones and a target are a triangle and a
7
+ * triangle has an answer. An iterative solver — FABRIK, or gradient descent — earns its keep on
8
+ * longer chains where there is no closed form; spending iterations on a case with an exact
9
+ * solution buys nothing and makes the result depend on how many were run, which a replay cannot
10
+ * tolerate.
11
+ *
12
+ * What it does not do is limits, twist or more than two bones. A knee that should not hyperextend
13
+ * is a constraint this does not know about, and it is a game's decision rather than the engine's —
14
+ * the same line the state machine draws about a character's verbs.
15
+ */
16
+ /**
17
+ * Reach `target` with the chain `root` → `mid` → `tip`, writing the two rotations into `pose`.
18
+ *
19
+ * Returns whether the target was reachable. An unreachable one straightens the chain toward it,
20
+ * which is the behaviour a limb should have: an arm reaching for something too far away extends,
21
+ * it does not give up or fold.
22
+ *
23
+ * `poleHint` decides the one thing the target cannot — which way the joint bends. A world-space
24
+ * point or direction the mid joint should lean toward; without it, a chain that is already
25
+ * straight has no plane to bend in and the elbow would flip unpredictably as a character turned.
26
+ *
27
+ * The skeleton's world matrices are refreshed as it goes, so they are current when this returns.
28
+ */
29
+ export declare function solveTwoBone(skeleton: Skeleton, pose: Pose, root: number, mid: number, tip: number, target: ArrayLike<number>, poleHint: ArrayLike<number>): boolean;