@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/src/pose.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
export interface Pose {
|
|
16
|
+
/** Three floats a joint. */
|
|
17
|
+
readonly translation: Float32Array;
|
|
18
|
+
/** Four floats a joint, xyzw, in the order `gl-matrix` uses. */
|
|
19
|
+
readonly rotation: Float32Array;
|
|
20
|
+
/** Three floats a joint. */
|
|
21
|
+
readonly scale: Float32Array;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A pose sized for `jointCount` joints, already at rest.
|
|
26
|
+
*
|
|
27
|
+
* **At rest rather than zeroed**, and that is a correctness matter rather than a convenience.
|
|
28
|
+
* A zero quaternion normalises to NaN and a zero scale collapses every vertex the joint touches,
|
|
29
|
+
* so a caller who allocates a pose and forgets to rest it would get a rig that vanishes rather
|
|
30
|
+
* than one that stands still. The same reasoning `vertexDefaults.ts` gives for handing an absent
|
|
31
|
+
* tangent `(1, 0, 0, 1)` instead of zero: the safe default is a usable value, never a sentinel
|
|
32
|
+
* that arithmetic turns into NaN.
|
|
33
|
+
*/
|
|
34
|
+
export function createPose(jointCount: number): Pose {
|
|
35
|
+
const pose: Pose = {
|
|
36
|
+
translation: new Float32Array(jointCount * 3),
|
|
37
|
+
rotation: new Float32Array(jointCount * 4),
|
|
38
|
+
scale: new Float32Array(jointCount * 3),
|
|
39
|
+
};
|
|
40
|
+
restPose(jointCount, pose);
|
|
41
|
+
return pose;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write the rest pose — no translation, identity rotation, unit scale — into an existing pose.
|
|
46
|
+
*
|
|
47
|
+
* Separate from `createPose` so a caller can return a pose to rest without allocating a second
|
|
48
|
+
* one, which is what a state machine does when it re-enters a state.
|
|
49
|
+
*/
|
|
50
|
+
export function restPose(jointCount: number, out: Pose): void {
|
|
51
|
+
out.translation.fill(0);
|
|
52
|
+
out.scale.fill(1);
|
|
53
|
+
for (let j = 0; j < jointCount; j++) {
|
|
54
|
+
const at = j * 4;
|
|
55
|
+
out.rotation[at] = 0;
|
|
56
|
+
out.rotation[at + 1] = 0;
|
|
57
|
+
out.rotation[at + 2] = 0;
|
|
58
|
+
out.rotation[at + 3] = 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/retarget.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Pose } from './pose.ts';
|
|
2
|
+
import type { Skeleton } from './skeleton.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Playing one skeleton's clip on another.
|
|
6
|
+
*
|
|
7
|
+
* **Rotations transfer and translations do not, and that is the whole of it.** Two rigs of
|
|
8
|
+
* different proportions share joint *orientations* and not bone lengths, so copying a translation
|
|
9
|
+
* puts the taller rig's limbs inside its own body. The root is the exception, because that is
|
|
10
|
+
* where locomotion lives — a walk whose root did not move would be a moonwalk.
|
|
11
|
+
*
|
|
12
|
+
* What this does not do is retarget between *different topologies*: a rig with a split spine
|
|
13
|
+
* playing a clip from one with a single spine needs a decision about how to distribute the
|
|
14
|
+
* rotation, and that decision belongs to whoever knows what the character is. This matches by
|
|
15
|
+
* name, transfers what matches, and says what did not.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface RetargetMap {
|
|
19
|
+
/** Source joint index to target joint index, or -1 where the target has no such joint. */
|
|
20
|
+
readonly indices: Int16Array;
|
|
21
|
+
/** Source joint names the target does not have, in source order. */
|
|
22
|
+
readonly unmatched: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Match two skeletons by joint name.
|
|
27
|
+
*
|
|
28
|
+
* **Exact matching, and no name database.** A table of "hips means Hips means pelvis" rots
|
|
29
|
+
* silently and its failure is a limb that does not move, which no test of this engine could catch
|
|
30
|
+
* — the same argument the gamepad layer makes for shipping no device database. A consumer whose
|
|
31
|
+
* two rigs disagree about capitalisation knows that and can rename; this engine guessing would be
|
|
32
|
+
* wrong in a way they could not see.
|
|
33
|
+
*/
|
|
34
|
+
export function buildRetargetMap(from: Skeleton, to: Skeleton): RetargetMap {
|
|
35
|
+
const byName = new Map<string, number>();
|
|
36
|
+
to.joints.forEach((joint, at) => {
|
|
37
|
+
/* First wins, so a rig with two joints of one name maps to the earlier — the one nearer the
|
|
38
|
+
root, since joints are sorted parents-first. */
|
|
39
|
+
if (!byName.has(joint.name)) byName.set(joint.name, at);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const indices = new Int16Array(from.jointCount);
|
|
43
|
+
const unmatched: string[] = [];
|
|
44
|
+
from.joints.forEach((joint, at) => {
|
|
45
|
+
const found = byName.get(joint.name);
|
|
46
|
+
if (found === undefined) {
|
|
47
|
+
indices[at] = -1;
|
|
48
|
+
unmatched.push(joint.name);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
indices[at] = found;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
* Reported rather than acted on. Whether a missing joint is a broken import, a deliberately
|
|
56
|
+
* simpler rig, or something to substitute for is a product decision — the same line `rebind`
|
|
57
|
+
* draws when it reports the actions it displaced rather than deciding about them.
|
|
58
|
+
*/
|
|
59
|
+
return { indices, unmatched };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Write `source`, played on `from`, onto `to` in `out`. Allocates nothing.
|
|
64
|
+
*
|
|
65
|
+
* A joint the target has and the source does not is **left exactly as it was found**, so a
|
|
66
|
+
* character with a tail wearing a clip from one without keeps its tail wherever its own idle put
|
|
67
|
+
* it rather than snapping to rest. That is the layering `sampleClip` provides one level down, and
|
|
68
|
+
* it is what lets a retargeted clip be laid over a base pose.
|
|
69
|
+
*/
|
|
70
|
+
export function retargetPose(
|
|
71
|
+
map: RetargetMap,
|
|
72
|
+
from: Skeleton,
|
|
73
|
+
source: Pose,
|
|
74
|
+
to: Skeleton,
|
|
75
|
+
out: Pose,
|
|
76
|
+
): void {
|
|
77
|
+
for (let j = 0; j < from.jointCount; j++) {
|
|
78
|
+
const target = map.indices[j] ?? -1;
|
|
79
|
+
/*
|
|
80
|
+
* The `-1` is the real guard; the upper bound is unreachable from a map `buildRetargetMap`
|
|
81
|
+
* produced, since it only ever names a joint the target has. Kept for a hand-built map, and
|
|
82
|
+
* said so rather than left to read as load-bearing — perturbing it away leaves every test
|
|
83
|
+
* green, which is how that was established.
|
|
84
|
+
*/
|
|
85
|
+
if (target < 0 || target >= to.jointCount) continue;
|
|
86
|
+
|
|
87
|
+
const sourceAt = j * 4;
|
|
88
|
+
const targetAt = target * 4;
|
|
89
|
+
for (let c = 0; c < 4; c++)
|
|
90
|
+
out.rotation[targetAt + c] = source.rotation[sourceAt + c] as number;
|
|
91
|
+
|
|
92
|
+
/*
|
|
93
|
+
* The root's translation, and only the root's. Everything below it is placed by its parent, so
|
|
94
|
+
* a bone length is the target rig's own fact — copying the source's would rebuild the target
|
|
95
|
+
* as the source, one joint at a time, which is precisely what retargeting exists to avoid.
|
|
96
|
+
*/
|
|
97
|
+
if ((to.joints[target]?.parent ?? -1) < 0) {
|
|
98
|
+
const sourceT = j * 3;
|
|
99
|
+
const targetT = target * 3;
|
|
100
|
+
for (let c = 0; c < 3; c++) {
|
|
101
|
+
out.translation[targetT + c] = source.translation[sourceT + c] as number;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/rigid.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { SceneNode } from '@driftengine/core';
|
|
2
|
+
|
|
3
|
+
import type { AnimationClip } from './clip.ts';
|
|
4
|
+
import { sampleClip } from './clip.ts';
|
|
5
|
+
import type { Pose } from './pose.ts';
|
|
6
|
+
import { createPose } from './pose.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rigid TRS: a clip driving a node's transform, with no skeleton and no palette anywhere.
|
|
10
|
+
*
|
|
11
|
+
* A door swinging, a lift rising, a turntable turning — geometry that moves as a whole rather than
|
|
12
|
+
* deforming. It is the cheapest thing animation offers and the one most games reach for first, and
|
|
13
|
+
* it needs no shader change at all.
|
|
14
|
+
*
|
|
15
|
+
* **This module is the only place this package touches core**, which is what makes the peer
|
|
16
|
+
* dependency load-bearing rather than declared. A node is core's, a pose is this package's, and
|
|
17
|
+
* this is the one line between them — kept in a module of its own so the rest of the package
|
|
18
|
+
* stays a pure function of time over typed arrays and could be tested with core absent.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Write one joint of a pose onto a node's local transform. Allocates nothing.
|
|
23
|
+
*
|
|
24
|
+
* `markMoved` is called because `SceneNode` says at its own fields that writing a transform in
|
|
25
|
+
* place does not mark it dirty. Omitting it is invisible on the first frame — a fresh node is
|
|
26
|
+
* dirty already — and shows on the second as a node whose world matrix never catches up, which
|
|
27
|
+
* reads as an animation that plays once and freezes.
|
|
28
|
+
*/
|
|
29
|
+
export function applyPoseToNode(pose: Pose, joint: number, node: SceneNode): void {
|
|
30
|
+
const t = joint * 3;
|
|
31
|
+
const r = joint * 4;
|
|
32
|
+
node.position[0] = pose.translation[t] as number;
|
|
33
|
+
node.position[1] = pose.translation[t + 1] as number;
|
|
34
|
+
node.position[2] = pose.translation[t + 2] as number;
|
|
35
|
+
node.rotation[0] = pose.rotation[r] as number;
|
|
36
|
+
node.rotation[1] = pose.rotation[r + 1] as number;
|
|
37
|
+
node.rotation[2] = pose.rotation[r + 2] as number;
|
|
38
|
+
node.rotation[3] = pose.rotation[r + 3] as number;
|
|
39
|
+
node.scale[0] = pose.scale[t] as number;
|
|
40
|
+
node.scale[1] = pose.scale[t + 1] as number;
|
|
41
|
+
node.scale[2] = pose.scale[t + 2] as number;
|
|
42
|
+
node.markMoved();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A clip bound to a list of nodes, one per joint the clip's tracks name.
|
|
47
|
+
*
|
|
48
|
+
* The pose is claimed once at construction and reused, so `apply` allocates nothing however often
|
|
49
|
+
* it runs.
|
|
50
|
+
*/
|
|
51
|
+
export class RigidAnimation {
|
|
52
|
+
private readonly pose: Pose;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The distinct joints this clip drives, collected once.
|
|
56
|
+
*
|
|
57
|
+
* Iterating `tracks` directly would write a joint's node once per track it has — three times for
|
|
58
|
+
* a joint carrying translation, rotation and scale — which is correct and is three times the
|
|
59
|
+
* work every frame. Collected here because the set cannot change: a clip is immutable.
|
|
60
|
+
*/
|
|
61
|
+
private readonly driven: number[] = [];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param nodes Indexed by joint. `null` for a joint this scene did not instantiate.
|
|
65
|
+
*/
|
|
66
|
+
constructor(
|
|
67
|
+
private readonly clip: AnimationClip,
|
|
68
|
+
private readonly nodes: readonly (SceneNode | null)[],
|
|
69
|
+
) {
|
|
70
|
+
/*
|
|
71
|
+
* Sized to the highest joint the clip names, not to `nodes.length`, so a caller passing a
|
|
72
|
+
* short list still gets a pose the sampler can write every track into — the skipping happens
|
|
73
|
+
* at the node, which is where the caller's intent is, rather than silently at the sample.
|
|
74
|
+
*/
|
|
75
|
+
let highest = -1;
|
|
76
|
+
for (const track of clip.tracks) {
|
|
77
|
+
highest = Math.max(highest, track.joint);
|
|
78
|
+
if (!this.driven.includes(track.joint)) this.driven.push(track.joint);
|
|
79
|
+
}
|
|
80
|
+
this.pose = createPose(highest + 1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Sample at a caller-supplied time and write every bound node. Reads no clock.
|
|
85
|
+
*
|
|
86
|
+
* A joint with no node is skipped rather than refused. An imported clip names joints a scene may
|
|
87
|
+
* not have instantiated, and the right behaviour is the parts that exist moving — the
|
|
88
|
+
* reliability rules forbid throwing in a frame loop outright, and a missing prop is not a reason
|
|
89
|
+
* to stop a scene.
|
|
90
|
+
*/
|
|
91
|
+
apply(timeSec: number): void {
|
|
92
|
+
sampleClip(this.clip, timeSec, this.pose);
|
|
93
|
+
for (const joint of this.driven) {
|
|
94
|
+
const node = this.nodes[joint];
|
|
95
|
+
if (node === null || node === undefined) continue;
|
|
96
|
+
applyPoseToNode(this.pose, joint, node);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type { AnimationClip, JointTrack, TrackPath } from '@driftengine/drft';
|
|
2
|
+
|
|
3
|
+
import { sampleTrack, wrapTime } from './clip.ts';
|
|
4
|
+
import type { Pose } from './pose.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Root motion: how far the root travelled between two sample times, handed to the caller.
|
|
8
|
+
*
|
|
9
|
+
* **The point is that the character moves and the pose does not.** A walk cycle authored with a
|
|
10
|
+
* moving root plays as a character sliding forward inside its own transform, and the rig then
|
|
11
|
+
* fights whatever the game does with that transform. Root motion splits the two: `extractRootMotion`
|
|
12
|
+
* answers the interval's displacement so a controller can apply it, and `stripRootMotion` pins the
|
|
13
|
+
* pose's root so the displacement is not applied twice.
|
|
14
|
+
*
|
|
15
|
+
* **A pure function of the clip and two times, with no clock of its own** — the same contract
|
|
16
|
+
* `sampleClip` makes and for the same reason. A recorded intent stream replays bit-identically
|
|
17
|
+
* only if every step of the pose pipeline is a function of a time the caller supplies.
|
|
18
|
+
*
|
|
19
|
+
* ## The convention, and what it costs
|
|
20
|
+
*
|
|
21
|
+
* The delta is expressed **in the root's own frame at the earlier time**, so a caller applies it as
|
|
22
|
+
*
|
|
23
|
+
* ```
|
|
24
|
+
* position = position + worldRotation * motion.translation
|
|
25
|
+
* worldRotation = worldRotation * motion.rotation
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* which composes: stepping a clip in sixtieths and applying each delta arrives where one query
|
|
29
|
+
* over the whole span says it should, and a character that has turned walks along its own forward
|
|
30
|
+
* axis rather than along the clip's. The rejected alternative is a delta in the clip's own space,
|
|
31
|
+
* which is one subtraction shorter and drags every turned character sideways.
|
|
32
|
+
*
|
|
33
|
+
* **What it gives up** is a vertical bob authored on the root: it leaves the pose along with
|
|
34
|
+
* everything else and becomes motion the caller applies. A caller whose height is owned by a
|
|
35
|
+
* physics controller ignores `translation[1]` — and then the bob is in neither the pose nor the
|
|
36
|
+
* position, which is a real loss and the reason this is written down rather than discovered. **What
|
|
37
|
+
* would make it wrong** is a consumer wanting per-axis control, and the honest answer then is a mask
|
|
38
|
+
* on the extraction rather than a second convention.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** A root's displacement over an interval. Three floats and a quaternion, both caller-owned. */
|
|
42
|
+
export interface RootMotion {
|
|
43
|
+
/** Three floats, in the root's frame at the earlier of the two times. */
|
|
44
|
+
readonly translation: Float32Array;
|
|
45
|
+
/** Four floats, xyzw, in the order `gl-matrix` uses. */
|
|
46
|
+
readonly rotation: Float32Array;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A zero displacement, ready to be written into. Identity rather than zeroed, as `createPose` is. */
|
|
50
|
+
export function createRootMotion(): RootMotion {
|
|
51
|
+
const motion: RootMotion = { translation: new Float32Array(3), rotation: new Float32Array(4) };
|
|
52
|
+
motion.rotation[3] = 1;
|
|
53
|
+
return motion;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Write the root's displacement between `fromSec` and `toSec` into `out`. Allocates nothing.
|
|
58
|
+
*
|
|
59
|
+
* **Loops are accumulated rather than subtracted**, which is the whole difficulty. Sampling the
|
|
60
|
+
* root at both times and subtracting answers a full stride *backwards* every time the clip wraps,
|
|
61
|
+
* because the root snaps from the end of the cycle to the start of it. So the interval is split at
|
|
62
|
+
* every loop boundary it crosses and the pieces are composed.
|
|
63
|
+
*
|
|
64
|
+
* `toSec` before `fromSec` is a clip running backwards and answers the negated motion, because a
|
|
65
|
+
* transition played in reverse is a real caller — the same reason `wrapTime` handles a negative
|
|
66
|
+
* time rather than assuming one cannot arrive.
|
|
67
|
+
*
|
|
68
|
+
* Cost is linear in the number of whole cycles between the two times. A fixed-step caller crosses
|
|
69
|
+
* at most one boundary a frame, so that count is zero or one; a query spanning a hundred cycles
|
|
70
|
+
* composes a hundred times, which is arithmetic rather than a hazard.
|
|
71
|
+
*/
|
|
72
|
+
export function extractRootMotion(
|
|
73
|
+
clip: AnimationClip,
|
|
74
|
+
rootJoint: number,
|
|
75
|
+
fromSec: number,
|
|
76
|
+
toSec: number,
|
|
77
|
+
out: RootMotion,
|
|
78
|
+
): void {
|
|
79
|
+
identity(out);
|
|
80
|
+
|
|
81
|
+
const duration = clip.durationSec;
|
|
82
|
+
const translation = findTrack(clip, rootJoint, 'translation');
|
|
83
|
+
const rotation = findTrack(clip, rootJoint, 'rotation');
|
|
84
|
+
if (translation === null && rotation === null) return;
|
|
85
|
+
/* A clip with no duration wraps every time to the same instant, so nothing moved. */
|
|
86
|
+
if (!(duration > 0)) return;
|
|
87
|
+
|
|
88
|
+
const from = wrapTime(fromSec, duration);
|
|
89
|
+
const to = wrapTime(toSec, duration);
|
|
90
|
+
const crossings = Math.floor(toSec / duration) - Math.floor(fromSec / duration);
|
|
91
|
+
|
|
92
|
+
if (crossings === 0) {
|
|
93
|
+
composeSegment(translation, rotation, from, to, out);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* Forwards: the tail of the current cycle, then whole cycles, then the head of the last one.
|
|
99
|
+
* Backwards is the same walk with the two ends of the clip exchanged, which is what makes the
|
|
100
|
+
* negative case the negation of the positive rather than a second implementation.
|
|
101
|
+
*/
|
|
102
|
+
const forwards = crossings > 0;
|
|
103
|
+
const open = forwards ? 0 : duration;
|
|
104
|
+
const close = forwards ? duration : 0;
|
|
105
|
+
|
|
106
|
+
composeSegment(translation, rotation, from, close, out);
|
|
107
|
+
const whole = Math.abs(crossings) - 1;
|
|
108
|
+
for (let cycle = 0; cycle < whole; cycle++) {
|
|
109
|
+
composeSegment(translation, rotation, open, close, out);
|
|
110
|
+
}
|
|
111
|
+
composeSegment(translation, rotation, open, to, out);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Pin a sampled pose's root to the value the clip authored at time zero.
|
|
116
|
+
*
|
|
117
|
+
* The other half of the capability: whatever `extractRootMotion` handed the caller has to leave the
|
|
118
|
+
* pose, or the character moves twice. Pinned to the clip's own first value rather than to the rest
|
|
119
|
+
* pose, so a root authored a metre off the origin keeps its offset — resting it would drop every
|
|
120
|
+
* character to the floor of its rig on the first frame.
|
|
121
|
+
*
|
|
122
|
+
* A joint that is not the root is left exactly as it was found, which is the same layering rule
|
|
123
|
+
* `sampleClip` and `retargetPose` both keep.
|
|
124
|
+
*/
|
|
125
|
+
export function stripRootMotion(clip: AnimationClip, rootJoint: number, pose: Pose): void {
|
|
126
|
+
const translation = findTrack(clip, rootJoint, 'translation');
|
|
127
|
+
if (translation !== null) sampleTrack(translation, 0, pose.translation, rootJoint * 3);
|
|
128
|
+
const rotation = findTrack(clip, rootJoint, 'rotation');
|
|
129
|
+
if (rotation !== null) sampleTrack(rotation, 0, pose.rotation, rootJoint * 4);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The track for one joint and one path, or null.
|
|
134
|
+
*
|
|
135
|
+
* A linear scan rather than an index, because a clip carries a few dozen tracks and this runs twice
|
|
136
|
+
* per extraction — building a map would allocate one per clip and have to be invalidated when a
|
|
137
|
+
* caller swaps a clip in place, which is worse than sixty comparisons.
|
|
138
|
+
*/
|
|
139
|
+
function findTrack(clip: AnimationClip, joint: number, path: TrackPath): JointTrack | null {
|
|
140
|
+
for (const track of clip.tracks) {
|
|
141
|
+
if (track.joint === joint && track.path === path) return track;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Compose one segment's local delta onto `out`. `t0` and `t1` are in range and not wrapped. */
|
|
147
|
+
function composeSegment(
|
|
148
|
+
translation: JointTrack | null,
|
|
149
|
+
rotation: JointTrack | null,
|
|
150
|
+
t0: number,
|
|
151
|
+
t1: number,
|
|
152
|
+
out: RootMotion,
|
|
153
|
+
): void {
|
|
154
|
+
FROM_T.fill(0);
|
|
155
|
+
TO_T.fill(0);
|
|
156
|
+
if (translation !== null) {
|
|
157
|
+
sampleTrack(translation, t0, FROM_T, 0);
|
|
158
|
+
sampleTrack(translation, t1, TO_T, 0);
|
|
159
|
+
}
|
|
160
|
+
identityInto(FROM_R);
|
|
161
|
+
identityInto(TO_R);
|
|
162
|
+
if (rotation !== null) {
|
|
163
|
+
sampleTrack(rotation, t0, FROM_R, 0);
|
|
164
|
+
sampleTrack(rotation, t1, TO_R, 0);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* The segment's delta in the root's frame at `t0`: rotate the world difference back by the
|
|
168
|
+
earlier orientation, and take the rotation that carries the earlier onto the later. */
|
|
169
|
+
for (let c = 0; c < 3; c++) SEG_T[c] = (TO_T[c] as number) - (FROM_T[c] as number);
|
|
170
|
+
conjugateInto(FROM_R, INVERSE);
|
|
171
|
+
rotateVector(INVERSE, SEG_T);
|
|
172
|
+
multiplyInto(INVERSE, TO_R, SEG_R);
|
|
173
|
+
|
|
174
|
+
/* `out` then this segment: the segment's translation is expressed in the frame `out` ends in,
|
|
175
|
+
so it is rotated by `out`'s rotation before it is added. */
|
|
176
|
+
rotateVector(out.rotation, SEG_T);
|
|
177
|
+
for (let c = 0; c < 3; c++) {
|
|
178
|
+
out.translation[c] = (out.translation[c] as number) + (SEG_T[c] as number);
|
|
179
|
+
}
|
|
180
|
+
multiplyInto(out.rotation, SEG_R, COMPOSED);
|
|
181
|
+
for (let c = 0; c < 4; c++) out.rotation[c] = COMPOSED[c] as number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function identity(out: RootMotion): void {
|
|
185
|
+
out.translation.fill(0);
|
|
186
|
+
identityInto(out.rotation);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function identityInto(quaternion: Float32Array): void {
|
|
190
|
+
quaternion[0] = 0;
|
|
191
|
+
quaternion[1] = 0;
|
|
192
|
+
quaternion[2] = 0;
|
|
193
|
+
quaternion[3] = 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The inverse of a unit quaternion. Not normalised: every source here is a sampled unit. */
|
|
197
|
+
function conjugateInto(quaternion: Float32Array, out: Float32Array): void {
|
|
198
|
+
out[0] = -(quaternion[0] as number);
|
|
199
|
+
out[1] = -(quaternion[1] as number);
|
|
200
|
+
out[2] = -(quaternion[2] as number);
|
|
201
|
+
out[3] = quaternion[3] as number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Quaternion product `a * b` into `out`. `out` must not alias either input. */
|
|
205
|
+
function multiplyInto(a: Float32Array, b: Float32Array, out: Float32Array): void {
|
|
206
|
+
const ax = a[0] as number;
|
|
207
|
+
const ay = a[1] as number;
|
|
208
|
+
const az = a[2] as number;
|
|
209
|
+
const aw = a[3] as number;
|
|
210
|
+
const bx = b[0] as number;
|
|
211
|
+
const by = b[1] as number;
|
|
212
|
+
const bz = b[2] as number;
|
|
213
|
+
const bw = b[3] as number;
|
|
214
|
+
out[0] = aw * bx + ax * bw + ay * bz - az * by;
|
|
215
|
+
out[1] = aw * by - ax * bz + ay * bw + az * bx;
|
|
216
|
+
out[2] = aw * bz + ax * by - ay * bx + az * bw;
|
|
217
|
+
out[3] = aw * bw - ax * bx - ay * by - az * bz;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Rotate a three-vector by a unit quaternion, in place.
|
|
222
|
+
*
|
|
223
|
+
* `v + 2w(q x v) + 2(q x (q x v))`, written out rather than built from a matrix: a matrix would be
|
|
224
|
+
* nine floats of scratch to save two cross products, and this runs four times a frame per
|
|
225
|
+
* character.
|
|
226
|
+
*/
|
|
227
|
+
function rotateVector(quaternion: Float32Array, vector: Float32Array): void {
|
|
228
|
+
const qx = quaternion[0] as number;
|
|
229
|
+
const qy = quaternion[1] as number;
|
|
230
|
+
const qz = quaternion[2] as number;
|
|
231
|
+
const qw = quaternion[3] as number;
|
|
232
|
+
const vx = vector[0] as number;
|
|
233
|
+
const vy = vector[1] as number;
|
|
234
|
+
const vz = vector[2] as number;
|
|
235
|
+
|
|
236
|
+
const tx = 2 * (qy * vz - qz * vy);
|
|
237
|
+
const ty = 2 * (qz * vx - qx * vz);
|
|
238
|
+
const tz = 2 * (qx * vy - qy * vx);
|
|
239
|
+
|
|
240
|
+
vector[0] = vx + qw * tx + (qy * tz - qz * ty);
|
|
241
|
+
vector[1] = vy + qw * ty + (qz * tx - qx * tz);
|
|
242
|
+
vector[2] = vz + qw * tz + (qx * ty - qy * tx);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* Module scope, claimed once: an extraction runs per character per frame. */
|
|
246
|
+
const FROM_T = new Float32Array(3);
|
|
247
|
+
const TO_T = new Float32Array(3);
|
|
248
|
+
const SEG_T = new Float32Array(3);
|
|
249
|
+
const FROM_R = new Float32Array(4);
|
|
250
|
+
const TO_R = new Float32Array(4);
|
|
251
|
+
const SEG_R = new Float32Array(4);
|
|
252
|
+
const INVERSE = new Float32Array(4);
|
|
253
|
+
const COMPOSED = new Float32Array(4);
|