331 lines
16 KiB
JavaScript
331 lines
16 KiB
JavaScript
// Ü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 <Ordner mit den 11 FBX> -> 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<MVN-Knochen,
|
||
* Weltdrehung>, 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 (Beinlänge Ziel ÷ Beinlänge 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 <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();
|