Files
gta6/docs/superpowers/plans/2026-09-13-schatten.md
T

407 lines
17 KiB
Markdown
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.
# 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 <datei>`, 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