PROCITY/web/js/world/lighting.js
m3ultra 900b274a9a Lane B round-8: weather (?weather=1) — seeded rain/overcast/clear, exposes PROCITY.weather for D
New js/world/weather.js + a one-line lighting.setSky(). Seeded per citySeed (weatherFor):
~57/24/19% clear/overcast/rain over 400 seeds, deterministic. Rain = ONE draw (camera-following
Points + procedural streak/fall/wind shader) + wet ground (roughness/darken on shared ground mats,
restored on dispose) + matching rainy sky dome via lighting.setSky. Fully procedural (0 depot fetch).

- Exposes window.PROCITY.weather = { state:'clear'|'overcast'|'rain', intensity:0..1 } for Lane D's
  weather-reactive crowds (decision #1). Documented the contract + the exact 3-line shell wiring for F
  in LANE_B_NOTES (I don't touch index.html). ?weather=1 seeded (default town rolls clear);
  ?weather=rain|overcast|clear forces a state for demos/smokes.
- Worst view 141 draws day / 126 night (<=300); composes with ?winmap + night (rain outside a lit
  interior-mapped window = the money shot, screenshotted); deterministic; 0 console errors; flag-off
  byte-identical (shell never constructs it). Deferred cut line: vertex-shader wind sway on
  trees/awnings (touches other lanes' materials) — noted, not done. Tram stretch not attempted.

qa.sh --strict GREEN (5/5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:34:14 +10:00

156 lines
7.2 KiB
JavaScript

// PROCITY Lane B — lighting.js
// Sky dome (seeded sky-*.jpg on a BackSide sphere) + a 6-segment day/night cycle ported from
// 90sDJsim's applyLighting: one hemisphere ambient + one directional sun (single shadow map that
// follows the player) + a night flag that drives window/lamp emissives (via a callback into the
// ChunkManager). Linear fog tuned to the streaming radius doubles as the pop-in mask.
//
// Global, not chunked. The sky dome and sun re-centre on the player each frame so the world
// never runs out from under you.
import * as THREE from 'three';
// [name, sunI, sunColor, hemiSky, hemiGround, hemiI, skyTint, fogColor, exposure, night, sunOffset]
const SEG = [
['DAWN', 0.75, 0xffd9b0, 0xbcd0e8, 0x8a7a60, 0.75, 0xdcdce8, 0xcfc6ba, 1.05, false, [80, 26, 14]],
['MORNING', 1.20, 0xffe8c8, 0xcfe0f2, 0x9a8a6c, 0.95, 0xffffff, 0xd6d6cc, 1.00, false, [50, 46, 22]],
['MIDDAY', 1.55, 0xffffff, 0xd8e8ff, 0xa89878, 1.10, 0xffffff, 0xe0e2da, 0.98, false, [12, 72, 12]],
['ARVO', 1.15, 0xffdca0, 0xc8d4e8, 0xa08858, 1.00, 0xf2e8d8, 0xe0cdb0, 1.02, false, [-42, 46, 20]],
['DUSK', 0.55, 0xff9860, 0x9a86b0, 0x6a4a3a, 0.72, 0xb89a8a, 0xa07860, 1.10, false, [-74, 16, 10]],
['NIGHT', 0.10, 0x8faaff, 0x2a3050, 0x14100c, 0.38, 0x1b2038, 0x0a0c18, 1.18, true, [-40, 66, 22]],
];
const HOUR = ['06:30', '09:00', '12:30', '15:30', '18:30', '22:00'];
const HOUR_NUM = [6.5, 9, 12.5, 15.5, 18.5, 22]; // numeric form (matches Lane F's currentHour() parse)
// A tiny sky→horizon→ground vertical gradient as an equirect env source (canvas, no asset). PMREM'd
// into scene.environment so PBR content is lit by neutral IBL instead of rendering dark — Lane D's
// rig-fleet peds bake their impostor atlas from scene.environment (sim.js), and future GLB props read it.
function gradientEquirect(THREE) {
const c = document.createElement('canvas'); c.width = 8; c.height = 64;
const g = c.getContext('2d');
const grad = g.createLinearGradient(0, 0, 0, 64);
grad.addColorStop(0.00, '#9fb6d6'); // zenith sky
grad.addColorStop(0.50, '#cdccc0'); // horizon haze
grad.addColorStop(1.00, '#5c5548'); // ground bounce
g.fillStyle = grad; g.fillRect(0, 0, 8, 64);
const tex = new THREE.CanvasTexture(c);
tex.mapping = THREE.EquirectangularReflectionMapping;
tex.colorSpace = THREE.SRGBColorSpace; tex.needsUpdate = true;
return tex;
}
export function createLighting(scene, plan, skins, { radius = 3, shadows = true } = {}) {
const group = new THREE.Group();
group.name = 'lighting';
scene.add(group);
// sky dome — seeded photo skin, darkened at night via material colour
const skyName = plan.sky || 'golden-arvo';
const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false });
skins.skyTex(skyName, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat);
group.add(dome);
const hemi = new THREE.HemisphereLight(0xd8e8ff, 0xa89878, 1.0);
group.add(hemi);
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
sun.castShadow = shadows; // dense cities skip the shadow pass (≈halves draw calls; doc allows "none")
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.bias = -0.0004;
const SH = 60; // half-extent of the shadow frustum around the player
Object.assign(sun.shadow.camera, { left: -SH, right: SH, top: SH, bottom: -SH, near: 1, far: 260 });
sun.shadow.camera.updateProjectionMatrix();
group.add(sun, sun.target);
// fog fades distant chunks out just before the streaming radius, hiding pop-in
const fog = new THREE.Fog(0xd6d6cc, 40, (radius + 0.4) * 64);
scene.fog = fog;
const _skyTint = new THREE.Color();
const _fogTint = new THREE.Color();
let seg = 2, night = false;
let clock = seg + 0.001, paused = false;
const DAY_SECONDS = 240; // one full cycle
let onNightChange = null;
let renderer = null;
let envRT = null; // PMREM env render target backing scene.environment
function apply(i) {
const [, sunI, sunC, hSky, hGnd, hI, skyTint, fogC, exposure, isNight] = SEG[i];
sun.intensity = sunI; sun.color.set(sunC);
hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd);
_skyTint.set(skyTint); skyMat.color.copy(_skyTint);
_fogTint.set(fogC); fog.color.copy(_fogTint);
scene.background = _fogTint.clone(); // horizon colour behind the (finite) dome
if (renderer) renderer.toneMappingExposure = exposure;
const was = night; night = isNight; seg = i;
if (isNight !== was && onNightChange) onNightChange(isNight);
// announce the segment so facade shop-hours state (buildings.js) re-evaluates once, not per frame.
// hour is the segment's representative hour, matching Lane F's currentHour()/isOpen() convention.
if (typeof window !== 'undefined')
window.dispatchEvent(new CustomEvent('procity:segment', { detail: { seg: i, hour: HOUR_NUM[i], night: isNight } }));
}
function setSegment(i) { clock = ((i % SEG.length) + SEG.length) % SEG.length + 0.001; apply(i); }
function stepSegment(d) { setSegment((seg + d + SEG.length) % SEG.length); }
// Swap the sky-dome skin at runtime (weather.js uses this for a matching rainy/overcast sky).
// The per-segment day/night colour tint (skyMat.color in apply) still rides on top of the new map.
function setSky(name) { skins.skyTex(name, (t) => { skyMat.map = t; skyMat.needsUpdate = true; }); }
const _sunOff = new THREE.Vector3();
function update(dt, playerPos) {
if (!paused) {
clock = (clock + (dt / DAY_SECONDS) * SEG.length) % SEG.length;
const i = Math.floor(clock);
if (i !== seg) apply(i);
}
// re-centre dome + sun on the player
dome.position.copy(playerPos);
const off = SEG[seg][10];
_sunOff.set(off[0], off[1], off[2]).multiplyScalar(0.8);
sun.position.copy(playerPos).add(_sunOff);
sun.target.position.copy(playerPos);
sun.target.updateMatrixWorld();
}
function getClock() {
const frac = clock - Math.floor(clock);
return { seg, label: SEG[seg][0], hour: HOUR[seg], night, frac };
}
function attachRenderer(r) {
renderer = r;
r.toneMappingExposure = SEG[seg][8];
// set scene.environment now (needs the renderer for PMREM). Called before citizens construct,
// so Lane D's ped impostor atlas bakes against a real environment. One neutral env for all
// segments — the atlas bakes once, and the mostly-non-metal streetscape is barely affected.
if (!envRT) {
const pmrem = new THREE.PMREMGenerator(r);
const eq = gradientEquirect(THREE);
envRT = pmrem.fromEquirectangular(eq);
scene.environment = envRT.texture;
eq.dispose(); pmrem.dispose();
}
}
function dispose() {
scene.fog = null;
scene.environment = null;
if (envRT) { envRT.dispose(); envRT = null; }
dome.geometry.dispose(); skyMat.dispose();
scene.remove(group);
}
apply(seg);
return {
group, sun, hemi,
update, setSegment, stepSegment, setSky, getClock, attachRenderer, dispose,
setPaused: (p) => { paused = p; },
togglePaused: () => (paused = !paused),
isNight: () => night,
get segCount() { return SEG.length; },
set onNightChange(fn) { onNightChange = fn; },
get onNightChange() { return onNightChange; },
};
}