fix: Retargeting auf absolute Rotation umgestellt (Ruling 67)

Beide Verfahren an allen drei Figuren durchprobiert statt vermutet:
Delta hielt Superhero Male/Female bei Idle_Loop und Walk_Loop in voller
T-Pose (upperarm_l weicht 70° von ual.glbs eigener Ruhepose ab, gegen
den A_TPose-Referenzclip gemessen — die Ruhepose-Differenz ist selbst
schon eine Pose, nicht der Bauartunterschied im Gradbereich, für den
Delta gedacht ist). Absolute liefert für beide einen sauberen Gang ohne
Schulterverdrehung; für Mannequin sind Delta und Absolute ohnehin
identisch (sourceRest===targetRest). Ein Verfahren trägt für alle drei.

retargetClips() behält "delta" als Voreinstellung (die im Auftrag
wörtlich vorgegebene Formel, weiterhin getestet); CHARACTERS trägt das
Messergebnis für dieses Projekt ein.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-13 03:40:24 +02:00
1 parent d30aae68a1
commit 182db238c6
2 files changed
+75 -13

No files matched your search

+46 -12
View File
@@ -8,12 +8,23 @@ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
// Pfade an dieses Projekts public/-Layout angepasst (dort ./glb/…, hier ./models/…).
export const CHARACTER_DIR = "./models/characters/";
export const HAIR_NONE = "None";
// retarget: "absolute" für alle drei (Fix-Runde 1, Ruling 67) — durchprobiert statt vermutet.
// Delta hielt bei Idle/Walk beide Quaternius-Figuren in praktisch voller T-Pose (upperarm_l
// weicht 70° von der eigenen ual.glb-Ruhepose ab, gegen den A_TPose-Referenzclip gemessen;
// jede andere Spur liegt bei 0–34°, siehe task-11-report.md) — kein "kaputtes" Retargeting im
// Sinne eines Rechenfehlers, sondern die falsche Referenzpose für dieses Quellrigg. Absolute
// zeigt für Superhero Male UND Female einen sauberen Gang ohne Schulterverdrehung; da
// Mannequin sourceRest===targetRest hat (ual.glb liefert sich selbst), ist Delta==Absolute
// für Mannequin ohnehin dieselbe Rechnung — ein Verfahren trägt also für alle drei, keine
// Fallunterscheidung nötig. retargetClips() selbst behält "delta" als Voreinstellung (siehe
// dort und tests/characters.test.js), weil das die im Auftrag vorgegebene, wörtlich zu
// übernehmende Formel ist — hier wird sie nur nicht mehr für diese drei Figuren gezogen.
export const CHARACTERS = [
{ name: "Mannequin", url: "./models/ual.glb", heightFactor: 1 },
{ name: "Superhero Male", url: `${CHARACTER_DIR}Superhero_Male_FullBody.gltf`, heightFactor: 1 },
{ name: "Mannequin", url: "./models/ual.glb", heightFactor: 1, retarget: "absolute" },
{ name: "Superhero Male", url: `${CHARACTER_DIR}Superhero_Male_FullBody.gltf`, heightFactor: 1, retarget: "absolute" },
// Erwachsene Frauen sind im Schnitt ca. 93% der Körpergröße von Männern; die im Modell
// eingebauten 97,5% Unterschied fallen kaum auf (Kommentar aus showcase.js übernommen).
{ name: "Superhero Female", url: `${CHARACTER_DIR}Superhero_Female_FullBody.gltf`, heightFactor: 0.93 },
{ name: "Superhero Female", url: `${CHARACTER_DIR}Superhero_Female_FullBody.gltf`, heightFactor: 0.93, retarget: "absolute" },
];
export const HAIRSTYLES = [
"Hair_Buzzed",
@@ -86,12 +97,32 @@ const retargetTmp = { corr: new Quaternion(), inv: new Quaternion(), q: new Quat
*
* Anders als in showcase.js (wo sourceRest ein Modul-Global war) nimmt diese Fassung die
* Quellruhepose als expliziten dritten Parameter — sonst ließe sich die Funktion nicht ohne
* versteckten globalen Zustand testen/exportieren. Die Rechnung selbst (q_new = R_target ·
* R_source⁻¹ · q_anim, konstante Verschiebung und Scale verworfen) ist unverändert: am realen
* Modell zeigte sich, dass ein direktes Übernehmen der absoluten Rotation die Schultern des
* weiblichen Modells sichtbar verdreht (siehe task-11-brief.md) — deshalb hier nicht vereinfachen.
* versteckten globalen Zustand testen/exportieren.
*
* `rotation` (Fix-Runde 1, Ruling 67) wählt, wie die Rotationsspuren behandelt werden:
* - "delta" (Voreinstellung): q_new = R_target · R_source⁻¹ · q_anim — überträgt nur die
* Differenz zur jeweiligen Ruhepose. Passend, wenn beide Ruheposen dieselbe Körperhaltung
* meinen und nur in Feinheiten der Bauart auseinanderliegen (Größenordnung Grad, nicht Pose).
* - "absolute": q_new = q_anim — übernimmt die animierte Rotation unverändert, ganz ohne
* Ruhepose-Referenz. Passend, wenn Quelle und Ziel dasselbe Bone-lokale Achsensystem teilen,
* ihre Ruheposen sich aber als *unterschiedliche Körperhaltungen* unterscheiden: ual.glb
* hängt in seiner Ruhepose die Arme (siehe A_TPose-Clip: 70° Abstand an upperarm_l zur
* eigenen Ruhepose, s. task-11-report.md), der Quaternius-Rig steht darin in T. In diesem
* Fall hält "delta" per Konstruktion die Ziel-Ruhepose fest, sobald die Animation nahe an
* der Quell-Ruhepose liegt (q_anim≈R_source ⇒ q_new≈R_target) — sichtbar als praktisch
* eingefrorene T-Pose, nicht als kaputtes Retargeting im Sinne von "Winkel falsch", sondern
* als falsche Referenzpose. Bei einem früheren Modellsatz (andere Quellanimation, s.
* historische Notiz in task-11-brief.md) brach die absolute Übernahme umgekehrt der
* weiblichen Figur die Schultern — dort lieferte offenbar ein anderes Skelett mit eigenem
* Achsensystem die Animationen. Für ual.glb + die beiden Quaternius-Figuren wurde das
* nachgeprüft (Idle_Loop und Walk_Loop von Hand angetrieben, Mixer-Gewicht explizit gesetzt,
* um den eingefrorenen Vorschau-Loop zu umgehen — s. Screenshots r67-05 bis r67-10 im
* Bericht): keine Schulterverdrehung, sauberer Gang für beide. Welches Verfahren trägt, ist
* pro Quelle/Ziel-Paar eine Messfrage, keine Vermutung — CHARACTERS unten trägt das
* Messergebnis für dieses Projekt ein, nicht diese Funktion (deren Voreinstellung "delta"
* bleibt, weil das die im Auftrag wörtlich vorgegebene Formel ist, s. Tests).
*/
export function retargetClips(clips, sourceRest, targetRest) {
export function retargetClips(clips, sourceRest, targetRest, { rotation = "delta" } = {}) {
if (!sourceRest) return clips;
return clips.map((clip) => {
const tracks = [];
@@ -104,6 +135,7 @@ export function retargetClips(clips, sourceRest, targetRest) {
const to = targetRest.get(bone);
if (prop === "quaternion") {
if (rotation === "absolute") { tracks.push(track); continue; }
if (!from || !to) { tracks.push(track); continue; }
// Die beiden Skelette unterscheiden sich in der Ruheausrichtung (Schlüsselbein
// 9,5°, Nacken 23°) — eine direkt übernommene absolute Rotation würde das
@@ -140,11 +172,13 @@ export function retargetClips(clips, sourceRest, targetRest) {
* Auf ein Modell angepasste Animationen, je Figur zwischengespeichert. `cache` und die
* Quelldaten (playerAnimations/sourceRestPose) kommen explizit herein statt aus Modul-Globalen
* (showcase.js hielt sie als `let` auf Skriptebene — hier ist die Funktion ein benannter
* Export und braucht darum einen expliziten, von außen kontrollierten Zustand).
* Export und braucht darum einen expliziten, von außen kontrollierten Zustand). `rotation`
* reicht die Wahl aus retargetClips durch (Fix-Runde 1, Ruling 67) — je Figur festgelegt in
* CHARACTERS, gemessen statt geraten (s. task-11-report.md).
*/
export function clipsForModel(name, model, { playerAnimations, sourceRestPose, cache }) {
export function clipsForModel(name, model, { playerAnimations, sourceRestPose, cache }, rotation = "delta") {
if (!cache.has(name)) {
cache.set(name, retargetClips(playerAnimations, sourceRestPose, collectRestPose(model)));
cache.set(name, retargetClips(playerAnimations, sourceRestPose, collectRestPose(model), { rotation }));
}
return cache.get(name);
}
@@ -221,7 +255,7 @@ export async function applyCharacter(state, name) {
await state.player.switchPlayerModel({
...PLAYER_MODEL_BASE,
model,
animations: clipsForModel(entry.name, model, state),
animations: clipsForModel(entry.name, model, state, entry.retarget),
scale: PLAYER_MODEL_BASE.scale * state.currentHeightFactor,
capsuleRadiusRatio: radiusRatio,
});