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>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-13 15:38:44 +02:00
1 parent 034b12537a
commit f76e97b69d
15 files changed
+762 -41

No files matched your search

+17 -2
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { AnimationClip, Quaternion, QuaternionKeyframeTrack, VectorKeyframeTrack } from "three";
import { CHARACTERS, retargetClips, teleportTargets } from "../src/characters/index.js";
import { AnimationClip, BoxGeometry, Group, Mesh, MeshStandardMaterial, Quaternion, QuaternionKeyframeTrack,
VectorKeyframeTrack } from "three";
import { CHARACTERS, createCharacterModule, retargetClips, teleportTargets } from "../src/characters/index.js";
const rest = (q) => new Map([["arm", { position: { x: 0, y: 1, z: 0 }, quaternion: q }]]);
@@ -64,3 +65,17 @@ describe("teleportTargets", () => {
expect(targets[0].z).toBe(-20);
});
});
describe("createCharacterModule", () => {
// Befund (Schatten-Spec): enableModelShadows lief nur in applyCharacter — das Startmodell
// (ual.glb, Mannequin) warf bis zum ersten Figurenwechsel keinen Schatten.
it("schaltet Schatten schon für das Startmodell ein", () => {
const body = new Mesh(new BoxGeometry(), new MeshStandardMaterial());
const playerModel = new Group().add(body);
createCharacterModule({ playerModel, playerModelConfig: { animations: [] } });
expect(body.castShadow).toBe(true);
expect(body.receiveShadow).toBe(true);
});
});
+6
View File
@@ -25,6 +25,12 @@ describe("buildAttribution", () => {
expect(text).toMatch(/Quaternius[^.]*CC0/);
});
// Himmel und Umgebungslicht kommen aus dem HDRI "Nowhere Road" (src/world/sky.js) — eine Quelle
// mehr, die wie die Texturen mit Herkunft und Lizenz im selben Satz stehen soll.
it("nennt das HDRI mit Poly Haven und CC0", () => {
expect(buildAttribution(manifest)).toMatch(/HDRI[^.]*Poly Haven[^.]*CC0/);
});
// Ruling (task-11-brief.md): keine Lizenz kürzen, keine zwei Lizenzen in einer Zeile
// vermischen, kein CC0 für etwas behaupten, das CC BY ist — der SUV-Satz muss darum
// isoliert bleiben und darf nicht in denselben Satz wie ein CC0-Eintrag rutschen.
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import { BoxGeometry, Color, Line, LineBasicMaterial, Mesh, MeshBasicMaterial, MeshStandardMaterial,
PerspectiveCamera, Scene, ShaderMaterial, Vector3 } from "three";
import { CSM } from "three/examples/jsm/csm/CSM.js";
import { cascadeBreaks, createSunShadows, prepareLitMaterials } from "../src/world/shadows.js";
describe("cascadeBreaks", () => {
// Bruch: Meter nicht durch far geteilt (CSM erwartet Anteile) oder letzte Grenze nicht 1 —
// dann endete die letzte Stufe vor far und ferne Gebäude verlören ihren Schatten.
it("rechnet Meter in Anteile von far um, letzte Grenze immer 1", () => {
expect(cascadeBreaks([25, 100, 350, 1000], 1000)).toEqual([0.025, 0.1, 0.35, 1]);
});
it("kappt Grenzen jenseits von far auf 1", () => {
expect(cascadeBreaks([25, 100, 350, 1000], 200)).toEqual([0.125, 0.5, 1, 1]);
});
});
describe("prepareLitMaterials", () => {
const newCsm = () => new CSM({ camera: new PerspectiveCamera(55, 16 / 9, 0.5, 2500), parent: new Scene() });
// Bruch: ein beleuchtetes Material bleibt ohne USE_CSM — es summiert dann alle vier
// Stufenlichter und leuchtet viermal zu hell; oder unbeleuchtete Materialien (Himmel, Linien)
// werden unnötig umgebaut.
it("richtet beleuchtete Materialien ein, auch in Material-Arrays, unbeleuchtete nicht", () => {
const csm = newCsm();
const root = new Scene();
const lit = new MeshStandardMaterial();
const litInArray = new MeshStandardMaterial();
const basic = new MeshBasicMaterial();
const shader = new ShaderMaterial();
const line = new LineBasicMaterial();
root.add(new Mesh(new BoxGeometry(), lit), new Mesh(new BoxGeometry(), [litInArray, basic]),
new Mesh(new BoxGeometry(), shader), new Line(new BoxGeometry(), line));
const count = prepareLitMaterials(root, csm);
expect(count).toBe(2);
expect(lit.defines.USE_CSM).toBe(1);
expect(litInArray.defines.USE_CSM).toBe(1);
expect(basic.defines?.USE_CSM).toBeUndefined();
expect(shader.defines?.USE_CSM).toBeUndefined();
expect(line.defines?.USE_CSM).toBeUndefined();
});
// Bruch: jedes Frame neu einrichten — needsUpdate zwänge three.js dann in jedem Frame zum
// Neuübersetzen aller Shader.
it("richtet ein Material nur einmal ein", () => {
const csm = newCsm();
const root = new Scene();
root.add(new Mesh(new BoxGeometry(), new MeshStandardMaterial()));
prepareLitMaterials(root, csm);
expect(prepareLitMaterials(root, csm)).toBe(0);
});
});
describe("createSunShadows", () => {
const setup = () => {
const scene = new Scene();
const camera = new PerspectiveCamera(55, 16 / 9, 0.5, 2500);
const sun = { direction: new Vector3(0, 0.8, 0.6), color: new Color(1, 0.9, 0.8), intensity: 2.5 };
return { scene, camera, sun, shadows: createSunShadows({ scene, camera, sun }) };
};
// Bruch: Vorzeichen vertauscht — CSM erwartet die Laufrichtung des Lichts, sky.js liefert die
// Richtung zur Sonne; falsch herum käme das Licht von unten.
it("stellt jedes Stufenlicht nach update() in Richtung der Sonne", () => {
const { shadows } = setup();
shadows.update();
expect(shadows.csm.lights).toHaveLength(4);
for (const light of shadows.csm.lights) {
const toLight = light.position.clone().sub(light.target.position).normalize();
expect(toLight.x).toBeCloseTo(0, 5);
expect(toLight.y).toBeCloseTo(0.8, 5);
expect(toLight.z).toBeCloseTo(0.6, 5);
}
});
// Bruch: der Regler "Sonne" träfe nur ein Stufenlicht — dann wechselte die Helligkeit mit der
// Entfernung sprunghaft an den Stufengrenzen.
it("gibt allen Stufenlichtern Farbe und Stärke der Sonne, auch nach dem Umstellen", () => {
const { shadows } = setup();
for (const light of shadows.csm.lights) {
expect(light.color.getHex()).toBe(new Color(1, 0.9, 0.8).getHex());
expect(light.intensity).toBe(2.5);
}
shadows.intensity = 4;
expect(shadows.csm.lights.map((light) => light.intensity)).toEqual([4, 4, 4, 4]);
expect(shadows.intensity).toBe(4);
});
// Bruch: eingebaute Aufteilung statt eigener Grenzen — die erste Stufe reichte bis ~127 m und
// wäre ~270 m breit, die Figur bekäme nur 13 cm pro Texel.
it("macht die erste Stufe so schmal, dass ein Texel etwa 2,6 cm misst", () => {
// Kamera-far-Ecke auf maxFar = 1000 m: x = 1000 * tan 27,5° * 16/9 = 925,46, y = 520,57;
// near-Ecke (0,5 m): x = 0,4627, y = 0,2603. Anteil 0,025 dazwischen: x = 23,588, y = 13,268.
// Diagonale der Stufenfernebene: 2 * sqrt(23,588² + 13,268²) = 54,13 m -> / 2048 = 2,64 cm.
const { shadows } = setup();
const cam = shadows.csm.lights[0].shadow.camera;
expect(cam.right - cam.left).toBeCloseTo(54.13, 1);
});
// Bruch: Schutznetz nur einmal beim Aufbau — nachgeladene Chunks, Autos und Figuren wären
// viermal zu hell.
it("richtet in update() auch nachträglich hinzugefügte Materialien ein", () => {
const { scene, shadows } = setup();
const material = new MeshStandardMaterial();
scene.add(new Mesh(new BoxGeometry(), material));
shadows.update();
expect(material.defines.USE_CSM).toBe(1);
});
// Befund (Nutzer-Screenshot): feine Streifen auf Figur und Auto. Doppelseitige Materialien
// schreiben ihre Vorderseiten in die Schattenkarte und verschatten sich ohne Versatz selbst.
// Bruch: Normal-Bias 0 (Streifen) oder fest statt je Stufe (ferne Stufen mit 1-m-Texeln
// behielten ihre Akne) oder nach einer Projektionsänderung nicht nachgeführt.
it("versetzt jede Stufe um 1,5 Texel ihrer Schattenbox entlang der Normalen, auch nach Nachführung", () => {
// Stufe 1 bei 16:9: 54,13 m / 2048 * 1,5 = 0,03965 m; bei Seitenverhältnis 2: 59,34 m / 2048 * 1,5 = 0,04346 m.
const { camera, shadows } = setup();
expect(shadows.csm.lights[0].shadow.normalBias).toBeCloseTo(0.03965, 4);
camera.aspect = 2;
camera.updateProjectionMatrix();
shadows.update();
expect(shadows.csm.lights[0].shadow.normalBias).toBeCloseTo(0.04346, 4);
for (const light of shadows.csm.lights) {
const texel = (light.shadow.camera.right - light.shadow.camera.left) / light.shadow.mapSize.x;
expect(light.shadow.normalBias).toBeCloseTo(1.5 * texel, 6);
}
});
// Bruch: Stufen bleiben nach Fenstergrößenänderung auf dem alten Seitenverhältnis — ein Teil
// des Bildes läge dann außerhalb jeder Schattenbox.
it("führt die Stufen nach, wenn sich das Seitenverhältnis ändert", () => {
// Seitenverhältnis 2: x-far = 1000 * 0,52057 * 2 = 1041,14, x-near = 0,5206; Anteil 0,025
// -> x = 26,536, y = 13,268 -> Diagonale 2 * sqrt(26,536² + 13,268²) = 59,34 m.
const { camera, shadows } = setup();
camera.aspect = 2;
camera.updateProjectionMatrix();
shadows.update();
const cam = shadows.csm.lights[0].shadow.camera;
expect(cam.right - cam.left).toBeCloseTo(59.34, 1);
});
});
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect, it } from "vitest";
import { DataUtils } from "three";
import { HDRLoader } from "three/examples/jsm/loaders/HDRLoader.js";
import { extractSun, horizonBand, rowColor, sunLight, toneMapToDisplay } from "../src/world/sky.js";
// Winziges Radiance-Panorama, flach ohne Lauflängenkodierung geschrieben und durch den echten
// HDRLoader gelesen — genau der Weg, auf dem sky.js das HDRI bekommt (Half-Float, Datenzeile 0
// = oberste Dateizeile). Wert je Kanal laut HDRLoader: byte * 2^(e-128) / 255.
function hdrFixture(width, height, pixel) {
const header = `#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y ${height} +X ${width}\n`;
const bytes = new Uint8Array(header.length + width * height * 4);
for (let i = 0; i < header.length; i++) bytes[i] = header.charCodeAt(i);
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
bytes.set(pixel(row, col), header.length + (row * width + col) * 4);
}
}
return new HDRLoader().parse(bytes.buffer);
}
const SKY = [26, 26, 26, 128]; // 26/255 = 0,102 je Kanal
const SUN = [255, 255, 255, 136]; // 255 * 256/255 = 256 je Kanal
describe("extractSun", () => {
// Bruch, den der Test fängt: Zeilen von unten gelesen (wie bei EXRLoader) oder Azimut
// gespiegelt — das Sonnenlicht käme dann von woanders als die sichtbare Sonne.
it("liest die Richtung wie equirectUv in three.js: erste Dateizeile oben, Spalte 4 von 8 nach +z", () => {
// 8x4: Zeile 1 liegt bei 90° - 1,5/4 * 180° = 22,5° Höhe, Spalte 4 bei (4,5/8 - 0,5) * 360°
// = 22,5° Azimut. Richtung = (cos²22,5°, sin 22,5°, cos 22,5° * sin 22,5°).
const { data, width, height } = hdrFixture(8, 4, (row, col) => (row === 1 && col === 4 ? SUN : SKY));
const sun = extractSun(data, width, height, 50);
expect(sun.direction.x).toBeCloseTo(0.85355, 4);
expect(sun.direction.y).toBeCloseTo(0.38268, 4);
expect(sun.direction.z).toBeCloseTo(0.35355, 4);
});
// Sonnenpixel (Zeile 1, Spalte 4), e=134 -> Faktor 64/255: R 64, G 57,7, B 25,1 — B liegt unter
// der Schwelle 50. Kleiner Exponent mit Absicht: toHalfFloat schneidet die Mantisse ab, zwischen
// 32 und 64 in Stufen von 1/32 statt 1/8 wie zwischen 128 und 256.
const WARM_SUN = [255, 230, 100, 134];
const sunAt = (row, col) => (row === 1 && col === 4 ? WARM_SUN : SKY);
const SUN_INDEX = (1 * 8 + 4) * 4;
// Bruch: Sonne bleibt im Bild (sie hellte dann über das Umgebungslicht auch jeden Schatten auf)
// oder ein Kanal unter der Schwelle wird mit auf die Schwelle gehoben.
it("kappt nur die Kanäle über der Schwelle, und zwar im Bild selbst", () => {
const { data, width, height } = hdrFixture(8, 4, sunAt);
extractSun(data, width, height, 50);
expect(DataUtils.fromHalfFloat(data[SUN_INDEX])).toBe(50);
expect(DataUtils.fromHalfFloat(data[SUN_INDEX + 1])).toBe(50);
expect(DataUtils.fromHalfFloat(data[SUN_INDEX + 2])).toBeCloseTo(25.1, 1);
expect(DataUtils.fromHalfFloat(data[0])).toBeCloseTo(0.102, 3);
});
// Bruch: Energie ohne Raumwinkel summiert, Schwelle nicht abgezogen, oder ein Kanal unter der
// Schwelle zieht negativ ab — das Sonnenlicht hätte dann die falsche Stärke oder Farbe.
it("gibt die abgezogene Energie als Einstrahlung zurück: Überschuss mal Raumwinkel des Pixels", () => {
// Raumwinkel eines Pixels in Zeile 1 von 4 bei 8 Spalten: cos 22,5° * (π/4) * (2π/8) = 0,56989 sr.
// Überschuss über 50: R 14, G 7,73, B 0 -> 7,98 / 4,40 / 0.
const { data, width, height } = hdrFixture(8, 4, sunAt);
const sun = extractSun(data, width, height, 50);
expect(sun.irradiance[0]).toBeCloseTo(7.98, 1);
expect(sun.irradiance[1]).toBeCloseTo(4.40, 1);
expect(sun.irradiance[2]).toBe(0);
});
// Bruch: ungewichteter Schwerpunkt — die schwachen Pixel am Rand (Linsenstrahlen um die echte
// Sonne) zögen die Lichtrichtung dann genauso stark zu sich wie der helle Kern.
it("gewichtet die Richtung mit der abgezogenen Energie", () => {
// Zeile 1: Spalte 3 (Azimut -22,5°) mit 64 -> Überschuss 14, Spalte 4 (+22,5°) mit 256 -> 206.
// Summe: x = cos²22,5° * 220 = 187,78, y = sin 22,5° * 220 = 84,19,
// z = cos 22,5° * sin 22,5° * (206 - 14) = 67,88; Länge 216,70 -> (0,86656, 0,38851, 0,31326).
const { data, width, height } = hdrFixture(8, 4, (row, col) => {
if (row === 1 && col === 3) return [255, 255, 255, 134];
if (row === 1 && col === 4) return SUN;
return SKY;
});
const sun = extractSun(data, width, height, 50);
expect(sun.direction.x).toBeCloseTo(0.86656, 4);
expect(sun.direction.y).toBeCloseTo(0.38851, 4);
expect(sun.direction.z).toBeCloseTo(0.31326, 4);
});
// Bruch: Ein Panorama ohne Sonne liefert still eine Nullrichtung — updateShadow() setzte das
// Licht dann genau auf den Spieler, und die Schattenkamera blickte in keine Richtung.
it("wirft, wenn kein Pixel über der Schwelle liegt", () => {
const { data, width, height } = hdrFixture(8, 4, () => SKY);
expect(() => extractSun(data, width, height, 50)).toThrow(/Sonne/);
});
});
describe("rowColor", () => {
// Bruch: Zeilen von unten gezählt oder die falsche Zeile gewählt — die Nebelfarbe käme dann aus
// dem Boden des Fotos oder aus dem Zenit statt aus dem Himmel knapp über dem Horizont.
it("mittelt die Zeile, deren Mitte der gewünschten Höhe am nächsten liegt", () => {
// 8x4, Zeilenmitten bei 67,5° / 22,5° / -22,5° / -67,5°; 20° -> Zeile 1. Bei e=128 gilt byte/255:
// Zeile 1 halb (1; 0,2; 0), halb (0,2; 1; 0,4) -> Mittel (0,6; 0,6; 0,2).
const { data, width, height } = hdrFixture(8, 4, (row, col) => {
if (row === 0) return [0, 0, 255, 128];
if (row === 1) return col < 4 ? [255, 51, 0, 128] : [51, 255, 102, 128];
return [0, 255, 0, 128];
});
const color = rowColor(data, width, height, 20);
expect(color[0]).toBeCloseTo(0.6, 3);
expect(color[1]).toBeCloseTo(0.6, 3);
expect(color[2]).toBeCloseTo(0.2, 3);
});
});
describe("horizonBand", () => {
// Bruch: Fenster wickelt nicht über Azimut ±180° (an der Bildnaht der Kuppel entstünde ein
// Helligkeitssprung) oder mittelt über die falsche Zeile / falsche Breite — Wolken zögen dann
// wieder als harte Streifen zum Horizont.
it("mittelt die Zeile je Azimut über ±radiusDeg, über die Bildnaht hinweg", () => {
// 8x4, Zeile 1 (22,5°): nur Spalte 0 hell (1,0). Pixel liegen 45° auseinander, ±50° erfasst
// also Nachbar links, sich selbst, Nachbar rechts. Texel 0 und 1 sehen Spalte 0, Texel 7
// ebenfalls — über die Naht (Spalten 6, 7, 0). Wert je Treffer 1/3, sonst 0; Alpha 1.
const { data, width, height } = hdrFixture(8, 4, (row, col) =>
(row === 1 && col === 0 ? [255, 255, 255, 128] : [0, 0, 0, 0]));
const band = horizonBand(data, width, height, 22.5, 50, 8);
const red = Array.from({ length: 8 }, (_, texel) => band[texel * 4]);
expect(red.map((v) => +v.toFixed(4))).toEqual([0.3333, 0.3333, 0, 0, 0, 0, 0, 0.3333]);
expect(band[3]).toBe(1);
});
});
describe("sunLight", () => {
// Bruch: Farbe nicht normiert (Licht doppelt so stark) oder auf den hellsten Kanal statt auf die
// Helligkeit normiert — dann hieße "Sonne 1" im Panel je nach Farbton etwas anderes.
it("teilt die Einstrahlung in eine Farbe mit Helligkeit 1 und die Helligkeit als Stärke", () => {
// (2, 1, 1): Helligkeit 0,2126 * 2 + 0,7152 + 0,0722 = 1,2126 -> Farbe (1,64935; 0,82467; 0,82467).
const light = sunLight([2, 1, 1]);
expect(light.intensity).toBeCloseTo(1.2126, 4);
expect(light.color[0]).toBeCloseTo(1.64935, 4);
expect(light.color[1]).toBeCloseTo(0.82467, 4);
expect(light.color[2]).toBeCloseTo(0.82467, 4);
});
});
describe("toneMapToDisplay", () => {
// Die Nebelfarbe wirkt in three.js erst NACH Tone Mapping und sRGB-Umrechnung (fog_fragment
// kommt nach colorspace_fragment). Soll der Nebel am Horizont genau wie der Himmel aussehen, muss
// die JS-Umrechnung dem Shader aufs Byte folgen. Erwartete Bytes: echter WebGLRenderer r184,
// ACESFilmicToneMapping, eine MeshBasicMaterial-Fläche in dieser Farbe, per readPixels gelesen.
// Bruch: 1/0,6-Faktor vergessen, Matrizen nicht transponiert, Belichtung ignoriert, falsche sRGB-Kurve.
const MEASURED = [
{ linear: [0.18, 0.18, 0.18], exposure: 1, bytes: [127, 127, 127] },
{ linear: [0.3732, 0.498, 0.7192], exposure: 1, bytes: [183, 197, 213] },
{ linear: [2.0, 1.0, 0.5], exposure: 1, bytes: [245, 227, 205] },
{ linear: [0.05, 0.02, 0.01], exposure: 1, bytes: [46, 21, 10] },
{ linear: [20, 20, 20], exposure: 1, bytes: [255, 255, 255] },
{ linear: [0.18, 0.18, 0.18], exposure: 2, bytes: [177, 177, 177] },
{ linear: [0, 0, 0], exposure: 1, bytes: [0, 0, 0] },
];
for (const { linear, exposure, bytes } of MEASURED) {
it(`bildet (${linear.join(", ")}) bei Belichtung ${exposure} auf die Renderer-Bytes (${bytes.join(", ")}) ab`, () => {
const display = toneMapToDisplay(linear, exposure);
// Auf 2 Stellen = ±1,3 Byte: Readback rundet auf 8 Bit.
for (let channel = 0; channel < 3; channel++) expect(display[channel]).toBeCloseTo(bytes[channel] / 255, 2);
});
}
});