Files
gta6/tests/sky.test.js
T
knsoandClaude Opus 5 f76e97b69d feat(world): HDRI-Himmel „Nowhere Road" mit Umgebungslicht und scharfen Kaskaden-Schatten
- 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>
2026-09-13 15:38:44 +02:00

179 lines
9.5 KiB
JavaScript

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