feat(assets): Blickrichtung, Drift und Schleifennaht der Mocap-Clips

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-17 21:46:51 +02:00
1 parent 070bb79593
commit aa6cde79d3
2 files changed
+165 -2

No files matched your search

+92 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { MathUtils, Quaternion, Vector3 } from "three";
import { BONE_MAP, clipId, createRetargeter, rigWorld } from "../tools/assets/motions.mjs";
import { BLEND_MAX, BLEND_MIN, blendDuration, BONE_MAP, clipId, closeLoop, createRetargeter, faceForward,
headingOf, removeDrift, rigWorld, sampleTimes, seamAngle, SOURCE_HIPS } from "../tools/assets/motions.mjs";
const turn = (x, y, z, degrees) =>
new Quaternion().setFromAxisAngle(new Vector3(x, y, z).normalize(), MathUtils.degToRad(degrees));
@@ -116,7 +117,97 @@ describe("createRetargeter", () => {
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);
});
});