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:
1 parent
d30aae68a1
commit
182db238c6
2 files changed
+75
-13
No files matched your search
+46
-12
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user