feat(assets): elf Mocap-Clips als weapons-mocap.glb für das Mannequin

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-17 22:15:28 +02:00
1 parent aa6cde79d3
commit 4422432998
4 files changed
+345 -3

No files matched your search

+144
View File
@@ -6,6 +6,12 @@
//
// Das Zip bleibt außerhalb des Repos. Die Rechenschritte sind reine Funktionen (tests/motions.test.js), main()
// liest nur Dateien und schreibt das Ergebnis.
import { mkdir, readdir, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { prune } from "@gltf-transform/functions";
import { MathUtils, Matrix4, Quaternion, Vector3 } from "three";
/**
@@ -184,3 +190,141 @@ export function closeLoop(times, poses, blend) {
};
});
}
/**
* Liest den ersten Clip eines FBX-Objekts ab: n gleichmäßige Zeitpunkte von Frame 1 bis zum Ende (Frame 0 ist die
* T-Pose, 8 ms davor). Je Zeitpunkt die Weltdrehungen der zugeordneten MVN-Knochen und die Hüft-Weltposition in cm.
* times beginnt bei 0. hipsRestHeight ist die Hüfthöhe der Ruhepose in cm, abgelesen bevor ein Bild gesetzt wird —
* die beiden Aufnahmetage liegen dort 0,9 cm auseinander.
*/
export function sampleFbx(root, rate = SAMPLE_RATE, boneMap = BONE_MAP) {
const clip = root.animations?.[0];
if (!clip) throw new Error("FBX ohne Animation");
const hips = root.getObjectByName(SOURCE_HIPS);
if (!hips) throw new Error(`FBX ohne Knochen ${SOURCE_HIPS}`);
root.updateMatrixWorld(true);
const hipsRestHeight = hips.getWorldPosition(new Vector3()).y;
const bindings = clip.tracks.map((track) => {
const dot = track.name.lastIndexOf(".");
const object = root.getObjectByName(track.name.slice(0, dot));
if (!object) throw new Error(`Spur ohne Knochen: ${track.name}`);
return { target: object[track.name.slice(dot + 1)], interpolant: track.createInterpolant() };
});
const hipsTimes = clip.tracks.find((track) => track.name === `${SOURCE_HIPS}.quaternion`)?.times;
if (!hipsTimes || hipsTimes.length < 3) throw new Error("Hüftspur fehlt oder ist zu kurz");
const bones = Object.keys(boneMap).map((name) => {
const bone = root.getObjectByName(name);
if (!bone) throw new Error(`FBX ohne Knochen ${name}`);
return bone;
});
const start = hipsTimes[1];
const absolute = sampleTimes(start, clip.duration, rate);
const frames = absolute.map((time) => {
for (const { target, interpolant } of bindings) target.fromArray(interpolant.evaluate(time));
root.updateMatrixWorld(true);
return {
rotations: new Map(bones.map((bone) => [bone.name, bone.getWorldQuaternion(new Quaternion())])),
hips: hips.getWorldPosition(new Vector3()),
};
});
return { times: absolute.map((time) => time - start), frames, hipsRestHeight };
}
/** Knotenhierarchie einer gltf-transform-Szene als rig (siehe rigWorld), Eltern vor Kindern. */
export function rigFromScene(scene) {
const rig = [];
const visit = (node, parent) => {
rig.push({
name: node.getName(),
parent,
position: node.getTranslation(),
quaternion: node.getRotation(),
scale: node.getScale(),
});
for (const child of node.listChildren()) visit(child, node.getName());
};
for (const node of scene.listChildren()) visit(node, null);
return rig;
}
/** Entfernt aus einem gelesenen ual.glb alles außer der Knotenhierarchie: Clips, Meshes, Skins, Materialien, Texturen. */
export async function stripToSkeleton(document) {
const root = document.getRoot();
for (const node of root.listNodes()) node.setMesh(null).setSkin(null);
// Die Accessors müssen ausdrücklich mit weg: animation.dispose() löst seine Sampler nicht auf, prune() hält
// deren Accessors darum weiter für benutzt. Ohne diese Zeile blieben die 53 Clips von ual.glb in der Datei
// (gemessen: 8745 Accessors und 3,41 MB statt 264 und 1,66 MB).
for (const property of [...root.listAnimations(), ...root.listMeshes(), ...root.listSkins(),
...root.listMaterials(), ...root.listTextures(), ...root.listAccessors()]) {
property.dispose();
}
// keepLeaves: Knochen ohne Kinder (Head, Blattknochen) sind leere Blattknoten und müssen als Ziele bleiben.
await document.transform(prune({ keepLeaves: true }));
}
/** Legt Posen als Float32-Reihen ab: je Knochen x, y, z, w pro Bild, dazu die Hüfte als x, y, z pro Bild. */
export function flattenPoses(poses) {
const rotations = new Map([...poses[0].local.keys()].map((bone) => [bone, new Float32Array(poses.length * 4)]));
const hips = new Float32Array(poses.length * 3);
poses.forEach((pose, i) => {
for (const [bone, values] of rotations) pose.local.get(bone).toArray(values, i * 4);
pose.hips.toArray(hips, i * 3);
});
return { rotations, hips };
}
/** Hängt einen Clip an das Dokument: ein Zeit-Accessor, je Knochen ein Drehungskanal, die Hüfte als Translation. */
export function addClip(document, id, times, poses, hipsBone = BONE_MAP[SOURCE_HIPS]) {
const root = document.getRoot();
const buffer = root.listBuffers()[0] ?? document.createBuffer();
const nodes = new Map(root.listNodes().map((node) => [node.getName(), node]));
const accessor = (array, type) => document.createAccessor().setArray(array).setType(type).setBuffer(buffer);
const animation = document.createAnimation(id);
const input = accessor(Float32Array.from(times), "SCALAR");
const channel = (bone, path, values, type) => {
const node = nodes.get(bone);
if (!node) throw new Error(`Skelett ohne Knoten ${bone}`);
const sampler = document.createAnimationSampler()
.setInput(input).setOutput(accessor(values, type)).setInterpolation("LINEAR");
animation.addSampler(sampler).addChannel(
document.createAnimationChannel().setTargetNode(node).setTargetPath(path).setSampler(sampler));
};
const { rotations, hips } = flattenPoses(poses);
for (const [bone, values] of rotations) channel(bone, "rotation", values, "VEC4");
channel(hipsBone, "translation", hips, "VEC3");
return animation;
}
async function main() {
const [sourceDir] = process.argv.slice(2);
if (!sourceDir) {
console.error("Aufruf: node tools/assets/motions.mjs <Ordner mit den FBX aus MotionLibrary_Weapons.zip>");
process.exit(1);
}
const { loadFbx } = await import("./fbx.mjs");
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const document = await io.read(fileURLToPath(new URL("../../public/models/ual.glb", import.meta.url)));
const rig = rigFromScene(document.getRoot().listScenes()[0]);
await stripToSkeleton(document);
const pelvisHeight = new Vector3().setFromMatrixPosition(rigWorld(rig).get(BONE_MAP[SOURCE_HIPS]).matrix).y;
const files = (await readdir(sourceDir)).filter((name) => name.endsWith(".fbx")).sort();
for (const file of files) {
const id = clipId(file);
const { times, frames, hipsRestHeight } = sampleFbx(await loadFbx(join(sourceDir, file)));
const cleaned = removeDrift(faceForward(frames));
const blend = blendDuration(seamAngle(cleaned));
const retarget = createRetargeter(rig, { hipsScale: pelvisHeight / (hipsRestHeight / 100) });
addClip(document, id, times, closeLoop(times, cleaned.map(retarget), blend));
console.log(`${id}: ${times.at(-1).toFixed(2)} s, ${times.length} Bilder, Überblendung ${blend.toFixed(2)} s`);
}
const target = fileURLToPath(new URL("../../public/models/motions/weapons-mocap.glb", import.meta.url));
await mkdir(dirname(target), { recursive: true });
await io.write(target, document);
console.log(`weapons-mocap.glb ${((await stat(target)).size / 1e6).toFixed(2)} MB`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();