/**
 * A cloud layer over the city.
 *
 * One `THREE.Mesh` — one draw call — holding about fifteen hundred instanced
 * quads, driven by a single number: `WeatherObservation.cloudCover`, which the
 * app already has and until now spent only on the light rig. At 0 the layer does
 * not draw at all; at 1 it is an overcast deck; and everything between is the
 * same field of clouds with more of them, larger, lower and flatter.
 *
 * ## The hard case is looking *down* at it
 *
 * A cloud layer is easy to make convincing from underneath. Point a camera at
 * the sky, put some soft sprites up there, and the eye supplies the rest. This
 * board is not looked at from underneath. San Francisco's opening chapter puts
 * the camera 430 units above the ground with the cloud base at 52, and the
 * orbit reaches two board spans out — so the **default** framing is from above,
 * looking down through the layer at the city. From there the obvious
 * implementation gives itself away in about a tenth of a second: a sheet of
 * camera-facing sprites at one altitude is a sheet of camera-facing sprites at
 * one altitude, and it reads as a decal on the lens rather than as weather.
 *
 * Four things are doing the work, and none of them is optional:
 *
 *   1. **Every puff is shaded as a sphere, not as a sticker.** The fragment
 *      shader builds a fake hemisphere normal out of the sprite's own disc
 *      coordinates and lights it from the rig's sun direction. So each puff has
 *      a lit limb and a dark limb, and a field of them seen from directly above
 *      is a *lumpy* field with the light coming from one side, which is the cue
 *      that says "volume". Take that out and everything below stops working.
 *   2. **Clouds have a base and a top.** Puffs are clustered into cells with a
 *      real vertical spread and a dome silhouette — wider and lower at the
 *      bottom, smaller and tighter at the top — and the shading knows where in
 *      its own cloud each puff sits. That is also where the requirement to be
 *      dark underneath is discharged: the bottom of a cell faces down, sees the
 *      ground rather than the sky, and gets `uBase` rather than `uShade`.
 *   3. **There are two decks at different heights, drifting at different
 *      speeds.** A low cumulus field of camera-facing puffs, and a high veil of
 *      flat quads lying in the horizontal plane. That buys genuine parallax:
 *      orbit the board and the two layers slide across each other, which is
 *      something no single sheet can fake. The upper wind is faster and veered
 *      clockwise from the lower one, which is true of the real atmosphere in the
 *      northern hemisphere and, more usefully here, means the combined pattern
 *      never repeats even though each layer separately tiles.
 *   4. **The veil is genuinely flat and the deck genuinely is not.** Cirrus is a
 *      sheet; drawing it as a sheet is correct rather than a shortcut, and the
 *      contrast between the flat upper layer and the lumpy lower one is most of
 *      what sells the altitude difference between them.
 *
 * ## Lighting is not ours
 *
 * This layer computes no light. It takes a `LightingState` — the thing
 * `Atmosphere` produces and `SceneKit` applies — and derives its own colours
 * from the terms already in it. CONTRACT.md §4 gives lighting exactly one owner
 * and a layer that works out its own sun is precisely what that rule exists to
 * forbid.
 *
 * There is one subtlety in that translation and it is worth stating in full,
 * because getting it wrong makes an overcast day look broken. A `LightingState`
 * describes **the light arriving at the ground**, and `applyCloud` in
 * `atmosphere.ts` correctly collapses the sun's intensity by nearly four fifths
 * at full cover — because the ground is *under* the deck. The top of the deck is
 * not. A sunlit overcast layer seen from above is the brightest thing in the
 * frame, and if this layer read its brightness off `sun.intensity` it would go
 * grey at exactly the moment it ought to go white.
 *
 * So the level comes from the **hemisphere** term instead, which is the one
 * number in the state that tracks day and night hard and barely moves with cloud
 * (`applyCloud` desaturates it and lifts its intensity by 18%, which very nearly
 * cancels). The sun's *hue* and *direction* still come from the sun, and its
 * *intensity* still sets `uKey` — how strongly the puffs are modelled — so an
 * overcast deck flattens out and a cumulus in clear noon sun does not. Nothing
 * here is invented; the question answered is only which term of the state
 * applies to a surface thirteen hundred metres up.
 *
 * ### The night was the one case that argument got wrong
 *
 * `hemisphere.intensity` tracks day and night hard — but not in the direction
 * this file assumed. `atmosphere.ts` *floors* the night so the world is never
 * black, and the floor arrives as a lifted hemisphere: measured over a full day
 * at San Francisco, that term is **1.34 at two in the morning against 0.96 at
 * noon**. Normalising by it therefore inflated the deck's night level by about
 * 40% relative to its day level, and the picture showed it. At 22:30 PDT, on a
 * keyless clone where the marine-layer model puts 0.63-0.65 cover over the city:
 *
 * | term | measured (linear luminance) |
 * |---|---|
 * | `uLit` — the moonward flank | 0.072 |
 * | `uShade` — the flank facing away | 0.084 |
 *
 * The shaded side was **brighter than the lit side**, which is not a lighting
 * bug so much as the absence of lighting: a flat wash with no modelling in it.
 * Rendered, the deck lifted a night frame's mean luminance from 25.3 to 44.3 out
 * of 255 while adding no readable shape — the whole board turning into a milky
 * blue rectangle with a city somewhere under it. Suppress the layer and the same
 * frame is a legible dark city with its street grid and its coastline in it.
 *
 * Two changes fix it, and both are corrections to the *normalisation* rather
 * than to `atmosphere.ts`. The model is right — August in San Francisco is
 * overcast at night — and it is the render of that fact that was wrong.
 *
 *   1. **The level is normalised on the sky dome's own colour** (`SKY_DAY_REFERENCE`)
 *      and no longer multiplied by an intensity that is lifted at night. The sky
 *      the cloud top hangs under is the light the cloud top is receiving, which
 *      is the physically honest quantity and happens also to be monotone through
 *      dusk: 0.87 at noon, 0.41 at four degrees of sun, 0.13 at civil twilight,
 *      0.079 all night.
 *   2. **At night the moon is the key and the sky is not.** `NIGHT_TOP_*` puts
 *      the deck's lit flank on the moon's key alone and `NIGHT_SHADE_*` takes
 *      the flank facing away down with it, so the modelling survives and the
 *      wash does not. A moonless overcast night still lands on the floor rather
 *      than on black — `0.06 is not black` was always the right instinct; the
 *      floor was simply being reached from a level that was four times too high.
 *
 * ### And the deck must not smother the map it sits under
 *
 * `opacityScale` finally has a caller, and it is the camera. A consolidated deck
 * seen from a whole-board standoff is the brightest thing in the frame *and* the
 * thing covering everything the frame is for; seen from a low chapter pose it is
 * weather you are flying under and it should be as solid as it has always been.
 * `altitudeThinning` is one smooth function of how far the camera is above the
 * cloud base in board spans, gated on `deckOf(cover)` so scattered cumulus never
 * thins — a broken fair-weather sky has gaps to see the city through already,
 * and a lid does not. It is smooth precisely so it cannot pop during a fly-in.
 *
 * ## What it costs
 *
 * One draw call, ~1,470 instances, 4 vertices each. No shadows: the mesh neither
 * casts nor receives, the shadow budget is spent (`stage.ts` gives the whole
 * city a 1024–2048 map over a 1504-unit box), and a cloud shadow at board scale
 * would be one texel wide and would strobe. Real cloud shadows are a wonderful
 * thing and they are a different renderer's problem.
 *
 * ## Scale: altitude is physics, size is not
 *
 * Altitudes go through `world.metres()`, so they pick up the city's vertical
 * exaggeration exactly as a building's height does. That is not decoration — it
 * is the only thing that keeps the deck above the towers. San Francisco is 94 m
 * per unit at 3.6x, so a 326 m tower stands 12.5 units tall and a 1,350 m cloud
 * base *without* the exaggeration would be 14.3 units: level with the roofs.
 * With it, 51.7 — four tower-heights up, which is what a cloud base looks like.
 *
 * Horizontal sizes deliberately do **not** come from metres, and this is the one
 * place the file lies. A fair-weather cumulus is a kilometre or two across,
 * which on this board is ten to twenty units against a 1,003-unit span: from
 * every camera position the orbit can reach, an honest cumulus is between one
 * and four pixels and the whole layer reads as film grain. So puff size is
 * derived from the *board* — see `CELL_GRID` — and the clouds this draws are
 * five to twelve kilometres across, which is a stratocumulus complex rather than
 * a cumulus humilis. It is the same trade `atmosphere.ts` makes for visibility
 * and for moonlight: a map is looked at from outside the atmosphere it is
 * depicting, and reproducing the true angular size of things in that atmosphere
 * produces a correct picture of nothing.
 */

import * as THREE from "three";
import { deviceProfile } from "./stage.ts";
import type { LightingState } from "./types.ts";
import { fbm, seededRandom, type World } from "./world.ts";

// ---- The dials ------------------------------------------------------------

/**
 * How much wider than the board the wrapping field is, per side.
 *
 * The field is a torus — every position is taken modulo `tile`, so it is
 * infinite by tiling and can never run out however long it drifts. What the
 * number buys is therefore not coverage but *the distance at which the repeat
 * becomes findable*, and 2.2 spans puts the second copy of any given cloud far
 * enough out that the scene's own fog has most of it. The two decks tile on the
 * same period and drift at different rates, so the combined pattern is aperiodic
 * even though each layer is not, which is why this can be as small as it is.
 */
const TILE_SPANS = 2.2;

/**
 * Cells per side of the low deck's jittered grid, and puffs in each cell.
 *
 * These two numbers set everything else about the deck's geometry, so it is
 * worth following the chain. 14x14 cells over a 2.2-span tile puts one cell
 * every 0.157 spans; a cell must therefore reach about 0.088 spans to own its
 * share of the plane once neighbours overlap, which for San Francisco's 1,003
 * units is 88 — a cloud complex a little under nine kilometres across, sitting
 * 1.35 km up. That is a real thing you can look at out of an aeroplane window.
 *
 * Seven puffs per cell is the smallest number that makes a cell read as a cloud
 * rather than as a blob: one core, a couple of shoulders, and enough on top to
 * build the dome. Below five the silhouette is a circle; above nine the extra
 * ones land inside the ones already there and cost fill rate for nothing.
 *
 * 196 x 7 = 1,372 instances, plus the veil, and the whole layer is one draw
 * call. For reference the city beside it is ~24,000 building instances.
 */
const CELL_GRID = 14;
const PUFFS_PER_CELL = 7;

/** Flat quads in the high veil. Large, thin and few; see `buildVeil`. */
const VEIL_QUADS = 96;

/**
 * How far a cell's centre may wander off its grid node, as a fraction of the
 * grid pitch.
 *
 * A pure grid reads as a grid the instant the camera gets above it — the eye is
 * extremely good at finding rows in a texture. A pure Poisson scatter clumps and
 * leaves holes that never close at high cover. A jittered grid is the standard
 * middle: even coverage, no rows.
 */
const CELL_JITTER = 0.5;

/**
 * The width of a cell's fade-in, in units of cover.
 *
 * Each cell holds a threshold and appears when `cover` passes it, which would be
 * a pop; this is the ramp that makes it a growth instead. Thresholds are drawn
 * uniformly from `[0, 1 - COVER_FEATHER]` rather than from `[0, 1]` precisely so
 * that `cover = 1` leaves every cell fully present — with thresholds up at 0.98
 * a nominally overcast sky would still have a scatter of half-formed clouds in
 * it, which is the one thing overcast never has.
 */
const COVER_FEATHER = 0.22;

/** Below this the layer stops drawing entirely rather than drawing nothing. */
const MIN_COVER = 0.004;

/**
 * How fast a change in cover is taken up, as a time constant in seconds.
 *
 * The weather arrives every few minutes and a poll that steps cover from 0.2 to
 * 0.8 between one frame and the next would materialise a sky. Eased, it is
 * clouds building — which is what actually happened in the twenty minutes the
 * two observations are apart. Godmode scrubs this dial directly and gets the
 * same ease, which is the point of it being here rather than in the caller.
 */
const COVER_TAU = 6;

/**
 * How much faster than life the field drifts.
 *
 * A 22 km/h wind is 6.1 m/s, which at San Francisco's 94 m per unit is 0.065
 * units a second: a cloud crosses one cell pitch in forty minutes and the layer
 * is, to anyone looking at it, nailed down. That is *correct* — real clouds
 * really are that slow against a ninety-kilometre board — and it is useless,
 * because a still image of weather reads as a bug in the weather.
 *
 * At 12x, the deck crosses the whole Bay Area board in about twenty minutes and
 * moves a visible amount over the ten or fifteen seconds someone actually spends
 * looking at one frame. It is still unmistakably a drift rather than a scroll.
 * Raise it much past this and the clouds start to look like smoke.
 */
const DRIFT_EXAGGERATION = 12;

/** Wind assumed when nobody has reported one. A westerly, because most are. */
const DEFAULT_WIND_KPH = 22;
const DEFAULT_WIND_FROM_DEG = 270;

/**
 * How much the upper wind veers clockwise from the lower one, in degrees, and
 * how much faster it runs.
 *
 * Both are real — friction slows and backs the wind near the surface, so it
 * veers and accelerates with height in the northern hemisphere — and both are
 * here for a rendering reason as much as a meteorological one. Two layers
 * sliding on the same vector are one layer; two layers sliding on different
 * vectors are a sky.
 */
const VEIL_VEER_DEG = 25;
const VEIL_SPEED_FACTOR = 1.9;

/**
 * Altitudes, in metres above the ground, before the city's vertical
 * exaggeration is applied.
 *
 * A stratocumulus base sits around 1,200–1,500 m and a deck is 400–900 m thick;
 * cirrus lives at 6–10 km. These are the numbers that go through
 * `world.metres()`, which is what makes the layer respect a city pack's own
 * exaggeration instead of hard-coding San Francisco's.
 */
const BASE_ALTITUDE_M = 1350;
const DECK_THICKNESS_M = 750;
const VEIL_ALTITUDE_M = 7000;

/**
 * How far the deck drops and flattens as it consolidates.
 *
 * Broken fair-weather cloud is high-based and deep; a solid overcast layer is
 * lower and thinner, which is why an overcast day feels like a lid. Applied on
 * `deckOf(cover)` so it arrives over the top half of the cover range rather than
 * at a threshold.
 */
const DECK_ALTITUDE_DROP = 0.62;
const DECK_FLATTEN = 0.55;

/**
 * Per-puff alpha at no cover and at full cover.
 *
 * These are the numbers that stop an overcast sky deleting the city, and the
 * arithmetic behind them is worth writing down because it is not obvious that
 * the *lower* number belongs at the *higher* cover.
 *
 * Opacity accumulates along the view ray: n overlapping puffs at alpha a leave
 * `1 - (1 - a)^n`. `buildDeck` packs a cell tightly enough that a ray through
 * its core crosses about four of its seven puffs; at full cover, with
 * neighbouring cells grown into each other, it crosses seven or eight. To land
 * the fully-overlapped deck short of opaque the per-puff figure therefore has to
 * *fall* as cover rises: at 0.28 against a texture whose core sits near 0.9,
 * seven layers leave `1 - 0.75^7 = 0.87` at the very centre of a cell and
 * visibly less than that everywhere else, because the texture is at its core
 * value over only a third of its radius. Measured against a rendered frame, the
 * city stays legible through the deck. At low cover the same ray crosses four
 * puffs, and 0.46 gives `1 - 0.59^4 = 0.88` — a cumulus that is properly opaque,
 * which a cumulus is.
 *
 * The first pass at these was less than half as large, on the theory that soft
 * translucent puffs would build up gently. They do not build up at all: two
 * sprites at 0.2 leave 0.36, the sky reads straight through, and the entire
 * layer looks like lens flare rather than weather. Cloud is *opaque*. The
 * softness belongs in the silhouette, which is what the texture is for, and not
 * in the body.
 *
 * Landing near 0.8 rather than at 1 is a deliberate lie of the same family as
 * `minVisibilityM` in `atmosphere.ts`. A genuinely opaque deck seen from the
 * whole-board pose is a grey rectangle where the map was, and a map that goes
 * blank when the weather turns is read as an outage, not as weather. At 0.8 the
 * coastline and the freeways are still findable through it and it is still
 * unmistakably overcast.
 *
 * Nothing can *bound* the accumulation exactly — how many puffs a ray crosses is
 * a property of the ray — so these are tuned, not derived, and `opacityScale` is
 * the dial for a deployment that wants a heavier or lighter sky.
 */
const PUFF_ALPHA_CLEAR = 0.46;
const PUFF_ALPHA_OVERCAST = 0.28;

/**
 * Puff radius multiplier at no cover and at full cover.
 *
 * The growth is what closes the deck, and it is doing a job the cell *count*
 * could otherwise do and must not. Covering a 2.2-span tile with clouds small
 * enough to still be individually cumulus at overcast would take several
 * thousand cells and as many thousand instances; growing two hundred of them
 * until they touch costs nothing and is what a consolidating stratocumulus sheet
 * genuinely does. At 1.75 a cell reaches 1.06 pitches, so every cell overlaps
 * all four of its neighbours and the gaps close. Below about 1.6 they do not,
 * and full overcast renders as a lattice of separate blobs with sky between them
 * — which is the one arrangement the real sky never produces and the eye finds
 * instantly from above.
 */
const PUFF_GROWTH_CLEAR = 0.7;
const PUFF_GROWTH_OVERCAST = 1.75;

/**
 * The sky-dome luminance at which the deck's top counts as fully daylit, and
 * how much of the deck's level rides on it.
 *
 * This pair replaces `luminance(sky) * hemisphere.intensity / 0.96`, and the
 * second factor was the mistake. `hemisphere.intensity` is not a measure of how
 * much daylight there is; it is the rig's fill term, and `atmosphere.ts`
 * deliberately *lifts* it after dark so the world is never black. Measured
 * across a full day at San Francisco it runs 0.96 at noon and **1.34 at two in
 * the morning**, so the one term this file was treating as "how much daylight is
 * there" was at its highest value in the middle of the night. See the header.
 *
 * The dome's colour alone has none of that problem, and is the more honest
 * quantity besides: a cloud top is lit by the sky it hangs under. Measured over
 * the same day, `luminance(hemisphere.sky)`:
 *
 * | sun elevation | luminance |
 * |---|---|
 * | +63 (noon) | 0.866 |
 * | +33 | 0.812 |
 * | +9.5 | 0.595 |
 * | +3.8 | 0.407 |
 * | -2.0 (civil twilight) | 0.126 |
 * | -8 and below (night) | 0.079 |
 *
 * Monotone through dusk, flat all night, and it needs no second opinion about
 * when night begins.
 *
 * **The two numbers are fitted, and fitted to change nothing in daylight.** The
 * day was never the defect — a sunlit overcast deck reads correctly today — so
 * these were chosen by sweeping both against the expression they replace over
 * 190 daylit frames spanning four places and two seasons (San Francisco in
 * August and December, Los Angeles in August, the Central Valley at the
 * solstice). At 0.838 and 0.772 the largest disagreement anywhere in daylight is
 * **0.032** on a multiplier that runs 0.06 to 0.86 — under four percent of full
 * scale, and below what a rendered frame shows. Everything this change actually
 * moves is on the other side of `NIGHT_SKY_HIGH`.
 */
const SKY_DAY_REFERENCE = 0.838;
const SKY_DAY_GAIN = 0.772;

/**
 * The sky-dome luminance range over which this layer hands the deck from the sun
 * to the moon.
 *
 * Deliberately a property of the dome rather than a second definition of
 * `nightFactor` — `atmosphere.ts` owns when night begins for the light rig, and
 * two modules each inventing their own idea of dusk is how a city ends up
 * switching its lights on after the moon is already the brightest thing in the
 * frame. What this needs to know is narrower: how much of the deck's
 * illumination is still coming from the sky. The upper edge sits at 0.42, just
 * above the +3.8 degree stop, because that is the last rig in which the sun is
 * still doing the lighting; the lower edge sits at 0.09, a hair above the flat
 * night value, so full night is reached and held rather than approached.
 */
const NIGHT_SKY_LOW = 0.09;
const NIGHT_SKY_HIGH = 0.42;

/**
 * The deck's lit flank at full night: a floor, plus what the moon's key buys.
 *
 * At night `state.sun` **is** the moon — `atmosphere.ts` hands the key over on
 * `nightFactor`'s own curve and `combineKey` merges them into one direction and
 * one colour, which is exactly why this layer can stay a pure consumer. So the
 * moonward flank of a cloud top is the moon's key and nothing else, and the two
 * numbers are the two things a night deck can be: 0.010 is a moonless overcast
 * night, which is dark but is not `#000`, and the 0.155 gain is what a moon
 * high and full enough to cast a shadow adds to it.
 *
 * Measured against the frame this exists to fix: 22:30 PDT, a 34%-lit moon at
 * 22 degrees, key 0.20. Old `uLit` 0.072 and `uShade` 0.084 — the shaded side
 * brighter than the lit one, i.e. no modelling at all. New `uLit` ~0.018 and
 * `uShade` ~0.014, so the deck is four times darker *and* has a light direction
 * in it for the first time.
 */
const NIGHT_TOP_FLOOR = 0.010;
const NIGHT_TOP_KEY = 0.155;

/**
 * What the two fill terms are worth at full night, against 0.55 and 0.7 by day.
 *
 * The flanks of a daylight cumulus are lit by the entire rest of the dome and by
 * several hundred metres of its own body, which is why the day weights are so
 * generous and why a cumulus never has a black side. After dark there is no dome
 * to speak of: the same multiple scattering is redistributing a moon, and the
 * honest answer is close to a tenth of what it is at noon. These are the numbers
 * that stop the deck being a flat wash, and they are the ones to reach for first
 * if a night frame ever looks *too* dark — raising them brings the whole deck up
 * without touching its modelling, whereas raising `NIGHT_TOP_KEY` brings up only
 * the moonward side.
 */
const NIGHT_SHADE_SKY = 0.10;
const NIGHT_SHADE_AMBIENT = 0.10;

/**
 * How much of the deck's opacity a whole-board standoff may take away, over
 * what range of camera altitude, and from how consolidated a sky.
 *
 * `clouds.ts` already argues that a genuinely opaque deck reads as an outage
 * rather than as weather, and lands its overcast alpha near 0.8 for that reason.
 * That argument was made about the ground being hidden; it applies with more
 * force from a standoff, where the deck is not merely covering the map but is
 * also the largest object in the frame — and at night the brightest. From a low
 * chapter pose it applies not at all: weather you are flying under should be as
 * solid as it has always been. So this is a function of the camera and not a
 * constant, and `opacityScale` — which had no caller anywhere in `src/` — is
 * where it multiplies in.
 *
 * **The altitude range is measured, not guessed.** `scene.ts` lets the orbit
 * reach 2.0 board spans and the boards are looked at from around 29 degrees of
 * elevation, so the camera's height above the cloud base runs about 0.1 spans at
 * a chapter to about 0.95 at the far end of the orbit — never anywhere near the
 * 1.6 a first pass at this used, which left the whole feature inert. 0.15 to 0.7
 * puts the Bay Area's resting pose (0.38 spans) at about two fifths of the way
 * through, which is where a whole-board frame should sit: thinned, not cleared.
 *
 * **The cover gate is its own curve rather than `deckOf`.** `deckOf` is tuned
 * for "when does the deck start behaving like a lid" and only reaches 0.22 at
 * the 0.65 cover the marine-layer model puts over San Francisco on an August
 * night — which is the exact case this exists for, so gating on it would again
 * leave the feature inert in the frame it was written for. 0.25 to 0.75 keeps a
 * genuinely broken fair-weather sky at full opacity, because that sky already
 * has gaps to see the city through and a lid is the only thing that does not.
 *
 * Smooth, and over a wide range, because the alternative is a deck that steps
 * during a chapter fly-in; `smoothstep` over half a board span is several
 * seconds of a move at board scale.
 */
const ALTITUDE_THINNING = 0.5;
const ALTITUDE_THIN_START = 0.15;
const ALTITUDE_THIN_FULL = 0.7;
const ALTITUDE_THIN_COVER_LOW = 0.25;
const ALTITUDE_THIN_COVER_HIGH = 0.75;

/**
 * The per-puff breath: how fast it runs, in radians a second, and the period
 * `uTime` is wrapped to so that it can keep running.
 *
 * `uTime` was the raw elapsed-seconds accumulator, which is correct for an
 * afternoon and wrong for a deployment. It is uploaded into a `float` uniform,
 * and float32 near 1e6 — eleven and a half days of uptime, which a page left
 * open on a wall display reaches without anybody meaning it to — has an ulp of
 * 0.0625 s. The breath advances `BOIL_RATE` radians a second, 0.0035 of them in
 * a 60 Hz frame, so past that point the argument of the sine can only change
 * every fourth or fifth frame: a smooth swell turns into a staircase, on every
 * puff in the sky at once, and nothing in the code looks any different. It is
 * the same loss of precision the drift accumulators in `tick` are already
 * wrapped against, arriving through the one door that was left open.
 *
 * The wrap has to be a whole number of cycles or it trades a slow quantisation
 * for a visible jump once a period, so the period is exactly 2π/rate — one
 * cycle, 29.9 s — and `sin` is continuous across it by construction rather than
 * by tuning.
 *
 * The rate is interpolated into the vertex shader from this constant rather than
 * written out in both places, because the two agreeing is the *only* reason the
 * wrap is invisible: change the literal in the shader alone and every puff in
 * the sky steps by up to a seventh of its own radius, together, once every
 * period — which is a great deal more noticeable than the staircase this was
 * fixing. `toFixed` rather than plain
 * interpolation because GLSL ES has no implicit int-to-float conversion, so a
 * rate that happened to be integral would emit `1` and fail to compile the
 * program — at which point the whole layer is gone, not just its breath.
 */
const BOIL_RATE = 0.21;
const BOIL_PERIOD = (Math.PI * 2) / BOIL_RATE;

/** Edge of the puff texture, in texels. Only the alpha channel carries anything. */
const PUFF_TEXTURE_SIZE = 256;

/** Draw after the satellite dome, so an overcast night hides the constellation. */
const CLOUD_RENDER_ORDER = 2;

/**
 * How often the depth sort may run, in seconds, and how far the camera may move
 * before it runs early. See `maybeSort`.
 */
const SORT_INTERVAL = 0.5;

// ---- Options and handle ---------------------------------------------------

export interface CloudLayerOptions {
  /**
   * Board span in scene units. Defaults to the same derivation `scene.ts` uses
   * — the larger of the two projected extents of `city.bounds` — so that a
   * caller who has already computed one can pass it and a caller who has not
   * gets the same answer anyway.
   */
  span?: number;
  /** Cloud base above ground, in metres, before vertical exaggeration. */
  baseAltitudeM?: number;
  /** Deck thickness in metres, before vertical exaggeration. */
  thicknessM?: number;
  /** The high veil's altitude in metres, before vertical exaggeration. */
  veilAltitudeM?: number;
  /**
   * Multiplier on every puff's alpha. The one dial for a heavier or a lighter
   * sky; see `PUFF_ALPHA_CLEAR` for what the default is holding.
   */
  opacityScale?: number;
  /**
   * Multiplier on the instance count, 0..1. Defaults to 0.6 on a handheld and 1
   * elsewhere, off `deviceProfile()` — the layer is fill-rate bound rather than
   * vertex bound, and on a phone the cheapest thing to give up is the number of
   * overlapping transparent quads.
   */
  density?: number;
  /** Seed for the field. A reload must produce the same sky; see `seededRandom`. */
  seed?: number;
}

export interface CloudLayer {
  /**
   * Add this to the **scene root**, not to a translated parent. The vertex
   * shader wraps positions against a tile anchored at `uOrigin` in world space
   * and the depth sort measures against the camera in world space; both assume
   * the group's own transform is the identity.
   */
  group: THREE.Group;
  /**
   * How much of the sky has cloud in it, 0..1 — `WeatherObservation.cloudCover`
   * exactly. Eased internally over `COVER_TAU`, so this may be called as often
   * as the weather changes without anything jumping.
   */
  setCover(fraction: number): void;
  /** Applies a rig computed elsewhere. This layer never works one out itself. */
  setLighting(state: LightingState): void;
  /**
   * The two fog uniforms alone, for a camera that moved under a sky that did
   * not. See `SceneHandle.setAerialFog`.
   *
   * The colour is deliberately absent and its absence is the whole seam: this
   * layer folds `fog.color` into `uBase` as well as into `uFogColor` — the
   * underside of a deck is lit by the haze under it — so a colour arriving here
   * without the rig that produced it would leave those two disagreeing. The
   * distances feed one uniform each and nothing else, which is exactly why they
   * are the pair that can travel alone.
   */
  setFogDistances(near: number, far: number): void;
  /**
   * The wind, as `WeatherObservation` reports it: speed in km/h and the bearing
   * it blows *from*, degrees clockwise from true north. Either may be `null` —
   * plenty of stations do not report wind — and the layer falls back to a light
   * westerly rather than standing still, because a sky with no drift in it reads
   * as a frozen frame.
   *
   * Not in the shape the brief asked for, and it earns the extra method: the
   * observation the app already holds carries these two numbers, they cost
   * fifteen lines, and a cloud field that drifts against the reported wind is
   * the difference between weather and wallpaper.
   */
  setWind(kph: number | null, fromDeg: number | null): void;
  /** Draw the layer, or do not. Independent of cover. */
  setVisible(visible: boolean): void;
  /** Advance the drift and the cover ease. Seconds. */
  tick(dt: number): void;
  dispose(): void;
}

// ---- Construction ---------------------------------------------------------

export function createCloudLayer(world: World, options: CloudLayerOptions = {}): CloudLayer {
  const group = new THREE.Group();
  group.name = "clouds";

  const { bounds } = world.city;
  const [westX, northZ] = world.project(bounds.maxLat, bounds.minLng);
  const [eastX, southZ] = world.project(bounds.minLat, bounds.maxLng);
  const span = options.span ?? Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ));

  /**
   * The field is centred on the **board**, not on the scene origin.
   *
   * Scene space is centred on `city.center`, and the Bay Area pack runs forty
   * kilometres down the peninsula from there — so the origin is nowhere near the
   * middle of what is drawn. A field centred on it would put the wrapping tile's
   * seam somewhere over San Jose. This is the same trap `satellites.ts` records
   * under `DOME_RADIUS_FACTOR`, arrived at from the other direction.
   */
  const originX = (westX + eastX) / 2;
  const originZ = (northZ + southZ) / 2;

  const tile = span * TILE_SPANS;

  const density = options.density ?? (isHandheld() ? 0.6 : 1);
  const grid = Math.max(4, Math.round(CELL_GRID * Math.sqrt(clamp(density, 0.1, 1))));
  const veilQuads = Math.max(8, Math.round(VEIL_QUADS * clamp(density, 0.1, 1)));
  const count = grid * grid * PUFFS_PER_CELL + veilQuads;

  const baseY = world.metres(options.baseAltitudeM ?? BASE_ALTITUDE_M);
  const thickY = world.metres(options.thicknessM ?? DECK_THICKNESS_M);
  const veilY = world.metres(options.veilAltitudeM ?? VEIL_ALTITUDE_M);
  const opacityScale = options.opacityScale ?? 1;

  // The field as authored, never touched again after the build. The buffers the
  // GPU reads are a permutation of these; see `maybeSort`.
  const srcCenter = new Float32Array(count * 3);
  const srcShape = new Float32Array(count * 4);
  const srcFade = new Float32Array(count * 4);

  const rand = seededRandom(options.seed ?? 0x2f9a_c1d3);
  const written = buildDeck(srcCenter, srcShape, srcFade, rand, {
    grid,
    tile,
    pitch: tile / grid,
    thickY,
  });
  buildVeil(srcCenter, srcShape, srcFade, rand, {
    from: written,
    quads: veilQuads,
    tile,
    pitch: tile / grid,
  });

  // ---- Geometry -----------------------------------------------------------

  const geometry = new THREE.InstancedBufferGeometry();
  /**
   * A hand-built unit quad rather than a `PlaneGeometry` we then throw away.
   *
   * Sharing a `PlaneGeometry`'s attribute objects and disposing the plane looks
   * tidy and is a use-after-free: `BufferGeometry.dispose()` fires the event the
   * renderer uses to delete the GL buffers *for those attribute objects*, which
   * are the ones still bound here. Four vertices are cheaper than the comment
   * explaining the crash.
   */
  geometry.setAttribute(
    "position",
    new THREE.BufferAttribute(
      Float32Array.of(-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0),
      3,
    ),
  );
  geometry.setIndex([0, 1, 2, 0, 2, 3]);

  const gpuCenter = new Float32Array(srcCenter);
  const gpuShape = new Float32Array(srcShape);
  const gpuFade = new Float32Array(srcFade);
  const centerAttr = new THREE.InstancedBufferAttribute(gpuCenter, 3);
  const shapeAttr = new THREE.InstancedBufferAttribute(gpuShape, 4);
  const fadeAttr = new THREE.InstancedBufferAttribute(gpuFade, 4);
  centerAttr.setUsage(THREE.DynamicDrawUsage);
  shapeAttr.setUsage(THREE.DynamicDrawUsage);
  fadeAttr.setUsage(THREE.DynamicDrawUsage);
  geometry.setAttribute("aCenter", centerAttr);
  geometry.setAttribute("aShape", shapeAttr);
  geometry.setAttribute("aFade", fadeAttr);
  geometry.instanceCount = count;

  // ---- Material -----------------------------------------------------------

  const texture = drawPuffTexture();

  const uniforms: Record<string, THREE.IUniform> = {
    uOrigin: { value: new THREE.Vector2(originX, originZ) },
    uTile: { value: tile },
    uDriftLow: { value: new THREE.Vector2(0, 0) },
    uDriftHigh: { value: new THREE.Vector2(0, 0) },
    uTierBaseY: { value: new THREE.Vector2(baseY, veilY) },
    uTierSpreadY: { value: new THREE.Vector2(1, 1) },
    uCover: { value: 0 },
    uFeather: { value: COVER_FEATHER },
    uGrow: { value: PUFF_GROWTH_CLEAR },
    uOpacity: { value: PUFF_ALPHA_CLEAR * opacityScale },
    uTime: { value: 0 },
    uSunWorld: { value: new THREE.Vector3(0, 1, 0) },
    uLit: { value: new THREE.Color(1, 1, 1) },
    uShade: { value: new THREE.Color(0.5, 0.55, 0.6) },
    uBase: { value: new THREE.Color(0.3, 0.33, 0.36) },
    uKey: { value: 1 },
    uUnderside: { value: 0.35 },
    uRim: { value: 0 },
    uFogColor: { value: new THREE.Color(0.8, 0.85, 0.9) },
    uFogNear: { value: span },
    uFogFar: { value: span * 4 },
  };
  if (texture) uniforms.uMap = { value: texture };

  const material = new THREE.ShaderMaterial({
    uniforms,
    defines: texture ? { USE_PUFF_MAP: "" } : {},
    vertexShader: VERTEX_SHADER,
    fragmentShader: FRAGMENT_SHADER,
    transparent: true,
    /**
     * Depth tested, never written.
     *
     * Tested, because a cloud behind Sutro Tower is behind Sutro Tower — from a
     * chapter pose down in the city the towers genuinely occlude the deck, and
     * turning the test off makes the sky float in front of the skyline. Not
     * written, because these are overlapping translucent quads and the first one
     * to draw would punch a hole in every one behind it, which on a deck seven
     * puffs deep is most of the deck.
     */
    depthWrite: false,
    depthTest: true,
    /**
     * Both faces, for the veil. The low deck's quads are built in view space and
     * keep their winding, but a flat quad lying in the horizontal plane is seen
     * from underneath for most of the orbit, and single-sided it simply is not
     * there.
     */
    side: THREE.DoubleSide,
    /**
     * Without this, the layer draws **twice** and recompiles itself forever.
     *
     * `WebGLRenderer.renderObject` special-cases a material that is both
     * `transparent` and `DoubleSide` with `forceSinglePass` off: it renders the
     * object once with `side = BackSide`, once with `side = FrontSide`, and sets
     * `needsUpdate = true` on each flip. So the deck — whose quads are always
     * camera-facing and front-winding — pays a full vertex pass that is then
     * culled outright, on the one layer in the scene that is fill-rate bound.
     *
     * The second consequence is worse and is permanent: `needsUpdate` bumps
     * `material.version`, so `setProgram`'s version check misses on every pass
     * and rebuilds the parameters object and the program cache key twice per
     * frame, for as long as the page is open. A steady-state per-frame
     * allocation, from a property nobody set.
     *
     * Turning the two-pass split off keeps both faces — culling is simply
     * disabled — and it is also what makes the layer's own back-to-front sort
     * authoritative, since a split by winding would otherwise only order
     * instances *within* each pass.
     */
    forceSinglePass: true,
  });

  const mesh = new THREE.Mesh(geometry, material);
  mesh.name = "cloud-puffs";
  /**
   * Never culled, and never in a shadow map.
   *
   * The bounding sphere is permanently wrong — positions are wrapped against the
   * tile in the vertex shader, so the geometry the CPU thinks it has is not the
   * geometry that gets drawn — and culling on it would cull the whole sky from
   * some angles and not others. Shadows are refused outright: see the file
   * header.
   */
  mesh.frustumCulled = false;
  mesh.castShadow = false;
  mesh.receiveShadow = false;
  mesh.renderOrder = CLOUD_RENDER_ORDER;
  group.add(mesh);

  // ---- State --------------------------------------------------------------

  let targetCover = 0;
  let cover = 0;
  let visible = true;
  /** Feeds `uTime` and nothing else, which is why `tick` may wrap it freely. */
  let elapsed = 0;

  let windKph = DEFAULT_WIND_KPH;
  let windFromDeg = DEFAULT_WIND_FROM_DEG;

  const driftLow = uniforms.uDriftLow?.value as THREE.Vector2;
  const driftHigh = uniforms.uDriftHigh?.value as THREE.Vector2;

  /** Depth sort scratch. `order` is a permutation of instance indices. */
  const order = new Uint32Array(count);
  for (let i = 0; i < count; i++) order[i] = i;
  const depth = new Float32Array(count);
  const lastSortAt = new THREE.Vector3(Infinity, Infinity, Infinity);
  let sortAge = Infinity;
  const sortMoveEps = (tile * 0.006) ** 2;

  /**
   * How far the camera is above the cloud base, in board spans, as of the last
   * frame that drew. `0` until something renders, which is the low-pose answer
   * and therefore the conservative one: a layer that has never been looked at
   * starts as opaque as it has always been and thins only once a camera is
   * demonstrably standing off from it.
   */
  let altitudeSpans = 0;

  /**
   * The camera's share of the deck's opacity, 0..1. See `ALTITUDE_THINNING`.
   *
   * Gated on how consolidated the sky is, so this is a property of a lid and of
   * nothing else: scattered fair-weather cumulus already has gaps to see the
   * city through and must stay as solid as a cumulus is.
   */
  function altitudeThinning(): number {
    const high = smoothstep(ALTITUDE_THIN_START, ALTITUDE_THIN_FULL, altitudeSpans);
    const lid = smoothstep(ALTITUDE_THIN_COVER_LOW, ALTITUDE_THIN_COVER_HIGH, cover);
    return 1 - ALTITUDE_THINNING * high * lid;
  }

  function applyOpacity(): void {
    uniforms.uOpacity!.value =
      lerp(PUFF_ALPHA_CLEAR, PUFF_ALPHA_OVERCAST, cover) * opacityScale * altitudeThinning();
  }

  function applyCover(): void {
    const deck = deckOf(cover);
    uniforms.uCover!.value = cover;
    uniforms.uGrow!.value = lerp(PUFF_GROWTH_CLEAR, PUFF_GROWTH_OVERCAST, cover);
    applyOpacity();
    // The deck drops and flattens as it consolidates; the veil does neither,
    // because cirrus does not care what the layer below it is doing.
    (uniforms.uTierBaseY!.value as THREE.Vector2).set(
      baseY * lerp(1, DECK_ALTITUDE_DROP, deck),
      veilY,
    );
    (uniforms.uTierSpreadY!.value as THREE.Vector2).set(lerp(1, DECK_FLATTEN, deck), 1);
    group.visible = visible && cover > MIN_COVER;
  }
  applyCover();

  const lowVelocity = new THREE.Vector2();
  const highVelocity = new THREE.Vector2();

  function applyWind(): void {
    // `windDirDeg` is the bearing the wind blows *from*, so the velocity points
    // along the reciprocal. Scene axes are `solar.ts`'s throughout the engine:
    // azimuth measured clockwise from north, north is -Z, east is +X. A bearing
    // b therefore points along `(sin b, -cos b)`, and the reciprocal of `from`
    // is `(-sin from, cos from)` — a westerly (270) comes out as +X, blowing
    // east, which is what a westerly does.
    const speed = ((windKph / 3.6) / world.metresPerUnit) * DRIFT_EXAGGERATION;
    const from = (windFromDeg * Math.PI) / 180;
    lowVelocity.set(-Math.sin(from) * speed, Math.cos(from) * speed);
    const veered = ((windFromDeg + VEIL_VEER_DEG) * Math.PI) / 180;
    const veilSpeed = speed * VEIL_SPEED_FACTOR;
    highVelocity.set(-Math.sin(veered) * veilSpeed, Math.cos(veered) * veilSpeed);
  }
  applyWind();

  /**
   * Re-order the instance buffers back to front, if it is worth doing.
   *
   * Alpha blending is order dependent and there are up to eight overlapping
   * quads on any ray, so an unsorted field composites a dark cloud base over a
   * sunlit top as readily as the other way round. At sunset, where the two are
   * furthest apart in colour, that shows as puffs cutting flat holes in each
   * other.
   *
   * It is throttled rather than per-frame because it does not need to be
   * per-frame. The drift is slow by construction and `OrbitControls` moves the
   * camera on a sphere, so a wrong order stays very nearly right for a long
   * time; twice a second, plus immediately whenever the camera has moved more
   * than about half a percent of the tile, is indistinguishable from exact. The
   * work itself is ~1,500 distance evaluations and an `n log n` sort of the same
   * — call it a fifth of a millisecond — against the 2 ms `satellites.ts` spends
   * every frame propagating.
   *
   * Driven from `onBeforeRender` rather than from `tick` because that is where
   * the camera is, without this layer having to be handed one or having to guess
   * which of several it is being drawn for.
   */
  function maybeSort(camera: THREE.Camera): void {
    const moved = camera.position.distanceToSquared(lastSortAt);
    if (sortAge < SORT_INTERVAL && moved < sortMoveEps) return;
    sortAge = 0;
    lastSortAt.copy(camera.position);

    const cx = camera.position.x;
    const cy = camera.position.y;
    const cz = camera.position.z;
    const half = tile * 0.5;
    for (let i = 0; i < count; i++) {
      const tier = srcFade[i * 4 + 3] ?? 0;
      const drift = tier < 0.5 ? driftLow : driftHigh;
      // The same wrap the vertex shader does, or the sort is sorting positions
      // nothing is drawn at.
      const x = originX + posMod((srcCenter[i * 3] ?? 0) + drift.x, tile) - half;
      const z = originZ + posMod((srcCenter[i * 3 + 2] ?? 0) + drift.y, tile) - half;
      const baseTier = tier < 0.5 ? (uniforms.uTierBaseY!.value as THREE.Vector2).x : veilY;
      const spread = tier < 0.5 ? (uniforms.uTierSpreadY!.value as THREE.Vector2).x : 1;
      const y = baseTier + (srcCenter[i * 3 + 1] ?? 0) * spread;
      depth[i] = (x - cx) ** 2 + (y - cy) ** 2 + (z - cz) ** 2;
    }

    // Furthest first. `sort` rather than an insertion pass over the previous
    // order: a camera crossing the deck reverses the whole array at once, and an
    // insertion sort's best case is not worth its worst one here.
    order.sort((a, b) => (depth[b] ?? 0) - (depth[a] ?? 0));

    for (let k = 0; k < count; k++) {
      const i = order[k] ?? 0;
      gpuCenter[k * 3] = srcCenter[i * 3] ?? 0;
      gpuCenter[k * 3 + 1] = srcCenter[i * 3 + 1] ?? 0;
      gpuCenter[k * 3 + 2] = srcCenter[i * 3 + 2] ?? 0;
      for (let c = 0; c < 4; c++) {
        gpuShape[k * 4 + c] = srcShape[i * 4 + c] ?? 0;
        gpuFade[k * 4 + c] = srcFade[i * 4 + c] ?? 0;
      }
    }
    centerAttr.needsUpdate = true;
    shapeAttr.needsUpdate = true;
    fadeAttr.needsUpdate = true;
  }

  /**
   * The one place this layer reads the camera, and the reason it is here rather
   * than behind a setter: the depth sort already needs the camera every frame,
   * `onBeforeRender` is where three hands it over, and adding a second way in
   * would give the layer two ideas of where it is being looked at from.
   *
   * `baseY` and not the deck's top, because the base is the altitude the whole
   * file is written against and the one a chapter camera is compared to. Both
   * the numerator and `span` are scene units, so the ratio is dimensionless and
   * a pack with a different vertical exaggeration behaves the same.
   */
  mesh.onBeforeRender = (_renderer, _scene, camera) => {
    const spans = Math.max(0, (camera.position.y - baseY) / span);
    if (Math.abs(spans - altitudeSpans) > 0.002) {
      altitudeSpans = spans;
      applyOpacity();
    }
    maybeSort(camera);
  };

  // ---- The handle ---------------------------------------------------------

  return {
    group,

    setCover(fraction) {
      targetCover = clamp(fraction, 0, 1);
    },

    setFogDistances(near, far) {
      uniforms.uFogNear!.value = near;
      uniforms.uFogFar!.value = far;
    },

    setLighting(state) {
      const sun = new THREE.Color().setHex(state.sun.color);
      const sky = new THREE.Color().setHex(state.hemisphere.sky);
      const ground = new THREE.Color().setHex(state.hemisphere.ground);
      const ambient = new THREE.Color().setHex(state.ambient.color);

      /**
       * Daylight level, off the sky dome rather than off the sun.
       *
       * See the file header for the argument. In short: `sun.intensity` is the
       * light reaching the *ground* and collapses under the very deck this layer
       * is drawing, whereas the dome the deck hangs under does not — the top of
       * an overcast layer is in the sunshine the ground has lost.
       *
       * The dome's *colour* and not the hemisphere's intensity, which is the
       * correction: the intensity is the rig's night floor and runs 1.34 after
       * dark against 0.96 at noon. See `SKY_DAY_REFERENCE`.
       */
      const skyLum = luminance(sky);
      const day = clamp(skyLum / SKY_DAY_REFERENCE, 0, 1);

      /**
       * How much of this deck's light is the moon's rather than the sky's.
       *
       * Read off the dome for the same reason `day` is, and gated to this
       * layer's own question — see `NIGHT_SKY_LOW`. `atmosphere.ts` remains the
       * only owner of when night begins for the *rig*; this is only which of the
       * rig's terms a surface thirteen hundred metres up is being lit by.
       */
      const night = 1 - smoothstep(NIGHT_SKY_LOW, NIGHT_SKY_HIGH, skyLum);

      /**
       * `key` is the sun's — or after dark the moon's — intensity as a contrast.
       * It sets how hard the puffs are modelled below, and at night it is also
       * the whole of the deck's level: there is nothing else up there lighting
       * it. Hoisted above `uLit` because both terms need it now.
       */
      const key = clamp(state.sun.intensity / 2.1, 0, 1);

      // A cloud top is a near-perfect diffuse reflector wearing the key's
      // colour — the sun's by day, and after dark the moon's, because
      // `atmosphere.ts` hands `state.sun` over to it. 0.06 is not black: even a
      // moonless overcast night has a deck you can see against the sky, and zero
      // here loses the layer entirely rather than darkening it. At night the
      // floor is `NIGHT_TOP_FLOOR` and what sits on it is the moon.
      (uniforms.uLit!.value as THREE.Color)
        .copy(sun)
        .multiplyScalar(
          lerp(0.06 + SKY_DAY_GAIN * day, NIGHT_TOP_FLOOR + NIGHT_TOP_KEY * key, night),
        );

      // The flanks are lit by the sky dome, plus whatever ambient the rig is
      // carrying. Both terms are colour times intensity, so both have to be
      // multiplied out — flooring one and not the other is how a night sky ends
      // up with daylight-coloured clouds in it.
      // `THREE.Color` has no `addScaledVector`, so the scaling happens on the
      // scratch copies made above — which is why `sky` and `ambient` are fresh
      // colours per call rather than cached ones.
      //
      // The two weights are generous, and the first render is why. At 0.42 and
      // 0.5 the sunset rig — a hemisphere at 0.6 over a dark blue sky — put the
      // shaded side of every cumulus at about 4% grey, and a field of clouds
      // that dark against a mauve sky reads as a smoke plume. Cloud is the most
      // multiply-scattering thing in the sky: the side facing away from the sun
      // is still being lit by the entire rest of the dome *and* by the several
      // hundred metres of its own body the light came through, which is why a
      // cumulus never has a black side and a sphere of rock does.
      //
      // Both weights come down at night, and they have to come down together for
      // the same reason they are generous together: after dark there is no dome
      // redistributing a sun, only a moon, and a night flank held at daylight
      // weights is the flat milky wash the header describes — measurably
      // *brighter* than the flank the moon was actually on.
      (uniforms.uShade!.value as THREE.Color)
        .copy(sky)
        .multiplyScalar(state.hemisphere.intensity * lerp(0.55, NIGHT_SHADE_SKY, night))
        .add(ambient.multiplyScalar(state.ambient.intensity * lerp(0.7, NIGHT_SHADE_AMBIENT, night)));

      /**
       * The underside sees the ground and the haze between here and it, which is
       * why the fog colour is in this term and in no other. It is also what
       * makes an overcast night the right colour without this layer knowing
       * anything about street lighting: `atmosphere.ts` floors the night fog at
       * a lifted horizon blue, and the deck picks that up off its own base.
       */
      const base = uniforms.uBase!.value as THREE.Color;
      base.copy(ground).multiplyScalar(state.hemisphere.intensity * 0.3);
      if (state.fog) {
        base.add(new THREE.Color().setHex(state.fog.color).multiplyScalar(0.3));
        (uniforms.uFogColor!.value as THREE.Color).setHex(state.fog.color);
        uniforms.uFogNear!.value = state.fog.near;
        uniforms.uFogFar!.value = state.fog.far;
      }

      const [dx, dy, dz] = state.sun.direction;
      (uniforms.uSunWorld!.value as THREE.Vector3).set(dx, dy, dz);

      /**
       * How hard the puffs are modelled, and how dark their bases go.
       *
       * `uKey` is the one place `sun.intensity` is still the right number: it is
       * a *contrast*, not a level, and a key that has collapsed under cloud
       * genuinely does flatten the deck it collapsed under. An overcast layer has
       * almost no modelling in it, which is why an overcast photograph has no
       * shapes in the sky.
       *
       * `lowSun` is read off the direction's own `y`, which is `sin(elevation)`.
       * `atmosphere.ts` floors that at 7 degrees (`shadowFloorDeg`) so this can
       * never see a true sunset angle, and it does not need to: what it needs to
       * know is "is the light coming in sideways", and 0.12 to 0.55 is that
       * question asked over the range the floor still permits.
       */
      const lowSun = 1 - smoothstep(0.12, 0.55, dy);
      uniforms.uKey!.value = 0.35 + 0.65 * key;
      uniforms.uUnderside!.value = clamp(0.26 + 0.38 * lowSun + 0.2 * deckOf(cover), 0, 0.72);
      // The silver lining: forward scattering round the limb of a puff with the
      // sun behind it. Only worth drawing when there is a real sun and it is
      // low, which is exactly when anybody has ever noticed one.
      uniforms.uRim!.value = 0.6 * lowSun * smoothstep(0.05, 0.5, key);
    },

    setWind(kph, fromDeg) {
      windKph = kph === null || !Number.isFinite(kph) ? DEFAULT_WIND_KPH : Math.max(0, kph);
      windFromDeg =
        fromDeg === null || !Number.isFinite(fromDeg) ? DEFAULT_WIND_FROM_DEG : fromDeg;
      applyWind();
    },

    setVisible(next) {
      visible = next;
      group.visible = visible && cover > MIN_COVER;
    },

    tick(dt) {
      // Wrapped on the breath's own period, so the uniform stays small enough to
      // keep its precision in float32 and the sine does not notice. See
      // `BOIL_PERIOD`; `posMod` rather than `%` so a pathological `dt` cannot
      // walk it negative.
      elapsed = posMod(elapsed + dt, BOIL_PERIOD);
      sortAge += dt;
      uniforms.uTime!.value = elapsed;

      if (cover !== targetCover) {
        // Exponential approach, framerate independent. A linear ramp would take
        // the same wall-clock time from 0.1 to 0.2 as from 0.1 to 0.9, and the
        // small change is the one that has to be invisible.
        const k = 1 - Math.exp(-dt / COVER_TAU);
        cover += (targetCover - cover) * k;
        if (Math.abs(targetCover - cover) < 0.0005) cover = targetCover;
        applyCover();
      }

      if (!group.visible) return;

      // Kept inside one tile so the accumulator can run for a week without
      // losing float precision, and kept *per tier* rather than scaling one
      // shared accumulator — a scaled copy of a wrapped number is not itself
      // continuous at the wrap, and the veil would teleport once a tile.
      driftLow.x = posMod(driftLow.x + lowVelocity.x * dt, tile);
      driftLow.y = posMod(driftLow.y + lowVelocity.y * dt, tile);
      driftHigh.x = posMod(driftHigh.x + highVelocity.x * dt, tile);
      driftHigh.y = posMod(driftHigh.y + highVelocity.y * dt, tile);
    },

    dispose() {
      geometry.dispose();
      material.dispose();
      texture?.dispose();
      group.clear();
    },
  };
}

// ---- The field ------------------------------------------------------------

interface DeckSpec {
  grid: number;
  tile: number;
  pitch: number;
  thickY: number;
}

/**
 * The low deck: a jittered grid of cells, each a dome of puffs.
 *
 * Returns how many instances were written, so the veil can carry on from there
 * into the same buffers — one buffer, one draw call, two layers.
 *
 * The shape of a cell is the whole point and is worth reading as geometry rather
 * than as arithmetic. Puffs are laid from the bottom up (`heightRank` walks 0 to
 * 1 with jitter, so the vertical spread is even rather than clumped); each one
 * sits on a radius that *narrows* with height, so the silhouette is a dome and
 * not a column; and each one is *smaller* with height, so the top of the cloud
 * breaks into smaller lumps the way a real cumulus crown does. A cell built with
 * uniform radii at uniform heights is a pillar of identical bubbles and is
 * instantly recognisable as one.
 */
function buildDeck(
  center: Float32Array,
  shape: Float32Array,
  fade: Float32Array,
  rand: () => number,
  spec: DeckSpec,
): number {
  const { grid, tile, pitch, thickY } = spec;
  let n = 0;

  for (let gz = 0; gz < grid; gz++) {
    for (let gx = 0; gx < grid; gx++) {
      const cellX = (gx + 0.5 + (rand() - 0.5) * 2 * CELL_JITTER) * pitch;
      const cellZ = (gz + 0.5 + (rand() - 0.5) * 2 * CELL_JITTER) * pitch;

      // Thresholds are independent per cell, so cloud arrives scattered rather
      // than sweeping across the board as a front. A front would be the more
      // beautiful model and it would need a second field of spatially coherent
      // noise, a direction, and something to say when it passes — none of which
      // is recoverable from one number between 0 and 1.
      const threshold = rand() * (1 - COVER_FEATHER);

      // How far this cell's puffs wander from its centre, and the base altitude
      // it sits at. The altitude jitter is what stops the deck being a plane:
      // seen from above at a shallow angle, a deck with no variation in its own
      // base reads as a sheet of paper.
      // Tighter than the puffs are wide, which is the relationship that makes a
      // cell one cloud rather than seven. At `spread` 0.33 of the pitch against
      // a puff radius of 0.42, a ray through the core meets four of the seven
      // and the arithmetic in `PUFF_ALPHA_CLEAR` holds. Loosen it and the cell
      // becomes a constellation of separate bubbles with sky between them.
      const spread = pitch * (0.26 + rand() * 0.14);
      const cellBase = (rand() - 0.5) * thickY * 0.8;
      const cellOpacity = 0.85 + rand() * 0.3;

      /**
       * How big this particular cloud is, against its neighbours.
       *
       * Added after looking at the first render, where every cell came out the
       * same size because every cell was built from the same pitch, and a field
       * of equal-sized clouds at equal spacing on a common condensation level
       * reads unmistakably as popcorn. Real cumulus fields have an enormous
       * spread of sizes — a handful of large cells and a great many small ones —
       * so the roll is squared to bias it that way rather than spread evenly.
       *
       * The flat *bases* stay: those really are all at the same height, because
       * they are all at the lifting condensation level, and it is one of the most
       * recognisable things about a cumulus field seen from the side.
       */
      const cellScale = 0.55 + 1.05 * rand() ** 2;

      for (let p = 0; p < PUFFS_PER_CELL; p++) {
        const heightRank = clamp((p + rand()) / PUFFS_PER_CELL, 0, 1);
        const angle = rand() * Math.PI * 2;
        // `sqrt` of a uniform is what makes the scatter uniform *by area*; the
        // uniform itself piles everything into the middle.
        const reach = Math.sqrt(rand()) * spread * cellScale * (1 - 0.55 * heightRank);

        center[n * 3] = posMod(cellX + Math.cos(angle) * reach, tile);
        center[n * 3 + 1] = cellBase + heightRank * thickY;
        center[n * 3 + 2] = posMod(cellZ + Math.sin(angle) * reach, tile);

        shape[n * 4] = pitch * (0.32 + rand() * 0.2) * cellScale * (1 - 0.32 * heightRank);
        shape[n * 4 + 1] = 1 + rand() * 0.35;
        shape[n * 4 + 2] = rand() * Math.PI * 2;
        shape[n * 4 + 3] = heightRank;

        fade[n * 4] = threshold;
        fade[n * 4 + 1] = cellOpacity;
        fade[n * 4 + 2] = rand();
        fade[n * 4 + 3] = 0;
        n += 1;
      }
    }
  }
  return n;
}

interface VeilSpec {
  from: number;
  quads: number;
  tile: number;
  pitch: number;
}

/**
 * The high veil: a few dozen long, faint, flat quads.
 *
 * Cirrus is a sheet of ice crystals seven kilometres up, and drawing it as a
 * sheet — quads lying in the horizontal plane rather than turning to face the
 * camera — is the correct model rather than the cheap one. It foreshortens
 * properly as the camera comes down toward the horizon, it is unambiguously
 * *above* the cumulus when seen from a chapter pose, and from the whole-board
 * view it slides across the deck below at nearly twice the speed, which is the
 * single strongest cue in the layer that there is more than one altitude in it.
 *
 * Thresholds are drawn from the bottom half of the range on purpose. A high thin
 * veil is what a 0.15 sky usually *is*, and it is the thing that arrives a day
 * ahead of a front; making the veil the first cloud to appear as cover rises is
 * both the commoner observation and the more interesting one to look at, since a
 * sky with two small cumulus in it and nothing else is indistinguishable from a
 * clear one at this scale.
 */
function buildVeil(
  center: Float32Array,
  shape: Float32Array,
  fade: Float32Array,
  rand: () => number,
  spec: VeilSpec,
): void {
  const { from, quads, tile, pitch } = spec;
  // Streaks share a rough bearing, because the wind that made them did. Chosen
  // at build time and not steered by `setWind`: re-orienting an existing streak
  // when the reported wind veers would be a sky visibly rotating in place, which
  // is a worse artefact than a veil whose grain is a few degrees off the drift.
  const bias = rand() * Math.PI;

  for (let i = 0; i < quads; i++) {
    const n = from + i;
    center[n * 3] = rand() * tile;
    // Spread through a shallow band, so the veil is a layer rather than a plane
    // and two streaks can cross with one plainly in front.
    center[n * 3 + 1] = (rand() - 0.5) * pitch * 0.35;
    center[n * 3 + 2] = rand() * tile;

    shape[n * 4] = pitch * (0.5 + rand() * 0.55);
    // Long and thin. This is the one place a large aspect ratio is wanted, and
    // it is applied before the rotation so the streak lies along its own axis
    // rather than along the world's.
    shape[n * 4 + 1] = 3 + rand() * 4;
    shape[n * 4 + 2] = bias + (rand() - 0.5) * 0.5;
    // Full height rank: a veil has no underside to darken, because it is thin
    // enough to transmit. See the fragment shader's base term.
    shape[n * 4 + 3] = 1;

    fade[n * 4] = rand() * 0.55 * (1 - COVER_FEATHER);
    fade[n * 4 + 1] = 0.45 + rand() * 0.3;
    fade[n * 4 + 2] = rand();
    fade[n * 4 + 3] = 1;
  }
}

// ---- The texture ----------------------------------------------------------

/**
 * One soft, ragged puff, drawn on a canvas.
 *
 * No image file, ever — CONTRACT.md §3, and the same reason `assets/textures.ts`
 * draws every surface in the office. The noise is the engine's own `fbm`, so the
 * clouds are grained by the function that grained the hills.
 *
 * **The alpha is stored in the red channel and the alpha channel is left at
 * 255**, which looks perverse and is the one genuinely load-bearing decision in
 * this function. A 2D canvas keeps a premultiplied backing store; a texture
 * uploaded from one and read as straight alpha comes back with its colour
 * quantised to death wherever alpha is low, and low alpha is the entire useful
 * range of a cloud sprite. Keeping the canvas fully opaque means no
 * premultiplication ever happens and the value that comes out of the sampler is
 * the value that went in. The colour space is irrelevant for the same reason
 * nothing else here is: the shader reads one channel as a mask, not as a colour.
 *
 * A four-tile atlas was tried and rejected. It gives more silhouette variety per
 * draw call, and at the framing this layer exists to survive — two board spans
 * out, where a puff is three or four pixels and therefore several mip levels
 * down — the tiles bleed into each other and every cloud acquires a faint square
 * halo. Per-puff rotation and a mild aspect ratio buy most of the same variety
 * with none of that, and cost two lines of vertex shader.
 *
 * Returns `null` where there is no DOM: the server workspace and the CI
 * typecheck run under Node, and an engine module that throws on import there
 * would break the zero-config boot CONTRACT.md §5.1 is tested on. The shader
 * falls back to an analytic falloff, which is rounder and perfectly usable.
 */
function drawPuffTexture(): THREE.Texture | null {
  if (typeof document === "undefined") return null;
  const size = PUFF_TEXTURE_SIZE;
  const canvas = document.createElement("canvas");
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;

  const image = ctx.createImageData(size, size);
  const data = image.data;
  for (let y = 0; y < size; y++) {
    const v = ((y + 0.5) / size) * 2 - 1;
    for (let x = 0; x < size; x++) {
      const u = ((x + 0.5) / size) * 2 - 1;
      const r = Math.hypot(u, v);
      let a = 0;
      if (r < 1) {
        /**
         * A **solid** core with a soft rim, not a Gaussian.
         *
         * The falloff started at `smoothstep(0, 0.92, r)`, which is a blur
         * rather than a cloud: the alpha is already halved a third of the way
         * out, so a sprite never contributes its full opacity anywhere and a
         * whole cell of them reads as a smear of fog. Holding alpha at 1 out to
         * `r = 0.34` and spending the rest of the radius on the edge is what
         * makes a puff have a *body*. Everything soft about a cloud is in its
         * silhouette, and this is the silhouette.
         */
        const core = 1 - smoothstep(0.34, 0.99, r);
        const n = fbm(u * 2.6 + 13.7, v * 2.6 + 5.1) / 0.94;
        // Centred a little above 1 and clamped, so the noise mostly bites into
        // the rim — where it produces lumps — and leaves the core alone.
        const lump = 0.55 + 0.75 * clamp(n, 0, 1);
        // The rim guarantee. Without it the noise can hold alpha out to r = 1,
        // where the quad's own edge cuts it off as a straight line.
        a = clamp(core * lump, 0, 1) * (1 - smoothstep(0.86, 1, r));
        a = a ** 0.8;
      }
      const i = (y * size + x) * 4;
      const byte = Math.round(a * 255);
      data[i] = byte;
      data[i + 1] = byte;
      data[i + 2] = byte;
      data[i + 3] = 255;
    }
  }
  ctx.putImageData(image, 0, 0);

  const texture = new THREE.CanvasTexture(canvas);
  texture.name = "cloudPuff";
  texture.wrapS = THREE.ClampToEdgeWrapping;
  texture.wrapT = THREE.ClampToEdgeWrapping;
  texture.minFilter = THREE.LinearMipmapLinearFilter;
  texture.magFilter = THREE.LinearFilter;
  texture.generateMipmaps = true;
  texture.needsUpdate = true;
  return texture;
}

// ---- Shaders --------------------------------------------------------------

/**
 * Two billboard modes, chosen per instance off `aFade.w`.
 *
 * Tier 0 offsets in **view space**, which is a screen-aligned billboard: always
 * face-on, no degenerate pose when the camera looks straight down at it, and no
 * shimmer as it passes overhead. The alternative — an axis-constrained billboard
 * pivoting about world up — is what a tree impostor wants and is wrong here,
 * because the camera spends most of its time above the layer and an
 * axis-constrained quad seen from above is edge-on and invisible.
 *
 * Tier 1 offsets in **world space in the horizontal plane**, so it does not
 * billboard at all. See `buildVeil`.
 */
const VERTEX_SHADER = /* glsl */ `
attribute vec3 aCenter;
attribute vec4 aShape;
attribute vec4 aFade;

uniform vec2 uOrigin;
uniform float uTile;
uniform vec2 uDriftLow;
uniform vec2 uDriftHigh;
uniform vec2 uTierBaseY;
uniform vec2 uTierSpreadY;
uniform float uCover;
uniform float uFeather;
uniform float uGrow;
uniform float uOpacity;
uniform float uTime;

varying vec2 vTexUv;
varying vec2 vDisc;
varying float vAlpha;
varying float vHeight;
varying float vTier;
varying vec3 vViewPos;

void main() {
  float tier = step(0.5, aFade.w);
  vTier = tier;
  vHeight = aShape.w;

  // Continuous by construction: a cell is not switched on at its threshold, it
  // grows through a window uFeather wide. At zero cover every window is still
  // ahead of us and the whole field collapses to zero-area quads, which the
  // rasteriser drops for free.
  float present = smoothstep(aFade.x, aFade.x + uFeather, uCover);

  // The wrap. This is what makes the field infinite: the tile is a torus and the
  // drift is a translation on it, so nothing ever reaches an edge.
  vec2 drift = mix(uDriftLow, uDriftHigh, tier);
  vec2 plan = mod(aCenter.xz + drift, uTile) - uTile * 0.5 + uOrigin;

  float baseY = mix(uTierBaseY.x, uTierBaseY.y, tier);
  float spread = mix(uTierSpreadY.x, uTierSpreadY.y, tier);
  vec3 centre = vec3(plan.x, baseY + aCenter.y * spread, plan.y);

  // A slow breath, a fifteenth of a radius, on a per-puff phase. Small enough
  // that nobody sees a puff pulse and large enough that the field is never
  // completely still even with no wind reported. The rate is interpolated in
  // from BOIL_RATE, which is also what uTime's wrap period is derived from: the
  // two are one number and have to stay one number. (No backticks in this
  // string, ever — it is a template literal and they close it.)
  float boil = 1.0 + 0.07 * sin(uTime * ${BOIL_RATE.toFixed(6)} + aFade.z * 6.2831853);
  float radius = aShape.x * uGrow * boil * present;

  vec2 corner = position.xy;
  vTexUv = corner * 0.5 + 0.5;

  // Stretched along the puff's own axis and *then* turned, which is the order
  // that makes aShape.y an elongation of the cloud rather than a squash of the
  // world. The disc coordinate the fragment shader builds its fake normal from
  // is the turned-but-unstretched corner, so the shading stays spherical however
  // long the silhouette is drawn.
  float c = cos(aShape.z);
  float s = sin(aShape.z);
  vec2 stretched = vec2(corner.x * aShape.y, corner.y);
  vec2 offset = vec2(stretched.x * c - stretched.y * s, stretched.x * s + stretched.y * c) * radius;
  vDisc = vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);

  vec4 mv;
  if (tier < 0.5) {
    mv = modelViewMatrix * vec4(centre, 1.0);
    mv.xy += offset;
  } else {
    mv = modelViewMatrix * vec4(centre + vec3(offset.x, 0.0, offset.y), 1.0);
  }
  vViewPos = mv.xyz;
  vAlpha = present * aFade.y * uOpacity;
  gl_Position = projectionMatrix * mv;
}
`;

/**
 * The shading, and why a flat sprite can read as a solid.
 *
 * Every puff builds a hemisphere normal out of its own disc coordinates — `z`
 * from `sqrt(1 - r^2)`, so the middle of the sprite faces the camera and the
 * limb faces away — in **view space**, which is exactly the space the tier-0
 * billboard was built in, so it needs no transform to be correct. Dotted against
 * the rig's sun direction, that gives a lit limb and a dark limb per puff, and a
 * field of those seen from above is the lumpy thing the file header is about.
 *
 * The terminator is wrapped hard (`key * 0.5 + 0.5`) rather than clamped at
 * zero, because a cloud is not an opaque ball. It is a scattering volume and
 * light gets a very long way round the back of one; a lambertian terminator on a
 * cumulus looks like a snooker ball and is the classic tell of a first attempt.
 *
 * `viewMatrix` is available here — three declares it in every fragment prefix —
 * so the sun and world up are transformed per fragment rather than per frame on
 * the CPU. That is two mat4-by-vec4 products against not having to be handed a
 * camera, and it means `setLighting` can stay a pure write of uniforms.
 */
const FRAGMENT_SHADER = /* glsl */ `
#ifdef USE_PUFF_MAP
uniform sampler2D uMap;
#endif

uniform vec3 uSunWorld;
uniform vec3 uLit;
uniform vec3 uShade;
uniform vec3 uBase;
uniform float uKey;
uniform float uUnderside;
uniform float uRim;
uniform vec3 uFogColor;
uniform float uFogNear;
uniform float uFogFar;

varying vec2 vTexUv;
varying vec2 vDisc;
varying float vAlpha;
varying float vHeight;
varying float vTier;
varying vec3 vViewPos;

void main() {
  float r2 = dot(vDisc, vDisc);
  // The quad's corners, which the texture's rim guarantee has already emptied.
  if (r2 > 1.0) discard;

  #ifdef USE_PUFF_MAP
    float mask = texture2D(uMap, vTexUv).r;
  #else
    float mask = 1.0 - smoothstep(0.2, 1.0, sqrt(r2));
  #endif
  float alpha = mask * vAlpha;

  vec3 sunView = normalize((viewMatrix * vec4(uSunWorld, 0.0)).xyz);
  vec3 upView = normalize((viewMatrix * vec4(0.0, 1.0, 0.0, 0.0)).xyz);

  // A fake sphere for a puff; the plane's own normal for a veil, which really is
  // flat and really is lit from above.
  vec3 n = vTier < 0.5 ? vec3(vDisc, sqrt(max(0.0, 1.0 - r2))) : upView;

  /**
   * The veil fades out edge-on.
   *
   * A flat quad is a proxy for a volume and the proxy is only honest near
   * face-on: seen along its own plane it collapses to a line, and a line of
   * accumulated alpha is a bright horizontal scratch across the sky rather than
   * a band of cirrus. The camera spends a lot of time at exactly that angle,
   * because the deck below is what it is looking at. Weighting by how squarely
   * the sheet faces the eye costs one dot product and removes the artefact
   * without needing the veil to be billboarded — which it must not be, since
   * being flat is the whole reason it looks like a different kind of cloud.
   */
  if (vTier >= 0.5) {
    alpha *= mix(0.35, 1.0, abs(dot(normalize(vViewPos), upView)));
  }
  if (alpha < 0.004) discard;

  float key = dot(n, sunView);
  float lit = clamp(key * 0.5 + 0.5, 0.0, 1.0);
  // uKey collapses toward a flat 0.5 as the rig's sun does, so an overcast
  // deck has no modelling in it and a clear-noon cumulus has all of it.
  lit = mix(0.5, pow(lit, 1.4), uKey);
  // Where in its own cloud this puff sits. The crown is in the light; the shelf
  // underneath is in the cloud's own shadow, which is a thing no per-puff normal
  // can know about on its own.
  lit = clamp(lit * mix(0.72, 1.06, vHeight), 0.0, 1.2);

  vec3 color = mix(uShade, uLit, lit);

  // The underside. What faces down sees the ground and the haze over it rather
  // than the sky, and at a low sun it sees almost nothing — which is the whole
  // of "dark on the bottom at low sun". Weighted by height so a cloud's base
  // goes dark and its crown does not.
  float down = clamp(-dot(n, upView), 0.0, 1.0);
  color = mix(color, uBase, uUnderside * down * mix(1.0, 0.22, vHeight));

  // The silver lining: a rim of forward-scattered light where the limb points
  // back toward a low sun.
  float rim = pow(clamp(key, 0.0, 1.0), 6.0) * smoothstep(0.35, 0.95, sqrt(r2));
  color += uLit * rim * uRim;

  /**
   * The same fog the rest of the scene is in, out of the same LightingState.
   *
   * Not THREE.Fog and not the material's fog: true path, which would mean
   * merging UniformsLib.fog and threading four shader chunks through a custom
   * program for numbers this layer is already holding. Reading them off the rig
   * it was handed is fewer moving parts and cannot drift from the scene's, since
   * it is literally the same three values SceneKit gave scene.fog.
   *
   * Colour only, alpha untouched — which is what three does too, and is right: a
   * distant cloud does not become transparent, it becomes the same colour as the
   * air in front of it. That is also what quietly hides the far edge of the
   * wrapping tile at the whole-board framing.
   */
  color = mix(color, uFogColor, smoothstep(uFogNear, uFogFar, -vViewPos.z));

  gl_FragColor = vec4(color, alpha);
  #include <colorspace_fragment>
}
`;

// ---- Small maths ----------------------------------------------------------

function clamp(v: number, lo: number, hi: number): number {
  return v < lo ? lo : v > hi ? hi : v;
}

function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t;
}

function smoothstep(edge0: number, edge1: number, x: number): number {
  if (edge1 <= edge0) return x < edge0 ? 0 : 1;
  const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
  return t * t * (3 - 2 * t);
}

/**
 * `%` with the sign of the divisor, matching GLSL's `mod`.
 *
 * JavaScript's `%` is a remainder and keeps the sign of the *dividend*, so a
 * westerly wind — which drives the drift negative on one axis — would wrap
 * positions to the wrong half of the tile and tear the field along a line. The
 * shader uses `mod`; the sort has to agree with the shader or it sorts positions
 * nothing is drawn at.
 */
function posMod(v: number, m: number): number {
  return ((v % m) + m) % m;
}

/** Rec.709 luminance, in whatever space the colour is already in. */
function luminance(c: THREE.Color): number {
  return 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
}

/**
 * How consolidated the deck is, 0..1.
 *
 * Deliberately not `cover` itself. Cloud *amount* rises linearly from the first
 * cumulus; the deck only starts behaving like a lid — dropping, flattening,
 * darkening underneath — once the cells have grown into each other, which
 * happens over the top of the range.
 */
function deckOf(cover: number): number {
  return smoothstep(0.5, 1, cover);
}

/** `deviceProfile()` reads `window`; the typecheck and the tests do not have one. */
function isHandheld(): boolean {
  if (typeof window === "undefined") return false;
  return deviceProfile().handheld;
}
