@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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { blendPoses } from './blend.js';
|
|
2
|
+
import { createPose } from './pose.js';
|
|
3
|
+
export class AnimationStateMachine {
|
|
4
|
+
transitions;
|
|
5
|
+
states = new Map();
|
|
6
|
+
parameters = {};
|
|
7
|
+
active;
|
|
8
|
+
/** The state being left while a fade runs, or null when none is. */
|
|
9
|
+
leaving = null;
|
|
10
|
+
fadeElapsed = 0;
|
|
11
|
+
fadeDuration = 0;
|
|
12
|
+
/** One per side of a fade, claimed at construction so `evaluate` allocates nothing. */
|
|
13
|
+
fromPose;
|
|
14
|
+
toPose;
|
|
15
|
+
/**
|
|
16
|
+
* Each state's own elapsed time.
|
|
17
|
+
*
|
|
18
|
+
* Per state rather than one for the machine, so a looping clip is not restarted every time
|
|
19
|
+
* something else changes — and so a state re-entered later resumes where its own clip was rather
|
|
20
|
+
* than wherever the machine happened to be.
|
|
21
|
+
*/
|
|
22
|
+
elapsed = new Map();
|
|
23
|
+
/**
|
|
24
|
+
* @param bind The bind pose, or omitted for one whose channels start at rest.
|
|
25
|
+
*
|
|
26
|
+
* The same reason `BlendTree` takes one: a crossfade calls `blendPoses`, which interpolates
|
|
27
|
+
* every channel, so without a bind pose the two sides of a fade start at zero translation and
|
|
28
|
+
* the figure folds toward its own origin for the length of the transition.
|
|
29
|
+
*/
|
|
30
|
+
constructor(states, transitions, jointCount, bind) {
|
|
31
|
+
this.transitions = transitions;
|
|
32
|
+
const first = states[0];
|
|
33
|
+
if (first === undefined)
|
|
34
|
+
throw new Error('AnimationStateMachine: needs at least one state');
|
|
35
|
+
for (const state of states) {
|
|
36
|
+
this.states.set(state.name, state);
|
|
37
|
+
this.elapsed.set(state.name, 0);
|
|
38
|
+
}
|
|
39
|
+
/*
|
|
40
|
+
* Checked here rather than met at the moment a transition fires. A transition naming a state
|
|
41
|
+
* that does not exist is a configuration error with exactly one correct outcome, and finding
|
|
42
|
+
* it at construction is the difference between a message naming the state and a character that
|
|
43
|
+
* silently never leaves an animation.
|
|
44
|
+
*/
|
|
45
|
+
for (const transition of transitions) {
|
|
46
|
+
for (const name of [transition.from, transition.to]) {
|
|
47
|
+
if (!this.states.has(name)) {
|
|
48
|
+
throw new Error(`AnimationStateMachine: a transition names state "${name}", which was not given`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
this.active = first;
|
|
53
|
+
this.fromPose = createPose(jointCount);
|
|
54
|
+
this.toPose = createPose(jointCount);
|
|
55
|
+
if (bind !== undefined) {
|
|
56
|
+
for (const pose of [this.fromPose, this.toPose]) {
|
|
57
|
+
pose.translation.set(bind.translation.subarray(0, pose.translation.length));
|
|
58
|
+
pose.rotation.set(bind.rotation.subarray(0, pose.rotation.length));
|
|
59
|
+
pose.scale.set(bind.scale.subarray(0, pose.scale.length));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
get current() {
|
|
64
|
+
return this.active.name;
|
|
65
|
+
}
|
|
66
|
+
/** Whether a crossfade is running. A caller wanting to gate input on one asks this. */
|
|
67
|
+
get transitioning() {
|
|
68
|
+
return this.leaving !== null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Set a parameter the transitions read.
|
|
72
|
+
*
|
|
73
|
+
* Unlike `BlendTree.set` this accepts any name: the predicates are the consumer's own functions
|
|
74
|
+
* and this class cannot know which keys they read. The tree below it still refuses a name no node
|
|
75
|
+
* declares, which is where a typo that matters is caught.
|
|
76
|
+
*/
|
|
77
|
+
set(parameter, value) {
|
|
78
|
+
this.parameters[parameter] = value;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Advance by a caller-supplied step: the fade, the active state's clock, and any transition
|
|
82
|
+
* whose condition now holds.
|
|
83
|
+
*/
|
|
84
|
+
advance(dtSec) {
|
|
85
|
+
if (this.leaving === null)
|
|
86
|
+
this.start();
|
|
87
|
+
this.elapsed.set(this.active.name, (this.elapsed.get(this.active.name) ?? 0) + dtSec);
|
|
88
|
+
if (this.leaving === null)
|
|
89
|
+
return;
|
|
90
|
+
this.elapsed.set(this.leaving.name, (this.elapsed.get(this.leaving.name) ?? 0) + dtSec);
|
|
91
|
+
/*
|
|
92
|
+
* **The frame that triggers a transition advances its fade too.** Starting the fade at zero
|
|
93
|
+
* and leaving it there until the next call is a one-frame stall at the top of every
|
|
94
|
+
* transition — invisible at 60 Hz on a long fade and a whole transition on a short one, which
|
|
95
|
+
* is the case a fade is shortest for.
|
|
96
|
+
*/
|
|
97
|
+
this.fadeElapsed += dtSec;
|
|
98
|
+
/*
|
|
99
|
+
* A running fade completes; it is not re-evaluated against the condition that started it. A
|
|
100
|
+
* transition interruptible by its own trigger would stutter whenever that parameter sat on its
|
|
101
|
+
* threshold — which is exactly where a speed parameter spends its time.
|
|
102
|
+
*/
|
|
103
|
+
if (this.fadeElapsed >= this.fadeDuration)
|
|
104
|
+
this.leaving = null;
|
|
105
|
+
}
|
|
106
|
+
/** Take the first transition out of the active state whose condition holds. */
|
|
107
|
+
start() {
|
|
108
|
+
for (const transition of this.transitions) {
|
|
109
|
+
if (transition.from !== this.active.name)
|
|
110
|
+
continue;
|
|
111
|
+
if (!transition.when(this.parameters))
|
|
112
|
+
continue;
|
|
113
|
+
const next = this.states.get(transition.to);
|
|
114
|
+
if (next === undefined)
|
|
115
|
+
continue;
|
|
116
|
+
this.leaving = this.active;
|
|
117
|
+
this.active = next;
|
|
118
|
+
this.elapsed.set(next.name, 0);
|
|
119
|
+
this.fadeElapsed = 0;
|
|
120
|
+
this.fadeDuration = transition.durationSec;
|
|
121
|
+
/*
|
|
122
|
+
* A zero-duration transition is a switch. Ended here rather than divided by below, because
|
|
123
|
+
* the division is what would produce the NaN — and a NaN weight reaches the palette and
|
|
124
|
+
* takes every vertex it touches.
|
|
125
|
+
*/
|
|
126
|
+
if (this.fadeDuration <= 0)
|
|
127
|
+
this.leaving = null;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** The current pose, crossfaded if a transition is running. Allocates nothing. */
|
|
132
|
+
evaluate(out) {
|
|
133
|
+
const activeTime = this.elapsed.get(this.active.name) ?? 0;
|
|
134
|
+
if (this.leaving === null) {
|
|
135
|
+
this.active.tree.evaluate(activeTime, out);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
this.leaving.tree.evaluate(this.elapsed.get(this.leaving.name) ?? 0, this.fromPose);
|
|
139
|
+
this.active.tree.evaluate(activeTime, this.toPose);
|
|
140
|
+
blendPoses(this.fromPose, this.toPose, this.fadeElapsed / this.fadeDuration, out);
|
|
141
|
+
}
|
|
142
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@driftengine/animation",
|
|
3
|
+
"version": "3.61.0",
|
|
4
|
+
"description": "Skeletons, clips, poses and the graphs over them: sampling as a pure function of a caller-supplied time",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"drift-source": "./src/index.ts",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json",
|
|
16
|
+
"./*": "./*"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*.test.mjs",
|
|
23
|
+
"!src/**/__snapshots__",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"NOTICE"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@driftengine/drft": "3.61.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@driftengine/core": "3.61.0"
|
|
34
|
+
},
|
|
35
|
+
"author": "Drift Technologies",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/drftrun/driftengine.git",
|
|
39
|
+
"directory": "packages/animation"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/drftrun/driftengine#readme",
|
|
42
|
+
"bugs": "https://github.com/drftrun/driftengine/issues",
|
|
43
|
+
"keywords": [
|
|
44
|
+
"driftengine",
|
|
45
|
+
"3d",
|
|
46
|
+
"webgl",
|
|
47
|
+
"webgpu",
|
|
48
|
+
"typescript",
|
|
49
|
+
"skeletal-animation",
|
|
50
|
+
"blend-tree",
|
|
51
|
+
"inverse-kinematics",
|
|
52
|
+
"morph-targets",
|
|
53
|
+
"skinning"
|
|
54
|
+
],
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=22.12.0"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/blend.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { Pose } from './pose.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Combining poses: a blend between two, an additive layer over one, and a joint written directly.
|
|
5
|
+
*
|
|
6
|
+
* All three write into a caller-owned pose and allocate nothing, because every one of them runs
|
|
7
|
+
* per character per frame. All three are pure functions of their inputs — no clock, no RNG — which
|
|
8
|
+
* is the determinism contract `sampleClip` establishes and which a graph over clips has to keep.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Blend `a` toward `b` by `t`, into `out`.
|
|
13
|
+
*
|
|
14
|
+
* **Clamped rather than extrapolated.** A weight past the ends puts limbs outside their range,
|
|
15
|
+
* which reads as a broken rig rather than as a weight out of bounds — the same reasoning
|
|
16
|
+
* `sampleClip` holds at a track's ends for. What it costs is that a caller cannot deliberately
|
|
17
|
+
* overshoot; what would make it wrong is somebody wanting to, and the honest answer then is a
|
|
18
|
+
* function that says so in its name.
|
|
19
|
+
*
|
|
20
|
+
* Safe when `out` is also `a` or `b`: every component is read before it is written.
|
|
21
|
+
*/
|
|
22
|
+
export function blendPoses(a: Pose, b: Pose, t: number, out: Pose): void {
|
|
23
|
+
const weight = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
24
|
+
const joints = out.rotation.length / 4;
|
|
25
|
+
|
|
26
|
+
for (let j = 0; j < joints; j++) {
|
|
27
|
+
const v = j * 3;
|
|
28
|
+
for (let c = 0; c < 3; c++) {
|
|
29
|
+
const from = a.translation[v + c] as number;
|
|
30
|
+
out.translation[v + c] = from + ((b.translation[v + c] as number) - from) * weight;
|
|
31
|
+
const scaleFrom = a.scale[v + c] as number;
|
|
32
|
+
out.scale[v + c] = scaleFrom + ((b.scale[v + c] as number) - scaleFrom) * weight;
|
|
33
|
+
}
|
|
34
|
+
slerpInto(a.rotation, j * 4, b.rotation, j * 4, weight, out.rotation, j * 4);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply `delta` on top of `base`, scaled by `weight`, into `out`.
|
|
40
|
+
*
|
|
41
|
+
* **A delta, not a target**, and the difference is the whole of what additive is for: a wave laid
|
|
42
|
+
* over a walk has to move the arm relative to wherever the walk put it rather than replace it. So
|
|
43
|
+
* translation adds, rotation composes, and **scale multiplies** — a scale delta of 1 means
|
|
44
|
+
* unchanged, where adding would make it double.
|
|
45
|
+
*
|
|
46
|
+
* Weight zero is the base untouched, which is what lets a layer fade in from nothing.
|
|
47
|
+
*/
|
|
48
|
+
export function addPose(base: Pose, delta: Pose, weight: number, out: Pose): void {
|
|
49
|
+
const w = weight < 0 ? 0 : weight;
|
|
50
|
+
const joints = out.rotation.length / 4;
|
|
51
|
+
|
|
52
|
+
for (let j = 0; j < joints; j++) {
|
|
53
|
+
const v = j * 3;
|
|
54
|
+
for (let c = 0; c < 3; c++) {
|
|
55
|
+
out.translation[v + c] =
|
|
56
|
+
(base.translation[v + c] as number) + (delta.translation[v + c] as number) * w;
|
|
57
|
+
/* One at weight zero, the delta's own factor at weight one, interpolated between. */
|
|
58
|
+
const factor = 1 + ((delta.scale[v + c] as number) - 1) * w;
|
|
59
|
+
out.scale[v + c] = (base.scale[v + c] as number) * factor;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/*
|
|
63
|
+
* The delta's rotation is scaled by sliding it away from identity along the shorter arc, then
|
|
64
|
+
* composed onto the base. Scaling by slerping from identity rather than by multiplying the
|
|
65
|
+
* components is what keeps a half-weight delta a half *rotation* instead of a shortened
|
|
66
|
+
* quaternion that renormalises to the whole of it.
|
|
67
|
+
*/
|
|
68
|
+
slerpInto(IDENTITY, 0, delta.rotation, j * 4, w > 1 ? 1 : w, SCALED, 0);
|
|
69
|
+
multiplyInto(base.rotation, j * 4, SCALED, 0, out.rotation, j * 4);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Write one joint's transform straight into a pose.
|
|
75
|
+
*
|
|
76
|
+
* What a solver drives. Track B's ragdolls need a pose to be *data* rather than only the result of
|
|
77
|
+
* sampling, and this is the whole of that requirement — stated here so the seam exists before the
|
|
78
|
+
* track that needs it, rather than being retrofitted around a closed type.
|
|
79
|
+
*/
|
|
80
|
+
export function setJoint(
|
|
81
|
+
pose: Pose,
|
|
82
|
+
joint: number,
|
|
83
|
+
translation: ArrayLike<number>,
|
|
84
|
+
rotation: ArrayLike<number>,
|
|
85
|
+
scale: ArrayLike<number>,
|
|
86
|
+
): void {
|
|
87
|
+
const v = joint * 3;
|
|
88
|
+
const r = joint * 4;
|
|
89
|
+
for (let c = 0; c < 3; c++) {
|
|
90
|
+
pose.translation[v + c] = translation[c] as number;
|
|
91
|
+
pose.scale[v + c] = scale[c] as number;
|
|
92
|
+
}
|
|
93
|
+
for (let c = 0; c < 4; c++) pose.rotation[r + c] = rotation[c] as number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Spherical interpolation between two quaternions held in flat arrays, along the shorter arc.
|
|
98
|
+
*
|
|
99
|
+
* The same arithmetic `clip.ts` performs between two keys, over two poses instead — and with the
|
|
100
|
+
* same hemisphere correction, for the same reason: a quaternion and its negation are one
|
|
101
|
+
* orientation, so two poses can be numerically far apart while being geometrically close, and
|
|
102
|
+
* without the flip a limb rotates the long way through the body.
|
|
103
|
+
*
|
|
104
|
+
* Not shared with `clip.ts` because that one reads a keyframe stride and this one reads a joint
|
|
105
|
+
* stride; the arithmetic between them is eight lines, and a shared function taking four offsets
|
|
106
|
+
* would be harder to read than either. What would make that wrong is a third caller.
|
|
107
|
+
*/
|
|
108
|
+
function slerpInto(
|
|
109
|
+
a: ArrayLike<number>,
|
|
110
|
+
aAt: number,
|
|
111
|
+
b: ArrayLike<number>,
|
|
112
|
+
bAt: number,
|
|
113
|
+
t: number,
|
|
114
|
+
out: Float32Array,
|
|
115
|
+
outAt: number,
|
|
116
|
+
): void {
|
|
117
|
+
let ax = a[aAt] as number;
|
|
118
|
+
let ay = a[aAt + 1] as number;
|
|
119
|
+
let az = a[aAt + 2] as number;
|
|
120
|
+
let aw = a[aAt + 3] as number;
|
|
121
|
+
const bx = b[bAt] as number;
|
|
122
|
+
const by = b[bAt + 1] as number;
|
|
123
|
+
const bz = b[bAt + 2] as number;
|
|
124
|
+
const bw = b[bAt + 3] as number;
|
|
125
|
+
|
|
126
|
+
let dot = ax * bx + ay * by + az * bz + aw * bw;
|
|
127
|
+
if (dot < 0) {
|
|
128
|
+
ax = -ax;
|
|
129
|
+
ay = -ay;
|
|
130
|
+
az = -az;
|
|
131
|
+
aw = -aw;
|
|
132
|
+
dot = -dot;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let s0: number;
|
|
136
|
+
let s1: number;
|
|
137
|
+
/* Nearly parallel falls back to a straight blend: `acos` at or past 1 is 0 or NaN, and dividing
|
|
138
|
+
by `sin(0)` is the NaN that reaches the palette and takes every vertex the joint touches. */
|
|
139
|
+
if (dot > 0.9995) {
|
|
140
|
+
s0 = 1 - t;
|
|
141
|
+
s1 = t;
|
|
142
|
+
} else {
|
|
143
|
+
const theta = Math.acos(dot);
|
|
144
|
+
const sinTheta = Math.sin(theta);
|
|
145
|
+
s0 = Math.sin((1 - t) * theta) / sinTheta;
|
|
146
|
+
s1 = Math.sin(t * theta) / sinTheta;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let x = s0 * ax + s1 * bx;
|
|
150
|
+
let y = s0 * ay + s1 * by;
|
|
151
|
+
let z = s0 * az + s1 * bz;
|
|
152
|
+
let w = s0 * aw + s1 * bw;
|
|
153
|
+
const length = Math.hypot(x, y, z, w);
|
|
154
|
+
if (length > 0) {
|
|
155
|
+
x /= length;
|
|
156
|
+
y /= length;
|
|
157
|
+
z /= length;
|
|
158
|
+
w /= length;
|
|
159
|
+
}
|
|
160
|
+
out[outAt] = x;
|
|
161
|
+
out[outAt + 1] = y;
|
|
162
|
+
out[outAt + 2] = z;
|
|
163
|
+
out[outAt + 3] = w;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Quaternion product, in flat arrays. Safe when `out` overlaps either input. */
|
|
167
|
+
function multiplyInto(
|
|
168
|
+
a: ArrayLike<number>,
|
|
169
|
+
aAt: number,
|
|
170
|
+
b: ArrayLike<number>,
|
|
171
|
+
bAt: number,
|
|
172
|
+
out: Float32Array,
|
|
173
|
+
outAt: number,
|
|
174
|
+
): void {
|
|
175
|
+
const ax = a[aAt] as number;
|
|
176
|
+
const ay = a[aAt + 1] as number;
|
|
177
|
+
const az = a[aAt + 2] as number;
|
|
178
|
+
const aw = a[aAt + 3] as number;
|
|
179
|
+
const bx = b[bAt] as number;
|
|
180
|
+
const by = b[bAt + 1] as number;
|
|
181
|
+
const bz = b[bAt + 2] as number;
|
|
182
|
+
const bw = b[bAt + 3] as number;
|
|
183
|
+
out[outAt] = aw * bx + ax * bw + ay * bz - az * by;
|
|
184
|
+
out[outAt + 1] = aw * by - ax * bz + ay * bw + az * bx;
|
|
185
|
+
out[outAt + 2] = aw * bz + ax * by - ay * bx + az * bw;
|
|
186
|
+
out[outAt + 3] = aw * bw - ax * bx - ay * by - az * bz;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* Module scope, claimed once: `addPose` runs per character per frame. */
|
|
190
|
+
const IDENTITY = new Float32Array([0, 0, 0, 1]);
|
|
191
|
+
const SCALED = new Float32Array(4);
|
package/src/blendTree.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type { AnimationClip } from './clip.ts';
|
|
2
|
+
import { sampleClip } from './clip.ts';
|
|
3
|
+
import { blendPoses } from './blend.ts';
|
|
4
|
+
import type { Pose } from './pose.ts';
|
|
5
|
+
import { createPose } from './pose.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A tree of clips blended by parameters the game names.
|
|
9
|
+
*
|
|
10
|
+
* **The parameters are the consumer's own strings, never an engine enumeration**, for the reason
|
|
11
|
+
* the input action maps give: a game's verbs are a game's business, and an engine that enumerated
|
|
12
|
+
* them would be deciding what a character can be doing.
|
|
13
|
+
*
|
|
14
|
+
* Evaluation is a pure function of a caller-supplied time and the current parameters. Nothing here
|
|
15
|
+
* reads a clock, which is the contract `sampleClip` establishes and which a graph over clips has
|
|
16
|
+
* to keep or the replay story ends one layer up.
|
|
17
|
+
*
|
|
18
|
+
* **A parameter is one of two things, and the tree keeps them apart.** A *blend* parameter is a
|
|
19
|
+
* weight: which children a set brackets, how far a lerp has gone. A *clock* is a time: which
|
|
20
|
+
* instant of its own clip a node is sampled at, named by a `clip` node's `clock` and defaulting to
|
|
21
|
+
* the time `evaluate` was given. A name may be one or the other and never both — see `clocks` for
|
|
22
|
+
* why that is refused rather than allowed.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type BlendNode =
|
|
26
|
+
| {
|
|
27
|
+
readonly kind: 'clip';
|
|
28
|
+
readonly clip: AnimationClip;
|
|
29
|
+
/**
|
|
30
|
+
* The parameter whose value is this clip's own time, or omitted for the frame clock.
|
|
31
|
+
*
|
|
32
|
+
* **A tree had one clock for every node until 2026-08-28, and that cannot express the
|
|
33
|
+
* canonical blend space.** A locomotion set is stand, walk, run over one speed parameter —
|
|
34
|
+
* and a stride has to advance with **distance travelled** or the foot slides while the body
|
|
35
|
+
* passes over it, which is the fact `rootMotion` exists for, while an idle has to advance
|
|
36
|
+
* with **time**, because somebody standing still is still breathing. On one clock one of the
|
|
37
|
+
* two is wrong: share the distance and the idle freezes whenever nobody moves, share the time
|
|
38
|
+
* and the walk skates. Reported from outside, where the workaround was two clocks kept by
|
|
39
|
+
* hand and a `blendPoses` per overlay — which gives up the declared graph, the scratch poses
|
|
40
|
+
* claimed at construction, and a state machine's crossfades on top.
|
|
41
|
+
*
|
|
42
|
+
* **The value is a time on the clip's own axis, in seconds**, and what advances it is the
|
|
43
|
+
* caller's business: a stride measured in metres is divided by the metres a cycle covers and
|
|
44
|
+
* multiplied by the cycle's duration, and an angle turned is the same arithmetic. The engine
|
|
45
|
+
* does not know about metres, and a parameter that meant metres here would be the engine
|
|
46
|
+
* deciding what a character is doing.
|
|
47
|
+
*
|
|
48
|
+
* **What it gives up** is inheritance: a clock names one clip, so a subtree of four gait
|
|
49
|
+
* clips names it four times. **What would change that** is a consumer with a subtree deep
|
|
50
|
+
* enough for the repetition to hide a mistake, and the answer then is a clock on an interior
|
|
51
|
+
* node that its children inherit — which is a rule about scope, so it is worth having a
|
|
52
|
+
* reason for rather than adding now.
|
|
53
|
+
*/
|
|
54
|
+
readonly clock?: string;
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
readonly kind: 'lerp';
|
|
58
|
+
readonly a: BlendNode;
|
|
59
|
+
readonly b: BlendNode;
|
|
60
|
+
readonly parameter: string;
|
|
61
|
+
}
|
|
62
|
+
| {
|
|
63
|
+
readonly kind: 'oneDimensional';
|
|
64
|
+
readonly children: readonly { readonly at: number; readonly node: BlendNode }[];
|
|
65
|
+
readonly parameter: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export class BlendTree {
|
|
69
|
+
private readonly parameters = new Map<string, number>();
|
|
70
|
+
/**
|
|
71
|
+
* One scratch pose per interior node, claimed at construction.
|
|
72
|
+
*
|
|
73
|
+
* A tree evaluates depth-first and every interior node needs somewhere to put its two operands,
|
|
74
|
+
* so without these `evaluate` would allocate per node per frame. Keyed by the node object
|
|
75
|
+
* itself, which is stable because a tree is immutable.
|
|
76
|
+
*/
|
|
77
|
+
private readonly scratch = new Map<BlendNode, [Pose, Pose]>();
|
|
78
|
+
/**
|
|
79
|
+
* Which names are clocks, so one cannot quietly be both.
|
|
80
|
+
*
|
|
81
|
+
* A parameter is a blend input and a clock is a time; one number doing both jobs is a rig that
|
|
82
|
+
* responds to the wrong dial, which reads as a broken tree rather than as a name used twice.
|
|
83
|
+
* `clock: 'speed'` written while meaning "the speed drives the blend" is the slip this catches.
|
|
84
|
+
*/
|
|
85
|
+
private readonly clocks = new Set<string>();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param bind The bind pose, or omitted for one whose channels start at rest.
|
|
89
|
+
*
|
|
90
|
+
* **Supply it whenever the clips are rotation-only, which is most of them.** `sampleClip` leaves
|
|
91
|
+
* a channel no track mentions exactly as it found it, so a rotation-only clip preserves whatever
|
|
92
|
+
* translations the pose already held. `blendPoses` cannot do that — it interpolates *every*
|
|
93
|
+
* channel of two poses — so without a bind pose here the scratch poses start at zero translation
|
|
94
|
+
* and every joint collapses onto its parent's origin the moment a tree or a transition is
|
|
95
|
+
* involved. Found by building a demo scene with it: one figure folded in on itself and the other,
|
|
96
|
+
* which happened to go through `retargetPose` instead, did not.
|
|
97
|
+
*/
|
|
98
|
+
constructor(
|
|
99
|
+
private readonly root: BlendNode,
|
|
100
|
+
jointCount: number,
|
|
101
|
+
private readonly bind?: Pose,
|
|
102
|
+
) {
|
|
103
|
+
this.prepare(root, jointCount);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Set a parameter, refusing a name no node declares.
|
|
108
|
+
*
|
|
109
|
+
* **Loud rather than ignored.** A typo silently does nothing, and what a consumer then sees is
|
|
110
|
+
* an animation that will not respond — a symptom a long way from the misspelling that caused it,
|
|
111
|
+
* and one no test of theirs would catch. What it costs is that a caller cannot set a parameter
|
|
112
|
+
* ahead of building the tree that uses it.
|
|
113
|
+
*/
|
|
114
|
+
set(parameter: string, value: number): void {
|
|
115
|
+
if (!this.parameters.has(parameter)) {
|
|
116
|
+
throw new Error(`BlendTree: no node in this tree takes a parameter called "${parameter}"`);
|
|
117
|
+
}
|
|
118
|
+
this.parameters.set(parameter, value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Sample the whole tree at `timeSec` into `out`. Allocates nothing.
|
|
123
|
+
*
|
|
124
|
+
* `timeSec` is the clock for every node that did not name one of its own, so a tree of ordinary
|
|
125
|
+
* clips behaves exactly as it did before clocks existed.
|
|
126
|
+
*/
|
|
127
|
+
evaluate(timeSec: number, out: Pose): void {
|
|
128
|
+
this.walk(this.root, timeSec, out);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A scratch pose starting from the bind pose where one was given, or at rest where none was. */
|
|
132
|
+
private blank(jointCount: number): Pose {
|
|
133
|
+
const pose = createPose(jointCount);
|
|
134
|
+
if (this.bind !== undefined) {
|
|
135
|
+
pose.translation.set(this.bind.translation.subarray(0, pose.translation.length));
|
|
136
|
+
pose.rotation.set(this.bind.rotation.subarray(0, pose.rotation.length));
|
|
137
|
+
pose.scale.set(this.bind.scale.subarray(0, pose.scale.length));
|
|
138
|
+
}
|
|
139
|
+
return pose;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Collect parameter names and claim scratch, once, so evaluation allocates nothing. */
|
|
143
|
+
private prepare(node: BlendNode, jointCount: number): void {
|
|
144
|
+
if (node.kind === 'clip') {
|
|
145
|
+
if (node.clock !== undefined) {
|
|
146
|
+
if (this.parameters.has(node.clock) && !this.clocks.has(node.clock)) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`BlendTree: "${node.clock}" is a blend parameter in this tree and cannot also be a ` +
|
|
149
|
+
`clock; one number cannot be both a weight and a time`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
this.clocks.add(node.clock);
|
|
153
|
+
/* Zero, so a tree evaluates before a caller has set anything — the same start every
|
|
154
|
+
blend parameter gets. */
|
|
155
|
+
this.parameters.set(node.clock, this.parameters.get(node.clock) ?? 0);
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (this.clocks.has(node.parameter)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`BlendTree: "${node.parameter}" is a clock in this tree and cannot also be a blend ` +
|
|
163
|
+
`parameter; one number cannot be both a time and a weight`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
this.parameters.set(node.parameter, this.parameters.get(node.parameter) ?? 0);
|
|
167
|
+
this.scratch.set(node, [this.blank(jointCount), this.blank(jointCount)]);
|
|
168
|
+
|
|
169
|
+
if (node.kind === 'lerp') {
|
|
170
|
+
this.prepare(node.a, jointCount);
|
|
171
|
+
this.prepare(node.b, jointCount);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (node.children.length === 0) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`BlendTree: the set on "${node.parameter}" has no children; it needs at least one`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
for (let i = 1; i < node.children.length; i++) {
|
|
181
|
+
const previous = node.children[i - 1]?.at ?? 0;
|
|
182
|
+
const current = node.children[i]?.at ?? 0;
|
|
183
|
+
/*
|
|
184
|
+
* Ascending stops are what makes the bracketing search a walk rather than a sort, and an
|
|
185
|
+
* unsorted set does not fail — it picks a neighbouring pair that is not the nearest one and
|
|
186
|
+
* blends between the wrong two clips, which reads as a rig that responds oddly to a
|
|
187
|
+
* parameter rather than as a misconfigured tree.
|
|
188
|
+
*/
|
|
189
|
+
if (current <= previous) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`BlendTree: the set on "${node.parameter}" has stops ${previous} then ${current}; ` +
|
|
192
|
+
`they must be ascending`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const child of node.children) this.prepare(child.node, jointCount);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private walk(node: BlendNode, timeSec: number, out: Pose): void {
|
|
200
|
+
if (node.kind === 'clip') {
|
|
201
|
+
/* Its own clock where it named one, and the frame clock where it did not — which is what
|
|
202
|
+
every tree written before clocks existed keeps doing, unchanged. */
|
|
203
|
+
const at = node.clock === undefined ? timeSec : (this.parameters.get(node.clock) ?? 0);
|
|
204
|
+
sampleClip(node.clip, at, out);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const scratch = this.scratch.get(node);
|
|
209
|
+
if (scratch === undefined) throw new Error('BlendTree: a node was not prepared');
|
|
210
|
+
const [left, right] = scratch;
|
|
211
|
+
const value = this.parameters.get(node.parameter) ?? 0;
|
|
212
|
+
|
|
213
|
+
if (node.kind === 'lerp') {
|
|
214
|
+
this.walk(node.a, timeSec, left);
|
|
215
|
+
this.walk(node.b, timeSec, right);
|
|
216
|
+
blendPoses(left, right, value, out);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const stops = node.children;
|
|
221
|
+
const first = stops[0];
|
|
222
|
+
const last = stops[stops.length - 1];
|
|
223
|
+
if (first === undefined || last === undefined) throw new Error('BlendTree: an empty set');
|
|
224
|
+
|
|
225
|
+
/*
|
|
226
|
+
* **A shortcut, not the clamp.** Outside the set these walk one child instead of two, which
|
|
227
|
+
* halves the work for a parameter parked at an extreme — a character standing still, which is
|
|
228
|
+
* the common case. The *correctness* is `blendPoses`, which clamps its own weight: deleting
|
|
229
|
+
* these two returns changes no output, only the work done, and perturbing them proved exactly
|
|
230
|
+
* that by leaving every assertion green. The comment said they were the clamp until then.
|
|
231
|
+
*/
|
|
232
|
+
if (value <= first.at) {
|
|
233
|
+
this.walk(first.node, timeSec, out);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (value >= last.at) {
|
|
237
|
+
this.walk(last.node, timeSec, out);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let upper = 1;
|
|
242
|
+
while (upper < stops.length - 1 && (stops[upper]?.at ?? 0) < value) upper += 1;
|
|
243
|
+
const lower = stops[upper - 1];
|
|
244
|
+
const higher = stops[upper];
|
|
245
|
+
if (lower === undefined || higher === undefined) throw new Error('BlendTree: a gap in a set');
|
|
246
|
+
|
|
247
|
+
const span = higher.at - lower.at;
|
|
248
|
+
const t = span > 0 ? (value - lower.at) / span : 0;
|
|
249
|
+
this.walk(lower.node, timeSec, left);
|
|
250
|
+
this.walk(higher.node, timeSec, right);
|
|
251
|
+
blendPoses(left, right, t, out);
|
|
252
|
+
}
|
|
253
|
+
}
|