Files
gta6/tools/assets/motions.mjs
T

187 lines
8.3 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 { 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),
};
});
}