Skip to content

Commit ccef434

Browse files
committed
render: add glTF/GLB animation export (#90)
Adds exportGLTF() (and buildAnimatedRig()) to posecode-render: bakes a movement into a glTF/GLB asset — the procedural mannequin rig plus one AnimationClip driving joint rotations and root travel/turn — via Three.js GLTFExporter. - Samples the authored timeline at a configurable fps; joint nodes are named by Posecode bone id, the animated root group is 'posecode_root'. - Full looped runtime baked as keyframes so duration/loop count survive. - Returns a GLB ArrayBuffer by default, or a glTF JSON object (binary:false). - Playground gains a 'Download glTF' button; render chunk stays lazy-loaded. - Documented limitations: procedural rig (no retargeting onto external/humanoid skeletons yet) and authored motion (not the contact/IK-solved motion). Tested headlessly: track construction, and a GLB round-trip through GLTFLoader that rebinds the clip to an AnimationMixer and advances it. Signed-off-by: Claude <noreply@anthropic.com>
1 parent e354036 commit ccef434

7 files changed

Lines changed: 338 additions & 4 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,36 @@ calibrated rig.
551551
(e.g. `reach: hand_left floor`) export the authored pose rather than the
552552
solved one. See [issue #63](https://github.com/posecode-dev/posecode/issues/63).
553553

554+
### Exporting motion (glTF / GLB)
555+
556+
For web animation pipelines, `posecode-render` can export the rig **and** a
557+
baked animation clip as a glTF/GLB asset. In the playground, use **Download
558+
glTF**; programmatically:
559+
560+
```ts
561+
import { parse } from "posecode-parser";
562+
import { exportGLTF } from "posecode-render";
563+
564+
const { ir } = parse(source);
565+
const glb = await exportGLTF(ir!); // GLB ArrayBuffer (default)
566+
const gltf = await exportGLTF(ir!, { binary: false }); // glTF JSON object
567+
```
568+
569+
The result loads with Three.js [`GLTFLoader`](https://threejs.org/docs/#GLTFLoader.load),
570+
and the clip plays on the included rig:
571+
572+
```ts
573+
const gltf = await new GLTFLoader().loadAsync(url);
574+
const mixer = new THREE.AnimationMixer(gltf.scene);
575+
mixer.clipAction(gltf.animations[0]).play();
576+
```
577+
578+
- Joint nodes are named by Posecode bone id; the animated root is `posecode_root`.
579+
- **Limitations:** exports the procedural mannequin rig, not a humanoid/Mixamo
580+
skeleton, so there is **no retargeting** onto external rigs yet, and (as with
581+
BVH) it bakes the authored motion, not the contact/IK-solved motion. See
582+
[issue #90](https://github.com/posecode-dev/posecode/issues/90).
583+
554584
---
555585

556586
## How Posecode Stays Honest

ROADMAP.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,11 @@ Each prop is a small scene object + an anchor type; movements then reference it
122122
- Self-collision is a bounded corrective pass over selected body pairs, not a
123123
comprehensive physics system. It exposes residuals for those sampled pairs,
124124
but does not detect every possible body-body collision.
125-
- BVH motion export bakes the **authored** joint motion and root choreography
126-
(travel/turn); it does not yet re-run the renderer's contact/IK solve, so
127-
IK-dependent movements export their authored pose rather than the solved one.
128-
There is no glTF/GLB export yet.
125+
- BVH and glTF/GLB motion export bake the **authored** joint motion and root
126+
choreography (travel/turn); they do not yet re-run the renderer's contact/IK
127+
solve, so IK-dependent movements export their authored pose rather than the
128+
solved one. glTF export uses the procedural mannequin rig with no retargeting
129+
onto external/humanoid skeletons yet.
129130
- A **starter** prop set (chair / wall / bar / box / dip bars): no bench,
130131
rings, bands, or loaded implements yet, and props sit at fixed default
131132
placements.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* glTF / GLB animation export.
3+
*
4+
* Bakes a Posecode movement into a standard glTF asset — the rig plus a baked
5+
* animation clip — so it can drop into an existing web animation pipeline and
6+
* load with Three.js' `GLTFLoader`.
7+
*
8+
* ## What is exported
9+
*
10+
* The procedural mannequin rig (bone hierarchy with limb meshes parented to
11+
* each joint) and one `AnimationClip` that drives the joint rotations and the
12+
* root travel/turn. Like the BVH path, this bakes the **authored** joint motion
13+
* plus root choreography; it does not re-run the renderer's contact/IK solve,
14+
* so IK-dependent movements export their authored pose. Exporting the fully
15+
* solved motion, and retargeting onto external/humanoid skeletons, are
16+
* documented future work (see issue #90).
17+
*
18+
* ## Conventions
19+
*
20+
* - Right-handed, **Y-up**, figure faces **+Z** at rest (Three.js default).
21+
* - Units are metres.
22+
* - Joint nodes are named by their Posecode bone id (`elbow_left`, `hip_right`,
23+
* …); the animated root group is `posecode_root`.
24+
* - The full looped runtime (cycle incl. loop-reset wrap × repeats) is baked as
25+
* keyframes, so duration and loop count survive without a runtime loop flag.
26+
*/
27+
28+
import * as THREE from "three";
29+
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
30+
import type { PosecodeIR } from "posecode-parser";
31+
import { buildMannequin, type Proportions } from "./mannequin.js";
32+
import { buildTimeline } from "./timeline.js";
33+
34+
const DEFAULT_FPS = 30;
35+
const ROOT_NODE_NAME = "posecode_root";
36+
37+
export interface GltfExportOptions {
38+
/** Keyframe sample rate. Defaults to 30 fps. */
39+
fps?: number;
40+
/** true → GLB binary ArrayBuffer (default); false → glTF JSON object. */
41+
binary?: boolean;
42+
/** Rig proportions, if exporting for a calibrated character. */
43+
proportions?: Proportions;
44+
}
45+
46+
const _up = new THREE.Vector3(0, 1, 0);
47+
const _yaw = new THREE.Quaternion();
48+
49+
/**
50+
* Build the mannequin rig and a baked `AnimationClip` for a movement, without
51+
* touching the DOM. Exposed for tests and advanced callers; most consumers want
52+
* {@link exportGLTF}.
53+
*/
54+
export function buildAnimatedRig(
55+
ir: PosecodeIR,
56+
options: GltfExportOptions = {},
57+
): { root: THREE.Group; clip: THREE.AnimationClip } {
58+
const fps = options.fps && options.fps > 0 ? options.fps : DEFAULT_FPS;
59+
const mannequin = buildMannequin(undefined, options.proportions);
60+
const timeline = buildTimeline(ir);
61+
62+
// Name every joint node by its bone id so animation tracks bind by name and
63+
// the exported glTF uses a stable, documented joint-naming convention.
64+
mannequin.root.name = ROOT_NODE_NAME;
65+
const animatedBones: string[] = [];
66+
for (const [id, node] of mannequin.bones) {
67+
node.name = id;
68+
animatedBones.push(id);
69+
}
70+
71+
const cycle = Math.max(timeline.duration, 1e-6);
72+
const total = cycle * Math.max(1, timeline.repeat);
73+
const dt = 1 / fps;
74+
const frameCount = Math.max(1, Math.round(total * fps)) + 1;
75+
76+
const times = new Float32Array(frameCount);
77+
const rootPos = new Float32Array(frameCount * 3);
78+
const rootQuat = new Float32Array(frameCount * 4);
79+
const boneQuat = new Map<string, Float32Array>();
80+
for (const id of animatedBones) boneQuat.set(id, new Float32Array(frameCount * 4));
81+
82+
for (let f = 0; f < frameCount; f++) {
83+
const t = Math.min(f * dt, total);
84+
times[f] = t;
85+
const info = timeline.sample(t, mannequin.bones);
86+
87+
// Root group carries the world travel (x,z) and the body yaw.
88+
rootPos[f * 3] = info.rootOffset.x;
89+
rootPos[f * 3 + 1] = 0;
90+
rootPos[f * 3 + 2] = info.rootOffset.z;
91+
_yaw.setFromAxisAngle(_up, info.rootYaw);
92+
rootQuat[f * 4] = _yaw.x;
93+
rootQuat[f * 4 + 1] = _yaw.y;
94+
rootQuat[f * 4 + 2] = _yaw.z;
95+
rootQuat[f * 4 + 3] = _yaw.w;
96+
97+
for (const id of animatedBones) {
98+
const q = mannequin.bones.get(id)!.quaternion;
99+
const buf = boneQuat.get(id)!;
100+
buf[f * 4] = q.x;
101+
buf[f * 4 + 1] = q.y;
102+
buf[f * 4 + 2] = q.z;
103+
buf[f * 4 + 3] = q.w;
104+
}
105+
}
106+
107+
const tracks: THREE.KeyframeTrack[] = [
108+
new THREE.VectorKeyframeTrack(`${ROOT_NODE_NAME}.position`, Array.from(times), Array.from(rootPos)),
109+
new THREE.QuaternionKeyframeTrack(`${ROOT_NODE_NAME}.quaternion`, Array.from(times), Array.from(rootQuat)),
110+
];
111+
for (const id of animatedBones) {
112+
tracks.push(
113+
new THREE.QuaternionKeyframeTrack(
114+
`${id}.quaternion`,
115+
Array.from(times),
116+
Array.from(boneQuat.get(id)!),
117+
),
118+
);
119+
}
120+
121+
const clip = new THREE.AnimationClip(ir.name || "posecode", total, tracks);
122+
// Reset the rig to its rest pose so the exported node transforms are neutral;
123+
// the clip supplies all motion.
124+
timeline.sample(0, mannequin.bones);
125+
return { root: mannequin.root, clip };
126+
}
127+
128+
/**
129+
* Export a parsed Posecode movement as a glTF/GLB asset.
130+
*
131+
* Returns a GLB `ArrayBuffer` (default) or a glTF JSON object when
132+
* `binary: false`. The result loads with Three.js `GLTFLoader`, and the baked
133+
* clip plays on the included rig.
134+
*/
135+
export async function exportGLTF(
136+
ir: PosecodeIR,
137+
options: GltfExportOptions = {},
138+
): Promise<ArrayBuffer | Record<string, unknown>> {
139+
const { root, clip } = buildAnimatedRig(ir, options);
140+
const exporter = new GLTFExporter();
141+
const binary = options.binary ?? true;
142+
return await new Promise((resolve, reject) => {
143+
exporter.parse(
144+
root,
145+
(result) => resolve(result as ArrayBuffer | Record<string, unknown>),
146+
(error) => reject(error),
147+
{ binary, animations: [clip] },
148+
);
149+
});
150+
}

packages/posecode-render/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1547,3 +1547,4 @@ export {
15471547
} from "./contacts.js";
15481548
export type { PhaseSegment } from "./timeline.js";
15491549
export { exportBVH, type BvhExportOptions } from "./bvh.js";
1550+
export { exportGLTF, buildAnimatedRig, type GltfExportOptions } from "./gltf.js";
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, it, beforeAll } from "vitest";
2+
import * as THREE from "three";
3+
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
4+
import { parse } from "posecode-parser";
5+
import { exportGLTF, buildAnimatedRig } from "../src/gltf.js";
6+
7+
// GLTFExporter serializes buffers via FileReader, which browsers provide but
8+
// Node does not. Polyfill it faithfully over Node's global Blob so the headless
9+
// round-trip mirrors the browser export the playground actually runs.
10+
beforeAll(() => {
11+
if (typeof (globalThis as { FileReader?: unknown }).FileReader !== "undefined") return;
12+
class NodeFileReader {
13+
result: ArrayBuffer | string | null = null;
14+
onloadend: (() => void) | null = null;
15+
onerror: ((err: unknown) => void) | null = null;
16+
readAsArrayBuffer(blob: Blob): void {
17+
blob.arrayBuffer().then(
18+
(buf) => {
19+
this.result = buf;
20+
this.onloadend?.();
21+
},
22+
(err) => this.onerror?.(err),
23+
);
24+
}
25+
readAsDataURL(blob: Blob): void {
26+
blob.arrayBuffer().then(
27+
(buf) => {
28+
const b64 = Buffer.from(buf).toString("base64");
29+
this.result = `data:${blob.type || "application/octet-stream"};base64,${b64}`;
30+
this.onloadend?.();
31+
},
32+
(err) => this.onerror?.(err),
33+
);
34+
}
35+
}
36+
(globalThis as { FileReader?: unknown }).FileReader = NodeFileReader;
37+
});
38+
39+
const BICEPS = `posecode exercise "Biceps curl"
40+
rig humanoid
41+
pose start = standing
42+
43+
step "Curl" 1.1s settle:
44+
elbows: flex 135
45+
step "Lower" 1.4s settle:
46+
elbows: flex 15
47+
repeat 2
48+
`;
49+
50+
function loadGlb(buffer: ArrayBuffer): Promise<THREE.Object3D & { animations: THREE.AnimationClip[] }> {
51+
return new Promise((resolve, reject) => {
52+
new GLTFLoader().parse(
53+
buffer,
54+
"",
55+
(gltf) => resolve(Object.assign(gltf.scene, { animations: gltf.animations })),
56+
reject,
57+
);
58+
});
59+
}
60+
61+
describe("exportGLTF", () => {
62+
it("builds a rig plus a baked clip with the expected tracks", () => {
63+
const { ir } = parse(BICEPS);
64+
expect(ir).toBeTruthy();
65+
const { root, clip } = buildAnimatedRig(ir!, { fps: 30 });
66+
67+
expect(root.name).toBe("posecode_root");
68+
// Root gets a position + quaternion track; each joint gets a quaternion track.
69+
expect(clip.tracks.some((t) => t.name === "posecode_root.position")).toBe(true);
70+
expect(clip.tracks.some((t) => t.name === "elbow_left.quaternion")).toBe(true);
71+
expect(clip.duration).toBeGreaterThan(0);
72+
// The elbow actually moves: some frame differs from the first keyframe.
73+
const elbow = clip.tracks.find((t) => t.name === "elbow_left.quaternion")!;
74+
const vals = elbow.values;
75+
const frames = vals.length / 4;
76+
let moved = false;
77+
for (let i = 1; i < frames && !moved; i++) {
78+
for (let k = 0; k < 4; k++) {
79+
if (Math.abs(vals[i * 4 + k]! - vals[k]!) > 1e-3) moved = true;
80+
}
81+
}
82+
expect(moved).toBe(true);
83+
});
84+
85+
it("exports a GLB that reloads through GLTFLoader with its animation", async () => {
86+
const { ir } = parse(BICEPS);
87+
const glb = await exportGLTF(ir!, { fps: 24, binary: true });
88+
expect(glb).toBeInstanceOf(ArrayBuffer);
89+
90+
const scene = await loadGlb(glb as ArrayBuffer);
91+
// The rig survived the round-trip.
92+
expect(scene.getObjectByName("elbow_left")).toBeTruthy();
93+
// Exactly one baked animation clip, and it drives the elbow joint.
94+
expect(scene.animations).toHaveLength(1);
95+
const clip = scene.animations[0]!;
96+
expect(clip.duration).toBeGreaterThan(0);
97+
expect(clip.tracks.some((t) => /elbow_left\.quaternion$/.test(t.name))).toBe(true);
98+
99+
// The clip can be bound to a mixer and advanced without error (plays).
100+
const mixer = new THREE.AnimationMixer(scene);
101+
const action = mixer.clipAction(clip);
102+
action.play();
103+
expect(() => mixer.update(0.5)).not.toThrow();
104+
});
105+
106+
it("can emit a glTF JSON object instead of GLB", async () => {
107+
const { ir } = parse(BICEPS);
108+
const gltf = (await exportGLTF(ir!, { binary: false })) as Record<string, unknown>;
109+
expect(gltf).toMatchObject({ asset: expect.anything() });
110+
expect(Array.isArray(gltf.animations)).toBe(true);
111+
expect((gltf.animations as unknown[]).length).toBe(1);
112+
});
113+
});

playground/play.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,14 @@
127127
>
128128
<span class="lbl" aria-live="polite">Download BVH</span>
129129
</button>
130+
<button
131+
id="download-gltf"
132+
class="btn ghost"
133+
aria-label="Download glTF"
134+
title="Download the movement as a .glb glTF asset (rig + animation) for Three.js and web pipelines"
135+
>
136+
<span class="lbl" aria-live="polite">Download glTF</span>
137+
</button>
130138
<button
131139
id="copy-prompt"
132140
class="btn primary"

playground/src/main.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const floorGuideReset = $<HTMLSpanElement>("floor-guide-reset");
5454
const copyBtn = $<HTMLButtonElement>("copy-prompt");
5555
const shareBtn = $<HTMLButtonElement>("share");
5656
const downloadBvhBtn = $<HTMLButtonElement>("download-bvh");
57+
const downloadGltfBtn = $<HTMLButtonElement>("download-gltf");
5758
const tabEditor = $<HTMLButtonElement>("tab-editor");
5859
const tabViewer = $<HTMLButtonElement>("tab-viewer");
5960

@@ -685,6 +686,36 @@ async function downloadBvh(): Promise<void> {
685686
}
686687
downloadBvhBtn.addEventListener("click", downloadBvh);
687688

689+
// --- glTF / GLB export ---
690+
// Bake the current movement into a GLB (rig + animation clip) and download it.
691+
async function downloadGltf(): Promise<void> {
692+
if (!editorApi) return;
693+
const source = editorApi.getValue();
694+
const { ir, errors } = parse(source);
695+
if (!ir || errors.length > 0) {
696+
flash(downloadGltfBtn, "Fix errors first", "error");
697+
return;
698+
}
699+
flash(downloadGltfBtn, "Exporting…", "pending", 0);
700+
try {
701+
const { exportGLTF } = await import("posecode-render");
702+
const glb = (await exportGLTF(ir, { binary: true })) as ArrayBuffer;
703+
const blob = new Blob([glb], { type: "model/gltf-binary" });
704+
const url = URL.createObjectURL(blob);
705+
const a = document.createElement("a");
706+
a.href = url;
707+
a.download = `${slugifyName(ir.name)}.glb`;
708+
document.body.appendChild(a);
709+
a.click();
710+
a.remove();
711+
URL.revokeObjectURL(url);
712+
flash(downloadGltfBtn, "Downloaded ✓", "success");
713+
} catch {
714+
flash(downloadGltfBtn, "Export failed", "error");
715+
}
716+
}
717+
downloadGltfBtn.addEventListener("click", downloadGltf);
718+
688719
// --- Slide-over panels (how-to, movement library) sharing one scrim ---
689720
const howto = $<HTMLElement>("howto");
690721
const scrim = $<HTMLDivElement>("scrim");

0 commit comments

Comments
 (0)