- Himmel: 4k-HDRI von Poly Haven (CC0, tools/assets/hdri.mjs) statt Sky-Shader. Kuppel zeigt das Foto ab 12°, darunter weiches Band und Dunst in Nebelfarbe; Umgebungslicht und Spiegelungen per PMREM; Sonne aus dem Bild gekappt und als Licht übernommen (77°, Stärke 2,5) - Schatten: CSM mit vier Stufen (25/100/350/1000 m, je 2048²), Schutznetz für beleuchtete Materialien, Normal-Bias 1,5 Texel je Stufe gegen Streifen auf doppelseitigen Materialien (Figur, Autos) - Startfigur wirft Schatten, Palmen und Laternen empfangen Schatten, Wasser als ruhige See - Debug-Panel: Sonne, Himmelslicht, Himmelshelligkeit; Namensnennung um das HDRI ergänzt Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
155 lines
7.2 KiB
JavaScript
155 lines
7.2 KiB
JavaScript
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);
|
|
});
|
|
});
|