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:
1 parent
aa6cde79d3
commit
4422432998
4 files changed
+345
-3
No files matched your search
@@ -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();
|
||||
Reference in new issue
Block a user