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

+73 -1
View File
@@ -6,7 +6,7 @@
//
// 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";
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
@@ -112,3 +112,75 @@ export function createRetargeter(rig, { boneMap = BONE_MAP, hipsScale = 1 } = {}
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),
};
});
}