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>
This commit is contained in:
1 parent
034b12537a
commit
f76e97b69d
15 files changed
+762
-41
No files matched your search
@@ -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;
|
||||
|
||||
+24
-33
@@ -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", () => {
|
||||
|
||||
+2
-1
@@ -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). */
|
||||
|
||||
+15
-3
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 <common>
|
||||
void main() {
|
||||
vWorldDirection = transformDirection( position, modelMatrix );
|
||||
#include <begin_vertex>
|
||||
#include <project_vertex>
|
||||
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 <common>
|
||||
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 <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
// 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user