Files
gta6/src/characters/index.js
T
knsoandClaude Opus 5 f76e97b69d feat(world): HDRI-Himmel „Nowhere Road" mit Umgebungslicht und scharfen Kaskaden-Schatten
- Himmel: 4k-HDRI von Poly Haven (CC0, tools/assets/hdri.mjs) statt Sky-Shader. Kuppel zeigt das Foto
  ab 12°, darunter weiches Band und Dunst in Nebelfarbe; Umgebungslicht und Spiegelungen per PMREM;
  Sonne aus dem Bild gekappt und als Licht übernommen (77°, Stärke 2,5)
- Schatten: CSM mit vier Stufen (25/100/350/1000 m, je 2048²), Schutznetz für beleuchtete Materialien,
  Normal-Bias 1,5 Texel je Stufe gegen Streifen auf doppelseitigen Materialien (Figur, Autos)
- Startfigur wirft Schatten, Palmen und Laternen empfangen Schatten, Wasser als ruhige See
- Debug-Panel: Sonne, Himmelslicht, Himmelshelligkeit; Namensnennung um das HDRI ergänzt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:38:44 +02:00

362 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }));
}