1
0

SoCal, the whole bay, a moon, and gates that actually run

Six agents in parallel, and the two city packs independently reported the same
blocker: `focusRegions` and `coarseFactor` existed on the `City` type and
nothing implemented them. Uniform lattices would have been 2.9M points for
Southern California and 3.7M for the expanded bay. Both packs were unloadable
as written.

`buildAxis` is the answer, and it is honest about its limits: refinement is per
axis, not per rectangle, so a focus region sharpens its whole row *and* its
whole column. Two regions at opposite corners refine nearly everything between
them. Measured, not guessed — the bay went 0.53M points with one region and
1.64M with three, for detail nobody is looking at from a board this wide. One
region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s.

Then three things that were only ever right because San Francisco was the only
city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a
230-unit board; the bay is 1003 units across and the camera physically could
not retreat far enough to frame it. Fog distances were scene units pinned to
the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest
weather, which over ninety-four kilometres of bay correctly hides three
quarters of it — the night view was a black rectangle for a completely
reasonable reason. All three now derive from the board.

The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a
physical ratio of one to four hundred thousand. What is being reproduced is
what a moonlit night looks like on a screen in a lit room.

The CI gate caught itself, which is the part worth keeping. Port 8431 was
already held by a server from an earlier session, so the boot check polled a
healthy stranger while the process it started died on EADDRINUSE. It now
refuses to run rather than pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Karti Tripathi
2026-08-05 03:13:32 -07:00
parent 8bcb391455
commit 44c5a79424
25 changed files with 9246 additions and 220 deletions
+521 -19
View File
@@ -20,11 +20,12 @@
* and touches nothing. Call it every frame or once a minute; the cost is
* the same table lookup either way.
*
* The solar half is computed locally by `solar.ts` with no network, and the
* weather half degrades to `null` — which this file reads as a clear day with
* the local climatology still running. The whole engine has to work with no
* account, no key and no network, and a sky that goes flat grey the moment the
* wifi drops would fail that in the most visible way possible.
* The solar half is computed locally by `solar.ts` with no network, the lunar
* half by `moonPosition` below, and the weather half degrades to `null` — which
* this file reads as a clear day with the local climatology still running. The
* whole engine has to work with no account, no key and no network, and a sky
* that goes flat grey the moment the wifi drops would fail that in the most
* visible way possible.
*
* Wiring one to a city, in full:
*
@@ -100,18 +101,205 @@ export interface WeatherObservation {
export interface Environment {
time: Date;
sun: SolarPosition;
/**
* The other light in the sky. Required rather than optional because a night
* without it is the black rectangle this file exists to avoid, and an
* `Environment` assembled without one would silently be that.
*/
moon: MoonPosition;
/** `null` when nobody was asked. A supported state, not an error. */
weather: WeatherObservation | null;
}
/** Build an `Environment` for a place and an instant, computing the sun locally. */
/**
* Build an `Environment` for a place and an instant, computing both bodies
* locally.
*/
export function observe(
lat: number,
lng: number,
when: Date,
weather: WeatherObservation | null = null,
): Environment {
return { time: when, sun: solarPosition(lat, lng, when), weather };
return {
time: when,
sun: solarPosition(lat, lng, when),
moon: moonPosition(lat, lng, when),
weather,
};
}
// ---- The moon -------------------------------------------------------------
/**
* Where the moon is, and how much of it is lit.
*
* This lives here rather than in `solar.ts` because it exists for exactly one
* consumer: the key light after sunset. `solar.ts` is the sun's own module and
* has a much harder accuracy contract to keep — sunrise to the minute — whereas
* nothing downstream of this can tell a tenth of a degree of moon from the
* right answer.
*/
export interface MoonPosition {
/** Degrees clockwise from true north, matching `SolarPosition.azimuth`. */
azimuth: number;
/** Degrees above the horizon, corrected for parallax. */
elevation: number;
/** Fraction of the visible disc in sunlight: 0 at new, 1 at full. */
illuminated: number;
/** 0 new, 0.25 first quarter, 0.5 full, 0.75 last quarter. */
phase: number;
/** Centre to centre, in kilometres. Roughly 356,500 to 406,700. */
distanceKm: number;
}
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
const JD_UNIX_EPOCH = 2_440_587.5;
const J2000 = 2_451_545;
/** Equatorial radius, for the parallax correction. */
const EARTH_RADIUS_KM = 6378.14;
/**
* The moon's position and phase, computed rather than fetched.
*
* Meeus, *Astronomical Algorithms* chapter 47, truncated hard. The full ELP
* series is sixty periodic terms in longitude alone; what is here is the
* thirteen largest, which are the ones with names — the equation of centre
* (6.29°, the orbit being an ellipse), the **evection** (1.27°, the sun pulling
* the orbit's own ellipse around every 32 days and the single largest thing
* Ptolemy did not know about), the **variation** (0.66°, the moon running fast
* at the syzygies and slow at the quadratures), the annual equation and the
* parallactic inequality. Against Meeus's own worked example 47.a — 1992 April
* 12, 0h TD — this returns 133.150° against his 133.163°, a latitude of -3.223°
* against -3.229°, and 368,335 km against 368,410. Thirteen thousandths of a
* degree. The moon's disc is half a degree wide, so a renderer cannot see the
* error and neither can anyone looking at it.
*
* Two deliberate omissions. The eccentricity factor `E` that Meeus applies to
* every term in the sun's mean anomaly is dropped, because it is a correction
* of about 0.017 to terms already under a fifth of a degree. And the elevation
* is not refracted — `solar.ts` owns that curve and does not export it, and at
* moonrise the moon is contributing almost nothing anyway. Parallax *is*
* applied, because it is the big one: the moon is close enough that standing on
* the surface of the Earth rather than at its centre moves it by most of a
* degree, which is fifty times the truncation error.
*/
export function moonPosition(lat: number, lng: number, when: Date): MoonPosition {
const jd = when.getTime() / MS_PER_DAY + JD_UNIX_EPOCH;
const t = (jd - J2000) / 36_525;
const sin = (deg: number) => Math.sin(deg * DEG);
const cos = (deg: number) => Math.cos(deg * DEG);
// The Delaunay arguments, Meeus 47.1-47.5: the moon's mean longitude, its
// mean elongation from the sun, the sun's mean anomaly, the moon's own mean
// anomaly, and its argument of latitude — the angle from the ascending node,
// which is what makes the moon's path wander 5° either side of the ecliptic.
const meanLongitude = 218.3164477 + 481_267.88123421 * t - 0.0015786 * t * t;
const elongation = 297.8501921 + 445_267.1114034 * t - 0.0018819 * t * t;
const sunAnomaly = 357.5291092 + 35_999.0502909 * t - 0.0001536 * t * t;
const anomaly = 134.9633964 + 477_198.8675055 * t + 0.0087414 * t * t;
const argLatitude = 93.272095 + 483_202.0175233 * t - 0.0036539 * t * t;
const d = elongation;
const m = sunAnomaly;
const mp = anomaly;
const f = argLatitude;
const longitude =
meanLongitude +
6.288774 * sin(mp) + // equation of centre
1.274027 * sin(2 * d - mp) + // evection
0.658314 * sin(2 * d) + // variation
0.213618 * sin(2 * mp) -
0.185116 * sin(m) - // annual equation
0.114332 * sin(2 * f) +
0.058793 * sin(2 * d - 2 * mp) +
0.057066 * sin(2 * d - m - mp) +
0.053322 * sin(2 * d + mp) +
0.045758 * sin(2 * d - m) -
0.040923 * sin(m - mp) -
0.03472 * sin(d) - // parallactic inequality
0.030383 * sin(m + mp);
const latitude =
5.128122 * sin(f) +
0.280602 * sin(mp + f) +
0.277693 * sin(mp - f) +
0.173237 * sin(2 * d - f) +
0.055413 * sin(2 * d - mp + f) +
0.046271 * sin(2 * d - mp - f) +
0.032573 * sin(2 * d + f) +
0.017198 * sin(2 * mp + f) +
0.009266 * sin(2 * d + mp - f) +
0.008822 * sin(2 * mp - f);
const distanceKm =
385_000.56 -
20_905.355 * cos(mp) -
3699.111 * cos(2 * d - mp) -
2955.968 * cos(2 * d) -
569.925 * cos(2 * mp);
// The sun's apparent longitude, to the same standard: needed only for the
// elongation the phase is read off, where a hundredth of a degree is three
// decimal places more than the illuminated fraction can carry.
const sunLongitude =
280.46646 +
36_000.76983 * t +
1.914602 * sin(m) +
0.019993 * sin(2 * m) +
0.000289 * sin(3 * m);
// Ecliptic to equatorial.
const obliquity = (23.4392911 - 0.0130042 * t) * DEG;
const lambda = longitude * DEG;
const beta = latitude * DEG;
const rightAscension = Math.atan2(
Math.sin(lambda) * Math.cos(obliquity) - Math.tan(beta) * Math.sin(obliquity),
Math.cos(lambda),
);
const declination = Math.asin(
clamp(
Math.sin(beta) * Math.cos(obliquity) + Math.cos(beta) * Math.sin(obliquity) * Math.sin(lambda),
-1,
1,
),
);
// Equatorial to horizontal, through the local hour angle. Greenwich sidereal
// time is Meeus 12.4: the extra 0.98564736629° a day over 360 is the Earth's
// orbital motion, which is the whole reason a sidereal day is four minutes
// short of a solar one.
const gmst = mod(280.46061837 + 360.98564736629 * (jd - J2000) + 0.000387933 * t * t, 360);
const hourAngle = (gmst + lng) * DEG - rightAscension;
const phi = lat * DEG;
const sinAltitude = clamp(
Math.sin(phi) * Math.sin(declination) +
Math.cos(phi) * Math.cos(declination) * Math.cos(hourAngle),
-1,
1,
);
let altitude = Math.asin(sinAltitude);
const azimuth = Math.atan2(
-Math.cos(declination) * Math.sin(hourAngle),
Math.sin(declination) * Math.cos(phi) - Math.cos(declination) * Math.sin(phi) * Math.cos(hourAngle),
);
altitude -= Math.asin(EARTH_RADIUS_KM / distanceKm) * Math.cos(altitude);
// Phase from the sun-moon elongation. The proper phase angle also wants the
// earth-sun distance, which moves the answer by about a sixth of a degree —
// two parts in a thousand of the illuminated fraction, and this drives a
// light rig.
const separation = mod(longitude - sunLongitude, 360);
return {
azimuth: mod(azimuth * RAD, 360),
elevation: altitude * RAD,
illuminated: (1 - Math.cos(separation * DEG)) / 2,
phase: separation / 360,
distanceKm,
};
}
// ---- Options --------------------------------------------------------------
@@ -178,6 +366,45 @@ export const PACIFIC_MARINE_LAYER: MarineLayerOptions = {
visibilityM: 5000,
};
/**
* Moonlight as a look rather than as a photometry.
*
* Full moonlight is about one four-hundred-thousandth of sunlight. Reproducing
* that ratio faithfully gives you a black screen, because a monitor has three
* orders of magnitude of range and this needs six, and because the eye that
* makes a moonlit landscape legible is doing an hour of dark adaptation that a
* lit room will not allow. So the numbers here are not the ratio; they are what
* a moonlit night *looks like* once you are in it — a low, soft, blue-shifted
* key you can read shapes by, on a sky that is deep blue rather than absent.
*
* The blue is the interesting lie. Moonlight is reflected sunlight off a
* grey-brown rock and is very slightly *warmer* than daylight, around 4,100 K.
* It looks blue because at those levels the eye is running on rods, whose peak
* sensitivity sits about 50 nm bluer than the cones' — the Purkinje shift — so
* a moonlit scene genuinely is blue to the person standing in it while being
* neutral to a light meter. Rendering it neutral is the more accurate choice
* and the wrong one, and every cinematographer since the 1930s has agreed.
*/
export interface MoonlightOptions {
/** Key intensity of a full moon, high, in clear air. Compare a noon sun at 2.1. */
intensity: number;
/** The key's colour. */
color: number;
/** How far a full moon lifts the night sky toward moonlit blue, 0..1. */
skyLift: number;
}
export const DEFAULT_MOONLIGHT: MoonlightOptions = {
// Physically absurd — moonlight is about 1/400,000 of sunlight — and the
// right number anyway. What is being reproduced is the *look* of a moonlit
// night on a screen someone is looking at in a lit room, not the photon
// count. At the honest value the map is a black rectangle, which is the bug
// this exists to fix.
intensity: 1.15,
color: 0x9db4e8,
skyLift: 0.85,
};
export interface AtmosphereOptions {
/**
* Observer longitude, degrees east. Needed for apparent solar time, which is
@@ -216,6 +443,13 @@ export interface AtmosphereOptions {
shadowFloorDeg?: number;
/** Coastal fog model, or nothing. Off unless a city asks for it. */
marineLayer?: MarineLayerOptions | null;
/**
* Moonlight, or `null` for none — which leaves the keyframe table's token
* night sidelight in charge, as it was before there was a moon to replace it.
* Defaults to `DEFAULT_MOONLIGHT`, because a black night is the failure and
* having to opt out of the fix is the wrong way round.
*/
moonlight?: MoonlightOptions | null;
}
export interface Atmosphere {
@@ -236,6 +470,26 @@ const DEFAULT_FOG_FAR = 460;
const DEFAULT_MIN_VISIBILITY_M = 4500;
const DEFAULT_SHADOW_FLOOR_DEG = 7;
/**
* The darkest the night sky is ever allowed to get, per channel.
*
* A correct night is `#000`, and `#000` is unusable: the horizon disappears,
* the skyline stops having a silhouette against anything, and the frame reads
* as a failed render rather than as darkness. These are the values of a clear
* moonless sky as a dark-adapted eye reports it rather than as a photometer
* does — still unmistakably night, with the horizon a little warmer and
* brighter than the zenith because that is where the airglow and everyone
* else's city lights are.
*/
const NIGHT_FLOOR_TOP = 0x090e1c;
const NIGHT_FLOOR_HORIZON = 0x16203a;
/** The same sky with a full moon in it. */
const MOONLIT_SKY_TOP = 0x111d3e;
const MOONLIT_SKY_HORIZON = 0x2d3c62;
const MOONLIT_HEMI_SKY = 0x2b3b60;
const MOONLIT_AMBIENT = 0x3f4c76;
/**
* Where fog starts, as a fraction of where it ends. `cityDaylight`'s 210/460 is
* 0.457 and looks right, so the ratio is held rather than the distance: a fog
@@ -456,6 +710,7 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
const floorFar = (options.minVisibilityM ?? DEFAULT_MIN_VISIBILITY_M) / metresPerUnit;
const shadowFloor = options.shadowFloorDeg ?? DEFAULT_SHADOW_FLOOR_DEG;
const marineOptions = options.marineLayer ?? null;
const moonOptions = options.moonlight === undefined ? DEFAULT_MOONLIGHT : options.moonlight;
function apply(env: Environment): LightingState {
const elevation = env.sun.elevation;
@@ -468,23 +723,37 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
// still black, and a modifier that does not know the difference will
// cheerfully raise the small hours to a uniform slate.
const day = smoothstep(-6, 6, elevation);
const night = nightFactor(elevation);
const weather = env.weather;
const cloud = clamp(weather?.cloudCover ?? 0, 0, 1);
const precipitation = clamp(weather?.precipitation ?? 0, 0, 1);
const condition = weather?.condition ?? "clear";
applyCloud(rig, cloud, day);
applyPrecipitation(rig, precipitation, condition, day);
// An observation always beats the climatology: `null` lets the model run
// free, which is what gives an offline San Francisco its summer fog, but a
// station reporting sunshine ends the argument. See `observedObscuration`.
//
// Computed here rather than after the rig passes, where it used to sit,
// because the moon needs to know how much air is in the way before it can
// say how much light is getting through. Both halves are pure functions of
// the observation and neither touches the rig, so the move is a reordering
// of independent statements and nothing else.
const observed = weather ? observedObscuration(weather) : null;
const modelled = marineOptions ? marineStrength(marineOptions, env, lng) : 0;
const obscuration =
observed === null ? modelled : observed === 0 ? 0 : Math.max(observed, modelled);
// The moon goes in before the weather does, so that an overcast night is
// the weather closing over a moonlit sky rather than over a black one.
const moon = moonOptions
? moonRig(env.moon, night, cloud, obscuration, moonOptions, shadowFloor)
: NO_MOON;
applyNight(rig, moon, night, moonOptions);
applyCloud(rig, cloud, day);
applyPrecipitation(rig, precipitation, condition, day);
let fogFar = visibilityFar(weather, condition, clearFar, metresPerUnit);
if (weather === null || weather.visibilityKm === null) {
// Rain shortens the view; a source that measured visibility has already
@@ -505,11 +774,7 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
const near = clamp(fogFar >= clearFar ? clearNear : fogFar * FOG_NEAR_RATIO, 2, fogFar * 0.9);
return {
sun: {
direction: lightDirection(env.sun, shadowFloor),
color: rig.sunColor,
intensity: Math.max(0, rig.sunIntensity),
},
sun: combineKey(lightDirection(env.sun, shadowFloor), rig.sunColor, rig.sunIntensity, moon),
hemisphere: {
sky: rig.hemiSky,
ground: rig.hemiGround,
@@ -563,6 +828,190 @@ function lightDirection(sun: SolarPosition, floorDeg: number): [number, number,
return [dir.x, dir.y, dir.z];
}
// ---- Night ----------------------------------------------------------------
/**
* How much of a night it is, from the sun alone: 0 in daylight, 1 once the
* sun's own light is gone.
*
* Exported because it is the engine's single definition of dusk, and more than
* one thing needs one. The moon takes over as the key light on this curve, and
* `nightlights.ts` reads it to decide how brightly a lit window burns against
* the sky behind it. Two modules each inventing their own idea of when night
* begins is how a city ends up switching its lights on well after the moon has
* already become the brightest thing in the frame.
*
* The upper edge is half a degree *above* the horizon rather than on it,
* because the sun's last half degree is its own disc setting and the light has
* already collapsed by then; the lower edge is the middle of nautical twilight,
* by which point what is left of the sun is a glow in one direction and not a
* light source.
*/
export function nightFactor(elevation: number): number {
return 1 - smoothstep(-8, 0.5, elevation);
}
/** The moon's contribution, once phase, altitude and the weather have had a say. */
interface MoonRig {
/** Unit vector toward the moon, floored like the sun's. `null` when it is down. */
direction: [number, number, number] | null;
color: number;
/** Key intensity. */
intensity: number;
/** How moonlit the sky and the fill are, 0..1. */
glow: number;
}
const NO_MOON: MoonRig = { direction: null, color: 0, intensity: 0, glow: 0 };
function moonRig(
moon: MoonPosition,
night: number,
cloud: number,
obscuration: number,
options: MoonlightOptions,
floorDeg: number,
): MoonRig {
// Below the horizon it contributes nothing, and the ramp above it is several
// degrees wide: a moon in the first degrees of its own rise is being
// reddened and extinguished by the same long air path that does it to the
// sun, and it has far less to lose.
const up = smoothstep(-1, 8, moon.elevation);
if (up <= 0 || night <= 0) return NO_MOON;
// Not linear in the illuminated fraction, and this is the part that makes a
// moon phase read as a moon phase. A half moon is nowhere near half as bright
// as a full one — nearer a tenth. Two things do that: at quarter phase the
// ground you can see is lit at a grazing angle and is mostly its own long
// shadows, and at full phase those shadows all hide behind the rocks casting
// them and the disc surges. The exponent is the cheap version of both. The
// floor is earthshine, and the fact that a key of exactly zero leaves a scene
// with no silhouettes in it at all.
const lit = 0.06 + 0.94 * clamp(moon.illuminated, 0, 1) ** 1.6;
// Cloud takes the key away much faster than it takes the glow away, and that
// asymmetry is the whole character of an overcast night: no shadows at all,
// but a deck lit from above that is brighter than a clear moonless sky.
const key = night * up * lit * clamp((1 - 0.9 * cloud) * (1 - 0.92 * obscuration), 0, 1);
const glow = night * up * lit * clamp(1 - 0.35 * cloud, 0, 1);
const lifted = floorDeg > 0 ? Math.max(moon.elevation, floorDeg) : moon.elevation;
return {
direction: skyDirection(moon.azimuth, lifted),
color: options.color,
intensity: options.intensity * key,
glow: clamp(glow, 0, 1),
};
}
/**
* Horizontal coordinates to the engine's axes: `x` east, `z` south, `y` up.
*
* `solar.ts` exports `sunDirection` for exactly this conversion and it would
* work unchanged on the moon — it reads only an azimuth and an elevation — but
* its argument is a `SolarPosition`, and inventing a declination and an
* equation of time to satisfy a type is worse than four lines that say what
* they mean. The convention is `solar.ts`'s and must stay it: azimuth is
* measured from north, and north is `-z`.
*/
function skyDirection(azimuth: number, elevation: number): [number, number, number] {
const el = elevation * DEG;
const az = azimuth * DEG;
const horizontal = Math.cos(el);
return [horizontal * Math.sin(az), Math.sin(el), -horizontal * Math.cos(az)];
}
/**
* Fold the night into the rig: the moon's fill, the residual glow of a moonless
* night, and the floor under the sky.
*
* The key itself is not set here — see `combineKey` — because a `LightingState`
* carries one directional light and the sun has not necessarily finished with
* it yet.
*/
function applyNight(
rig: Rig,
moon: MoonRig,
night: number,
options: MoonlightOptions | null,
): void {
if (night <= 0) return;
if (options) {
// The table's deepest stops carry a token sidelight standing in for
// moonlight and city glow, because a scene lit by hemisphere alone has no
// silhouettes in it and reads as a bug. There is a real moon now, so the
// stand-in gets out of its way — not entirely, because something still has
// to hold the shape of the city up on an overcast night at new moon.
rig.sunIntensity *= 1 - 0.85 * night;
const glow = clamp(moon.glow * options.skyLift, 0, 1);
rig.skyTop = mixHex(rig.skyTop, MOONLIT_SKY_TOP, glow);
rig.skyHorizon = mixHex(rig.skyHorizon, MOONLIT_SKY_HORIZON, glow);
rig.hemiSky = mixHex(rig.hemiSky, MOONLIT_HEMI_SKY, 0.7 * glow);
rig.ambientColor = mixHex(rig.ambientColor, MOONLIT_AMBIENT, 0.7 * glow);
rig.hemiIntensity *= 1 + 0.5 * glow;
rig.ambientIntensity *= 1 + 0.45 * glow;
}
// Starlight, airglow, and the sodium of everywhere else bouncing off whatever
// is overhead. Small, unshaped, and the difference between a night that is
// dark and a night that is missing.
rig.hemiIntensity *= 1 + 0.28 * night;
rig.ambientIntensity *= 1 + 0.22 * night;
// Upward only and per channel, so this can rescue a sky and can never dim
// one — the weather passes that follow are free to keep taking light out of
// the frame without having to know this ran.
rig.skyTop = mixHex(rig.skyTop, atLeast(rig.skyTop, NIGHT_FLOOR_TOP), night);
rig.skyHorizon = mixHex(rig.skyHorizon, atLeast(rig.skyHorizon, NIGHT_FLOOR_HORIZON), night);
}
/**
* One directional light, two things in the sky.
*
* `LightingState` carries a single key and that is the right shape — a second
* shadow-casting light is a second shadow map and a second full pass over 24k
* instances, for a source that is a two-hundred-thousandth as bright as the one
* already there. So the two get averaged, weighted by their own intensities,
* which is what a single light standing in for both ought to do: at dusk with a
* bright moon already up the key points somewhere between them, and by the time
* either one dominates it has arrived at that one. The crossover happens inside
* civil twilight, where both are near a tenth of an intensity and there is
* almost no shadow left to be wrong about.
*/
function combineKey(
sunDir: [number, number, number],
sunColor: number,
sunIntensity: number,
moon: MoonRig,
): LightingState["sun"] {
const sun = Math.max(0, sunIntensity);
if (!moon.direction || moon.intensity <= 0) {
return { direction: sunDir, color: sunColor, intensity: sun };
}
const total = sun + moon.intensity;
const weight = moon.intensity / total;
const x = lerp(sunDir[0], moon.direction[0], weight);
const y = lerp(sunDir[1], moon.direction[1], weight);
const z = lerp(sunDir[2], moon.direction[2], weight);
const length = Math.hypot(x, y, z);
// A full moon rises as the sun sets and the two stand opposite each other,
// which is the one configuration where the average of the two directions is
// nothing at all. A zero direction would put the light inside the ground and
// black the scene out, so take the brighter of the two instead.
const direction: [number, number, number] =
length < 0.05
? weight >= 0.5
? moon.direction
: sunDir
: [x / length, y / length, z / length];
return { direction, color: mixHex(sunColor, moon.color, weight), intensity: total };
}
// ---- Weather --------------------------------------------------------------
/**
@@ -847,6 +1296,21 @@ function desaturate(hex: number, t: number): number {
return encode(lerp(r, y, k), lerp(g, y, k), lerp(b, y, k));
}
/**
* Per-channel maximum, in linear light: a colour raised to a floor and never
* pushed below it.
*
* Per channel rather than by luminance, because the floor is a *colour* — a
* blue-black — and clamping a night sky by its brightness alone would let a
* grey of the same luminance through, which is the one thing the night must not
* be allowed to look like.
*/
function atLeast(hex: number, floor: number): number {
const [r, g, b] = linear(hex);
const [fr, fg, fb] = linear(floor);
return encode(Math.max(r, fr), Math.max(g, fg), Math.max(b, fb));
}
/** Multiply a colour's light, not its bytes. */
function scale(hex: number, factor: number): number {
const [r, g, b] = linear(hex);
@@ -923,10 +1387,13 @@ function wrapSigned(x: number, period: number): number {
* back a clear golden morning or it is not a model of anything — October is
* the month San Francisco is warm and cloudless and every visitor is
* surprised by it.
* - **03:00 PDT** (-23.7°): sun 0.01, hemisphere 0.25, ambient 0.10, and the
* light direction's `y` pinned at 0.122, which is sin 7° — the shadow
* floor, keeping the token night sidelight from shining up through the
* ground.
* - **03:00 PDT** (-23.7°, with the moon down): key 0.002, hemisphere 0.32,
* ambient 0.12, and the light direction's `y` pinned at 0.122, which is
* sin 7° — the shadow floor, keeping what is left of the token night
* sidelight from shining up through the ground. The key is a thousandth
* because the marine layer is at its thickest at 3 a.m. in June and takes
* 88% of it; the sky is a fog grey rather than a night blue for the same
* reason, which is right — a foggy night has no stars in it either.
* - **Solar noon, 21 December** (28.8°): sun 2.07, fog 204/453, sky exactly
* the palette's own. Out of season, the layer is not there.
*
@@ -952,4 +1419,39 @@ function wrapSigned(x: number, period: number): number {
* Tromsø on 5 January, at -2.98°, returns sun 0.30 against a twilight-blue sky
* and nothing non-finite anywhere, which is the polar-night path through
* `solar.ts` arriving here intact.
*
* **At night, San Francisco with no weather and no marine layer**, so that the
* moon can be read on its own:
*
* - **Full moon 43° up** (28 August 2026, 08:00 UTC): key 0.307, colour
* #9bb2e6, hemisphere 0.46, ambient 0.17, sky #101b39 over #2a385b. A
* seventh of the sun's noon intensity against nearly half of its fill,
* which is a soft directional key with shadows you can find and not trip
* over — on a sky that is unmistakably night and unmistakably blue.
* - **New moon, below the horizon** (12 August 2026, 08:00 UTC): key 0.008
* of the table's own sidelight colour, and the sky lands on #090e1c over
* #16203a — the floor, exactly. That is the darkest frame this file can
* produce, and it is the point: a genuinely correct night is `#000` and
* `#000` is a bug report.
* - **Half moon 4° up** (20 August 2026, 06:00 UTC): key 0.078. A quarter of
* the full moon's key from half its disc and a tenth of its altitude,
* which is the phase curve and the rise ramp both doing visible work.
* - **The same full moon with `PACIFIC_MARINE_LAYER` on**: key 0.16 and the
* sky greyed to #232b43 over #2d3855. The fog takes half the moonlight and
* all of the colour, and August is when it would.
* - **The same night reported overcast**: key 0.032 — no shadows at all —
* with the fill barely down, because a cloud deck over a full moon is a
* softbox rather than a lid.
* - **`moonlight: null`** returns the pre-moon rig unchanged: key 0.050,
* colour #2e3c66, the table's token sidelight left in charge. The night
* floor under the sky still applies, because that one is not about the
* moon.
*
* The 28 August 2026 dusk is worth watching as a sequence, because it is the
* configuration `combineKey` exists for — a full moon rising as the sun sets,
* the two of them opposite each other in the sky. At sun +0.8° the key is 0.663
* and #d4835a from the west; at -3.4° it is 0.226 and #9482a0 from between
* them; by -7.3° it is 0.303 and #9ab0e3 from the east. The shadows swing
* across the city over about half an hour, which is not an artefact — it is
* what actually happens, and on the one night a month it happens on.
*/
+50
View File
@@ -31,6 +31,36 @@ const PALETTES = {
industrial: [0xbdb5a8, 0xa89f92, 0xcac2b4, 0xb0a89a, 0x9c9488],
} satisfies Record<District["palette"], number[]>;
/**
* How commercial each palette's buildings are, 0..1.
*
* Read only by `nightlights.ts`, and the reason a night city looks like a city
* rather than like a uniform field of dots: an office floor is a continuous
* band of large windows with half of them left on all night, and a house is two
* small warm rectangles that go out. The number is the same fact the palette
* already encodes, which is why it is derived from it rather than authored
* again per district.
*/
const COMMERCIAL = {
downtown: 1,
residential: 0.12,
industrial: 0.45,
} satisfies Record<District["palette"], number>;
/**
* The name of the per-instance attribute `createBlocks` leaves on its geometry:
* `[commercial, seed]`.
*
* A vertex attribute rather than a field on `userData` because the only
* consumer is a shader, and this puts the data where the GPU already wants it.
* `createBlocks` writes it because `createBlocks` is what knows which district
* a given instance came out of; nothing else can recover that from the mesh.
*/
export const FACADE_ATTRIBUTE = "aFacade";
/** An independent stream for the facades; see where it is drawn from. */
const FACADE_SEED = 20_261;
interface Box {
x: number;
z: number;
@@ -40,6 +70,7 @@ interface Box {
h: number;
rot: number;
color: THREE.Color;
commercial: number;
}
function polygonBounds(poly: [number, number][]) {
@@ -65,6 +96,7 @@ export function createBlocks(world: World): THREE.InstancedMesh {
seedBase += 7919;
const palette = PALETTES[district.palette];
const commercial = COMMERCIAL[district.palette];
const angle = district.gridAngle;
const coverage = district.coverage ?? 0.88;
@@ -124,6 +156,8 @@ export function createBlocks(world: World): THREE.InstancedMesh {
h: world.metres(heightM),
rot: angle + (rand() - 0.5) * 0.03,
color: new THREE.Color(palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6),
// A tower is an office whatever district it landed in.
commercial: Math.min(1, commercial + (isTower ? 0.4 : 0)),
});
}
}
@@ -132,6 +166,22 @@ export function createBlocks(world: World): THREE.InstancedMesh {
const geometry = new THREE.BoxGeometry(1, 1, 1);
geometry.translate(0, 0.5, 0); // pivot at the base, so y is ground level
// The per-instance facade data, drawn from a stream of its own.
//
// The obvious place for the seed is inside the placement loop, next to every
// other `rand()` — and putting it there would have been a mistake, because a
// scatter's draw sequence is load-bearing. One extra call shifts every
// subsequent draw, and the whole city would have rebuilt itself the first
// time anyone lit a window. A second stream costs nothing, is just as
// deterministic across reloads, and leaves the skyline exactly where it was.
const windows = seededRandom(FACADE_SEED);
const facade = new Float32Array(boxes.length * 2);
boxes.forEach((b, i) => {
facade[i * 2] = b.commercial;
facade[i * 2 + 1] = windows();
});
geometry.setAttribute(FACADE_ATTRIBUTE, new THREE.InstancedBufferAttribute(facade, 2));
const mesh = new THREE.InstancedMesh(geometry, new THREE.MeshLambertMaterial(), boxes.length);
mesh.name = "blocks";
mesh.castShadow = true;
+439 -31
View File
@@ -15,6 +15,7 @@
*/
import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import type { Aircraft, FlightSource } from "./types.ts";
import { seededRandom, type World } from "./world.ts";
@@ -54,20 +55,33 @@ export class SimulatedFlights implements FlightSource {
this.t += Math.min(now - this.last, 5);
this.last = now;
return this.routes.map((route, i) => {
const p = ((this.t / route.duration + (this.phase[i] ?? 0)) % 1 + 1) % 1;
const lat = route.from[0] + (route.to[0] - route.from[0]) * p;
const lng = route.from[1] + (route.to[1] - route.from[1]) * p;
// Ease the altitude so departures climb steeply and level off.
const ease = 1 - (1 - p) ** 2;
const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease;
const heading =
(Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI;
return { id: `sim-${route.callsign}`, callsign: route.callsign, lat, lng, altitude, heading };
});
return this.routes.map((route, i) => sampleRoute(route, this.t / route.duration + (this.phase[i] ?? 0)));
}
}
/**
* One aircraft's state at a fraction of the way along its leg. `p` wraps, so
* anything can be handed in and 1.4 means the same as 0.4.
*
* Split out of `SimulatedFlights.poll` because the HTTP adapter needs exactly
* this and cannot reuse the class to get it: `SimulatedFlights` runs on a
* monotonic clock that starts when it is constructed, whereas the wire's
* `FlightsPlanBody` anchors every route to a fixed epoch so that two browsers
* agree about where the aircraft are. Same arithmetic, different origin — and
* two copies of the arithmetic would drift.
*/
export function sampleRoute(route: SimRoute, p: number): Aircraft {
const t = ((p % 1) + 1) % 1;
const lat = route.from[0] + (route.to[0] - route.from[0]) * t;
const lng = route.from[1] + (route.to[1] - route.from[1]) * t;
// Ease the altitude so departures climb steeply and level off.
const ease = 1 - (1 - t) ** 2;
const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease;
const heading =
(Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI;
return { id: `sim-${route.callsign}`, callsign: route.callsign, lat, lng, altitude, heading };
}
function nowSeconds(): number {
return (typeof performance !== "undefined" ? performance.now() : 0) / 1000;
}
@@ -125,53 +139,447 @@ interface RawAircraft {
export interface FlightLayer {
group: THREE.Group;
/**
* Hand over a fresh observation. Called on the source's own timer, which is
* once a second for the simulator and once every several seconds for a real
* feed; the motion in between is this layer's problem, not the caller's.
*/
update(aircraft: Aircraft[]): void;
/**
* Move everything to where it should be at this instant.
*
* A pure function of the wall clock and the last two observations, so calling
* it twice in a frame does the same thing as calling it once. That matters:
* the layer drives itself from the trail geometry's `onBeforeRender` — see
* `createFlightLayer` — and a scene that also ticks it explicitly must not end
* up advancing time twice as fast.
*/
tick(): void;
dispose(): void;
}
/**
* Aircraft as small darts with a shadow-less trail. Rendered at true altitude
* through the world's vertical exaggeration, so a jet on approach sits visibly
* below one at cruise.
* How many observations a trail remembers, and how long it may hold one.
*
* Both limits are needed. The count keeps the shared vertex buffer bounded, and
* the age keeps a slow feed from drawing a trail across the entire bay: at
* `AdsbFlights`'s eight-second interval, twenty samples is nearly three minutes
* of flying, which is most of a leg.
*/
const TRAIL_POINTS = 20;
const TRAIL_SECONDS = 45;
/** Ceiling on tracks that get a trail, so the buffer can be allocated once. */
const MAX_TRACKS = 192;
/**
* Opacity at the head of a trail, fading to nothing at the tail. Well under 1
* on purpose: the trail is context for the dart, not a second subject, and a
* dozen opaque lines over a city read as a wiring diagram.
*/
const TRAIL_ALPHA = 0.55;
/**
* Bounds on how long a leg between two observations may be taken to be.
*
* The span is measured rather than declared, because a `FlightSource` announces
* an `interval` and then misses it — a tab in the background, a slow upstream,
* a fetch that took two seconds. Interpolating over the announced interval when
* the real gap was four times that gives an aircraft that darts and then waits.
*/
const MIN_SPAN = 0.2;
const MAX_SPAN = 15;
/**
* Above this, a step is a teleport rather than a flight.
*
* Scene units per second, and generous: a fast jet at this city's ~94 m per
* unit covers about three. The case this exists for is the simulator's routes
* looping — an aircraft reaching the end of its leg reappears at the start,
* which is several hundred units in one poll — and without the check the trail
* draws a bright line straight across San Francisco every time one wraps.
*/
const JUMP_UNITS_PER_SECOND = 8;
/**
* Altitude, as colour.
*
* The obvious cue is a drop line to the ground, and it was tried first and
* removed: this city renders at ~94 m per scene unit with a 3.6× vertical
* exaggeration, so an aircraft at cruise sits about 230 units above a downtown
* whose tallest tower is 10, and its drop line is a full-height wire through the
* middle of the frame. Twelve of those is a birdcage. Colour costs nothing, is
* readable at any camera distance, and — because the trail carries it too — a
* climb shows up as a gradient along the ribbon rather than as a number nobody
* reads.
*/
const LOW_COLOR = new THREE.Color(0xffb277);
const HIGH_COLOR = new THREE.Color(0xdfeaf6);
/** Metres at which the ramp reaches `HIGH_COLOR`. Roughly a cruising airliner. */
const CRUISE_METRES = 9000;
/** Distinct materials along the ramp. Enough to look continuous, few enough to cache. */
const COLOR_BANDS = 12;
/** Steepest nose-up or nose-down attitude a dart is drawn at, in radians. */
const MAX_PITCH = 0.42;
interface TrailSample {
position: THREE.Vector3;
altitude: number;
/** Compass degrees, as reported. */
heading: number;
/** Seconds on `nowSeconds`'s monotonic clock. */
at: number;
}
interface Track {
mesh: THREE.Mesh;
/** Observations, oldest first. The last is where the aircraft is heading. */
samples: TrailSample[];
/** Seconds the current leg should take: the measured gap between the last two. */
span: number;
/** Climb angle of the current leg, radians, positive nose-up. */
pitch: number;
/** Which cached material is on the mesh, so a band change is the only write. */
band: number;
/** Interpolated position, reused rather than reallocated every frame. */
head: THREE.Vector3;
/** Altitude at `head`, which is what the dart's colour is chosen from. */
headAltitude: number;
}
/**
* Aircraft as small darts, each dragging a fading trail of where it has been.
*
* Rendered at true altitude through the world's vertical exaggeration, so a jet
* on approach sits visibly below one at cruise, and coloured by that altitude so
* the difference survives a camera far enough away that the heights stop being
* separable.
*
* The layer moves things every frame while being told where they are only every
* poll. Positions are interpolated between the last two observations rather than
* extrapolated past the newest one: that costs one interval of lag — a second
* for the simulator — and in exchange an aircraft never overshoots and then
* snaps back, which is what extrapolation does the moment a feed stutters.
*/
export function createFlightLayer(world: World): FlightLayer {
const group = new THREE.Group();
group.name = "flights";
const geo = new THREE.ConeGeometry(0.1, 0.42, 5);
geo.rotateX(Math.PI / 2); // point along +z, so heading maps to a Y rotation
const material = new THREE.MeshLambertMaterial({ color: 0xf2f5f8 });
const meshes = new Map<string, THREE.Mesh>();
const geo = dartGeometry();
const materials = new Map<number, THREE.MeshLambertMaterial>();
const tracks = new Map<string, Track>();
const scratch = new THREE.Color();
/**
* One material per altitude band, built on demand.
*
* The emissive term is small and deliberate. Aircraft are lit by the same rig
* as the city, and after sunset that rig is a tenth of an intensity — a dart
* of pure diffuse white simply disappears at night, which is the one time of
* day the sky is worth looking at.
*/
function materialFor(band: number): THREE.MeshLambertMaterial {
const existing = materials.get(band);
if (existing) return existing;
const color = scratch.copy(LOW_COLOR).lerp(HIGH_COLOR, band / (COLOR_BANDS - 1)).getHex();
const mat = new THREE.MeshLambertMaterial({
color,
emissive: color,
emissiveIntensity: 0.35,
});
materials.set(band, mat);
return mat;
}
// ---- The trail ----------------------------------------------------------
// One `LineSegments` for every trail in the scene rather than one per
// aircraft: the vertex count is trivial either way, and a single draw call
// with a preallocated buffer avoids allocating and disposing geometry every
// time traffic changes. Per-vertex alpha does the fade, which needs a
// four-component colour attribute — three.js reads the item size and switches
// the shader on it.
const maxVertices = MAX_TRACKS * TRAIL_POINTS * 2;
const trailPositions = new Float32Array(maxVertices * 3);
const trailColors = new Float32Array(maxVertices * 4);
const trailGeo = new THREE.BufferGeometry();
trailGeo.setAttribute("position", new THREE.BufferAttribute(trailPositions, 3));
trailGeo.setAttribute("color", new THREE.BufferAttribute(trailColors, 4));
trailGeo.setDrawRange(0, 0);
const trailMat = new THREE.LineBasicMaterial({
vertexColors: true,
transparent: true,
// Trails cross each other constantly and are the faintest thing in the
// scene; letting them write depth makes the one that happened to draw first
// punch a hole in every one behind it.
depthWrite: false,
});
const trailLine = new THREE.LineSegments(trailGeo, trailMat);
trailLine.name = "flight-trails";
// The buffer is rewritten from scene-space coordinates every frame, so its
// bounding sphere is permanently wrong and culling it would be culling the
// whole layer.
trailLine.frustumCulled = false;
// The layer is handed observations on the source's timer and is otherwise
// never called, so the interpolation hangs off the one thing guaranteed to
// happen every frame: this line being drawn. `tick` is idempotent, so a scene
// that would rather drive the layer itself can call it and nothing here
// double-counts.
trailLine.onBeforeRender = () => tick();
group.add(trailLine);
// ---- Observations -------------------------------------------------------
function update(aircraft: Aircraft[]) {
const now = nowSeconds();
const seen = new Set<string>();
for (const a of aircraft) {
seen.add(a.id);
let mesh = meshes.get(a.id);
if (!mesh) {
mesh = new THREE.Mesh(geo, material);
meshes.set(a.id, mesh);
group.add(mesh);
}
const [x, z] = world.project(a.lat, a.lng);
mesh.position.set(x, world.metres(a.altitude), z);
mesh.rotation.y = -(a.heading * Math.PI) / 180;
const position = new THREE.Vector3(x, world.metres(a.altitude), z);
const sample: TrailSample = { position, altitude: a.altitude, heading: a.heading, at: now };
let track = tracks.get(a.id);
if (!track) {
const mesh = new THREE.Mesh(geo, materialFor(0));
// Yaw then pitch, because the heading is about the world's vertical and
// the climb angle is about the aircraft's own wing.
mesh.rotation.order = "YXZ";
group.add(mesh);
track = {
mesh,
samples: [],
span: MIN_SPAN,
pitch: 0,
band: -1,
head: position.clone(),
headAltitude: a.altitude,
};
tracks.set(a.id, track);
}
const previous = track.samples[track.samples.length - 1];
if (previous) {
// The clamp is load-bearing on both ends. Two polls arriving in the same
// millisecond — a manual refresh, a tab waking up — divide by nearly
// zero and make every aircraft look like it teleported; a source that
// stalled for a minute makes the next honest step look like one too.
const span = clamp(now - previous.at, MIN_SPAN, MAX_SPAN);
// Ground distance only. Scene height is exaggerated 3.6× here, so a
// healthy climb contributes more to a straight 3-D distance than the
// aircraft's actual speed does, and a departure out of SFO would trip
// the teleport test on every poll.
const travelled = Math.hypot(
position.x - previous.position.x,
position.z - previous.position.z,
);
if (travelled / span > JUMP_UNITS_PER_SECOND) {
// A source that has moved something further than anything flies has
// either looped a simulated route or reused an id. Either way the
// history is about a different flight; keeping it would draw a trail
// across the map.
track.samples.length = 0;
track.head.copy(position);
track.pitch = 0;
} else {
track.span = span;
track.pitch = climbAngle(world, previous, sample);
}
}
track.samples.push(sample);
trim(track, now);
}
for (const [id, mesh] of meshes) {
for (const [id, track] of tracks) {
if (seen.has(id)) continue;
group.remove(mesh);
meshes.delete(id);
group.remove(track.mesh);
tracks.delete(id);
}
tick();
}
/** Forget history that is too old or too long to be worth drawing. */
function trim(track: Track, now: number) {
while (track.samples.length > TRAIL_POINTS) track.samples.shift();
while (track.samples.length > 2) {
const oldest = track.samples[0];
if (!oldest || now - oldest.at <= TRAIL_SECONDS) break;
track.samples.shift();
}
}
// ---- Per-frame ----------------------------------------------------------
function tick() {
const now = nowSeconds();
for (const track of tracks.values()) {
const n = track.samples.length;
const to = track.samples[n - 1];
if (!to) continue;
const from = track.samples[n - 2] ?? to;
const alpha = from === to ? 1 : clamp((now - to.at) / track.span, 0, 1);
track.head.lerpVectors(from.position, to.position, alpha);
track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha;
track.mesh.position.copy(track.head);
// A heading of 0 is north, and north is -z, so a dart whose nose is
// modelled along +z has to be turned all the way round before the compass
// and the scene agree. The previous mapping was a bare negation of the
// heading, which flew every aircraft tail-first and put an easterly
// departure over the Pacific.
track.mesh.rotation.y = Math.PI - (interpolateHeading(from.heading, to.heading, alpha) * Math.PI) / 180;
// Negative, because rotating the nose (+z) about +x by a positive angle
// pushes it down.
track.mesh.rotation.x = -track.pitch;
const band = bandFor(track.headAltitude);
if (band !== track.band) {
track.band = band;
track.mesh.material = materialFor(band);
}
}
rebuildTrails();
}
/**
* Rewrite the shared trail buffer.
*
* The spine is every observation except the newest, followed by the
* interpolated head — the newest observation is where the aircraft is *going*,
* and drawing to it would put the trail in front of the dart.
*/
function rebuildTrails() {
let vertex = 0;
for (const track of tracks.values()) {
const spine = track.samples.length - 1;
if (spine < 1) continue;
const points = spine + 1; // the spine, plus the head
for (let i = 1; i < points; i++) {
if (vertex + 2 > maxVertices) break;
const a = track.samples[i - 1];
if (!a) continue;
const bSample = i < spine ? track.samples[i] : null;
const bPosition = bSample ? bSample.position : track.head;
const bAltitude = bSample ? bSample.altitude : track.headAltitude;
// Alpha runs from nothing at the tail to `TRAIL_ALPHA` at the aircraft,
// eased so that the fade happens mostly in the older half and the
// segment behind the dart stays legible.
writeTrailVertex(vertex++, a.position, a.altitude, ((i - 1) / spine) ** 1.7);
writeTrailVertex(vertex++, bPosition, bAltitude, (i / spine) ** 1.7);
}
}
trailGeo.setDrawRange(0, vertex);
trailGeo.attributes.position!.needsUpdate = true;
trailGeo.attributes.color!.needsUpdate = true;
}
function writeTrailVertex(index: number, position: THREE.Vector3, altitude: number, fade: number) {
const p = index * 3;
trailPositions[p] = position.x;
trailPositions[p + 1] = position.y;
trailPositions[p + 2] = position.z;
// `THREE.Color` holds working-space values, which is what a vertex colour
// attribute is read as — so the ramp and the dart materials, which come from
// the same two colours, agree.
scratch.copy(LOW_COLOR).lerp(HIGH_COLOR, ramp(altitude));
const c = index * 4;
trailColors[c] = scratch.r;
trailColors[c + 1] = scratch.g;
trailColors[c + 2] = scratch.b;
trailColors[c + 3] = fade * TRAIL_ALPHA;
}
return {
group,
update,
tick,
dispose() {
geo.dispose();
material.dispose();
meshes.clear();
for (const m of materials.values()) m.dispose();
materials.clear();
trailGeo.dispose();
trailMat.dispose();
tracks.clear();
group.clear();
},
};
}
/**
* A dart: a five-sided body with a wing and a tailplane, merged into one
* geometry so an aircraft is one draw call.
*
* The wing is what earns its keep. A bare cone at this scale is a bright speck
* with no orientation, and the whole reason to draw traffic on a city map is
* that it is going somewhere — the crossbar is the only part of the silhouette
* that says which way.
*/
function dartGeometry(): THREE.BufferGeometry {
const body = new THREE.ConeGeometry(0.09, 0.42, 5);
body.rotateX(Math.PI / 2); // nose along +z, so heading is a rotation about Y
const wing = new THREE.BoxGeometry(0.44, 0.016, 0.085);
wing.translate(0, -0.005, -0.02);
const tail = new THREE.BoxGeometry(0.15, 0.014, 0.055);
tail.translate(0, 0.02, -0.165);
const parts = [body, wing, tail];
const merged = mergeGeometries(parts);
for (const part of parts) part.dispose();
if (merged) return merged;
// `mergeGeometries` returns null when the inputs disagree about their
// attributes, which three primitives from the same library cannot — but the
// signature allows it, and a missing aircraft is worse than a plain one.
const fallback = new THREE.ConeGeometry(0.09, 0.42, 5);
fallback.rotateX(Math.PI / 2);
return fallback;
}
/**
* The climb angle of a leg, from the real numbers rather than the scene's.
*
* Scene height is exaggerated 3.6× here, so an angle measured off the rendered
* positions would put a routine departure at forty degrees nose-up. Horizontal
* distance in scene units *is* proportional to distance on the ground, so one
* multiplication converts it and the altitudes are already metres.
*/
function climbAngle(world: World, from: TrailSample, to: TrailSample): number {
const dx = to.position.x - from.position.x;
const dz = to.position.z - from.position.z;
const horizontal = Math.hypot(dx, dz) * world.metresPerUnit;
if (horizontal < 1) return 0;
return clamp(Math.atan2(to.altitude - from.altitude, horizontal), -MAX_PITCH, MAX_PITCH);
}
/**
* Blend two compass headings the short way round.
*
* A straight lerp from 350° to 10° spins the aircraft 340° through south over
* the course of a second, which is the most conspicuous artefact this whole file
* could have.
*/
function interpolateHeading(from: number, to: number, t: number): number {
const delta = (((to - from) % 360) + 540) % 360 - 180;
return from + delta * t;
}
/** 0 on the deck, 1 at cruise. Curved, because the low end is where the eye is. */
function ramp(altitude: number): number {
return clamp(altitude / CRUISE_METRES, 0, 1) ** 0.6;
}
function bandFor(altitude: number): number {
return Math.round(ramp(altitude) * (COLOR_BANDS - 1));
}
function clamp(x: number, lo: number, hi: number): number {
return x < lo ? lo : x > hi ? hi : x;
}
+572
View File
@@ -0,0 +1,572 @@
/**
* 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. */
const WINDOW_GAIN = 0.8;
/** 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 1.8 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.
float glow = mix(1.8 * 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 1.8 x 0.2128 x 0.24 x 0.8 = 0.074,
* against the avenues' 0.025 — 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 the buildings come out at about #4a403d against water at
* #191b21 and a sky at #2d3855: the city is the brightest thing in the frame,
* as it should be, and the sky is still visibly a sky.
*
* 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.
*/
+40 -6
View File
@@ -19,6 +19,7 @@
import * as THREE from "three";
import { createBlocks, createLandmarks } from "./blocks.ts";
import { createNightLights, type NightLights } from "./nightlights.ts";
import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
import { createSceneKit, type Pose } from "./scenekit.ts";
@@ -63,6 +64,12 @@ export interface SceneHandle {
stageScene: StageScene;
/** Applies a rig computed elsewhere. The scene never works one out itself. */
setLighting(state: LightingState): void;
/**
* Solar elevation in degrees, for the layers that need the sun's position
* rather than the rig it implies. `LightingState` deliberately carries no
* elevation, so the number has to arrive separately.
*/
setSolarElevation(degrees: number): void;
flyTo(chapterId: string): void;
current(): string;
onChapterChange(fn: (id: string) => void): void;
@@ -78,16 +85,33 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
const stage = createStage(canvas);
const scene = new THREE.Scene();
/**
* Every camera limit is derived from how big this city's board actually is.
*
* These were constants tuned for San Francisco — `maxDistance: 340`,
* `far: 900`, a 170-unit shadow box. That silently made board size a fixed
* property of the engine rather than of a city: expanding the pack from San
* Francisco to the whole Bay Area took the board from 230 units across to
* 1003, and the camera physically could not retreat far enough to frame it.
* You got a close-up of the peninsula with everything else off-screen, and
* nothing in the types said why.
*
* A city pack now chooses its own `latScale` freely and the camera follows.
*/
const [westX, northZ] = world.project(city.bounds.maxLat, city.bounds.minLng);
const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng);
const boardSpan = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ));
const kit = createSceneKit({
scene,
dom: stage.renderer.domElement,
fov: 42,
near: 0.1,
far: 900,
minDistance: 12,
maxDistance: 340,
shadowExtent: 170,
shadowFar: 520,
far: boardSpan * 3,
minDistance: Math.max(4, boardSpan * 0.02),
maxDistance: boardSpan * 1.5,
shadowExtent: boardSpan * 0.75,
shadowFar: boardSpan * 2.2,
});
kit.applyLighting(options.lighting ?? cityDaylight(pal));
@@ -95,10 +119,18 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
scene.add(createShorePlates(world));
scene.add(createTerrain(world));
scene.add(createRoads(world));
scene.add(createBlocks(world));
const blocks = createBlocks(world);
scene.add(blocks);
scene.add(createLandmarks(world));
scene.add(createBridges(world));
/**
* The city switching itself on after sunset. Built after `blocks` because it
* patches the material that `createBlocks` made — order is load-bearing.
*/
const nightLights: NightLights = createNightLights({ world, blocks });
scene.add(nightLights.group);
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
scene.add(markerLayer.group);
@@ -176,6 +208,7 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
dispose() {
options.flights?.dispose?.();
flightLayer?.dispose();
nightLights.dispose();
markerLayer.dispose();
kit.dispose();
scene.traverse((obj) => {
@@ -195,6 +228,7 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
stage,
stageScene,
setLighting: (state) => kit.applyLighting(state),
setSolarElevation: (degrees) => nightLights.setSolarElevation(degrees),
flyTo,
current: () => currentChapter,
onChapterChange(fn) {
+3 -4
View File
@@ -110,8 +110,7 @@ export function createShorePlates(world: World): THREE.Mesh {
*/
export function createTerrain(world: World): THREE.Mesh {
const pal = paletteFor(world);
const { latSteps, lngSteps, height, land } = world.lattice();
const { bounds, cellLat, cellLng } = world.city;
const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice();
const positions: number[] = [];
const colors: number[] = [];
@@ -126,8 +125,8 @@ export function createTerrain(world: World): THREE.Mesh {
const k = i * (lngSteps + 1) + j;
const existing = vertexAt[k];
if (existing !== undefined && existing >= 0) return existing;
const lat = bounds.minLat + i * cellLat;
const lng = bounds.minLng + j * cellLng;
const lat = lats[i] as number;
const lng = lngs[j] as number;
const e = height[k] ?? 0;
const [x, z] = world.project(lat, lng);
positions.push(x, world.metres(e) + 0.012, z);
+159 -24
View File
@@ -20,6 +20,8 @@ export class World {
private readonly bboxes = new WeakMap<LatLng[], Float64Array>();
private field: Float32Array | null = null;
private fieldLand: Uint8Array | null = null;
private lats: Float64Array | null = null;
private lngs: Float64Array | null = null;
private latSteps = 0;
private lngSteps = 0;
@@ -114,14 +116,26 @@ export class World {
}
/** Shortest distance to a polygon's boundary, in degrees. */
private distanceToEdge(lat: number, lng: number, poly: LatLng[]): number {
let best = Infinity;
private distanceToEdge(lat: number, lng: number, poly: LatLng[], cap = Infinity): number {
let best = cap;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const from = poly[j];
const to = poly[i];
if (!from || !to) continue;
const [aLat, aLng] = from;
const [bLat, bLng] = to;
// Cheap rejection against the segment's own extent. The Bay Area's
// coastlines run to hundreds of vertices and this is called for every
// land point in the lattice, so skipping a segment that cannot beat the
// current best is worth the four comparisons.
if (
(lat - aLat > best && lat - bLat > best) ||
(aLat - lat > best && bLat - lat > best) ||
(lng - aLng > best && lng - bLng > best) ||
(aLng - lng > best && bLng - lng > best)
) {
continue;
}
const dLat = bLat - aLat;
const dLng = bLng - aLng;
const lenSq = dLat * dLat + dLng * dLng;
@@ -173,14 +187,19 @@ export class World {
/** 0 at the waterline, 1 once `coastFalloff` degrees inland. */
private coastalFalloff(lat: number, lng: number): number {
const limit = this.city.coastFalloff;
let d = Infinity;
for (const poly of this.city.landmasses) {
if (this.pointInPolygon(lat, lng, poly)) {
d = Math.min(d, this.distanceToEdge(lat, lng, poly));
}
if (!this.pointInPolygon(lat, lng, poly)) continue;
// Seed the search with the cap: the result saturates at `coastFalloff`,
// so any edge farther than that cannot change the answer, and seeding
// `best` lets the per-segment rejection above discard almost everything
// for a point well inland.
const dist = this.distanceToEdge(lat, lng, poly, limit);
if (dist < d) d = dist;
}
if (!Number.isFinite(d)) return 0;
const t = Math.min(1, d / this.city.coastFalloff);
const t = Math.min(1, d / limit);
return t * t * (3 - 2 * t);
}
@@ -193,17 +212,39 @@ export class World {
* camera target wants it again. Computed once, read back bilinearly.
*/
private buildField(): { height: Float32Array; land: Uint8Array } {
if (this.field && this.fieldLand) return { height: this.field, land: this.fieldLand };
if (this.field && this.fieldLand && this.lats && this.lngs) {
return { height: this.field, land: this.fieldLand };
}
const { bounds, cellLat, cellLng } = this.city;
this.latSteps = Math.ceil((bounds.maxLat - bounds.minLat) / cellLat);
this.lngSteps = Math.ceil((bounds.maxLng - bounds.minLng) / cellLng);
const coarse = Math.max(1, this.city.coarseFactor ?? 1);
const regions = this.city.focusRegions ?? [];
// Rectilinear but NOT uniform: fine spacing across any band that a focus
// region occupies, coarse everywhere else. See `buildAxis`.
this.lats = buildAxis(
bounds.minLat,
bounds.maxLat,
cellLat,
cellLat * coarse,
regions.map((r) => [r.minLat, r.maxLat] as [number, number]),
);
this.lngs = buildAxis(
bounds.minLng,
bounds.maxLng,
cellLng,
cellLng * coarse,
regions.map((r) => [r.minLng, r.maxLng] as [number, number]),
);
this.latSteps = this.lats.length - 1;
this.lngSteps = this.lngs.length - 1;
const w = this.lngSteps + 1;
const height = new Float32Array((this.latSteps + 1) * w);
const land = new Uint8Array((this.latSteps + 1) * w);
for (let i = 0; i <= this.latSteps; i++) {
const lat = bounds.minLat + i * cellLat;
const lat = this.lats[i] as number;
for (let j = 0; j <= this.lngSteps; j++) {
const lng = bounds.minLng + j * cellLng;
const lng = this.lngs[j] as number;
const k = i * w + j;
const onLand = this.isLand(lat, lng);
land[k] = onLand ? 1 : 0;
@@ -215,24 +256,50 @@ export class World {
return { height, land };
}
/** Lattice dimensions, for the terrain mesh builder. */
lattice(): { latSteps: number; lngSteps: number; height: Float32Array; land: Uint8Array } {
/**
* The lattice, for the terrain mesh builder.
*
* `lats`/`lngs` are the coordinate of every row and column, because the
* spacing is no longer uniform and a consumer cannot recover it from
* `minLat + i * cellLat` any more.
*/
lattice(): {
latSteps: number;
lngSteps: number;
lats: Float64Array;
lngs: Float64Array;
height: Float32Array;
land: Uint8Array;
} {
const { height, land } = this.buildField();
return { latSteps: this.latSteps, lngSteps: this.lngSteps, height, land };
return {
latSteps: this.latSteps,
lngSteps: this.lngSteps,
lats: this.lats as Float64Array,
lngs: this.lngs as Float64Array,
height,
land,
};
}
/** Elevation in metres, bilinearly sampled from the cached lattice. */
elevationSampled(lat: number, lng: number): number {
const { height } = this.buildField();
const { bounds, cellLat, cellLng } = this.city;
const lats = this.lats as Float64Array;
const lngs = this.lngs as Float64Array;
const w = this.lngSteps + 1;
const fi = (lat - bounds.minLat) / cellLat;
const fj = (lng - bounds.minLng) / cellLng;
if (fi < 0 || fj < 0 || fi >= this.latSteps || fj >= this.lngSteps) return 0;
const i = Math.floor(fi);
const j = Math.floor(fj);
const ti = fi - i;
const tj = fj - j;
const i = cellIndex(lats, lat);
const j = cellIndex(lngs, lng);
if (i < 0 || j < 0) return 0;
const lat0 = lats[i] as number;
const lat1 = lats[i + 1] as number;
const lng0 = lngs[j] as number;
const lng1 = lngs[j + 1] as number;
const ti = lat1 > lat0 ? (lat - lat0) / (lat1 - lat0) : 0;
const tj = lng1 > lng0 ? (lng - lng0) / (lng1 - lng0) : 0;
const a = height[i * w + j] ?? 0;
const b = height[i * w + j + 1] ?? 0;
const c = height[(i + 1) * w + j] ?? 0;
@@ -246,11 +313,79 @@ export class World {
}
}
// ---- Variable-resolution lattice ------------------------------------------
/**
* The coordinates of every row (or column) of the heightfield: fine spacing
* across the bands the focus regions occupy, coarse in between.
*
* This is what makes a region the size of Southern California renderable at
* all. San Francisco is 0.20 x 0.36 degrees and a uniform 45 m lattice over it
* is 337k points — fine. LA plus Orange County plus Riverside is about
* fourteen times that area, and the same uniform lattice is 2.9M points; the
* Bay Area extended to San Jose is 3.7M and roughly eleven seconds of build.
* Neither is a thing anyone waits for.
*
* The refinement is per-axis rather than per-rectangle, so a focus region
* refines its whole row *and* its whole column — a plus, not a box. That over-
* samples the corners where two regions' bands cross, and it is deliberate: it
* keeps the lattice rectilinear, which keeps the mesh builder a double loop and
* bilinear sampling a pair of binary searches. A true quadtree would sample
* less and cost far more everywhere else.
*/
function buildAxis(
min: number,
max: number,
fine: number,
coarse: number,
bands: [number, number][],
): Float64Array {
const out: number[] = [min];
let x = min;
// A guard band of one coarse cell either side, so the transition from coarse
// to fine happens outside the region rather than exactly on its edge, where
// it would show as a crease in the terrain.
const inFine = (v: number) => bands.some(([a, b]) => v >= a - coarse && v <= b + coarse);
while (x < max) {
x += inFine(x) ? fine : coarse;
out.push(Math.min(x, max));
}
// A degenerate final cell (the clamp above landing on `max` twice) would make
// a zero-width row that the interpolator would divide by.
if (out.length > 1 && out[out.length - 1] === out[out.length - 2]) out.pop();
return Float64Array.from(out);
}
/** Index of the cell containing `v`, or -1 if outside. Binary search. */
function cellIndex(axis: Float64Array, v: number): number {
const last = axis.length - 1;
if (v < (axis[0] as number) || v >= (axis[last] as number)) return -1;
let lo = 0;
let hi = last;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if ((axis[mid] as number) <= v) lo = mid;
else hi = mid;
}
return lo;
}
// ---- Deterministic noise and randomness -----------------------------------
/**
* Integer hash, not the `Math.sin(...) * 43758` trick this used to be.
*
* `valueNoise` calls this four times and `fbm` runs four octaves, so every
* elevation sample was sixteen `Math.sin` calls. Over the Bay Area's lattice
* that is twenty-six million of them, and it was most of an eleven-second
* terrain build. The inputs are already integers here — `valueNoise` floors
* them — so an imul-based mix is both faster and better distributed.
*/
function hash2(x: number, y: number): number {
const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return s - Math.floor(s);
let h = Math.imul(x | 0, 0x27d4eb2d) ^ Math.imul(y | 0, 0x165667b1);
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
h ^= h >>> 13;
return (h >>> 0) / 4294967296;
}
function valueNoise(x: number, y: number): number {