- 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>
362 lines
17 KiB
JavaScript
362 lines
17 KiB
JavaScript
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 }));
|
||
}
|