diff --git a/.gitignore b/.gitignore index 7574068..dbffd08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Große Quell-Archive; die benötigten Assets werden entpackt eingebunden *.zip +# HDRI-Rohdateien (nowhere_road_4k.exr: 84 MB); ins Spiel kommt die .hdr aus tools/assets/hdri.mjs +*.exr # Node node_modules/ diff --git a/public/textures/nowhere_road_4k.hdr b/public/textures/nowhere_road_4k.hdr new file mode 100644 index 0000000..3231b05 Binary files /dev/null and b/public/textures/nowhere_road_4k.hdr differ diff --git a/src/characters/index.js b/src/characters/index.js index daa3a53..deac8d8 100644 --- a/src/characters/index.js +++ b/src/characters/index.js @@ -320,6 +320,9 @@ export function createCharacterModule(player) { currentHeightFactor: 1, busy: false, }; + // Das Startmodell durchläuft applyCharacter nie — ohne diesen Aufruf warf die Figur erst nach + // dem ersten Figurenwechsel einen Schatten. + enableModelShadows(player.playerModel); async function switchCharacter(name) { if (state.busy || name === state.currentCharacterName) return; diff --git a/src/main.js b/src/main.js index 4d3f327..5d7a12a 100644 --- a/src/main.js +++ b/src/main.js @@ -1,11 +1,12 @@ -import { ACESFilmicToneMapping, AmbientLight, BoxGeometry, DirectionalLight, Fog, Mesh, +import { ACESFilmicToneMapping, BoxGeometry, Fog, Mesh, PerspectiveCamera, PlaneGeometry, Scene, Timer, Vector3, WebGLRenderer } from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; -import { Sky } from "three/examples/jsm/objects/Sky.js"; import { playerController } from "three-player-controller"; import { ChunkStreamer } from "./world/streaming.js"; import { createMaterials } from "./world/materials.js"; +import { createSky } from "./world/sky.js"; +import { createSunShadows } from "./world/shadows.js"; import { PropLayer } from "./world/props.js"; import { chunkForWorld, threeToWorld } from "./world/coords.js"; import { createCharacterModule } from "./characters/index.js"; @@ -35,11 +36,21 @@ container.appendChild(renderer.domElement); const camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.5, 2500); // Pflichtoption für player.init: der Controller übernimmt die Kamera, braucht aber controls.domElement als Eingabeziel. const controls = new OrbitControls(camera, renderer.domElement); -scene.add(new AmbientLight(0xffffff, 1.5)); -const sun = new DirectionalLight(0xffffff, 2); -sun.position.set(60, 120, 40); -sun.castShadow = true; -scene.add(sun); + +// Himmel, Umgebungslicht und Sonne aus dem HDRI "Nowhere Road" (src/world/sky.js) statt Sky-Shader, +// AmbientLight und fest gesetzter Sonne. Das Foto hat harte Mittagssonne — mit seinen echten +// Verhältnissen blieben Schatten fast schwarz. Himmelslicht und Kuppel laufen deshalb fünffach, die +// Sonne mit ihrer gemessenen Stärke (2,5); nachjustierbar im Debug-Panel unter "Welt". +const SUN_STRENGTH = 1; +const SKY_LIGHT = 5; +const SKY_BRIGHTNESS = 5; +scene.fog = new Fog(0xffffff, 500, 2200); // Farbe setzt sky.setBrightness() passend zum Horizont +const sky = await createSky({ renderer, scene }); +sky.setBrightness(SKY_BRIGHTNESS); +scene.environmentIntensity = SKY_LIGHT; +// 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 } }); // Ein Material je Schicht aus buildChunkGeometry() (Task 7), jetzt mit echten Poly-Haven- // Texturen statt Flächenfarben (Task 9, src/world/materials.js). buildings trägt zusätzlich @@ -51,15 +62,6 @@ const materials = await createMaterials(renderer.capabilities.getMaxAnisotropy() // Kartenbild im Debug-Panel. const manifest = await fetch("./data/vice-a/manifest.json").then((r) => r.json()); -// Himmel und Nebel über der Szene; sunPosition zeigt in dieselbe Richtung wie das Sonnenlicht. -const sky = new Sky(); -sky.scale.setScalar(45000); -scene.add(sky); -sky.material.uniforms.turbidity.value = 8; -sky.material.uniforms.rayleigh.value = 1.6; -sky.material.uniforms.sunPosition.value.copy(sun.position.clone().normalize()); -scene.fog = new Fog(0xbcd7ea, 500, 2200); - // Meer rund um den Ausschnitt: eine einzige große Ebene unter/um die Chunks, keine je Chunk. const water = new Mesh(new PlaneGeometry(4000, 4000), materials.water); water.rotation.x = -Math.PI / 2; @@ -183,7 +185,7 @@ function requestTeleport(position) { player.reset(new Vector3(position.x, position.y, position.z)); } -createPanel({ player, characterModule, scene, manifest, requestTeleport }); +createPanel({ player, characterModule, scene, sky, sun, manifest, requestTeleport }); // Minimap auf Taste M (docs/superpowers/specs/2026-09-13-minimap-design.md). Position über // playerPosition — im Fahrzeug liefert die Kapsel nur den lokalen Sitzplatz-Offset. Die Richtung @@ -224,17 +226,6 @@ player.addCollider({ motion: "static", shape: { kind: "mesh", source: seafloor } const props = new PropLayer(); await props.load(); -// Schattenkamera folgt dem Spieler, statt eine feste Box zu benutzen. -function updateShadow(target) { - sun.position.set(target.x + 120, 240, target.z + 80); - sun.target.position.copy(target); - sun.target.updateMatrixWorld(); - sun.shadow.camera.left = -150; sun.shadow.camera.right = 150; - sun.shadow.camera.top = 150; sun.shadow.camera.bottom = -150; - sun.shadow.camera.far = 600; - sun.shadow.camera.updateProjectionMatrix(); -} - // Chunk-Collider treffen erst nach und nach ein (Fetch + asynchroner BVH-Bau, siehe // streaming.js). setSkipCapsuleCollision(true) schaltet nur das horizontale Herausdrücken // ab; applyGravity läuft im Controller unabhängig davon weiter (CharacterMovement.ts), die @@ -254,7 +245,7 @@ let vehiclesSpawned = false; function tick(delta) { // Befund A (Fix-Runde 3): playerPosition() statt player.playerCapsule.position an jeder - // der sechs Stellen, die "wo ist der Spieler" brauchen — sitzt er in einem Fahrzeug, hängt + // der fünf Stellen, die "wo ist der Spieler" brauchen — sitzt er in einem Fahrzeug, hängt // die Kapsel als Kind an dessen vehicleGroup und ihre .position ist nur noch der lokale // Sitzplatz-Offset, nicht die Weltposition (Begründung in src/player.js). Ohne diese // Umstellung hört das Streaming beim Fahren auf nachzuladen. @@ -291,7 +282,7 @@ function tick(delta) { // requestTeleport() ruft player.reset() synchron auf, noch bevor dieser tick() läuft, und // reset() steigt bei aktivem Fahrzeug zuerst aus (controllerMode -> 0, s. PlayerController. // reset()) — hier kann also nie ein Fahrzeug aktiv sein. playerPosition() ändert an diesem - // Aufruf inhaltlich nichts, macht die Stelle aber konsistent mit den übrigen fünf. + // Aufruf inhaltlich nichts, macht die Stelle aber konsistent mit den übrigen vier. if (pendingTeleport) { if (streamer.isReadyAt(playerPosition(player))) { player.setSkipCapsuleCollision(false); @@ -331,10 +322,10 @@ renderer.setAnimationLoop((t) => { const delta = Math.min(timer.getDelta(), 1 / 40); player.update(delta); tick(delta); - // Befund A: Schatten folgen der aktuellen Position (Fahrzeug beim Fahren, sonst Kapsel) — - // sonst bliebe die Schattenkamera beim Fahren auf dem Einstiegspunkt stehen. - updateShadow(playerPosition(player)); materials.water.normalMap.offset.x += delta * 0.01; + // Nach player.update/tick steht die Kamera für diesen Frame fest; die Schattenstufen folgen + // ihrem Blick — beim Fahren wie zu Fuß, ohne eigene Positionsquelle. + sun.update(); renderer.render(scene, camera); }); addEventListener("resize", () => { diff --git a/src/ui/hud.js b/src/ui/hud.js index 15c0a74..48f2e63 100644 --- a/src/ui/hud.js +++ b/src/ui/hud.js @@ -2,6 +2,7 @@ // - Kartenkacheln und Wahrzeichenhöhen: schon in manifest.json (Task 6), wörtlich übernommen. // - SUV: Boris731 (Sketchfab, "Low-poly cyberpunk SUV") unter CC BY — Namensnennung Pflicht. // - Texturen: Poly Haven, CC0. +// - HDRI "Nowhere Road" (Himmel, Umgebungslicht; tools/assets/hdri.mjs): Poly Haven, CC0. // - Palme: Nobiax (OpenGameArt), CC0 (public/models/palm/readme.txt: "absolutely free to // use or to modify in any kind of work"). // - Figuren/Frisuren: Quaternius, "Universal Base Characters[Standard]"-Paket, CC0 1.0 @@ -9,7 +10,7 @@ // task-11-report.md für den wörtlichen Fund. // Jede Quelle bekommt ihren eigenen Satz mit ihrer eigenen Lizenz: keine Lizenz kürzen, // keine zwei Lizenzen in einer Zeile vermischen, kein CC0 für die CC-BY-Quelle behaupten. -const MODEL_CREDITS = "SUV: Boris731 (Sketchfab, CC BY). Texturen: Poly Haven (CC0). " + +const MODEL_CREDITS = "SUV: Boris731 (Sketchfab, CC BY). Texturen und HDRI: Poly Haven (CC0). " + "Palme: Nobiax (OpenGameArt, CC0). Figuren: Quaternius (CC0)."; /** Reine Textzusammensetzung, ohne DOM — so ohne Browser testbar (siehe tests/hud.test.js). */ diff --git a/src/ui/panel.js b/src/ui/panel.js index d4dcecf..503eb53 100644 --- a/src/ui/panel.js +++ b/src/ui/panel.js @@ -157,13 +157,14 @@ export async function switchCharacterAndSyncHeight(characterModule, params, heig /** * Baut das lil-gui-Debug-Panel (Task 11, Schritt 6): Figur, Frisur, Höhe, Hitbox anzeigen, - * Chunk-Grenzen anzeigen, Kartenbild auf dem Boden, Sichtweite (Nebel) und Sprung zu - * Wahrzeichen. requestTeleport(position) kommt aus main.js, weil nur main.js weiß, wie ein + * Chunk-Grenzen anzeigen, Kartenbild auf dem Boden, Sichtweite (Nebel), Sonne, Himmelslicht, + * Himmelshelligkeit (HDRI-Himmel, src/world/sky.js) und Sprung zu Wahrzeichen. + * requestTeleport(position) kommt aus main.js, weil nur main.js weiß, wie ein * Sprung sicher wird (Collider am Ziel abwarten, s. dortiger Kommentar zu pendingTeleport) — * ohne das würde ein Sprung auf ein noch nicht gestreamtes Wahrzeichen den Spieler ins * Leere fallen und vom Auffangnetz zurückschicken lassen. */ -export function createPanel({ player, characterModule, scene, manifest, baseUrl = "./data/vice-a", requestTeleport }) { +export function createPanel({ player, characterModule, scene, sky, sun, manifest, baseUrl = "./data/vice-a", requestTeleport }) { const targets = teleportTargets(LANDMARKS); const chunkGrid = buildChunkGrid(); scene.add(chunkGrid); @@ -191,6 +192,9 @@ export function createPanel({ player, characterModule, scene, manifest, baseUrl showChunkGrid: false, showMapImage: false, fogDistance: scene.fog?.far ?? 2200, + sunIntensity: sun.intensity, + skyLight: scene.environmentIntensity, + skyBrightness: sky.brightness, landmark: targets[0]?.name ?? "", }; @@ -215,6 +219,14 @@ export function createPanel({ player, characterModule, scene, manifest, baseUrl .onChange((value) => setMapImageVisible(value)); worldFolder.add(params, "fogDistance", 400, 3000, 50).name("Sichtweite (Nebel)") .onChange((value) => { if (scene.fog) scene.fog.far = value; }); + // decimals: Startwert ist die gemessene Sonnenstärke (2,5023…), nicht auf die Schrittweite gerundet. + worldFolder.add(params, "sunIntensity", 0, 10, 0.1).name("Sonne").decimals(1) + .onChange((value) => { sun.intensity = value; }); + worldFolder.add(params, "skyLight", 0, 10, 0.1).name("Himmelslicht") + .onChange((value) => { scene.environmentIntensity = value; }); + // Setzt auch die Nebelfarbe neu, damit der Horizont zur helleren oder dunkleren Kuppel passt. + worldFolder.add(params, "skyBrightness", 0, 10, 0.1).name("Himmelshelligkeit") + .onChange((value) => sky.setBrightness(value)); const teleportFolder = gui.addFolder("Sprung zu Wahrzeichen"); teleportFolder.add(params, "landmark", targets.map((t) => t.name)).name("Wahrzeichen") diff --git a/src/world/materials.js b/src/world/materials.js index 470fcb9..3b9b979 100644 --- a/src/world/materials.js +++ b/src/world/materials.js @@ -1,4 +1,5 @@ -import { DataTexture, MeshStandardMaterial, RepeatWrapping, RGBAFormat, SRGBColorSpace, TextureLoader } from "three"; +import { DataTexture, MeshStandardMaterial, RepeatWrapping, RGBAFormat, SRGBColorSpace, TextureLoader, + Vector2 } from "three"; export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "roads", "bridges", "buildings"]; export const TEXTURES = { @@ -135,8 +136,12 @@ export async function createMaterials(maxAnisotropy) { normal.wrapS = normal.wrapT = RepeatWrapping; normal.repeat.set(60, 60); normal.needsUpdate = true; + // Seit das HDRI als scene.environment spiegelt (src/world/sky.js), zeigte die 128er-Kachel mit + // roughness 0.15 und voller Normalstärke von oben ein Fliesenmuster aus Wellenringen. Rauer und mit + // flacheren Normalen wirkt sie als ruhige See, von oben wie vom Strand. materials.water = new MeshStandardMaterial({ - color: 0x2c67a4, normalMap: normal, roughness: 0.15, metalness: 0.2, transparent: true, opacity: 0.92, + color: 0x2c67a4, normalMap: normal, normalScale: new Vector2(0.35, 0.35), roughness: 0.3, metalness: 0.2, + transparent: true, opacity: 0.92, }); materials.seafloor = new MeshStandardMaterial({ color: 0x1d4468, roughness: 1 }); return materials; diff --git a/src/world/props.js b/src/world/props.js index 07a2392..4c16001 100644 --- a/src/world/props.js +++ b/src/world/props.js @@ -185,6 +185,7 @@ export class PropLayer { if (palms.length) { const mesh = new InstancedMesh(this.palmGeometry, this.palmMaterial, palms.length); mesh.castShadow = true; + mesh.receiveShadow = true; // im Schatten eines Gebäudes dunkler statt hell const dummy = new Object3D(); palms.forEach((p, i) => { const s = p.height / PALM_MODEL_HEIGHT; @@ -238,6 +239,7 @@ export class PropLayer { for (const part of this.lampParts) { const mesh = new InstancedMesh(part.geometry, part.material, points.length); mesh.castShadow = true; + mesh.receiveShadow = true; // im Schatten eines Gebäudes dunkler statt hell mesh.userData.excludeFromCollider = true; // sharedGeometry: part.geometry gehört this.lampParts, einmalig aus load() // (Ruling 62, Fix-Runde 2 — dieselbe Begründung wie bei den Palmen oben). diff --git a/src/world/shadows.js b/src/world/shadows.js new file mode 100644 index 0000000..e055042 --- /dev/null +++ b/src/world/shadows.js @@ -0,0 +1,90 @@ +// 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). +import { CSM } from "three/examples/jsm/csm/CSM.js"; + +// 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]; +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; +// Doppelseitige Materialien (Figur, Autos, Palmen, Laternen) schreiben ihre Vorderseiten in die +// Schattenkarte (einseitige wie Gebäude und Boden ihre Rückseiten) und verschatteten sich ohne +// Versatz selbst: feine Streifen auf Karosserie und Körper. 1,5 Texel entlang der Normalen beseitigen +// das (Motorhaube −48 % Streifen-Energie), der Schatten bleibt an Füßen und Reifen. shadowSide = +// BackSide hätte dieselbe Wirkung, ließe aber die Blattflächen der Palmenkronen fast schattenlos +// (31 % -> 3 % Schattenfläche). Je Stufe in deren Texeln, weil die fernen Stufen bis 1 m messen. +const NORMAL_BIAS_TEXELS = 1.5; + +/** 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; +} + +/** + * 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; + } + // Die Breite der Schattenboxen legt CSM erst in updateFrustums fest — danach jedes Mal nachziehen. + const applyNormalBias = () => { + for (const light of csm.lights) { + light.shadow.normalBias = NORMAL_BIAS_TEXELS * (light.shadow.camera.right - light.shadow.camera.left) / light.shadow.mapSize.x; + } + }; + applyNormalBias(); + 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(); + applyNormalBias(); + } + camera.updateMatrixWorld(); + csm.update(); + }, + }; +} diff --git a/src/world/sky.js b/src/world/sky.js new file mode 100644 index 0000000..400595d --- /dev/null +++ b/src/world/sky.js @@ -0,0 +1,242 @@ +// Himmel, Umgebungslicht und Sonne aus einem HDRI (public/textures/nowhere_road_4k.hdr, Poly Haven, +// CC0; geladen von tools/assets/hdri.mjs). Ersetzt den prozeduralen Sky-Shader: Die Kuppel zeigt das +// Foto, scene.environment beleuchtet und spiegelt es, die Sonne des Fotos wird zum DirectionalLight. +import { BackSide, BoxGeometry, Color, ColorManagement, DataTexture, DataUtils, HalfFloatType, LinearFilter, + LinearSRGBColorSpace, MathUtils, Mesh, PMREMGenerator, RepeatWrapping, RGBAFormat, ShaderMaterial, + SRGBColorSpace, UniformsLib, UniformsUtils, Vector3 } from "three"; +import { HDRLoader } from "three/examples/jsm/loaders/HDRLoader.js"; + +// Sonnenscheibe: Kanäle über 50 werden gekappt (beim Nowhere-Road-HDRI 668 Pixel, Stärke 2,5); die +// schwächere Aureole ringsum bleibt im Bild und damit Himmelslicht. +const SUN_THRESHOLD = 50; +// Unter dieser Höhe steht im Foto Landschaft: Maststangen bis 8,5°, ihre Leitungen bis knapp 11° +// (gemessen als dünne dunkle Linien vor dem Himmel), Zaun, Berge, darunter die Wüstenstraße. Die +// Kuppel zeigt dort die Himmelszeile von 12° und blendet zum Horizont in den Nebel. +const HOLD_ELEVATION = 12; +// Die gehaltene Zeile nach unten gezogen ergäbe senkrechte Streifen, wo Wolken sie kreuzen. Bis 8° +// geht sie darum in dieselbe Zeile über, waagerecht über ±10° weichgezeichnet (512 Texel). +const SOFT_ELEVATION = 8; +const BAND_RADIUS = 10; +const BAND_SAMPLES = 512; + +// Helligkeitsgewichte des Arbeitsfarbraums (lineares sRGB: Rec. 709), wie three.js sie selbst nutzt. +const LUMINANCE = ColorManagement.getLuminanceCoefficients(new Vector3()).toArray(); + +/** + * Sucht die Sonne im Equirect-Panorama und nimmt sie heraus: Jeder Kanal über threshold wird im + * Bild auf threshold gekappt, der Überschuss (mal Raumwinkel des Pixels) als Einstrahlung + * zurückgegeben — die übernimmt das DirectionalLight, das anders als Umgebungslicht Schatten wirft. + * + * data ist Half-Float-RGBA, wie HDRLoader es liefert: Datenzeile 0 = oberste Bildzeile (die + * Textur bekommt flipY = true, oben liegt also bei v = 1). Richtungen folgen equirectUv aus + * three.js (u = atan(z, x) / 2π + 0,5, v = asin(y) / π + 0,5). + */ +export function extractSun(data, width, height, threshold) { + // Nichtnegative Half-Floats sind als Uint16 genauso geordnet wie als Zahl (Exponent vor + // Mantisse) — der Vergleich spart das Dekodieren aller 33 Millionen Kanäle des 4k-Bilds. + const limit = DataUtils.toHalfFloat(threshold); + const direction = new Vector3(); + const irradiance = [0, 0, 0]; + for (let row = 0; row < height; row++) { + const elevation = (0.5 - (row + 0.5) / height) * Math.PI; + const cosElevation = Math.cos(elevation), sinElevation = Math.sin(elevation); + const solidAngle = cosElevation * (Math.PI / height) * (2 * Math.PI / width); + for (let col = 0; col < width; col++) { + const i = (row * width + col) * 4; + if (data[i] <= limit && data[i + 1] <= limit && data[i + 2] <= limit) continue; + let weight = 0; + for (let channel = 0; channel < 3; channel++) { + if (data[i + channel] <= limit) continue; + const removed = (DataUtils.fromHalfFloat(data[i + channel]) - threshold) * solidAngle; + irradiance[channel] += removed; + weight += LUMINANCE[channel] * removed; + data[i + channel] = limit; + } + const azimuth = ((col + 0.5) / width - 0.5) * 2 * Math.PI; + direction.x += weight * cosElevation * Math.cos(azimuth); + direction.y += weight * sinElevation; + direction.z += weight * cosElevation * Math.sin(azimuth); + } + } + if (direction.lengthSq() === 0) throw new Error(`Keine Sonne im HDRI: kein Kanal über ${threshold}.`); + return { direction: direction.normalize(), irradiance }; +} + +/** Einstrahlung -> DirectionalLight: Farbe mit Helligkeit 1, Stärke = Helligkeit. Farbe * Stärke = Einstrahlung. */ +export function sunLight(irradiance) { + const intensity = irradiance.reduce((sum, c, channel) => sum + LUMINANCE[channel] * c, 0); + return { color: irradiance.map((c) => c / intensity), intensity }; +} + +// ACESFilmicToneMapping aus tonemapping_pars_fragment.glsl.js (r184), Matrizen dort spaltenweise +// notiert und hier zeilenweise ausgeschrieben. +const ACES_INPUT = [ + [0.59719, 0.35458, 0.04823], + [0.07600, 0.90834, 0.01566], + [0.02840, 0.13383, 0.83777], +]; +const ACES_OUTPUT = [ + [1.60475, -0.53108, -0.07367], + [-0.10208, 1.10813, -0.00605], + [-0.00327, -0.07276, 1.07602], +]; +const multiply = (matrix, v) => matrix.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2]); + +/** + * Lineare Farbe -> Bildschirmfarbe (sRGB, 0..1), genau wie der Renderer sie mit ACESFilmicToneMapping + * ausgibt. Gebraucht für scene.fog.color: three.js mischt den Nebel erst NACH Tone Mapping und + * sRGB-Umrechnung ein (fog_fragment folgt auf colorspace_fragment), sonst passte er nicht zum Himmel. + */ +export function toneMapToDisplay(linear, exposure) { + const scaled = linear.map((c) => (c * exposure) / 0.6); + const fitted = multiply(ACES_INPUT, scaled).map((v) => + (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.4329510) + 0.238081)); + return multiply(ACES_OUTPUT, fitted).map((c) => { + const clamped = Math.min(1, Math.max(0, c)); + // sRGBTransferOETF aus colorspace_pars_fragment.glsl.js, samt dessen Exponent 0,41666. + return clamped <= 0.0031308 ? clamped * 12.92 : Math.pow(clamped, 0.41666) * 1.055 - 0.055; + }); +} + +// Datenzeile, deren Mitte elevationDeg am nächsten liegt: Zeilenmitte bei (0,5 - (row + 0,5) / height) +// * 180°, nach row aufgelöst und gerundet. +const rowIndex = (height, elevationDeg) => + Math.min(height - 1, Math.max(0, Math.round((0.5 - elevationDeg / 180) * height - 0.5))); + +/** Mittlere lineare Farbe der Datenzeile, deren Mitte elevationDeg am nächsten liegt (Daten wie bei extractSun). */ +export function rowColor(data, width, height, elevationDeg) { + const row = rowIndex(height, elevationDeg); + const color = [0, 0, 0]; + for (let col = 0; col < width; col++) { + const i = (row * width + col) * 4; + for (let channel = 0; channel < 3; channel++) color[channel] += DataUtils.fromHalfFloat(data[i + channel]) / width; + } + return color; +} + +/** + * Dieselbe Zeile, waagerecht weichgezeichnet: Texel s (Mitte bei Azimut (s + 0,5) / samples) mittelt + * alle Pixel, deren Mitte höchstens radiusDeg entfernt liegt — über die Naht bei ±180° hinweg. + * Lineares RGBA (Alpha 1), samples Texel. + */ +export function horizonBand(data, width, height, elevationDeg, radiusDeg, samples) { + const row = rowIndex(height, elevationDeg); + const band = new Float32Array(samples * 4); + for (let texel = 0; texel < samples; texel++) { + const center = ((texel + 0.5) / samples) * 360; + let count = 0; + for (let col = 0; col < width; col++) { + const offset = ((((((col + 0.5) / width) * 360 - center) % 360) + 540) % 360) - 180; + if (Math.abs(offset) > radiusDeg) continue; + const i = (row * width + col) * 4; + for (let channel = 0; channel < 3; channel++) band[texel * 4 + channel] += DataUtils.fromHalfFloat(data[i + channel]); + count++; + } + for (let channel = 0; channel < 3; channel++) band[texel * 4 + channel] /= count; + band[texel * 4 + 3] = 1; + } + return band; +} + +// Kuppel nach dem Muster von Sky.js und dem Hintergrund-Würfel in three.js: Einheitswürfel um die +// Kamera, von innen gesehen, Tiefe fest auf der Fernebene — Größe und Kamera-far spielen keine Rolle. +const SKY_VERTEX = /* glsl */ ` + varying vec3 vWorldDirection; + #include + void main() { + vWorldDirection = transformDirection( position, modelMatrix ); + #include + #include + gl_Position.z = gl_Position.w; + } +`; + +const SKY_FRAGMENT = /* glsl */ ` + uniform sampler2D skyMap; + uniform sampler2D horizonBand; + uniform float brightness; + uniform float holdV; + uniform float holdTop; + uniform float softBottom; + uniform vec3 fogColor; + varying vec3 vWorldDirection; + #include + void main() { + vec3 direction = normalize( vWorldDirection ); + vec2 uv = equirectUv( direction ); + // Über holdTop das Foto selbst, darunter dessen Zeile bei holdTop, die bis softBottom in ihre + // weichgezeichnete Fassung übergeht. + vec3 sharp = texture2D( skyMap, vec2( uv.x, max( uv.y, holdV ) ) ).rgb; + vec3 soft = texture2D( horizonBand, vec2( uv.x, 0.5 ) ).rgb; + vec3 sky = mix( soft, sharp, smoothstep( softBottom, holdTop, direction.y ) ); + gl_FragColor = vec4( sky * brightness, 1.0 ); + #include + #include + // Wie fog_fragment erst nach Tone Mapping und sRGB: fogColor ist dieselbe Bildschirmfarbe, in + // der entfernte Gebäude und das Meer verschwinden — am Horizont gibt es so keine Naht. + float haze = 1.0 - smoothstep( 0.0, holdTop, direction.y ); + gl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, haze ); + } +`; + +/** + * Lädt das HDRI, nimmt die Sonne heraus, setzt scene.environment und hängt die Himmelskuppel in die + * Szene. setBrightness() stellt die Kuppel und passend dazu scene.fog.color ein — scene.fog muss dafür + * schon existieren, sonst bleibt der Nebel unberührt. + */ +export async function createSky({ renderer, scene, url = "./textures/nowhere_road_4k.hdr" }) { + const texture = await new HDRLoader().loadAsync(url); + texture.wrapS = RepeatWrapping; // Naht bei Azimut ±180° ohne Kante filtern + // Beides vor dem ersten Hochladen (PMREM unten): gekappte Sonne in Kuppel und Umgebungslicht. + const { data, width, height } = texture.image; + const sun = extractSun(data, width, height, SUN_THRESHOLD); + const horizon = rowColor(data, width, height, HOLD_ELEVATION); + const band = new DataTexture( + Uint16Array.from(horizonBand(data, width, height, HOLD_ELEVATION, BAND_RADIUS, BAND_SAMPLES), DataUtils.toHalfFloat), + BAND_SAMPLES, 1, RGBAFormat, HalfFloatType, + ); + band.colorSpace = LinearSRGBColorSpace; + band.minFilter = band.magFilter = LinearFilter; + band.wrapS = RepeatWrapping; + band.needsUpdate = true; + + const pmrem = new PMREMGenerator(renderer); + scene.environment = pmrem.fromEquirectangular(texture).texture; + pmrem.dispose(); + + // fog: true lässt three.js fogColor (in Bildschirmfarbe) selbst aus scene.fog nachführen; dafür + // müssen alle Nebel-Uniforms existieren. Texturen erst nach merge, das würde sie klonen. + const uniforms = UniformsUtils.merge([UniformsLib.fog, { + brightness: { value: 1 }, + holdV: { value: HOLD_ELEVATION / 180 + 0.5 }, + holdTop: { value: Math.sin(HOLD_ELEVATION * MathUtils.DEG2RAD) }, + softBottom: { value: Math.sin(SOFT_ELEVATION * MathUtils.DEG2RAD) }, + }]); + uniforms.skyMap = { value: texture }; + uniforms.horizonBand = { value: band }; + const material = new ShaderMaterial({ + name: "HdriSky", uniforms, vertexShader: SKY_VERTEX, fragmentShader: SKY_FRAGMENT, + side: BackSide, depthWrite: false, fog: true, + }); + const mesh = new Mesh(new BoxGeometry(1, 1, 1), material); + mesh.name = "HdriSky"; + mesh.frustumCulled = false; + mesh.renderOrder = -1; // als Erstes, wie der Hintergrund in three.js; der Rest liegt davor + mesh.onBeforeRender = (_renderer, _scene, camera) => mesh.matrixWorld.copyPosition(camera.matrixWorld); + scene.add(mesh); + + const light = sunLight(sun.irradiance); + return { + sun: { + direction: sun.direction, + color: new Color().setRGB(...light.color, LinearSRGBColorSpace), + intensity: light.intensity, + }, + get brightness() { return uniforms.brightness.value; }, + setBrightness(value) { + uniforms.brightness.value = value; + const display = toneMapToDisplay(horizon.map((c) => c * value), renderer.toneMappingExposure); + scene.fog?.color.setRGB(...display, SRGBColorSpace); + }, + }; +} diff --git a/tests/characters.test.js b/tests/characters.test.js index ac1ea1c..53dec99 100644 --- a/tests/characters.test.js +++ b/tests/characters.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { AnimationClip, Quaternion, QuaternionKeyframeTrack, VectorKeyframeTrack } from "three"; -import { CHARACTERS, retargetClips, teleportTargets } from "../src/characters/index.js"; +import { AnimationClip, BoxGeometry, Group, Mesh, MeshStandardMaterial, Quaternion, QuaternionKeyframeTrack, + VectorKeyframeTrack } from "three"; +import { CHARACTERS, createCharacterModule, retargetClips, teleportTargets } from "../src/characters/index.js"; const rest = (q) => new Map([["arm", { position: { x: 0, y: 1, z: 0 }, quaternion: q }]]); @@ -64,3 +65,17 @@ describe("teleportTargets", () => { expect(targets[0].z).toBe(-20); }); }); + +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); + }); +}); diff --git a/tests/hud.test.js b/tests/hud.test.js index 4d0dd0a..4dea872 100644 --- a/tests/hud.test.js +++ b/tests/hud.test.js @@ -25,6 +25,12 @@ describe("buildAttribution", () => { expect(text).toMatch(/Quaternius[^.]*CC0/); }); + // Himmel und Umgebungslicht kommen aus dem HDRI "Nowhere Road" (src/world/sky.js) — eine Quelle + // mehr, die wie die Texturen mit Herkunft und Lizenz im selben Satz stehen soll. + it("nennt das HDRI mit Poly Haven und CC0", () => { + expect(buildAttribution(manifest)).toMatch(/HDRI[^.]*Poly Haven[^.]*CC0/); + }); + // Ruling (task-11-brief.md): keine Lizenz kürzen, keine zwei Lizenzen in einer Zeile // vermischen, kein CC0 für etwas behaupten, das CC BY ist — der SUV-Satz muss darum // isoliert bleiben und darf nicht in denselben Satz wie ein CC0-Eintrag rutschen. diff --git a/tests/shadows.test.js b/tests/shadows.test.js new file mode 100644 index 0000000..b8507e4 --- /dev/null +++ b/tests/shadows.test.js @@ -0,0 +1,154 @@ +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); + }); +}); diff --git a/tests/sky.test.js b/tests/sky.test.js new file mode 100644 index 0000000..bb29038 --- /dev/null +++ b/tests/sky.test.js @@ -0,0 +1,178 @@ +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); + }); + } +}); diff --git a/tools/assets/hdri.mjs b/tools/assets/hdri.mjs new file mode 100644 index 0000000..4ee75b3 --- /dev/null +++ b/tools/assets/hdri.mjs @@ -0,0 +1,20 @@ +// Lädt das HDRI "Nowhere Road" (Poly Haven, CC0) in 4k als Radiance-.hdr nach public/textures/. +// Dasselbe Bild wie das 84-MB-EXR von Poly Haven, als RGBE aber nur ein Drittel so groß und ohne +// eigenen Encoder — src/world/sky.js macht daraus Himmel, Umgebungslicht und Sonne. +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; + +const UA = { "User-Agent": "leonida-assets/0.1 (local hobby project)" }; +const SLUG = "nowhere_road"; +const RESOLUTION = "4k"; + +const files = await (await fetch(`https://api.polyhaven.com/files/${SLUG}`, { headers: UA })).json(); +const entry = files.hdri?.[RESOLUTION]?.hdr; +if (!entry) throw new Error(`${SLUG}: kein ${RESOLUTION}-HDR bei Poly Haven`); +const buf = Buffer.from(await (await fetch(entry.url, { headers: UA })).arrayBuffer()); +// Ein abgebrochener Download ergäbe ein halbes Panorama, das HDRLoader trotzdem still lädt. +const md5 = createHash("md5").update(buf).digest("hex"); +if (md5 !== entry.md5) throw new Error(`${SLUG}: MD5 ${md5} statt ${entry.md5}`); +await mkdir("public/textures", { recursive: true }); +await writeFile(`public/textures/${SLUG}_${RESOLUTION}.hdr`, buf); +console.log(`${SLUG}_${RESOLUTION}.hdr ${(buf.length / 1e6).toFixed(1)} MB`);