/** * 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. */