@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,231 @@
1
+ import { MAX_JOINTS } from '@driftengine/core';
2
+ import type { Joint } from '@driftengine/drft';
3
+ import { mat4, quat, vec3 } from 'gl-matrix';
4
+
5
+ import type { Pose } from './pose.ts';
6
+
7
+ /**
8
+ * A joint hierarchy and the skinning palette it resolves a pose into.
9
+ *
10
+ * **A joint is not a `SceneNode`, and that is deliberate.** Core has a transform hierarchy with a
11
+ * parent, dirty tracking and a derived world matrix, and reusing it here is the obvious move. A
12
+ * rig is sixty to ninety joints and a scene holds several characters, so a class instance per node
13
+ * holding its own matrices is the allocation the performance rules forbid — and the palette a
14
+ * shader reads has to be contiguous anyway, which an object graph is not. A *character* is still a
15
+ * `SceneNode`; its skeleton hangs off one, and `rigid.ts` is the seam between them.
16
+ *
17
+ * What it costs is that a joint cannot be reparented or addressed the way a scene node can. What
18
+ * would make it wrong is a consumer needing to attach arbitrary scene content to a joint — a sword
19
+ * in a hand — which is answered by reading the joint's world matrix out rather than by making the
20
+ * joint a node.
21
+ */
22
+
23
+ /* `Joint` is the format package's, for the reason `clip.ts` gives about the `ANIM` chunk. */
24
+ export type { Joint } from '@driftengine/drft';
25
+
26
+ export class Skeleton {
27
+ readonly joints: readonly Joint[];
28
+ readonly jointCount: number;
29
+
30
+ /**
31
+ * Sixteen floats a joint, column-major, claimed once and written in place.
32
+ *
33
+ * This is a *skinning* palette rather than a set of world matrices: each entry is the joint's
34
+ * world transform times its inverse bind, so it takes a vertex from model space to where the
35
+ * joint has moved it. At the bind pose every entry is identity, whatever the bind pose is, which
36
+ * is the property `skeleton.test.ts` pins — a reversed multiplication order does not shift a
37
+ * mesh slightly, it explodes it.
38
+ */
39
+ readonly palette: Float32Array;
40
+
41
+ /**
42
+ * Each joint's world matrix, sixteen floats a joint, resolved on the way to the palette.
43
+ *
44
+ * **Public because a palette entry is not a world transform.** An entry is the world matrix
45
+ * times the joint's inverse bind, which is what a shader needs and is useless to anything asking
46
+ * *where a joint is* — attaching a sword to a hand, or an IK solver reading a chain. Those want
47
+ * this. Valid after `applyPose` and meaningless before it.
48
+ */
49
+ readonly world: Float32Array;
50
+
51
+ private readonly inverseBind: Float32Array;
52
+
53
+ /*
54
+ * One 16-float view per joint into each of the three arrays, built once at construction.
55
+ *
56
+ * `gl-matrix` writes through whatever it is handed, so a view lets `applyPose` multiply straight
57
+ * into the palette with no copy at all. The views exist because `subarray` **is an allocation** —
58
+ * a new typed-array object every call — and `applyPose` runs per character per frame, which is
59
+ * exactly where §4's rule about typed-array views bites. Built here, they cost one object per
60
+ * joint for the skeleton's life and nothing per frame.
61
+ *
62
+ * What it costs is three arrays of small objects held for the skeleton's life. What would make it
63
+ * wrong is a joint count large enough for that to matter, which the 512-joint cap rules out.
64
+ */
65
+ private readonly worldViews: Float32Array[] = [];
66
+ private readonly paletteViews: Float32Array[] = [];
67
+ private readonly bindViews: Float32Array[] = [];
68
+
69
+ /**
70
+ * @param joints Sorted **parents-first**. Refused otherwise.
71
+ * @param inverseBind Sixteen floats a joint, column-major, in the same order as `joints`.
72
+ */
73
+ constructor(joints: readonly Joint[], inverseBind: Float32Array) {
74
+ /*
75
+ * Parents before children, checked once here rather than sorted here.
76
+ *
77
+ * `applyPose` walks the joints in index order and reads each joint's parent matrix as it goes,
78
+ * which is only correct on a sorted hierarchy — and a rig that is nearly sorted produces a
79
+ * skeleton that is wrong in one limb, which reads as a bad animation rather than as a data
80
+ * error. Sorting is the importer's job because it is the layer that can also remap every index
81
+ * that names a joint; sorting here would leave a mesh's joint attribute pointing at the old
82
+ * order. See `gltfSkin.ts`.
83
+ */
84
+ for (let j = 0; j < joints.length; j++) {
85
+ const parent = (joints[j] as Joint).parent;
86
+ if (parent >= j) {
87
+ throw new Error(
88
+ `Skeleton: joint ${j} ("${(joints[j] as Joint).name}") names parent ${parent}, which is ` +
89
+ `not before it. Joints must be sorted parents-first, because the palette is resolved ` +
90
+ `in index order and a parent read before it is written is a limb in the wrong place.`,
91
+ );
92
+ }
93
+ if (parent < -1) {
94
+ throw new Error(`Skeleton: joint ${j} names parent ${parent}; -1 means "no parent"`);
95
+ }
96
+ }
97
+
98
+ /*
99
+ * The cap is a fact about the palette *texture* — 2048 is WebGL2's guaranteed width and four
100
+ * texels carry a matrix — so it is defined in core beside the texture and imported here rather
101
+ * than restated. Two numbers for one decision is the shape that drifts, and this one would
102
+ * drift silently: a rig between the two would build here and read past the end of a texture row
103
+ * on the GPU, which draws limbs from whatever memory follows rather than failing.
104
+ */
105
+ if (joints.length > MAX_JOINTS) {
106
+ throw new Error(
107
+ `Skeleton: ${joints.length} joints exceeds the ${MAX_JOINTS} a palette texture holds. ` +
108
+ `That is WebGL2's guaranteed texture width rather than this machine's limit.`,
109
+ );
110
+ }
111
+
112
+ if (inverseBind.length !== joints.length * 16) {
113
+ throw new Error(
114
+ `Skeleton: the inverse bind array has ${inverseBind.length} floats for ${joints.length} ` +
115
+ `joints; expected ${joints.length * 16} (16 per joint, column-major).`,
116
+ );
117
+ }
118
+
119
+ this.joints = joints;
120
+ this.jointCount = joints.length;
121
+ this.inverseBind = inverseBind;
122
+ this.palette = new Float32Array(joints.length * 16);
123
+ this.world = new Float32Array(joints.length * 16);
124
+
125
+ for (let j = 0; j < joints.length; j++) {
126
+ const at = j * 16;
127
+ this.worldViews.push(this.world.subarray(at, at + 16));
128
+ this.paletteViews.push(this.palette.subarray(at, at + 16));
129
+ this.bindViews.push(inverseBind.subarray(at, at + 16));
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Resolve a pose into the palette. Allocates nothing.
135
+ *
136
+ * One pass in index order: compose the joint's local matrix from its TRS, multiply by its
137
+ * parent's already-written world matrix, then by its inverse bind into the palette. The
138
+ * parents-first ordering the constructor checks is what makes the single pass correct.
139
+ */
140
+ /**
141
+ * Resolve the hierarchy from local matrices rather than from TRS.
142
+ *
143
+ * **For a rig whose poses are authored as matrices.** `applyPose` is the right door for
144
+ * anything that interpolates — a clip, a blend tree, a state machine — because a quaternion is
145
+ * what you can blend and Euler angles are not. But a rig computed fresh every frame from
146
+ * gameplay state has no interpolation to do, and forcing it through TRS means decomposing
147
+ * matrices it just composed: a square root and a branch per joint, and a chance to get the
148
+ * rotation order wrong that no test of the *engine* can catch.
149
+ *
150
+ * The two writers agree, and `skeleton.test.ts` pins that against `applyPose` rather than
151
+ * asserting it — a caller choosing between them on convenience must not be choosing between two
152
+ * answers.
153
+ *
154
+ * **What it costs** is a second way in, which is a real cost: a reader now has to know both
155
+ * exist. **What would make it wrong** is a caller reaching for this to avoid learning
156
+ * quaternions and then wanting to blend — at which point they need `applyPose` and have built
157
+ * their rig in the one representation that cannot get there.
158
+ *
159
+ * @param locals Sixteen floats a joint, column-major, in joint order. Each is the joint's
160
+ * transform **relative to its parent**, not its world transform.
161
+ */
162
+ applyLocalMatrices(locals: Float32Array): void {
163
+ if (locals.length !== this.jointCount * 16) {
164
+ throw new Error(
165
+ `Skeleton: applyLocalMatrices wants sixteen floats a joint — ` +
166
+ `${this.jointCount * 16} for ${this.jointCount} joints, got ${locals.length}. ` +
167
+ `A short array leaves the tail joints holding whatever the last frame wrote, which ` +
168
+ `animates as one limb frozen rather than as an error.`,
169
+ );
170
+ }
171
+ for (let j = 0; j < this.jointCount; j++) {
172
+ const at = j * 16;
173
+ /* Copied element by element rather than with `subarray`, because a subarray is a new
174
+ typed-array object every call — the same allocation the views above exist to avoid,
175
+ and this runs per character per frame beside `applyPose`. */
176
+ for (let i = 0; i < 16; i++) SCRATCH_LOCAL[i] = locals[at + i] as number;
177
+
178
+ const parent = (this.joints[j] as Joint).parent;
179
+ const world = this.worldViews[j] as Float32Array;
180
+ if (parent < 0) {
181
+ world.set(SCRATCH_LOCAL);
182
+ } else {
183
+ mat4.multiply(world, this.worldViews[parent] as Float32Array, SCRATCH_LOCAL);
184
+ }
185
+ mat4.multiply(this.paletteViews[j] as Float32Array, world, this.bindViews[j] as Float32Array);
186
+ }
187
+ }
188
+
189
+ applyPose(pose: Pose): void {
190
+ for (let j = 0; j < this.jointCount; j++) {
191
+ SCRATCH_T[0] = pose.translation[j * 3] as number;
192
+ SCRATCH_T[1] = pose.translation[j * 3 + 1] as number;
193
+ SCRATCH_T[2] = pose.translation[j * 3 + 2] as number;
194
+ SCRATCH_R[0] = pose.rotation[j * 4] as number;
195
+ SCRATCH_R[1] = pose.rotation[j * 4 + 1] as number;
196
+ SCRATCH_R[2] = pose.rotation[j * 4 + 2] as number;
197
+ SCRATCH_R[3] = pose.rotation[j * 4 + 3] as number;
198
+ SCRATCH_S[0] = pose.scale[j * 3] as number;
199
+ SCRATCH_S[1] = pose.scale[j * 3 + 1] as number;
200
+ SCRATCH_S[2] = pose.scale[j * 3 + 2] as number;
201
+
202
+ mat4.fromRotationTranslationScale(SCRATCH_LOCAL, SCRATCH_R, SCRATCH_T, SCRATCH_S);
203
+
204
+ const parent = (this.joints[j] as Joint).parent;
205
+ const world = this.worldViews[j] as Float32Array;
206
+ if (parent < 0) {
207
+ world.set(SCRATCH_LOCAL);
208
+ } else {
209
+ mat4.multiply(world, this.worldViews[parent] as Float32Array, SCRATCH_LOCAL);
210
+ }
211
+
212
+ mat4.multiply(this.paletteViews[j] as Float32Array, world, this.bindViews[j] as Float32Array);
213
+ }
214
+ }
215
+ }
216
+
217
+ /*
218
+ * Module-scope scratch, claimed once for the process rather than per skeleton or per call.
219
+ *
220
+ * `applyPose` runs per character per frame, so anything allocated inside it is a garbage collector
221
+ * in the frame loop. Module scope rather than instance fields because these hold nothing between
222
+ * calls and one set serves every skeleton — the engine is single-threaded on this path, and a
223
+ * worker gets its own module instance.
224
+ *
225
+ * What would make it wrong is `applyPose` ever being re-entered, which would need it to call back
226
+ * into caller code; it calls only `gl-matrix`.
227
+ */
228
+ const SCRATCH_T = vec3.create();
229
+ const SCRATCH_S = vec3.create();
230
+ const SCRATCH_R = quat.create();
231
+ const SCRATCH_LOCAL = mat4.create();