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:
knsoandClaude Opus 5 committed 2026-09-13 15:38:44 +02:00
1 parent 034b12537a
commit f76e97b69d
15 files changed
+762 -41

No files matched your search

+24 -33
View File
@@ -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", () => {