@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/dist/rigid.js ADDED
@@ -0,0 +1,92 @@
1
+ import { sampleClip } from './clip.js';
2
+ import { createPose } from './pose.js';
3
+ /**
4
+ * Rigid TRS: a clip driving a node's transform, with no skeleton and no palette anywhere.
5
+ *
6
+ * A door swinging, a lift rising, a turntable turning — geometry that moves as a whole rather than
7
+ * deforming. It is the cheapest thing animation offers and the one most games reach for first, and
8
+ * it needs no shader change at all.
9
+ *
10
+ * **This module is the only place this package touches core**, which is what makes the peer
11
+ * dependency load-bearing rather than declared. A node is core's, a pose is this package's, and
12
+ * this is the one line between them — kept in a module of its own so the rest of the package
13
+ * stays a pure function of time over typed arrays and could be tested with core absent.
14
+ */
15
+ /**
16
+ * Write one joint of a pose onto a node's local transform. Allocates nothing.
17
+ *
18
+ * `markMoved` is called because `SceneNode` says at its own fields that writing a transform in
19
+ * place does not mark it dirty. Omitting it is invisible on the first frame — a fresh node is
20
+ * dirty already — and shows on the second as a node whose world matrix never catches up, which
21
+ * reads as an animation that plays once and freezes.
22
+ */
23
+ export function applyPoseToNode(pose, joint, node) {
24
+ const t = joint * 3;
25
+ const r = joint * 4;
26
+ node.position[0] = pose.translation[t];
27
+ node.position[1] = pose.translation[t + 1];
28
+ node.position[2] = pose.translation[t + 2];
29
+ node.rotation[0] = pose.rotation[r];
30
+ node.rotation[1] = pose.rotation[r + 1];
31
+ node.rotation[2] = pose.rotation[r + 2];
32
+ node.rotation[3] = pose.rotation[r + 3];
33
+ node.scale[0] = pose.scale[t];
34
+ node.scale[1] = pose.scale[t + 1];
35
+ node.scale[2] = pose.scale[t + 2];
36
+ node.markMoved();
37
+ }
38
+ /**
39
+ * A clip bound to a list of nodes, one per joint the clip's tracks name.
40
+ *
41
+ * The pose is claimed once at construction and reused, so `apply` allocates nothing however often
42
+ * it runs.
43
+ */
44
+ export class RigidAnimation {
45
+ clip;
46
+ nodes;
47
+ pose;
48
+ /**
49
+ * The distinct joints this clip drives, collected once.
50
+ *
51
+ * Iterating `tracks` directly would write a joint's node once per track it has — three times for
52
+ * a joint carrying translation, rotation and scale — which is correct and is three times the
53
+ * work every frame. Collected here because the set cannot change: a clip is immutable.
54
+ */
55
+ driven = [];
56
+ /**
57
+ * @param nodes Indexed by joint. `null` for a joint this scene did not instantiate.
58
+ */
59
+ constructor(clip, nodes) {
60
+ this.clip = clip;
61
+ this.nodes = nodes;
62
+ /*
63
+ * Sized to the highest joint the clip names, not to `nodes.length`, so a caller passing a
64
+ * short list still gets a pose the sampler can write every track into — the skipping happens
65
+ * at the node, which is where the caller's intent is, rather than silently at the sample.
66
+ */
67
+ let highest = -1;
68
+ for (const track of clip.tracks) {
69
+ highest = Math.max(highest, track.joint);
70
+ if (!this.driven.includes(track.joint))
71
+ this.driven.push(track.joint);
72
+ }
73
+ this.pose = createPose(highest + 1);
74
+ }
75
+ /**
76
+ * Sample at a caller-supplied time and write every bound node. Reads no clock.
77
+ *
78
+ * A joint with no node is skipped rather than refused. An imported clip names joints a scene may
79
+ * not have instantiated, and the right behaviour is the parts that exist moving — the
80
+ * reliability rules forbid throwing in a frame loop outright, and a missing prop is not a reason
81
+ * to stop a scene.
82
+ */
83
+ apply(timeSec) {
84
+ sampleClip(this.clip, timeSec, this.pose);
85
+ for (const joint of this.driven) {
86
+ const node = this.nodes[joint];
87
+ if (node === null || node === undefined)
88
+ continue;
89
+ applyPoseToNode(this.pose, joint, node);
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,74 @@
1
+ import type { AnimationClip } from '@driftengine/drft';
2
+ import type { Pose } from './pose.ts';
3
+ /**
4
+ * Root motion: how far the root travelled between two sample times, handed to the caller.
5
+ *
6
+ * **The point is that the character moves and the pose does not.** A walk cycle authored with a
7
+ * moving root plays as a character sliding forward inside its own transform, and the rig then
8
+ * fights whatever the game does with that transform. Root motion splits the two: `extractRootMotion`
9
+ * answers the interval's displacement so a controller can apply it, and `stripRootMotion` pins the
10
+ * pose's root so the displacement is not applied twice.
11
+ *
12
+ * **A pure function of the clip and two times, with no clock of its own** — the same contract
13
+ * `sampleClip` makes and for the same reason. A recorded intent stream replays bit-identically
14
+ * only if every step of the pose pipeline is a function of a time the caller supplies.
15
+ *
16
+ * ## The convention, and what it costs
17
+ *
18
+ * The delta is expressed **in the root's own frame at the earlier time**, so a caller applies it as
19
+ *
20
+ * ```
21
+ * position = position + worldRotation * motion.translation
22
+ * worldRotation = worldRotation * motion.rotation
23
+ * ```
24
+ *
25
+ * which composes: stepping a clip in sixtieths and applying each delta arrives where one query
26
+ * over the whole span says it should, and a character that has turned walks along its own forward
27
+ * axis rather than along the clip's. The rejected alternative is a delta in the clip's own space,
28
+ * which is one subtraction shorter and drags every turned character sideways.
29
+ *
30
+ * **What it gives up** is a vertical bob authored on the root: it leaves the pose along with
31
+ * everything else and becomes motion the caller applies. A caller whose height is owned by a
32
+ * physics controller ignores `translation[1]` — and then the bob is in neither the pose nor the
33
+ * position, which is a real loss and the reason this is written down rather than discovered. **What
34
+ * would make it wrong** is a consumer wanting per-axis control, and the honest answer then is a mask
35
+ * on the extraction rather than a second convention.
36
+ */
37
+ /** A root's displacement over an interval. Three floats and a quaternion, both caller-owned. */
38
+ export interface RootMotion {
39
+ /** Three floats, in the root's frame at the earlier of the two times. */
40
+ readonly translation: Float32Array;
41
+ /** Four floats, xyzw, in the order `gl-matrix` uses. */
42
+ readonly rotation: Float32Array;
43
+ }
44
+ /** A zero displacement, ready to be written into. Identity rather than zeroed, as `createPose` is. */
45
+ export declare function createRootMotion(): RootMotion;
46
+ /**
47
+ * Write the root's displacement between `fromSec` and `toSec` into `out`. Allocates nothing.
48
+ *
49
+ * **Loops are accumulated rather than subtracted**, which is the whole difficulty. Sampling the
50
+ * root at both times and subtracting answers a full stride *backwards* every time the clip wraps,
51
+ * because the root snaps from the end of the cycle to the start of it. So the interval is split at
52
+ * every loop boundary it crosses and the pieces are composed.
53
+ *
54
+ * `toSec` before `fromSec` is a clip running backwards and answers the negated motion, because a
55
+ * transition played in reverse is a real caller — the same reason `wrapTime` handles a negative
56
+ * time rather than assuming one cannot arrive.
57
+ *
58
+ * Cost is linear in the number of whole cycles between the two times. A fixed-step caller crosses
59
+ * at most one boundary a frame, so that count is zero or one; a query spanning a hundred cycles
60
+ * composes a hundred times, which is arithmetic rather than a hazard.
61
+ */
62
+ export declare function extractRootMotion(clip: AnimationClip, rootJoint: number, fromSec: number, toSec: number, out: RootMotion): void;
63
+ /**
64
+ * Pin a sampled pose's root to the value the clip authored at time zero.
65
+ *
66
+ * The other half of the capability: whatever `extractRootMotion` handed the caller has to leave the
67
+ * pose, or the character moves twice. Pinned to the clip's own first value rather than to the rest
68
+ * pose, so a root authored a metre off the origin keeps its offset — resting it would drop every
69
+ * character to the floor of its rig on the first frame.
70
+ *
71
+ * A joint that is not the root is left exactly as it was found, which is the same layering rule
72
+ * `sampleClip` and `retargetPose` both keep.
73
+ */
74
+ export declare function stripRootMotion(clip: AnimationClip, rootJoint: number, pose: Pose): void;
@@ -0,0 +1,182 @@
1
+ import { sampleTrack, wrapTime } from './clip.js';
2
+ /** A zero displacement, ready to be written into. Identity rather than zeroed, as `createPose` is. */
3
+ export function createRootMotion() {
4
+ const motion = { translation: new Float32Array(3), rotation: new Float32Array(4) };
5
+ motion.rotation[3] = 1;
6
+ return motion;
7
+ }
8
+ /**
9
+ * Write the root's displacement between `fromSec` and `toSec` into `out`. Allocates nothing.
10
+ *
11
+ * **Loops are accumulated rather than subtracted**, which is the whole difficulty. Sampling the
12
+ * root at both times and subtracting answers a full stride *backwards* every time the clip wraps,
13
+ * because the root snaps from the end of the cycle to the start of it. So the interval is split at
14
+ * every loop boundary it crosses and the pieces are composed.
15
+ *
16
+ * `toSec` before `fromSec` is a clip running backwards and answers the negated motion, because a
17
+ * transition played in reverse is a real caller — the same reason `wrapTime` handles a negative
18
+ * time rather than assuming one cannot arrive.
19
+ *
20
+ * Cost is linear in the number of whole cycles between the two times. A fixed-step caller crosses
21
+ * at most one boundary a frame, so that count is zero or one; a query spanning a hundred cycles
22
+ * composes a hundred times, which is arithmetic rather than a hazard.
23
+ */
24
+ export function extractRootMotion(clip, rootJoint, fromSec, toSec, out) {
25
+ identity(out);
26
+ const duration = clip.durationSec;
27
+ const translation = findTrack(clip, rootJoint, 'translation');
28
+ const rotation = findTrack(clip, rootJoint, 'rotation');
29
+ if (translation === null && rotation === null)
30
+ return;
31
+ /* A clip with no duration wraps every time to the same instant, so nothing moved. */
32
+ if (!(duration > 0))
33
+ return;
34
+ const from = wrapTime(fromSec, duration);
35
+ const to = wrapTime(toSec, duration);
36
+ const crossings = Math.floor(toSec / duration) - Math.floor(fromSec / duration);
37
+ if (crossings === 0) {
38
+ composeSegment(translation, rotation, from, to, out);
39
+ return;
40
+ }
41
+ /*
42
+ * Forwards: the tail of the current cycle, then whole cycles, then the head of the last one.
43
+ * Backwards is the same walk with the two ends of the clip exchanged, which is what makes the
44
+ * negative case the negation of the positive rather than a second implementation.
45
+ */
46
+ const forwards = crossings > 0;
47
+ const open = forwards ? 0 : duration;
48
+ const close = forwards ? duration : 0;
49
+ composeSegment(translation, rotation, from, close, out);
50
+ const whole = Math.abs(crossings) - 1;
51
+ for (let cycle = 0; cycle < whole; cycle++) {
52
+ composeSegment(translation, rotation, open, close, out);
53
+ }
54
+ composeSegment(translation, rotation, open, to, out);
55
+ }
56
+ /**
57
+ * Pin a sampled pose's root to the value the clip authored at time zero.
58
+ *
59
+ * The other half of the capability: whatever `extractRootMotion` handed the caller has to leave the
60
+ * pose, or the character moves twice. Pinned to the clip's own first value rather than to the rest
61
+ * pose, so a root authored a metre off the origin keeps its offset — resting it would drop every
62
+ * character to the floor of its rig on the first frame.
63
+ *
64
+ * A joint that is not the root is left exactly as it was found, which is the same layering rule
65
+ * `sampleClip` and `retargetPose` both keep.
66
+ */
67
+ export function stripRootMotion(clip, rootJoint, pose) {
68
+ const translation = findTrack(clip, rootJoint, 'translation');
69
+ if (translation !== null)
70
+ sampleTrack(translation, 0, pose.translation, rootJoint * 3);
71
+ const rotation = findTrack(clip, rootJoint, 'rotation');
72
+ if (rotation !== null)
73
+ sampleTrack(rotation, 0, pose.rotation, rootJoint * 4);
74
+ }
75
+ /**
76
+ * The track for one joint and one path, or null.
77
+ *
78
+ * A linear scan rather than an index, because a clip carries a few dozen tracks and this runs twice
79
+ * per extraction — building a map would allocate one per clip and have to be invalidated when a
80
+ * caller swaps a clip in place, which is worse than sixty comparisons.
81
+ */
82
+ function findTrack(clip, joint, path) {
83
+ for (const track of clip.tracks) {
84
+ if (track.joint === joint && track.path === path)
85
+ return track;
86
+ }
87
+ return null;
88
+ }
89
+ /** Compose one segment's local delta onto `out`. `t0` and `t1` are in range and not wrapped. */
90
+ function composeSegment(translation, rotation, t0, t1, out) {
91
+ FROM_T.fill(0);
92
+ TO_T.fill(0);
93
+ if (translation !== null) {
94
+ sampleTrack(translation, t0, FROM_T, 0);
95
+ sampleTrack(translation, t1, TO_T, 0);
96
+ }
97
+ identityInto(FROM_R);
98
+ identityInto(TO_R);
99
+ if (rotation !== null) {
100
+ sampleTrack(rotation, t0, FROM_R, 0);
101
+ sampleTrack(rotation, t1, TO_R, 0);
102
+ }
103
+ /* The segment's delta in the root's frame at `t0`: rotate the world difference back by the
104
+ earlier orientation, and take the rotation that carries the earlier onto the later. */
105
+ for (let c = 0; c < 3; c++)
106
+ SEG_T[c] = TO_T[c] - FROM_T[c];
107
+ conjugateInto(FROM_R, INVERSE);
108
+ rotateVector(INVERSE, SEG_T);
109
+ multiplyInto(INVERSE, TO_R, SEG_R);
110
+ /* `out` then this segment: the segment's translation is expressed in the frame `out` ends in,
111
+ so it is rotated by `out`'s rotation before it is added. */
112
+ rotateVector(out.rotation, SEG_T);
113
+ for (let c = 0; c < 3; c++) {
114
+ out.translation[c] = out.translation[c] + SEG_T[c];
115
+ }
116
+ multiplyInto(out.rotation, SEG_R, COMPOSED);
117
+ for (let c = 0; c < 4; c++)
118
+ out.rotation[c] = COMPOSED[c];
119
+ }
120
+ function identity(out) {
121
+ out.translation.fill(0);
122
+ identityInto(out.rotation);
123
+ }
124
+ function identityInto(quaternion) {
125
+ quaternion[0] = 0;
126
+ quaternion[1] = 0;
127
+ quaternion[2] = 0;
128
+ quaternion[3] = 1;
129
+ }
130
+ /** The inverse of a unit quaternion. Not normalised: every source here is a sampled unit. */
131
+ function conjugateInto(quaternion, out) {
132
+ out[0] = -quaternion[0];
133
+ out[1] = -quaternion[1];
134
+ out[2] = -quaternion[2];
135
+ out[3] = quaternion[3];
136
+ }
137
+ /** Quaternion product `a * b` into `out`. `out` must not alias either input. */
138
+ function multiplyInto(a, b, out) {
139
+ const ax = a[0];
140
+ const ay = a[1];
141
+ const az = a[2];
142
+ const aw = a[3];
143
+ const bx = b[0];
144
+ const by = b[1];
145
+ const bz = b[2];
146
+ const bw = b[3];
147
+ out[0] = aw * bx + ax * bw + ay * bz - az * by;
148
+ out[1] = aw * by - ax * bz + ay * bw + az * bx;
149
+ out[2] = aw * bz + ax * by - ay * bx + az * bw;
150
+ out[3] = aw * bw - ax * bx - ay * by - az * bz;
151
+ }
152
+ /**
153
+ * Rotate a three-vector by a unit quaternion, in place.
154
+ *
155
+ * `v + 2w(q x v) + 2(q x (q x v))`, written out rather than built from a matrix: a matrix would be
156
+ * nine floats of scratch to save two cross products, and this runs four times a frame per
157
+ * character.
158
+ */
159
+ function rotateVector(quaternion, vector) {
160
+ const qx = quaternion[0];
161
+ const qy = quaternion[1];
162
+ const qz = quaternion[2];
163
+ const qw = quaternion[3];
164
+ const vx = vector[0];
165
+ const vy = vector[1];
166
+ const vz = vector[2];
167
+ const tx = 2 * (qy * vz - qz * vy);
168
+ const ty = 2 * (qz * vx - qx * vz);
169
+ const tz = 2 * (qx * vy - qy * vx);
170
+ vector[0] = vx + qw * tx + (qy * tz - qz * ty);
171
+ vector[1] = vy + qw * ty + (qz * tx - qx * tz);
172
+ vector[2] = vz + qw * tz + (qx * ty - qy * tx);
173
+ }
174
+ /* Module scope, claimed once: an extraction runs per character per frame. */
175
+ const FROM_T = new Float32Array(3);
176
+ const TO_T = new Float32Array(3);
177
+ const SEG_T = new Float32Array(3);
178
+ const FROM_R = new Float32Array(4);
179
+ const TO_R = new Float32Array(4);
180
+ const SEG_R = new Float32Array(4);
181
+ const INVERSE = new Float32Array(4);
182
+ const COMPOSED = new Float32Array(4);
@@ -0,0 +1,81 @@
1
+ import type { Joint } from '@driftengine/drft';
2
+ import type { Pose } from './pose.ts';
3
+ /**
4
+ * A joint hierarchy and the skinning palette it resolves a pose into.
5
+ *
6
+ * **A joint is not a `SceneNode`, and that is deliberate.** Core has a transform hierarchy with a
7
+ * parent, dirty tracking and a derived world matrix, and reusing it here is the obvious move. A
8
+ * rig is sixty to ninety joints and a scene holds several characters, so a class instance per node
9
+ * holding its own matrices is the allocation the performance rules forbid — and the palette a
10
+ * shader reads has to be contiguous anyway, which an object graph is not. A *character* is still a
11
+ * `SceneNode`; its skeleton hangs off one, and `rigid.ts` is the seam between them.
12
+ *
13
+ * What it costs is that a joint cannot be reparented or addressed the way a scene node can. What
14
+ * would make it wrong is a consumer needing to attach arbitrary scene content to a joint — a sword
15
+ * in a hand — which is answered by reading the joint's world matrix out rather than by making the
16
+ * joint a node.
17
+ */
18
+ export type { Joint } from '@driftengine/drft';
19
+ export declare class Skeleton {
20
+ readonly joints: readonly Joint[];
21
+ readonly jointCount: number;
22
+ /**
23
+ * Sixteen floats a joint, column-major, claimed once and written in place.
24
+ *
25
+ * This is a *skinning* palette rather than a set of world matrices: each entry is the joint's
26
+ * world transform times its inverse bind, so it takes a vertex from model space to where the
27
+ * joint has moved it. At the bind pose every entry is identity, whatever the bind pose is, which
28
+ * is the property `skeleton.test.ts` pins — a reversed multiplication order does not shift a
29
+ * mesh slightly, it explodes it.
30
+ */
31
+ readonly palette: Float32Array;
32
+ /**
33
+ * Each joint's world matrix, sixteen floats a joint, resolved on the way to the palette.
34
+ *
35
+ * **Public because a palette entry is not a world transform.** An entry is the world matrix
36
+ * times the joint's inverse bind, which is what a shader needs and is useless to anything asking
37
+ * *where a joint is* — attaching a sword to a hand, or an IK solver reading a chain. Those want
38
+ * this. Valid after `applyPose` and meaningless before it.
39
+ */
40
+ readonly world: Float32Array;
41
+ private readonly inverseBind;
42
+ private readonly worldViews;
43
+ private readonly paletteViews;
44
+ private readonly bindViews;
45
+ /**
46
+ * @param joints Sorted **parents-first**. Refused otherwise.
47
+ * @param inverseBind Sixteen floats a joint, column-major, in the same order as `joints`.
48
+ */
49
+ constructor(joints: readonly Joint[], inverseBind: Float32Array);
50
+ /**
51
+ * Resolve a pose into the palette. Allocates nothing.
52
+ *
53
+ * One pass in index order: compose the joint's local matrix from its TRS, multiply by its
54
+ * parent's already-written world matrix, then by its inverse bind into the palette. The
55
+ * parents-first ordering the constructor checks is what makes the single pass correct.
56
+ */
57
+ /**
58
+ * Resolve the hierarchy from local matrices rather than from TRS.
59
+ *
60
+ * **For a rig whose poses are authored as matrices.** `applyPose` is the right door for
61
+ * anything that interpolates — a clip, a blend tree, a state machine — because a quaternion is
62
+ * what you can blend and Euler angles are not. But a rig computed fresh every frame from
63
+ * gameplay state has no interpolation to do, and forcing it through TRS means decomposing
64
+ * matrices it just composed: a square root and a branch per joint, and a chance to get the
65
+ * rotation order wrong that no test of the *engine* can catch.
66
+ *
67
+ * The two writers agree, and `skeleton.test.ts` pins that against `applyPose` rather than
68
+ * asserting it — a caller choosing between them on convenience must not be choosing between two
69
+ * answers.
70
+ *
71
+ * **What it costs** is a second way in, which is a real cost: a reader now has to know both
72
+ * exist. **What would make it wrong** is a caller reaching for this to avoid learning
73
+ * quaternions and then wanting to blend — at which point they need `applyPose` and have built
74
+ * their rig in the one representation that cannot get there.
75
+ *
76
+ * @param locals Sixteen floats a joint, column-major, in joint order. Each is the joint's
77
+ * transform **relative to its parent**, not its world transform.
78
+ */
79
+ applyLocalMatrices(locals: Float32Array): void;
80
+ applyPose(pose: Pose): void;
81
+ }
@@ -0,0 +1,187 @@
1
+ import { MAX_JOINTS } from '@driftengine/core';
2
+ import { mat4, quat, vec3 } from 'gl-matrix';
3
+ export class Skeleton {
4
+ joints;
5
+ jointCount;
6
+ /**
7
+ * Sixteen floats a joint, column-major, claimed once and written in place.
8
+ *
9
+ * This is a *skinning* palette rather than a set of world matrices: each entry is the joint's
10
+ * world transform times its inverse bind, so it takes a vertex from model space to where the
11
+ * joint has moved it. At the bind pose every entry is identity, whatever the bind pose is, which
12
+ * is the property `skeleton.test.ts` pins — a reversed multiplication order does not shift a
13
+ * mesh slightly, it explodes it.
14
+ */
15
+ palette;
16
+ /**
17
+ * Each joint's world matrix, sixteen floats a joint, resolved on the way to the palette.
18
+ *
19
+ * **Public because a palette entry is not a world transform.** An entry is the world matrix
20
+ * times the joint's inverse bind, which is what a shader needs and is useless to anything asking
21
+ * *where a joint is* — attaching a sword to a hand, or an IK solver reading a chain. Those want
22
+ * this. Valid after `applyPose` and meaningless before it.
23
+ */
24
+ world;
25
+ inverseBind;
26
+ /*
27
+ * One 16-float view per joint into each of the three arrays, built once at construction.
28
+ *
29
+ * `gl-matrix` writes through whatever it is handed, so a view lets `applyPose` multiply straight
30
+ * into the palette with no copy at all. The views exist because `subarray` **is an allocation** —
31
+ * a new typed-array object every call — and `applyPose` runs per character per frame, which is
32
+ * exactly where §4's rule about typed-array views bites. Built here, they cost one object per
33
+ * joint for the skeleton's life and nothing per frame.
34
+ *
35
+ * What it costs is three arrays of small objects held for the skeleton's life. What would make it
36
+ * wrong is a joint count large enough for that to matter, which the 512-joint cap rules out.
37
+ */
38
+ worldViews = [];
39
+ paletteViews = [];
40
+ bindViews = [];
41
+ /**
42
+ * @param joints Sorted **parents-first**. Refused otherwise.
43
+ * @param inverseBind Sixteen floats a joint, column-major, in the same order as `joints`.
44
+ */
45
+ constructor(joints, inverseBind) {
46
+ /*
47
+ * Parents before children, checked once here rather than sorted here.
48
+ *
49
+ * `applyPose` walks the joints in index order and reads each joint's parent matrix as it goes,
50
+ * which is only correct on a sorted hierarchy — and a rig that is nearly sorted produces a
51
+ * skeleton that is wrong in one limb, which reads as a bad animation rather than as a data
52
+ * error. Sorting is the importer's job because it is the layer that can also remap every index
53
+ * that names a joint; sorting here would leave a mesh's joint attribute pointing at the old
54
+ * order. See `gltfSkin.ts`.
55
+ */
56
+ for (let j = 0; j < joints.length; j++) {
57
+ const parent = joints[j].parent;
58
+ if (parent >= j) {
59
+ throw new Error(`Skeleton: joint ${j} ("${joints[j].name}") names parent ${parent}, which is ` +
60
+ `not before it. Joints must be sorted parents-first, because the palette is resolved ` +
61
+ `in index order and a parent read before it is written is a limb in the wrong place.`);
62
+ }
63
+ if (parent < -1) {
64
+ throw new Error(`Skeleton: joint ${j} names parent ${parent}; -1 means "no parent"`);
65
+ }
66
+ }
67
+ /*
68
+ * The cap is a fact about the palette *texture* — 2048 is WebGL2's guaranteed width and four
69
+ * texels carry a matrix — so it is defined in core beside the texture and imported here rather
70
+ * than restated. Two numbers for one decision is the shape that drifts, and this one would
71
+ * drift silently: a rig between the two would build here and read past the end of a texture row
72
+ * on the GPU, which draws limbs from whatever memory follows rather than failing.
73
+ */
74
+ if (joints.length > MAX_JOINTS) {
75
+ throw new Error(`Skeleton: ${joints.length} joints exceeds the ${MAX_JOINTS} a palette texture holds. ` +
76
+ `That is WebGL2's guaranteed texture width rather than this machine's limit.`);
77
+ }
78
+ if (inverseBind.length !== joints.length * 16) {
79
+ throw new Error(`Skeleton: the inverse bind array has ${inverseBind.length} floats for ${joints.length} ` +
80
+ `joints; expected ${joints.length * 16} (16 per joint, column-major).`);
81
+ }
82
+ this.joints = joints;
83
+ this.jointCount = joints.length;
84
+ this.inverseBind = inverseBind;
85
+ this.palette = new Float32Array(joints.length * 16);
86
+ this.world = new Float32Array(joints.length * 16);
87
+ for (let j = 0; j < joints.length; j++) {
88
+ const at = j * 16;
89
+ this.worldViews.push(this.world.subarray(at, at + 16));
90
+ this.paletteViews.push(this.palette.subarray(at, at + 16));
91
+ this.bindViews.push(inverseBind.subarray(at, at + 16));
92
+ }
93
+ }
94
+ /**
95
+ * Resolve a pose into the palette. Allocates nothing.
96
+ *
97
+ * One pass in index order: compose the joint's local matrix from its TRS, multiply by its
98
+ * parent's already-written world matrix, then by its inverse bind into the palette. The
99
+ * parents-first ordering the constructor checks is what makes the single pass correct.
100
+ */
101
+ /**
102
+ * Resolve the hierarchy from local matrices rather than from TRS.
103
+ *
104
+ * **For a rig whose poses are authored as matrices.** `applyPose` is the right door for
105
+ * anything that interpolates — a clip, a blend tree, a state machine — because a quaternion is
106
+ * what you can blend and Euler angles are not. But a rig computed fresh every frame from
107
+ * gameplay state has no interpolation to do, and forcing it through TRS means decomposing
108
+ * matrices it just composed: a square root and a branch per joint, and a chance to get the
109
+ * rotation order wrong that no test of the *engine* can catch.
110
+ *
111
+ * The two writers agree, and `skeleton.test.ts` pins that against `applyPose` rather than
112
+ * asserting it — a caller choosing between them on convenience must not be choosing between two
113
+ * answers.
114
+ *
115
+ * **What it costs** is a second way in, which is a real cost: a reader now has to know both
116
+ * exist. **What would make it wrong** is a caller reaching for this to avoid learning
117
+ * quaternions and then wanting to blend — at which point they need `applyPose` and have built
118
+ * their rig in the one representation that cannot get there.
119
+ *
120
+ * @param locals Sixteen floats a joint, column-major, in joint order. Each is the joint's
121
+ * transform **relative to its parent**, not its world transform.
122
+ */
123
+ applyLocalMatrices(locals) {
124
+ if (locals.length !== this.jointCount * 16) {
125
+ throw new Error(`Skeleton: applyLocalMatrices wants sixteen floats a joint — ` +
126
+ `${this.jointCount * 16} for ${this.jointCount} joints, got ${locals.length}. ` +
127
+ `A short array leaves the tail joints holding whatever the last frame wrote, which ` +
128
+ `animates as one limb frozen rather than as an error.`);
129
+ }
130
+ for (let j = 0; j < this.jointCount; j++) {
131
+ const at = j * 16;
132
+ /* Copied element by element rather than with `subarray`, because a subarray is a new
133
+ typed-array object every call — the same allocation the views above exist to avoid,
134
+ and this runs per character per frame beside `applyPose`. */
135
+ for (let i = 0; i < 16; i++)
136
+ SCRATCH_LOCAL[i] = locals[at + i];
137
+ const parent = this.joints[j].parent;
138
+ const world = this.worldViews[j];
139
+ if (parent < 0) {
140
+ world.set(SCRATCH_LOCAL);
141
+ }
142
+ else {
143
+ mat4.multiply(world, this.worldViews[parent], SCRATCH_LOCAL);
144
+ }
145
+ mat4.multiply(this.paletteViews[j], world, this.bindViews[j]);
146
+ }
147
+ }
148
+ applyPose(pose) {
149
+ for (let j = 0; j < this.jointCount; j++) {
150
+ SCRATCH_T[0] = pose.translation[j * 3];
151
+ SCRATCH_T[1] = pose.translation[j * 3 + 1];
152
+ SCRATCH_T[2] = pose.translation[j * 3 + 2];
153
+ SCRATCH_R[0] = pose.rotation[j * 4];
154
+ SCRATCH_R[1] = pose.rotation[j * 4 + 1];
155
+ SCRATCH_R[2] = pose.rotation[j * 4 + 2];
156
+ SCRATCH_R[3] = pose.rotation[j * 4 + 3];
157
+ SCRATCH_S[0] = pose.scale[j * 3];
158
+ SCRATCH_S[1] = pose.scale[j * 3 + 1];
159
+ SCRATCH_S[2] = pose.scale[j * 3 + 2];
160
+ mat4.fromRotationTranslationScale(SCRATCH_LOCAL, SCRATCH_R, SCRATCH_T, SCRATCH_S);
161
+ const parent = this.joints[j].parent;
162
+ const world = this.worldViews[j];
163
+ if (parent < 0) {
164
+ world.set(SCRATCH_LOCAL);
165
+ }
166
+ else {
167
+ mat4.multiply(world, this.worldViews[parent], SCRATCH_LOCAL);
168
+ }
169
+ mat4.multiply(this.paletteViews[j], world, this.bindViews[j]);
170
+ }
171
+ }
172
+ }
173
+ /*
174
+ * Module-scope scratch, claimed once for the process rather than per skeleton or per call.
175
+ *
176
+ * `applyPose` runs per character per frame, so anything allocated inside it is a garbage collector
177
+ * in the frame loop. Module scope rather than instance fields because these hold nothing between
178
+ * calls and one set serves every skeleton — the engine is single-threaded on this path, and a
179
+ * worker gets its own module instance.
180
+ *
181
+ * What would make it wrong is `applyPose` ever being re-entered, which would need it to call back
182
+ * into caller code; it calls only `gl-matrix`.
183
+ */
184
+ const SCRATCH_T = vec3.create();
185
+ const SCRATCH_S = vec3.create();
186
+ const SCRATCH_R = quat.create();
187
+ const SCRATCH_LOCAL = mat4.create();