import { describe, expect, it } from "vitest"; import { DataUtils } from "three"; import { HDRLoader } from "three/examples/jsm/loaders/HDRLoader.js"; import { extractSun, horizonBand, rowColor, sunLight, toneMapToDisplay } from "../src/world/sky.js"; // Winziges Radiance-Panorama, flach ohne Lauflängenkodierung geschrieben und durch den echten // HDRLoader gelesen — genau der Weg, auf dem sky.js das HDRI bekommt (Half-Float, Datenzeile 0 // = oberste Dateizeile). Wert je Kanal laut HDRLoader: byte * 2^(e-128) / 255. function hdrFixture(width, height, pixel) { const header = `#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y ${height} +X ${width}\n`; const bytes = new Uint8Array(header.length + width * height * 4); for (let i = 0; i < header.length; i++) bytes[i] = header.charCodeAt(i); for (let row = 0; row < height; row++) { for (let col = 0; col < width; col++) { bytes.set(pixel(row, col), header.length + (row * width + col) * 4); } } return new HDRLoader().parse(bytes.buffer); } const SKY = [26, 26, 26, 128]; // 26/255 = 0,102 je Kanal const SUN = [255, 255, 255, 136]; // 255 * 256/255 = 256 je Kanal describe("extractSun", () => { // Bruch, den der Test fängt: Zeilen von unten gelesen (wie bei EXRLoader) oder Azimut // gespiegelt — das Sonnenlicht käme dann von woanders als die sichtbare Sonne. it("liest die Richtung wie equirectUv in three.js: erste Dateizeile oben, Spalte 4 von 8 nach +z", () => { // 8x4: Zeile 1 liegt bei 90° - 1,5/4 * 180° = 22,5° Höhe, Spalte 4 bei (4,5/8 - 0,5) * 360° // = 22,5° Azimut. Richtung = (cos²22,5°, sin 22,5°, cos 22,5° * sin 22,5°). const { data, width, height } = hdrFixture(8, 4, (row, col) => (row === 1 && col === 4 ? SUN : SKY)); const sun = extractSun(data, width, height, 50); expect(sun.direction.x).toBeCloseTo(0.85355, 4); expect(sun.direction.y).toBeCloseTo(0.38268, 4); expect(sun.direction.z).toBeCloseTo(0.35355, 4); }); // Sonnenpixel (Zeile 1, Spalte 4), e=134 -> Faktor 64/255: R 64, G 57,7, B 25,1 — B liegt unter // der Schwelle 50. Kleiner Exponent mit Absicht: toHalfFloat schneidet die Mantisse ab, zwischen // 32 und 64 in Stufen von 1/32 statt 1/8 wie zwischen 128 und 256. const WARM_SUN = [255, 230, 100, 134]; const sunAt = (row, col) => (row === 1 && col === 4 ? WARM_SUN : SKY); const SUN_INDEX = (1 * 8 + 4) * 4; // Bruch: Sonne bleibt im Bild (sie hellte dann über das Umgebungslicht auch jeden Schatten auf) // oder ein Kanal unter der Schwelle wird mit auf die Schwelle gehoben. it("kappt nur die Kanäle über der Schwelle, und zwar im Bild selbst", () => { const { data, width, height } = hdrFixture(8, 4, sunAt); extractSun(data, width, height, 50); expect(DataUtils.fromHalfFloat(data[SUN_INDEX])).toBe(50); expect(DataUtils.fromHalfFloat(data[SUN_INDEX + 1])).toBe(50); expect(DataUtils.fromHalfFloat(data[SUN_INDEX + 2])).toBeCloseTo(25.1, 1); expect(DataUtils.fromHalfFloat(data[0])).toBeCloseTo(0.102, 3); }); // Bruch: Energie ohne Raumwinkel summiert, Schwelle nicht abgezogen, oder ein Kanal unter der // Schwelle zieht negativ ab — das Sonnenlicht hätte dann die falsche Stärke oder Farbe. it("gibt die abgezogene Energie als Einstrahlung zurück: Überschuss mal Raumwinkel des Pixels", () => { // Raumwinkel eines Pixels in Zeile 1 von 4 bei 8 Spalten: cos 22,5° * (π/4) * (2π/8) = 0,56989 sr. // Überschuss über 50: R 14, G 7,73, B 0 -> 7,98 / 4,40 / 0. const { data, width, height } = hdrFixture(8, 4, sunAt); const sun = extractSun(data, width, height, 50); expect(sun.irradiance[0]).toBeCloseTo(7.98, 1); expect(sun.irradiance[1]).toBeCloseTo(4.40, 1); expect(sun.irradiance[2]).toBe(0); }); // Bruch: ungewichteter Schwerpunkt — die schwachen Pixel am Rand (Linsenstrahlen um die echte // Sonne) zögen die Lichtrichtung dann genauso stark zu sich wie der helle Kern. it("gewichtet die Richtung mit der abgezogenen Energie", () => { // Zeile 1: Spalte 3 (Azimut -22,5°) mit 64 -> Überschuss 14, Spalte 4 (+22,5°) mit 256 -> 206. // Summe: x = cos²22,5° * 220 = 187,78, y = sin 22,5° * 220 = 84,19, // z = cos 22,5° * sin 22,5° * (206 - 14) = 67,88; Länge 216,70 -> (0,86656, 0,38851, 0,31326). const { data, width, height } = hdrFixture(8, 4, (row, col) => { if (row === 1 && col === 3) return [255, 255, 255, 134]; if (row === 1 && col === 4) return SUN; return SKY; }); const sun = extractSun(data, width, height, 50); expect(sun.direction.x).toBeCloseTo(0.86656, 4); expect(sun.direction.y).toBeCloseTo(0.38851, 4); expect(sun.direction.z).toBeCloseTo(0.31326, 4); }); // Bruch: Ein Panorama ohne Sonne liefert still eine Nullrichtung — updateShadow() setzte das // Licht dann genau auf den Spieler, und die Schattenkamera blickte in keine Richtung. it("wirft, wenn kein Pixel über der Schwelle liegt", () => { const { data, width, height } = hdrFixture(8, 4, () => SKY); expect(() => extractSun(data, width, height, 50)).toThrow(/Sonne/); }); }); describe("rowColor", () => { // Bruch: Zeilen von unten gezählt oder die falsche Zeile gewählt — die Nebelfarbe käme dann aus // dem Boden des Fotos oder aus dem Zenit statt aus dem Himmel knapp über dem Horizont. it("mittelt die Zeile, deren Mitte der gewünschten Höhe am nächsten liegt", () => { // 8x4, Zeilenmitten bei 67,5° / 22,5° / -22,5° / -67,5°; 20° -> Zeile 1. Bei e=128 gilt byte/255: // Zeile 1 halb (1; 0,2; 0), halb (0,2; 1; 0,4) -> Mittel (0,6; 0,6; 0,2). const { data, width, height } = hdrFixture(8, 4, (row, col) => { if (row === 0) return [0, 0, 255, 128]; if (row === 1) return col < 4 ? [255, 51, 0, 128] : [51, 255, 102, 128]; return [0, 255, 0, 128]; }); const color = rowColor(data, width, height, 20); expect(color[0]).toBeCloseTo(0.6, 3); expect(color[1]).toBeCloseTo(0.6, 3); expect(color[2]).toBeCloseTo(0.2, 3); }); }); describe("horizonBand", () => { // Bruch: Fenster wickelt nicht über Azimut ±180° (an der Bildnaht der Kuppel entstünde ein // Helligkeitssprung) oder mittelt über die falsche Zeile / falsche Breite — Wolken zögen dann // wieder als harte Streifen zum Horizont. it("mittelt die Zeile je Azimut über ±radiusDeg, über die Bildnaht hinweg", () => { // 8x4, Zeile 1 (22,5°): nur Spalte 0 hell (1,0). Pixel liegen 45° auseinander, ±50° erfasst // also Nachbar links, sich selbst, Nachbar rechts. Texel 0 und 1 sehen Spalte 0, Texel 7 // ebenfalls — über die Naht (Spalten 6, 7, 0). Wert je Treffer 1/3, sonst 0; Alpha 1. const { data, width, height } = hdrFixture(8, 4, (row, col) => (row === 1 && col === 0 ? [255, 255, 255, 128] : [0, 0, 0, 0])); const band = horizonBand(data, width, height, 22.5, 50, 8); const red = Array.from({ length: 8 }, (_, texel) => band[texel * 4]); expect(red.map((v) => +v.toFixed(4))).toEqual([0.3333, 0.3333, 0, 0, 0, 0, 0, 0.3333]); expect(band[3]).toBe(1); }); }); describe("sunLight", () => { // Bruch: Farbe nicht normiert (Licht doppelt so stark) oder auf den hellsten Kanal statt auf die // Helligkeit normiert — dann hieße "Sonne 1" im Panel je nach Farbton etwas anderes. it("teilt die Einstrahlung in eine Farbe mit Helligkeit 1 und die Helligkeit als Stärke", () => { // (2, 1, 1): Helligkeit 0,2126 * 2 + 0,7152 + 0,0722 = 1,2126 -> Farbe (1,64935; 0,82467; 0,82467). const light = sunLight([2, 1, 1]); expect(light.intensity).toBeCloseTo(1.2126, 4); expect(light.color[0]).toBeCloseTo(1.64935, 4); expect(light.color[1]).toBeCloseTo(0.82467, 4); expect(light.color[2]).toBeCloseTo(0.82467, 4); }); }); describe("toneMapToDisplay", () => { // Die Nebelfarbe wirkt in three.js erst NACH Tone Mapping und sRGB-Umrechnung (fog_fragment // kommt nach colorspace_fragment). Soll der Nebel am Horizont genau wie der Himmel aussehen, muss // die JS-Umrechnung dem Shader aufs Byte folgen. Erwartete Bytes: echter WebGLRenderer r184, // ACESFilmicToneMapping, eine MeshBasicMaterial-Fläche in dieser Farbe, per readPixels gelesen. // Bruch: 1/0,6-Faktor vergessen, Matrizen nicht transponiert, Belichtung ignoriert, falsche sRGB-Kurve. const MEASURED = [ { linear: [0.18, 0.18, 0.18], exposure: 1, bytes: [127, 127, 127] }, { linear: [0.3732, 0.498, 0.7192], exposure: 1, bytes: [183, 197, 213] }, { linear: [2.0, 1.0, 0.5], exposure: 1, bytes: [245, 227, 205] }, { linear: [0.05, 0.02, 0.01], exposure: 1, bytes: [46, 21, 10] }, { linear: [20, 20, 20], exposure: 1, bytes: [255, 255, 255] }, { linear: [0.18, 0.18, 0.18], exposure: 2, bytes: [177, 177, 177] }, { linear: [0, 0, 0], exposure: 1, bytes: [0, 0, 0] }, ]; for (const { linear, exposure, bytes } of MEASURED) { it(`bildet (${linear.join(", ")}) bei Belichtung ${exposure} auf die Renderer-Bytes (${bytes.join(", ")}) ab`, () => { const display = toneMapToDisplay(linear, exposure); // Auf 2 Stellen = ±1,3 Byte: Readback rundet auf 8 Bit. for (let channel = 0; channel < 3; channel++) expect(display[channel]).toBeCloseTo(bytes[channel] / 255, 2); }); } });