diff --git a/docs/superpowers/plans/2026-09-13-schatten.md b/docs/superpowers/plans/2026-09-13-schatten.md new file mode 100644 index 0000000..6ef265c --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-schatten.md @@ -0,0 +1,406 @@ +# Scharfe Schatten — Umsetzungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Scharfe, gut sichtbare Schatten von Gebäuden, allen Objekten und der Spielfigur, nah gestochen scharf und bis in Sichtweite. + +**Architecture:** Neues Modul `src/world/shadows.js` kapselt `CSM` aus three.js (vier Stufen, eigene Grenzen 25/100/350/1000 m, je 2048²) und ersetzt das einzelne `DirectionalLight` in `main.js`. Ein Schutznetz richtet vor jedem Frame alle noch nicht vorbereiteten beleuchteten Materialien für CSM ein. Die Startfigur bekommt ihre Schatten-Flags, Palmen und Laternen empfangen Schatten. + +**Tech Stack:** three.js 0.184 (`three/examples/jsm/csm/CSM.js`), Vite 7, vitest 3. + +**Spec:** `docs/superpowers/specs/2026-09-13-schatten-design.md` + +## Global Constraints + +- Sonne bleibt die des HDRI (`sky.sun`: Richtung zur Sonne, 77° hoch, Farbe, Stärke 2,5). Keine andere Sonnenhöhe. +- Stufen `[25, 100, 350, 1000]` m, `shadowMapSize` 2048, `lightMargin` 400, `lightFar` 3000. +- **Keine Commits**, bis der Nutzer die Schatten im Spiel abgenommen hat (Anweisung des Nutzers). Die Commit-Schritte entfallen. +- Kommentare und Testnamen auf Deutsch, im Stil der vorhandenen Dateien. +- Tests laufen mit `npx vitest run `, alle mit `npm test`. + +--- + +### Task 1: Reine Helfer `cascadeBreaks` und `prepareLitMaterials` + +**Files:** +- Create: `src/world/shadows.js` +- Test: `tests/shadows.test.js` + +**Interfaces:** +- Produces: `export const CASCADE_SPLITS = [25, 100, 350, 1000]`; `export function cascadeBreaks(splits: number[], far: number): number[]`; `export function prepareLitMaterials(root: Object3D, csm: CSM): number` + +- [x] **Step 1: Write the failing tests** + +```js +import { describe, expect, it } from "vitest"; +import { BoxGeometry, Line, LineBasicMaterial, Mesh, MeshBasicMaterial, MeshStandardMaterial, + PerspectiveCamera, Scene, ShaderMaterial } from "three"; +import { CSM } from "three/examples/jsm/csm/CSM.js"; +import { cascadeBreaks, 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); + }); +}); +``` + +- [x] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/shadows.test.js` +Expected: FAIL, `Cannot find module '../src/world/shadows.js'` + +- [x] **Step 3: Write minimal implementation** + +```js +// Scharfe Schatten bis in Sichtweite: Kaskaden-Schattenkarten (CSM aus three.js) für die Sonne +// aus dem HDRI (src/world/sky.js). Ersetzt das einzelne DirectionalLight mit 512er-Karte, das auf +// ±150 m nur 0,59 m pro Texel hatte (docs/superpowers/specs/2026-09-13-schatten-design.md). + +// Stufengrenzen in Metern Sichttiefe: bei 55° Sichtfeld und 16:9 mit je 2048 Texeln etwa +// 2,6 cm / 10 cm / 36 cm / 1 m pro Texel. +export const CASCADE_SPLITS = [25, 100, 350, 1000]; + +/** Stufengrenzen in Metern -> Anteile von far (= min(camera.far, maxFar)); letzte Grenze immer 1. */ +export function cascadeBreaks(splits, far) { + return splits.map((meters, i) => (i === splits.length - 1 ? 1 : Math.min(1, meters / far))); +} + +const isLit = (material) => material.isMeshStandardMaterial || material.isMeshLambertMaterial + || material.isMeshPhongMaterial || material.isMeshToonMaterial; + +/** + * Richtet jedes noch nicht vorbereitete beleuchtete Material unter root für CSM ein und gibt die + * Zahl zurück. Pflicht, nicht Kür: CSM ersetzt lights_fragment_begin global, ein Material ohne + * USE_CSM summiert alle Stufenlichter und wäre so viel zu hell, wie es Stufen gibt. + */ +export function prepareLitMaterials(root, csm) { + let prepared = 0; + root.traverse((object) => { + if (!object.isMesh) return; + for (const material of Array.isArray(object.material) ? object.material : [object.material]) { + if (!material || !isLit(material) || csm.shaders.has(material)) continue; + csm.setupMaterial(material); + material.needsUpdate = true; // schon ohne USE_CSM übersetzt? Dann neu übersetzen. + prepared++; + } + }); + return prepared; +} +``` + +- [x] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/shadows.test.js` +Expected: PASS (4 tests) + +--- + +### Task 2: `createSunShadows` — CSM mit Sonne aus dem HDRI + +**Files:** +- Modify: `src/world/shadows.js` +- Test: `tests/shadows.test.js` + +**Interfaces:** +- Consumes: `CASCADE_SPLITS`, `cascadeBreaks`, `prepareLitMaterials` (Task 1); `sky.sun = { direction: Vector3 (zur Sonne, Länge 1), color: Color, intensity: number }` aus `src/world/sky.js` +- Produces: `export function createSunShadows({ scene, camera, sun }): { csm: CSM, intensity: number (get/set, alle Stufenlichter), update(): void }` + +- [x] **Step 1: Write the failing tests** (an `tests/shadows.test.js` anhängen, Import um `Color`, `Vector3` und `createSunShadows` erweitern) + +```js +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); + }); + + // 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); + }); +}); +``` + +- [x] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/shadows.test.js` +Expected: FAIL, `createSunShadows is not a function` + +- [x] **Step 3: Write minimal implementation** (in `src/world/shadows.js` ergänzen) + +```js +import { CSM } from "three/examples/jsm/csm/CSM.js"; + +const SHADOW_MAP_SIZE = 2048; +// Auch 250-m-Türme außerhalb des Kamerakegels werfen noch in die Stufe; far deckt diesen Rand plus +// die Tiefe der fernsten Stufe (~1400 m) ab. +const LIGHT_MARGIN = 400; +const LIGHT_FAR = 3000; + +/** + * Sonne mit Kaskaden-Schatten. sun kommt aus createSky (direction zeigt zur Sonne). update() vor + * jedem renderer.render aufrufen: richtet neue Materialien ein, führt die Stufen bei geänderter + * Kameraprojektion nach und stellt die Stufenlichter auf den aktuellen Blick ein. + */ +export function createSunShadows({ scene, camera, sun }) { + const csm = new CSM({ + camera, parent: scene, cascades: CASCADE_SPLITS.length, maxFar: CASCADE_SPLITS.at(-1), + mode: "custom", + customSplitsCallback: (_cascades, _near, far, target) => target.push(...cascadeBreaks(CASCADE_SPLITS, far)), + shadowMapSize: SHADOW_MAP_SIZE, + lightDirection: sun.direction.clone().negate(), // CSM will die Laufrichtung des Lichts + lightMargin: LIGHT_MARGIN, lightFar: LIGHT_FAR, + }); + for (const light of csm.lights) { + light.color.copy(sun.color); + light.intensity = sun.intensity; + } + const projection = camera.projectionMatrix.clone(); + return { + csm, + get intensity() { return csm.lights[0].intensity; }, + set intensity(value) { for (const light of csm.lights) light.intensity = value; }, + update() { + prepareLitMaterials(scene, csm); + if (!camera.projectionMatrix.equals(projection)) { + projection.copy(camera.projectionMatrix); + csm.updateFrustums(); + } + camera.updateMatrixWorld(); + csm.update(); + }, + }; +} +``` + +- [x] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/shadows.test.js` +Expected: PASS (9 tests) + +--- + +### Task 3: Startfigur wirft Schatten + +**Files:** +- Modify: `src/characters/index.js` (`createCharacterModule`) +- Test: `tests/characters.test.js` + +**Interfaces:** +- Consumes: `enableModelShadows(root)` (vorhanden, `src/characters/index.js`) +- Produces: unverändert `createCharacterModule(player) -> { switchCharacter, switchHair, applyHeight, state }` + +- [x] **Step 1: Write the failing test** (an `tests/characters.test.js` anhängen, Importe um `BoxGeometry`, `Group`, `Mesh`, `MeshStandardMaterial` und `createCharacterModule` erweitern) + +```js +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); + }); +}); +``` + +- [x] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/characters.test.js` +Expected: FAIL, `expected false to be true` + +- [x] **Step 3: Write minimal implementation** (in `createCharacterModule`, direkt nach dem `state`-Objekt) + +```js + // Das Startmodell durchläuft applyCharacter nie — ohne diesen Aufruf warf die Figur erst nach + // dem ersten Figurenwechsel einen Schatten. + enableModelShadows(player.playerModel); +``` + +- [x] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/characters.test.js` +Expected: PASS + +--- + +### Task 4: Verdrahtung, Requisiten, Abstimmung im Browser + +**Files:** +- Modify: `src/main.js` (Sonne, `updateShadow`, Schleife) +- Modify: `src/world/props.js` (Palmen- und Laternen-`InstancedMesh`: `receiveShadow`) +- Modify: `src/world/shadows.js` (Bias, Normal-Bias, ggf. `fade` nach Sichtprüfung) + +**Interfaces:** +- Consumes: `createSunShadows` (Task 2), `sky.sun` (`src/world/sky.js`) +- Produces: `sun` in `main.js` ist das Objekt aus `createSunShadows` (hat `intensity`), `createPanel` bleibt unverändert + +- [x] **Step 1: main.js umstellen** + +Import `DirectionalLight` entfernen, `import { createSunShadows } from "./world/shadows.js";` ergänzen. Ersetzen: + +```js +const sun = new DirectionalLight(sky.sun.color, sky.sun.intensity * SUN_STRENGTH); +sun.castShadow = true; +scene.add(sun); +``` + +durch + +```js +// Kaskaden-Schatten statt eines einzelnen DirectionalLight (src/world/shadows.js): Figur, Laternen +// und Palmen scharf, Gebäude bis 1 km. sun.intensity steuert alle Stufenlichter (Regler "Sonne"). +const sun = createSunShadows({ scene, camera, sun: { ...sky.sun, intensity: sky.sun.intensity * SUN_STRENGTH } }); +``` + +`SUN_DISTANCE`, `updateShadow()` und deren Aufruf in `renderer.setAnimationLoop` entfernen. Vor `renderer.render(scene, camera);` einsetzen: + +```js + // Nach player.update/tick: Kamera steht für diesen Frame fest, die Stufen folgen ihrem Blick. + sun.update(); +``` + +- [x] **Step 2: Requisiten empfangen Schatten** (`src/world/props.js`, beide `mesh.castShadow = true;` in `build` und `#buildLamps`) + +```js + mesh.castShadow = true; + mesh.receiveShadow = true; // im Schatten eines Gebäudes dunkler statt hell +``` + +- [x] **Step 3: Alle Tests laufen lassen** + +Run: `npm test` +Expected: PASS, 0 fehlgeschlagen + +- [x] **Step 4: Sichtprüfung im Browser** (eigener Dev-Server, ausgeblendetes Panel siehe Memory `browser-verification-hidden-pane`) + +Prüfen und belegen: +1. Konsole ohne Fehler, `renderer.info.memory` und Programmzahl plausibel. +2. Nahaufnahme von der Schattenseite (Kamera der Sonne abgewandt): Figur, Laterne, Palme, Auto werfen scharf umrissene Schatten. +3. Gebäude auf ~20 m und ~300 m: Schatten am Fuß vorhanden, Kanten scharf bzw. sauber. +4. Stufengrenzen bei 25/100/350 m: keine harten Kanten; sonst in `createSunShadows` `csm.fade = true` setzen und neu prüfen. +5. Wände mit streifendem Licht: keine Akne, keine abgelösten Schatten. Falls Akne: `light.shadow.normalBias` (ab 0,02) und `light.shadow.bias` (ab −0,0002) in der Lichtschleife von `createSunShadows` setzen, mit Nahaufnahme gegenprüfen. +6. Keine zu hellen Objekte: Auto, Figur, nach Figurenwechsel und Frisurwechsel. +7. Regler „Sonne“ ändert die Helligkeit gleichmäßig. +8. Framezeit von `renderer.render` vor/nach dem Umbau vergleichen. + +- [x] **Step 5: Alle Tests nach der Abstimmung** + +Run: `npm test` +Expected: PASS, 0 fehlgeschlagen diff --git a/docs/superpowers/specs/2026-09-13-schatten-design.md b/docs/superpowers/specs/2026-09-13-schatten-design.md new file mode 100644 index 0000000..9aac54f --- /dev/null +++ b/docs/superpowers/specs/2026-09-13-schatten-design.md @@ -0,0 +1,87 @@ +# Scharfe Schatten — Entwurf + +**Datum:** 2026-09-13 +**Zusammenhang:** Folgt auf den HDRI-Himmel (`src/world/sky.js`). Der Nutzer committet den Himmel erst, wenn Gebäude, alle Objekte und die Spielfigur scharfe, gut sichtbare Schatten werfen. +**Baut auf:** `src/world/sky.js` (Sonnenrichtung, -farbe und -stärke aus dem HDRI), `src/main.js` (bisher ein `DirectionalLight` mit `updateShadow()`). + +## Ziel + +Scharfe Schatten von Gebäuden, Palmen, Laternen, Autos und der Spielfigur, nah am Spieler gestochen scharf und bis in Sichtweite vorhanden. + +## Befund (zur Laufzeit geprüft) + +| Ursache | Beleg | +|---|---| +| Die Startfigur wirft keinen Schatten | Die `SkinnedMesh`es `Mannequin_1/_2` haben `castShadow = false`. `enableModelShadows` läuft nur in `applyCharacter`, also erst beim Figurenwechsel. | +| Die Schattenkarte ist zu grob | 512² Texel auf ±150 m ergeben 0,59 m pro Texel, dazu PCF und Bias 0. Laternen (~0,15 m), Palmenstämme und die Figur (~0,4 m) sind schmaler als ein Texel und werden zum Klecks. Mit 4096² ist der Laternenschatten scharf umrissen (zur Laufzeit getestet). | +| Gebäude außerhalb der Box bleiben ohne Schatten | Vom Spawn aus liegen die nächsten Gebäudewände 190–410 m entfernt, die Box reicht nur ±150 m um die Figur. | +| Kurze Schatten | Die Sonne des HDRI steht bei 77° (Figur ~0,4 m, 20-m-Gebäude ~4,6 m Schattenlänge). **Bleibt so**, siehe Entscheidungen. | + +## Entscheidungen des Nutzers + +| Frage | Entscheidung | +|---|---| +| Reichweite | Kaskaden-Schatten bis Sichtweite (CSM aus three.js), keine einzelne Karte um den Spieler. | +| Sonnenhöhe | Sonne des HDRI behalten (77°). Licht, Schatten und Spiegelungen kommen von der sichtbaren Sonne. | + +## Aufbau + +| Einheit | Aufgabe | +|---|---| +| `src/world/shadows.js` | Neu. `createSunShadows({ scene, camera, sun })` baut `CSM` aus `three/examples/jsm/csm/CSM.js` und liefert `{ csm, intensity (get/set), update() }`. Reine Helfer: `cascadeBreaks(splits, far)` und `prepareLitMaterials(root, csm)`. | +| `src/main.js` | Ersetzt `DirectionalLight` und `updateShadow()` durch `createSunShadows`. Die Schleife ruft `sunShadows.update()` vor `renderer.render`. Ans Panel geht `sun: sunShadows` (gleiche `intensity`-Schnittstelle, `panel.js` bleibt unverändert). | +| `src/characters/index.js` | `createCharacterModule` schaltet die Schatten auch fürs Startmodell ein. | +| `src/world/props.js` | Palmen und Laternen bekommen zusätzlich `receiveShadow`. | + +### Stufen + +Vier Stufen mit je 2048², eigene Grenzen über `mode: "custom"`. Die eingebaute Aufteilung `practical` (λ fest 0,5) legte die erste Grenze bei ~127 m. + +| Stufe | Bis | Breite der Schattenbox (55° Sichtfeld, 16:9) | pro Texel | +|---|---|---|---| +| 1 | 25 m | ~53 m | ~2,6 cm | +| 2 | 100 m | ~212 m | ~10 cm | +| 3 | 350 m | ~743 m | ~36 cm | +| 4 | 1000 m | ~2124 m | ~1 m | + +`maxFar` = 1000 m. Dahinter wirkt das Sonnenlicht ohne Schatten, der Nebel (500–2200 m) blendet den Übergang aus. `cascadeBreaks` rechnet die Meter in Anteile von `far = min(camera.far, maxFar)` um und kappt sie auf 1. + +### Licht + +- Richtung: `lightDirection = −sun.direction` (CSM erwartet die Laufrichtung des Lichts, `sky.js` liefert die Richtung zur Sonne). Farbe und Stärke kommen von `sky.sun` auf alle vier Stufenlichter. +- `lightMargin` 400 m: Auch 250-m-Türme außerhalb des Kamerakegels werfen noch in die Stufe. `lightFar` 3000 m deckt Rand plus Tiefe der fernsten Stufe (~1400 m) ab. +- Texel-Einrasten bringt `CSM.update()` bereits mit, das verhindert Flimmern beim Laufen. +- **Normal-Bias 1,5 Texel je Stufe** (bei 16:9 etwa 4 cm / 16 cm / 54 cm / 1,56 m), nach jedem `updateFrustums` neu berechnet. Nachtrag nach Nutzer-Screenshot mit Streifen auf Figur und Auto: + - *Ursache:* Figur, Autos (228 von 236 Meshes je SUV), Palmen und Laternen haben doppelseitige Materialien. Für die schreibt three.js die Vorderseiten in die Schattenkarte (`WebGLShadowMap`: `DoubleSide -> DoubleSide`, einseitige Materialien schreiben ihre Rückseiten). Mit dem CSM-Bias von 1e-6 verschatten sie sich selbst. + - *Gemessen (Hochpass-Energie im Bildausschnitt):* Motorhaube −49 %, Rücken der Figur −40 %. Der Schatten bleibt an Füßen und Reifen. + - *Verworfen:* `shadowSide = BackSide` wirkt gegen die Streifen genauso, lässt aber die einzelnen Blattflächen der Palmenkronen fast ohne Schatten (Schattenfläche 31 % -> 3 %). + - Wände und Boden (einseitig) waren nie betroffen. Der Tiefen-Bias bleibt beim CSM-Standard. + +### Schutznetz für Materialien + +CSM ersetzt `lights_fragment_begin` global. Nur Materialien mit `USE_CSM` bekommen je Pixel genau ein Stufenlicht, jedes andere beleuchtete Material summiert alle vier und wäre viermal zu hell. Deshalb bereitet `update()` vor jedem Frame alle noch nicht vorbereiteten beleuchteten Materialien vor: + +- `MeshStandardMaterial` (inkl. Physical), `MeshLambertMaterial`, `MeshPhongMaterial` und `MeshToonMaterial` bekommen `csm.setupMaterial` und `needsUpdate`. +- Unbeleuchtete Materialien (Himmelskuppel-`ShaderMaterial`, `MeshBasicMaterial`, Linien) bleiben unberührt. +- Bereits vorbereitete Materialien (`csm.shaders.has`) werden übersprungen. + +So sind nachgeladene Chunks, Autos, Figurenwechsel und Frisuren abgedeckt, ohne dass diese Stellen CSM kennen müssen. Kein Material im Projekt oder im Controller nutzt `onBeforeCompile`, das `setupMaterial` überschreibt (geprüft). + +### Kamera + +`update()` vergleicht die Projektionsmatrix der Kamera mit der vom letzten Frame. Ändern sich Seitenverhältnis, Sichtfeld, near oder far, folgt `csm.updateFrustums()`. Einen eigenen Resize-Haken braucht es nicht. + +## Fehlerfälle und Risiken + +- **Vergessenes Material:** Das Schutznetz fängt es ab, bevor es zum ersten Mal gezeichnet wird. +- **Übergänge zwischen den Stufen:** Werden Kanten zwischen den Stufen sichtbar, wird `csm.fade` eingeschaltet (kostet Shader-Zeit, weitet die Boxen). +- **Kosten:** Bis zu vier Schatten-Durchgänge pro Frame statt einem, rund 64 MB GPU-Speicher. Die Framezeit vorher/nachher wird gemessen. + +## Tests + +- `tests/shadows.test.js` + - `cascadeBreaks`: Meter werden zu Anteilen, Kappung bei kürzerem `far`. + - `prepareLitMaterials` mit echtem `CSM`: beleuchtete Materialien bekommen `USE_CSM` (auch in Material-Arrays), unbeleuchtete nicht, kein zweites Vorbereiten. + - `createSunShadows`: Stufenlichter zeigen nach `update()` in Sonnenrichtung. Die Stärke gilt für alle vier. Nachträglich hinzugefügte Meshes werden vorbereitet. Die Stufen folgen einer Änderung des Seitenverhältnisses. +- `tests/characters.test.js`: Die Startfigur wirft und empfängt Schatten. +- **Browser:** Nahaufnahmen von der Schattenseite (Figur, Laterne, Palme, Auto), Gebäude auf ~20 m und ~300 m, Übergänge zwischen den Stufen, keine Akne. Keine zu hellen Objekte, auch nach Figurenwechsel. Konsole sauber. Framezeit.