feat(assets): Blickrichtung, Drift und Schleifennaht der Mocap-Clips
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
070bb79593
commit
aa6cde79d3
2 files changed
+165
-2
No files matched your search
+92
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user