Skip to content

Commit e3c1b32

Browse files
committed
Build normalized Xbot runtime character
1 parent 47dfd70 commit e3c1b32

6 files changed

Lines changed: 168 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"coverage": "vitest run --coverage",
1616
"dev": "npm run dev -w playground",
1717
"build": "npm run build -w playground",
18+
"prepare:xbot": "node scripts/prepare-xbot.mjs",
1819
"eval": "tsx packages/posecode-eval/src/cli.ts",
1920
"typecheck": "for p in packages/*/tsconfig.json playground/tsconfig.json editors/*/tsconfig.json; do tsc --noEmit -p \"$p\" || exit 1; done",
2021
"gifs": "node scripts/capture-gifs.mjs"
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import fs from "node:fs";
2+
import { describe, expect, it } from "vitest";
3+
import * as THREE from "three";
4+
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
5+
6+
const ASSET = new URL("../../../playground/public/models/xbot.glb", import.meta.url);
7+
8+
async function loadXbot(): Promise<THREE.Object3D> {
9+
const bytes = fs.readFileSync(ASSET);
10+
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
11+
return await new Promise((resolve, reject) => {
12+
new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject);
13+
});
14+
}
15+
16+
describe("normalized Xbot runtime asset", () => {
17+
it("ships two meshes on one meter-scale Mixamo skeleton", async () => {
18+
const scene = await loadXbot();
19+
const meshes: THREE.SkinnedMesh[] = [];
20+
scene.traverse((node) => {
21+
if ((node as THREE.SkinnedMesh).isSkinnedMesh) meshes.push(node as THREE.SkinnedMesh);
22+
});
23+
24+
expect(meshes.map((mesh) => mesh.name).sort()).toEqual(["Beta_Joints", "Beta_Surface"]);
25+
expect(meshes[0]!.skeleton.bones).toHaveLength(65);
26+
expect(new Set(meshes[0]!.skeleton.bones.map((bone) => bone.name)).size).toBe(65);
27+
// Both skins must reference the same bone objects, not Mixamo's duplicated
28+
// nested skeleton that previously made retargeting ambiguous and unstable.
29+
expect(meshes[1]!.skeleton.bones[0]).toBe(meshes[0]!.skeleton.bones[0]);
30+
31+
const bounds = new THREE.Box3().setFromObject(scene);
32+
const height = bounds.getSize(new THREE.Vector3()).y;
33+
expect(height).toBeGreaterThan(1.7);
34+
expect(height).toBeLessThan(1.8);
35+
const vertices = meshes.reduce(
36+
(sum, mesh) => sum + mesh.geometry.getAttribute("position").count,
37+
0,
38+
);
39+
expect(vertices).toBeLessThan(30_000);
40+
});
41+
});

playground/public/models/xbot.glb

1.89 MB
Binary file not shown.

playground/src/landing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function initHero(): void {
3131
const viewer = createViewer(heroCanvas, {
3232
autoRotate: !prefersReducedMotion,
3333
// Realistic skinned figure without flashing the procedural fallback.
34-
characterUrl: "/models/character.glb",
34+
characterUrl: "/models/xbot.glb",
3535
showProceduralWhileLoading: false,
3636
// Marketing surface: the hero movement declares `clip "jumping-jacks"`, so
3737
// it plays the retargeted Mixamo mocap for maximum polish on first paint.

playground/src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ void import("posecode-render").then(({ createViewer }) => {
553553
...(classicFigure
554554
? {}
555555
: {
556-
characterUrl: "/models/character.glb",
556+
characterUrl: "/models/xbot.glb",
557557
// Avoid flashing the procedural/classic figure while the default
558558
// mannequin asset loads. It still appears if the GLB genuinely fails.
559559
showProceduralWhileLoading: false,

scripts/prepare-xbot.mjs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Normalize Mixamo's Xbot FBX into the single-skeleton GLB used at runtime.
4+
*
5+
* Mixamo exports Beta_Surface and Beta_Joints with separate copies of the same
6+
* skeleton. Posecode needs one unambiguous bone tree, so the runtime character
7+
* uses the complete Beta_Surface mesh/skeleton. The decorative joint shell is
8+
* deliberately omitted; it can return later as a rigid overlay after the core
9+
* retarget path is stable.
10+
*
11+
* Usage: node scripts/prepare-xbot.mjs <input.fbx> [output.glb]
12+
*/
13+
import fs from "node:fs";
14+
import path from "node:path";
15+
import * as THREE from "three";
16+
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
17+
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
18+
import { mergeVertices } from "three/examples/jsm/utils/BufferGeometryUtils.js";
19+
20+
globalThis.ProgressEvent ??= class ProgressEvent {};
21+
globalThis.FileReader ??= class FileReader {
22+
readAsArrayBuffer(blob) {
23+
blob.arrayBuffer().then((result) => {
24+
this.result = result;
25+
this.onloadend?.();
26+
}, (error) => this.onerror?.(error));
27+
}
28+
29+
readAsDataURL(blob) {
30+
blob.arrayBuffer().then((result) => {
31+
this.result = `data:${blob.type};base64,${Buffer.from(result).toString("base64")}`;
32+
this.onloadend?.();
33+
}, (error) => this.onerror?.(error));
34+
}
35+
};
36+
37+
const input = process.argv[2];
38+
const output = process.argv[3] ?? "playground/public/models/xbot.glb";
39+
if (!input) throw new Error("usage: node scripts/prepare-xbot.mjs <input.fbx> [output.glb]");
40+
41+
const bytes = fs.readFileSync(input);
42+
const scene = new FBXLoader().parse(
43+
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
44+
`${path.dirname(input)}/`,
45+
);
46+
47+
let surface = null;
48+
let joints = null;
49+
const remove = [];
50+
scene.traverse((node) => {
51+
if (node.isSkinnedMesh && node.name === "Beta_Surface") surface = node;
52+
if (node.isSkinnedMesh && node.name === "Beta_Joints") joints = node;
53+
// The second skeleton is nested under the primary Hips in Mixamo's Xbot.
54+
if (node.isBone && /Hips$/.test(node.name) && node.parent?.isBone) remove.push(node);
55+
});
56+
if (!surface) throw new Error("Xbot normalization: Beta_Surface was not found");
57+
if (!joints) throw new Error("Xbot normalization: Beta_Joints was not found");
58+
for (const node of remove) node.removeFromParent();
59+
60+
const boneNames = new Set(surface.skeleton.bones.map((bone) => bone.name));
61+
if (boneNames.size !== surface.skeleton.bones.length) {
62+
throw new Error("Xbot normalization: primary skeleton still has duplicate bone names");
63+
}
64+
65+
// Re-index the decorative joint shell onto the primary skeleton. Both meshes
66+
// use the same Mixamo names but export separate bone objects (and Beta_Joints
67+
// omits one terminal bone), so indices must be translated by name first.
68+
const primaryIndex = new Map(surface.skeleton.bones.map((bone, index) => [bone.name, index]));
69+
const jointToPrimary = joints.skeleton.bones.map((bone) => {
70+
const index = primaryIndex.get(bone.name);
71+
if (index === undefined) throw new Error(`Xbot normalization: unmapped joint bone ${bone.name}`);
72+
return index;
73+
});
74+
const jointSkinIndex = joints.geometry.getAttribute("skinIndex");
75+
for (let vertex = 0; vertex < jointSkinIndex.count; vertex++) {
76+
for (let lane = 0; lane < jointSkinIndex.itemSize; lane++) {
77+
const source = jointSkinIndex.getComponent(vertex, lane);
78+
jointSkinIndex.setComponent(vertex, lane, jointToPrimary[source]);
79+
}
80+
}
81+
jointSkinIndex.needsUpdate = true;
82+
83+
// Bake Mixamo centimeters into Posecode meters. Scaling the wrapper around a
84+
// live skin makes Xbot's bone matrices participate in that scale as well; bake
85+
// it into vertices + joint translations and rebuild inverse binds instead.
86+
scene.updateMatrixWorld(true);
87+
const bounds = new THREE.Box3().setFromObject(surface);
88+
const unitScale = 1.75 / bounds.getSize(new THREE.Vector3()).y;
89+
surface.geometry.scale(unitScale, unitScale, unitScale);
90+
joints.geometry.scale(unitScale, unitScale, unitScale);
91+
surface.geometry = mergeVertices(surface.geometry, 1e-4);
92+
joints.geometry = mergeVertices(joints.geometry, 1e-4);
93+
for (const bone of surface.skeleton.bones) bone.position.multiplyScalar(unitScale);
94+
scene.updateMatrixWorld(true);
95+
surface.bind(surface.skeleton);
96+
surface.normalizeSkinWeights();
97+
joints.bind(surface.skeleton);
98+
joints.normalizeSkinWeights();
99+
100+
// FBX legacy materials import almost black on Posecode's dark stage. Produce a
101+
// stable runtime PBR material rather than depending on converter heuristics.
102+
const oldMaterials = Array.isArray(surface.material) ? surface.material : [surface.material];
103+
for (const material of oldMaterials) material?.dispose();
104+
surface.material = new THREE.MeshStandardMaterial({
105+
color: 0xb9c0cc,
106+
metalness: 0.08,
107+
roughness: 0.68,
108+
});
109+
const oldJointMaterials = Array.isArray(joints.material) ? joints.material : [joints.material];
110+
for (const material of oldJointMaterials) material?.dispose();
111+
joints.material = new THREE.MeshStandardMaterial({
112+
color: 0x48515f,
113+
metalness: 0.12,
114+
roughness: 0.62,
115+
});
116+
117+
const glb = await new GLTFExporter().parseAsync(scene, {
118+
animations: [],
119+
binary: true,
120+
onlyVisible: true,
121+
});
122+
fs.mkdirSync(path.dirname(output), { recursive: true });
123+
fs.writeFileSync(output, Buffer.from(glb));
124+
console.log(`Prepared ${output}: ${surface.skeleton.bones.length} bones, ${surface.geometry.attributes.position.count + joints.geometry.attributes.position.count} vertices, scale ${unitScale.toFixed(6)}`);

0 commit comments

Comments
 (0)