docs: Entwurf und Umsetzungsplan für scharfe Kaskaden-Schatten
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c79b4f347b
commit
034b12537a
2 files changed
+493
No files matched your search
@@ -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 <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
|
||||
Reference in new issue
Block a user