The town is inhabited: 23 towns finally differ, 181 back yards have a Hills Hoist with washing on it, a magpie owns one street for six weeks of the year, and six point sources put a life off-screen. MEASURED, walked, not bookmarked — two port-isolated no-store servers (:8472 = `git archive HEAD`, :8471 = treatment, assets symlinked so E's mid-round wave is identical on both sides), 102 8-metre spine steps at yaw 0, each frame fully streamed, through the shell's own composer: worst draws 224 -> 225 (+1) worst tris 89,502 -> 93,298 (+3,796 of ~75k spare) sum draws over 102 frames 16,676 -> 16,663 (-13) The citizen sim and the tram are hidden identically on both sides, and that IS the measurement: with them live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time. `?yards=0` reproduces HEAD exactly (224 / 89,502 / 16,676). Isolated at a fixed pose: washing +1 draw / +512 tris, magpie +1 draw / +182 tris, every yard box prop +0 draws. §1.2 THE CHARACTER VECTOR — +0 draws, +0 tris, classic byte-identical BY CONSTRUCTION. `townCharacter(null)` returns the frozen v1 literals and a null tint keeps the SAME material cache key, so classic gets the same object, not a copy of it: default-boot ground hashes identical to HEAD mesh-for-mesh, `?classic=1` hashes unmoved from R37. There is no `if (classic)` in the ground path. The sky feeds the BASE skyName, never `setSky` (a one-way latch that would have killed R32's dawn dome on all 23 towns). The vacuous arm is per-town: `newtown_godverse` / `redhill_godverse` carry `state: ''`, so a per-corpus arm would have dropped exactly those two silently. 21 distinct palettes across 23 towns. `gravel` and `reddust` were not unwired but UNWIREABLE — their `use` values named no slot ground.js had; making the slot addressable fixed it at the root, and both are selected now. E's three new grounds landed mid-round and the upgrade was a name change in one table, which is what procedural-first is for. §1.4 BACKYARD AUSTRALIA — inside boxMats, +0 draws. Houses inset off their boundaries so there is a yard; 36 `yard` lots that had rendered as blank shopfront sheds since v1 are now fenced blocks with a hoist, a shed and often a tank. Collision byte-identical (the lot rect is where the fence stands). THE WASHING SHIPS, and I wrote the inverted weight rather than dropping it. The shipped top-weighted mode moves a hem 0.10 cm calm / 0.67 cm gusty; `pegged` gives 4.5 cm / 31 cm — ~52x, and at the hem instead of the pegs. The `canopy` string is byte-for-byte unchanged, so the gum-tree covenant argument is untouched. It is ONE TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, proximity-first off the R37 chunk seam) rather than per-chunk, which would have been +1 x 31 live chunks = 4x the whole margin. Zero on every real town (no hoists ⇒ no mesh) and zero at night. §1.3 THE MAGPIE — +1 draw on screen, exactly 0 otherwise; 182 tris of 900. Territory scales with the road graph: 6 on the 6.78 km synthetic town, 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m from spawn. The latch sets, the day roll clears it, localStorage gains no key and the save never mentions it. `swoopPos()` is exported and pure. The hat stays cut. MY OWN SEASON MECHANISM WAS VACUOUS AND THE SWEEP CAUGHT IT: keyed on citySeed alone it produced a CONSTANT, because the town key is deliberately off the plan — 24 of 24 towns were 214 days from swooping season, and it would have gated green because `?magpie=1` forces it. Re-keyed on (citySeed, townKey): `newtown_real` is in season on arrival on the default seed, marrickville is 8 days out, and 12.9% of 1,000 seeds put the synthetic default in season against a design ratio of 11.5%. §1.1 THE AUDIO LAYER — six new mixer layers, zero draws, zero tris, seeded anchors, distance falloff, AND A StereoPannerNode PER LAYER, so direction is real (measured spread -0.71..+0.85) instead of a limitation written into the notes. Day-of-week derived locally. All six anchored on the default boot; 3 of 6 report `no-anchor` on real towns rather than being faked onto shopfronts. The falsifiability control — one manifest key renamed away, served from a third port — makes that source report `no-entry`, 0 plays, never `audible`, with the other five unchanged and zero console errors. TWO DEFECTS MY OWN GREEN NUMBERS COULD NOT SEE, both caught by a screenshot: the washing shipped at 256 instances / +1 draw / cap respected and was INVISIBLE behind its own back fence (per-instance peg height, and a fence taller than the hoist), and the magpie perched on THIN AIR over the road. R37's lesson relearned at full price. Fence 1.60 / hoist 2.15 measured against the object; territories now snap to furniture.js's own streetlight rule. LANE E'S PROPS, ADJUDICATED: hoist + aerial permanently primitive (E's spike killed them, and primitive is also the +0-draw mechanism). `paling_fence` at 1,121 tris x 905 runs = 1.01M triangles — no. The `magpie` GLB is HELD FOR THE TINT: it reads as a crow, and a magpie seen for 1.2 s in peripheral vision IS its white bar. My 182-tri bird already carries it, at a fifth of the triangles and no fetch. furniture.js's "novelty_record is 26.5k tris" was stale by six epochs — corrected to 8,000. Zero fetch delta: `?noassets=1` fetches the same 23 assets, same list, on HEAD and on R38. 0 console errors on 10 boots (default, classic, yards=0, washing=0&magpie=0&ambient=0, noassets, magpie=1, darwin, hobart, bendigo, the patched-manifest control) beyond the known PointerLock ones. Shots: docs/shots/laneB_r38/. Detail + every number: LANE_B_NOTES §38.
180 lines
8.9 KiB
JavaScript
180 lines
8.9 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;
|
|
}
|
|
|
|
// [R38 §1.2 — THE SKY BIAS IS A BASE SELECTION, NEVER `setSky`] `sky` (option) biases which dome
|
|
// skin this TOWN wears. It deliberately feeds the BASE `skyName` below rather than calling
|
|
// `setSky()`, because `externalSky` (declared a few lines down) is a **one-way latch with no un-set
|
|
// path**: whatever calls `setSky` owns the dome for the rest of the session, which would have
|
|
// silently killed R32's DAWN swap on all 23 towns and made a character sky beat the weather's
|
|
// rain dome. Feeding the base instead keeps the whole existing precedence intact —
|
|
// weather override > dawn swap > town base > 'golden-arvo'.
|
|
export function createLighting(scene, plan, skins, { radius = 3, shadows = true, dawnSky = null, sky = null } = {}) {
|
|
const group = new THREE.Group();
|
|
group.name = 'lighting';
|
|
scene.add(group);
|
|
|
|
// sky dome — seeded photo skin, darkened at night via material colour
|
|
// [R32 §31.6-3] `dawnSky` (option, shell passes null under ?classic=1): the default dome is a
|
|
// golden-SUNSET photo, so DAWN read as dusk-orange (the R31 playtest's cosmetic #3). During the DAWN
|
|
// segment only, the dome swaps to a cool morning skin, then back. A weather setSky() override
|
|
// (rain/overcast) OWNS the dome outright — the dawn swap never fights it.
|
|
const skyName = sky || plan.sky || 'golden-arvo'; // [R38 §1.2] town bias, then the plan, then v1's literal
|
|
const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false });
|
|
let externalSky = null; // set by setSky() (weather) — wins over base + dawn forever after
|
|
let shownSky = skyName;
|
|
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);
|
|
function applySkyFor(segIdx) {
|
|
const want = externalSky || (segIdx === 0 && dawnSky ? dawnSky : skyName);
|
|
if (want === shownSky) return;
|
|
shownSky = want;
|
|
skins.skyTex(want, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
|
|
}
|
|
|
|
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];
|
|
applySkyFor(i); // [R32] dawn dome swap (no-op under classic / weather override / other segments)
|
|
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.
|
|
// [R32] a caller-set sky is an OVERRIDE: it wins over the base skin and the dawn swap from then on.
|
|
function setSky(name) {
|
|
externalSky = name; shownSky = 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; },
|
|
};
|
|
}
|