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