From 182db238c6564383eed08d09e4f1b9481b97d21a Mon Sep 17 00:00:00 2001 From: KNSO Date: Sun, 13 Sep 2026 03:40:24 +0200 Subject: [PATCH] fix: Retargeting auf absolute Rotation umgestellt (Ruling 67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/characters/index.js | 58 +++++++++++++++++++++++++++++++--------- tests/characters.test.js | 30 ++++++++++++++++++++- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/characters/index.js b/src/characters/index.js index 68299db..daa3a53 100644 --- a/src/characters/index.js +++ b/src/characters/index.js @@ -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, }); diff --git a/tests/characters.test.js b/tests/characters.test.js index b72c12f..ac1ea1c 100644 --- a/tests/characters.test.js +++ b/tests/characters.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { AnimationClip, Quaternion, QuaternionKeyframeTrack, VectorKeyframeTrack } from "three"; -import { retargetClips, teleportTargets } from "../src/characters/index.js"; +import { CHARACTERS, retargetClips, teleportTargets } from "../src/characters/index.js"; const rest = (q) => new Map([["arm", { position: { x: 0, y: 1, z: 0 }, quaternion: q }]]); @@ -24,6 +24,34 @@ describe("retargetClips", () => { const v = out[0].tracks[0].values; expect(new Quaternion(v[0], v[1], v[2], v[3]).angleTo(dst)).toBeLessThan(1e-6); }); + + // Fix-Runde 1, Ruling 67: ual.glb hängt die Arme, der Quaternius-Rig steht in T (70° an + // upperarm_l, s. task-11-report.md) — die Ruhepose-Differenz ist damit selbst schon fast + // eine ganze Pose, nicht die paar Grad Bauartunterschied, für die "Delta" gedacht ist. Im + // absoluten Modus wird die animierte Rotation unverändert übernommen (q_neu = q_anim), + // ohne jede Ruhepose-Referenz — das Gegenstück zu "relativ zur Ruhepose" oben. + it("übernimmt Rotationen im absoluten Modus unverändert (Ruling 67)", () => { + const src = new Quaternion().setFromAxisAngle({ x: 0, y: 1, z: 0 }, 0.3); + const dst = new Quaternion().setFromAxisAngle({ x: 0, y: 1, z: 0 }, 0.9); + const clip = new AnimationClip("a", 1, [ + new QuaternionKeyframeTrack("arm.quaternion", [0], [src.x, src.y, src.z, src.w]), + ]); + const out = retargetClips([clip], rest(src), rest(dst), { rotation: "absolute" }); + const v = out[0].tracks[0].values; + expect(new Quaternion(v[0], v[1], v[2], v[3]).angleTo(src)).toBeLessThan(1e-6); + }); +}); + +// Fix-Runde 1, Ruling 67: durchprobiert statt vermutet — Delta hielt Superhero Male/Female +// bei Idle_Loop UND Walk_Loop in praktisch voller T-Pose (Screenshots r67-03..r67-04 im +// Bericht), Absolute zeigte für beide einen sauberen Gang ohne Schulterverdrehung +// (r67-05..r67-10), und für Mannequin sind beide Verfahren ohnehin identisch (sourceRest === +// targetRest, da ual.glb sich selbst liefert). Ein Verfahren trägt für alle drei — dieser Test +// hält das Messergebnis fest, damit ein künftiger Umbau nicht versehentlich zu Delta zurückfällt. +describe("CHARACTERS", () => { + it("verwendet absolute Rotation für alle drei Figuren (gemessen, nicht vermutet — Ruling 67)", () => { + for (const c of CHARACTERS) expect(c.retarget).toBe("absolute"); + }); }); describe("teleportTargets", () => {