Files
gta6/tests/motions.test.js
T

335 lines
17 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.
import { describe, expect, it } from "vitest";
import { AnimationClip, Bone, Group, MathUtils, Quaternion, QuaternionKeyframeTrack, Vector3,
VectorKeyframeTrack } from "three";
import { Document } from "@gltf-transform/core";
import { addClip, BLEND_MAX, BLEND_MIN, blendDuration, BONE_MAP, clipId, closeLoop, createRetargeter, faceForward,
flattenPoses, headingOf, removeDrift, rigFromScene, rigWorld, sampleFbx, sampleTimes, seamAngle,
SOURCE_HIPS, stripToSkeleton } 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);
});
// Bruch: Hüftposition in bewegten Elternraum gerechnet — die Figur wanderte mit jedem Bild zusätzlich mit ihrem Hüft-Elternknochen.
it("verlangt einen unbewegten Elternknochen über der Hüfte", () => {
expect(() => createRetargeter(RIG, { boneMap: { ...MAP, Reference: "root" } })).toThrow(/darf nicht animiert sein/);
});
});
describe("sampleTimes", () => {
// Bruch: ungleichmäßige Abstände — der Clip liefe stellenweise zu schnell.
it("verteilt round(Dauer · 30) + 1 Zeiten gleichmäßig über den Clip", () => {
const times = sampleTimes(0.008, 6.56, 30);
expect(times).toHaveLength(Math.round(6.552 * 30) + 1);
expect(times[0]).toBe(0.008);
expect(times.at(-1)).toBeCloseTo(6.56, 12);
const steps = times.slice(1).map((time, i) => time - times[i]);
expect(Math.max(...steps) - Math.min(...steps)).toBeLessThan(1e-12);
});
// Bruch: unbemerkt leerer Zeitraum — der Clip hätte nur ein Bild.
it("lehnt einen leeren Zeitraum ab", () => {
expect(() => sampleTimes(2, 2)).toThrow(/Leerer Zeitraum/);
});
});
describe("faceForward", () => {
const sideways = () => [0, 1].map(() => ({
rotations: new Map([[SOURCE_HIPS, turn(0, 1, 0, -30)], ["Head", turn(1, 0, 0, 10)]]),
hips: new Vector3(10, 90, 0),
}));
// Bruch: Blickrichtung nicht gedreht — die Dummies stünden schräg zur Spielfigur (bis 44°).
it("dreht den Clip, bis die Hüfte im ersten Bild nach +Z schaut", () => {
const faced = faceForward(sideways());
expect(headingOf(faced[0].rotations.get(SOURCE_HIPS))).toBeCloseTo(0, 9);
});
it("dreht Positionen und alle Knochen mit derselben Drehung", () => {
const faced = faceForward(sideways());
expect(faced[1].hips.x).toBeCloseTo(8.660254, 5);
expect(faced[1].hips.z).toBeCloseTo(-5, 5);
expect(faced[1].hips.y).toBeCloseTo(90, 9);
expectSameRotation(faced[1].rotations.get("Head"), turn(0, 1, 0, 30).multiply(turn(1, 0, 0, 10)));
});
});
describe("removeDrift", () => {
// Bruch: Drift bleibt — der Dummy wanderte bei jeder Schleife weiter von seinem Platz.
it("legt das erste Bild auf 0 und zieht die Drift linear ab", () => {
const frames = [[4, 90, 2], [6, 91, 5], [10, 92, 8]].map(([x, y, z]) =>
({ rotations: new Map(), hips: new Vector3(x, y, z) }));
expect(removeDrift(frames).map((frame) => frame.hips.toArray()))
.toEqual([[0, 90, 0], [-1, 91, 0], [0, 92, 0]]);
});
});
describe("seamAngle und blendDuration", () => {
const frame = (degrees) => ({
rotations: new Map([[SOURCE_HIPS, new Quaternion()], ["LeftFoot", turn(1, 0, 0, degrees)]]),
hips: new Vector3(),
});
it("misst die größte Abweichung zwischen letztem und erstem Bild", () => {
expect(seamAngle([frame(0), frame(5), frame(91)])).toBeCloseTo(91, 6);
});
// Bruch: feste Überblendzeit — die 91°-Naht des Bogenschützen ruckte sichtbar.
it("bemisst die Überblendung an der Naht und hält die Grenzen ein", () => {
expect(blendDuration(91)).toBeCloseTo(91 / 90, 9);
expect(blendDuration(19)).toBe(BLEND_MIN);
expect(blendDuration(400)).toBe(BLEND_MAX);
});
});
describe("closeLoop", () => {
const times = [0, 0.5, 1, 1.5, 2];
const poses = () => times.map((time) => ({
local: new Map([["pelvis", turn(0, 1, 0, 40 * time)]]),
hips: new Vector3(time, 1, 0),
}));
// Bruch: Schleife nicht geschlossen — jeder Rücksprung wäre ein sichtbarer Ruck.
it("macht das letzte Bild gleich dem ersten", () => {
const closed = closeLoop(times, poses(), 1);
expectSameRotation(closed.at(-1).local.get("pelvis"), poses()[0].local.get("pelvis"));
expect(closed.at(-1).hips.toArray()).toEqual([0, 1, 0]);
});
it("lässt alles vor der Überblendung unberührt und blendet weich", () => {
const original = poses();
const closed = closeLoop(times, original, 1);
expect(closed[0]).toBe(original[0]);
expect(closed[2]).toBe(original[2]);
expect(closed[3].hips.x).toBeCloseTo(0.75, 9);
});
});
describe("sampleFbx", () => {
/** Nachbau eines MVN-FBX: Reference -> Hips -> Spine, Frame 0 in T-Pose, danach Bewegung. */
function fakeFbx() {
const root = new Group();
const reference = new Bone();
reference.name = "Reference";
const hips = new Bone();
hips.name = "Hips";
hips.position.set(0, 90, 0);
const spine = new Bone();
spine.name = "Spine";
spine.position.set(0, 10, 0);
root.add(reference);
reference.add(hips);
hips.add(spine);
const quarter = turn(0, 1, 0, 90).toArray();
root.animations = [new AnimationClip("take", 1.008, [
new VectorKeyframeTrack("Hips.position", [0, 0.008, 1.008], [0, 90, 0, 5, 90, 0, 5, 90, 30]),
new QuaternionKeyframeTrack("Hips.quaternion", [0, 0.008, 1.008], [0, 0, 0, 1, 0, 0, 0, 1, ...quarter]),
new QuaternionKeyframeTrack("Spine.quaternion", [0, 0.008, 1.008], [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]),
])];
return root;
}
// Bruch: Frame 0 mitgenommen — jede Schleife begänne mit einem Zucken in die T-Pose.
it("lässt Frame 0 weg, tastet bis zum Ende ab und liest die Hüfthöhe der Ruhepose", () => {
const { times, frames, hipsRestHeight } = sampleFbx(fakeFbx(), 30, { Hips: "pelvis", Spine: "spine_01" });
expect(hipsRestHeight).toBe(90);
expect(times).toHaveLength(31);
expect(times[0]).toBe(0);
expect(times.at(-1)).toBeCloseTo(1, 9);
expect(frames[0].hips.x).toBeCloseTo(5, 6);
expect(frames.at(-1).hips.z).toBeCloseTo(30, 6);
});
it("liest Weltdrehungen, nicht lokale", () => {
const { frames } = sampleFbx(fakeFbx(), 30, { Hips: "pelvis", Spine: "spine_01" });
expectSameRotation(frames.at(-1).rotations.get("Spine"), turn(0, 1, 0, 90));
});
});
describe("rigFromScene und stripToSkeleton", () => {
function skeletonDocument() {
const document = new Document();
const root = document.createNode("root").setRotation(turn(1, 0, 0, -90).toArray());
const pelvis = document.createNode("pelvis").setTranslation([0, 0.05, 0.917]);
const leaf = document.createNode("Head");
root.addChild(pelvis);
pelvis.addChild(leaf);
document.getRoot().setDefaultScene(document.createScene("Armature").addChild(root));
return document;
}
it("liest die Knoten in Eltern-vor-Kind-Reihenfolge", () => {
const document = skeletonDocument();
const rig = rigFromScene(document.getRoot().listScenes()[0]);
expect(rig.map((bone) => [bone.name, bone.parent]))
.toEqual([["root", null], ["pelvis", "root"], ["Head", "pelvis"]]);
expect(rig[1].position).toEqual([0, 0.05, 0.917]);
});
// Bruch: Blattknoten weggeprunt — Head und die Fingerknochen fehlten als Ziel der Clips.
it("räumt Clips und Geometrie weg, behält aber jeden Knoten", async () => {
const document = skeletonDocument();
const mesh = document.createMesh("Mannequin");
document.getRoot().listNodes()[1].setMesh(mesh);
document.createAnimation("Idle_Loop");
await stripToSkeleton(document);
expect(document.getRoot().listAnimations()).toHaveLength(0);
expect(document.getRoot().listMeshes()).toHaveLength(0);
expect(document.getRoot().listNodes().map((node) => node.getName())).toEqual(["root", "pelvis", "Head"]);
});
});
describe("addClip", () => {
it("legt einen Zeit-Accessor, Drehungen je Knochen und die Hüfte als Translation an", () => {
const document = new Document();
const pelvis = document.createNode("pelvis");
const spine = document.createNode("spine_01");
document.getRoot().setDefaultScene(document.createScene().addChild(pelvis.addChild(spine)));
const poses = [0, 1].map((i) => ({
local: new Map([["pelvis", new Quaternion()], ["spine_01", turn(0, 0, 1, 10 * i)]]),
hips: new Vector3(0, 0.9 + i, 0),
}));
const animation = addClip(document, "test_tk01", [0, 0.5], poses, "pelvis");
expect(animation.getName()).toBe("test_tk01");
expect(animation.listChannels()).toHaveLength(3);
expect(new Set(animation.listSamplers().map((sampler) => sampler.getInput())).size).toBe(1);
const translation = animation.listChannels().find((channel) => channel.getTargetPath() === "translation");
expect(translation.getTargetNode().getName()).toBe("pelvis");
// Float32Array-Speicher: 0.9 und 1.9 runden beim Schreiben, darum hier durch Math.fround vergleichen statt
// mit den float64-Literalen (Abweichung vom Brief, siehe task-5-report.md).
expect([...translation.getSampler().getOutput().getArray()])
.toEqual([0, Math.fround(0.9), 0, 0, Math.fround(1.9), 0]);
});
it("meldet einen fehlenden Knoten", () => {
const document = new Document();
document.getRoot().setDefaultScene(document.createScene().addChild(document.createNode("pelvis")));
const poses = [{ local: new Map([["fehlt", new Quaternion()]]), hips: new Vector3() }];
expect(() => addClip(document, "test", [0], poses, "pelvis")).toThrow(/Skelett ohne Knoten fehlt/);
});
});
describe("flattenPoses", () => {
it("reiht Drehungen als xyzw und die Hüfte als xyz je Bild auf", () => {
const poses = [
{ local: new Map([["pelvis", new Quaternion(0, 0, 0, 1)]]), hips: new Vector3(1, 2, 3) },
{ local: new Map([["pelvis", new Quaternion(0.5, 0, 0, 0.8660254)]]), hips: new Vector3(4, 5, 6) },
];
const { rotations, hips } = flattenPoses(poses);
expect([...hips]).toEqual([1, 2, 3, 4, 5, 6]);
expect([...rotations.get("pelvis")].slice(0, 4)).toEqual([0, 0, 0, 1]);
expect([...rotations.get("pelvis")][4]).toBeCloseTo(0.5, 6);
});
});