import { AnimationClip, Box3, Quaternion, QuaternionKeyframeTrack, Vector3, VectorKeyframeTrack } from "three"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; // Übernommen aus three-player-controller/example/showcase.js (drei Figuren teilen sich // dasselbe 65-Bone-Skelett; Animation kommt immer von ual.glb, siehe retargetClips unten). // 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, 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, retarget: "absolute" }, ]; export const HAIRSTYLES = [ "Hair_Buzzed", "Hair_BuzzedFemale", "Hair_SimpleParted", "Hair_Long", "Hair_Buns", "Hair_Beard", ]; // Nur zur Breitenmessung (measureTorsoRadiusRatio): T-Pose-Arme sollen die Breite nicht verfälschen. const HITBOX_TORSO_BONES = ["pelvis", "spine_01", "spine_02", "spine_03"]; // Controller-Standard playerCapsuleHeight / playerCapsuleRadius = 180 / 30. const CAPSULE_HEIGHT_TO_RADIUS = 6; // Modellunabhängige Spielerkonfiguration, gemeinsam für Ersteinrichtung (main.js) und // Figurenwechsel (applyCharacter) — identisch zu PLAYER_MODEL_BASE in showcase.js, weil // alle drei Figuren von genau demselben ual.glb animiert werden. export const PLAYER_MODEL_BASE = { scale: 0.005, idleAnim: "Idle_Loop", walkAnim: "Walk_Loop", runAnim: "Sprint_Loop", jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"], flyAnim: "fly", flyIdleAnim: "flyIdle", flyHoverForwardAnim: "flyHoverForward", flyHoverBackAnim: "flyHoverBack", flyHoverLeftAnim: "flyHoverLeft", flyHoverRightAnim: "flyHoverRight", flyHoverUpAnim: "flyHoverUp", flyHoverDownAnim: "flyHoverDown", drivingAnim: "Driving_Loop", headBoneName: "Head", firstPersonCameraOffset: [0, 0.15, 0.12], rotateY: -Math.PI / 2, speed: 100, runSpeed: 600, }; // Records: Bone-Name -> { position, quaternion } zum Zeitpunkt des Ladens (vor dem ersten // mixer-Update aufgenommen). Unverändert aus showcase.js. export function collectRestPose(root) { const rest = new Map(); root?.traverse((obj) => { if (obj.isBone) rest.set(obj.name, { position: obj.position.clone(), quaternion: obj.quaternion.clone() }); }); return rest; } // Ändert sich die Spur über die Zeit? Unverändert aus showcase.js. export function trackVaries(track, stride) { const v = track.values; for (let i = stride; i < v.length; i++) { if (Math.abs(v[i] - v[i % stride]) > 1e-6) return true; } return false; } // Wiederverwendete Skalare für retargetClips, um pro Aufruf keine neuen Quaternion-Objekte // anzulegen (unverändert aus showcase.js, dort retargetTmp genannt). const retargetTmp = { corr: new Quaternion(), inv: new Quaternion(), q: new Quaternion() }; /** * Passt die Animationen von ual.glb an ein anderes Körperschema an. Der Blender-Export legt * allen 65 Bones Translation-/Scale-Spuren an; direktes Abspielen würde dem Zielmodell die * Bone-Positionen des Mannequins aufzwingen und Schulterbreite/Gliedmaßenlängen verzerren. * Deshalb bleiben nur Rotationen erhalten, konstante Verschiebung und alle Scale-Spuren * fallen weg; die einzige echt bewegte Spur (pelvis-Verschiebung) wird auf die Ziel-Ruhepose * umgerechnet. * * 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. * * `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, { rotation = "delta" } = {}) { if (!sourceRest) return clips; return clips.map((clip) => { const tracks = []; for (const track of clip.tracks) { const dot = track.name.lastIndexOf("."); const bone = track.name.slice(0, dot); const prop = track.name.slice(dot + 1); const from = sourceRest.get(bone); 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 // Zielskelett verdrehen, daher wird nur die Differenz zur Ruhepose übertragen. retargetTmp.corr.copy(to.quaternion).multiply(retargetTmp.inv.copy(from.quaternion).invert()); if (1 - Math.abs(retargetTmp.corr.w) < 1e-9) { tracks.push(track); continue; } const q = Float32Array.from(track.values); for (let i = 0; i + 3 < q.length; i += 4) { retargetTmp.q.set(q[i], q[i + 1], q[i + 2], q[i + 3]).premultiply(retargetTmp.corr); q[i] = retargetTmp.q.x; q[i + 1] = retargetTmp.q.y; q[i + 2] = retargetTmp.q.z; q[i + 3] = retargetTmp.q.w; } tracks.push(new QuaternionKeyframeTrack(track.name, track.times, q)); continue; } if (prop !== "position") continue; // scale wird immer verworfen if (!trackVaries(track, 3)) continue; // konstante Verschiebung ist Ruhepose, verwerfen if (!from || !to) { tracks.push(track); continue; } const values = Float32Array.from(track.values); for (let i = 0; i + 2 < values.length; i += 3) { values[i] += to.position.x - from.position.x; values[i + 1] += to.position.y - from.position.y; values[i + 2] += to.position.z - from.position.z; } tracks.push(new VectorKeyframeTrack(track.name, track.times, values)); } return new AnimationClip(clip.name, clip.duration, tracks); }); } /** * 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). `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 }, rotation = "delta") { if (!cache.has(name)) { cache.set(name, retargetClips(playerAnimations, sourceRestPose, collectRestPose(model), { rotation })); } return cache.get(name); } /** Erstes SkinnedMesh unter root, oder null. Unverändert aus showcase.js. */ function findSkinnedMesh(root) { let found = null; root?.traverse((obj) => { if (!found && obj.isSkinnedMesh) found = obj; }); return found; } function enableModelShadows(root) { root?.traverse((obj) => { if (obj.isMesh) { obj.castShadow = true; obj.receiveShadow = true; } }); } /** * Misst die tatsächliche halbe Rumpfbreite und rechnet sie in ein capsuleRadiusRatio um, das * zur Netzform passt. Das Modell steht in T-Pose; ein direkter Bounding-Box-Griff würde die * ausgestreckten Arme mit einrechnen, daher nur Vertices mit dominantem Rumpf-Bone-Gewicht. * Unverändert aus showcase.js. */ export function measureTorsoRadiusRatio(root) { const mesh = findSkinnedMesh(root); const pos = mesh?.geometry?.attributes?.position; const skinIndex = mesh?.geometry?.attributes?.skinIndex; const skinWeight = mesh?.geometry?.attributes?.skinWeight; if (!mesh || !pos || !skinIndex || !skinWeight) return 1; const torso = new Set(); mesh.skeleton?.bones.forEach((bone, i) => { if (HITBOX_TORSO_BONES.includes(bone.name)) torso.add(i); }); if (!torso.size) return 1; let halfWidth = 0; for (let v = 0; v < pos.count; v++) { let dominant = 0; let best = -1; for (let k = 0; k < 4; k++) { const w = skinWeight.getComponent(v, k); if (w > best) { best = w; dominant = skinIndex.getComponent(v, k); } } if (torso.has(dominant)) halfWidth = Math.max(halfWidth, Math.abs(pos.getX(v))); } if (halfWidth <= 0) return 1; root.updateMatrixWorld(true); const height = new Box3().setFromObject(root).getSize(new Vector3()).y; if (!(height > 0)) return 1; return CAPSULE_HEIGHT_TO_RADIUS * (halfWidth / height); } function loadGltfCached(state, url) { if (!state.gltfCache.has(url)) state.gltfCache.set(url, state.gltfLoader.loadAsync(url)); return state.gltfCache.get(url); } /** * Wechselt das Figurenmodell; die Animationen kommen immer von ual.glb (state.playerAnimations). * Unverändert aus showcase.js außer dem expliziten state-Objekt statt Modul-Globalen (siehe * clipsForModel) und dem Wegfall der showcase-eigenen Zonen-/Armjustierfunktionen, die es in * diesem Projekt nicht gibt (kein schrumpfender Korridor, keine Arm-Feinjustierung im Panel). */ export async function applyCharacter(state, name) { const entry = CHARACTERS.find((c) => c.name === name) ?? CHARACTERS[0]; const gltf = await loadGltfCached(state, entry.url); // Jedes Mal eine frische Kopie: der Controller skaliert das Modell direkt, dieselbe // Instanz wiederzuverwenden würde die Skalierung aufsummieren. const model = cloneSkeleton(gltf.scene); const radiusRatio = measureTorsoRadiusRatio(model); state.currentHeightFactor = state.characterHeights.get(entry.name) ?? entry.heightFactor ?? 1; await state.player.switchPlayerModel({ ...PLAYER_MODEL_BASE, model, animations: clipsForModel(entry.name, model, state, entry.retarget), scale: PLAYER_MODEL_BASE.scale * state.currentHeightFactor, capsuleRadiusRatio: radiusRatio, }); state.currentCharacterName = entry.name; enableModelShadows(state.player.playerModel); state.currentHairMesh = null; // altes Haar verschwindet mit dem alten Modell await applyHair(state, state.currentHairName); } /** * Wechselt die Frisur; Figur und Frisur teilen dasselbe Skelett mit identischer Bone- * Reihenfolge und lauter Einheitsmatrizen als Bind-Matrix, daher direkt bindbar. Unverändert * aus showcase.js außer dem expliziten state-Objekt (siehe applyCharacter). */ export async function applyHair(state, name) { state.currentHairName = name; if (state.currentHairMesh) { state.currentHairMesh.removeFromParent(); state.currentHairMesh = null; } if (name === HAIR_NONE) return; const gltf = await loadGltfCached(state, `${CHARACTER_DIR}${name}.gltf`); const hair = findSkinnedMesh(cloneSkeleton(gltf.scene)); const target = findSkinnedMesh(state.player.playerModel); if (!hair || !target) { console.warn("[characters] Frisur oder Figur ohne SkinnedMesh:", name); return; } hair.name = name; hair.position.set(0, 0, 0); hair.quaternion.identity(); hair.scale.set(1, 1, 1); target.parent.add(hair); hair.bind(target.skeleton, hair.bindMatrix); // Bounding-Box wird nach dem Neu-Binden ungültig; Frustum-Culling aus, sonst verschwindet // die Frisur fälschlich, sobald ihre alte Box aus dem Sichtfeld wandert. hair.frustumCulled = false; enableModelShadows(hair); state.currentHairMesh = hair; } /** * Baut den {switchCharacter, switchHair, applyHeight}-Zustand für einen bereits initialisierten * Player (Task 1). playerAnimations/sourceRestPose werden hier einmalig eingefroren: zum * Zeitpunkt des Aufrufs trägt player.playerModel noch das ursprüngliche ual.glb (Mannequin), * das gleichzeitig das Quellskelett für retargetClips ist. */ export function createCharacterModule(player) { const state = { player, playerAnimations: player.playerModelConfig?.animations ?? [], sourceRestPose: collectRestPose(player.playerModel), gltfLoader: new GLTFLoader(), gltfCache: new Map(), cache: new Map(), characterHeights: new Map(CHARACTERS.map((c) => [c.name, c.heightFactor])), currentCharacterName: CHARACTERS[0].name, currentHairName: HAIR_NONE, currentHairMesh: null, currentHeightFactor: 1, busy: false, }; // Das Startmodell durchläuft applyCharacter nie — ohne diesen Aufruf warf die Figur erst nach // dem ersten Figurenwechsel einen Schatten. enableModelShadows(player.playerModel); async function switchCharacter(name) { if (state.busy || name === state.currentCharacterName) return; state.busy = true; try { await applyCharacter(state, name); } catch (err) { console.error("[characters] Figurenwechsel fehlgeschlagen:", err); } finally { state.busy = false; } } async function switchHair(name) { if (state.busy || name === state.currentHairName) return; state.busy = true; try { await applyHair(state, name); } catch (err) { console.error("[characters] Frisurenwechsel fehlgeschlagen:", err); } finally { state.busy = false; } } /** Höhenfaktor der aktuellen Figur (0.7–1.3 im Panel), skaliert den Spieler direkt. */ function applyHeight(factor) { state.currentHeightFactor = factor; state.characterHeights.set(state.currentCharacterName, factor); player.setPlayerScale(PLAYER_MODEL_BASE.scale * factor); } return { switchCharacter, switchHair, applyHeight, state }; } /** * Wahrzeichen absteigend nach Höhe sortiert, mit three-Weltkoordinaten statt gtadb-XY * (worldToThree: x bleibt, z = -Y, s. src/world/coords.js). */ export function teleportTargets(landmarks) { return [...landmarks] .sort((a, b) => b.height - a.height) .map((l) => ({ name: l.name, x: l.xyz[0], z: -l.xyz[1], height: l.height })); }