@pose_flow/babylon 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +104 -2
- package/dist/babylon.d.ts +29 -0
- package/dist/babylon.js +126 -0
- package/dist/core/controller.d.ts +33 -0
- package/dist/core/controller.js +92 -0
- package/dist/core/database.d.ts +6 -0
- package/dist/core/database.js +59 -0
- package/dist/core/features.d.ts +15 -0
- package/dist/core/features.js +43 -0
- package/dist/core/profile.d.ts +37 -0
- package/dist/core/profile.js +64 -0
- package/dist/core/root-motion.d.ts +22 -0
- package/dist/core/root-motion.js +42 -0
- package/dist/core/search.d.ts +11 -0
- package/dist/core/search.js +50 -0
- package/dist/core/types.d.ts +57 -0
- package/dist/core/types.js +2 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/package.json +37 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Motion Match contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,105 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Motion Match · Babylon.js
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
O código original desta biblioteca é distribuído sob a [licença MIT](LICENSE), com aviso de copyright “2026 Motion Match contributors”. Dependências, modelos, texturas e animações de terceiros mantêm suas próprias licenças; este aviso não os relicencia. Consulte a [proveniência dos assets](docs/ASSETS.md).
|
|
4
|
+
|
|
5
|
+
Biblioteca TypeScript de Motion Matching clássico com Pose Search e laboratório 3D. Inspiração conceitual no Unreal Engine; implementação própria e inicial, sem paridade com o plugin da Epic.
|
|
6
|
+
|
|
7
|
+
O banco compara **pose visível + velocidade + trajetória desejada** e seleciona clipe/tempo. O controlador do jogo continua responsável por posição, colisões, orientação e combate.
|
|
8
|
+
|
|
9
|
+
**Prioridade atual: biblioteca reutilizável para diferentes personagens e jogos.** Modelo, rig, escala, clipes e banco são definidos por perfis JSON, compartilhados pelo bake e laboratório. Veja [como adicionar um personagem](docs/PROFILES.md). Há também o exemplo Barbarian com escala e sample rate próprios.
|
|
10
|
+
|
|
11
|
+
## Executar
|
|
12
|
+
|
|
13
|
+
```powershell
|
|
14
|
+
npm ci
|
|
15
|
+
npm run dev
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Abrir http://127.0.0.1:5173. WASD movimenta, Shift corre, arraste orbita e scroll aproxima. O seletor alterna Knight/KayKit e Paladin/Mixamo. “Teste de locomoção” mantém um movimento ativo para inspeção, sem segurar teclas; voltar a “Manual” devolve o controle ao WASD. “Manter orientação” permite testar deslocamento lateral e para trás no modo manual. O painel mostra candidato, custo, tempo, transições e velocidade. O valor “Atualização MM” inclui o controlador e só inclui busca quando o intervalo vence.
|
|
19
|
+
|
|
20
|
+
Os GLB e bancos já foram gerados neste workspace. Para regenerar:
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
npm run bake
|
|
24
|
+
npm run bake -- --paladin
|
|
25
|
+
npm run validate:assets -- --profile public/profiles/barbarian.json
|
|
26
|
+
npm run bake -- --profile public/profiles/barbarian.json
|
|
27
|
+
npm run typecheck
|
|
28
|
+
npm test
|
|
29
|
+
npm run build
|
|
30
|
+
npm run benchmark
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
O build produz `dist/` (biblioteca ESM + declarações) e `demo-dist/` (laboratório). O pacote npm inclui apenas a biblioteca, não os personagens. Não foi publicado.
|
|
34
|
+
|
|
35
|
+
## Implementado
|
|
36
|
+
|
|
37
|
+
- Núcleo sem engine: esquema fixo de 27 características, normalização compartilhada, busca exata ponderada em `Float32Array`, filtros de gait e banco versionado JSON + float32 little endian.
|
|
38
|
+
- Continuidade: avanço natural, loops, fim de clipe, intervalo de busca, tempo mínimo entre trocas e margem de melhora. Defaults de tuning, não parâmetros oficiais do Unreal.
|
|
39
|
+
- Predição com aceleração, frenagem e velocidade angular; horizontes 0,2 / 0,4 / 0,6 s.
|
|
40
|
+
- Adaptador Babylon.js 7.54.3: leitura dos pés visíveis e reprodução com crossfade, incluindo saltos entre dois tempos do mesmo clipe.
|
|
41
|
+
- Bake offline via o mesmo carregador/interpolador Babylon usado na demonstração.
|
|
42
|
+
- Três bancos separados: **Knight, 174 poses / 6 clipes**; **Paladin, 610 poses / 9 clipes**; **Barbarian, 138 poses / 6 clipes**. Todos têm idle, walk, run, backward, left, right; o Paladin inclui stop, turn-left e turn-right sem loop.
|
|
43
|
+
- Trajetória medida dos hips para os três novos clipes: posição, yaw contínuo, velocidade, interpolação e término sem repetir o início. Os dados extraídos alimentam apenas o bake; deslocamento e heading em runtime continuam definidos pelo controlador.
|
|
44
|
+
- Pipeline Blender 5.2 para inspecionar e converter os FBX locais do Paladin.
|
|
45
|
+
|
|
46
|
+
## Features e coordenadas
|
|
47
|
+
|
|
48
|
+
| Índices | Característica |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| 0–5 | Posição local dos dois pés, XYZ |
|
|
51
|
+
| 6–11 | Velocidade dos pés no mundo, expressa nos eixos locais, XYZ |
|
|
52
|
+
| 12–14 | Velocidade do controlador nos eixos locais, XYZ |
|
|
53
|
+
| 15–20 | Posições futuras locais, XZ, em três horizontes |
|
|
54
|
+
| 21–26 | Direções futuras locais, XZ, em três horizontes |
|
|
55
|
+
|
|
56
|
+
Metros, segundos, Y acima e Z frente. A demo e o bake usam `scene.useRightHandedSystem = true`. Nesse referencial, o lado anatômico esquerdo dos modelos olhando +Z aponta para +X; yaw positivo gira +Z em direção a +X. A direita da câmera é calculada por sua projeção, não por assumir +X. Query e banco precisam usar exatamente a mesma convenção e escala. Não mudar a convenção do MMORPG para importar a lib; adaptar seu referencial ou gerar banco compatível.
|
|
57
|
+
|
|
58
|
+
Peso inicial por dimensão: pose/velocidade 1, trajetória/direção 3. Dimensões com desvio abaixo de 0,0001 usam escala 1, evitando amplificar canais constantes em bancos sem giros. Filtros são opcionais; a demonstração pesquisa todos os clipes.
|
|
59
|
+
|
|
60
|
+
**Os dois conjuntos são reproduzidos in-place.** Nos seis loops básicos, velocidades em `tools/bake.ts` são parâmetros de autoria: frente 1,8/4,5 m/s; costas −1,5 m/s; strafe KayKit ±3,2 m/s; strafe Paladin ±1,8 m/s. São valores iniciais, não medidas. Esquerda usa +X e direita −X. O bake KayKit remove as mesmas trilhas de root que o player, inclusive o heading presente nos strafes.
|
|
61
|
+
|
|
62
|
+
Para **stop e giros do Paladin**, Blender registra posição planar e yaw dos hips antes de remover esses movimentos da animação. Os arquivos `.motion.json` são amostrados em relação ao heading atual; futuros além do fim ficam no último estado. A velocidade dos pés inclui a contribuição da rotação do referencial. O movimento vertical do esqueleto é preservado. A orientação é um proxy extraído da pelvis e inclui oscilação natural; não equivale a root motion limpo de produção. Foot sliding ainda pode acontecer.
|
|
63
|
+
|
|
64
|
+
O laboratório tem ciclos de parada e giros no seletor do Paladin. Filtros por estado disponibilizam parada durante frenagem e giro durante mudança de heading com baixa velocidade. A busca decide o clipe/tempo dentro dos candidatos permitidos. Clips concluídos ficam inelegíveis por 0,6 s (configurável em `reselectDelay`). `Query.excludedClips` permite outras exclusões explícitas. O painel exibe a origem da trajetória e as três últimas trocas.
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { decodeDatabase, MotionMatcher, featureVector, predictTrajectory } from '@motion-match/babylon';
|
|
70
|
+
import { BabylonMotionPlayer, BabylonPoseReader } from '@motion-match/babylon/babylon';
|
|
71
|
+
|
|
72
|
+
// manifest e bytes são carregados uma vez; sem bake durante o jogo.
|
|
73
|
+
const database = decodeDatabase(manifest, bytes);
|
|
74
|
+
const matcher = new MotionMatcher(database);
|
|
75
|
+
// sources: Map<clip.id, AnimationGroup>; nodes: Map<nome original do osso, TransformNode>.
|
|
76
|
+
const player = new BabylonMotionPlayer(scene, manifest.clips, sources, nodes);
|
|
77
|
+
const reader = new BabylonPoseReader(controllerRoot, leftFoot, rightFoot);
|
|
78
|
+
|
|
79
|
+
// A cada frame, usando a pose medida após a renderização anterior:
|
|
80
|
+
const future = predictTrajectory(trajectoryInput);
|
|
81
|
+
const query = { features: featureVector(lastVisiblePose, future) };
|
|
82
|
+
const playback = matcher.update(dt, query);
|
|
83
|
+
player.update(playback, dt); // antes de scene.render()
|
|
84
|
+
scene.render();
|
|
85
|
+
lastVisiblePose = reader.read(dt); // depois da avaliação de animações
|
|
86
|
+
|
|
87
|
+
// Em teleporte/reconciliação descontínua: reader.reset(); matcher.reset().
|
|
88
|
+
// Na remoção do personagem: player.dispose().
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Instalar localmente no jogo, quando integrar: `npm install D:/motion_match`. Copiar os assets/banco necessários para a pasta pública do jogo é uma etapa separada. Ver contrato e restrições em [docs/INTEGRATION.md](docs/INTEGRATION.md).
|
|
92
|
+
|
|
93
|
+
## Limites atuais
|
|
94
|
+
|
|
95
|
+
Ainda não há IK, foot locking, contato dos pés, inertialization, motion warping, root-motion-driven movement, worker, KD-tree ou PCA. Starts e giros em movimento ainda não foram preparados. Os três clipes medidos são sem loop; não há extração com composição de root motion através de ciclos. A busca linear é apropriada para medir esta base pequena; não há garantia de orçamento em bancos grandes ou com muitos jogadores.
|
|
96
|
+
|
|
97
|
+
A biblioteca tem esquema fixo, não um editor visual de schemas como o Unreal. Não há retargeting automático entre esqueletos diferentes. KayKit e Mixamo possuem bancos e mapas de ossos independentes. Crossfade de clips com trilhas ausentes exige preparação de cobertura dos ossos.
|
|
98
|
+
|
|
99
|
+
O laboratório tem um bundle de demonstração grande pelo carregamento amplo de Babylon; o build avisa sobre isso. O núcleo da lib não depende de Babylon, e sua entrada pública separada permite usá-lo sem importar o adaptador.
|
|
100
|
+
|
|
101
|
+
## Referências e proveniência
|
|
102
|
+
|
|
103
|
+
- [Epic: Motion Matching](https://dev.epicgames.com/documentation/en-us/unreal-engine/motion-matching-in-unreal-engine): banco, pose, trajetória e busca.
|
|
104
|
+
- [Babylon: Advanced Animations](https://doc.babylonjs.com/features/featuresDeepDive/animation/advanced_animations): grupos de animação e pesos. A implementação foi validada contra o código instalado da versão 7.54.3.
|
|
105
|
+
- [Assets locais](docs/ASSETS.md), [evidências e pendências](docs/VALIDATION.md), [referência fornecida pelo usuário](MOTION_MATCHING_POSE_SEARCH_WEB_BASE_REFERENCIAS.md).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { AnimationGroup, TransformNode, type Scene } from '@babylonjs/core';
|
|
2
|
+
import type { Clip, Playback, Pose } from './index.js';
|
|
3
|
+
/** Clone onto a target rig. Two playback slots allow crossfade between times of the same clip. */
|
|
4
|
+
export declare class BabylonMotionPlayer {
|
|
5
|
+
private scene;
|
|
6
|
+
private clips;
|
|
7
|
+
private sources;
|
|
8
|
+
private nodes;
|
|
9
|
+
readonly fadeSeconds: number;
|
|
10
|
+
private rootName;
|
|
11
|
+
private tracks;
|
|
12
|
+
private active?;
|
|
13
|
+
private disposed;
|
|
14
|
+
constructor(scene: Scene, clips: readonly Clip[], sources: ReadonlyMap<string, AnimationGroup>, nodes: ReadonlyMap<string, TransformNode>, fadeSeconds?: number, rootName?: string);
|
|
15
|
+
private create;
|
|
16
|
+
update(state: Playback, dt: number): void;
|
|
17
|
+
private clear;
|
|
18
|
+
dispose(): void;
|
|
19
|
+
}
|
|
20
|
+
/** Sample AFTER scene animation evaluation. Measures visible feet, including crossfade. */
|
|
21
|
+
export declare class BabylonPoseReader {
|
|
22
|
+
private root;
|
|
23
|
+
private left;
|
|
24
|
+
private right;
|
|
25
|
+
private previous?;
|
|
26
|
+
constructor(root: TransformNode, left: TransformNode, right: TransformNode);
|
|
27
|
+
reset(): void;
|
|
28
|
+
read(dt: number): Pose;
|
|
29
|
+
}
|
package/dist/babylon.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { AnimationGroup, Matrix, Vector3 } from '@babylonjs/core';
|
|
2
|
+
/** Clone onto a target rig. Two playback slots allow crossfade between times of the same clip. */
|
|
3
|
+
export class BabylonMotionPlayer {
|
|
4
|
+
scene;
|
|
5
|
+
clips;
|
|
6
|
+
sources;
|
|
7
|
+
nodes;
|
|
8
|
+
fadeSeconds;
|
|
9
|
+
rootName;
|
|
10
|
+
tracks = [];
|
|
11
|
+
active;
|
|
12
|
+
disposed = false;
|
|
13
|
+
constructor(scene, clips, sources, nodes, fadeSeconds = 0.15, rootName = 'root') {
|
|
14
|
+
this.scene = scene;
|
|
15
|
+
this.clips = clips;
|
|
16
|
+
this.sources = sources;
|
|
17
|
+
this.nodes = nodes;
|
|
18
|
+
this.fadeSeconds = fadeSeconds;
|
|
19
|
+
this.rootName = rootName;
|
|
20
|
+
if (!Number.isFinite(fadeSeconds) || fadeSeconds < 0)
|
|
21
|
+
throw new Error('Invalid crossfade duration');
|
|
22
|
+
for (const clip of clips)
|
|
23
|
+
if (!sources.has(clip.id))
|
|
24
|
+
throw new Error(`Missing animation source: ${clip.id}`);
|
|
25
|
+
}
|
|
26
|
+
create(clip, time) {
|
|
27
|
+
const source = this.sources.get(this.clips[clip].id);
|
|
28
|
+
const group = new AnimationGroup(`mm-${this.clips[clip].id}-${this.tracks.length}`, this.scene);
|
|
29
|
+
for (const targeted of source.targetedAnimations) {
|
|
30
|
+
const node = this.nodes.get(targeted.target.name);
|
|
31
|
+
if (!node)
|
|
32
|
+
continue;
|
|
33
|
+
const property = targeted.animation.targetProperty;
|
|
34
|
+
// Gameplay owns horizontal root displacement AND heading. Preserve vertical bob only on other bones.
|
|
35
|
+
if (node.name === this.rootName && (property === 'position' || property === 'rotationQuaternion' || property === 'rotation'))
|
|
36
|
+
continue;
|
|
37
|
+
group.addTargetedAnimation(targeted.animation, node);
|
|
38
|
+
}
|
|
39
|
+
if (!group.targetedAnimations.length) {
|
|
40
|
+
group.dispose();
|
|
41
|
+
throw new Error('No matching rig animation tracks');
|
|
42
|
+
}
|
|
43
|
+
// Paused groups still participate in Babylon's late weighted bindings when
|
|
44
|
+
// goToFrame(frame, true) is called. Speed zero alone resets on scene.render().
|
|
45
|
+
group.start(true, 0);
|
|
46
|
+
group.pause();
|
|
47
|
+
group.setWeightForAllAnimatables(0);
|
|
48
|
+
const track = { group, clip, time, weight: this.tracks.length === 0 ? 1 : 0 };
|
|
49
|
+
this.tracks.push(track);
|
|
50
|
+
return track;
|
|
51
|
+
}
|
|
52
|
+
update(state, dt) {
|
|
53
|
+
if (this.disposed)
|
|
54
|
+
throw new Error('Player already disposed');
|
|
55
|
+
if (!Number.isFinite(dt) || dt < 0)
|
|
56
|
+
throw new Error('Invalid player delta');
|
|
57
|
+
if (state.clip < 0) {
|
|
58
|
+
this.clear();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!this.clips[state.clip])
|
|
62
|
+
throw new Error('Unknown playback clip');
|
|
63
|
+
if (!this.active || state.switched || this.active.clip !== state.clip) {
|
|
64
|
+
// Bound simultaneous slots during rapid replanning; residual poses retain relative weights.
|
|
65
|
+
if (this.tracks.length >= 4) {
|
|
66
|
+
const oldest = this.tracks.shift();
|
|
67
|
+
oldest.group.dispose();
|
|
68
|
+
}
|
|
69
|
+
this.active = this.create(state.clip, state.time);
|
|
70
|
+
}
|
|
71
|
+
this.active.time = state.time;
|
|
72
|
+
const alpha = this.fadeSeconds === 0 ? 1 : 1 - Math.exp(-dt * 4 / this.fadeSeconds);
|
|
73
|
+
for (const track of this.tracks) {
|
|
74
|
+
if (track !== this.active)
|
|
75
|
+
track.time += dt;
|
|
76
|
+
track.weight += ((track === this.active ? 1 : 0) - track.weight) * alpha;
|
|
77
|
+
}
|
|
78
|
+
// First pose must be fully defined even at dt=0.
|
|
79
|
+
const total = this.tracks.reduce((sum, t) => sum + t.weight, 0);
|
|
80
|
+
for (const track of this.tracks) {
|
|
81
|
+
const descriptor = this.clips[track.clip], group = track.group;
|
|
82
|
+
const time = descriptor.looping ? track.time % descriptor.duration : Math.min(track.time, descriptor.duration);
|
|
83
|
+
group.setWeightForAllAnimatables(total > 0 ? track.weight / total : (track === this.active ? 1 : 0));
|
|
84
|
+
group.goToFrame(group.from + (group.to - group.from) * time / descriptor.duration, true);
|
|
85
|
+
}
|
|
86
|
+
this.tracks = this.tracks.filter(track => {
|
|
87
|
+
if (track !== this.active && track.weight < 0.001) {
|
|
88
|
+
track.group.dispose();
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
clear() { for (const t of this.tracks)
|
|
95
|
+
t.group.dispose(); this.tracks = []; this.active = undefined; }
|
|
96
|
+
dispose() { this.clear(); this.disposed = true; }
|
|
97
|
+
}
|
|
98
|
+
/** Sample AFTER scene animation evaluation. Measures visible feet, including crossfade. */
|
|
99
|
+
export class BabylonPoseReader {
|
|
100
|
+
root;
|
|
101
|
+
left;
|
|
102
|
+
right;
|
|
103
|
+
previous;
|
|
104
|
+
constructor(root, left, right) {
|
|
105
|
+
this.root = root;
|
|
106
|
+
this.left = left;
|
|
107
|
+
this.right = right;
|
|
108
|
+
}
|
|
109
|
+
reset() { this.previous = undefined; }
|
|
110
|
+
read(dt) {
|
|
111
|
+
if (!Number.isFinite(dt) || dt < 0)
|
|
112
|
+
throw new Error('Invalid pose delta');
|
|
113
|
+
this.root.computeWorldMatrix(true);
|
|
114
|
+
this.left.computeWorldMatrix(true);
|
|
115
|
+
this.right.computeWorldMatrix(true);
|
|
116
|
+
const root = this.root.getAbsolutePosition().clone(), left = this.left.getAbsolutePosition().clone(), right = this.right.getAbsolutePosition().clone();
|
|
117
|
+
const inverse = Matrix.Invert(this.root.getWorldMatrix());
|
|
118
|
+
// Vector transform through inverse includes inverse model scale: consumers should keep root scale 1.
|
|
119
|
+
const tuple = (v) => [v.x, v.y, v.z];
|
|
120
|
+
const velocity = (value, previous) => previous && dt > 0 ? Vector3.TransformNormal(value.subtract(previous).scale(1 / dt), inverse) : Vector3.Zero();
|
|
121
|
+
const pose = { leftPosition: tuple(Vector3.TransformCoordinates(left, inverse)), rightPosition: tuple(Vector3.TransformCoordinates(right, inverse)),
|
|
122
|
+
leftVelocity: tuple(velocity(left, this.previous?.left)), rightVelocity: tuple(velocity(right, this.previous?.right)), rootVelocity: tuple(velocity(root, this.previous?.root)) };
|
|
123
|
+
this.previous = { left, right, root };
|
|
124
|
+
return pose;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { PoseSearch } from './search.js';
|
|
2
|
+
import type { Database, Match, Query } from './types.js';
|
|
3
|
+
export interface Playback {
|
|
4
|
+
clip: number;
|
|
5
|
+
time: number;
|
|
6
|
+
switched: boolean;
|
|
7
|
+
match: Match | null;
|
|
8
|
+
}
|
|
9
|
+
export interface MatcherOptions {
|
|
10
|
+
searchInterval: number;
|
|
11
|
+
minimumHold: number;
|
|
12
|
+
improvement: number;
|
|
13
|
+
jumpPenalty: number;
|
|
14
|
+
timeSeparation: number;
|
|
15
|
+
reselectDelay: number;
|
|
16
|
+
}
|
|
17
|
+
/** Owns animation time only. Caller retains movement and action authority. */
|
|
18
|
+
export declare class MotionMatcher {
|
|
19
|
+
readonly database: Database;
|
|
20
|
+
readonly search: PoseSearch;
|
|
21
|
+
readonly options: MatcherOptions;
|
|
22
|
+
private clip;
|
|
23
|
+
private time;
|
|
24
|
+
private sinceSearch;
|
|
25
|
+
private sinceSwitch;
|
|
26
|
+
private last;
|
|
27
|
+
private completed;
|
|
28
|
+
private indices;
|
|
29
|
+
constructor(database: Database, options?: Partial<MatcherOptions>);
|
|
30
|
+
reset(): void;
|
|
31
|
+
update(dt: number, query: Query): Playback;
|
|
32
|
+
nearestFrame(clip: number, time: number): number;
|
|
33
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { PoseSearch } from './search.js';
|
|
2
|
+
/** Owns animation time only. Caller retains movement and action authority. */
|
|
3
|
+
export class MotionMatcher {
|
|
4
|
+
database;
|
|
5
|
+
search;
|
|
6
|
+
options;
|
|
7
|
+
clip = -1;
|
|
8
|
+
time = 0;
|
|
9
|
+
sinceSearch = Infinity;
|
|
10
|
+
sinceSwitch = Infinity;
|
|
11
|
+
last = null;
|
|
12
|
+
completed = new Map();
|
|
13
|
+
indices;
|
|
14
|
+
constructor(database, options = {}) {
|
|
15
|
+
this.database = database;
|
|
16
|
+
this.search = new PoseSearch(database);
|
|
17
|
+
this.options = { searchInterval: 0.1, minimumHold: 0.12, improvement: 0.1, jumpPenalty: 0.5, timeSeparation: 0.2, reselectDelay: 0.6, ...options };
|
|
18
|
+
if (!Object.values(this.options).every(v => Number.isFinite(v) && v >= 0) || this.options.searchInterval === 0 || this.options.improvement >= 1)
|
|
19
|
+
throw new Error('Invalid matcher options');
|
|
20
|
+
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
|
+
if (this.indices.some(i => !i.length))
|
|
22
|
+
throw new Error('Every clip must have indexed samples');
|
|
23
|
+
}
|
|
24
|
+
reset() { this.clip = -1; this.time = 0; this.sinceSearch = this.sinceSwitch = Infinity; this.last = null; this.completed.clear(); }
|
|
25
|
+
update(dt, query) {
|
|
26
|
+
if (!Number.isFinite(dt) || dt < 0)
|
|
27
|
+
throw new Error('Invalid delta time');
|
|
28
|
+
let switched = false, ended = false;
|
|
29
|
+
for (const [clip, remaining] of this.completed) {
|
|
30
|
+
if (remaining <= dt)
|
|
31
|
+
this.completed.delete(clip);
|
|
32
|
+
else
|
|
33
|
+
this.completed.set(clip, remaining - dt);
|
|
34
|
+
}
|
|
35
|
+
if (this.clip >= 0) {
|
|
36
|
+
const clip = this.database.manifest.clips[this.clip];
|
|
37
|
+
this.time += dt;
|
|
38
|
+
ended = !clip.looping && this.time >= clip.duration;
|
|
39
|
+
this.time = clip.looping ? this.time % clip.duration : Math.min(this.time, clip.duration);
|
|
40
|
+
}
|
|
41
|
+
this.sinceSearch += dt;
|
|
42
|
+
this.sinceSwitch += dt;
|
|
43
|
+
const allowed = this.clip >= 0 && !query.excludedClips?.has(this.clip) && (!query.gaits || query.gaits.has(this.database.manifest.clips[this.clip].gait));
|
|
44
|
+
if (this.sinceSearch >= this.options.searchInterval || !allowed || ended) {
|
|
45
|
+
this.sinceSearch = 0;
|
|
46
|
+
// A finished one-shot must yield to another clip, rather than selecting
|
|
47
|
+
// its terminal pose and restarting forever. User exclusions still apply.
|
|
48
|
+
if (ended)
|
|
49
|
+
this.completed.set(this.clip, this.options.reselectDelay);
|
|
50
|
+
const excludedClips = new Set([...(query.excludedClips ?? []), ...this.completed.keys()]);
|
|
51
|
+
const best = this.search.search({ ...query, excludedClips });
|
|
52
|
+
this.last = best;
|
|
53
|
+
if (best) {
|
|
54
|
+
const current = this.clip >= 0 ? this.nearestFrame(this.clip, this.time) : -1;
|
|
55
|
+
const duration = this.database.manifest.clips[best.clip].duration;
|
|
56
|
+
let gap = Math.abs(best.time - this.time);
|
|
57
|
+
if (best.clip === this.clip && this.database.manifest.clips[best.clip].looping)
|
|
58
|
+
gap = Math.min(gap, duration - gap);
|
|
59
|
+
const distant = best.clip !== this.clip || gap > this.options.timeSeparation;
|
|
60
|
+
const better = current < 0 || best.cost + this.options.jumpPenalty < this.search.cost(current) * (1 - this.options.improvement);
|
|
61
|
+
if (current < 0 || !allowed || ended || (distant && better && this.sinceSwitch >= this.options.minimumHold)) {
|
|
62
|
+
this.clip = best.clip;
|
|
63
|
+
this.time = best.time;
|
|
64
|
+
this.sinceSwitch = 0;
|
|
65
|
+
switched = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else if (!allowed || ended) {
|
|
69
|
+
this.clip = -1;
|
|
70
|
+
this.time = 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { clip: this.clip, time: this.time, switched, match: this.last };
|
|
74
|
+
}
|
|
75
|
+
nearestFrame(clip, time) {
|
|
76
|
+
const indices = this.indices[clip];
|
|
77
|
+
if (!indices?.length)
|
|
78
|
+
return -1;
|
|
79
|
+
const { frames, clips } = this.database.manifest;
|
|
80
|
+
let best = indices[0], distance = Infinity;
|
|
81
|
+
for (const i of indices) {
|
|
82
|
+
let delta = Math.abs(frames[i].time - time);
|
|
83
|
+
if (clips[clip].looping)
|
|
84
|
+
delta = Math.min(delta, clips[clip].duration - delta);
|
|
85
|
+
if (delta < distance) {
|
|
86
|
+
best = i;
|
|
87
|
+
distance = delta;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return best;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Database, type DatabaseManifest } from './types.js';
|
|
2
|
+
export declare function validateDatabase(db: Database): void;
|
|
3
|
+
export declare function buildDatabase(manifest: Omit<DatabaseManifest, 'version' | 'dimensions' | 'coordinates' | 'horizons' | 'mean' | 'std'>, raw: Float32Array): Database;
|
|
4
|
+
/** Binary format is explicitly little endian float32; version/layout live in manifest. */
|
|
5
|
+
export declare function encodeFeatures(db: Database): ArrayBuffer;
|
|
6
|
+
export declare function decodeDatabase(manifest: DatabaseManifest, bytes: ArrayBuffer): Database;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { DIMENSIONS, HORIZONS } from './types.js';
|
|
2
|
+
export function validateDatabase(db) {
|
|
3
|
+
const m = db.manifest;
|
|
4
|
+
if (m.version !== 1 || m.dimensions !== DIMENSIONS || m.coordinates !== 'meters-y-up-z-forward'
|
|
5
|
+
|| m.horizons.length !== 3 || m.horizons.some((v, i) => v !== HORIZONS[i]))
|
|
6
|
+
throw new Error('Unsupported pose database schema');
|
|
7
|
+
if (!Number.isFinite(m.sampleRate) || m.sampleRate <= 0 || !m.frames.length || !m.clips.length
|
|
8
|
+
|| db.features.length !== m.frames.length * DIMENSIONS)
|
|
9
|
+
throw new Error('Invalid database size/sample rate');
|
|
10
|
+
if (m.mean.length !== DIMENSIONS || m.std.length !== DIMENSIONS || !m.mean.every(Number.isFinite)
|
|
11
|
+
|| !m.std.every(v => Number.isFinite(v) && v >= 1e-4) || !db.features.every(Number.isFinite))
|
|
12
|
+
throw new Error('Invalid normalization/features');
|
|
13
|
+
const ids = new Set();
|
|
14
|
+
for (const clip of m.clips) {
|
|
15
|
+
if (ids.has(clip.id) || !clip.id || !clip.file || !clip.animation || !Number.isFinite(clip.duration) || clip.duration <= 0
|
|
16
|
+
|| !['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(clip.gait))
|
|
17
|
+
throw new Error('Invalid clip');
|
|
18
|
+
ids.add(clip.id);
|
|
19
|
+
}
|
|
20
|
+
for (const frame of m.frames) {
|
|
21
|
+
if (!Number.isInteger(frame.clip) || !m.clips[frame.clip] || !Number.isFinite(frame.time)
|
|
22
|
+
|| frame.time < 0 || frame.time > m.clips[frame.clip].duration + 1e-6)
|
|
23
|
+
throw new Error('Invalid pose metadata');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function buildDatabase(manifest, raw) {
|
|
27
|
+
const count = manifest.frames.length;
|
|
28
|
+
if (!count || raw.length !== count * DIMENSIONS || !raw.every(Number.isFinite))
|
|
29
|
+
throw new Error('Invalid raw samples');
|
|
30
|
+
const mean = Array(DIMENSIONS).fill(0), std = Array(DIMENSIONS).fill(0);
|
|
31
|
+
for (let i = 0; i < raw.length; i++)
|
|
32
|
+
mean[i % DIMENSIONS] += raw[i] / count;
|
|
33
|
+
for (let i = 0; i < raw.length; i++)
|
|
34
|
+
std[i % DIMENSIONS] += (raw[i] - mean[i % DIMENSIONS]) ** 2 / count;
|
|
35
|
+
// Constant channels carry no measured variation. A unit scale avoids amplifying
|
|
36
|
+
// unsupported trajectories (e.g. turns absent from the dataset) by 10,000×.
|
|
37
|
+
for (let j = 0; j < DIMENSIONS; j++)
|
|
38
|
+
std[j] = Math.sqrt(std[j]) < 1e-4 ? 1 : Math.sqrt(std[j]);
|
|
39
|
+
const features = Float32Array.from(raw, (v, i) => (v - mean[i % DIMENSIONS]) / std[i % DIMENSIONS]);
|
|
40
|
+
const db = { manifest: { ...manifest, version: 1, dimensions: DIMENSIONS, coordinates: 'meters-y-up-z-forward', horizons: HORIZONS, mean, std }, features };
|
|
41
|
+
validateDatabase(db);
|
|
42
|
+
return db;
|
|
43
|
+
}
|
|
44
|
+
/** Binary format is explicitly little endian float32; version/layout live in manifest. */
|
|
45
|
+
export function encodeFeatures(db) {
|
|
46
|
+
const bytes = new ArrayBuffer(db.features.length * 4), view = new DataView(bytes);
|
|
47
|
+
db.features.forEach((v, i) => view.setFloat32(i * 4, v, true));
|
|
48
|
+
return bytes;
|
|
49
|
+
}
|
|
50
|
+
export function decodeDatabase(manifest, bytes) {
|
|
51
|
+
if (bytes.byteLength % 4)
|
|
52
|
+
throw new Error('Truncated feature binary');
|
|
53
|
+
const view = new DataView(bytes), features = new Float32Array(bytes.byteLength / 4);
|
|
54
|
+
for (let i = 0; i < features.length; i++)
|
|
55
|
+
features[i] = view.getFloat32(i * 4, true);
|
|
56
|
+
const db = { manifest, features };
|
|
57
|
+
validateDatabase(db);
|
|
58
|
+
return db;
|
|
59
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Pose, type TrajectoryPoint } from './types.js';
|
|
2
|
+
export declare function featureVector(pose: Pose, trajectory: readonly TrajectoryPoint[], out?: Float32Array<ArrayBuffer>): Float32Array;
|
|
3
|
+
/** World delta to current character-local XZ (yaw zero looks along +Z). */
|
|
4
|
+
export declare function toLocal(x: number, z: number, yaw: number): [number, number];
|
|
5
|
+
export interface TrajectoryInput {
|
|
6
|
+
velocity: readonly [number, number];
|
|
7
|
+
desiredVelocity: readonly [number, number];
|
|
8
|
+
yaw: number;
|
|
9
|
+
desiredYaw: number;
|
|
10
|
+
acceleration: number;
|
|
11
|
+
braking: number;
|
|
12
|
+
turnSpeed: number;
|
|
13
|
+
}
|
|
14
|
+
/** Prediction only: never writes to the gameplay controller. Uses fixed substeps. */
|
|
15
|
+
export declare function predictTrajectory(input: TrajectoryInput): TrajectoryPoint[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { DIMENSIONS, HORIZONS } from './types.js';
|
|
2
|
+
export function featureVector(pose, trajectory, out = new Float32Array(DIMENSIONS)) {
|
|
3
|
+
if (trajectory.length !== 3 || out.length !== DIMENSIONS)
|
|
4
|
+
throw new Error('Expected 3 trajectory points and 27 features');
|
|
5
|
+
out.set(pose.leftPosition, 0);
|
|
6
|
+
out.set(pose.rightPosition, 3);
|
|
7
|
+
out.set(pose.leftVelocity, 6);
|
|
8
|
+
out.set(pose.rightVelocity, 9);
|
|
9
|
+
out.set(pose.rootVelocity, 12);
|
|
10
|
+
trajectory.forEach((point, i) => { out.set(point.position, 15 + i * 2); out.set(point.facing, 21 + i * 2); });
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
/** World delta to current character-local XZ (yaw zero looks along +Z). */
|
|
14
|
+
export function toLocal(x, z, yaw) {
|
|
15
|
+
const c = Math.cos(yaw), s = Math.sin(yaw);
|
|
16
|
+
return [c * x - s * z, s * x + c * z];
|
|
17
|
+
}
|
|
18
|
+
/** Prediction only: never writes to the gameplay controller. Uses fixed substeps. */
|
|
19
|
+
export function predictTrajectory(input) {
|
|
20
|
+
if (![...input.velocity, ...input.desiredVelocity, input.yaw, input.desiredYaw, input.acceleration, input.braking, input.turnSpeed].every(Number.isFinite)
|
|
21
|
+
|| Math.min(input.acceleration, input.braking, input.turnSpeed) < 0)
|
|
22
|
+
throw new Error('Invalid trajectory input');
|
|
23
|
+
let [vx, vz] = input.velocity, x = 0, z = 0, yaw = input.yaw, elapsed = 0;
|
|
24
|
+
const points = [];
|
|
25
|
+
for (const horizon of HORIZONS) {
|
|
26
|
+
while (elapsed < horizon - 1e-9) {
|
|
27
|
+
const dt = Math.min(1 / 60, horizon - elapsed);
|
|
28
|
+
const dx = input.desiredVelocity[0] - vx, dz = input.desiredVelocity[1] - vz, length = Math.hypot(dx, dz);
|
|
29
|
+
const rate = Math.hypot(...input.desiredVelocity) < Math.hypot(vx, vz) ? input.braking : input.acceleration;
|
|
30
|
+
const amount = length > 0 ? Math.min(1, rate * dt / length) : 0;
|
|
31
|
+
const nx = vx + dx * amount, nz = vz + dz * amount;
|
|
32
|
+
x += (vx + nx) * 0.5 * dt;
|
|
33
|
+
z += (vz + nz) * 0.5 * dt;
|
|
34
|
+
vx = nx;
|
|
35
|
+
vz = nz;
|
|
36
|
+
const angle = Math.atan2(Math.sin(input.desiredYaw - yaw), Math.cos(input.desiredYaw - yaw));
|
|
37
|
+
yaw += Math.sign(angle) * Math.min(Math.abs(angle), input.turnSpeed * dt);
|
|
38
|
+
elapsed += dt;
|
|
39
|
+
}
|
|
40
|
+
points.push({ position: toLocal(x, z, input.yaw), facing: [Math.sin(yaw - input.yaw), Math.cos(yaw - input.yaw)] });
|
|
41
|
+
}
|
|
42
|
+
return points;
|
|
43
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Gait, Vec3 } from './types.js';
|
|
2
|
+
export interface CharacterProfile {
|
|
3
|
+
version: 1;
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
assetBase: string;
|
|
7
|
+
model: string;
|
|
8
|
+
database: string;
|
|
9
|
+
scale: number;
|
|
10
|
+
sampleRate: number;
|
|
11
|
+
rig: {
|
|
12
|
+
root: string;
|
|
13
|
+
leftFoot: string;
|
|
14
|
+
rightFoot: string;
|
|
15
|
+
};
|
|
16
|
+
/** Source animation node -> model node; name mapping does not perform retargeting. */
|
|
17
|
+
boneMap?: Record<string, string>;
|
|
18
|
+
/** Model node whose position/heading tracks are excluded by the player. */
|
|
19
|
+
stripRoot?: string;
|
|
20
|
+
clips: {
|
|
21
|
+
id: string;
|
|
22
|
+
file: string;
|
|
23
|
+
animation: string;
|
|
24
|
+
gait: Gait;
|
|
25
|
+
looping: boolean;
|
|
26
|
+
nominalVelocity?: Vec3;
|
|
27
|
+
motionFile?: string;
|
|
28
|
+
}[];
|
|
29
|
+
}
|
|
30
|
+
/** Read untrusted JSON as unknown; report a configuration error before loading assets. */
|
|
31
|
+
export declare function parseProfile(input: unknown): CharacterProfile;
|
|
32
|
+
/** Deterministic configuration fingerprint; detects stale banks, not a security signature. */
|
|
33
|
+
export declare function profileSignature(profile: CharacterProfile): string;
|
|
34
|
+
/** Suggestions only: ambiguity is returned to the caller, never silently resolved. */
|
|
35
|
+
export declare function suggestRig(names: readonly string[]): Record<'root' | 'leftFoot' | 'rightFoot', string[]>;
|
|
36
|
+
/** Semantic availability, independent of character/clip names. Preserve optional actions. */
|
|
37
|
+
export declare function filterLocomotionGaits(available: ReadonlySet<Gait>, speed: number): Set<Gait>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
2
|
+
const name = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
3
|
+
const asset = (v) => name(v) && !v.includes('\\') && !v.includes('..') && !/[?#:]/.test(v);
|
|
4
|
+
/** Read untrusted JSON as unknown; report a configuration error before loading assets. */
|
|
5
|
+
export function parseProfile(input) {
|
|
6
|
+
if (!record(input) || input.version !== 1 || !name(input.id) || !/^[a-z0-9][a-z0-9-]*$/.test(input.id) || !name(input.label))
|
|
7
|
+
throw new Error('Profile: invalid version/id/label');
|
|
8
|
+
if (!asset(input.assetBase) || !input.assetBase.startsWith('/') || !input.assetBase.endsWith('/') || !asset(input.database) || !input.database.startsWith('/') || !input.database.endsWith('/')
|
|
9
|
+
|| !asset(input.model) || input.model.startsWith('/'))
|
|
10
|
+
throw new Error('Profile: use public URL folders and relative model files');
|
|
11
|
+
if (typeof input.scale !== 'number' || !Number.isFinite(input.scale) || input.scale <= 0 || typeof input.sampleRate !== 'number' || !Number.isFinite(input.sampleRate) || input.sampleRate < 1 || input.sampleRate > 120)
|
|
12
|
+
throw new Error('Profile: invalid scale/sampleRate');
|
|
13
|
+
if (!record(input.rig) || !name(input.rig.root) || !name(input.rig.leftFoot) || !name(input.rig.rightFoot))
|
|
14
|
+
throw new Error('Profile: root and both feet must be mapped');
|
|
15
|
+
const rig = input.rig;
|
|
16
|
+
if (new Set([rig.root, rig.leftFoot, rig.rightFoot]).size !== 3)
|
|
17
|
+
throw new Error('Profile: required bones must be distinct');
|
|
18
|
+
if (input.stripRoot !== undefined && !name(input.stripRoot))
|
|
19
|
+
throw new Error('Profile: invalid stripRoot');
|
|
20
|
+
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))
|
|
21
|
+
throw new Error('Profile: boneMap must map distinct nonempty names');
|
|
22
|
+
if (!Array.isArray(input.clips) || !input.clips.length)
|
|
23
|
+
throw new Error('Profile: no clips');
|
|
24
|
+
const ids = new Set();
|
|
25
|
+
for (const clip of input.clips) {
|
|
26
|
+
if (!record(clip) || !name(clip.id) || ids.has(clip.id) || !asset(clip.file) || clip.file.startsWith('/') || !name(clip.animation) || typeof clip.looping !== 'boolean'
|
|
27
|
+
|| !['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(String(clip.gait)))
|
|
28
|
+
throw new Error('Profile: invalid/duplicate clip');
|
|
29
|
+
ids.add(clip.id);
|
|
30
|
+
const nominal = clip.nominalVelocity !== undefined, measured = clip.motionFile !== undefined;
|
|
31
|
+
if (nominal === measured)
|
|
32
|
+
throw new Error(`Profile ${clip.id}: declare exactly one of nominalVelocity or motionFile`);
|
|
33
|
+
if (nominal && (!Array.isArray(clip.nominalVelocity) || clip.nominalVelocity.length !== 3 || !clip.nominalVelocity.every(Number.isFinite)))
|
|
34
|
+
throw new Error(`Profile ${clip.id}: invalid velocity`);
|
|
35
|
+
if (measured && (!asset(clip.motionFile) || clip.motionFile.startsWith('/') || clip.looping))
|
|
36
|
+
throw new Error(`Profile ${clip.id}: measured motion requires a relative file and non-looping clip`);
|
|
37
|
+
}
|
|
38
|
+
return structuredClone(input);
|
|
39
|
+
}
|
|
40
|
+
/** Deterministic configuration fingerprint; detects stale banks, not a security signature. */
|
|
41
|
+
export function profileSignature(profile) {
|
|
42
|
+
const sorted = (value) => Array.isArray(value) ? value.map(sorted) : record(value) ? Object.fromEntries(Object.keys(value).sort().map(k => [k, sorted(value[k])])) : value;
|
|
43
|
+
const text = JSON.stringify(sorted(profile));
|
|
44
|
+
let hash = 2166136261;
|
|
45
|
+
for (let i = 0; i < text.length; i++)
|
|
46
|
+
hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
|
|
47
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
48
|
+
}
|
|
49
|
+
/** Suggestions only: ambiguity is returned to the caller, never silently resolved. */
|
|
50
|
+
export function suggestRig(names) {
|
|
51
|
+
const aliases = { root: ['root', 'hips', 'pelvis'], leftFoot: ['footl', 'leftfoot', 'lfoot'], rightFoot: ['footr', 'rightfoot', 'rfoot'] };
|
|
52
|
+
return Object.fromEntries(Object.entries(aliases).map(([role, values]) => [role, names.filter(n => values.includes(n.split(':').at(-1).toLowerCase().replace(/[^a-z]/g, '')))]));
|
|
53
|
+
}
|
|
54
|
+
/** Semantic availability, independent of character/clip names. Preserve optional actions. */
|
|
55
|
+
export function filterLocomotionGaits(available, speed) {
|
|
56
|
+
if (!Number.isFinite(speed) || speed < 0)
|
|
57
|
+
throw new Error('Invalid locomotion speed');
|
|
58
|
+
const result = new Set(available), movement = ['walk', 'run', 'strafe'];
|
|
59
|
+
if (speed > 0.2 && movement.some(g => result.has(g)))
|
|
60
|
+
result.delete('idle');
|
|
61
|
+
else if (speed < 0.05 && result.has('idle'))
|
|
62
|
+
movement.forEach(g => result.delete(g));
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type TrajectoryPoint } from './types.js';
|
|
2
|
+
export interface RootMotionSample {
|
|
3
|
+
time: number;
|
|
4
|
+
x: number;
|
|
5
|
+
z: number;
|
|
6
|
+
yaw: number;
|
|
7
|
+
}
|
|
8
|
+
/** Unwrapped yaw in radians. Root track is a bake input, never a movement command. */
|
|
9
|
+
export interface RootMotionTrack {
|
|
10
|
+
version: 1;
|
|
11
|
+
duration: number;
|
|
12
|
+
source: 'hips';
|
|
13
|
+
samples: RootMotionSample[];
|
|
14
|
+
}
|
|
15
|
+
export declare function validateRootMotion(track: RootMotionTrack): void;
|
|
16
|
+
/** Clamped sampling for non-looping clips; completed motion stays at its endpoint. */
|
|
17
|
+
export declare function sampleRootMotion(track: RootMotionTrack, time: number): RootMotionSample;
|
|
18
|
+
export declare function rootMotionFeatures(track: RootMotionTrack, time: number, derivativeStep?: number): {
|
|
19
|
+
velocity: readonly [number, number, number];
|
|
20
|
+
angularVelocity: number;
|
|
21
|
+
trajectory: TrajectoryPoint[];
|
|
22
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { HORIZONS } from './types.js';
|
|
2
|
+
import { toLocal } from './features.js';
|
|
3
|
+
export function validateRootMotion(track) {
|
|
4
|
+
if (track.version !== 1 || track.source !== 'hips' || !Number.isFinite(track.duration) || track.duration <= 0 || track.samples.length < 2)
|
|
5
|
+
throw new Error('Invalid root motion track');
|
|
6
|
+
let previous = -Infinity;
|
|
7
|
+
for (const sample of track.samples) {
|
|
8
|
+
if (!Object.values(sample).every(Number.isFinite) || sample.time <= previous || sample.time < 0 || sample.time > track.duration + 1e-6)
|
|
9
|
+
throw new Error('Invalid root motion sample');
|
|
10
|
+
previous = sample.time;
|
|
11
|
+
}
|
|
12
|
+
if (track.samples[0].time !== 0 || Math.abs(track.samples.at(-1).time - track.duration) > 1e-6)
|
|
13
|
+
throw new Error('Incomplete root motion track');
|
|
14
|
+
}
|
|
15
|
+
/** Clamped sampling for non-looping clips; completed motion stays at its endpoint. */
|
|
16
|
+
export function sampleRootMotion(track, time) {
|
|
17
|
+
if (!Number.isFinite(time))
|
|
18
|
+
throw new Error('Invalid root motion time');
|
|
19
|
+
const t = Math.max(0, Math.min(time, track.duration)), samples = track.samples;
|
|
20
|
+
let lo = 0, hi = samples.length - 1;
|
|
21
|
+
while (hi - lo > 1) {
|
|
22
|
+
const mid = (lo + hi) >>> 1;
|
|
23
|
+
if (samples[mid].time <= t)
|
|
24
|
+
lo = mid;
|
|
25
|
+
else
|
|
26
|
+
hi = mid;
|
|
27
|
+
}
|
|
28
|
+
const a = samples[lo], b = samples[hi], alpha = (t - a.time) / (b.time - a.time);
|
|
29
|
+
return { time: t, x: a.x + (b.x - a.x) * alpha, z: a.z + (b.z - a.z) * alpha, yaw: a.yaw + (b.yaw - a.yaw) * alpha };
|
|
30
|
+
}
|
|
31
|
+
export function rootMotionFeatures(track, time, derivativeStep = 1 / 30) {
|
|
32
|
+
if (!Number.isFinite(derivativeStep) || derivativeStep <= 0)
|
|
33
|
+
throw new Error('Invalid derivative step');
|
|
34
|
+
const now = sampleRootMotion(track, time), a = sampleRootMotion(track, time - derivativeStep), b = sampleRootMotion(track, time + derivativeStep);
|
|
35
|
+
const elapsed = b.time - a.time;
|
|
36
|
+
const v = elapsed > 0 ? toLocal((b.x - a.x) / elapsed, (b.z - a.z) / elapsed, now.yaw) : [0, 0];
|
|
37
|
+
return { velocity: [v[0], 0, v[1]], angularVelocity: elapsed > 0 ? (b.yaw - a.yaw) / elapsed : 0,
|
|
38
|
+
trajectory: HORIZONS.map(horizon => {
|
|
39
|
+
const future = sampleRootMotion(track, time + horizon), yaw = future.yaw - now.yaw;
|
|
40
|
+
return { position: toLocal(future.x - now.x, future.z - now.z, now.yaw), facing: [Math.sin(yaw), Math.cos(yaw)] };
|
|
41
|
+
}) };
|
|
42
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Database, type Match, type Query } from './types.js';
|
|
2
|
+
export declare function defaultWeights(): Float32Array;
|
|
3
|
+
export declare class PoseSearch {
|
|
4
|
+
readonly database: Database;
|
|
5
|
+
private normalized;
|
|
6
|
+
readonly weights: Float32Array;
|
|
7
|
+
constructor(database: Database, weights?: Float32Array<ArrayBufferLike>);
|
|
8
|
+
normalize(query: Query): void;
|
|
9
|
+
cost(index: number): number;
|
|
10
|
+
search(query: Query): Match | null;
|
|
11
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { validateDatabase } from './database.js';
|
|
2
|
+
import { DIMENSIONS } from './types.js';
|
|
3
|
+
export function defaultWeights() {
|
|
4
|
+
const weights = new Float32Array(DIMENSIONS).fill(1);
|
|
5
|
+
weights.fill(3, 15);
|
|
6
|
+
return weights;
|
|
7
|
+
}
|
|
8
|
+
export class PoseSearch {
|
|
9
|
+
database;
|
|
10
|
+
normalized = new Float32Array(DIMENSIONS);
|
|
11
|
+
weights;
|
|
12
|
+
constructor(database, weights = defaultWeights()) {
|
|
13
|
+
this.database = database;
|
|
14
|
+
validateDatabase(database);
|
|
15
|
+
if (weights.length !== DIMENSIONS || !weights.every(v => Number.isFinite(v) && v >= 0))
|
|
16
|
+
throw new Error('Invalid feature weights');
|
|
17
|
+
this.weights = weights.slice();
|
|
18
|
+
}
|
|
19
|
+
normalize(query) {
|
|
20
|
+
if (query.features.length !== DIMENSIONS || !query.features.every(Number.isFinite))
|
|
21
|
+
throw new Error('Invalid pose query');
|
|
22
|
+
const { mean, std } = this.database.manifest;
|
|
23
|
+
for (let j = 0; j < DIMENSIONS; j++)
|
|
24
|
+
this.normalized[j] = (query.features[j] - mean[j]) / std[j];
|
|
25
|
+
}
|
|
26
|
+
cost(index) {
|
|
27
|
+
const { features } = this.database;
|
|
28
|
+
let sum = 0;
|
|
29
|
+
for (let j = 0; j < DIMENSIONS; j++)
|
|
30
|
+
sum += this.weights[j] * (this.normalized[j] - features[index * DIMENSIONS + j]) ** 2;
|
|
31
|
+
return sum;
|
|
32
|
+
}
|
|
33
|
+
search(query) {
|
|
34
|
+
this.normalize(query);
|
|
35
|
+
const { frames, clips } = this.database.manifest;
|
|
36
|
+
let index = -1, cost = Infinity;
|
|
37
|
+
for (let i = 0; i < frames.length; i++) {
|
|
38
|
+
if (query.excludedClips?.has(frames[i].clip))
|
|
39
|
+
continue;
|
|
40
|
+
if (query.gaits && !query.gaits.has(clips[frames[i].clip].gait))
|
|
41
|
+
continue;
|
|
42
|
+
const score = this.cost(i);
|
|
43
|
+
if (score < cost) {
|
|
44
|
+
index = i;
|
|
45
|
+
cost = score;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return index < 0 ? null : { ...frames[index], index, cost };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export declare const DIMENSIONS = 27;
|
|
2
|
+
export declare const HORIZONS: readonly [0.2, 0.4, 0.6];
|
|
3
|
+
export type Vec3 = readonly [number, number, number];
|
|
4
|
+
export type Gait = 'idle' | 'walk' | 'run' | 'strafe' | 'turn' | 'stop';
|
|
5
|
+
export interface Clip {
|
|
6
|
+
id: string;
|
|
7
|
+
file: string;
|
|
8
|
+
animation: string;
|
|
9
|
+
duration: number;
|
|
10
|
+
looping: boolean;
|
|
11
|
+
gait: Gait;
|
|
12
|
+
/** Explicit authoring velocity for in-place clips, never inferred root motion. */
|
|
13
|
+
nominalVelocity?: Vec3;
|
|
14
|
+
/** Hips is a measured pelvis proxy, not a dedicated animation root. */
|
|
15
|
+
motionSource?: 'nominal' | 'hips';
|
|
16
|
+
}
|
|
17
|
+
export interface Frame {
|
|
18
|
+
clip: number;
|
|
19
|
+
time: number;
|
|
20
|
+
}
|
|
21
|
+
export interface DatabaseManifest {
|
|
22
|
+
profileId?: string;
|
|
23
|
+
profileSignature?: string;
|
|
24
|
+
version: 1;
|
|
25
|
+
dimensions: 27;
|
|
26
|
+
sampleRate: number;
|
|
27
|
+
coordinates: 'meters-y-up-z-forward';
|
|
28
|
+
horizons: readonly number[];
|
|
29
|
+
clips: Clip[];
|
|
30
|
+
frames: Frame[];
|
|
31
|
+
mean: number[];
|
|
32
|
+
std: number[];
|
|
33
|
+
}
|
|
34
|
+
export interface Database {
|
|
35
|
+
manifest: DatabaseManifest;
|
|
36
|
+
features: Float32Array;
|
|
37
|
+
}
|
|
38
|
+
export interface Match extends Frame {
|
|
39
|
+
index: number;
|
|
40
|
+
cost: number;
|
|
41
|
+
}
|
|
42
|
+
export interface Query {
|
|
43
|
+
features: Float32Array;
|
|
44
|
+
gaits?: ReadonlySet<Gait>;
|
|
45
|
+
excludedClips?: ReadonlySet<number>;
|
|
46
|
+
}
|
|
47
|
+
export interface Pose {
|
|
48
|
+
leftPosition: Vec3;
|
|
49
|
+
rightPosition: Vec3;
|
|
50
|
+
leftVelocity: Vec3;
|
|
51
|
+
rightVelocity: Vec3;
|
|
52
|
+
rootVelocity: Vec3;
|
|
53
|
+
}
|
|
54
|
+
export interface TrajectoryPoint {
|
|
55
|
+
position: readonly [number, number];
|
|
56
|
+
facing: readonly [number, number];
|
|
57
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pose_flow/babylon",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./babylon": {
|
|
15
|
+
"types": "./dist/babylon.d.ts",
|
|
16
|
+
"import": "./dist/babylon.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"dev": "vite --host 127.0.0.1",
|
|
21
|
+
"build": "tsc -p tsconfig.build.json && vite build",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"test": "tsx --test tests/*.test.ts",
|
|
24
|
+
"bake": "tsx tools/bake.ts",
|
|
25
|
+
"validate:assets": "tsx tools/validate-assets.ts",
|
|
26
|
+
"benchmark": "tsx tools/benchmark.ts"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@babylonjs/core": "^7.54.3"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@babylonjs/core": "7.54.3",
|
|
33
|
+
"@babylonjs/loaders": "7.54.3",
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"tsx": "^4.19.0",
|
|
36
|
+
"typescript": "^5.7.0",
|
|
37
|
+
"vite": "^6.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|