@pose_flow/babylon 0.2.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 +9 -3
- package/dist/AI_USAGE.md +107 -12
- package/dist/PUBLISHING.md +2 -0
- package/dist/babylon-inertialization.d.ts +29 -0
- package/dist/babylon-inertialization.js +88 -0
- package/dist/babylon.d.ts +14 -1
- package/dist/babylon.js +88 -8
- package/dist/core/controller.d.ts +26 -0
- package/dist/core/controller.js +73 -8
- package/dist/core/database.js +2 -0
- package/dist/core/inertialization.d.ts +3 -0
- package/dist/core/inertialization.js +15 -0
- package/dist/core/profile.d.ts +11 -2
- package/dist/core/profile.js +14 -0
- package/dist/core/scenario.d.ts +29 -0
- package/dist/core/scenario.js +57 -0
- package/dist/core/search.d.ts +32 -0
- package/dist/core/search.js +92 -8
- package/dist/core/selection.d.ts +24 -0
- package/dist/core/selection.js +41 -0
- package/dist/core/types.d.ts +3 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/offline/src/babylon-inertialization.js +88 -0
- package/dist/offline/src/babylon.js +88 -8
- package/dist/offline/src/core/controller.js +73 -8
- package/dist/offline/src/core/database.js +2 -0
- package/dist/offline/src/core/inertialization.js +15 -0
- package/dist/offline/src/core/profile.js +14 -0
- package/dist/offline/src/core/scenario.js +57 -0
- package/dist/offline/src/core/search.js +92 -8
- package/dist/offline/src/core/selection.js +41 -0
- package/dist/offline/src/index.js +3 -0
- package/dist/offline/tools/bake.js +3 -1
- package/package.json +4 -4
package/dist/core/types.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { PoseSchema } from './schema.js';
|
|
2
|
+
import type { SelectionContext, SelectionMetadata } from './selection.js';
|
|
2
3
|
/** Legacy preset constants; dynamic consumers must use compileSchema. */
|
|
3
4
|
export declare const DIMENSIONS = 27;
|
|
4
5
|
export declare const HORIZONS: readonly [0.2, 0.4, 0.6];
|
|
5
6
|
export type Vec3 = readonly [number, number, number];
|
|
6
7
|
export type Quat = readonly [number, number, number, number];
|
|
7
8
|
export type Gait = 'idle' | 'walk' | 'run' | 'strafe' | 'turn' | 'stop';
|
|
8
|
-
export interface Clip {
|
|
9
|
+
export interface Clip extends SelectionMetadata {
|
|
9
10
|
id: string;
|
|
10
11
|
file: string;
|
|
11
12
|
animation: string;
|
|
@@ -53,6 +54,7 @@ export interface Match extends Frame {
|
|
|
53
54
|
cost: number;
|
|
54
55
|
}
|
|
55
56
|
export interface Query {
|
|
57
|
+
context?: SelectionContext;
|
|
56
58
|
features: Float32Array;
|
|
57
59
|
schemaSignature?: string;
|
|
58
60
|
gaits?: ReadonlySet<Gait>;
|
package/dist/index.d.ts
CHANGED
|
@@ -6,3 +6,6 @@ export * from './core/search.js';
|
|
|
6
6
|
export * from './core/controller.js';
|
|
7
7
|
export * from './core/root-motion.js';
|
|
8
8
|
export * from './core/profile.js';
|
|
9
|
+
export * from './core/selection.js';
|
|
10
|
+
export * from './core/inertialization.js';
|
|
11
|
+
export * from './core/scenario.js';
|
package/dist/index.js
CHANGED
|
@@ -6,3 +6,6 @@ export * from './core/search.js';
|
|
|
6
6
|
export * from './core/controller.js';
|
|
7
7
|
export * from './core/root-motion.js';
|
|
8
8
|
export * from './core/profile.js';
|
|
9
|
+
export * from './core/selection.js';
|
|
10
|
+
export * from './core/inertialization.js';
|
|
11
|
+
export * from './core/scenario.js';
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Animation, AnimationGroup, Matrix, Quaternion, Vector3 } from '@babylonjs/core';
|
|
2
2
|
import { compileSchema, LOCOMOTION_SCHEMA } from './core/schema.js';
|
|
3
3
|
import { parseRootOrientation } from './core/profile.js';
|
|
4
|
+
import { BabylonInertializer, captureLocal, poseVelocity } from './babylon-inertialization.js';
|
|
5
|
+
const nodeOwners = new WeakMap();
|
|
4
6
|
/** Constant authoring orientation belongs to the pose; varying root heading belongs to gameplay. */
|
|
5
7
|
function constantRootRotation(animation) {
|
|
6
8
|
const keys = animation.getKeys();
|
|
@@ -32,7 +34,10 @@ export class BabylonMotionPlayer {
|
|
|
32
34
|
disposed = false;
|
|
33
35
|
strippedRoot;
|
|
34
36
|
rootRestRotation;
|
|
35
|
-
|
|
37
|
+
owned = new Set();
|
|
38
|
+
rest = new Map();
|
|
39
|
+
inertia;
|
|
40
|
+
constructor(scene, clips, sources, nodes, fadeSeconds = 0.15, rootName = 'root', options = {}) {
|
|
36
41
|
this.scene = scene;
|
|
37
42
|
this.clips = clips;
|
|
38
43
|
this.sources = sources;
|
|
@@ -41,6 +46,10 @@ export class BabylonMotionPlayer {
|
|
|
41
46
|
this.rootName = rootName;
|
|
42
47
|
if (!Number.isFinite(fadeSeconds) || fadeSeconds < 0)
|
|
43
48
|
throw new Error('Invalid crossfade duration');
|
|
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');
|
|
44
53
|
for (const clip of clips) {
|
|
45
54
|
const source = sources.get(clip.id);
|
|
46
55
|
if (!source)
|
|
@@ -50,8 +59,23 @@ export class BabylonMotionPlayer {
|
|
|
50
59
|
|| start + clip.duration > (source.to - source.from) / fps + 1e-5)
|
|
51
60
|
throw new Error(`Invalid source range: ${clip.id}`);
|
|
52
61
|
}
|
|
53
|
-
|
|
54
|
-
if (
|
|
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)) {
|
|
55
79
|
this.strippedRoot.rotationQuaternion ??= Quaternion.FromEulerVector(this.strippedRoot.rotation);
|
|
56
80
|
this.rootRestRotation = this.strippedRoot.rotationQuaternion.clone();
|
|
57
81
|
}
|
|
@@ -61,6 +85,18 @@ export class BabylonMotionPlayer {
|
|
|
61
85
|
if (!this.strippedRoot)
|
|
62
86
|
throw new Error('rootOrientation requires a mapped stripped root');
|
|
63
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);
|
|
64
100
|
}
|
|
65
101
|
create(clip, time) {
|
|
66
102
|
const source = this.sources.get(this.clips[clip].id);
|
|
@@ -68,7 +104,7 @@ export class BabylonMotionPlayer {
|
|
|
68
104
|
let rootRotation = this.rootRestRotation?.clone();
|
|
69
105
|
for (const targeted of source.targetedAnimations) {
|
|
70
106
|
const node = this.nodes.get(targeted.target.name);
|
|
71
|
-
if (!node)
|
|
107
|
+
if (!node || !this.owned.has(node))
|
|
72
108
|
continue;
|
|
73
109
|
const property = targeted.animation.targetProperty;
|
|
74
110
|
if (node === this.strippedRoot) {
|
|
@@ -88,7 +124,7 @@ export class BabylonMotionPlayer {
|
|
|
88
124
|
const correction = this.clips[clip].rootOrientation;
|
|
89
125
|
if (correction)
|
|
90
126
|
rootRotation = Quaternion.FromArray([...correction]).normalize();
|
|
91
|
-
if (this.strippedRoot && rootRotation) {
|
|
127
|
+
if (this.strippedRoot && this.owned.has(this.strippedRoot) && rootRotation) {
|
|
92
128
|
// An explicit constant binding also restores the baseline after a clip with
|
|
93
129
|
// another orientation, and participates in the same weighted crossfade.
|
|
94
130
|
const rotation = new Animation('mm-root-pose', 'rotationQuaternion', source.targetedAnimations[0].animation.framePerSecond, Animation.ANIMATIONTYPE_QUATERNION);
|
|
@@ -115,6 +151,13 @@ export class BabylonMotionPlayer {
|
|
|
115
151
|
}
|
|
116
152
|
if (!this.clips[state.clip])
|
|
117
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
|
+
}
|
|
118
161
|
if (!this.active || state.switched || this.active.clip !== state.clip) {
|
|
119
162
|
// Bound simultaneous slots during rapid replanning; residual poses retain relative weights.
|
|
120
163
|
if (this.tracks.length >= 4) {
|
|
@@ -124,7 +167,18 @@ export class BabylonMotionPlayer {
|
|
|
124
167
|
this.active = this.create(state.clip, state.time);
|
|
125
168
|
}
|
|
126
169
|
this.active.time = state.time;
|
|
127
|
-
|
|
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);
|
|
128
182
|
for (const track of this.tracks) {
|
|
129
183
|
if (track !== this.active)
|
|
130
184
|
track.time += dt;
|
|
@@ -147,9 +201,35 @@ export class BabylonMotionPlayer {
|
|
|
147
201
|
return true;
|
|
148
202
|
});
|
|
149
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(); }
|
|
150
222
|
clear() { for (const t of this.tracks)
|
|
151
|
-
t.group.dispose(); this.tracks = []; this.active = undefined; }
|
|
152
|
-
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
|
+
}
|
|
153
233
|
}
|
|
154
234
|
/** Sample AFTER scene animation evaluation. Semantic roles are defined by the schema. */
|
|
155
235
|
export class BabylonSchemaPoseReader {
|
|
@@ -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,22 +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');
|
|
28
41
|
// Validate identity even while continuity skips a search this frame.
|
|
29
42
|
this.search.validateQuery(query);
|
|
43
|
+
const previousClip = this.clip;
|
|
44
|
+
let reason = 'search-interval', searched = false, currentCost = null, candidateCost = null;
|
|
30
45
|
let switched = false, ended = false;
|
|
31
46
|
for (const [clip, remaining] of this.completed) {
|
|
32
47
|
if (remaining <= dt)
|
|
@@ -42,15 +57,47 @@ export class MotionMatcher {
|
|
|
42
57
|
}
|
|
43
58
|
this.sinceSearch += dt;
|
|
44
59
|
this.sinceSwitch += dt;
|
|
45
|
-
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
|
+
}
|
|
46
68
|
if (this.sinceSearch >= this.options.searchInterval || !allowed || ended) {
|
|
69
|
+
searched = true;
|
|
47
70
|
this.sinceSearch = 0;
|
|
48
71
|
// A finished one-shot must yield to another clip, rather than selecting
|
|
49
72
|
// its terminal pose and restarting forever. User exclusions still apply.
|
|
50
73
|
if (ended)
|
|
51
74
|
this.completed.set(this.clip, this.options.reselectDelay);
|
|
52
75
|
const excludedClips = new Set([...(query.excludedClips ?? []), ...this.completed.keys()]);
|
|
53
|
-
|
|
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 });
|
|
54
101
|
this.last = best;
|
|
55
102
|
if (best) {
|
|
56
103
|
const current = this.clip >= 0 ? this.nearestFrame(this.clip, this.time) : -1;
|
|
@@ -60,21 +107,39 @@ export class MotionMatcher {
|
|
|
60
107
|
gap = Math.min(gap, duration - gap);
|
|
61
108
|
const distant = best.clip !== this.clip || gap > this.options.timeSeparation;
|
|
62
109
|
const better = current < 0 || best.cost + this.options.jumpPenalty < this.search.cost(current) * (1 - this.options.improvement);
|
|
110
|
+
currentCost = current < 0 ? null : this.search.cost(current);
|
|
111
|
+
candidateCost = best.cost;
|
|
63
112
|
const incompatibleVelocity = current >= 0 && distant && this.sinceSwitch >= this.options.minimumHold && query.velocityTolerance !== undefined
|
|
64
113
|
&& this.search.rootVelocityError(current, query) > Math.max(query.velocityTolerance, this.search.rootVelocityError(best.index, query) + 1e-6);
|
|
65
114
|
const mayReselectTime = query.allowTemporalReselection !== false || best.clip !== this.clip || !this.database.manifest.clips[best.clip].looping;
|
|
66
|
-
|
|
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) {
|
|
67
120
|
this.clip = best.clip;
|
|
68
121
|
this.time = best.time;
|
|
69
122
|
this.sinceSwitch = 0;
|
|
70
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 = [];
|
|
71
131
|
}
|
|
72
132
|
}
|
|
73
|
-
else
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
}
|
|
76
139
|
}
|
|
77
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 };
|
|
78
143
|
return { clip: this.clip, time: this.time, switched, match: this.last };
|
|
79
144
|
}
|
|
80
145
|
nearestFrame(clip, time) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { compileSchema, LOCOMOTION_SCHEMA } from './schema.js';
|
|
2
2
|
import { parseRootOrientation } from './profile.js';
|
|
3
|
+
import { validateSelection } from './selection.js';
|
|
3
4
|
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
4
5
|
export function validateDatabase(db) {
|
|
5
6
|
const m = db?.manifest;
|
|
@@ -30,6 +31,7 @@ export function validateDatabase(db) {
|
|
|
30
31
|
|| !['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(clip.gait))
|
|
31
32
|
throw new Error('Invalid clip');
|
|
32
33
|
ids.add(clip.id);
|
|
34
|
+
validateSelection(clip, clip.duration);
|
|
33
35
|
if (clip.sourceStart !== undefined && (!Number.isFinite(clip.sourceStart) || clip.sourceStart < 0))
|
|
34
36
|
throw new Error('Invalid source start');
|
|
35
37
|
if (clip.averageVelocity !== undefined && (!Array.isArray(clip.averageVelocity) || clip.averageVelocity.length !== 3 || !clip.averageVelocity.every(Number.isFinite)))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Critically damped residual. With zero initial velocity, offset halves at halfLife.
|
|
2
|
+
* Does not integrate gameplay/root displacement. */
|
|
3
|
+
export function decayInertialOffset(offset, velocity, elapsed, halfLife) {
|
|
4
|
+
if (![offset, velocity, elapsed, halfLife].every(Number.isFinite) || elapsed < 0 || halfLife <= 0)
|
|
5
|
+
throw new Error('Invalid inertial residual');
|
|
6
|
+
if (elapsed === 0)
|
|
7
|
+
return offset;
|
|
8
|
+
const exponent = (1.67834699001666 / halfLife) * elapsed;
|
|
9
|
+
if (exponent > 745)
|
|
10
|
+
return 0;
|
|
11
|
+
const decay = Math.exp(-exponent);
|
|
12
|
+
// Reassociate products: avoid Infinity * 0 after a long pause, and avoid
|
|
13
|
+
// overflowing omega*offset while the final damped value is representable.
|
|
14
|
+
return offset * ((1 + exponent) * decay) + velocity * (elapsed * decay);
|
|
15
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { compileSchema, parseSchema } from './schema.js';
|
|
2
|
+
import { validateContext, validateSelection } from './selection.js';
|
|
2
3
|
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
3
4
|
const name = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
4
5
|
const asset = (v) => name(v) && !v.includes('\\') && !v.includes('..') && !/[?#:]/.test(v);
|
|
@@ -25,6 +26,18 @@ export function parseProfile(input) {
|
|
|
25
26
|
throw new Error('Profile: required bones must be distinct');
|
|
26
27
|
if (input.stripRoot !== undefined && !name(input.stripRoot))
|
|
27
28
|
throw new Error('Profile: invalid stripRoot');
|
|
29
|
+
if (input.masks !== undefined && (!record(input.masks) || !Object.entries(input.masks).length || !Object.entries(input.masks).every(([key, values]) => name(key)
|
|
30
|
+
&& Array.isArray(values) && values.length > 0 && values.every(name) && new Set(values).size === values.length)))
|
|
31
|
+
throw new Error('Profile: invalid masks');
|
|
32
|
+
if (input.layerPreview !== undefined) {
|
|
33
|
+
const preview = input.layerPreview, masks = input.masks;
|
|
34
|
+
if (!record(preview) || !name(preview.baseMask) || !name(preview.overlayMask) || !masks || !Object.hasOwn(masks, preview.baseMask) || !Object.hasOwn(masks, preview.overlayMask)
|
|
35
|
+
|| masks[preview.baseMask].some(node => masks[preview.overlayMask].includes(node)))
|
|
36
|
+
throw new Error('Profile: layer preview requires two disjoint masks');
|
|
37
|
+
validateContext(preview.context);
|
|
38
|
+
if (!Object.keys(preview.context).length)
|
|
39
|
+
throw new Error('Profile: layer preview requires explicit context');
|
|
40
|
+
}
|
|
28
41
|
if (input.boneMap !== undefined && (!record(input.boneMap) || !Object.entries(input.boneMap).every(([k, v]) => name(k) && name(v)) || new Set(Object.values(input.boneMap)).size !== Object.values(input.boneMap).length))
|
|
29
42
|
throw new Error('Profile: boneMap must map distinct nonempty names');
|
|
30
43
|
return structuredClone(input);
|
|
@@ -42,6 +55,7 @@ export function parseAnimationSet(input) {
|
|
|
42
55
|
parseRootOrientation(clip.rootOrientation);
|
|
43
56
|
if (clip.range !== undefined && (!Array.isArray(clip.range) || clip.range.length !== 2 || !clip.range.every(Number.isFinite) || clip.range[0] < 0 || clip.range[1] <= clip.range[0]))
|
|
44
57
|
throw new Error('Invalid clip range');
|
|
58
|
+
validateSelection(clip, Array.isArray(clip.range) ? clip.range[1] - clip.range[0] : Infinity);
|
|
45
59
|
const nominal = clip.nominalVelocity !== undefined, measured = clip.motionFile !== undefined;
|
|
46
60
|
if (nominal === measured)
|
|
47
61
|
throw new Error(`Profile ${clip.id}: declare exactly one of nominalVelocity or motionFile`);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { encodeFeatures } from './database.js';
|
|
2
|
+
import { MotionMatcher } from './controller.js';
|
|
3
|
+
import { PoseSearch } from './search.js';
|
|
4
|
+
/** Content identity for exact replay, not a cryptographic/security signature. */
|
|
5
|
+
export function scenarioDatabaseIdentity(db) {
|
|
6
|
+
let hash = 2166136261;
|
|
7
|
+
for (const char of JSON.stringify(db.manifest))
|
|
8
|
+
hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
|
|
9
|
+
for (const byte of new Uint8Array(encodeFeatures(db)))
|
|
10
|
+
hash = Math.imul(hash ^ byte, 16777619);
|
|
11
|
+
return 'scenario-db-v1:' + (hash >>> 0).toString(16).padStart(8, '0');
|
|
12
|
+
}
|
|
13
|
+
export function restoreScenarioQuery(recorded) {
|
|
14
|
+
return { ...recorded, features: Float32Array.from(recorded.features), gaits: recorded.gaits ? new Set(recorded.gaits) : undefined,
|
|
15
|
+
excludedClips: recorded.excludedClips ? new Set(recorded.excludedClips) : undefined };
|
|
16
|
+
}
|
|
17
|
+
export function captureScenarioFrame(dt, query, position, yaw, playback, presentation) {
|
|
18
|
+
return { dt, query: { ...query, features: [...query.features], ...(query.context ? { context: { ...query.context } } : {}),
|
|
19
|
+
gaits: query.gaits ? [...query.gaits] : undefined, excludedClips: query.excludedClips ? [...query.excludedClips] : undefined },
|
|
20
|
+
controller: { position: [...position], yaw }, playback: { clip: playback.clip, time: playback.time, switched: playback.switched },
|
|
21
|
+
...(presentation ? { presentation: { clip: presentation.clip, time: presentation.time, switched: presentation.switched } } : {}) };
|
|
22
|
+
}
|
|
23
|
+
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
24
|
+
/** Import only validated local data, never execute content from a scenario file. */
|
|
25
|
+
export function parseMotionScenario(input, db) {
|
|
26
|
+
if (!record(input) || input.version !== 1 || input.databaseIdentity !== scenarioDatabaseIdentity(db) || !Array.isArray(input.frames)
|
|
27
|
+
|| !input.frames.length || input.frames.length > 6000 || input.frames.length * db.manifest.dimensions > 2_000_000 || !record(input.matcherOptions))
|
|
28
|
+
throw new Error('Invalid/incompatible motion scenario');
|
|
29
|
+
const options = input.matcherOptions;
|
|
30
|
+
if (Object.keys(options).some(k => !['searchInterval', 'minimumHold', 'improvement', 'jumpPenalty', 'timeSeparation', 'reselectDelay', 'idleVariation', 'diagnostics'].includes(k)))
|
|
31
|
+
throw new Error('Invalid scenario matcher options');
|
|
32
|
+
new MotionMatcher(db, options);
|
|
33
|
+
const search = new PoseSearch(db);
|
|
34
|
+
for (const frame of input.frames) {
|
|
35
|
+
if (!record(frame) || typeof frame.dt !== 'number' || !Number.isFinite(frame.dt) || frame.dt < 0 || frame.dt > 10 || !record(frame.query)
|
|
36
|
+
|| !record(frame.controller) || !record(frame.playback))
|
|
37
|
+
throw new Error('Invalid scenario frame');
|
|
38
|
+
const q = frame.query, p = frame.playback, controller = frame.controller;
|
|
39
|
+
if (Object.keys(q).some(k => !['features', 'schemaSignature', 'gaits', 'excludedClips', 'velocityTolerance', 'allowTemporalReselection', 'context'].includes(k))
|
|
40
|
+
|| !Array.isArray(q.features) || q.features.length !== db.manifest.dimensions || !q.features.every(Number.isFinite)
|
|
41
|
+
|| (q.gaits !== undefined && (!Array.isArray(q.gaits) || !q.gaits.every(g => ['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(g))))
|
|
42
|
+
|| (q.excludedClips !== undefined && (!Array.isArray(q.excludedClips) || !q.excludedClips.every(c => Number.isInteger(c) && c >= 0 && c < db.manifest.clips.length))))
|
|
43
|
+
throw new Error('Invalid scenario query');
|
|
44
|
+
search.validateQuery(restoreScenarioQuery(q));
|
|
45
|
+
if (!Array.isArray(controller.position) || controller.position.length !== 3 || !controller.position.every(Number.isFinite) || typeof controller.yaw !== 'number' || !Number.isFinite(controller.yaw)
|
|
46
|
+
|| !Number.isInteger(p.clip) || p.clip < -1 || p.clip >= db.manifest.clips.length || typeof p.time !== 'number' || !Number.isFinite(p.time) || p.time < 0
|
|
47
|
+
|| (p.clip >= 0 && p.time > db.manifest.clips[p.clip].duration + 1e-6) || typeof p.switched !== 'boolean')
|
|
48
|
+
throw new Error('Invalid scenario playback/controller');
|
|
49
|
+
if (frame.presentation !== undefined) {
|
|
50
|
+
const p = frame.presentation;
|
|
51
|
+
if (!record(p) || !Number.isInteger(p.clip) || p.clip < -1 || p.clip >= db.manifest.clips.length || typeof p.time !== 'number' || !Number.isFinite(p.time) || p.time < 0
|
|
52
|
+
|| (p.clip >= 0 && p.time > db.manifest.clips[p.clip].duration + 1e-6) || typeof p.switched !== 'boolean')
|
|
53
|
+
throw new Error('Invalid scenario presentation');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return structuredClone(input);
|
|
57
|
+
}
|