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 { describe, expect, it } from "vitest";
|
||||||
import { MathUtils, Quaternion, Vector3 } from "three";
|
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) =>
|
const turn = (x, y, z, degrees) =>
|
||||||
new Quaternion().setFromAxisAngle(new Vector3(x, y, z).normalize(), MathUtils.degToRad(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);
|
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", () => {
|
it("verlangt einen unbewegten Elternknochen über der Hüfte", () => {
|
||||||
expect(() => createRetargeter(RIG, { boneMap: { ...MAP, Reference: "root" } })).toThrow(/darf nicht animiert sein/);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
//
|
//
|
||||||
// Das Zip bleibt außerhalb des Repos. Die Rechenschritte sind reine Funktionen (tests/motions.test.js), main()
|
// Das Zip bleibt außerhalb des Repos. Die Rechenschritte sind reine Funktionen (tests/motions.test.js), main()
|
||||||
// liest nur Dateien und schreibt das Ergebnis.
|
// liest nur Dateien und schreibt das Ergebnis.
|
||||||
import { Matrix4, Quaternion, Vector3 } from "three";
|
import { MathUtils, Matrix4, Quaternion, Vector3 } from "three";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MVN-Knochen -> Mannequin-Knochen, zugeordnet nach gemessener Höhe in der Ruhepose. Spine2 (1,187 m) hat kein
|
* MVN-Knochen -> Mannequin-Knochen, zugeordnet nach gemessener Höhe in der Ruhepose. Spine2 (1,187 m) hat kein
|
||||||
@@ -112,3 +112,75 @@ export function createRetargeter(rig, { boneMap = BONE_MAP, hipsScale = 1 } = {}
|
|||||||
return { local, hips };
|
return { local, hips };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SAMPLE_RATE = 30;
|
||||||
|
/** Überblendzeit am Schleifenende: Naht in Grad ÷ BLEND_SPEED (°/s), begrenzt auf BLEND_MIN bis BLEND_MAX Sekunden. */
|
||||||
|
export const BLEND_SPEED = 90;
|
||||||
|
export const BLEND_MIN = 0.25;
|
||||||
|
export const BLEND_MAX = 1.5;
|
||||||
|
|
||||||
|
/** n = round((end − start) · rate) + 1 gleichmäßig verteilte Zeitpunkte von start bis end, beide eingeschlossen. */
|
||||||
|
export function sampleTimes(start, end, rate = SAMPLE_RATE) {
|
||||||
|
if (!(end > start)) throw new Error(`Leerer Zeitraum ${start}–${end}`);
|
||||||
|
const count = Math.round((end - start) * rate) + 1;
|
||||||
|
return Array.from({ length: count }, (_, i) => start + ((end - start) * i) / (count - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Winkel des Vorwärtsvektors (+Z) einer Drehung gegen +Z in der Waagrechten, im Bogenmaß, positiv zu +X. */
|
||||||
|
export function headingOf(quaternion) {
|
||||||
|
const forward = new Vector3(0, 0, 1).applyQuaternion(quaternion);
|
||||||
|
return Math.atan2(forward.x, forward.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dreht alle Bilder um die Hochachse, bis die Hüfte im ersten Bild nach +Z schaut. */
|
||||||
|
export function faceForward(frames) {
|
||||||
|
const angle = -headingOf(frames[0].rotations.get(SOURCE_HIPS));
|
||||||
|
const turn = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), angle);
|
||||||
|
return frames.map((frame) => ({
|
||||||
|
rotations: new Map([...frame.rotations].map(([name, rotation]) => [name, turn.clone().multiply(rotation)])),
|
||||||
|
hips: frame.hips.clone().applyQuaternion(turn),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt die Hüfte im ersten Bild waagrecht auf 0 und zieht die Drift linear ab, sodass das letzte Bild dort endet. */
|
||||||
|
export function removeDrift(frames) {
|
||||||
|
const first = frames[0].hips;
|
||||||
|
const last = frames.at(-1).hips;
|
||||||
|
return frames.map((frame, i) => {
|
||||||
|
const along = frames.length > 1 ? i / (frames.length - 1) : 0;
|
||||||
|
const hips = frame.hips.clone();
|
||||||
|
hips.x -= first.x + along * (last.x - first.x);
|
||||||
|
hips.z -= first.z + along * (last.z - first.z);
|
||||||
|
return { rotations: frame.rotations, hips };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Größte Abweichung der Weltdrehung zwischen letztem und erstem Bild über alle Knochen, in Grad. */
|
||||||
|
export function seamAngle(frames) {
|
||||||
|
const last = frames.at(-1).rotations;
|
||||||
|
let largest = 0;
|
||||||
|
for (const [name, rotation] of frames[0].rotations) largest = Math.max(largest, rotation.angleTo(last.get(name)));
|
||||||
|
return MathUtils.radToDeg(largest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blendDuration(seamDegrees) {
|
||||||
|
return MathUtils.clamp(seamDegrees / BLEND_SPEED, BLEND_MIN, BLEND_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schließt die Schleife: In den letzten `blend` Sekunden werden lokale Drehungen (slerp) und die Hüfte (lerp) mit
|
||||||
|
* smoothstep-Gewicht zum ersten Bild gezogen. Das letzte Bild gleicht danach dem ersten. poses: [{ local, hips }].
|
||||||
|
*/
|
||||||
|
export function closeLoop(times, poses, blend) {
|
||||||
|
const end = times.at(-1);
|
||||||
|
const first = poses[0];
|
||||||
|
return poses.map((pose, i) => {
|
||||||
|
if (times[i] <= end - blend) return pose;
|
||||||
|
const weight = MathUtils.smoothstep(times[i], end - blend, end);
|
||||||
|
return {
|
||||||
|
local: new Map([...pose.local].map(([name, rotation]) =>
|
||||||
|
[name, rotation.clone().slerp(first.local.get(name), weight)])),
|
||||||
|
hips: pose.hips.clone().lerp(first.hips, weight),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user