/** * The city's own lights: lit windows in the buildings, and lamps along the * streets. * * This is the other half of making a night usable. `atmosphere.ts` puts a moon * up so there is something to see the city *by*; this puts light *in* the city, * which is most of what a city at night actually is — from any distance a * skyline after dark is not a shape you can make out, it is a field of small * bright rectangles that happens to have a shape. * * Two constraints shaped everything here: * * - **There are ~24,000 buildings in one `InstancedMesh`.** A point light per * building is not a slow version of this, it is an impossible one: three.js * evaluates every light in the fragment shader for every lit surface, and * the practical ceiling is a few dozen. So the buildings do not emit light * at all. They *are* light — an emissive term added inside the existing * material, which costs one shader patch and no extra draw calls, and which * the moon and the fog and the shadows all continue to work around * untouched. * - **Nothing may reshuffle between frames or reloads.** Which windows are on * is a hash of the window's own cell index and a per-instance seed drawn * from `blocks.ts`'s seeded RNG, evaluated in the fragment shader. It is a * pure function of position, so it is stable across frames for free, and it * costs no memory at all: 24,000 buildings' worth of individual windows * would be millions of booleans and there are none of them anywhere. * * **This is not a lighting owner.** `Atmosphere` owns the rig and CONTRACT.md §4 * is explicit that nothing else may touch it; what this module owns is * *emission*, which is a property of the buildings and not of the light rig, and * it never constructs a `THREE.Light` of any kind. The seam is one number in: * `setSolarElevation`, which is the same solar elevation `Atmosphere` is * reading. Night is data, not a mode flag, and there is nothing to switch. * * An interior gets none of this, for the same reason it gets no `Atmosphere`: * an office has its own fixed rig and no idea what time it is outside. */ import * as THREE from "three"; import { nightFactor } from "./atmosphere.ts"; import { FACADE_ATTRIBUTE } from "./blocks.ts"; import { seededRandom, type World } from "./world.ts"; export interface NightLightsOptions { world: World; /** The buildings, exactly as `createBlocks` returned them. */ blocks: THREE.InstancedMesh; /** Lamps along the road network. On by default. */ streetLamps?: boolean; /** Metres between street lamps. */ lampSpacingM?: number; /** * Ceiling on how many lamps get built, as insurance against a city pack with * a very dense road network. SF's twenty-five roads produce a few thousand. */ maxLamps?: number; } export interface NightLights { /** Everything this layer adds to the scene. Added once, then driven. */ group: THREE.Group; /** * The seam. Hand it the solar elevation in degrees — the same number * `Atmosphere` is working from — and the city switches itself on. */ setSolarElevation(degrees: number): void; /** How on the lights currently are, 0..1. For a debug readout. */ strength(): number; dispose(): void; } // ---- Constants ------------------------------------------------------------ /** * A window bay, and a storey, in metres. * * The storey is real. The bay is not: a curtain wall's mullions are nearer 1.5 m * apart, and at San Francisco's ~94 m per scene unit that is a third of a pixel * from anywhere the camera is allowed to be, so an honest bay renders as grey * noise and nothing else. 6.5 m is the coarsest grid that still reads as * windows rather than as panels, which puts about six bays across a 40 m lot * and gives a pane the wide flat shape of a ribbon window. Vertically there is * no such problem — the city's 3.6x exaggeration makes a storey four times a * bay on screen — so the storey stays honest. */ const WINDOW_PITCH_M = 6.5; const STOREY_M = 3.6; /** * Fraction of windows left on, by how commercial the building is. * * Both are lower than they look, and deliberately: the aggregate is what the * eye reads, and the first pass at this — half of every office window on — came * out as a city of solid glowing slabs with no building shapes left in it. A * quarter is already a *lot* of light once every pane is at nearly full * emission, and a house showing one window in sixteen is a street with somebody * still up on it. */ const HOUSE_LIT = 0.06; const OFFICE_LIT = 0.24; /** * The two colours a lit window comes in. * * Warm is a domestic lamp — tungsten, or the LED everyone buys because it looks * like tungsten — at something like 2,700 K. Cool is an office ceiling left on * by the cleaners, which is the other half of any real skyline and the half * that makes the warm windows read as warm. Passed as `THREE.Color`, so three * converts them out of sRGB into the linear working space on the way to the * uniform and the emissive term lands in the same space as everything else in * the shader. */ const WINDOW_WARM = 0xffc178; const WINDOW_COOL = 0xd8e4ff; /** * Peak emissive radiance of a lit pane. Below 1 so a window is bright, not blown. * * 0.8 when the ground under the city was effectively black, 0.95 now that * `atmosphere.ts` holds a real floor under a moonless night. That floor moved * the terrain from about #000004 to something you can find a coastline in, and * a window has to stay the brightest thing in the frame by a comfortable factor * or the whole picture stops being a city at night and becomes a city at dusk. * It is the *ratio* that is being defended here, not the absolute value. * * Still under 1, and that is not an accident: at 1.0 the emissive term alone * saturates the channel and a lit pane clips to white, taking `WINDOW_WARM` with * it. A skyline whose windows have lost the difference between tungsten and a * ceiling fluorescent is a skyline with the character taken out of it, and there * is no HDR buffer here to get it back from. */ const WINDOW_GAIN = 0.95; /** Sodium, because a street lamp is the one light in a city that still is. */ const LAMP_COLOR = 0xffb264; const LAMP_HEIGHT_M = 9; const DEFAULT_LAMP_SPACING_M = 55; const DEFAULT_MAX_LAMPS = 24_000; /** * Glow radius of a lamp, in scene units. * * Chosen against the far end of the camera's orbit rather than the near end. At * 100 units out — the framing the city is usually looked at from — this is a * few pixels, which is what a street lamp is; flying down to the 12-unit * minimum blooms it to something much larger than a lamp. That is the wrong way * round from a purist's point of view and the right way round for the frame * anyone actually looks at, and the alternative — a fixed pixel size — turns the * whole road network into a sheet of aliasing sparkle the moment you pull back. */ const LAMP_SIZE = 0.3; const LAMP_SEED = 61_803; /** * When the lamps come on, in degrees of solar elevation. * * Earlier than `nightFactor`, and deliberately so: street lighting switches on * around sunset, an hour before the sky is dark, and offices have been lit * since the afternoon. What `nightFactor` then adds is not more lights but more * *contrast* — the same windows against a sky that has stopped competing with * them. Multiplying the two is what produces the real sequence, where the city * appears to come on gradually over an hour without anything ever switching. */ const LAMPS_ON_HIGH = 5; const LAMPS_ON_LOW = -5; /** Below this the layer is hidden outright rather than drawn at zero. */ const DARK_ENOUGH = 0.002; // ---- The layer ------------------------------------------------------------ export function createNightLights(options: NightLightsOptions): NightLights { const { world, blocks } = options; const group = new THREE.Group(); group.name = "nightlights"; // Shared with the shader by reference: `onBeforeCompile` hands these exact // objects to the program, so writing `.value` here is what drives the frame. const uniforms = { uNight: { value: 0 }, uWindowPitch: { value: WINDOW_PITCH_M / world.metresPerUnit }, // A storey goes through `world.metres`, so it picks up the city's vertical // exaggeration exactly as the building's own height did. Without that the // floor count would be wrong by the exaggeration factor — a 100 m tower // would come out with a hundred floors in it. uStorey: { value: world.metres(STOREY_M) }, uWarm: { value: new THREE.Color(WINDOW_WARM) }, uCool: { value: new THREE.Color(WINDOW_COOL) }, uGain: { value: WINDOW_GAIN }, uHouseLit: { value: HOUSE_LIT }, uOfficeLit: { value: OFFICE_LIT }, }; const facade = blocks.geometry.getAttribute(FACADE_ATTRIBUTE); const material = blocks.material; const patched = !Array.isArray(material) && material instanceof THREE.MeshLambertMaterial && facade ? patchFacades(material, uniforms) : null; const lamps = (options.streetLamps ?? true) ? buildLamps(world, options) : null; if (lamps) group.add(lamps.points); let strength = 0; function setSolarElevation(degrees: number) { // Two curves, multiplied: when the lights are on, and how much darker than // them the sky is. See `LAMPS_ON_HIGH`. const on = 1 - smoothstep(LAMPS_ON_LOW, LAMPS_ON_HIGH, degrees); strength = on * (0.35 + 0.65 * nightFactor(degrees)); uniforms.uNight.value = strength; if (lamps) { lamps.points.visible = strength > DARK_ENOUGH; lamps.material.opacity = strength; } } setSolarElevation(90); return { group, setSolarElevation, strength: () => strength, dispose() { // The buildings are not ours and outlive this layer, so the material goes // back exactly as it was found rather than being left with a dark // uniform in it and a patch nobody remembers applying. patched?.(); lamps?.points.geometry.dispose(); lamps?.material.map?.dispose(); lamps?.material.dispose(); group.clear(); }, }; } // ---- Lit windows ---------------------------------------------------------- type Uniforms = Record; /** * Add an emissive window grid to the buildings' own material. * * Patching in place rather than replacing the material, because `blocks.ts` * owns what a facade looks like in daylight and this has no business having an * opinion about that. Everything below is additive: a `totalEmissiveRadiance` * term, computed after the lighting has been accumulated and before fog and the * colour-space encode, so a lit window is correctly hazed by the marine layer * and correctly *not* darkened by being in shadow. Which is right — a window is * a hole with a light behind it, and nothing outside the building can shade it. * * Returns the undo. */ function patchFacades(material: THREE.MeshLambertMaterial, uniforms: Uniforms): () => void { const previous = material.onBeforeCompile; material.onBeforeCompile = (shader) => { for (const [name, uniform] of Object.entries(uniforms)) { shader.uniforms[name] = uniform as THREE.IUniform; } shader.vertexShader = shader.vertexShader .replace("#include ", `#include \n${VERTEX_PARS}`) .replace("#include ", `#include \n${VERTEX_BODY}`); shader.fragmentShader = shader.fragmentShader .replace("#include ", `#include \n${FRAGMENT_PARS}`) .replace( "#include ", `#include \n${FRAGMENT_BODY}`, ); }; // `Material.customProgramCacheKey` defaults to the source of // `onBeforeCompile`, so the renderer will not hand this material a program // compiled for an unpatched one. Changing the function is still a new // program, hence the flag. material.needsUpdate = true; return () => { material.onBeforeCompile = previous; material.needsUpdate = true; }; } /** * The varyings are declared unconditionally in both stages — a varying present * in one and absent from the other is a link error — while the two things that * only exist under instancing are guarded. `aFacade` needs no guard: an * unbound vertex attribute reads as zero, which is a building with no windows * lit, which is a perfectly good failure. */ const VERTEX_PARS = /* glsl */ ` attribute vec2 aFacade; varying vec3 vFacadeLocal; varying vec3 vFacadeNormal; varying vec3 vFacadeSize; varying vec2 vFacade; `; const VERTEX_BODY = /* glsl */ ` vFacadeLocal = transformed; vFacadeNormal = objectNormal; vFacade = aFacade; #ifdef USE_INSTANCING // The instance's scale, recovered from the columns of its own matrix. This is // what puts the window grid in scene units instead of in fractions of a // building: without it every tower would have the same number of floors as // the bungalow next door, stretched to fit. vFacadeSize = vec3( length(instanceMatrix[0].xyz), length(instanceMatrix[1].xyz), length(instanceMatrix[2].xyz) ); #else vFacadeSize = vec3(1.0); #endif `; const FRAGMENT_PARS = /* glsl */ ` uniform float uNight; uniform float uWindowPitch; uniform float uStorey; uniform float uGain; uniform float uHouseLit; uniform float uOfficeLit; uniform vec3 uWarm; uniform vec3 uCool; varying vec3 vFacadeLocal; varying vec3 vFacadeNormal; varying vec3 vFacadeSize; varying vec2 vFacade; float facadeHash(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453123); } `; /** * The window grid, and the reason it does not sparkle. * * A window bay is about 4 m, which at San Francisco's ~94 m per scene unit is * 0.042 units — and from the distance the city is normally looked at, that is * well under a pixel. Drawn honestly it would be a sheet of moiré that crawls * whenever the camera moves, which is the classic failure of any procedural * pattern with no mip chain behind it. `fwidth` gives the pattern's own * footprint in pixels, and past about one cell per pixel the grid is replaced * by its average — which is exactly what a mip level would have contained. So * the far city is a smooth glow whose brightness is the density of its lit * windows, downtown reads brighter than the avenues because it genuinely has * more of them on, and flying in resolves individual windows out of it. */ const FRAGMENT_BODY = /* glsl */ ` if (uNight > 0.002) { vec3 faceNormal = normalize(vFacadeNormal); // Roofs have no windows in them, and this is a box. float wall = 1.0 - smoothstep(0.55, 0.95, abs(faceNormal.y)); if (wall > 0.0) { float across = abs(faceNormal.x) > 0.5 ? vFacadeLocal.z * vFacadeSize.z : vFacadeLocal.x * vFacadeSize.x; float up = vFacadeLocal.y * vFacadeSize.y; vec2 grid = vec2(across / uWindowPitch, up / uStorey); vec2 cell = fract(grid); vec2 pane = step(vec2(0.22, 0.34), cell) * step(cell, vec2(0.78, 0.72)); float coverage = pane.x * pane.y; // Which windows are on: a hash of the cell and the building's own seed, so // it is a pure function of where you are looking and never has to be // stored, animated or reconciled. float roll = facadeHash(vec3(floor(grid), vFacade.y * 137.0)); // How lit this particular building is, on top of what its district says. // Without it every tower downtown has the same window density, and from a // distance the whole financial district smears into one flat brown // rectangle — which is the one thing a night skyline never looks like. The // curve is squared so most buildings are dim and a few blaze, and scaled so // that the mean of it is exactly 1 and the district's own figure still // means what it says. float variation = 0.15 + 2.55 * vFacade.y * vFacade.y; float chance = clamp(mix(uHouseLit, uOfficeLit, vFacade.x) * variation, 0.0, 0.9); float on = step(1.0 - chance, roll); float footprint = max(fwidth(grid.x), fwidth(grid.y)); float detail = 1.0 - smoothstep(0.5, 1.4, footprint); // 0.56 x 0.38 is the pane inside its cell, so 0.2128 x chance is the grid's // own mean — and 2.6 times that is what is actually used, which is a lie // worth being explicit about. The mean is the right answer for a display // whose response is linear, and no display's is: a pixel that in reality // contains one small blazing window and three dark ones does not read to // the eye as the average of the four, it reads as lit. With no HDR buffer // and no bloom to arrive at that honestly, the multiplier is the cheap way // to keep the far city as bright as the near city says it ought to be. // // 1.8 for as long as the ground was black, because against black anything // reads. This is the branch the whole-board framing takes — every pixel of // the city is past the fwidth cutoff from up there — so it is also the // branch that had to answer when the atmosphere's night floor brought the // terrain up to meet it. At 1.8 against the new floor the lit grid and the // bare ground came out at the same luminance and downtown stopped being // findable, which is a worse bug than the one being fixed. float glow = mix(2.6 * 0.2128 * chance, coverage * on, detail); // Roughly seven windows in ten warm. A skyline is mostly people's lamps and // only partly the floors the cleaners are still on. // // The colour needs the same averaging the mask got, and forgetting it is a // subtle and very visible bug: a mask correctly resolved to its mean, tinted // by a hard per-cell choice between two colours at a frequency far below one // pixel, gives a distant city that is the right brightness and crawling with // orange and white confetti. vec3 tint = mix(mix(uWarm, uCool, 0.3), mix(uWarm, uCool, step(0.7, fract(roll * 7.13))), detail); totalEmissiveRadiance += tint * (glow * wall * uNight * uGain); } } `; // ---- Street lamps --------------------------------------------------------- interface Lamps { points: THREE.Points; material: THREE.PointsMaterial; } /** * Lamps along the road network, as one additive point cloud. * * Cheap enough to be worth it: a few thousand points in a single draw call, * with no lighting, no shadows and no per-frame work beyond an opacity. What * they buy is the thing the buildings cannot — the *ground* has light on it, so * the street grid is still legible at night and the city keeps the shape that * makes it recognisable from above. In San Francisco that shape is the 46° * between the grid north of Market and the grid south of it, and losing it * after dark would lose the city. * * They emit nothing, of course. A real street lamp pooling light on the road * under it is a second set of lights and a second shadow problem, and the * pooling would be invisible at any framing where the lamp itself is a pixel. */ function buildLamps(world: World, options: NightLightsOptions): Lamps | null { const spacing = (options.lampSpacingM ?? DEFAULT_LAMP_SPACING_M) / world.metresPerUnit; const lift = world.metres(LAMP_HEIGHT_M); const limit = options.maxLamps ?? DEFAULT_MAX_LAMPS; const rand = seededRandom(LAMP_SEED); const positions: number[] = []; let index = 0; for (const road of world.city.roads) { // Carried across segment joins, so the spacing is even along the whole // street rather than restarting at every corner — which would cluster // lamps wherever a road was written with a lot of vertices in it, and // those are exactly the bends. let carry = 0; for (let i = 0; i < road.path.length - 1; i++) { const from = road.path[i]; const to = road.path[i + 1]; if (!from || !to) continue; const [lat0, lng0] = from; const [lat1, lng1] = to; const [x0, z0] = world.project(lat0, lng0); const [x1, z1] = world.project(lat1, lng1); const dx = x1 - x0; const dz = z1 - z0; const length = Math.hypot(dx, dz); if (length <= 0) continue; // Unit normal to the street, for the kerb offset. const nx = -dz / length; const nz = dx / length; let s = carry; for (; s < length; s += spacing) { if (index >= limit) break; const t = s / length; const lat = lat0 + (lat1 - lat0) * t; const lng = lng0 + (lng1 - lng0) * t; // Alternating kerbs, jittered, because a street lit by a perfect ruler // of identical dots reads as a dashed line and not as lighting. const side = index % 2 === 0 ? 1 : -1; const offset = road.width * 0.55 * side * (0.8 + rand() * 0.4); positions.push( x0 + dx * t + nx * offset, world.groundAt(lat, lng) + lift, z0 + dz * t + nz * offset, ); index++; } carry = Math.max(0, s - length); } } if (positions.length === 0) return null; const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); const material = new THREE.PointsMaterial({ color: LAMP_COLOR, map: lampTexture(), size: LAMP_SIZE, sizeAttenuation: true, transparent: true, opacity: 0, // Additive, so a hundred lamps down one street saturate into the continuous // line of light that a street at night actually is, rather than staying a // hundred separate dots however far away they are. blending: THREE.AdditiveBlending, depthWrite: false, }); const points = new THREE.Points(geometry, material); points.name = "streetlamps"; points.visible = false; return { points, material }; } /** * The lamp's glow, drawn on a canvas rather than shipped as a file. No binary * assets is a licensing rule and not a stylistic one; see ARCHITECTURE.md. */ function lampTexture(): THREE.Texture { const canvas = document.createElement("canvas"); canvas.width = 64; canvas.height = 64; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("2D canvas context unavailable"); const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); gradient.addColorStop(0, "rgba(255,255,255,1)"); gradient.addColorStop(0.22, "rgba(255,232,190,0.7)"); gradient.addColorStop(0.55, "rgba(255,190,110,0.18)"); gradient.addColorStop(1, "rgba(255,170,80,0)"); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 64, 64); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; return texture; } // ---- Helpers -------------------------------------------------------------- /** Hermite ease over a span, flat at both ends. `atmosphere.ts` has the twin. */ function smoothstep(edge0: number, edge1: number, x: number): number { if (edge1 === edge0) return x < edge0 ? 0 : 1; const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0))); return t * t * (3 - 2 * t); } // ---- Sanity checks -------------------------------------------------------- /** * What this produces for San Francisco, so the numbers above can be argued with. * * At `metresPerUnit` 94.34 and a vertical exaggeration of 3.6, a window bay is * 0.0424 scene units across and a storey is 0.1374 units tall — so a 260 m * tower gets 72 floors and a 40 m lot's frontage gets ten bays, both of which * are about right. The camera orbits between 12 and 340 units, and at 100 units * out with a 42° field of view a bay covers roughly half a pixel, which is why * `FRAGMENT_BODY` spends four lines on `fwidth` and would be unusable without * them. * * The switch-on sequence, by solar elevation: * * - **+5° and above**: 0. The lamps are not drawn at all. * - **+2°**: 0.076. The first offices, barely findable against the sky. * - **0°, sunset**: 0.178. * - **-2°**: 0.381. * - **-5°, most of the way through civil twilight**: 0.814. * - **-8° and below**: 1.0. The lights stopped changing some minutes ago; * what changed after that was the sky behind them. * * Downtown's mean emission at distance is 2.6 x 0.2128 x 0.24 x 0.95 = 0.126, * against the avenues' 0.043 — a ratio of just under 3:1, which is the whole * picture, since the thing that makes a night skyline is not that the towers * are taller but that they are the part of the city with all its lights still * on. Around each of those figures the per-building variation spans 0.15x to * 2.7x, so a run of towers has dark ones in it and the odd one blazing, and the * financial district does not smear into a single rectangle when you pull back. * * On a moonless night, whole-board framing, measured off the render: the Bay * Area board puts downtown at about y34 mean and its brightest windows past * y130, against land at y23, bay water at y15 and sky at y29; the SoCal board, * which has no marine layer over it, comes out at y36 / y137 against land y34, * ocean y13 and sky y15. The city is comfortably the brightest thing in the * frame in both, which is the relationship that has to hold — and it stopped * holding, briefly, when `atmosphere.ts` first raised the ground under it. * That is what the 2.6 and the 0.95 are for. * * SF's twenty-nine roads at 55 m spacing come to 12,038 lamps in one draw call, * comfortably under the 24,000 ceiling. The ceiling exists for the city pack * that arrives with a full street network in it rather than twenty-nine * arterials, where the same spacing would produce a point cloud in the millions. */