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:
+521
-19
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user