@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/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
|
+
}
|
package/dist/skeleton.js
ADDED
|
@@ -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();
|