// Überträgt die Mocap-Clips aus MotionLibrary_Weapons.zip (Xsens MVN, 24 Knochen, cm) auf das Skelett des // Mannequins aus public/models/ual.glb (docs/superpowers/specs/2026-09-17-animationen-design.md, "Umbau der // Animationen"): // // node tools/assets/motions.mjs -> public/models/motions/weapons-mocap.glb // // 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"; /** * MVN-Knochen -> Mannequin-Knochen, zugeordnet nach gemessener Höhe in der Ruhepose. Spine2 (1,187 m) hat kein * Gegenstück: Die Übertragung rechnet mit Weltdrehungen, seine Drehung steckt deshalb in der von spine_03. */ export const BONE_MAP = { Hips: "pelvis", Spine: "spine_01", Spine1: "spine_02", Spine3: "spine_03", Neck: "neck_01", Head: "Head", LeftShoulder: "clavicle_l", LeftArm: "upperarm_l", LeftForeArm: "lowerarm_l", LeftHand: "hand_l", RightShoulder: "clavicle_r", RightArm: "upperarm_r", RightForeArm: "lowerarm_r", RightHand: "hand_r", LeftUpLeg: "thigh_l", LeftLeg: "calf_l", LeftFoot: "foot_l", LeftToeBase: "ball_l", RightUpLeg: "thigh_r", RightLeg: "calf_r", RightFoot: "foot_r", RightToeBase: "ball_r", }; export const SOURCE_HIPS = "Hips"; /** Clip-Name aus dem Dateinamen: 20210715_s231_idle_loadGun-Hand_tk01_ERJA-mvn359.fbx -> idle_loadGun-Hand_tk01. */ export function clipId(fileName) { const match = /^\d{8}_s\d+_(.+)_ERJA-mvn\d+\.fbx$/.exec(fileName); if (!match) throw new Error(`Unbekannter Dateiname: ${fileName}`); return match[1]; } /** * Ruhepose eines Zielskeletts in Weltkoordinaten. rig ist eine Liste in Eltern-vor-Kind-Reihenfolge: * { name, parent, position: [x, y, z], quaternion: [x, y, z, w], scale: [x, y, z] }. Liefert je Knochen die * Weltmatrix und die Weltdrehung (Drehungen verkettet, das Mannequin hat überall Maßstab 1). */ export function rigWorld(rig) { const world = new Map(); for (const bone of rig) { const parent = bone.parent === null ? null : world.get(bone.parent); if (bone.parent !== null && !parent) throw new Error(`Rig: ${bone.parent} steht nicht vor ${bone.name}`); const rotation = new Quaternion().fromArray(bone.quaternion); const local = new Matrix4().compose( new Vector3().fromArray(bone.position), rotation, new Vector3().fromArray(bone.scale ?? [1, 1, 1]), ); world.set(bone.name, { matrix: parent ? parent.matrix.clone().multiply(local) : local, quaternion: parent ? parent.quaternion.clone().multiply(rotation) : rotation.clone(), }); } return world; } /** * Baut die Übertragung eines MVN-Bilds auf das Zielskelett. Ein Bild ist { rotations: Map, hips: Weltposition der Hüfte in cm }. Beide Skelette ruhen in derselben T-Pose mit Blick nach +Z, * und jede MVN-Ruhe-Weltdrehung ist 0°. Deshalb gilt je zugeordnetem Knochen: * * Weltdrehung Ziel = Weltdrehung MVN · Ruhe-Weltdrehung Ziel, lokal = (Weltdrehung Eltern)⁻¹ · Weltdrehung * * Knochen ohne Zuordnung (root, Finger, Blattknochen) gehen mit ihrer Ruhedrehung in die Kette ein und bekommen * keine Spur. Die Hüfte: cm -> m, mal hipsScale (Hüfthöhe Ziel ÷ Hüfthöhe Quelle), dann in den Raum ihres * Elternknochens, der dafür unbewegt sein muss. */ export function createRetargeter(rig, { boneMap = BONE_MAP, hipsScale = 1 } = {}) { const rest = rigWorld(rig); const sourceOf = new Map(Object.entries(boneMap).map(([source, target]) => [target, source])); const hipsBone = rig.find((bone) => bone.name === boneMap[SOURCE_HIPS]); if (!hipsBone) throw new Error(`Rig ohne Hüftknochen ${boneMap[SOURCE_HIPS]}`); if (hipsBone.parent !== null && sourceOf.has(hipsBone.parent)) { throw new Error(`Elternknochen der Hüfte (${hipsBone.parent}) darf nicht animiert sein`); } const hipsParentInverse = hipsBone.parent === null ? new Matrix4() : rest.get(hipsBone.parent).matrix.clone().invert(); return function retarget(frame) { const world = new Map(); const local = new Map(); for (const bone of rig) { const parentWorld = bone.parent === null ? new Quaternion() : world.get(bone.parent); const source = sourceOf.get(bone.name); if (source === undefined) { world.set(bone.name, parentWorld.clone().multiply(new Quaternion().fromArray(bone.quaternion))); continue; } const sourceWorld = frame.rotations.get(source); if (!sourceWorld) throw new Error(`Bild ohne Drehung für ${source}`); const targetWorld = sourceWorld.clone().multiply(rest.get(bone.name).quaternion); world.set(bone.name, targetWorld); local.set(bone.name, parentWorld.clone().invert().multiply(targetWorld)); } const hips = frame.hips.clone().multiplyScalar(hipsScale / 100).applyMatrix4(hipsParentInverse); return { local, hips }; }; } export const SAMPLE_RATE = 30; /** Überblendzeit am Schleifenende: Naht in Grad ÷ BLEND_SPEED (°/s), begrenzt auf BLEND_MIN bis BLEND_MAX Sekunden. */ export const BLEND_SPEED = 90; export const BLEND_MIN = 0.25; export const BLEND_MAX = 1.5; /** n = round((end − start) · rate) + 1 gleichmäßig verteilte Zeitpunkte von start bis end, beide eingeschlossen. */ export function sampleTimes(start, end, rate = SAMPLE_RATE) { if (!(end > start)) throw new Error(`Leerer Zeitraum ${start}–${end}`); const count = Math.round((end - start) * rate) + 1; return Array.from({ length: count }, (_, i) => start + ((end - start) * i) / (count - 1)); } /** Winkel des Vorwärtsvektors (+Z) einer Drehung gegen +Z in der Waagrechten, im Bogenmaß, positiv zu +X. */ export function headingOf(quaternion) { const forward = new Vector3(0, 0, 1).applyQuaternion(quaternion); return Math.atan2(forward.x, forward.z); } /** Dreht alle Bilder um die Hochachse, bis die Hüfte im ersten Bild nach +Z schaut. */ export function faceForward(frames) { const angle = -headingOf(frames[0].rotations.get(SOURCE_HIPS)); const turn = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), angle); return frames.map((frame) => ({ rotations: new Map([...frame.rotations].map(([name, rotation]) => [name, turn.clone().multiply(rotation)])), hips: frame.hips.clone().applyQuaternion(turn), })); } /** Legt die Hüfte im ersten Bild waagrecht auf 0 und zieht die Drift linear ab, sodass das letzte Bild dort endet. */ export function removeDrift(frames) { const first = frames[0].hips; const last = frames.at(-1).hips; return frames.map((frame, i) => { const along = frames.length > 1 ? i / (frames.length - 1) : 0; const hips = frame.hips.clone(); hips.x -= first.x + along * (last.x - first.x); hips.z -= first.z + along * (last.z - first.z); return { rotations: frame.rotations, hips }; }); } /** Größte Abweichung der Weltdrehung zwischen letztem und erstem Bild über alle Knochen, in Grad. */ export function seamAngle(frames) { const last = frames.at(-1).rotations; let largest = 0; for (const [name, rotation] of frames[0].rotations) largest = Math.max(largest, rotation.angleTo(last.get(name))); return MathUtils.radToDeg(largest); } export function blendDuration(seamDegrees) { return MathUtils.clamp(seamDegrees / BLEND_SPEED, BLEND_MIN, BLEND_MAX); } /** * Schließt die Schleife: In den letzten `blend` Sekunden werden lokale Drehungen (slerp) und die Hüfte (lerp) mit * smoothstep-Gewicht zum ersten Bild gezogen. Das letzte Bild gleicht danach dem ersten. poses: [{ local, hips }]. */ export function closeLoop(times, poses, blend) { const end = times.at(-1); const first = poses[0]; return poses.map((pose, i) => { if (times[i] <= end - blend) return pose; const weight = MathUtils.smoothstep(times[i], end - blend, end); return { local: new Map([...pose.local].map(([name, rotation]) => [name, rotation.clone().slerp(first.local.get(name), weight)])), hips: pose.hips.clone().lerp(first.hips, weight), }; }); } /** * 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 "); 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();