@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.
package/src/clip.ts ADDED
@@ -0,0 +1,223 @@
1
+ import type { AnimationClip, JointTrack } from '@driftengine/drft';
2
+
3
+ import type { Pose } from './pose.ts';
4
+
5
+ /**
6
+ * Keyframed clips, and sampling one into a pose.
7
+ *
8
+ * **Sampling is a pure function of a time the caller supplies, and that is a contract rather than
9
+ * a style.** Nothing on this path reads `performance.now`, `Date.now` or `Math.random`, which is
10
+ * what lets a recorded intent stream replay bit-identically on another machine. `AGENTS.md` calls
11
+ * this the determinism boundary and `docs/ARCHITECTURE.md` §5 names animation specifically,
12
+ * because it is the subsystem where every other engine reaches for a clock.
13
+ *
14
+ * What it costs is that a caller has to carry its own time. What would make it wrong is nothing —
15
+ * a clip that wants wall-clock time can be handed one; the reverse is not recoverable.
16
+ */
17
+
18
+ /*
19
+ * The shapes come from the format package, which is where the `ANIM` chunk's contents belong —
20
+ * and which is what lets `@driftengine/assets` produce a clip without either package depending on
21
+ * the other. Re-exported here so a consumer of the animation barrel imports from one place.
22
+ */
23
+ export type { AnimationClip, JointTrack, TrackPath } from '@driftengine/drft';
24
+
25
+ /**
26
+ * Sample every track into `out`. Pure, allocation-free, and it reads no clock.
27
+ *
28
+ * **A joint no track mentions is left exactly as it was found**, which is what makes layering
29
+ * work: a clip animating one arm can be sampled over a pose already holding the rest of the body.
30
+ * The alternative — resting untouched joints — would make every partial clip a full-body clip and
31
+ * silently overwrite whatever it was layered onto.
32
+ */
33
+ export function sampleClip(clip: AnimationClip, timeSec: number, out: Pose): void {
34
+ const time = wrapTime(timeSec, clip.durationSec);
35
+
36
+ for (const track of clip.tracks) {
37
+ const width = track.path === 'rotation' ? 4 : 3;
38
+ const target =
39
+ track.path === 'translation'
40
+ ? out.translation
41
+ : track.path === 'rotation'
42
+ ? out.rotation
43
+ : out.scale;
44
+ sampleTrack(track, time, target, track.joint * width);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Sample one track at a time already brought into range, into `target` at `at`.
50
+ *
51
+ * **Split out of `sampleClip` so root motion reads the same interpolation rather than a second
52
+ * copy of it.** Two implementations of one decision drift, and they drift invisibly when they
53
+ * start identical — which is why this is a shared function and not eight lines repeated in
54
+ * `rootMotion.ts`. Root motion needs one joint's two tracks at four different times a frame, and
55
+ * sampling a whole rig four times to read one joint would be the alternative.
56
+ *
57
+ * `time` is **not** wrapped here: a caller asking for the value at exactly `durationSec` wants
58
+ * the last key held, and wrapping would answer the first instead. `sampleClip` wraps before it
59
+ * calls this; `rootMotion.ts` splits an interval at the loop boundary and asks for both ends.
60
+ */
61
+ export function sampleTrack(
62
+ track: JointTrack,
63
+ time: number,
64
+ target: Float32Array,
65
+ at: number,
66
+ ): void {
67
+ const keys = track.times.length;
68
+ if (keys === 0) return;
69
+ const width = track.path === 'rotation' ? 4 : 3;
70
+
71
+ /*
72
+ * A single key is a constant, handled before the search so nothing divides by a zero interval.
73
+ * Held at both ends rather than extrapolated, for the same reason a blend tree clamps: a pose
74
+ * extrapolated past its last key puts limbs outside their range, which reads as a broken rig
75
+ * rather than as a time out of bounds.
76
+ */
77
+ if (keys === 1 || time <= (track.times[0] as number)) {
78
+ copyKey(track, 0, width, target, at);
79
+ return;
80
+ }
81
+ if (time >= (track.times[keys - 1] as number)) {
82
+ copyKey(track, keys - 1, width, target, at);
83
+ return;
84
+ }
85
+
86
+ const upper = upperBound(track.times, time);
87
+ const lower = upper - 1;
88
+ const t0 = track.times[lower] as number;
89
+ const t1 = track.times[upper] as number;
90
+ /* t1 > t0 here: equal adjacent times would have been caught by one of the two holds above
91
+ only at the ends, so guard the interior case rather than assuming a well-formed clip. */
92
+ const span = t1 - t0;
93
+ const alpha = span > 0 ? (time - t0) / span : 0;
94
+
95
+ if (track.path === 'rotation') {
96
+ slerpKeys(track, lower, upper, alpha, target, at);
97
+ return;
98
+ }
99
+
100
+ const a = lower * width;
101
+ const b = upper * width;
102
+ for (let c = 0; c < width; c++) {
103
+ const from = track.values[a + c] as number;
104
+ const to = track.values[b + c] as number;
105
+ target[at + c] = from + (to - from) * alpha;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Bring a time into `[0, duration)`.
111
+ *
112
+ * `%` is not enough: it answers a negative for a negative input, which would read the wrong pair
113
+ * of keys rather than looping. A transition running backwards hands in a negative time, so this
114
+ * is not hypothetical.
115
+ *
116
+ * Exported for `rootMotion.ts`, which needs the same wrap and the whole-cycle count that goes
117
+ * with it — a second definition of "which instant of the clip is this" would be the drift this
118
+ * repository has been bitten by twice.
119
+ */
120
+ export function wrapTime(timeSec: number, durationSec: number): number {
121
+ if (!(durationSec > 0)) return 0;
122
+ return timeSec - Math.floor(timeSec / durationSec) * durationSec;
123
+ }
124
+
125
+ /** The index of the first key strictly after `time`. Binary, because a clip can be long. */
126
+ function upperBound(times: Float32Array, time: number): number {
127
+ let low = 0;
128
+ let high = times.length - 1;
129
+ while (low < high) {
130
+ const mid = (low + high) >> 1;
131
+ if ((times[mid] as number) > time) high = mid;
132
+ else low = mid + 1;
133
+ }
134
+ return low;
135
+ }
136
+
137
+ function copyKey(
138
+ track: JointTrack,
139
+ key: number,
140
+ width: number,
141
+ target: Float32Array,
142
+ at: number,
143
+ ): void {
144
+ const from = key * width;
145
+ for (let c = 0; c < width; c++) target[at + c] = track.values[from + c] as number;
146
+ }
147
+
148
+ /**
149
+ * Spherical interpolation between two rotation keys, along the **shorter** arc.
150
+ *
151
+ * The hemisphere correction is the whole of it: a quaternion and its negation are the same
152
+ * orientation, so two keys can be numerically far apart while being geometrically close. Without
153
+ * the flip a limb rotates the long way through the body, which is the most recognisable animation
154
+ * defect there is.
155
+ *
156
+ * Written out rather than calling `quat.slerp` so nothing has to be copied into a scratch
157
+ * quaternion and back: this runs per track per joint per frame, and the values live in a flat
158
+ * array the caller owns.
159
+ */
160
+ function slerpKeys(
161
+ track: JointTrack,
162
+ lower: number,
163
+ upper: number,
164
+ alpha: number,
165
+ target: Float32Array,
166
+ at: number,
167
+ ): void {
168
+ const a = lower * 4;
169
+ const b = upper * 4;
170
+ let ax = track.values[a] as number;
171
+ let ay = track.values[a + 1] as number;
172
+ let az = track.values[a + 2] as number;
173
+ let aw = track.values[a + 3] as number;
174
+ const bx = track.values[b] as number;
175
+ const by = track.values[b + 1] as number;
176
+ const bz = track.values[b + 2] as number;
177
+ const bw = track.values[b + 3] as number;
178
+
179
+ let dot = ax * bx + ay * by + az * bz + aw * bw;
180
+ if (dot < 0) {
181
+ ax = -ax;
182
+ ay = -ay;
183
+ az = -az;
184
+ aw = -aw;
185
+ dot = -dot;
186
+ }
187
+
188
+ let s0: number;
189
+ let s1: number;
190
+ /*
191
+ * Nearly parallel keys fall back to a straight blend, because `Math.acos` of a value at or past
192
+ * 1 is 0 or NaN and dividing by `sin(0)` is the NaN that reaches the palette and takes every
193
+ * vertex the joint touches. The threshold is where the two agree to well within float precision.
194
+ */
195
+ if (dot > 0.9995) {
196
+ s0 = 1 - alpha;
197
+ s1 = alpha;
198
+ } else {
199
+ const theta = Math.acos(dot);
200
+ const sinTheta = Math.sin(theta);
201
+ s0 = Math.sin((1 - alpha) * theta) / sinTheta;
202
+ s1 = Math.sin(alpha * theta) / sinTheta;
203
+ }
204
+
205
+ let x = s0 * ax + s1 * bx;
206
+ let y = s0 * ay + s1 * by;
207
+ let z = s0 * az + s1 * bz;
208
+ let w = s0 * aw + s1 * bw;
209
+
210
+ /* The straight-blend arm above shortens the quaternion, so normalise rather than assume. */
211
+ const length = Math.hypot(x, y, z, w);
212
+ if (length > 0) {
213
+ x /= length;
214
+ y /= length;
215
+ z /= length;
216
+ w /= length;
217
+ }
218
+
219
+ target[at] = x;
220
+ target[at + 1] = y;
221
+ target[at + 2] = z;
222
+ target[at + 3] = w;
223
+ }
package/src/ik.ts ADDED
@@ -0,0 +1,401 @@
1
+ import { mat3, quat, vec3 } from 'gl-matrix';
2
+
3
+ import type { Pose } from './pose.ts';
4
+ import type { Skeleton } from './skeleton.ts';
5
+
6
+ /**
7
+ * Two-bone inverse kinematics: put a chain's tip on a target, closed form.
8
+ *
9
+ * **Closed form rather than iterative**, because two bones and a target are a triangle and a
10
+ * triangle has an answer. An iterative solver — FABRIK, or gradient descent — earns its keep on
11
+ * longer chains where there is no closed form; spending iterations on a case with an exact
12
+ * solution buys nothing and makes the result depend on how many were run, which a replay cannot
13
+ * tolerate.
14
+ *
15
+ * What it does not do is limits, twist or more than two bones. A knee that should not hyperextend
16
+ * is a constraint this does not know about, and it is a game's decision rather than the engine's —
17
+ * the same line the state machine draws about a character's verbs.
18
+ */
19
+
20
+ /**
21
+ * Reach `target` with the chain `root` → `mid` → `tip`, writing the two rotations into `pose`.
22
+ *
23
+ * Returns whether the target was reachable. An unreachable one straightens the chain toward it,
24
+ * which is the behaviour a limb should have: an arm reaching for something too far away extends,
25
+ * it does not give up or fold.
26
+ *
27
+ * `poleHint` decides the one thing the target cannot — which way the joint bends. A world-space
28
+ * point or direction the mid joint should lean toward; without it, a chain that is already
29
+ * straight has no plane to bend in and the elbow would flip unpredictably as a character turned.
30
+ *
31
+ * The skeleton's world matrices are refreshed as it goes, so they are current when this returns.
32
+ */
33
+ export function solveTwoBone(
34
+ skeleton: Skeleton,
35
+ pose: Pose,
36
+ root: number,
37
+ mid: number,
38
+ tip: number,
39
+ target: ArrayLike<number>,
40
+ poleHint: ArrayLike<number>,
41
+ ): boolean {
42
+ const joints = skeleton.joints;
43
+ if (joints[mid]?.parent !== root || joints[tip]?.parent !== mid) {
44
+ throw new Error(
45
+ `solveTwoBone: ${root} → ${mid} → ${tip} is not a parent-to-child chain in this skeleton`,
46
+ );
47
+ }
48
+
49
+ skeleton.applyPose(pose);
50
+ worldPosition(skeleton, root, ROOT_POS);
51
+ worldPosition(skeleton, mid, MID_POS);
52
+ worldPosition(skeleton, tip, TIP_POS);
53
+
54
+ const upper = vec3.distance(ROOT_POS, MID_POS);
55
+ const lower = vec3.distance(MID_POS, TIP_POS);
56
+ vec3.set(TARGET, target[0] as number, target[1] as number, target[2] as number);
57
+ vec3.subtract(TO_TARGET, TARGET, ROOT_POS);
58
+ const reach = vec3.length(TO_TARGET);
59
+
60
+ /*
61
+ * A chain with a zero-length bone, or a target sitting exactly on the root, has no triangle at
62
+ * all — every angle below would come from a division by zero. Answered by leaving the pose alone
63
+ * and reporting failure, because no pose is more correct than the one the animation produced.
64
+ */
65
+ if (upper <= EPSILON || lower <= EPSILON || reach <= EPSILON) return false;
66
+
67
+ /*
68
+ * The reachable band. Past `upper + lower` the chain cannot stretch; inside `|upper - lower|` it
69
+ * cannot fold that far. Both make the law of cosines take an argument outside [-1, 1], where
70
+ * `Math.acos` answers NaN — and a NaN rotation reaches the palette and takes every vertex the
71
+ * joint touches. Clamped rather than refused, so the chain still points the right way.
72
+ */
73
+ const longest = upper + lower;
74
+ const shortest = Math.abs(upper - lower);
75
+ const reachable = reach <= longest && reach >= shortest;
76
+ const distance = Math.min(Math.max(reach, shortest + EPSILON), longest - EPSILON);
77
+
78
+ /*
79
+ * **The mid joint first, and the order is the whole of it.** Rotating the root turns its entire
80
+ * subtree, so it changes where the chain *points* and never how long it is — only the mid joint
81
+ * can set `|tip - root|`. Bending the root first, as the obvious reading of the triangle
82
+ * suggests, leaves the tip at whatever length the pose already had and the aim step then puts it
83
+ * on the wrong point of the right ray. Measured as a tip 1.22 units out where 1.00 was wanted.
84
+ */
85
+ vec3.subtract(TO_ROOT, ROOT_POS, MID_POS);
86
+ vec3.subtract(TO_TIP, TIP_POS, MID_POS);
87
+ const midNow = angleBetween(TO_ROOT, TO_TIP);
88
+ const midWanted = Math.acos(
89
+ clampCosine((upper * upper + lower * lower - distance * distance) / (2 * upper * lower)),
90
+ );
91
+ bendAxis(TO_ROOT, TO_TIP, poleHint, ROOT_POS, AXIS);
92
+ rotateJointAboutWorldAxis(skeleton, pose, mid, AXIS, midWanted - midNow);
93
+ skeleton.applyPose(pose);
94
+
95
+ /*
96
+ * Now the chain is the right length, so aiming it puts the tip on the target exactly — where a
97
+ * chain of the wrong length would land on the right ray at the wrong distance.
98
+ */
99
+ worldPosition(skeleton, root, ROOT_POS);
100
+ worldPosition(skeleton, tip, TIP_POS);
101
+ vec3.subtract(CHAIN_DIR, TIP_POS, ROOT_POS);
102
+ vec3.subtract(TO_TARGET, TARGET, ROOT_POS);
103
+ if (vec3.length(CHAIN_DIR) > EPSILON && vec3.length(TO_TARGET) > EPSILON) {
104
+ vec3.normalize(CHAIN_DIR, CHAIN_DIR);
105
+ vec3.normalize(TO_TARGET, TO_TARGET);
106
+ quat.rotationTo(AIM, CHAIN_DIR, TO_TARGET);
107
+ applyWorldRotation(skeleton, pose, root, AIM);
108
+ skeleton.applyPose(pose);
109
+ }
110
+
111
+ /*
112
+ * **The roll, which is what the pole is actually for.** Aiming leaves the chain free to spin
113
+ * about the line to the target, and every angle of that spin puts the tip in the same place — so
114
+ * nothing above decides which way the elbow points. Without this an elbow flips unpredictably as
115
+ * a character turns, which is the defect a pole hint exists to prevent.
116
+ */
117
+ rollTowardPole(skeleton, pose, root, mid, poleHint);
118
+ skeleton.applyPose(pose);
119
+
120
+ return reachable;
121
+ }
122
+
123
+ /**
124
+ * An axis to bend the mid joint about: **the normal of the chain's own plane**.
125
+ *
126
+ * **Not "anything perpendicular to the bone", which is what this used to say and is what made the
127
+ * solver land short.** The angle asked for above is the interior angle at the mid joint, and
128
+ * rotating by `midWanted - midNow` produces exactly that angle only when the rotation happens *in*
129
+ * the plane the angle is measured in. Any other axis turns the bone out of that plane instead, so
130
+ * the interior angle changes by less than was asked and `|tip - root|` comes out short — after
131
+ * which the aim step lands the tip on the right ray at the wrong distance.
132
+ *
133
+ * The old axis was the bone crossed with the *pole*, and **that is right exactly when the pole lies
134
+ * in the chain's plane** — which is why this survived as long as it did. Every case in `ik.test.ts`
135
+ * put the pole in that plane, and the first test written for the report did too and passed against
136
+ * the unfixed solver. A knee's pole points where the character faces; the plane its leg bends in is
137
+ * wherever the animation left it, and the two coincide only by accident.
138
+ *
139
+ * Reported from outside as a chain reaching its target on the second call and not the first, with
140
+ * the residual falling to zero and staying there — which is a solver converging, and a closed-form
141
+ * solver has nothing to converge. Held now by a leg with a 0.46 m thigh, a 0.44 m shin and a pole
142
+ * off the plane: 0.028 short of its target without this, under a micrometre with it.
143
+ *
144
+ * **The pole is still what decides the bend for a chain with no plane.** A straight chain has
145
+ * `root`, `mid` and `tip` collinear and the cross product below is zero; there is genuinely no
146
+ * plane to bend in, and the pole is the only thing that can choose one. Two non-parallel fallbacks
147
+ * follow it, so a bone parallel to one is not parallel to the other.
148
+ */
149
+ function bendAxis(
150
+ toRoot: vec3,
151
+ toTip: vec3,
152
+ poleHint: ArrayLike<number>,
153
+ rootPos: vec3,
154
+ out: vec3,
155
+ ): void {
156
+ vec3.cross(out, toRoot, toTip);
157
+ if (vec3.length(out) > EPSILON) {
158
+ vec3.normalize(out, out);
159
+ return;
160
+ }
161
+ vec3.set(POLE, poleHint[0] as number, poleHint[1] as number, poleHint[2] as number);
162
+ vec3.subtract(POLE, POLE, rootPos);
163
+ vec3.cross(out, toTip, POLE);
164
+ if (vec3.length(out) > EPSILON) {
165
+ vec3.normalize(out, out);
166
+ return;
167
+ }
168
+ vec3.cross(out, toTip, FALLBACK_POLE);
169
+ if (vec3.length(out) <= EPSILON) vec3.cross(out, toTip, SECOND_FALLBACK);
170
+ vec3.normalize(out, out);
171
+ }
172
+
173
+ /**
174
+ * Spin the chain about the line to the target until the mid joint faces the pole.
175
+ *
176
+ * Both the elbow and the pole are projected onto the plane perpendicular to that line, because the
177
+ * component *along* it is the part the spin cannot change — comparing the unprojected directions
178
+ * would ask for a rotation that does not exist and answer with one that moves the tip.
179
+ */
180
+ function rollTowardPole(
181
+ skeleton: Skeleton,
182
+ pose: Pose,
183
+ root: number,
184
+ mid: number,
185
+ poleHint: ArrayLike<number>,
186
+ ): void {
187
+ worldPosition(skeleton, root, ROOT_POS);
188
+ worldPosition(skeleton, mid, MID_POS);
189
+ worldPosition(skeleton, tipOf(skeleton, mid), TIP_POS);
190
+ vec3.subtract(TO_TARGET, TIP_POS, ROOT_POS);
191
+ if (vec3.length(TO_TARGET) <= EPSILON) return;
192
+ vec3.normalize(TO_TARGET, TO_TARGET);
193
+
194
+ vec3.set(POLE, poleHint[0] as number, poleHint[1] as number, poleHint[2] as number);
195
+ vec3.subtract(POLE, POLE, ROOT_POS);
196
+ vec3.subtract(UPPER_DIR, MID_POS, ROOT_POS);
197
+
198
+ projectOntoPlane(UPPER_DIR, TO_TARGET, ELBOW_FLAT);
199
+ projectOntoPlane(POLE, TO_TARGET, POLE_FLAT);
200
+ if (vec3.length(ELBOW_FLAT) <= EPSILON || vec3.length(POLE_FLAT) <= EPSILON) return;
201
+ vec3.normalize(ELBOW_FLAT, ELBOW_FLAT);
202
+ vec3.normalize(POLE_FLAT, POLE_FLAT);
203
+
204
+ /* Signed about the aim direction, so the roll turns the short way and to the right side. */
205
+ vec3.cross(AXIS, ELBOW_FLAT, POLE_FLAT);
206
+ const angle = Math.atan2(vec3.dot(AXIS, TO_TARGET), vec3.dot(ELBOW_FLAT, POLE_FLAT));
207
+ rotateJointAboutWorldAxis(skeleton, pose, root, TO_TARGET, angle);
208
+ }
209
+
210
+ /** The child of `joint` in this skeleton, which for a two-bone chain is its tip. */
211
+ function tipOf(skeleton: Skeleton, joint: number): number {
212
+ for (let j = joint + 1; j < skeleton.jointCount; j++) {
213
+ if (skeleton.joints[j]?.parent === joint) return j;
214
+ }
215
+ return joint;
216
+ }
217
+
218
+ /** The part of `v` with its component along `normal` removed. */
219
+ function projectOntoPlane(v: vec3, normal: vec3, out: vec3): void {
220
+ const along = vec3.dot(v, normal);
221
+ vec3.scaleAndAdd(out, v, normal, -along);
222
+ }
223
+
224
+ /** A joint's world position: the translation column of its world matrix. */
225
+ function worldPosition(skeleton: Skeleton, joint: number, out: vec3): void {
226
+ const at = joint * 16 + 12;
227
+ vec3.set(
228
+ out,
229
+ skeleton.world[at] as number,
230
+ skeleton.world[at + 1] as number,
231
+ skeleton.world[at + 2] as number,
232
+ );
233
+ }
234
+
235
+ /**
236
+ * `acos` answers NaN a hair outside its domain, and a NaN rotation reaches the palette.
237
+ *
238
+ * **Defence in depth rather than the guard that matters**, which is worth saying because the
239
+ * comment here claimed otherwise until it was perturbed: `distance` is already clamped into the
240
+ * reachable band above, so every argument reaching this is inside [-1, 1] by construction and
241
+ * removing the clamp leaves every test green. What it defends against is floating point on the
242
+ * boundary of that band, which no deterministic test can reliably reach. Kept because the cost is
243
+ * a comparison and the failure it prevents is a limb full of NaN.
244
+ */
245
+ function clampCosine(value: number): number {
246
+ return value < -1 ? -1 : value > 1 ? 1 : value;
247
+ }
248
+
249
+ function angleBetween(a: vec3, b: vec3): number {
250
+ const lengths = vec3.length(a) * vec3.length(b);
251
+ if (lengths <= EPSILON) return 0;
252
+ return Math.acos(clampCosine(vec3.dot(a, b) / lengths));
253
+ }
254
+
255
+ /**
256
+ * Turn a joint by `angle` about a **world-space** axis, by writing its local rotation.
257
+ *
258
+ * A joint's world rotation is its parent's times its own, so a world-space delta `Q` gives
259
+ * `local' = inverse(parentWorld) · Q · parentWorld · local` — and conjugating a rotation about an
260
+ * axis is the same rotation about the transformed axis. So the axis is taken into the parent's
261
+ * frame and the delta applied there, which is one vector transform instead of three quaternion
262
+ * products.
263
+ */
264
+ function rotateJointAboutWorldAxis(
265
+ skeleton: Skeleton,
266
+ pose: Pose,
267
+ joint: number,
268
+ axis: vec3,
269
+ angle: number,
270
+ ): void {
271
+ if (!Number.isFinite(angle) || Math.abs(angle) <= EPSILON) return;
272
+ parentFrame(skeleton, joint, PARENT_ROT);
273
+ mat3.invert(PARENT_ROT, PARENT_ROT);
274
+ vec3.transformMat3(LOCAL_AXIS, axis, PARENT_ROT);
275
+ vec3.normalize(LOCAL_AXIS, LOCAL_AXIS);
276
+ quat.setAxisAngle(DELTA, LOCAL_AXIS, angle);
277
+ composeInto(pose, joint, DELTA);
278
+ }
279
+
280
+ /** The same, for a delta already expressed as a world-space quaternion. */
281
+ function applyWorldRotation(skeleton: Skeleton, pose: Pose, joint: number, world: quat): void {
282
+ parentFrame(skeleton, joint, PARENT_ROT);
283
+ mat3.invert(INVERSE_PARENT, PARENT_ROT);
284
+ quat.fromMat3(PARENT_QUAT, PARENT_ROT);
285
+ quat.fromMat3(INVERSE_QUAT, INVERSE_PARENT);
286
+ quat.multiply(DELTA, INVERSE_QUAT, world);
287
+ quat.multiply(DELTA, DELTA, PARENT_QUAT);
288
+ composeInto(pose, joint, DELTA);
289
+ }
290
+
291
+ /**
292
+ * The rotation part of a joint's parent's world matrix, or identity for a root.
293
+ *
294
+ * **The columns are normalised, and without that a scaled rig is solved wrong.** A world matrix's
295
+ * upper 3x3 carries the scale as well as the rotation, and one of this function's two callers reads
296
+ * *quaternions* out of it: `quat.fromMat3` recovers a rotation from the matrix trace by way of
297
+ * `sqrt(trace + 1)`, so scaling the matrix scales the trace while that `+ 1` stays put. What comes
298
+ * back is not the original rotation at a different length — it is a **different rotation**, and
299
+ * normalising the quaternion afterwards only fixes the length of something already pointing the
300
+ * wrong way.
301
+ *
302
+ * Measured against `gl-matrix` alone, a known rotation scaled and read back: 13.98 degrees out at
303
+ * scale 0.5, 61.59 at 0.007132, 22.89 at 140.2. Neither direction is safe and nothing but exactly 1
304
+ * is close. Reported from outside against an animal 46 mm long whose model is authored in metres —
305
+ * the chain came out the right length to a part in ten million and pointed 108.8 degrees wrong,
306
+ * with `solveTwoBone` returning `true` while doing it.
307
+ *
308
+ * **Why no test here saw it**: a rig authored at the scale it is played at has determinant 1, where
309
+ * `quat.fromMat3` is exact. A scale in the hierarchy is the ordinary way to reuse one rig at two
310
+ * sizes, and every skeleton in this package's suite was built without one until that report.
311
+ *
312
+ * The bend step is unaffected either way — it transforms an *axis* through this frame and
313
+ * normalises the result, so a uniform scale divides out — but it is normalised for both callers
314
+ * rather than for one, because a frame that means "rotation" should not sometimes mean something
315
+ * else depending on who asked.
316
+ *
317
+ * **Uniform scale is what this makes exact.** A non-uniform one leaves shear that no per-column
318
+ * normalise can remove, and a zero-length column is a collapsed joint with no frame to recover;
319
+ * both keep the axis they had rather than dividing by zero. A hierarchy at unit scale divides by 1
320
+ * and gets precisely what it got before.
321
+ */
322
+ function parentFrame(skeleton: Skeleton, joint: number, out: mat3): void {
323
+ const parent = skeleton.joints[joint]?.parent ?? -1;
324
+ if (parent < 0) {
325
+ mat3.identity(out);
326
+ return;
327
+ }
328
+ const at = parent * 16;
329
+ mat3.set(
330
+ out,
331
+ skeleton.world[at] as number,
332
+ skeleton.world[at + 1] as number,
333
+ skeleton.world[at + 2] as number,
334
+ skeleton.world[at + 4] as number,
335
+ skeleton.world[at + 5] as number,
336
+ skeleton.world[at + 6] as number,
337
+ skeleton.world[at + 8] as number,
338
+ skeleton.world[at + 9] as number,
339
+ skeleton.world[at + 10] as number,
340
+ );
341
+ for (let column = 0; column < 3; column++) {
342
+ const c = column * 3;
343
+ const x = out[c] as number;
344
+ const y = out[c + 1] as number;
345
+ const z = out[c + 2] as number;
346
+ const length = Math.hypot(x, y, z);
347
+ if (length <= EPSILON) continue;
348
+ out[c] = x / length;
349
+ out[c + 1] = y / length;
350
+ out[c + 2] = z / length;
351
+ }
352
+ }
353
+
354
+ /** `pose.rotation[joint] = delta · pose.rotation[joint]`, normalised. */
355
+ function composeInto(pose: Pose, joint: number, delta: quat): void {
356
+ const at = joint * 4;
357
+ quat.set(
358
+ CURRENT,
359
+ pose.rotation[at] as number,
360
+ pose.rotation[at + 1] as number,
361
+ pose.rotation[at + 2] as number,
362
+ pose.rotation[at + 3] as number,
363
+ );
364
+ quat.multiply(CURRENT, delta, CURRENT);
365
+ quat.normalize(CURRENT, CURRENT);
366
+ for (let c = 0; c < 4; c++) pose.rotation[at + c] = CURRENT[c] as number;
367
+ }
368
+
369
+ /**
370
+ * Below this a length is zero and an angle is nothing.
371
+ *
372
+ * Not a tuning constant: it is the width of the band where the arithmetic above stops being
373
+ * defined. Every use of it guards a division or an `acos` domain.
374
+ */
375
+ const EPSILON = 1e-6;
376
+
377
+ /* Module scope, claimed once: a solve runs per chain per frame. */
378
+ const ROOT_POS = vec3.create();
379
+ const MID_POS = vec3.create();
380
+ const TIP_POS = vec3.create();
381
+ const TARGET = vec3.create();
382
+ const TO_TARGET = vec3.create();
383
+ const UPPER_DIR = vec3.create();
384
+ const CHAIN_DIR = vec3.create();
385
+ const TO_ROOT = vec3.create();
386
+ const TO_TIP = vec3.create();
387
+ const AXIS = vec3.create();
388
+ const POLE = vec3.create();
389
+ const ELBOW_FLAT = vec3.create();
390
+ const POLE_FLAT = vec3.create();
391
+ const LOCAL_AXIS = vec3.create();
392
+ const DELTA = quat.create();
393
+ const AIM = quat.create();
394
+ const CURRENT = quat.create();
395
+ const PARENT_ROT = mat3.create();
396
+ const INVERSE_PARENT = mat3.create();
397
+ const PARENT_QUAT = quat.create();
398
+ const INVERSE_QUAT = quat.create();
399
+ /* Two non-parallel axes, so a target parallel to one is not parallel to the other. */
400
+ const FALLBACK_POLE = vec3.fromValues(0, 0, 1);
401
+ const SECOND_FALLBACK = vec3.fromValues(1, 0, 0);
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * Animation: skeletons, clips, poses, and the graphs over them.
4
+ *
5
+ * **Nothing here touches a GPU, and that is the boundary rather than an omission.**
6
+ * `RendererApi` is a fixed surface a package cannot extend, so the palette upload and the skinned
7
+ * draw live in core; this package produces a `Float32Array` and knows nothing about how it reaches
8
+ * a shader. That line is also the determinism boundary `docs/ARCHITECTURE.md` §5 names: everything
9
+ * in here is a pure function of a time the caller supplies, and nothing reads a clock.
10
+ *
11
+ * What it costs is that a consumer wanting skinned characters imports two packages rather than
12
+ * one. What would make it wrong is a renderer surface a package could extend, which nothing
13
+ * proposes and which `render/backend/api.ts` argues against at its own definition.
14
+ */
15
+ export type { Joint } from './skeleton.ts';
16
+ export { Skeleton } from './skeleton.ts';
17
+ export type { Pose } from './pose.ts';
18
+ export { createPose, restPose } from './pose.ts';
19
+ export type { AnimationClip, JointTrack, TrackPath } from './clip.ts';
20
+ export { sampleClip } from './clip.ts';
21
+ export type { RootMotion } from './rootMotion.ts';
22
+ export { createRootMotion, extractRootMotion, stripRootMotion } from './rootMotion.ts';
23
+ export { RigidAnimation, applyPoseToNode } from './rigid.ts';
24
+ export { addPose, blendPoses, setJoint } from './blend.ts';
25
+ export { BlendTree } from './blendTree.ts';
26
+ export type { BlendNode } from './blendTree.ts';
27
+ export { AnimationStateMachine } from './stateMachine.ts';
28
+ export type { AnimationState, AnimationTransition } from './stateMachine.ts';
29
+ export { solveTwoBone } from './ik.ts';
30
+ export { buildRetargetMap, retargetPose } from './retarget.ts';
31
+ export type { RetargetMap } from './retarget.ts';
32
+ export {
33
+ sampleSpring,
34
+ sampleSpringChain,
35
+ springChainSettleSec,
36
+ springSettleSec,
37
+ } from './spring.ts';
38
+ export type { SpringAnchor, SpringLink, SpringSettings } from './spring.ts';