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