115 lines
5.1 KiB
JavaScript
115 lines
5.1 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 { 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 };
|
|
};
|
|
}
|