feat(assets): Mocap-Knochen auf das Mannequin übertragen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2a60f92539
commit
070bb79593
2 files changed
+236
No files matched your search
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MathUtils, Quaternion, Vector3 } from "three";
|
||||
import { BONE_MAP, clipId, createRetargeter, rigWorld } from "../tools/assets/motions.mjs";
|
||||
|
||||
const turn = (x, y, z, degrees) =>
|
||||
new Quaternion().setFromAxisAngle(new Vector3(x, y, z).normalize(), MathUtils.degToRad(degrees));
|
||||
|
||||
/**
|
||||
* Kleines Zielskelett im Bau des Mannequins: root dreht −90° um X (Blender-Z-oben), darunter Becken, Brust,
|
||||
* Schlüsselbein, Oberarm und eine nicht zugeordnete Hand. Die Ruhedrehungen sind absichtlich schief, damit eine
|
||||
* vertauschte Reihenfolge der Quaternionen auffällt.
|
||||
*/
|
||||
const RIG = [
|
||||
{ name: "root", parent: null, position: [0, 0, 0], quaternion: turn(1, 0, 0, -90).toArray(), scale: [1, 1, 1] },
|
||||
{ name: "pelvis", parent: "root", position: [0, 0.05, 0.917], quaternion: turn(1, 0, 0, 104.5).toArray(), scale: [1, 1, 1] },
|
||||
{ name: "spine_03", parent: "pelvis", position: [0, 0.4, 0], quaternion: turn(1, 0, 0, -12).toArray(), scale: [1, 1, 1] },
|
||||
{ name: "clavicle_l", parent: "spine_03", position: [0.03, 0.2, 0], quaternion: turn(0.3, -0.7, 0.2, 80).toArray(), scale: [1, 1, 1] },
|
||||
{ name: "upperarm_l", parent: "clavicle_l", position: [0, 0.15, 0], quaternion: turn(0, 0, 1, 15).toArray(), scale: [1, 1, 1] },
|
||||
{ name: "hand_l", parent: "upperarm_l", position: [0, 0.27, 0], quaternion: turn(0, 1, 0, 5).toArray(), scale: [1, 1, 1] },
|
||||
];
|
||||
const MAP = { Hips: "pelvis", Spine3: "spine_03", LeftShoulder: "clavicle_l", LeftArm: "upperarm_l" };
|
||||
const restFrame = () => ({
|
||||
rotations: new Map(Object.keys(MAP).map((name) => [name, new Quaternion()])),
|
||||
hips: new Vector3(0, 90, 0),
|
||||
});
|
||||
const restOf = (name) => new Quaternion().fromArray(RIG.find((bone) => bone.name === name).quaternion);
|
||||
/** Quaternionen vergleichen, ohne dass acos nahe 0 die Genauigkeit frisst. */
|
||||
const expectSameRotation = (actual, expected) => expect(1 - Math.abs(actual.dot(expected))).toBeLessThan(1e-9);
|
||||
|
||||
describe("clipId", () => {
|
||||
it("liest den Namen aus jedem der elf Dateinamen", () => {
|
||||
expect(clipId("20210715_s231_idle_loadGun-Hand_tk01_ERJA-mvn359.fbx")).toBe("idle_loadGun-Hand_tk01");
|
||||
expect(clipId("20210715_s231_idle_loadGun-Rifle_tk04_ERJA-mvn362.fbx")).toBe("idle_loadGun-Rifle_tk04");
|
||||
expect(clipId("20210714_s022_hunter_bowArrow_tk01_ERJA-mvn006.fbx")).toBe("hunter_bowArrow_tk01");
|
||||
expect(clipId("20210714_s055_leftHandThrow_grenade_tk02_ERJA-mvn052.fbx")).toBe("leftHandThrow_grenade_tk02");
|
||||
expect(clipId("20210714_s077_taunt_shootSelfInHead_tk01_ERJA-mvn072.fbx")).toBe("taunt_shootSelfInHead_tk01");
|
||||
expect(clipId("20210714_s085_stabTwist_Knife_tk02_ERJA-mvn086.fbx")).toBe("stabTwist_Knife_tk02");
|
||||
});
|
||||
|
||||
// Bruch: eine fremde Datei im Ordner würde sonst einen unbrauchbaren Clipnamen erzeugen.
|
||||
it("lehnt fremde Dateinamen ab", () => {
|
||||
expect(() => clipId("SM_Pistol.fbx")).toThrow(/Unbekannter Dateiname/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BONE_MAP", () => {
|
||||
it("ordnet 22 Knochen zu, ohne Spine2 und ohne doppeltes Ziel", () => {
|
||||
const targets = Object.values(BONE_MAP);
|
||||
expect(targets).toHaveLength(22);
|
||||
expect(new Set(targets).size).toBe(22);
|
||||
expect(BONE_MAP.Spine2).toBeUndefined();
|
||||
expect(BONE_MAP.Hips).toBe("pelvis");
|
||||
expect(BONE_MAP.LeftToeBase).toBe("ball_l");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rigWorld", () => {
|
||||
it("verkettet Ruhedrehungen und Ruhepositionen", () => {
|
||||
const world = rigWorld(RIG);
|
||||
const pelvis = new Vector3().setFromMatrixPosition(world.get("pelvis").matrix);
|
||||
expect(pelvis.x).toBeCloseTo(0, 9);
|
||||
expect(pelvis.y).toBeCloseTo(0.917, 9);
|
||||
expect(pelvis.z).toBeCloseTo(-0.05, 9);
|
||||
expectSameRotation(world.get("pelvis").quaternion, restOf("root").clone().multiply(restOf("pelvis")));
|
||||
});
|
||||
|
||||
// Bruch: Reihenfolge im rig vertauscht — die Kette rechnete mit einem noch leeren Elternknochen.
|
||||
it("verlangt Eltern vor Kindern", () => {
|
||||
expect(() => rigWorld([RIG[1], RIG[0]])).toThrow(/steht nicht vor/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRetargeter", () => {
|
||||
// Bruch: falsche Formel — die Figur stünde dauerhaft verdreht statt in ihrer Ruhepose.
|
||||
it("macht aus der MVN-Ruhepose die Ruhepose des Mannequins", () => {
|
||||
const { local } = createRetargeter(RIG, { boneMap: MAP })(restFrame());
|
||||
for (const name of Object.values(MAP)) expectSameRotation(local.get(name), restOf(name));
|
||||
});
|
||||
|
||||
it("gibt nicht zugeordneten Knochen keine Spur", () => {
|
||||
const { local } = createRetargeter(RIG, { boneMap: MAP })(restFrame());
|
||||
expect(local.has("hand_l")).toBe(false);
|
||||
expect(local.has("root")).toBe(false);
|
||||
expect(local.size).toBe(4);
|
||||
});
|
||||
|
||||
it("überträgt eine Weltdrehung am Oberarm eins zu eins", () => {
|
||||
const rotation = turn(0, 0, 1, 90);
|
||||
const frame = restFrame();
|
||||
frame.rotations.set("LeftArm", rotation);
|
||||
const { local } = createRetargeter(RIG, { boneMap: MAP })(frame);
|
||||
const rest = rigWorld(RIG);
|
||||
// Der Schlüsselbein-Knochen bleibt in Ruhe, also ist seine Weltdrehung die Ruhe-Weltdrehung.
|
||||
const world = rest.get("clavicle_l").quaternion.clone().multiply(local.get("upperarm_l"));
|
||||
expectSameRotation(world, rotation.clone().multiply(rest.get("upperarm_l").quaternion));
|
||||
});
|
||||
|
||||
// Bruch: Elternkette übersehen — eine Drehung der Brust würde Arme und Kopf zusätzlich verbiegen.
|
||||
it("lässt die lokale Drehung der Kinder unverändert, wenn sich nur der Elternknochen dreht", () => {
|
||||
const rotation = turn(0, 1, 0, 40);
|
||||
const frame = restFrame();
|
||||
for (const name of ["Spine3", "LeftShoulder", "LeftArm"]) frame.rotations.set(name, rotation);
|
||||
const { local } = createRetargeter(RIG, { boneMap: MAP })(frame);
|
||||
expectSameRotation(local.get("clavicle_l"), restOf("clavicle_l"));
|
||||
expectSameRotation(local.get("upperarm_l"), restOf("upperarm_l"));
|
||||
expect(1 - Math.abs(local.get("spine_03").dot(restOf("spine_03")))).toBeGreaterThan(1e-3);
|
||||
});
|
||||
|
||||
// Bruch: cm als m gelesen oder Raum verwechselt — die Figur stünde 90 m über dem Boden oder läge flach.
|
||||
it("rechnet die Hüfte in Meter um, skaliert sie und legt sie in den Raum von root", () => {
|
||||
const frame = restFrame();
|
||||
frame.hips.set(10, 90, 20);
|
||||
const { hips } = createRetargeter(RIG, { boneMap: MAP, hipsScale: 1.02 })(frame);
|
||||
expect(hips.x).toBeCloseTo(0.102, 9);
|
||||
expect(hips.y).toBeCloseTo(-0.204, 9);
|
||||
expect(hips.z).toBeCloseTo(0.918, 9);
|
||||
});
|
||||
|
||||
it("verlangt einen unbewegten Elternknochen über der Hüfte", () => {
|
||||
expect(() => createRetargeter(RIG, { boneMap: { ...MAP, Reference: "root" } })).toThrow(/darf nicht animiert sein/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// Ü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 };
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user