Files
gta6/tools/assets/motions.mjs
T

331 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Ü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();