@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/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +74 -0
- package/dist/blend.d.ts +39 -0
- package/dist/blend.js +158 -0
- package/dist/blendTree.d.ts +115 -0
- package/dist/blendTree.js +171 -0
- package/dist/clip.d.ts +50 -0
- package/dist/clip.js +171 -0
- package/dist/ik.d.ts +29 -0
- package/dist/ik.js +329 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +12 -0
- package/dist/pose.d.ts +39 -0
- package/dist/pose.js +49 -0
- package/dist/retarget.d.ts +40 -0
- package/dist/retarget.js +72 -0
- package/dist/rigid.d.ts +56 -0
- package/dist/rigid.js +92 -0
- package/dist/rootMotion.d.ts +74 -0
- package/dist/rootMotion.js +182 -0
- package/dist/skeleton.d.ts +81 -0
- package/dist/skeleton.js +187 -0
- package/dist/spring.d.ts +108 -0
- package/dist/spring.js +386 -0
- package/dist/stateMachine.d.ts +75 -0
- package/dist/stateMachine.js +142 -0
- package/package.json +61 -0
- package/src/blend.ts +191 -0
- package/src/blendTree.ts +253 -0
- package/src/clip.ts +223 -0
- package/src/ik.ts +401 -0
- package/src/index.ts +38 -0
- package/src/pose.ts +60 -0
- package/src/retarget.ts +105 -0
- package/src/rigid.ts +99 -0
- package/src/rootMotion.ts +253 -0
- package/src/skeleton.ts +231 -0
- package/src/spring.ts +520 -0
- package/src/stateMachine.ts +181 -0
package/dist/ik.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { mat3, quat, vec3 } from 'gl-matrix';
|
|
2
|
+
/**
|
|
3
|
+
* Two-bone inverse kinematics: put a chain's tip on a target, closed form.
|
|
4
|
+
*
|
|
5
|
+
* **Closed form rather than iterative**, because two bones and a target are a triangle and a
|
|
6
|
+
* triangle has an answer. An iterative solver — FABRIK, or gradient descent — earns its keep on
|
|
7
|
+
* longer chains where there is no closed form; spending iterations on a case with an exact
|
|
8
|
+
* solution buys nothing and makes the result depend on how many were run, which a replay cannot
|
|
9
|
+
* tolerate.
|
|
10
|
+
*
|
|
11
|
+
* What it does not do is limits, twist or more than two bones. A knee that should not hyperextend
|
|
12
|
+
* is a constraint this does not know about, and it is a game's decision rather than the engine's —
|
|
13
|
+
* the same line the state machine draws about a character's verbs.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Reach `target` with the chain `root` → `mid` → `tip`, writing the two rotations into `pose`.
|
|
17
|
+
*
|
|
18
|
+
* Returns whether the target was reachable. An unreachable one straightens the chain toward it,
|
|
19
|
+
* which is the behaviour a limb should have: an arm reaching for something too far away extends,
|
|
20
|
+
* it does not give up or fold.
|
|
21
|
+
*
|
|
22
|
+
* `poleHint` decides the one thing the target cannot — which way the joint bends. A world-space
|
|
23
|
+
* point or direction the mid joint should lean toward; without it, a chain that is already
|
|
24
|
+
* straight has no plane to bend in and the elbow would flip unpredictably as a character turned.
|
|
25
|
+
*
|
|
26
|
+
* The skeleton's world matrices are refreshed as it goes, so they are current when this returns.
|
|
27
|
+
*/
|
|
28
|
+
export function solveTwoBone(skeleton, pose, root, mid, tip, target, poleHint) {
|
|
29
|
+
const joints = skeleton.joints;
|
|
30
|
+
if (joints[mid]?.parent !== root || joints[tip]?.parent !== mid) {
|
|
31
|
+
throw new Error(`solveTwoBone: ${root} → ${mid} → ${tip} is not a parent-to-child chain in this skeleton`);
|
|
32
|
+
}
|
|
33
|
+
skeleton.applyPose(pose);
|
|
34
|
+
worldPosition(skeleton, root, ROOT_POS);
|
|
35
|
+
worldPosition(skeleton, mid, MID_POS);
|
|
36
|
+
worldPosition(skeleton, tip, TIP_POS);
|
|
37
|
+
const upper = vec3.distance(ROOT_POS, MID_POS);
|
|
38
|
+
const lower = vec3.distance(MID_POS, TIP_POS);
|
|
39
|
+
vec3.set(TARGET, target[0], target[1], target[2]);
|
|
40
|
+
vec3.subtract(TO_TARGET, TARGET, ROOT_POS);
|
|
41
|
+
const reach = vec3.length(TO_TARGET);
|
|
42
|
+
/*
|
|
43
|
+
* A chain with a zero-length bone, or a target sitting exactly on the root, has no triangle at
|
|
44
|
+
* all — every angle below would come from a division by zero. Answered by leaving the pose alone
|
|
45
|
+
* and reporting failure, because no pose is more correct than the one the animation produced.
|
|
46
|
+
*/
|
|
47
|
+
if (upper <= EPSILON || lower <= EPSILON || reach <= EPSILON)
|
|
48
|
+
return false;
|
|
49
|
+
/*
|
|
50
|
+
* The reachable band. Past `upper + lower` the chain cannot stretch; inside `|upper - lower|` it
|
|
51
|
+
* cannot fold that far. Both make the law of cosines take an argument outside [-1, 1], where
|
|
52
|
+
* `Math.acos` answers NaN — and a NaN rotation reaches the palette and takes every vertex the
|
|
53
|
+
* joint touches. Clamped rather than refused, so the chain still points the right way.
|
|
54
|
+
*/
|
|
55
|
+
const longest = upper + lower;
|
|
56
|
+
const shortest = Math.abs(upper - lower);
|
|
57
|
+
const reachable = reach <= longest && reach >= shortest;
|
|
58
|
+
const distance = Math.min(Math.max(reach, shortest + EPSILON), longest - EPSILON);
|
|
59
|
+
/*
|
|
60
|
+
* **The mid joint first, and the order is the whole of it.** Rotating the root turns its entire
|
|
61
|
+
* subtree, so it changes where the chain *points* and never how long it is — only the mid joint
|
|
62
|
+
* can set `|tip - root|`. Bending the root first, as the obvious reading of the triangle
|
|
63
|
+
* suggests, leaves the tip at whatever length the pose already had and the aim step then puts it
|
|
64
|
+
* on the wrong point of the right ray. Measured as a tip 1.22 units out where 1.00 was wanted.
|
|
65
|
+
*/
|
|
66
|
+
vec3.subtract(TO_ROOT, ROOT_POS, MID_POS);
|
|
67
|
+
vec3.subtract(TO_TIP, TIP_POS, MID_POS);
|
|
68
|
+
const midNow = angleBetween(TO_ROOT, TO_TIP);
|
|
69
|
+
const midWanted = Math.acos(clampCosine((upper * upper + lower * lower - distance * distance) / (2 * upper * lower)));
|
|
70
|
+
bendAxis(TO_ROOT, TO_TIP, poleHint, ROOT_POS, AXIS);
|
|
71
|
+
rotateJointAboutWorldAxis(skeleton, pose, mid, AXIS, midWanted - midNow);
|
|
72
|
+
skeleton.applyPose(pose);
|
|
73
|
+
/*
|
|
74
|
+
* Now the chain is the right length, so aiming it puts the tip on the target exactly — where a
|
|
75
|
+
* chain of the wrong length would land on the right ray at the wrong distance.
|
|
76
|
+
*/
|
|
77
|
+
worldPosition(skeleton, root, ROOT_POS);
|
|
78
|
+
worldPosition(skeleton, tip, TIP_POS);
|
|
79
|
+
vec3.subtract(CHAIN_DIR, TIP_POS, ROOT_POS);
|
|
80
|
+
vec3.subtract(TO_TARGET, TARGET, ROOT_POS);
|
|
81
|
+
if (vec3.length(CHAIN_DIR) > EPSILON && vec3.length(TO_TARGET) > EPSILON) {
|
|
82
|
+
vec3.normalize(CHAIN_DIR, CHAIN_DIR);
|
|
83
|
+
vec3.normalize(TO_TARGET, TO_TARGET);
|
|
84
|
+
quat.rotationTo(AIM, CHAIN_DIR, TO_TARGET);
|
|
85
|
+
applyWorldRotation(skeleton, pose, root, AIM);
|
|
86
|
+
skeleton.applyPose(pose);
|
|
87
|
+
}
|
|
88
|
+
/*
|
|
89
|
+
* **The roll, which is what the pole is actually for.** Aiming leaves the chain free to spin
|
|
90
|
+
* about the line to the target, and every angle of that spin puts the tip in the same place — so
|
|
91
|
+
* nothing above decides which way the elbow points. Without this an elbow flips unpredictably as
|
|
92
|
+
* a character turns, which is the defect a pole hint exists to prevent.
|
|
93
|
+
*/
|
|
94
|
+
rollTowardPole(skeleton, pose, root, mid, poleHint);
|
|
95
|
+
skeleton.applyPose(pose);
|
|
96
|
+
return reachable;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* An axis to bend the mid joint about: **the normal of the chain's own plane**.
|
|
100
|
+
*
|
|
101
|
+
* **Not "anything perpendicular to the bone", which is what this used to say and is what made the
|
|
102
|
+
* solver land short.** The angle asked for above is the interior angle at the mid joint, and
|
|
103
|
+
* rotating by `midWanted - midNow` produces exactly that angle only when the rotation happens *in*
|
|
104
|
+
* the plane the angle is measured in. Any other axis turns the bone out of that plane instead, so
|
|
105
|
+
* the interior angle changes by less than was asked and `|tip - root|` comes out short — after
|
|
106
|
+
* which the aim step lands the tip on the right ray at the wrong distance.
|
|
107
|
+
*
|
|
108
|
+
* The old axis was the bone crossed with the *pole*, and **that is right exactly when the pole lies
|
|
109
|
+
* in the chain's plane** — which is why this survived as long as it did. Every case in `ik.test.ts`
|
|
110
|
+
* put the pole in that plane, and the first test written for the report did too and passed against
|
|
111
|
+
* the unfixed solver. A knee's pole points where the character faces; the plane its leg bends in is
|
|
112
|
+
* wherever the animation left it, and the two coincide only by accident.
|
|
113
|
+
*
|
|
114
|
+
* Reported from outside as a chain reaching its target on the second call and not the first, with
|
|
115
|
+
* the residual falling to zero and staying there — which is a solver converging, and a closed-form
|
|
116
|
+
* solver has nothing to converge. Held now by a leg with a 0.46 m thigh, a 0.44 m shin and a pole
|
|
117
|
+
* off the plane: 0.028 short of its target without this, under a micrometre with it.
|
|
118
|
+
*
|
|
119
|
+
* **The pole is still what decides the bend for a chain with no plane.** A straight chain has
|
|
120
|
+
* `root`, `mid` and `tip` collinear and the cross product below is zero; there is genuinely no
|
|
121
|
+
* plane to bend in, and the pole is the only thing that can choose one. Two non-parallel fallbacks
|
|
122
|
+
* follow it, so a bone parallel to one is not parallel to the other.
|
|
123
|
+
*/
|
|
124
|
+
function bendAxis(toRoot, toTip, poleHint, rootPos, out) {
|
|
125
|
+
vec3.cross(out, toRoot, toTip);
|
|
126
|
+
if (vec3.length(out) > EPSILON) {
|
|
127
|
+
vec3.normalize(out, out);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
vec3.set(POLE, poleHint[0], poleHint[1], poleHint[2]);
|
|
131
|
+
vec3.subtract(POLE, POLE, rootPos);
|
|
132
|
+
vec3.cross(out, toTip, POLE);
|
|
133
|
+
if (vec3.length(out) > EPSILON) {
|
|
134
|
+
vec3.normalize(out, out);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
vec3.cross(out, toTip, FALLBACK_POLE);
|
|
138
|
+
if (vec3.length(out) <= EPSILON)
|
|
139
|
+
vec3.cross(out, toTip, SECOND_FALLBACK);
|
|
140
|
+
vec3.normalize(out, out);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Spin the chain about the line to the target until the mid joint faces the pole.
|
|
144
|
+
*
|
|
145
|
+
* Both the elbow and the pole are projected onto the plane perpendicular to that line, because the
|
|
146
|
+
* component *along* it is the part the spin cannot change — comparing the unprojected directions
|
|
147
|
+
* would ask for a rotation that does not exist and answer with one that moves the tip.
|
|
148
|
+
*/
|
|
149
|
+
function rollTowardPole(skeleton, pose, root, mid, poleHint) {
|
|
150
|
+
worldPosition(skeleton, root, ROOT_POS);
|
|
151
|
+
worldPosition(skeleton, mid, MID_POS);
|
|
152
|
+
worldPosition(skeleton, tipOf(skeleton, mid), TIP_POS);
|
|
153
|
+
vec3.subtract(TO_TARGET, TIP_POS, ROOT_POS);
|
|
154
|
+
if (vec3.length(TO_TARGET) <= EPSILON)
|
|
155
|
+
return;
|
|
156
|
+
vec3.normalize(TO_TARGET, TO_TARGET);
|
|
157
|
+
vec3.set(POLE, poleHint[0], poleHint[1], poleHint[2]);
|
|
158
|
+
vec3.subtract(POLE, POLE, ROOT_POS);
|
|
159
|
+
vec3.subtract(UPPER_DIR, MID_POS, ROOT_POS);
|
|
160
|
+
projectOntoPlane(UPPER_DIR, TO_TARGET, ELBOW_FLAT);
|
|
161
|
+
projectOntoPlane(POLE, TO_TARGET, POLE_FLAT);
|
|
162
|
+
if (vec3.length(ELBOW_FLAT) <= EPSILON || vec3.length(POLE_FLAT) <= EPSILON)
|
|
163
|
+
return;
|
|
164
|
+
vec3.normalize(ELBOW_FLAT, ELBOW_FLAT);
|
|
165
|
+
vec3.normalize(POLE_FLAT, POLE_FLAT);
|
|
166
|
+
/* Signed about the aim direction, so the roll turns the short way and to the right side. */
|
|
167
|
+
vec3.cross(AXIS, ELBOW_FLAT, POLE_FLAT);
|
|
168
|
+
const angle = Math.atan2(vec3.dot(AXIS, TO_TARGET), vec3.dot(ELBOW_FLAT, POLE_FLAT));
|
|
169
|
+
rotateJointAboutWorldAxis(skeleton, pose, root, TO_TARGET, angle);
|
|
170
|
+
}
|
|
171
|
+
/** The child of `joint` in this skeleton, which for a two-bone chain is its tip. */
|
|
172
|
+
function tipOf(skeleton, joint) {
|
|
173
|
+
for (let j = joint + 1; j < skeleton.jointCount; j++) {
|
|
174
|
+
if (skeleton.joints[j]?.parent === joint)
|
|
175
|
+
return j;
|
|
176
|
+
}
|
|
177
|
+
return joint;
|
|
178
|
+
}
|
|
179
|
+
/** The part of `v` with its component along `normal` removed. */
|
|
180
|
+
function projectOntoPlane(v, normal, out) {
|
|
181
|
+
const along = vec3.dot(v, normal);
|
|
182
|
+
vec3.scaleAndAdd(out, v, normal, -along);
|
|
183
|
+
}
|
|
184
|
+
/** A joint's world position: the translation column of its world matrix. */
|
|
185
|
+
function worldPosition(skeleton, joint, out) {
|
|
186
|
+
const at = joint * 16 + 12;
|
|
187
|
+
vec3.set(out, skeleton.world[at], skeleton.world[at + 1], skeleton.world[at + 2]);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* `acos` answers NaN a hair outside its domain, and a NaN rotation reaches the palette.
|
|
191
|
+
*
|
|
192
|
+
* **Defence in depth rather than the guard that matters**, which is worth saying because the
|
|
193
|
+
* comment here claimed otherwise until it was perturbed: `distance` is already clamped into the
|
|
194
|
+
* reachable band above, so every argument reaching this is inside [-1, 1] by construction and
|
|
195
|
+
* removing the clamp leaves every test green. What it defends against is floating point on the
|
|
196
|
+
* boundary of that band, which no deterministic test can reliably reach. Kept because the cost is
|
|
197
|
+
* a comparison and the failure it prevents is a limb full of NaN.
|
|
198
|
+
*/
|
|
199
|
+
function clampCosine(value) {
|
|
200
|
+
return value < -1 ? -1 : value > 1 ? 1 : value;
|
|
201
|
+
}
|
|
202
|
+
function angleBetween(a, b) {
|
|
203
|
+
const lengths = vec3.length(a) * vec3.length(b);
|
|
204
|
+
if (lengths <= EPSILON)
|
|
205
|
+
return 0;
|
|
206
|
+
return Math.acos(clampCosine(vec3.dot(a, b) / lengths));
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Turn a joint by `angle` about a **world-space** axis, by writing its local rotation.
|
|
210
|
+
*
|
|
211
|
+
* A joint's world rotation is its parent's times its own, so a world-space delta `Q` gives
|
|
212
|
+
* `local' = inverse(parentWorld) · Q · parentWorld · local` — and conjugating a rotation about an
|
|
213
|
+
* axis is the same rotation about the transformed axis. So the axis is taken into the parent's
|
|
214
|
+
* frame and the delta applied there, which is one vector transform instead of three quaternion
|
|
215
|
+
* products.
|
|
216
|
+
*/
|
|
217
|
+
function rotateJointAboutWorldAxis(skeleton, pose, joint, axis, angle) {
|
|
218
|
+
if (!Number.isFinite(angle) || Math.abs(angle) <= EPSILON)
|
|
219
|
+
return;
|
|
220
|
+
parentFrame(skeleton, joint, PARENT_ROT);
|
|
221
|
+
mat3.invert(PARENT_ROT, PARENT_ROT);
|
|
222
|
+
vec3.transformMat3(LOCAL_AXIS, axis, PARENT_ROT);
|
|
223
|
+
vec3.normalize(LOCAL_AXIS, LOCAL_AXIS);
|
|
224
|
+
quat.setAxisAngle(DELTA, LOCAL_AXIS, angle);
|
|
225
|
+
composeInto(pose, joint, DELTA);
|
|
226
|
+
}
|
|
227
|
+
/** The same, for a delta already expressed as a world-space quaternion. */
|
|
228
|
+
function applyWorldRotation(skeleton, pose, joint, world) {
|
|
229
|
+
parentFrame(skeleton, joint, PARENT_ROT);
|
|
230
|
+
mat3.invert(INVERSE_PARENT, PARENT_ROT);
|
|
231
|
+
quat.fromMat3(PARENT_QUAT, PARENT_ROT);
|
|
232
|
+
quat.fromMat3(INVERSE_QUAT, INVERSE_PARENT);
|
|
233
|
+
quat.multiply(DELTA, INVERSE_QUAT, world);
|
|
234
|
+
quat.multiply(DELTA, DELTA, PARENT_QUAT);
|
|
235
|
+
composeInto(pose, joint, DELTA);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The rotation part of a joint's parent's world matrix, or identity for a root.
|
|
239
|
+
*
|
|
240
|
+
* **The columns are normalised, and without that a scaled rig is solved wrong.** A world matrix's
|
|
241
|
+
* upper 3x3 carries the scale as well as the rotation, and one of this function's two callers reads
|
|
242
|
+
* *quaternions* out of it: `quat.fromMat3` recovers a rotation from the matrix trace by way of
|
|
243
|
+
* `sqrt(trace + 1)`, so scaling the matrix scales the trace while that `+ 1` stays put. What comes
|
|
244
|
+
* back is not the original rotation at a different length — it is a **different rotation**, and
|
|
245
|
+
* normalising the quaternion afterwards only fixes the length of something already pointing the
|
|
246
|
+
* wrong way.
|
|
247
|
+
*
|
|
248
|
+
* Measured against `gl-matrix` alone, a known rotation scaled and read back: 13.98 degrees out at
|
|
249
|
+
* scale 0.5, 61.59 at 0.007132, 22.89 at 140.2. Neither direction is safe and nothing but exactly 1
|
|
250
|
+
* is close. Reported from outside against an animal 46 mm long whose model is authored in metres —
|
|
251
|
+
* the chain came out the right length to a part in ten million and pointed 108.8 degrees wrong,
|
|
252
|
+
* with `solveTwoBone` returning `true` while doing it.
|
|
253
|
+
*
|
|
254
|
+
* **Why no test here saw it**: a rig authored at the scale it is played at has determinant 1, where
|
|
255
|
+
* `quat.fromMat3` is exact. A scale in the hierarchy is the ordinary way to reuse one rig at two
|
|
256
|
+
* sizes, and every skeleton in this package's suite was built without one until that report.
|
|
257
|
+
*
|
|
258
|
+
* The bend step is unaffected either way — it transforms an *axis* through this frame and
|
|
259
|
+
* normalises the result, so a uniform scale divides out — but it is normalised for both callers
|
|
260
|
+
* rather than for one, because a frame that means "rotation" should not sometimes mean something
|
|
261
|
+
* else depending on who asked.
|
|
262
|
+
*
|
|
263
|
+
* **Uniform scale is what this makes exact.** A non-uniform one leaves shear that no per-column
|
|
264
|
+
* normalise can remove, and a zero-length column is a collapsed joint with no frame to recover;
|
|
265
|
+
* both keep the axis they had rather than dividing by zero. A hierarchy at unit scale divides by 1
|
|
266
|
+
* and gets precisely what it got before.
|
|
267
|
+
*/
|
|
268
|
+
function parentFrame(skeleton, joint, out) {
|
|
269
|
+
const parent = skeleton.joints[joint]?.parent ?? -1;
|
|
270
|
+
if (parent < 0) {
|
|
271
|
+
mat3.identity(out);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const at = parent * 16;
|
|
275
|
+
mat3.set(out, skeleton.world[at], skeleton.world[at + 1], skeleton.world[at + 2], skeleton.world[at + 4], skeleton.world[at + 5], skeleton.world[at + 6], skeleton.world[at + 8], skeleton.world[at + 9], skeleton.world[at + 10]);
|
|
276
|
+
for (let column = 0; column < 3; column++) {
|
|
277
|
+
const c = column * 3;
|
|
278
|
+
const x = out[c];
|
|
279
|
+
const y = out[c + 1];
|
|
280
|
+
const z = out[c + 2];
|
|
281
|
+
const length = Math.hypot(x, y, z);
|
|
282
|
+
if (length <= EPSILON)
|
|
283
|
+
continue;
|
|
284
|
+
out[c] = x / length;
|
|
285
|
+
out[c + 1] = y / length;
|
|
286
|
+
out[c + 2] = z / length;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** `pose.rotation[joint] = delta · pose.rotation[joint]`, normalised. */
|
|
290
|
+
function composeInto(pose, joint, delta) {
|
|
291
|
+
const at = joint * 4;
|
|
292
|
+
quat.set(CURRENT, pose.rotation[at], pose.rotation[at + 1], pose.rotation[at + 2], pose.rotation[at + 3]);
|
|
293
|
+
quat.multiply(CURRENT, delta, CURRENT);
|
|
294
|
+
quat.normalize(CURRENT, CURRENT);
|
|
295
|
+
for (let c = 0; c < 4; c++)
|
|
296
|
+
pose.rotation[at + c] = CURRENT[c];
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Below this a length is zero and an angle is nothing.
|
|
300
|
+
*
|
|
301
|
+
* Not a tuning constant: it is the width of the band where the arithmetic above stops being
|
|
302
|
+
* defined. Every use of it guards a division or an `acos` domain.
|
|
303
|
+
*/
|
|
304
|
+
const EPSILON = 1e-6;
|
|
305
|
+
/* Module scope, claimed once: a solve runs per chain per frame. */
|
|
306
|
+
const ROOT_POS = vec3.create();
|
|
307
|
+
const MID_POS = vec3.create();
|
|
308
|
+
const TIP_POS = vec3.create();
|
|
309
|
+
const TARGET = vec3.create();
|
|
310
|
+
const TO_TARGET = vec3.create();
|
|
311
|
+
const UPPER_DIR = vec3.create();
|
|
312
|
+
const CHAIN_DIR = vec3.create();
|
|
313
|
+
const TO_ROOT = vec3.create();
|
|
314
|
+
const TO_TIP = vec3.create();
|
|
315
|
+
const AXIS = vec3.create();
|
|
316
|
+
const POLE = vec3.create();
|
|
317
|
+
const ELBOW_FLAT = vec3.create();
|
|
318
|
+
const POLE_FLAT = vec3.create();
|
|
319
|
+
const LOCAL_AXIS = vec3.create();
|
|
320
|
+
const DELTA = quat.create();
|
|
321
|
+
const AIM = quat.create();
|
|
322
|
+
const CURRENT = quat.create();
|
|
323
|
+
const PARENT_ROT = mat3.create();
|
|
324
|
+
const INVERSE_PARENT = mat3.create();
|
|
325
|
+
const PARENT_QUAT = quat.create();
|
|
326
|
+
const INVERSE_QUAT = quat.create();
|
|
327
|
+
/* Two non-parallel axes, so a target parallel to one is not parallel to the other. */
|
|
328
|
+
const FALLBACK_POLE = vec3.fromValues(0, 0, 1);
|
|
329
|
+
const SECOND_FALLBACK = vec3.fromValues(1, 0, 0);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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 { sampleSpring, sampleSpringChain, springChainSettleSec, springSettleSec, } from './spring.ts';
|
|
33
|
+
export type { SpringAnchor, SpringLink, SpringSettings } from './spring.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
|
|
2
|
+
export { Skeleton } from './skeleton.js';
|
|
3
|
+
export { createPose, restPose } from './pose.js';
|
|
4
|
+
export { sampleClip } from './clip.js';
|
|
5
|
+
export { createRootMotion, extractRootMotion, stripRootMotion } from './rootMotion.js';
|
|
6
|
+
export { RigidAnimation, applyPoseToNode } from './rigid.js';
|
|
7
|
+
export { addPose, blendPoses, setJoint } from './blend.js';
|
|
8
|
+
export { BlendTree } from './blendTree.js';
|
|
9
|
+
export { AnimationStateMachine } from './stateMachine.js';
|
|
10
|
+
export { solveTwoBone } from './ik.js';
|
|
11
|
+
export { buildRetargetMap, retargetPose } from './retarget.js';
|
|
12
|
+
export { sampleSpring, sampleSpringChain, springChainSettleSec, springSettleSec, } from './spring.js';
|
package/dist/pose.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pose: one transform per joint, as three flat arrays rather than an array of objects.
|
|
3
|
+
*
|
|
4
|
+
* **Structure-of-arrays because a pose is bulk data on a hot path.** A rig is sixty to ninety
|
|
5
|
+
* joints and a scene holds several characters, so a pose per character per frame as objects is
|
|
6
|
+
* exactly the allocation the performance rules forbid. Three typed arrays are claimed once and
|
|
7
|
+
* written in place for the character's whole life, and the palette they resolve into is
|
|
8
|
+
* contiguous for the same reason.
|
|
9
|
+
*
|
|
10
|
+
* What it costs is that reading one joint is three indexed reads rather than a property access.
|
|
11
|
+
* What would make it wrong is a caller needing to hold a single joint's transform as a value —
|
|
12
|
+
* which `blend.ts`'s `setJoint` answers by writing rather than by handing one out.
|
|
13
|
+
*/
|
|
14
|
+
export interface Pose {
|
|
15
|
+
/** Three floats a joint. */
|
|
16
|
+
readonly translation: Float32Array;
|
|
17
|
+
/** Four floats a joint, xyzw, in the order `gl-matrix` uses. */
|
|
18
|
+
readonly rotation: Float32Array;
|
|
19
|
+
/** Three floats a joint. */
|
|
20
|
+
readonly scale: Float32Array;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A pose sized for `jointCount` joints, already at rest.
|
|
24
|
+
*
|
|
25
|
+
* **At rest rather than zeroed**, and that is a correctness matter rather than a convenience.
|
|
26
|
+
* A zero quaternion normalises to NaN and a zero scale collapses every vertex the joint touches,
|
|
27
|
+
* so a caller who allocates a pose and forgets to rest it would get a rig that vanishes rather
|
|
28
|
+
* than one that stands still. The same reasoning `vertexDefaults.ts` gives for handing an absent
|
|
29
|
+
* tangent `(1, 0, 0, 1)` instead of zero: the safe default is a usable value, never a sentinel
|
|
30
|
+
* that arithmetic turns into NaN.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createPose(jointCount: number): Pose;
|
|
33
|
+
/**
|
|
34
|
+
* Write the rest pose — no translation, identity rotation, unit scale — into an existing pose.
|
|
35
|
+
*
|
|
36
|
+
* Separate from `createPose` so a caller can return a pose to rest without allocating a second
|
|
37
|
+
* one, which is what a state machine does when it re-enters a state.
|
|
38
|
+
*/
|
|
39
|
+
export declare function restPose(jointCount: number, out: Pose): void;
|
package/dist/pose.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pose: one transform per joint, as three flat arrays rather than an array of objects.
|
|
3
|
+
*
|
|
4
|
+
* **Structure-of-arrays because a pose is bulk data on a hot path.** A rig is sixty to ninety
|
|
5
|
+
* joints and a scene holds several characters, so a pose per character per frame as objects is
|
|
6
|
+
* exactly the allocation the performance rules forbid. Three typed arrays are claimed once and
|
|
7
|
+
* written in place for the character's whole life, and the palette they resolve into is
|
|
8
|
+
* contiguous for the same reason.
|
|
9
|
+
*
|
|
10
|
+
* What it costs is that reading one joint is three indexed reads rather than a property access.
|
|
11
|
+
* What would make it wrong is a caller needing to hold a single joint's transform as a value —
|
|
12
|
+
* which `blend.ts`'s `setJoint` answers by writing rather than by handing one out.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* A pose sized for `jointCount` joints, already at rest.
|
|
16
|
+
*
|
|
17
|
+
* **At rest rather than zeroed**, and that is a correctness matter rather than a convenience.
|
|
18
|
+
* A zero quaternion normalises to NaN and a zero scale collapses every vertex the joint touches,
|
|
19
|
+
* so a caller who allocates a pose and forgets to rest it would get a rig that vanishes rather
|
|
20
|
+
* than one that stands still. The same reasoning `vertexDefaults.ts` gives for handing an absent
|
|
21
|
+
* tangent `(1, 0, 0, 1)` instead of zero: the safe default is a usable value, never a sentinel
|
|
22
|
+
* that arithmetic turns into NaN.
|
|
23
|
+
*/
|
|
24
|
+
export function createPose(jointCount) {
|
|
25
|
+
const pose = {
|
|
26
|
+
translation: new Float32Array(jointCount * 3),
|
|
27
|
+
rotation: new Float32Array(jointCount * 4),
|
|
28
|
+
scale: new Float32Array(jointCount * 3),
|
|
29
|
+
};
|
|
30
|
+
restPose(jointCount, pose);
|
|
31
|
+
return pose;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Write the rest pose — no translation, identity rotation, unit scale — into an existing pose.
|
|
35
|
+
*
|
|
36
|
+
* Separate from `createPose` so a caller can return a pose to rest without allocating a second
|
|
37
|
+
* one, which is what a state machine does when it re-enters a state.
|
|
38
|
+
*/
|
|
39
|
+
export function restPose(jointCount, out) {
|
|
40
|
+
out.translation.fill(0);
|
|
41
|
+
out.scale.fill(1);
|
|
42
|
+
for (let j = 0; j < jointCount; j++) {
|
|
43
|
+
const at = j * 4;
|
|
44
|
+
out.rotation[at] = 0;
|
|
45
|
+
out.rotation[at + 1] = 0;
|
|
46
|
+
out.rotation[at + 2] = 0;
|
|
47
|
+
out.rotation[at + 3] = 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Pose } from './pose.ts';
|
|
2
|
+
import type { Skeleton } from './skeleton.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Playing one skeleton's clip on another.
|
|
5
|
+
*
|
|
6
|
+
* **Rotations transfer and translations do not, and that is the whole of it.** Two rigs of
|
|
7
|
+
* different proportions share joint *orientations* and not bone lengths, so copying a translation
|
|
8
|
+
* puts the taller rig's limbs inside its own body. The root is the exception, because that is
|
|
9
|
+
* where locomotion lives — a walk whose root did not move would be a moonwalk.
|
|
10
|
+
*
|
|
11
|
+
* What this does not do is retarget between *different topologies*: a rig with a split spine
|
|
12
|
+
* playing a clip from one with a single spine needs a decision about how to distribute the
|
|
13
|
+
* rotation, and that decision belongs to whoever knows what the character is. This matches by
|
|
14
|
+
* name, transfers what matches, and says what did not.
|
|
15
|
+
*/
|
|
16
|
+
export interface RetargetMap {
|
|
17
|
+
/** Source joint index to target joint index, or -1 where the target has no such joint. */
|
|
18
|
+
readonly indices: Int16Array;
|
|
19
|
+
/** Source joint names the target does not have, in source order. */
|
|
20
|
+
readonly unmatched: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Match two skeletons by joint name.
|
|
24
|
+
*
|
|
25
|
+
* **Exact matching, and no name database.** A table of "hips means Hips means pelvis" rots
|
|
26
|
+
* silently and its failure is a limb that does not move, which no test of this engine could catch
|
|
27
|
+
* — the same argument the gamepad layer makes for shipping no device database. A consumer whose
|
|
28
|
+
* two rigs disagree about capitalisation knows that and can rename; this engine guessing would be
|
|
29
|
+
* wrong in a way they could not see.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildRetargetMap(from: Skeleton, to: Skeleton): RetargetMap;
|
|
32
|
+
/**
|
|
33
|
+
* Write `source`, played on `from`, onto `to` in `out`. Allocates nothing.
|
|
34
|
+
*
|
|
35
|
+
* A joint the target has and the source does not is **left exactly as it was found**, so a
|
|
36
|
+
* character with a tail wearing a clip from one without keeps its tail wherever its own idle put
|
|
37
|
+
* it rather than snapping to rest. That is the layering `sampleClip` provides one level down, and
|
|
38
|
+
* it is what lets a retargeted clip be laid over a base pose.
|
|
39
|
+
*/
|
|
40
|
+
export declare function retargetPose(map: RetargetMap, from: Skeleton, source: Pose, to: Skeleton, out: Pose): void;
|
package/dist/retarget.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Match two skeletons by joint name.
|
|
3
|
+
*
|
|
4
|
+
* **Exact matching, and no name database.** A table of "hips means Hips means pelvis" rots
|
|
5
|
+
* silently and its failure is a limb that does not move, which no test of this engine could catch
|
|
6
|
+
* — the same argument the gamepad layer makes for shipping no device database. A consumer whose
|
|
7
|
+
* two rigs disagree about capitalisation knows that and can rename; this engine guessing would be
|
|
8
|
+
* wrong in a way they could not see.
|
|
9
|
+
*/
|
|
10
|
+
export function buildRetargetMap(from, to) {
|
|
11
|
+
const byName = new Map();
|
|
12
|
+
to.joints.forEach((joint, at) => {
|
|
13
|
+
/* First wins, so a rig with two joints of one name maps to the earlier — the one nearer the
|
|
14
|
+
root, since joints are sorted parents-first. */
|
|
15
|
+
if (!byName.has(joint.name))
|
|
16
|
+
byName.set(joint.name, at);
|
|
17
|
+
});
|
|
18
|
+
const indices = new Int16Array(from.jointCount);
|
|
19
|
+
const unmatched = [];
|
|
20
|
+
from.joints.forEach((joint, at) => {
|
|
21
|
+
const found = byName.get(joint.name);
|
|
22
|
+
if (found === undefined) {
|
|
23
|
+
indices[at] = -1;
|
|
24
|
+
unmatched.push(joint.name);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
indices[at] = found;
|
|
28
|
+
});
|
|
29
|
+
/*
|
|
30
|
+
* Reported rather than acted on. Whether a missing joint is a broken import, a deliberately
|
|
31
|
+
* simpler rig, or something to substitute for is a product decision — the same line `rebind`
|
|
32
|
+
* draws when it reports the actions it displaced rather than deciding about them.
|
|
33
|
+
*/
|
|
34
|
+
return { indices, unmatched };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Write `source`, played on `from`, onto `to` in `out`. Allocates nothing.
|
|
38
|
+
*
|
|
39
|
+
* A joint the target has and the source does not is **left exactly as it was found**, so a
|
|
40
|
+
* character with a tail wearing a clip from one without keeps its tail wherever its own idle put
|
|
41
|
+
* it rather than snapping to rest. That is the layering `sampleClip` provides one level down, and
|
|
42
|
+
* it is what lets a retargeted clip be laid over a base pose.
|
|
43
|
+
*/
|
|
44
|
+
export function retargetPose(map, from, source, to, out) {
|
|
45
|
+
for (let j = 0; j < from.jointCount; j++) {
|
|
46
|
+
const target = map.indices[j] ?? -1;
|
|
47
|
+
/*
|
|
48
|
+
* The `-1` is the real guard; the upper bound is unreachable from a map `buildRetargetMap`
|
|
49
|
+
* produced, since it only ever names a joint the target has. Kept for a hand-built map, and
|
|
50
|
+
* said so rather than left to read as load-bearing — perturbing it away leaves every test
|
|
51
|
+
* green, which is how that was established.
|
|
52
|
+
*/
|
|
53
|
+
if (target < 0 || target >= to.jointCount)
|
|
54
|
+
continue;
|
|
55
|
+
const sourceAt = j * 4;
|
|
56
|
+
const targetAt = target * 4;
|
|
57
|
+
for (let c = 0; c < 4; c++)
|
|
58
|
+
out.rotation[targetAt + c] = source.rotation[sourceAt + c];
|
|
59
|
+
/*
|
|
60
|
+
* The root's translation, and only the root's. Everything below it is placed by its parent, so
|
|
61
|
+
* a bone length is the target rig's own fact — copying the source's would rebuild the target
|
|
62
|
+
* as the source, one joint at a time, which is precisely what retargeting exists to avoid.
|
|
63
|
+
*/
|
|
64
|
+
if ((to.joints[target]?.parent ?? -1) < 0) {
|
|
65
|
+
const sourceT = j * 3;
|
|
66
|
+
const targetT = target * 3;
|
|
67
|
+
for (let c = 0; c < 3; c++) {
|
|
68
|
+
out.translation[targetT + c] = source.translation[sourceT + c];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
package/dist/rigid.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SceneNode } from '@driftengine/core';
|
|
2
|
+
import type { AnimationClip } from './clip.ts';
|
|
3
|
+
import type { Pose } from './pose.ts';
|
|
4
|
+
/**
|
|
5
|
+
* Rigid TRS: a clip driving a node's transform, with no skeleton and no palette anywhere.
|
|
6
|
+
*
|
|
7
|
+
* A door swinging, a lift rising, a turntable turning — geometry that moves as a whole rather than
|
|
8
|
+
* deforming. It is the cheapest thing animation offers and the one most games reach for first, and
|
|
9
|
+
* it needs no shader change at all.
|
|
10
|
+
*
|
|
11
|
+
* **This module is the only place this package touches core**, which is what makes the peer
|
|
12
|
+
* dependency load-bearing rather than declared. A node is core's, a pose is this package's, and
|
|
13
|
+
* this is the one line between them — kept in a module of its own so the rest of the package
|
|
14
|
+
* stays a pure function of time over typed arrays and could be tested with core absent.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Write one joint of a pose onto a node's local transform. Allocates nothing.
|
|
18
|
+
*
|
|
19
|
+
* `markMoved` is called because `SceneNode` says at its own fields that writing a transform in
|
|
20
|
+
* place does not mark it dirty. Omitting it is invisible on the first frame — a fresh node is
|
|
21
|
+
* dirty already — and shows on the second as a node whose world matrix never catches up, which
|
|
22
|
+
* reads as an animation that plays once and freezes.
|
|
23
|
+
*/
|
|
24
|
+
export declare function applyPoseToNode(pose: Pose, joint: number, node: SceneNode): void;
|
|
25
|
+
/**
|
|
26
|
+
* A clip bound to a list of nodes, one per joint the clip's tracks name.
|
|
27
|
+
*
|
|
28
|
+
* The pose is claimed once at construction and reused, so `apply` allocates nothing however often
|
|
29
|
+
* it runs.
|
|
30
|
+
*/
|
|
31
|
+
export declare class RigidAnimation {
|
|
32
|
+
private readonly clip;
|
|
33
|
+
private readonly nodes;
|
|
34
|
+
private readonly pose;
|
|
35
|
+
/**
|
|
36
|
+
* The distinct joints this clip drives, collected once.
|
|
37
|
+
*
|
|
38
|
+
* Iterating `tracks` directly would write a joint's node once per track it has — three times for
|
|
39
|
+
* a joint carrying translation, rotation and scale — which is correct and is three times the
|
|
40
|
+
* work every frame. Collected here because the set cannot change: a clip is immutable.
|
|
41
|
+
*/
|
|
42
|
+
private readonly driven;
|
|
43
|
+
/**
|
|
44
|
+
* @param nodes Indexed by joint. `null` for a joint this scene did not instantiate.
|
|
45
|
+
*/
|
|
46
|
+
constructor(clip: AnimationClip, nodes: readonly (SceneNode | null)[]);
|
|
47
|
+
/**
|
|
48
|
+
* Sample at a caller-supplied time and write every bound node. Reads no clock.
|
|
49
|
+
*
|
|
50
|
+
* A joint with no node is skipped rather than refused. An imported clip names joints a scene may
|
|
51
|
+
* not have instantiated, and the right behaviour is the parts that exist moving — the
|
|
52
|
+
* reliability rules forbid throwing in a frame loop outright, and a missing prop is not a reason
|
|
53
|
+
* to stop a scene.
|
|
54
|
+
*/
|
|
55
|
+
apply(timeSec: number): void;
|
|
56
|
+
}
|