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

17 KiB
Raw Blame History

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

  • Step 1: Write the failing tests

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);
    });
});
  • 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'

  • Step 3: Write minimal implementation
// 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;
}
  • 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 }

  • Step 1: Write the failing tests (an tests/shadows.test.js anhängen, Import um Color, Vector3 und createSunShadows erweitern)

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);
    });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run tests/shadows.test.js Expected: FAIL, createSunShadows is not a function

  • Step 3: Write minimal implementation (in src/world/shadows.js ergänzen)
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();
        },
    };
}
  • 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 }

  • Step 1: Write the failing test (an tests/characters.test.js anhängen, Importe um BoxGeometry, Group, Mesh, MeshStandardMaterial und createCharacterModule erweitern)

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);
    });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run tests/characters.test.js Expected: FAIL, expected false to be true

  • Step 3: Write minimal implementation (in createCharacterModule, direkt nach dem state-Objekt)
    // Das Startmodell durchläuft applyCharacter nie — ohne diesen Aufruf warf die Figur erst nach
    // dem ersten Figurenwechsel einen Schatten.
    enableModelShadows(player.playerModel);
  • 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

  • Step 1: main.js umstellen

Import DirectionalLight entfernen, import { createSunShadows } from "./world/shadows.js"; ergänzen. Ersetzen:

const sun = new DirectionalLight(sky.sun.color, sky.sun.intensity * SUN_STRENGTH);
sun.castShadow = true;
scene.add(sun);

durch

// 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:

    // Nach player.update/tick: Kamera steht für diesen Frame fest, die Stufen folgen ihrem Blick.
    sun.update();
  • Step 2: Requisiten empfangen Schatten (src/world/props.js, beide mesh.castShadow = true; in build und #buildLamps)
            mesh.castShadow = true;
            mesh.receiveShadow = true;   // im Schatten eines Gebäudes dunkler statt hell
  • Step 3: Alle Tests laufen lassen

Run: npm test Expected: PASS, 0 fehlgeschlagen

  • 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.
  • Step 5: Alle Tests nach der Abstimmung

Run: npm test Expected: PASS, 0 fehlgeschlagen