1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/engine/atmosphere.ts
T
karti 2d87d9f354 The city points at its own buildings, and the sky stops depending on an API
**Clouds were invisible to everyone who had not wired up NWS.** The layer
took `currentWeather()?.cloudCover ?? 0`, and `currentWeather()` is null on
any deployment without a weather source — which is the default, and the
exact configuration this repo is held to: a stranger clones it, runs one
command, and gets a city with no account and no key. Their sky was
permanently, silently empty. `atmosphere.ts` already models a sky when
nobody has observed one; it now models cover too, an observed reading
still wins outright, and the clouds are there on a bare clone.

**Both offices are pins on the city, and clicking one walks you in.** Each
pack has carried a real `site` since the sun needed one, and that
coordinate was known to the lighting and to nothing else — a visitor
looking at the board had no way to tell that two of those buildings are
ones they can go inside. The coordinates move to a tiny eagerly-imported
`offices/sites.ts` that the packs import *from*, because a pack is a 25 kB
lazy chunk and the board wants its pins long before anybody opens a door.
A test asserts the pack and the table hold the **same object**, not merely
equal values: a drifted coordinate would put the marker on one building
and the sun on another and both would look entirely plausible.

**Aircraft bank into their turns.** The roll channel existed and was never
written, so every turn was flat. Bank comes from the coordinated-turn
relation against the measured turn rate, damped by a first-order lag so it
settles rather than oscillates, and clamped at 30° like a real limiter.
Six regression tests, because roll is the one channel that feeds itself —
position and heading are recomputed from the last two observations and
wash out a bad value, while a NaN in the roll would persist for the life
of the track.

That fed straight into a real defect: `AdsbFlights` substituted
`heading: 0` for records with no `track` field, which is harmless for a
symmetrical dart and is a **sustained full-scale artefact** once aircraft
bank — a target whose real heading is 200° reported as 0° reads as a 160°
turn and pins the roll at its limiter for as long as it is in the feed.
Those records are dropped now. An aeroplane the feed will not give a
heading for is one this layer cannot draw honestly.

**The office empties out overnight.** A full complement of seated people
at one in the morning, under house lights that came on because the sun is
down, was the least believable thing left in the room once the clock
became real. A live roster always wins — an API that says the building is
empty is telling the truth about the building.

**Robots go somewhere.** They pick real addresses — a seat, a room — and
turn to face the seat when they arrive, rather than stopping at a random
angle. Godmode gets an office section: house lights forced on or off or
following the sun, robots and ceilings toggled, with a readout.

**The bundle is split.** Entry chunk 758 kB to 208 kB, with three.js and
satellite.js in a vendor chunk that survives an app deploy instead of
being re-downloaded on every one. Rollup's 500 kB warning still fires and
should — it now points at three.js, where it is true, instead of at our
code, where it was pointing at three.js all along.

Reviewers caught two false geography claims in the new prose ("both
shipped buildings stand in San Francisco" — one is across the estuary at
Alameda Point) and several miscounted figures. Fixed. In a codebase where
the comments are the design record, those are defects.

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

1869 lines
83 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The sky: what the sun and the weather mean for the light rig.
*
* `Atmosphere` is the **sole owner of lighting**. It takes an `Environment` — an
* observation of the world, `{ time, sun, weather }` — and returns a
* `LightingState`, which a `SceneKit` applies. One direction, no write-backs,
* and nothing else in the engine is allowed to reach into the three lights. Two
* modules both constructing and mutating the same `DirectionalLight` is the
* failure this shape exists to prevent; see CONTRACT.md §4.
*
* Two things follow from that ownership and are worth stating up front:
*
* 1. **An office gets no Atmosphere at all.** An interior has walls, no
* horizon and no weather: it wants `fog: null`, `sky: null` — which tells
* `SceneKit` to leave `scene.background` alone entirely — and a fixed
* interior rig of its own that never moves. Daylight through windows is a
* later refinement and deliberately not a v1 coupling. Nothing in this
* file is for an interior, and an office importing it is a mistake.
* 2. **`apply` is pure.** It reads its argument, allocates one small object,
* 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, 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.
*
* There is a second output alongside the rig, `cloudCover`, and it is here for
* the same reason. It answers "how much sky has cloud in it" rather than "what
* does that cloud do to the light" — the number a cloud layer draws from, not a
* light — and it falls back to the same local climatology when nothing was
* observed. See `modelledCloudCover`: a caller that reads
* `weather?.cloudCover ?? 0` instead gets an empty sky on every deployment
* without a weather API, which is the default one.
*
* Wiring one to a city, in full:
*
* ```ts
* const atmosphere = createAtmosphere({
* lng: city.center.lng,
* // Degrees of latitude are ~111.32 km everywhere, so the city's own
* // `latScale` is the scale conversion already.
* metresPerUnit: 111_320 / city.latScale,
* marineLayer: PACIFIC_MARINE_LAYER,
* });
* handle.setLighting(atmosphere.apply(observe(city.center.lat, city.center.lng, new Date())));
* ```
*
* The marine layer is opt-in and off by default, because it is a fact about a
* coast and this module has no idea which one it is looking at.
*/
import { solarPosition, sunDirection, type SolarPosition } from "./solar.ts";
import type { LightingState } from "./types.ts";
// ---- The observation ------------------------------------------------------
/**
* Sky conditions as a renderer can use them.
*
* This mirrors `WeatherCondition` in `src/server/wire.ts` member for member,
* and is named differently on purpose. The engine must not import the wire —
* `wire.ts` already imports `engine/types.ts`, and pointing the arrow back
* would make the engine's type graph depend on the server's. A `WeatherBody`
* off the wire is structurally assignable to `WeatherObservation` as it stands,
* so the adapter is a pass-through and not a translation; if a member is ever
* added on one side, add it on the other.
*/
export type SkyCondition =
| "clear"
| "partly-cloudy"
| "cloudy"
| "overcast"
| "fog"
| "rain"
| "snow"
| "thunderstorm";
/**
* What the sky is doing, reduced to the four things that change a light rig.
*
* `null` fields mean *not reported*, never zero. The difference matters: a
* station that does not measure visibility is not a station reporting perfect
* visibility, and treating the two the same is how a foggy morning renders
* clear.
*/
export interface WeatherObservation {
/** 0..1. */
cloudCover: number;
/** 0..1, an intensity rather than a rate. */
precipitation: number;
visibilityKm: number | null;
windKph: number | null;
/** Degrees clockwise from true north, the direction the wind blows *from*. */
windDirDeg: number | null;
condition: SkyCondition;
}
/**
* Everything the light rig is a consequence of.
*
* An *observation*, emphatically not a state: this is what the world is doing,
* and a `LightingState` is what that means for the three lights. The two were
* once one type called `Environment` in two places meaning both things at once,
* which is why the names are now this far apart.
*/
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 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),
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 --------------------------------------------------------------
/**
* Coastal advection fog, as a season, a clock and a wind gate.
*
* Parameterised rather than hard-coded because the model is climatology, not
* geography: the same three curves describe the eastern Pacific layer, the
* Namibian one and the Peruvian one, with different numbers in them.
*/
export interface MarineLayerOptions {
/** Day of year, 1-366, at which the season peaks. */
peakDay: number;
/** Half-width of the season in days. Outside it, `offSeason` applies. */
seasonDays: number;
/** Residual strength out of season, 0..1. */
offSeason: number;
/** Bearing the onshore wind blows *from*, degrees clockwise from true north. */
onshoreBearing: number;
/** How far off that bearing still counts as onshore, in degrees. */
onshoreHalfWidth: number;
/** Strength at the peak of the season, 0..1. */
strength: number;
/** Visibility inside the layer, in metres. */
visibilityM: number;
}
/**
* The eastern-Pacific summer layer: San Francisco's fog.
*
* This is the single most recognisable atmospheric fact about the city and it
* is worth getting specifically right rather than approximating with generic
* haze. The layer is cold air over the California Current, dragged inland
* through the one sea-level gap in the coast range by the Central Valley's
* afternoon heat low. That gives it a shape a renderer can actually use:
*
* - **A season.** It is a summer phenomenon, peaking in July, essentially
* gone by the clear warm October that surprises every visitor who packed
* for August. Winter fog in the Bay Area is a different animal — radiation
* fog, mostly inland — and the small `offSeason` residual is all of it that
* belongs here.
* - **A clock.** In through the Gate in the late afternoon, thickest from
* midnight to a couple of hours after sunrise, burning off through the late
* morning and back again from about five.
* - **A wind.** It is *advection* fog: it has to be blown in. A westerly at
* 15-30 km/h is the engine of it; an offshore easterly kills it outright,
* and a gale mixes it up into stratus instead.
*
* What is deliberately not modelled: the layer is shallow — a few hundred
* metres — so downtown's towers and Twin Peaks stand in clear air above a white
* floor, and the Sunset is buried while the Mission is in sunshine. Both facts
* need height fog and a horizontal gradient; `THREE.Fog` is a single global
* linear ramp and can express neither. Rather than fake it, the strength is
* capped so that the city dims and flattens instead of disappearing.
*/
export const PACIFIC_MARINE_LAYER: MarineLayerOptions = {
peakDay: 196, // 15 July
seasonDays: 88, // roughly mid-April to mid-October
offSeason: 0.1,
onshoreBearing: 275, // just north of due west, straight in through the Gate
onshoreHalfWidth: 75,
strength: 1,
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.
//
// Left where it was when the moonless floor came up under it, and that is a
// decision rather than an oversight. The key is already three quarters of the
// light in a full-moon frame, so raising it to keep the gap would have been
// raising the one term that is nearest to overshooting into a blue-graded day;
// the gap is defended in `applyNight` instead, on the fill, where there was
// room. What did have to be checked is that moonrise is still an *event* —
// see the sanity checks at the foot of this file, where a full moon 43° up is
// seven times the key of a moonless night and better than twice its ground
// luminance on screen.
intensity: 1.15,
color: 0x9db4e8,
skyLift: 0.85,
};
export interface AtmosphereOptions {
/**
* Observer longitude, degrees east. Needed for apparent solar time, which is
* what the marine layer's clock runs on — a fog that burns off at 11 a.m.
* needs to know when 11 a.m. is, and we ship no timezone database.
*/
lng: number;
/**
* Metres in one scene unit. Visibility arrives in kilometres and fog
* distances leave in scene units, and this is the only thing that knows the
* exchange rate: ~94 m per unit for San Francisco, 1 m for an interior that
* will never call this anyway.
*/
metresPerUnit: number;
/**
* The clear-noon sky this city wants. Every other stop in the table is a
* consequence of where the sun is and is not a city's business to override.
*/
sky?: { top: number; horizon: number };
/** Fog on a clear day, in scene units. */
clearFog?: { near: number; far: number };
/**
* Visibility, in metres, that fog is never allowed to fall below.
*
* A deliberate lie. Real advection fog at the Golden Gate has visibility
* under 400 m, and rendering that honestly produces a white rectangle with a
* city somewhere inside it. What reads as fog is the *look* — no horizon, no
* shadows, a dead sun, everything the same flat grey — at a range you can
* still fly a camera through.
*/
minVisibilityM?: number;
/**
* Degrees. The light direction is never allowed below this elevation. `0`
* turns the lift off; see `liftedElevation` for why it exists.
*/
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 {
/** The rig this observation implies. Pure; the caller applies the result. */
apply(env: Environment): LightingState;
/**
* How much of the sky has cloud in it, 0..1 — observed if anyone observed it,
* modelled from this place and this instant if nobody did. Pure, like `apply`.
*
* The cause, not the consequence: what a cloud layer needs in order to draw
* the right amount of cloud, as distinct from everything in `LightingState`,
* which is what that cloud does to the light once it is there.
*/
cloudCover(env: Environment): number;
}
// ---- Constants ------------------------------------------------------------
const MS_PER_MINUTE = 60_000;
const MS_PER_DAY = 86_400_000;
/** Matches `cityDaylight()` in `scene.ts`, so switching to a live sun at noon does not jolt. */
const DEFAULT_SKY_TOP = 0x8fb8d8;
const DEFAULT_SKY_HORIZON = 0xd9e6ee;
const DEFAULT_FOG_NEAR = 210;
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 floor, for the light that lands on the ground.
*
* `NIGHT_FLOOR_TOP` above makes the argument for the sky and then only fixes
* the sky, which is exactly half the job — and the half that hides the other
* half, because a lifted sky behind a black landmass reads as a working render
* of an empty ocean. What it was actually producing at -18° was terrain at
* #000004 under a sky at #16203a: the coastline gone, the hills gone, water and
* land the same colour, and nothing left in the frame but the lit windows and
* the freeway threads floating in it.
*
* A photometrically correct answer here really is close to zero. A moonless
* night is a few thousandths of a lux of airglow and starlight, against a hundred
* thousand at noon, and any honest ratio lands under one code value. But three
* things make zero the wrong number to render:
*
* - **The display has no room underneath.** Everything from 1/1000 of white
* down to nothing shares the bottom two or three code values of an 8-bit
* sRGB ramp. A correctly exposed night does not come out dim, it comes out
* quantised to black, and no amount of squinting recovers a coastline that
* was rounded to #000.
* - **Nobody is dark-adapted.** The eye that can read a moonless landscape has
* spent forty minutes getting there. The eye looking at this has a lit room
* behind it and a white browser chrome around it, and its black point is
* several stops above the screen's.
* - **This is a map.** It is looked at from eighty kilometres up, from outside
* the atmosphere it is depicting, by someone who wants to know where the bay
* is. A view that goes correctly blank at 3 a.m. is not a night mode, it is
* an outage — and it is reported as one.
*
* So these are the same kind of lie as `DEFAULT_MOONLIGHT.intensity`: not the
* light there is, but the light a moonless night *looks like* it has once you
* are standing in it. Held deliberately low enough that the city's own lit
* windows stay the brightest thing in the frame by a factor of four or five,
* which is the one relationship that makes it read as night rather than as a
* blue-graded day.
*
* Split five ways rather than folded into one brightness because the *ratio*
* between them is what stops the result looking like fog. Ambient is
* unshaped — every surface gets the same number whichever way it faces — so a
* night lit by ambient alone is flat, and flat and dark is fog, not darkness.
* The hemisphere carries most of it instead, sky term well above ground term, so
* a roof is lighter than a wall; and the keyframe table's token sidelight
* survives at full strength on a moonless night (see `applyNight`) so the hills
* still have a lit side and a dark one.
*/
const NIGHT_FLOOR_HEMI_SKY = 0x354c88;
const NIGHT_FLOOR_HEMI_GROUND = 0x1f2740;
const NIGHT_FLOOR_HEMI_INTENSITY = 0.78;
const NIGHT_FLOOR_AMBIENT = 0x47557f;
const NIGHT_FLOOR_AMBIENT_INTENSITY = 0.22;
/**
* The same sky, and the same fill, with a full moon in it.
*
* These moved up when the floor did, and they had to: a floor raised to meet the
* moon has deleted the moon, and `moonPosition` is four hundred lines of Meeus
* that would then be decorative. The gap is the point — a moonlit night has to
* arrive as an event, four to five times the moonless floor in linear light, and
* with a *direction* in it that the floor by construction does not have.
*/
const MOONLIT_SKY_TOP = 0x111d3e;
const MOONLIT_SKY_HORIZON = 0x2d3c62;
const MOONLIT_HEMI_SKY = 0x40597f;
const MOONLIT_AMBIENT = 0x546490;
/**
* 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
* that closes to 60 units with its near plane still at 210 is not fog, it is a
* solid wall.
*/
const FOG_NEAR_RATIO = 0.45;
/**
* Kilometres at and above which a reported visibility means "as far as anyone
* bothered to look".
*
* This is the correction that stops every clear day rendering hazy. A METAR of
* `10SM` is the *maximum value the report can carry*, not a measurement of ten
* miles — the observer stopped counting. Taking it literally puts the fog plane
* at 170 scene units, inside the camera's own orbit range, and washes out a sky
* that is in fact unlimited. The blend from `VISIBILITY_HAZY_KM` upward also
* keeps the transition smooth, because a cliff at exactly 16 km would make the
* whole city snap between hazy and crisp on a one-decimal change upstream.
*/
const VISIBILITY_UNLIMITED_KM = 16;
const VISIBILITY_HAZY_KM = 8;
/** Visibility assumed when a source says "fog" and reports no number. */
const FOG_CONDITION_VISIBILITY_KM = 1.5;
// ---- The daylight table ---------------------------------------------------
interface Rig {
skyTop: number;
skyHorizon: number;
sunColor: number;
sunIntensity: number;
hemiSky: number;
hemiGround: number;
hemiIntensity: number;
ambientColor: number;
ambientIntensity: number;
}
interface Keyframe extends Rig {
/** Solar elevation, in degrees, that this frame describes exactly. */
elevation: number;
}
/**
* The sky at eight solar elevations, interpolated between.
*
* The stops sit on the twilight boundaries `solar.ts` already names — -18, -12,
* -6, the refracted horizon, and up through golden hour into full day — so that
* `daylightPhase` and this table agree about where a phase begins. A table
* rather than a formula because the interesting part of a sunset is not
* physical: the horizon band goes salmon while the zenith is still deep blue,
* and the ratio between them is a thing you tune by looking, not by deriving.
*
* Two stops carry the city's own daylight colours (see `AtmosphereOptions.sky`),
* so a city that declares a paler or bluer sky keeps it at noon and still gets
* the same dusk as everywhere else — dusk is not regional in any way this
* renderer can see.
*/
function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
return [
{
// Full night. The directional light is not the sun and is not the moon
// either; it is 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 rather than as darkness.
//
// The three night stops used to be an order of magnitude below this, and
// the reason they were wrong is instructive: they were read off a
// photograph of a night sky, which is a picture of the *sky* and says
// nothing about the ground under it. Multiplied out, `hemiSky` at 0x121a30
// times 0.25 came to about four thousandths of the fill at noon, which is
// roughly honest and rendered San Francisco as #000004. What is here now
// is the ground reading the eye reports — the coastline findable, the
// hills with a lit side, the bay darker than the land around it — with the
// sky stops left where they were, because those were never the problem.
// See `NIGHT_FLOOR_HEMI_SKY`, which is what actually holds this up: these
// rows sit just under the floor and the floor is what binds.
elevation: -18,
skyTop: 0x05070f,
skyHorizon: 0x0b1120,
sunColor: 0x44558a,
sunIntensity: 0.16,
hemiSky: 0x2f447e,
hemiGround: 0x1b2234,
hemiIntensity: 0.55,
ambientColor: 0x414e78,
ambientIntensity: 0.16,
},
{
elevation: -12,
skyTop: 0x080d1e,
skyHorizon: 0x141d38,
sunColor: 0x51629b,
sunIntensity: 0.19,
hemiSky: 0x32477d,
hemiGround: 0x1d2437,
hemiIntensity: 0.57,
ambientColor: 0x424f7a,
ambientIntensity: 0.17,
},
{
elevation: -6,
skyTop: 0x101a3a,
skyHorizon: 0x2b3560,
sunColor: 0x66699a,
sunIntensity: 0.26,
hemiSky: 0x3c558c,
hemiGround: 0x23293c,
hemiIntensity: 0.6,
ambientColor: 0x485389,
ambientIntensity: 0.19,
},
{
// The sun on the horizon. Warm at the bottom, cold at the top, and the
// widest colour spread the sky ever has.
elevation: -0.4,
skyTop: 0x2a4275,
skyHorizon: 0x9a6a63,
sunColor: 0xc2795c,
sunIntensity: 0.45,
hemiSky: 0x4a5f8c,
hemiGround: 0x2a2a2c,
hemiIntensity: 0.6,
ambientColor: 0x6a6a80,
ambientIntensity: 0.2,
},
{
elevation: 3,
skyTop: 0x4d76ac,
skyHorizon: 0xdba078,
sunColor: 0xff9c56,
sunIntensity: 1.25,
hemiSky: 0x86a6cc,
hemiGround: 0x54503f,
hemiIntensity: 0.85,
ambientColor: 0xffd9b8,
ambientIntensity: 0.24,
},
{
elevation: 8,
skyTop: 0x6b96c6,
skyHorizon: 0xebc9a4,
sunColor: 0xffc489,
sunIntensity: 1.8,
hemiSky: 0xb2cbe4,
hemiGround: 0x6a6752,
hemiIntensity: 0.98,
ambientColor: 0xffe7cf,
ambientIntensity: 0.28,
},
{
// Ordinary daylight, and the one stop that reproduces `cityDaylight()`.
elevation: 25,
skyTop: dayTop,
skyHorizon: dayHorizon,
sunColor: 0xfff3e0,
sunIntensity: 2.1,
hemiSky: 0xdcecf7,
hemiGround: 0x6b6f5e,
hemiIntensity: 1.05,
ambientColor: 0xffffff,
ambientIntensity: 0.32,
},
{
// A high sun. The zenith deepens — less air to scatter through overhead —
// while the horizon whitens, so the gradient is at its steepest at noon.
elevation: 65,
skyTop: mixHex(dayTop, 0x2f6bb0, 0.35),
skyHorizon: mixHex(dayHorizon, 0xffffff, 0.2),
sunColor: 0xfffdf6,
sunIntensity: 2.35,
hemiSky: 0xe6f2fb,
hemiGround: 0x74786a,
hemiIntensity: 1.1,
ambientColor: 0xffffff,
ambientIntensity: 0.3,
},
];
}
/**
* The frame for an elevation, blended between the two stops around it.
*
* Eased rather than linear. Straight lerp between table rows leaves a visible
* crease every time the sun crosses a stop — the rate of change jumps, and on
* a sky the eye reads that as a seam sliding down the screen. Smoothstep makes
* the derivative zero at each stop, so the stops stop being findable.
*/
function sample(frames: readonly Keyframe[], elevation: number): Rig {
const first = frames[0];
const last = frames[frames.length - 1];
if (!first || !last) throw new Error("atmosphere: empty keyframe table");
if (elevation <= first.elevation) return first;
if (elevation >= last.elevation) return last;
for (let i = 1; i < frames.length; i++) {
const a = frames[i - 1];
const b = frames[i];
if (!a || !b) continue;
if (elevation <= b.elevation) {
const t = ease((elevation - a.elevation) / (b.elevation - a.elevation));
return {
skyTop: mixHex(a.skyTop, b.skyTop, t),
skyHorizon: mixHex(a.skyHorizon, b.skyHorizon, t),
sunColor: mixHex(a.sunColor, b.sunColor, t),
sunIntensity: lerp(a.sunIntensity, b.sunIntensity, t),
hemiSky: mixHex(a.hemiSky, b.hemiSky, t),
hemiGround: mixHex(a.hemiGround, b.hemiGround, t),
hemiIntensity: lerp(a.hemiIntensity, b.hemiIntensity, t),
ambientColor: mixHex(a.ambientColor, b.ambientColor, t),
ambientIntensity: lerp(a.ambientIntensity, b.ambientIntensity, t),
};
}
}
return last;
}
// ---- The atmosphere -------------------------------------------------------
export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
const { lng, metresPerUnit } = options;
const table = keyframes(
options.sky?.top ?? DEFAULT_SKY_TOP,
options.sky?.horizon ?? DEFAULT_SKY_HORIZON,
);
const clearNear = options.clearFog?.near ?? DEFAULT_FOG_NEAR;
const clearFar = options.clearFog?.far ?? DEFAULT_FOG_FAR;
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;
// Copied, because `sample` hands back a table row unchanged when the
// elevation is off either end of it and everything below mutates in place.
const rig = { ...sample(table, elevation) };
// How much of the light is daylight at all. Used to keep the weather from
// brightening the night: overcast at noon is grey, overcast at 2 a.m. is
// 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";
// 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
// said so, and applying both would count it twice.
fogFar *= 1 - 0.45 * precipitation;
}
if (marineOptions && obscuration > 0) {
const inside = marineOptions.visibilityM / metresPerUnit;
fogFar = Math.min(fogFar, lerp(fogFar, inside, obscuration));
}
fogFar = Math.max(fogFar, floorFar);
const fogColor = applyObscuration(rig, obscuration, day, night, condition);
// A clear day keeps the near plane it was given; anything shorter holds the
// ratio instead, because fog that starts where the clear day's did and ends
// sixty units out is not fog, it is a wall.
const near = clamp(fogFar >= clearFar ? clearNear : fogFar * FOG_NEAR_RATIO, 2, fogFar * 0.9);
return {
sun: combineKey(lightDirection(env.sun, shadowFloor), rig.sunColor, rig.sunIntensity, moon),
hemisphere: {
sky: rig.hemiSky,
ground: rig.hemiGround,
intensity: Math.max(0, rig.hemiIntensity),
},
ambient: { color: rig.ambientColor, intensity: Math.max(0, rig.ambientIntensity) },
sky: { top: rig.skyTop, horizon: rig.skyHorizon },
fog: { color: fogColor, near, far: fogFar },
};
}
/**
* The sky's own cover, 0..1, for whatever wants to draw it.
*
* **A method on `Atmosphere` rather than a field of `LightingState` or a
* second return from `apply`.** The rig is a set of consequences — three
* lights, a gradient and a fog — and CONTRACT.md §4's one-way rule survives
* only while causes travel in an `Environment` and consequences travel in a
* `LightingState`. A cover parked on the rig is an invitation for the next
* module along to read a cause back out of a light, which is the shape this
* file exists to prevent. It is not a field of `Environment` either: an
* `Environment` is what was *observed*, `observe()` is handed nothing but a
* place and an instant, and a modelled number sitting in the observation is
* exactly the confusion that `WeatherObservation`'s `null`-means-unreported
* rule is careful about.
*
* Which leaves a method, and the closure is the reason it is a good one: the
* answer needs `lng`, because the marine layer's clock runs on apparent solar
* time, and it needs this city's `marineLayer`, because the fog is a fact
* about one coast. Both are already held here. A free function would have to
* be handed both at every call site, and the call site that matters already
* has an `Atmosphere` in scope.
*/
function cloudCover(env: Environment): number {
// **An observation wins outright, and nothing below is allowed to argue
// with it.** `WeatherObservation.cloudCover` is a measured fraction — never
// `null`, unlike the fields that can go unreported — so the presence of an
// observation at all is the whole test. A *reported* clear sky ends the
// argument here exactly as it does for obscuration in `apply`: the model
// below is what to do when nobody was asked, not a second opinion.
if (env.weather) return clamp(env.weather.cloudCover, 0, 1);
return modelledCloudCover(marineOptions, env, lng);
}
return { apply, cloudCover };
}
// ---- The sun's direction, and the shadow camera ---------------------------
/**
* A unit vector toward the sun, never allowed below `floorDeg`.
*
* This is the whole of the atmosphere's shadow handling, and it is a lift on
* the light direction rather than a change to the shadow camera because a
* `LightingState` deliberately carries no shadow fields — the extents belong to
* the scene, which knows its own scale, and letting the sun reach into them
* would be exactly the write-back CONTRACT.md §4 forbids. So the rule is
* inverted: never ask for a direction the shadow camera cannot serve.
*
* What goes wrong without it, at `SceneKit`'s defaults — a 2048 map over a
* 340-unit box, so one texel is ~0.17 units or about 16 m of San Francisco:
*
* - Depth across a texel grows as 1/tan(elevation). At 20° a texel spans 0.46
* units of depth; at 3° it spans 3.2. A single fixed `shadow.bias` tuned at
* one of those is acne or peter-panning at the other, and there is no value
* that is right at both.
* - A shadow three hundred metres long and two metres wide is a shape a 16 m
* texel cannot represent at all. It comes out as a dashed line that crawls
* when the camera moves.
* - Below the horizon the direction points *up* from underneath, lighting the
* undersides of everything and leaving the roofs black. That is not a
* subtle artefact; it is a scene that looks inside out.
*
* The visible cost is that shadows stop lengthening a few degrees before
* sunset. Against a sunset in which they shimmer, dash and then invert, that is
* a cheap trade — and by the time the lift is doing real work the sun's
* intensity is already down near a tenth, so there is very little shadow left
* to be wrong about. A scene that genuinely wants grazing shadows widens its
* own `shadowExtent`/`shadowMapSize` and passes `shadowFloorDeg: 0`.
*/
function lightDirection(sun: SolarPosition, floorDeg: number): [number, number, number] {
const lifted = floorDeg > 0 ? Math.max(sun.elevation, floorDeg) : sun.elevation;
const dir = sunDirection({ ...sun, elevation: lifted });
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.
//
// Weighted by how much moon there actually is, and not, as it was, by how
// much night there is. Those are the same number only on the nights the
// moon happens to be up, and the difference is the whole bug: `moonRig`
// correctly returns nothing for a moon below the horizon, so on the common
// case — which is most of every month, and *every* night before moonrise —
// this line was removing 85% of the only directional light in the scene in
// favour of a moon that was not there. The hills lost their lit side and
// the frame went flat, on exactly the nights that needed the stand-in most.
const present = clamp(moon.glow, 0, 1);
rig.sunIntensity *= 1 - 0.85 * night * present;
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.7 * glow;
rig.ambientIntensity *= 1 + 0.6 * 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);
// And the same again for the light that lands on the ground, which is the
// half the sky floor above was always missing. Colours per channel through
// `atLeast` for the reason that function documents — the floor is a blue, not
// a brightness — and the two intensities by plain maximum, because both terms
// are colour *times* intensity and flooring only one of them can be undone by
// the other. Both ramp in on `night`, so this is a floor that arrives through
// civil twilight rather than a step that switches on at some elevation.
//
// A moonlit night is already well above all five of these and passes through
// untouched, which is the whole reason the moon's own lift went up when this
// went in. See `MOONLIT_HEMI_SKY`.
rig.hemiSky = mixHex(rig.hemiSky, atLeast(rig.hemiSky, NIGHT_FLOOR_HEMI_SKY), night);
rig.hemiGround = mixHex(rig.hemiGround, atLeast(rig.hemiGround, NIGHT_FLOOR_HEMI_GROUND), night);
const ambientFloor = atLeast(rig.ambientColor, NIGHT_FLOOR_AMBIENT);
rig.ambientColor = mixHex(rig.ambientColor, ambientFloor, night);
rig.hemiIntensity = lerp(
rig.hemiIntensity,
Math.max(rig.hemiIntensity, NIGHT_FLOOR_HEMI_INTENSITY),
night,
);
rig.ambientIntensity = lerp(
rig.ambientIntensity,
Math.max(rig.ambientIntensity, NIGHT_FLOOR_AMBIENT_INTENSITY),
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 --------------------------------------------------------------
/**
* Cloud flattens and desaturates, and it does both by taking the sun away.
*
* Overcast is not "the same scene, dimmer": the directional component collapses
* and what is left is a uniform dome, so shadows vanish, the sky's gradient
* closes up and every colour loses its warmth because it is being lit by grey.
* Raising ambient while cutting the sun is what reproduces that — the total
* falls, but the ratio falls much further, which is the part the eye reads.
*/
function applyCloud(rig: Rig, cloud: number, day: number): void {
if (cloud <= 0) return;
rig.sunIntensity *= 1 - 0.78 * cloud;
rig.sunColor = mixHex(rig.sunColor, 0xf2f4f6, 0.6 * cloud);
// The zenith comes down to meet the horizon: an overcast sky has almost no
// gradient left in it, which is why an overcast photograph has no top.
rig.skyTop = desaturate(mixHex(rig.skyTop, rig.skyHorizon, 0.55 * cloud), 0.7 * cloud);
rig.skyHorizon = desaturate(rig.skyHorizon, 0.6 * cloud);
const dim = 1 - 0.16 * cloud * day;
rig.skyTop = scale(rig.skyTop, dim);
rig.skyHorizon = scale(rig.skyHorizon, dim);
rig.hemiSky = desaturate(rig.hemiSky, 0.55 * cloud);
rig.hemiIntensity *= 1 + 0.18 * cloud * day;
rig.ambientColor = desaturate(rig.ambientColor, 0.7 * cloud);
rig.ambientIntensity *= 1 + 0.55 * cloud * day;
}
function applyPrecipitation(
rig: Rig,
precipitation: number,
condition: SkyCondition,
day: number,
): void {
if (condition === "snow") {
// Snow is the largest reflector a scene ever acquires: the bounce light off
// the ground stops being dirt-coloured and starts being sky-coloured, which
// is most of why a snowy day looks the way it does from below.
rig.hemiGround = mixHex(rig.hemiGround, 0xe9eef2, 0.75);
rig.hemiIntensity *= 1 + 0.2 * day;
rig.ambientIntensity *= 1 + 0.15 * day;
}
if (precipitation <= 0 && condition !== "thunderstorm") return;
const heavy = condition === "thunderstorm" ? Math.max(0.75, precipitation) : precipitation;
const dim = 1 - 0.3 * heavy * day;
rig.sunIntensity *= 1 - 0.4 * heavy;
rig.hemiIntensity *= dim;
rig.ambientIntensity *= dim;
rig.skyTop = scale(desaturate(rig.skyTop, 0.4 * heavy), dim);
rig.skyHorizon = scale(desaturate(rig.skyHorizon, 0.4 * heavy), dim);
}
/**
* Fog distance from a reported visibility, in scene units.
*
* `THREE.Fog` is linear and fully opaque at `far`, and meteorological
* visibility is the range at which contrast is essentially gone, so the two are
* the same number by definition — once the "10 miles means we stopped counting"
* problem above is dealt with.
*/
function visibilityFar(
weather: WeatherObservation | null,
condition: SkyCondition,
clearFar: number,
metresPerUnit: number,
): number {
let km = weather?.visibilityKm ?? null;
if (km === null && condition === "fog") km = FOG_CONDITION_VISIBILITY_KM;
if (km === null) return clearFar;
const observed = (km * 1000) / metresPerUnit;
return lerp(observed, clearFar, smoothstep(VISIBILITY_HAZY_KM, VISIBILITY_UNLIMITED_KM, km));
}
// ---- The marine layer -----------------------------------------------------
/**
* How thoroughly the air itself is in the way, 0..1, as reported.
*
* Kept separate from the marine layer, and generic, because obscuration is not
* a San Francisco phenomenon even though the model of where it comes from is:
* a city with no `marineLayer` configured must still render a reported fog as
* fog — flat, shadowless, no horizon — rather than as a blue sky with the far
* shore mysteriously missing. The layer's job is to *supply* this number when
* nobody was asked; this is what to do with one once it exists.
*/
function observedObscuration(weather: WeatherObservation): number {
if (weather.condition === "fog") return 1;
const visibility = weather.visibilityKm;
// A continuous ramp rather than a threshold: 10 km is where a distant hill
// starts losing its edges and half a kilometre is where everything has gone,
// and a step anywhere between them would make the sky snap on a rounding
// difference upstream.
if (visibility !== null && visibility < 10) return clamp(1 - (visibility - 0.5) / 9.5, 0, 1);
// High stratus: the same air mass a few hundred metres up. Locally this is
// the overcast that gets called May grey and June gloom, and it flattens the
// light the same way without ever touching the ground.
if (weather.cloudCover > 0.8 && (visibility === null || visibility < 12)) return 0.35;
return 0;
}
function marineStrength(layer: MarineLayerOptions, env: Environment, lng: number): number {
const season = seasonFactor(layer, dayOfYear(env.time));
const diurnal = diurnalFactor(solarHours(env.time, lng, env.sun.equationOfTime));
const wind = windGate(layer, env.weather);
return clamp(layer.strength * season * diurnal * wind, 0, 1);
}
/** A smooth bump centred on the season's peak, on a residual floor. */
function seasonFactor(layer: MarineLayerOptions, doy: number): number {
const d = Math.abs(wrapSigned(doy - layer.peakDay, 365.25));
if (d >= layer.seasonDays) return layer.offSeason;
const bump = 0.5 * (1 + Math.cos((Math.PI * d) / layer.seasonDays));
return layer.offSeason + (1 - layer.offSeason) * bump;
}
/**
* The day's shape: thickest before dawn, burnt off through the late morning,
* back in from mid-afternoon.
*
* The hours are **apparent solar**, not civil, which is both physically right —
* burn-off is the sun doing work, and it starts when the sun does — and the
* only clock available offline. It does mean the curve reads early against a
* wristwatch: San Francisco's solar noon is around 13:07 PDT, so the 11.0 here
* where the layer is thinnest is a little after midday on the clock, and the
* 16.0 where it starts coming back is around five.
*/
const DIURNAL: readonly (readonly [number, number])[] = [
[0, 0.95],
[5, 1],
[7, 0.95],
[9, 0.6],
[11, 0.2],
[14, 0.12],
[16, 0.4],
[18, 0.8],
[20, 0.92],
[24, 0.95],
];
function diurnalFactor(hours: number): number {
const h = mod(hours, 24);
for (let i = 1; i < DIURNAL.length; i++) {
const a = DIURNAL[i - 1];
const b = DIURNAL[i];
if (!a || !b) continue;
if (h <= b[0]) return lerp(a[1], b[1], ease((h - a[0]) / (b[0] - a[0])));
}
return 0.95;
}
/**
* Advection fog has to be blown in, so the wind is a gate and not a garnish.
*
* This is also the only place wind touches the rig at all, and that is
* deliberate: wind moves clouds and shreds fog, and this renderer has no cloud
* layer for it to move. Wiring it to anything else — a brightness, a colour —
* would be decoration dressed as physics.
*
* Missing wind data returns 1. An unreported wind is not a calm.
*/
function windGate(layer: MarineLayerOptions, weather: WeatherObservation | null): number {
const speed = weather?.windKph ?? null;
const from = weather?.windDirDeg ?? null;
if (speed === null && from === null) return 1;
let gate = 1;
if (from !== null) {
const off = Math.abs(wrapSigned(from - layer.onshoreBearing, 360));
gate *=
off >= layer.onshoreHalfWidth
? 0.1
: 0.1 + 0.9 * (0.5 * (1 + Math.cos((Math.PI * off) / layer.onshoreHalfWidth)));
}
if (speed !== null) {
// Calm: the layer sits offshore and never arrives. Gale: mechanical mixing
// lifts it clear of the ground into stratus, which is why the foggiest days
// are breezy rather than windy.
if (speed < 3) gate *= 0.55;
else if (speed < 8) gate *= 0.55 + 0.45 * ((speed - 3) / 5);
else if (speed > 35) gate *= Math.max(0.25, 1 - (speed - 35) / 35);
}
return gate;
}
/**
* Fold obscuration into the rig and return the fog colour.
*
* Everything converges: the zenith, the horizon and the fog all end up the same
* flat grey-white, which is what kills the horizon line — there is no boundary
* left between sky and distance. The sun goes out but the total light does not,
* because fog is a diffuser and not a lid; it is bright, shadowless and
* directionless, and getting that combination right is the difference between
* fog and dusk.
*
* At zero the fog colour is the horizon colour exactly, which is both what
* `cityDaylight()` commits to and the physically honest answer — distant haze
* is lit by the sky it sits in front of, so it goes warm at sunset along with
* everything else rather than staying a neutral grey.
*
* **The convergence is a daytime effect and only a daytime effect**, which is
* what `night` is for. Fog is bright because the sun is in it, so pulling the
* fill toward the fog colour is a *brightening* and a flattening — the two
* things that make an overcast noon look like an overcast noon. Run the same
* mixes at 3 a.m., where the fog colour is a near-black derived from a night
* sky, and they are pure subtraction: San Francisco's August marine layer is at
* its thickest at three in the morning, and it was quietly taking 63% of the
* ground fill and all of its colour straight back out again — after
* `applyNight` had finished, so the night floor could not see it happen and had
* no chance to defend the frame. Which is a fair description of what a fog does
* to a photograph and a terrible description of what it does to a city, where
* the deck is lit from *underneath* by everything that is still switched on.
* The sky still converges at night; it should, since a foggy night has no stars
* in it. The ground no longer does.
*/
function applyObscuration(
rig: Rig,
obscuration: number,
day: number,
night: number,
condition: SkyCondition,
): number {
let thick = mixHex(desaturate(rig.skyHorizon, 0.9), 0xbfc8cc, 0.5 * day);
if (condition === "snow") thick = mixHex(thick, 0xeef2f5, 0.4);
const fogColor = mixHex(rig.skyHorizon, thick, obscuration);
if (obscuration <= 0) return fogColor;
rig.skyTop = mixHex(rig.skyTop, fogColor, 0.85 * obscuration);
rig.skyHorizon = mixHex(rig.skyHorizon, fogColor, 0.92 * obscuration);
rig.sunIntensity *= 1 - 0.88 * obscuration;
rig.sunColor = mixHex(rig.sunColor, 0xdfe6ea, 0.7 * obscuration);
const lit = 1 - night;
rig.hemiSky = mixHex(rig.hemiSky, fogColor, 0.7 * obscuration * lit);
rig.hemiGround = desaturate(rig.hemiGround, 0.6 * obscuration * lit);
rig.hemiIntensity *= 1 + 0.12 * obscuration * day;
rig.ambientColor = mixHex(rig.ambientColor, fogColor, 0.6 * obscuration * lit);
rig.ambientIntensity *= 1 + 0.45 * obscuration * day;
return fogColor;
}
// ---- Modelled cloud cover -------------------------------------------------
/**
* The most sky the generic term is ever allowed to cover.
*
* Scattered to broken, never overcast, and the cap is a statement about what
* this module is entitled to claim. It knows one climate in detail — the coast
* `MarineLayerOptions` describes — and for every other city it has a longitude
* and a sun angle. That is enough to say "there is usually some cloud about,
* more of it in the afternoon"; it is not enough to close a stranger's sky over
* a city it has never been told anything about. An overcast is a real event with
* a real cause, and if a deployment wants one rendered it can report one, at
* which point `cloudCover` hands the report straight through.
*/
const SYNOPTIC_MAX_COVER = 0.55;
/**
* The slow term: `[period in days, amplitude, phase in turns]`.
*
* Weather systems arrive, cover the sky for a day or two and leave, and that is
* the variation a viewer notices across a week. Three cosines of deliberately
* incommensurate period are the cheapest thing that produces it while staying
* *smooth* — every requirement on this number at once. Continuous in time, and
* in every derivative, so a clock that scrubs forward never steps. Deterministic
* from the instant alone, so two people looking at the same city at the same
* moment on different machines see the same sky, with no seed to agree on and
* nothing stored. And non-repeating on any timescale anyone will watch: as
* tenths of a day the three periods are 29, 67 and 151, all prime, so the
* combined pattern closes after 29 × 67 × 151 tenths — 29,339.3 days, a little
* over eighty years. A single period would come back around inside a fortnight
* and be recognised.
*
* The phases are there only so the three do not all start aligned at the Unix
* epoch, which is a real instant the clock can be scrubbed to.
*
* The amplitudes sum to 1, which is what lets `synopticCover` rescale without a
* second constant to keep in step.
*/
const SYNOPTIC_WAVES: readonly (readonly [number, number, number])[] = [
[2.9, 0.5, 0.13],
[6.7, 0.32, 0.61],
[15.1, 0.18, 0.29],
];
/**
* Exponent leaning the slow term back toward a clear sky.
*
* Three cosines summed and rescaled pile up around their own midpoint. Over a
* year of hourly samples the unshaped term runs 0.20 at the tenth percentile,
* 0.50 at the median and 0.80 at the ninetieth — a sky that is half covered
* half the time, which is not weather, it is a permanent haze the eye stops
* seeing after a minute. Raising it moves the middle down and leaves both ends
* alone: the shaped term still reaches its cap on the days all three waves
* agree and still reaches zero, but the same samples now run 0.11 / 0.38 /
* 0.73. Multiplied out through `SYNOPTIC_MAX_COVER` and `cumulusDiurnal`, a
* city with no marine layer spends a year at a median cover of 0.14, a
* ninetieth percentile of 0.30 and a maximum of 0.53 — some cloud up there
* nearly always, a busy sky now and again, and never a lid.
*/
const SYNOPTIC_SHAPE = 1.4;
/**
* Apparent solar hour the diurnal term peaks at, and how far it falls overnight.
*
* Cumulus over land is built by the ground under it: the surface heats, the
* heat takes time to get into the air above it, and the cloud that results
* peaks well after noon and thins out overnight. Mid-afternoon here rather than
* noon is that lag — and it is the reason this cannot be driven off the sun's
* elevation, which is the obvious idea and is symmetric about noon. Elevation
* alone makes nine in the morning and three in the afternoon the same sky, and
* they are not the same sky. Apparent solar hours are asymmetric about noon and
* are what `DIURNAL` already runs the marine layer's clock on; see `solarHours`
* for why that is also the only clock available offline.
*
* A floor rather than zero because not all cloud is convective. A sky that
* emptied completely every night and refilled every morning would be a stronger
* claim than this module has any way to support, and the marine layer — which
* does exactly the opposite, being thickest before dawn — is the standing proof
* that it would be wrong somewhere. Where it sits is a compromise between that
* and the requirement that a day not be flat: 0.4 leaves the afternoon two and
* a half times the pre-dawn sky at most, which is a shape you can watch arrive
* without it ever emptying.
*/
const CUMULUS_PEAK_SOLAR_HOUR = 15;
const CUMULUS_NIGHT_FLOOR = 0.4;
/**
* Cloud cover with nobody to ask: 0..1, from the calendar, the clock and the
* coast.
*
* This is the offline path and the offline path is the *default* one. A city
* with no weather API configured — no account, no key, no network, which is the
* case this whole engine is written around — has no observation to draw a sky
* from, and a caller that reads a cover of 0 out of that draws no cloud, ever.
* The fix belongs in this file because this file already models a sky nobody
* observed: it is where the marine layer lives, and the marine layer is the same
* argument already won once for fog.
*
* Two terms, and the split is the point:
*
* - **The coast, when there is one.** `marineStrength` is season × clock ×
* wind and is already what the fog is computed from; it is reused rather
* than paraphrased, so the deck a viewer sees and the grey the rig goes are
* the same event and cannot drift apart. Its strength reads directly as a
* cover because that is what it physically is — an advected stratus deck at
* full strength is a covered sky. It carries the season and the burn-off
* clock with it, which is why a June morning here comes out closed in and a
* December one does not.
* - **Everywhere else.** A slow synoptic drift over days, modulated by the
* afternoon build of cumulus over warm ground. Capped well short of
* overcast — see `SYNOPTIC_MAX_COVER` — because unlike the marine layer it
* is not a fact about anywhere in particular.
*
* The two combine by random overlap: two decks placed independently of one
* another leave `(1 - a)(1 - b)` of the sky clear between them. Plain `max` was
* the alternative and swallows the weaker layer whole, so a summer morning in
* San Francisco would render identically whether or not there was anything else
* in the sky that week.
*
* **Nothing here reaches the light rig, deliberately.** `apply` still reads its
* `cloud` from `env.weather` alone and every keyframe, curve and constant above
* is untouched — the rig is tuned and deployed and this is an output, not a new
* input. It would also be wrong twice over: the marine half of this number
* already reaches the rig as `obscuration`, so feeding it back in as `cloud`
* would count the same deck against the sun twice.
*/
function modelledCloudCover(
layer: MarineLayerOptions | null,
env: Environment,
lng: number,
): number {
const marine = layer ? marineStrength(layer, env, lng) : 0;
// UTC milliseconds, so the phase is an absolute instant rather than anything
// to do with the viewer's timezone — two people in different zones are
// looking at the same sky and must be given the same number for it.
const days = env.time.getTime() / MS_PER_DAY;
const background =
SYNOPTIC_MAX_COVER *
synopticCover(days) *
cumulusDiurnal(solarHours(env.time, lng, env.sun.equationOfTime));
return clamp(1 - (1 - marine) * (1 - background), 0, 1);
}
/** The slow term, 0..1. See `SYNOPTIC_WAVES` and `SYNOPTIC_SHAPE`. */
function synopticCover(days: number): number {
let sum = 0;
for (const [period, amplitude, phase] of SYNOPTIC_WAVES) {
sum += amplitude * Math.cos(2 * Math.PI * (days / period + phase));
}
// The amplitudes sum to 1, so `sum` lands in -1..1 and the base in 0..1.
// Clamped even so, and the reason is the exponent: it is fractional, and a
// base a single float error *below* zero raised to a fractional power is
// `NaN` rather than a small number. Neither 0.32 nor 0.18 is exact in binary,
// so "the amplitudes sum to 1" is true of the decimals and not quite of the
// doubles. One `NaN` leaving here is a cloud layer that silently stops
// drawing at one instant on one machine, which is the least debuggable
// failure available to a function this small.
return clamp((sum + 1) / 2, 0, 1) ** SYNOPTIC_SHAPE;
}
/**
* The afternoon build, as a multiplier on the slow term rather than a term of
* its own: a cloudy week is cloudier in the afternoon, and a clear week is
* still clear at four o'clock.
*
* A cosine rather than a table like `DIURNAL`, because this one has to close the
* loop at midnight and a table has to be trusted to. Periodic by construction
* means there is no midnight seam to get wrong later.
*/
function cumulusDiurnal(hours: number): number {
const turns = mod(hours - CUMULUS_PEAK_SOLAR_HOUR, 24) / 24;
const bump = 0.5 * (1 + Math.cos(2 * Math.PI * turns));
return CUMULUS_NIGHT_FLOOR + (1 - CUMULUS_NIGHT_FLOOR) * bump;
}
// ---- Time -----------------------------------------------------------------
/**
* Apparent solar hours since local midnight.
*
* `solar.ts` computes this same quantity on its way to an azimuth and does not
* export it. Three lines here is cheaper than widening `SolarPosition` with a
* field only the marine layer reads — and the equation of time, which is the
* hard part, does come across on the observation.
*/
function solarHours(when: Date, lng: number, equationOfTime: number): number {
const utcMinutes = mod(when.getTime() / MS_PER_MINUTE, 1440);
return mod(utcMinutes + equationOfTime + 4 * lng, 1440) / 60;
}
/**
* Day of the year, 1-366, in UTC. The seasonal curve is nearly three months
* wide, so which side of midnight the local day falls on is not a difference it
* can express.
*/
function dayOfYear(when: Date): number {
const start = Date.UTC(when.getUTCFullYear(), 0, 1);
return Math.floor((when.getTime() - start) / MS_PER_DAY) + 1;
}
// ---- Colour ---------------------------------------------------------------
/**
* Blend two colours the way light blends, not the way bytes do.
*
* sRGB is a display encoding, and lerping in it sends the midpoint between a
* twilight blue and a sunset orange through a dead brown-grey that neither
* colour has any of. Squaring into approximately linear light, mixing there and
* taking the square root back is the cheapest fix that removes it, and at dusk
* — when almost every colour in the table is being interpolated at once — the
* difference is the whole mood of the frame.
*/
function mixHex(a: number, b: number, t: number): number {
const k = clamp(t, 0, 1);
const [ar, ag, ab] = linear(a);
const [br, bg, bb] = linear(b);
return encode(lerp(ar, br, k), lerp(ag, bg, k), lerp(ab, bb, k));
}
/** Pull a colour toward its own brightness. `t = 1` is grey. */
function desaturate(hex: number, t: number): number {
const k = clamp(t, 0, 1);
const [r, g, b] = linear(hex);
// Rec. 709 luminance, on linear values, which is the only place it means
// anything.
const y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
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);
const f = Math.max(0, factor);
return encode(r * f, g * f, b * f);
}
function linear(hex: number): [number, number, number] {
const r = ((hex >> 16) & 0xff) / 255;
const g = ((hex >> 8) & 0xff) / 255;
const b = (hex & 0xff) / 255;
return [r * r, g * g, b * b];
}
function encode(r: number, g: number, b: number): number {
const to = (v: number) => Math.round(clamp(Math.sqrt(Math.max(0, v)), 0, 1) * 255);
return (to(r) << 16) | (to(g) << 8) | to(b);
}
// ---- Helpers --------------------------------------------------------------
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function clamp(x: number, lo: number, hi: number): number {
return x < lo ? lo : x > hi ? hi : x;
}
/** Hermite ease over a span, flat at both ends. */
function smoothstep(edge0: number, edge1: number, x: number): number {
if (edge1 === edge0) return x < edge0 ? 0 : 1;
return ease((x - edge0) / (edge1 - edge0));
}
function ease(t: number): number {
const k = clamp(t, 0, 1);
return k * k * (3 - 2 * k);
}
/** `%` keeps the sign of the dividend, which is wrong for angles and clocks. */
function mod(x: number, n: number): number {
return ((x % n) + n) % n;
}
/** The shortest signed distance around a cycle: -180..180 for degrees. */
function wrapSigned(x: number, period: number): number {
return mod(x + period / 2, period) - period / 2;
}
// ---- Sanity checks --------------------------------------------------------
/**
* Values this file actually produces, so the numbers above can be argued with
* rather than only read.
*
* All of these are San Francisco — 37.7749 N, 122.4194 W, `metresPerUnit` of
* 94.34, `marineLayer: PACIFIC_MARINE_LAYER` — with fog distances in scene
* units, where the clear-day baseline is 210/460.
*
* **With no weather at all, which is the offline case:**
*
* - **Solar noon, 21 June** (75.6°): sun 2.03, fog 179/397. The layer's
* diurnal curve is near its minimum and its season factor near its
* maximum, which nets out as the faint July haze that softens the far side
* of the bay without touching the light.
* - **08:00 PDT, 21 June** (23.4°): sun 0.61, hemisphere 1.15, ambient 0.43,
* fog 60/133. A high sun almost entirely extinguished, the sky and the fog
* converged on one grey, and more fill light than at noon. That is the
* single most San Franciscan frame this engine can produce, and the fact
* that the *fill goes up* as the *sun goes down* is the whole trick.
* - **08:00 PDT, 21 October** (6.1°): sun 1.49, fog 190/421, horizon
* #e6bd98. The same hour, four months later, and the model has to give
* 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°, with the moon down): key 0.045, hemisphere 0.78,
* ambient 0.22, 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. The hemisphere and the ambient are the night
* floor's own two numbers to the digit, which is the floor doing exactly the
* job it is there for; the key is down to 0.045 from the table's 0.16
* because the marine layer is at its thickest at 3 a.m. in June and takes
* 88% of it, and 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.
*
* **With weather, at solar noon on 21 June:**
*
* - `cloudCover: 0.05, visibilityKm: 16` — sun 2.26, fog 210/460 exactly.
* The observation says clear and the climatology is overruled; 16 km is
* read as "unlimited" rather than as 170 units of haze, which is the `10SM`
* correction doing its job.
* - `cloudCover: 1, visibilityKm: 14, condition: "overcast"` — sun 0.52,
* ambient 0.31 → 0.46, fog 185/411. Bright and shadowless, not dusk.
* - `condition: "fog"`, no visibility number — sun 0.06, hemisphere 1.45,
* ambient 0.67, fog 21/48. 48 units is the 4500 m floor: fog this thick is
* capped deliberately, because the honest number renders a white rectangle.
* - `visibilityKm: 6` under light cloud — sun 1.13, fog 27/59. Haze, not fog.
*
* **Without a `marineLayer` at all**, a reported fog still renders as fog —
* sun 0.06, one flat grey, no horizon — and the same city with no weather
* renders the clear baseline exactly. The layer decides where obscuration
* *comes from* when nobody was asked; it is not what makes obscuration look
* like anything.
*
* Tromsø on 5 January, at -2.98°, returns sun 0.37 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 1.174, colour
* #9cb3e6, hemisphere 1.12 of #3a5188, ambient 0.29 of #4d5c87, sky #101b39
* over #2a385b. Half the sun's noon intensity, which is a preposterous
* number and the one that puts a soft directional key on the city 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.160 of
* the table's own sidelight colour #44558a, hemisphere 0.78 of #354c88 over
* #1f2740, ambient 0.22 of #47557f, and the sky at #090e1c over #16203a.
* Every one of those five is the floor to the digit. 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. Rendered, the Bay Area board
* comes out at about y23 on the land against y15 on the bay and y29 on the
* sky, with downtown's windows peaking past y130 — dark, but a dark you can
* find a coastline in.
* - **Half moon 4° up** (20 August 2026, 06:00 UTC): key 0.397. A third 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.610 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 *fill* it no longer
* takes; see `applyObscuration`.
* - **The same night reported overcast**: key 0.131 — 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.160,
* colour #44558a, the table's token sidelight left in charge — which is now
* the same frame the moon-below-the-horizon case produces, and should be.
* Both floors still apply, because neither of them is about the moon.
*
* The gap between the second of those and the first is the one relationship
* this file is tuned around: seven times the key, and on screen a Bay Area board
* that goes from about y23 on the land to about y53. Moonrise is an event you
* can watch happen, which is the whole justification for `moonPosition` being
* four hundred lines of Meeus rather than a constant.
*
* 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.5° the key is 0.592
* and #ce805b from the west; at -3.7° it is 0.328 and #8e6f87 from between
* them; by -7.6° it is 0.572 and #8ea0d3 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.
*
* **`cloudCover`, with no weather at all**, which is the case it exists for. San
* Francisco with `PACIFIC_MARINE_LAYER`, 21 June 2026, midnight to 23:00 PDT:
*
* ```
* 0.83 0.83 0.83 0.83 0.84 0.85 0.86 0.84 0.82 0.73 0.57 0.46
* 0.28 0.27 0.25 0.23 0.30 0.43 0.54 0.72 0.76 0.81 0.81 0.82
* ```
*
* A June night closed in at over four fifths, burning back to under a quarter
* by mid-afternoon and shut again by nine — which is the marine layer's own
* diurnal curve arriving in the sky as well as in the fog, and is the day that
* city actually has in June. The 08:00 in that row is 0.82; the same hour four
* months later, on 21 October, is 0.12, and solar noon on 21 December 0.04:
* out of season the layer is
* not there, and neither is the cloud — the same October the fog notes above
* are careful about, arriving here for the same reason and out of the same
* curve. Los Angeles, same 24 hours and no marine layer, runs 0.15 down
* to 0.09 before dawn and back to 0.14 through the afternoon — the generic term
* alone, which is a few clouds about and a slight afternoon build, and never
* pretends to be more than that.
*
* Over a full year sampled hourly, San Francisco's modelled cover runs a median
* of 0.27 and a ninetieth percentile of 0.76, and touches 1.00 at the peak of
* the fog season; Los Angeles runs 0.14 and 0.30 against a maximum of 0.53,
* which is `SYNOPTIC_MAX_COVER` very nearly reached. Neither produces a
* non-finite value or leaves 0..1 anywhere in that year, including at 69.65 N,
* where the sun never sets and `solarHours` is doing the only clock there is.
*
* The largest change in one minute anywhere in that year is 0.0065, at San
* Francisco's steepest burn-off. There is nothing in this to step on: a scrubbed
* clock moves the sky the way an advancing one does.
*
* And with a station reporting, the model does not get a vote: the same June
* morning that models 0.82 returns exactly 0.05 when the observation says 0.05.
*/