@pose_flow/babylon 0.1.0 → 0.3.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/README.md +129 -105
- package/dist/AI_USAGE.md +386 -0
- package/dist/PUBLISHING.md +110 -0
- package/dist/babylon-inertialization.d.ts +29 -0
- package/dist/babylon-inertialization.js +88 -0
- package/dist/babylon.d.ts +27 -5
- package/dist/babylon.js +181 -26
- package/dist/core/controller.d.ts +26 -0
- package/dist/core/controller.js +78 -8
- package/dist/core/database.d.ts +8 -2
- package/dist/core/database.js +62 -24
- package/dist/core/features.d.ts +1 -1
- package/dist/core/features.js +9 -11
- package/dist/core/inertialization.d.ts +3 -0
- package/dist/core/inertialization.js +15 -0
- package/dist/core/profile.d.ts +34 -8
- package/dist/core/profile.js +49 -10
- package/dist/core/root-motion.d.ts +5 -3
- package/dist/core/root-motion.js +43 -7
- package/dist/core/scenario.d.ts +29 -0
- package/dist/core/scenario.js +57 -0
- package/dist/core/schema.d.ts +56 -0
- package/dist/core/schema.js +123 -0
- package/dist/core/search.d.ts +40 -2
- package/dist/core/search.js +147 -16
- package/dist/core/selection.d.ts +24 -0
- package/dist/core/selection.js +41 -0
- package/dist/core/types.d.ts +24 -4
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/offline/src/babylon-inertialization.js +88 -0
- package/dist/offline/src/babylon.js +281 -0
- package/dist/offline/src/core/controller.js +162 -0
- package/dist/offline/src/core/database.js +97 -0
- package/dist/offline/src/core/features.js +41 -0
- package/dist/offline/src/core/inertialization.js +15 -0
- package/dist/offline/src/core/profile.js +103 -0
- package/dist/offline/src/core/root-motion.js +78 -0
- package/dist/offline/src/core/scenario.js +57 -0
- package/dist/offline/src/core/schema.js +123 -0
- package/dist/offline/src/core/search.js +181 -0
- package/dist/offline/src/core/selection.js +41 -0
- package/dist/offline/src/core/types.js +3 -0
- package/dist/offline/src/index.js +11 -0
- package/dist/offline/tools/bake.js +88 -0
- package/dist/offline/tools/cli.js +30 -0
- package/dist/offline/tools/glb.js +54 -0
- package/dist/offline/tools/prepare.js +165 -0
- package/dist/offline/tools/profile-assets.js +90 -0
- package/dist/offline/tools/rig.js +47 -0
- package/dist/offline/tools/validate-assets.js +43 -0
- package/package.json +48 -39
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Quaternion, Vector3 } from '@babylonjs/core';
|
|
2
|
+
import { decayInertialOffset } from './core/inertialization.js';
|
|
3
|
+
export function captureLocal(node) {
|
|
4
|
+
return { position: node.position.clone(), rotation: node.rotationQuaternion?.clone() ?? Quaternion.FromEulerVector(node.rotation), scaling: node.scaling.clone() };
|
|
5
|
+
}
|
|
6
|
+
export function rotationVector(q) {
|
|
7
|
+
q = q.clone().normalize();
|
|
8
|
+
if (q.w < 0)
|
|
9
|
+
q.scaleInPlace(-1);
|
|
10
|
+
const length = Math.hypot(q.x, q.y, q.z);
|
|
11
|
+
return length < 1e-8 ? new Vector3(q.x * 2, q.y * 2, q.z * 2) : new Vector3(q.x, q.y, q.z).scale(2 * Math.atan2(length, q.w) / length);
|
|
12
|
+
}
|
|
13
|
+
function rotationFromVector(v) {
|
|
14
|
+
const angle = v.length();
|
|
15
|
+
return angle < 1e-8 ? new Quaternion(v.x / 2, v.y / 2, v.z / 2, 1).normalize() : Quaternion.RotationAxis(v.scale(1 / angle), angle);
|
|
16
|
+
}
|
|
17
|
+
export function poseVelocity(before, after, dt) {
|
|
18
|
+
return { position: after.position.subtract(before.position).scale(1 / dt), scaling: after.scaling.subtract(before.scaling).scale(1 / dt),
|
|
19
|
+
// rotation stores a tangent vector in XYZ; W is unused for velocities.
|
|
20
|
+
rotation: Quaternion.FromArray([...rotationVector(after.rotation.multiply(Quaternion.Inverse(before.rotation))).scale(1 / dt).asArray(), 0]) };
|
|
21
|
+
}
|
|
22
|
+
const zeroVelocity = () => ({ position: Vector3.Zero(), scaling: Vector3.Zero(), rotation: new Quaternion(0, 0, 0, 0) });
|
|
23
|
+
/** Owns local presentation residuals only. Evaluation happens before world matrices/rendering. */
|
|
24
|
+
export class BabylonInertializer {
|
|
25
|
+
scene;
|
|
26
|
+
halfLife;
|
|
27
|
+
observer;
|
|
28
|
+
last = new Map();
|
|
29
|
+
velocity = new Map();
|
|
30
|
+
residuals = new Map();
|
|
31
|
+
target = new Map();
|
|
32
|
+
dt = 0;
|
|
33
|
+
elapsed = 0;
|
|
34
|
+
revision = 0;
|
|
35
|
+
renderedRevision = -1;
|
|
36
|
+
constructor(scene, halfLife) {
|
|
37
|
+
this.scene = scene;
|
|
38
|
+
this.halfLife = halfLife;
|
|
39
|
+
this.observer = scene.onAfterAnimationsObservable.add(() => this.render());
|
|
40
|
+
}
|
|
41
|
+
update(target, targetVelocity, dt, switched) {
|
|
42
|
+
if (switched) {
|
|
43
|
+
this.residuals.clear();
|
|
44
|
+
this.elapsed = 0;
|
|
45
|
+
for (const [node, pose] of target) {
|
|
46
|
+
const before = this.last.get(node);
|
|
47
|
+
if (!before)
|
|
48
|
+
continue;
|
|
49
|
+
const v = this.velocity.get(node) ?? zeroVelocity(), incoming = targetVelocity.get(node) ?? zeroVelocity();
|
|
50
|
+
const angular = rotationVector(before.rotation.multiply(Quaternion.Inverse(pose.rotation)));
|
|
51
|
+
this.residuals.set(node, { offset: { position: before.position.subtract(pose.position), scaling: before.scaling.subtract(pose.scaling), rotation: new Quaternion(angular.x, angular.y, angular.z, 0) },
|
|
52
|
+
velocity: { position: v.position.subtract(incoming.position), scaling: v.scaling.subtract(incoming.scaling), rotation: v.rotation.subtract(incoming.rotation) } });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
this.elapsed += dt;
|
|
57
|
+
this.target = target;
|
|
58
|
+
this.dt = dt;
|
|
59
|
+
this.revision++;
|
|
60
|
+
}
|
|
61
|
+
render() {
|
|
62
|
+
for (const [node, target] of this.target) {
|
|
63
|
+
const residual = this.residuals.get(node);
|
|
64
|
+
const decay = (offset, velocity) => new Vector3(...offset.asArray().map((v, i) => decayInertialOffset(v, velocity.asArray()[i], this.elapsed, this.halfLife)));
|
|
65
|
+
node.position.copyFrom(target.position);
|
|
66
|
+
node.scaling.copyFrom(target.scaling);
|
|
67
|
+
node.rotationQuaternion ??= Quaternion.Identity();
|
|
68
|
+
node.rotationQuaternion.copyFrom(target.rotation);
|
|
69
|
+
if (residual) {
|
|
70
|
+
node.position.addInPlace(decay(residual.offset.position, residual.velocity.position));
|
|
71
|
+
node.scaling.addInPlace(decay(residual.offset.scaling, residual.velocity.scaling));
|
|
72
|
+
const r = residual.offset.rotation, v = residual.velocity.rotation;
|
|
73
|
+
node.rotationQuaternion.copyFrom(rotationFromVector(decay(new Vector3(r.x, r.y, r.z), new Vector3(v.x, v.y, v.z))).multiply(target.rotation).normalize());
|
|
74
|
+
}
|
|
75
|
+
if (this.renderedRevision !== this.revision) {
|
|
76
|
+
const pose = captureLocal(node), previous = this.last.get(node);
|
|
77
|
+
if (previous && this.dt > 0)
|
|
78
|
+
this.velocity.set(node, poseVelocity(previous, pose, this.dt));
|
|
79
|
+
this.last.set(node, pose);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
this.renderedRevision = this.revision;
|
|
83
|
+
if (this.elapsed > this.halfLife * 40)
|
|
84
|
+
this.residuals.clear();
|
|
85
|
+
}
|
|
86
|
+
reset() { this.last.clear(); this.velocity.clear(); this.residuals.clear(); this.target.clear(); this.elapsed = 0; this.renderedRevision = -1; }
|
|
87
|
+
dispose() { this.reset(); this.scene.onAfterAnimationsObservable.remove(this.observer); }
|
|
88
|
+
}
|
package/dist/babylon.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { AnimationGroup, TransformNode, type Scene } from '@babylonjs/core';
|
|
2
2
|
import type { Clip, Playback, Pose } from './index.js';
|
|
3
|
+
import { type PoseSchema, type SchemaPose } from './core/schema.js';
|
|
4
|
+
export interface BabylonPlayerOptions {
|
|
5
|
+
/** Exact MODEL node names. No descendant expansion or name mapping is implied. */
|
|
6
|
+
mask?: readonly string[];
|
|
7
|
+
transition?: 'crossfade' | 'inertialization';
|
|
8
|
+
/** Positive seconds. Applies only to inertialization; default 0.1. */
|
|
9
|
+
inertialHalfLife?: number;
|
|
10
|
+
}
|
|
3
11
|
/** Clone onto a target rig. Two playback slots allow crossfade between times of the same clip. */
|
|
4
12
|
export declare class BabylonMotionPlayer {
|
|
5
13
|
private scene;
|
|
@@ -11,18 +19,32 @@ export declare class BabylonMotionPlayer {
|
|
|
11
19
|
private tracks;
|
|
12
20
|
private active?;
|
|
13
21
|
private disposed;
|
|
14
|
-
|
|
22
|
+
private strippedRoot?;
|
|
23
|
+
private rootRestRotation?;
|
|
24
|
+
private owned;
|
|
25
|
+
private rest;
|
|
26
|
+
private inertia?;
|
|
27
|
+
constructor(scene: Scene, clips: readonly Clip[], sources: ReadonlyMap<string, AnimationGroup>, nodes: ReadonlyMap<string, TransformNode>, fadeSeconds?: number, rootName?: string, options?: BabylonPlayerOptions);
|
|
15
28
|
private create;
|
|
16
29
|
update(state: Playback, dt: number): void;
|
|
30
|
+
private sampleLocal;
|
|
31
|
+
/** Clear presentation history after teleport/reconciliation; ownership stays until dispose. */
|
|
32
|
+
reset(): void;
|
|
17
33
|
private clear;
|
|
18
34
|
dispose(): void;
|
|
19
35
|
}
|
|
20
|
-
/** Sample AFTER scene animation evaluation.
|
|
21
|
-
export declare class
|
|
36
|
+
/** Sample AFTER scene animation evaluation. Semantic roles are defined by the schema. */
|
|
37
|
+
export declare class BabylonSchemaPoseReader {
|
|
22
38
|
private root;
|
|
23
|
-
private left;
|
|
24
|
-
private right;
|
|
25
39
|
private previous?;
|
|
40
|
+
private nodes;
|
|
41
|
+
constructor(root: TransformNode, bones: ReadonlyMap<string, TransformNode>, schema: PoseSchema);
|
|
42
|
+
reset(): void;
|
|
43
|
+
read(dt: number): SchemaPose;
|
|
44
|
+
}
|
|
45
|
+
/** Compatibility reader for the original two-foot preset. */
|
|
46
|
+
export declare class BabylonPoseReader {
|
|
47
|
+
private reader;
|
|
26
48
|
constructor(root: TransformNode, left: TransformNode, right: TransformNode);
|
|
27
49
|
reset(): void;
|
|
28
50
|
read(dt: number): Pose;
|
package/dist/babylon.js
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
|
-
import { AnimationGroup, Matrix, Vector3 } from '@babylonjs/core';
|
|
1
|
+
import { Animation, AnimationGroup, Matrix, Quaternion, Vector3 } from '@babylonjs/core';
|
|
2
|
+
import { compileSchema, LOCOMOTION_SCHEMA } from './core/schema.js';
|
|
3
|
+
import { parseRootOrientation } from './core/profile.js';
|
|
4
|
+
import { BabylonInertializer, captureLocal, poseVelocity } from './babylon-inertialization.js';
|
|
5
|
+
const nodeOwners = new WeakMap();
|
|
6
|
+
/** Constant authoring orientation belongs to the pose; varying root heading belongs to gameplay. */
|
|
7
|
+
function constantRootRotation(animation) {
|
|
8
|
+
const keys = animation.getKeys();
|
|
9
|
+
if (!keys.length || keys.some(k => k.inTangent !== undefined || k.outTangent !== undefined))
|
|
10
|
+
return undefined;
|
|
11
|
+
const rotation = (value) => animation.targetProperty === 'rotationQuaternion' && value instanceof Quaternion ? value.clone()
|
|
12
|
+
: animation.targetProperty === 'rotation' && value instanceof Vector3 ? Quaternion.FromEulerVector(value) : undefined;
|
|
13
|
+
const first = rotation(keys[0].value);
|
|
14
|
+
if (!first || !first.asArray().every(Number.isFinite) || first.length() < 1e-8)
|
|
15
|
+
return undefined;
|
|
16
|
+
first.normalize();
|
|
17
|
+
for (const key of keys) {
|
|
18
|
+
const q = rotation(key.value);
|
|
19
|
+
if (!q || !q.asArray().every(Number.isFinite) || q.length() < 1e-8 || Math.abs(Quaternion.Dot(first, q.normalize())) < 1 - 1e-8)
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return first;
|
|
23
|
+
}
|
|
2
24
|
/** Clone onto a target rig. Two playback slots allow crossfade between times of the same clip. */
|
|
3
25
|
export class BabylonMotionPlayer {
|
|
4
26
|
scene;
|
|
@@ -10,7 +32,12 @@ export class BabylonMotionPlayer {
|
|
|
10
32
|
tracks = [];
|
|
11
33
|
active;
|
|
12
34
|
disposed = false;
|
|
13
|
-
|
|
35
|
+
strippedRoot;
|
|
36
|
+
rootRestRotation;
|
|
37
|
+
owned = new Set();
|
|
38
|
+
rest = new Map();
|
|
39
|
+
inertia;
|
|
40
|
+
constructor(scene, clips, sources, nodes, fadeSeconds = 0.15, rootName = 'root', options = {}) {
|
|
14
41
|
this.scene = scene;
|
|
15
42
|
this.clips = clips;
|
|
16
43
|
this.sources = sources;
|
|
@@ -19,27 +46,91 @@ export class BabylonMotionPlayer {
|
|
|
19
46
|
this.rootName = rootName;
|
|
20
47
|
if (!Number.isFinite(fadeSeconds) || fadeSeconds < 0)
|
|
21
48
|
throw new Error('Invalid crossfade duration');
|
|
22
|
-
|
|
23
|
-
|
|
49
|
+
if (options.transition !== undefined && !['crossfade', 'inertialization'].includes(options.transition))
|
|
50
|
+
throw new Error('Invalid transition mode');
|
|
51
|
+
if (options.inertialHalfLife !== undefined && (!Number.isFinite(options.inertialHalfLife) || options.inertialHalfLife <= 0))
|
|
52
|
+
throw new Error('Invalid inertial half life');
|
|
53
|
+
for (const clip of clips) {
|
|
54
|
+
const source = sources.get(clip.id);
|
|
55
|
+
if (!source)
|
|
24
56
|
throw new Error(`Missing animation source: ${clip.id}`);
|
|
57
|
+
const fps = source.targetedAnimations[0]?.animation.framePerSecond, start = clip.sourceStart ?? 0;
|
|
58
|
+
if (!Number.isFinite(start) || start < 0 || !Number.isFinite(clip.duration) || clip.duration <= 0 || !fps
|
|
59
|
+
|| start + clip.duration > (source.to - source.from) / fps + 1e-5)
|
|
60
|
+
throw new Error(`Invalid source range: ${clip.id}`);
|
|
61
|
+
}
|
|
62
|
+
const mapped = new Set(nodes.values());
|
|
63
|
+
if (options.mask !== undefined && (!Array.isArray(options.mask) || !options.mask.length || new Set(options.mask).size !== options.mask.length
|
|
64
|
+
|| options.mask.some(name => typeof name !== 'string' || [...mapped].filter(n => n.name === name).length !== 1)))
|
|
65
|
+
throw new Error('Invalid player mask: use unique mapped model node names');
|
|
66
|
+
const mask = options.mask === undefined ? mapped : new Set([...mapped].filter(n => options.mask.includes(n.name)));
|
|
67
|
+
for (const clip of clips)
|
|
68
|
+
for (const targeted of sources.get(clip.id).targetedAnimations) {
|
|
69
|
+
const node = nodes.get(targeted.target.name);
|
|
70
|
+
if (node && mask.has(node))
|
|
71
|
+
this.owned.add(node);
|
|
72
|
+
}
|
|
73
|
+
if (!this.owned.size)
|
|
74
|
+
throw new Error('No matching rig animation tracks in player mask');
|
|
75
|
+
if ([...this.owned].some(node => nodeOwners.has(node)))
|
|
76
|
+
throw new Error('Animation node already owned by another motion player');
|
|
77
|
+
this.strippedRoot = [...mapped].find(n => n.name === rootName);
|
|
78
|
+
if (this.strippedRoot && this.owned.has(this.strippedRoot)) {
|
|
79
|
+
this.strippedRoot.rotationQuaternion ??= Quaternion.FromEulerVector(this.strippedRoot.rotation);
|
|
80
|
+
this.rootRestRotation = this.strippedRoot.rotationQuaternion.clone();
|
|
81
|
+
}
|
|
82
|
+
for (const clip of clips)
|
|
83
|
+
if (clip.rootOrientation !== undefined) {
|
|
84
|
+
parseRootOrientation(clip.rootOrientation);
|
|
85
|
+
if (!this.strippedRoot)
|
|
86
|
+
throw new Error('rootOrientation requires a mapped stripped root');
|
|
87
|
+
}
|
|
88
|
+
if (options.transition === 'inertialization') {
|
|
89
|
+
for (const clip of clips)
|
|
90
|
+
for (const targeted of sources.get(clip.id).targetedAnimations) {
|
|
91
|
+
const node = nodes.get(targeted.target.name), property = targeted.animation.targetProperty;
|
|
92
|
+
if (node && this.owned.has(node) && node !== this.strippedRoot && !/^(?:(?:position|scaling)(\.[xyz])?|rotationQuaternion(\.[xyzw])?)$/.test(property))
|
|
93
|
+
throw new Error('Inertialization supports local position/scaling/quaternion tracks only');
|
|
94
|
+
}
|
|
95
|
+
this.rest = new Map([...this.owned].map(node => [node, captureLocal(node)]));
|
|
96
|
+
this.inertia = new BabylonInertializer(scene, options.inertialHalfLife ?? .1);
|
|
97
|
+
}
|
|
98
|
+
for (const node of this.owned)
|
|
99
|
+
nodeOwners.set(node, this);
|
|
25
100
|
}
|
|
26
101
|
create(clip, time) {
|
|
27
102
|
const source = this.sources.get(this.clips[clip].id);
|
|
28
103
|
const group = new AnimationGroup(`mm-${this.clips[clip].id}-${this.tracks.length}`, this.scene);
|
|
104
|
+
let rootRotation = this.rootRestRotation?.clone();
|
|
29
105
|
for (const targeted of source.targetedAnimations) {
|
|
30
106
|
const node = this.nodes.get(targeted.target.name);
|
|
31
|
-
if (!node)
|
|
107
|
+
if (!node || !this.owned.has(node))
|
|
32
108
|
continue;
|
|
33
109
|
const property = targeted.animation.targetProperty;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
110
|
+
if (node === this.strippedRoot) {
|
|
111
|
+
if (property === 'position' || property.startsWith('position.'))
|
|
112
|
+
continue;
|
|
113
|
+
if (property === 'rotationQuaternion' || property === 'rotation' || property.startsWith('rotation.') || property.startsWith('rotationQuaternion.')) {
|
|
114
|
+
rootRotation = constantRootRotation(targeted.animation) ?? rootRotation;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
37
118
|
group.addTargetedAnimation(targeted.animation, node);
|
|
38
119
|
}
|
|
39
120
|
if (!group.targetedAnimations.length) {
|
|
40
121
|
group.dispose();
|
|
41
122
|
throw new Error('No matching rig animation tracks');
|
|
42
123
|
}
|
|
124
|
+
const correction = this.clips[clip].rootOrientation;
|
|
125
|
+
if (correction)
|
|
126
|
+
rootRotation = Quaternion.FromArray([...correction]).normalize();
|
|
127
|
+
if (this.strippedRoot && this.owned.has(this.strippedRoot) && rootRotation) {
|
|
128
|
+
// An explicit constant binding also restores the baseline after a clip with
|
|
129
|
+
// another orientation, and participates in the same weighted crossfade.
|
|
130
|
+
const rotation = new Animation('mm-root-pose', 'rotationQuaternion', source.targetedAnimations[0].animation.framePerSecond, Animation.ANIMATIONTYPE_QUATERNION);
|
|
131
|
+
rotation.setKeys([{ frame: source.from, value: rootRotation }, { frame: source.to, value: rootRotation.clone() }]);
|
|
132
|
+
group.addTargetedAnimation(rotation, this.strippedRoot);
|
|
133
|
+
}
|
|
43
134
|
// Paused groups still participate in Babylon's late weighted bindings when
|
|
44
135
|
// goToFrame(frame, true) is called. Speed zero alone resets on scene.render().
|
|
45
136
|
group.start(true, 0);
|
|
@@ -60,6 +151,13 @@ export class BabylonMotionPlayer {
|
|
|
60
151
|
}
|
|
61
152
|
if (!this.clips[state.clip])
|
|
62
153
|
throw new Error('Unknown playback clip');
|
|
154
|
+
const switched = !this.active || state.switched || this.active.clip !== state.clip;
|
|
155
|
+
if (switched && this.inertia) {
|
|
156
|
+
for (const track of this.tracks)
|
|
157
|
+
track.group.dispose();
|
|
158
|
+
this.tracks = [];
|
|
159
|
+
this.active = undefined;
|
|
160
|
+
}
|
|
63
161
|
if (!this.active || state.switched || this.active.clip !== state.clip) {
|
|
64
162
|
// Bound simultaneous slots during rapid replanning; residual poses retain relative weights.
|
|
65
163
|
if (this.tracks.length >= 4) {
|
|
@@ -69,7 +167,18 @@ export class BabylonMotionPlayer {
|
|
|
69
167
|
this.active = this.create(state.clip, state.time);
|
|
70
168
|
}
|
|
71
169
|
this.active.time = state.time;
|
|
72
|
-
|
|
170
|
+
if (this.inertia) {
|
|
171
|
+
const clip = this.clips[state.clip], h = Math.min(1 / 120, clip.duration / 4);
|
|
172
|
+
const time = clip.looping ? ((state.time % clip.duration) + clip.duration) % clip.duration : Math.max(0, Math.min(state.time, clip.duration));
|
|
173
|
+
const low = clip.looping ? time - h : Math.max(0, time - h), high = clip.looping ? time + h : Math.min(clip.duration, time + h);
|
|
174
|
+
let incomingVelocity = new Map();
|
|
175
|
+
if (switched) {
|
|
176
|
+
const before = this.sampleLocal(this.active, low), after = this.sampleLocal(this.active, high);
|
|
177
|
+
incomingVelocity = new Map([...before].map(([node, pose]) => [node, poseVelocity(pose, after.get(node), high - low)]));
|
|
178
|
+
}
|
|
179
|
+
this.inertia.update(this.sampleLocal(this.active, time), incomingVelocity, dt, switched);
|
|
180
|
+
}
|
|
181
|
+
const alpha = this.inertia || this.fadeSeconds === 0 ? 1 : 1 - Math.exp(-dt * 4 / this.fadeSeconds);
|
|
73
182
|
for (const track of this.tracks) {
|
|
74
183
|
if (track !== this.active)
|
|
75
184
|
track.time += dt;
|
|
@@ -79,9 +188,10 @@ export class BabylonMotionPlayer {
|
|
|
79
188
|
const total = this.tracks.reduce((sum, t) => sum + t.weight, 0);
|
|
80
189
|
for (const track of this.tracks) {
|
|
81
190
|
const descriptor = this.clips[track.clip], group = track.group;
|
|
82
|
-
const time = descriptor.looping ? track.time % descriptor.duration : Math.min(track.time, descriptor.duration);
|
|
191
|
+
const time = descriptor.looping ? ((track.time % descriptor.duration) + descriptor.duration) % descriptor.duration : Math.max(0, Math.min(track.time, descriptor.duration));
|
|
83
192
|
group.setWeightForAllAnimatables(total > 0 ? track.weight / total : (track === this.active ? 1 : 0));
|
|
84
|
-
|
|
193
|
+
const source = this.sources.get(descriptor.id), fps = source.targetedAnimations[0].animation.framePerSecond;
|
|
194
|
+
group.goToFrame(source.from + ((descriptor.sourceStart ?? 0) + time) * fps, true);
|
|
85
195
|
}
|
|
86
196
|
this.tracks = this.tracks.filter(track => {
|
|
87
197
|
if (track !== this.active && track.weight < 0.001) {
|
|
@@ -91,36 +201,81 @@ export class BabylonMotionPlayer {
|
|
|
91
201
|
return true;
|
|
92
202
|
});
|
|
93
203
|
}
|
|
204
|
+
sampleLocal(track, time) {
|
|
205
|
+
const result = new Map([...this.rest].map(([node, pose]) => [node, { position: pose.position.clone(), scaling: pose.scaling.clone(), rotation: pose.rotation.clone() }]));
|
|
206
|
+
const clip = this.clips[track.clip], source = this.sources.get(clip.id);
|
|
207
|
+
time = clip.looping ? ((time % clip.duration) + clip.duration) % clip.duration : Math.max(0, Math.min(time, clip.duration));
|
|
208
|
+
for (const targeted of track.group.targetedAnimations) {
|
|
209
|
+
const node = targeted.target, pose = result.get(node);
|
|
210
|
+
const [property, component] = targeted.animation.targetProperty.split('.');
|
|
211
|
+
const value = targeted.animation.evaluate(source.from + ((clip.sourceStart ?? 0) + time) * targeted.animation.framePerSecond);
|
|
212
|
+
const target = property === 'rotationQuaternion' ? pose.rotation : property === 'scaling' ? pose.scaling : pose.position;
|
|
213
|
+
if (component)
|
|
214
|
+
target[component] = value;
|
|
215
|
+
else
|
|
216
|
+
target.copyFrom(value);
|
|
217
|
+
}
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
/** Clear presentation history after teleport/reconciliation; ownership stays until dispose. */
|
|
221
|
+
reset() { this.clear(); }
|
|
94
222
|
clear() { for (const t of this.tracks)
|
|
95
|
-
t.group.dispose(); this.tracks = []; this.active = undefined; }
|
|
96
|
-
dispose() {
|
|
223
|
+
t.group.dispose(); this.tracks = []; this.active = undefined; this.inertia?.reset(); }
|
|
224
|
+
dispose() {
|
|
225
|
+
this.clear();
|
|
226
|
+
this.disposed = true;
|
|
227
|
+
this.inertia?.dispose();
|
|
228
|
+
for (const node of this.owned)
|
|
229
|
+
if (nodeOwners.get(node) === this)
|
|
230
|
+
nodeOwners.delete(node);
|
|
231
|
+
this.owned.clear();
|
|
232
|
+
}
|
|
97
233
|
}
|
|
98
|
-
/** Sample AFTER scene animation evaluation.
|
|
99
|
-
export class
|
|
234
|
+
/** Sample AFTER scene animation evaluation. Semantic roles are defined by the schema. */
|
|
235
|
+
export class BabylonSchemaPoseReader {
|
|
100
236
|
root;
|
|
101
|
-
left;
|
|
102
|
-
right;
|
|
103
237
|
previous;
|
|
104
|
-
|
|
238
|
+
nodes;
|
|
239
|
+
constructor(root, bones, schema) {
|
|
105
240
|
this.root = root;
|
|
106
|
-
|
|
107
|
-
|
|
241
|
+
const roles = compileSchema(schema).bones;
|
|
242
|
+
for (const role of roles)
|
|
243
|
+
if (!bones.has(role))
|
|
244
|
+
throw new Error('Missing schema bone node: ' + role);
|
|
245
|
+
this.nodes = new Map(roles.map(role => [role, bones.get(role)]));
|
|
108
246
|
}
|
|
109
247
|
reset() { this.previous = undefined; }
|
|
110
248
|
read(dt) {
|
|
111
249
|
if (!Number.isFinite(dt) || dt < 0)
|
|
112
250
|
throw new Error('Invalid pose delta');
|
|
113
251
|
this.root.computeWorldMatrix(true);
|
|
114
|
-
this.
|
|
115
|
-
this.
|
|
116
|
-
|
|
252
|
+
const root = this.root.getAbsolutePosition().clone(), positions = new Map();
|
|
253
|
+
for (const [role, node] of this.nodes) {
|
|
254
|
+
node.computeWorldMatrix(true);
|
|
255
|
+
positions.set(role, node.getAbsolutePosition().clone());
|
|
256
|
+
}
|
|
117
257
|
const inverse = Matrix.Invert(this.root.getWorldMatrix());
|
|
118
258
|
// Vector transform through inverse includes inverse model scale: consumers should keep root scale 1.
|
|
119
259
|
const tuple = (v) => [v.x, v.y, v.z];
|
|
120
260
|
const velocity = (value, previous) => previous && dt > 0 ? Vector3.TransformNormal(value.subtract(previous).scale(1 / dt), inverse) : Vector3.Zero();
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
261
|
+
const bones = Object.fromEntries([...positions].map(([role, position]) => [role, {
|
|
262
|
+
position: tuple(Vector3.TransformCoordinates(position, inverse)), velocity: tuple(velocity(position, this.previous?.bones.get(role))),
|
|
263
|
+
}]));
|
|
264
|
+
const pose = { bones, rootVelocity: tuple(velocity(root, this.previous?.root)) };
|
|
265
|
+
this.previous = { bones: positions, root };
|
|
124
266
|
return pose;
|
|
125
267
|
}
|
|
126
268
|
}
|
|
269
|
+
/** Compatibility reader for the original two-foot preset. */
|
|
270
|
+
export class BabylonPoseReader {
|
|
271
|
+
reader;
|
|
272
|
+
constructor(root, left, right) {
|
|
273
|
+
this.reader = new BabylonSchemaPoseReader(root, new Map([['leftFoot', left], ['rightFoot', right]]), LOCOMOTION_SCHEMA);
|
|
274
|
+
}
|
|
275
|
+
reset() { this.reader.reset(); }
|
|
276
|
+
read(dt) {
|
|
277
|
+
const pose = this.reader.read(dt);
|
|
278
|
+
return { leftPosition: pose.bones.leftFoot.position, rightPosition: pose.bones.rightFoot.position,
|
|
279
|
+
leftVelocity: pose.bones.leftFoot.velocity, rightVelocity: pose.bones.rightFoot.velocity, rootVelocity: pose.rootVelocity };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { PoseSearch } from './search.js';
|
|
2
2
|
import type { Database, Match, Query } from './types.js';
|
|
3
|
+
import type { SearchReport } from './search.js';
|
|
3
4
|
export interface Playback {
|
|
4
5
|
clip: number;
|
|
5
6
|
time: number;
|
|
6
7
|
switched: boolean;
|
|
7
8
|
match: Match | null;
|
|
8
9
|
}
|
|
10
|
+
export interface MatcherDecision {
|
|
11
|
+
searched: boolean;
|
|
12
|
+
reason: string;
|
|
13
|
+
previousClip: number;
|
|
14
|
+
currentClip: number;
|
|
15
|
+
currentCost: number | null;
|
|
16
|
+
candidateCost: number | null;
|
|
17
|
+
sinceSwitch: number;
|
|
18
|
+
minimumHold: number;
|
|
19
|
+
jumpPenalty: number;
|
|
20
|
+
improvement: number;
|
|
21
|
+
}
|
|
9
22
|
export interface MatcherOptions {
|
|
10
23
|
searchInterval: number;
|
|
11
24
|
minimumHold: number;
|
|
@@ -13,6 +26,12 @@ export interface MatcherOptions {
|
|
|
13
26
|
jumpPenalty: number;
|
|
14
27
|
timeSeparation: number;
|
|
15
28
|
reselectDelay: number;
|
|
29
|
+
/** Active only when the query explicitly requests idle alone. */
|
|
30
|
+
idleVariation?: {
|
|
31
|
+
interval: number;
|
|
32
|
+
historySize: number;
|
|
33
|
+
};
|
|
34
|
+
diagnostics?: boolean;
|
|
16
35
|
}
|
|
17
36
|
/** Owns animation time only. Caller retains movement and action authority. */
|
|
18
37
|
export declare class MotionMatcher {
|
|
@@ -26,6 +45,13 @@ export declare class MotionMatcher {
|
|
|
26
45
|
private last;
|
|
27
46
|
private completed;
|
|
28
47
|
private indices;
|
|
48
|
+
private idleElapsed;
|
|
49
|
+
private idleHistory;
|
|
50
|
+
private effectiveQuery?;
|
|
51
|
+
private decisionState;
|
|
52
|
+
/** Snapshot of the last continuity decision. Query copying requires diagnostics=true. */
|
|
53
|
+
get decision(): MatcherDecision | null;
|
|
54
|
+
inspect(limit?: number): SearchReport | null;
|
|
29
55
|
constructor(database: Database, options?: Partial<MatcherOptions>);
|
|
30
56
|
reset(): void;
|
|
31
57
|
update(dt: number, query: Query): Playback;
|
package/dist/core/controller.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PoseSearch } from './search.js';
|
|
2
|
+
import { withinWindows } from './selection.js';
|
|
2
3
|
/** Owns animation time only. Caller retains movement and action authority. */
|
|
3
4
|
export class MotionMatcher {
|
|
4
5
|
database;
|
|
@@ -11,20 +12,36 @@ export class MotionMatcher {
|
|
|
11
12
|
last = null;
|
|
12
13
|
completed = new Map();
|
|
13
14
|
indices;
|
|
15
|
+
idleElapsed = 0;
|
|
16
|
+
idleHistory = [];
|
|
17
|
+
effectiveQuery;
|
|
18
|
+
decisionState = null;
|
|
19
|
+
/** Snapshot of the last continuity decision. Query copying requires diagnostics=true. */
|
|
20
|
+
get decision() { return this.decisionState ? { ...this.decisionState } : null; }
|
|
21
|
+
inspect(limit = 5) { return this.options.diagnostics && this.effectiveQuery ? this.search.inspect(this.effectiveQuery, limit) : null; }
|
|
14
22
|
constructor(database, options = {}) {
|
|
15
23
|
this.database = database;
|
|
16
24
|
this.search = new PoseSearch(database);
|
|
17
25
|
this.options = { searchInterval: 0.1, minimumHold: 0.12, improvement: 0.1, jumpPenalty: 0.5, timeSeparation: 0.2, reselectDelay: 0.6, ...options };
|
|
18
|
-
|
|
26
|
+
const { idleVariation, diagnostics, ...numeric } = this.options;
|
|
27
|
+
if (!Object.values(numeric).every(v => Number.isFinite(v) && v >= 0) || this.options.searchInterval === 0 || this.options.improvement >= 1)
|
|
19
28
|
throw new Error('Invalid matcher options');
|
|
29
|
+
if (idleVariation && (!Number.isFinite(idleVariation.interval) || idleVariation.interval <= 0 || !Number.isInteger(idleVariation.historySize) || idleVariation.historySize < 1))
|
|
30
|
+
throw new Error('Invalid idle variation policy');
|
|
31
|
+
if (diagnostics !== undefined && typeof diagnostics !== 'boolean')
|
|
32
|
+
throw new Error('Invalid diagnostics option');
|
|
20
33
|
this.indices = database.manifest.clips.map((_, clip) => database.manifest.frames.flatMap((f, i) => f.clip === clip ? [i] : []).sort((a, b) => database.manifest.frames[a].time - database.manifest.frames[b].time));
|
|
21
34
|
if (this.indices.some(i => !i.length))
|
|
22
35
|
throw new Error('Every clip must have indexed samples');
|
|
23
36
|
}
|
|
24
|
-
reset() { this.clip = -1; this.time = 0; this.sinceSearch = this.sinceSwitch = Infinity; this.last = null; this.completed.clear(); }
|
|
37
|
+
reset() { this.clip = -1; this.time = 0; this.sinceSearch = this.sinceSwitch = Infinity; this.last = null; this.completed.clear(); this.idleElapsed = 0; this.idleHistory = []; this.effectiveQuery = undefined; this.decisionState = null; }
|
|
25
38
|
update(dt, query) {
|
|
26
39
|
if (!Number.isFinite(dt) || dt < 0)
|
|
27
40
|
throw new Error('Invalid delta time');
|
|
41
|
+
// Validate identity even while continuity skips a search this frame.
|
|
42
|
+
this.search.validateQuery(query);
|
|
43
|
+
const previousClip = this.clip;
|
|
44
|
+
let reason = 'search-interval', searched = false, currentCost = null, candidateCost = null;
|
|
28
45
|
let switched = false, ended = false;
|
|
29
46
|
for (const [clip, remaining] of this.completed) {
|
|
30
47
|
if (remaining <= dt)
|
|
@@ -40,15 +57,47 @@ export class MotionMatcher {
|
|
|
40
57
|
}
|
|
41
58
|
this.sinceSearch += dt;
|
|
42
59
|
this.sinceSwitch += dt;
|
|
43
|
-
const allowed = this.
|
|
60
|
+
const allowed = this.search.allowsClip(this.clip, query);
|
|
61
|
+
const idleOnly = query.gaits?.size === 1 && query.gaits.has('idle') && !!this.options.idleVariation;
|
|
62
|
+
if (idleOnly && allowed)
|
|
63
|
+
this.idleElapsed += dt;
|
|
64
|
+
else {
|
|
65
|
+
this.idleElapsed = 0;
|
|
66
|
+
this.idleHistory = [];
|
|
67
|
+
}
|
|
44
68
|
if (this.sinceSearch >= this.options.searchInterval || !allowed || ended) {
|
|
69
|
+
searched = true;
|
|
45
70
|
this.sinceSearch = 0;
|
|
46
71
|
// A finished one-shot must yield to another clip, rather than selecting
|
|
47
72
|
// its terminal pose and restarting forever. User exclusions still apply.
|
|
48
73
|
if (ended)
|
|
49
74
|
this.completed.set(this.clip, this.options.reselectDelay);
|
|
50
75
|
const excludedClips = new Set([...(query.excludedClips ?? []), ...this.completed.keys()]);
|
|
51
|
-
|
|
76
|
+
let variation = false;
|
|
77
|
+
let best = null;
|
|
78
|
+
const runSearch = (effective) => {
|
|
79
|
+
// Keep the query that actually produced the candidate, including idle
|
|
80
|
+
// history/completion exclusions. Features are snapshotted for later UI.
|
|
81
|
+
if (this.options.diagnostics)
|
|
82
|
+
this.effectiveQuery = { ...effective, features: effective.features.slice(), context: effective.context ? { ...effective.context } : undefined,
|
|
83
|
+
gaits: effective.gaits ? new Set(effective.gaits) : undefined, excludedClips: effective.excludedClips ? new Set(effective.excludedClips) : undefined };
|
|
84
|
+
return this.search.search(effective);
|
|
85
|
+
};
|
|
86
|
+
if (idleOnly && allowed && !ended) {
|
|
87
|
+
if (this.idleElapsed >= this.options.idleVariation.interval) {
|
|
88
|
+
// Relax oldest history first when the pool is small, but never force
|
|
89
|
+
// a restart of the current clip or relax the consumer's context.
|
|
90
|
+
const history = [...this.idleHistory];
|
|
91
|
+
do {
|
|
92
|
+
best = runSearch({ ...query, excludedClips: new Set([...excludedClips, this.clip, ...history]) });
|
|
93
|
+
} while (!best && history.shift() !== undefined);
|
|
94
|
+
variation = best !== null;
|
|
95
|
+
}
|
|
96
|
+
if (!best)
|
|
97
|
+
best = runSearch({ ...query, excludedClips: new Set([...excludedClips, ...this.database.manifest.clips.flatMap((_, i) => i === this.clip ? [] : [i])]) });
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
best = runSearch({ ...query, excludedClips });
|
|
52
101
|
this.last = best;
|
|
53
102
|
if (best) {
|
|
54
103
|
const current = this.clip >= 0 ? this.nearestFrame(this.clip, this.time) : -1;
|
|
@@ -58,18 +107,39 @@ export class MotionMatcher {
|
|
|
58
107
|
gap = Math.min(gap, duration - gap);
|
|
59
108
|
const distant = best.clip !== this.clip || gap > this.options.timeSeparation;
|
|
60
109
|
const better = current < 0 || best.cost + this.options.jumpPenalty < this.search.cost(current) * (1 - this.options.improvement);
|
|
61
|
-
|
|
110
|
+
currentCost = current < 0 ? null : this.search.cost(current);
|
|
111
|
+
candidateCost = best.cost;
|
|
112
|
+
const incompatibleVelocity = current >= 0 && distant && this.sinceSwitch >= this.options.minimumHold && query.velocityTolerance !== undefined
|
|
113
|
+
&& this.search.rootVelocityError(current, query) > Math.max(query.velocityTolerance, this.search.rootVelocityError(best.index, query) + 1e-6);
|
|
114
|
+
const mayReselectTime = query.allowTemporalReselection !== false || best.clip !== this.clip || !this.database.manifest.clips[best.clip].looping;
|
|
115
|
+
const mayExit = this.clip < 0 || withinWindows(this.time, this.database.manifest.clips[this.clip].exitWindows);
|
|
116
|
+
const shouldSwitch = current < 0 || !allowed || ended || (mayExit && mayReselectTime && (incompatibleVelocity || (distant && (better || variation) && this.sinceSwitch >= this.options.minimumHold)));
|
|
117
|
+
reason = current < 0 ? 'initial' : !allowed ? 'incompatible-current' : ended ? 'clip-ended' : !mayExit ? 'exit-window' : !mayReselectTime ? 'phase-preserved'
|
|
118
|
+
: incompatibleVelocity ? 'velocity-incompatible' : !distant ? 'nearby-candidate' : this.sinceSwitch < this.options.minimumHold ? 'minimum-hold' : variation ? 'idle-variation' : better ? 'better-candidate' : 'insufficient-improvement';
|
|
119
|
+
if (shouldSwitch) {
|
|
62
120
|
this.clip = best.clip;
|
|
63
121
|
this.time = best.time;
|
|
64
122
|
this.sinceSwitch = 0;
|
|
65
123
|
switched = true;
|
|
124
|
+
this.idleElapsed = 0;
|
|
125
|
+
if (this.database.manifest.clips[this.clip].gait === 'idle') {
|
|
126
|
+
this.idleHistory.push(this.clip);
|
|
127
|
+
this.idleHistory = this.idleHistory.slice(-(this.options.idleVariation?.historySize ?? 1));
|
|
128
|
+
}
|
|
129
|
+
else
|
|
130
|
+
this.idleHistory = [];
|
|
66
131
|
}
|
|
67
132
|
}
|
|
68
|
-
else
|
|
69
|
-
|
|
70
|
-
|
|
133
|
+
else {
|
|
134
|
+
reason = !allowed || ended ? 'no-candidate-cleared' : 'no-entry-continue';
|
|
135
|
+
if (!allowed || ended) {
|
|
136
|
+
this.clip = -1;
|
|
137
|
+
this.time = 0;
|
|
138
|
+
}
|
|
71
139
|
}
|
|
72
140
|
}
|
|
141
|
+
this.decisionState = { searched, reason, previousClip, currentClip: this.clip, currentCost, candidateCost, sinceSwitch: this.sinceSwitch,
|
|
142
|
+
minimumHold: this.options.minimumHold, improvement: this.options.improvement, jumpPenalty: this.options.jumpPenalty };
|
|
73
143
|
return { clip: this.clip, time: this.time, switched, match: this.last };
|
|
74
144
|
}
|
|
75
145
|
nearestFrame(clip, time) {
|
package/dist/core/database.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { type Database, type DatabaseManifest } from './types.js';
|
|
2
|
+
import { type PoseSchema } from './schema.js';
|
|
2
3
|
export declare function validateDatabase(db: Database): void;
|
|
3
|
-
|
|
4
|
+
type BuildManifest = Omit<DatabaseManifest, 'version' | 'dimensions' | 'schema' | 'schemaSignature' | 'coordinates' | 'horizons' | 'mean' | 'std'>;
|
|
5
|
+
/** Omitting schema selects the legacy biped preset. New data is always v2. */
|
|
6
|
+
export declare function buildDatabase(manifest: BuildManifest, raw: Float32Array, schema?: PoseSchema): Database;
|
|
4
7
|
/** Binary format is explicitly little endian float32; version/layout live in manifest. */
|
|
5
8
|
export declare function encodeFeatures(db: Database): ArrayBuffer;
|
|
6
|
-
export declare function decodeDatabase(manifest:
|
|
9
|
+
export declare function decodeDatabase(manifest: unknown, bytes: ArrayBuffer, expectedSchema?: PoseSchema): Database;
|
|
10
|
+
/** Explicit, lossless conversion ONLY for the original v1 27-dimensional preset. */
|
|
11
|
+
export declare function migrateLegacyDatabase(input: unknown, bytes: ArrayBuffer): Database;
|
|
12
|
+
export {};
|