1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/engine/nightlights.ts
T
karti 5bc7258753 A plan view in the corner, a night you can actually see, and three kinds of visitor
The right half of the screen was empty sky. It holds the board now, drawn flat,
with the footprint of the camera's own frustum on it — the one part of a minimap
that earns its place, because it answers "where am I looking from" without
leaving the shot. Click it, drag it, scroll it. It is a 2D canvas rather than a
second WebGL context, cached per city and redrawn only when something moved.

Night was black. Not dark — black: at 3 a.m. the coastline, the hills and the
bay were one shape, and the frame read as a failed render rather than as
darkness. The sky already had a floor for exactly this reason and nothing did
the equivalent for the ground, so the ground has one now. The moon still has to
be worth computing, so the gap between a moonlit night and a moonless one is
preserved rather than filled in.

Three tiers, resolved once in the new src/access.ts: anonymous, signed in,
admin. Anonymous gets the map and a public office — the shell, the furniture,
the named viewpoints, nobody home — built without the private objects rather
than with them hidden, because scene.traverse makes hiding a leak with a bow on
it. The time scrubber and the debug readouts are admin only, and admin is
granted by TERA_ADMIN_SUBJECTS on the server and inferred nowhere else. An
unreachable API means member, never god: the promise is "clone it and it works",
not "clone it and you are an administrator of a deployment you did not
configure".

Three things this run found and fixed rather than shipped:

  - entryUrl came off the wire and went straight into an href with no scheme
    check, and a CSP of script-src 'self' 'unsafe-inline' does not stop a
    javascript: URL from navigating. One rejection point in access.ts now.
  - A 5xx from /health was the same null as "no API at all" and therefore the
    opposite conclusion. Eight seconds of tera-api restarting would have told
    every anonymous visitor they were a member. A 5xx is an answer; it fails
    closed.
  - decodeURIComponent in cookieToken was the one path in auth/index.ts that
    threw rather than returning ANONYMOUS, so one malformed cookie header from
    an unauthenticated caller turned /api/v1/session into a 500.

Also: keyboard shortcuts, focus rings, a boot state instead of a blank 2.3
seconds, a collapsible panel under 900px, and no horizontal overflow at 375,
768, 1440 or 2560.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:53:30 -07:00

601 lines
25 KiB
TypeScript

/**
* 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<string, { value: unknown }>;
/**
* 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 <common>", `#include <common>\n${VERTEX_PARS}`)
.replace("#include <project_vertex>", `#include <project_vertex>\n${VERTEX_BODY}`);
shader.fragmentShader = shader.fragmentShader
.replace("#include <common>", `#include <common>\n${FRAGMENT_PARS}`)
.replace(
"#include <emissivemap_fragment>",
`#include <emissivemap_fragment>\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.
*/