/** * Rain over California, as one quad and one texture. * * The whole layer is **two triangles and one draw call**. That is not thrift for * its own sake: `echo_cells` turned out to be a regular quarter-degree lattice * rather than a list of extracted features, and once the data is a raster the * question "geometry or texture" has already been answered by the data. Clipped * to the extended state board the entire statewide field is 38 x 42 = 1,596 * texels — 6.2 KB as RGBA, rebuilt only when a scan arrives. * * ### Only on the state board, and this is a fact about the cell * * A 0.25-degree cell is **27.8 km across**. The SoCal board spans 1.08 degrees * of latitude, so it is 4.3 cells tall; the Bay Area board is 3.4. A per-cell * raster there is four enormous squares over a city — a lie about resolution, * told in a medium that reads as truthful. `precipFactoryFor` refuses to build * the layer on a board that cannot carry the cell, and returns `null` rather * than a layer that draws nothing, so a fine board pays no import, no material * and no draw call at all. * * ### The empty sky is the normal sky * * California is under rain a mean 0.596% of the time, and across the twenty-six * frames in the upstream store tonight the range is 0.472% to 1.95%. So the * ordinary state of this layer is **nothing at all**, and "nothing at all" here * means the mesh is not in the group: `group.children` is empty, not one * invisible mesh and not 1,596 transparent texels. The gate upstairs * (`src/server/radar.ts`) decides that from statewide coverage, and when it says * no this object holds a geometry and a material and contributes nothing to any * frame. * * ### Three values a texel can hold, and they are three different claims * * - **Rain**: at or above `RADAR_RAIN_DBZ`, coloured from the product's own * NWS ramp so anyone who has seen a weather map reads it without being * taught, alpha climbing steeply off the threshold. * - **Dry**: a working radar looked and saw less than 20 dBZ. Fully * transparent. * - **Unknown**: `null` — a radar that should be looking there is off the * air and nothing else covers the hole. Drawn as a faint neutral wash, * because a hole drawn as clear sky is a claim nobody made. It is claimed * narrowly: on a night with all sixteen stations transmitting, which is * tonight, there is not one such cell. `src/server/radar.ts` has the * argument for why the wider version of that rule was wrong, and the frame * that showed it. * * ### Altitude: the cloud base, and the height was chosen with a picture * * A reflectivity composite is a plan view and has no altitude of its own, so * where the sheet goes is a rendering decision and not a meteorological one. It * was made twice. * * The first answer was six kilometres — above every point of California's land * in real metres, so the sheet cleared the Sierra crest, which at this board's * `verticalExaggeration: 15` stands at **39.5 scene units**. It looked wrong, * and the reason is parallax: 47 units of lift under a camera looking down at * fifty degrees shifts the sheet about forty units across the frame, which is * *seventy-five kilometres* on a board at 1,919 m to the unit. A weather map * whose rain is drawn seventy-five kilometres from where it fell is worse than * one whose mountains poke through it. * * So the sheet sits at the cloud base — 1,350 m, `clouds.ts`'s own * `BASE_ALTITUDE_M`, 10.6 units here — and the Sierra rises through it and * occludes it, which is what a low rain layer under a high range actually looks * like. Parallax drops to about twenty kilometres and the rain reads as being on * the land it is on. Both frames are in `/tmp/tera-look`; the second is the one * that is right. * * **This layer owns no light.** CONTRACT.md §4. `setLighting` reads the sky * colour and dims the sheet after dark; it constructs nothing. It reads the * *sky colour* and not `hemisphere.intensity`, which is the trap here — see * `skyBrightness`. */ import * as THREE from "three"; import { RADAR_RAIN_DBZ } from "../server/radar.ts"; import { radarRampRgb } from "../assets/radarRamp.ts"; import type { RadarField } from "./types.ts"; import type { LightingState } from "./types.ts"; import type { World } from "./world.ts"; // ---- Constants ------------------------------------------------------------ /** * Where the sheet sits, in metres above sea level, before the board's own * vertical exaggeration. * * The cloud base — `BASE_ALTITUDE_M` in `clouds.ts`, to the metre, so the rain * falls out of the bottom of the deck rather than out of a number of its own. * See the header for why the first attempt at 6,000 m was wrong. */ export const PRECIP_ALTITUDE_M = 1_350; /** * Alpha at the rain threshold, and at `PRECIP_ALPHA_FULL_DBZ` and above. * * Steep off the threshold on purpose. The sheet is transparent over about 99% of * its texels on an ordinary day, so the failure mode to design against is not * "too loud" — it is a whole layer nobody notices is there. A cell that has just * crossed into rain is already clearly a mark on the board; a 50 dBZ core is * nearly opaque. */ const PRECIP_ALPHA_MIN = 0.3; const PRECIP_ALPHA_MAX = 0.92; const PRECIP_ALPHA_FULL_DBZ = 50; /** Exponent on the alpha ramp. Below 1, so the rise is fastest at the threshold. */ const PRECIP_ALPHA_CURVE = 0.7; /** * How a cell nobody is looking at is drawn: a faint neutral wash. * * Deliberately weak. It has to be visible enough that the Pacific corner reads * as "not measured" when somebody looks for it, and weak enough that a fifth of * the board carrying it does not read as weather. Neutral rather than tinted, * because every colour on the ramp already means a reflectivity. */ const PRECIP_UNKNOWN_RGB: readonly [number, number, number] = [150, 155, 165]; const PRECIP_UNKNOWN_ALPHA = 0.07; /** * How long a new scan takes to replace the one before it, in seconds. * * Scans are ten minutes apart, and the two obvious answers are both wrong. A * hard cut is a whole state's weather changing between two frames, which reads * as a glitch. A dissolve stretched across the full ten-minute step means that * for nine and a half of every ten minutes the board is showing a blend of two * observations and not either of them — the same objection that forbids splining * a vessel between fixes, in a gentler costume. Thirty seconds hides the seam * and leaves 95% of every step showing one real scan. */ const PRECIP_CROSSFADE_SECONDS = 30; /** * The fewest lattice cells a board must span before a raster is honest on it. * * Twelve, in the shorter axis. California spans 38 x 42 and passes; SoCal spans * 4.3 x 6.6 and the Bay Area 3.4 x 3.6, and both are refused. There is no * threshold that makes four texels over Los Angeles into a picture of rain. */ export const PRECIP_MIN_CELLS = 12; /** * How opaque the sheet is at midnight, as a fraction of its daytime self. * * Rain at night is dark. But this is a data overlay before it is weather, and an * overlay that fades out at sunset is a defect rather than a mood — the boards * this repo photographs at night are the ones with the most going on. */ const PRECIP_NIGHT_OPACITY = 0.55; /** Above the terrain and the smoke, below the cloud deck's own sort. */ const PRECIP_RENDER_ORDER = 1; // ---- The handle ----------------------------------------------------------- export interface PrecipLayerOptions { /** Board span in scene units, as `scene.ts` computes it. */ span: number; /** Metres above sea level. Defaults to `PRECIP_ALTITUDE_M`; see the header. */ altitudeM?: number; /** Seconds a new scan takes to replace the last. Defaults to 30. */ crossfadeSeconds?: number; } export interface PrecipLayer { group: THREE.Object3D; setField(field: RadarField | null): void; setLighting(state: LightingState): void; tick(dt: number): void; dispose(): void; /** Texels at or above the rain threshold in the field being drawn. Zero when empty. */ wetTexels(): number; /** Whether the sheet is currently contributing anything to the frame. */ drawing(): boolean; } /** A board rectangle, in degrees. Restated; see `src/server/radar.ts`. */ export interface PrecipBounds { minLat: number; maxLat: number; minLng: number; maxLng: number; } /** * Whether this board's bounds can carry a quarter-degree cell. * * The whole "which boards get a raster" decision, in a pure function a test can * hold, rather than an `if (city.id === "california")` buried in the wiring. * `cellDeg` is a parameter so that the day the composite changes resolution this * answers the new question rather than the old one. */ export function boardCarriesRaster(bounds: PrecipBounds, cellDeg = 0.25): boolean { if (!Number.isFinite(cellDeg) || cellDeg <= 0) return false; const rows = (bounds.maxLat - bounds.minLat) / cellDeg; const cols = (bounds.maxLng - bounds.minLng) / cellDeg; return Math.min(rows, cols) >= PRECIP_MIN_CELLS; } /** * The factory for this board, or `null` for a board too fine to carry the cell. * * `null` and not a layer that draws nothing: a board that refuses the raster * must pay no material, no geometry and no import, and the difference is * visible in `scripts/performance-budget.mjs` rather than only in principle. */ export function precipFactoryFor( bounds: PrecipBounds, ): ((world: World, options: { span: number }) => PrecipLayer) | null { if (!boardCarriesRaster(bounds)) return null; return (world, options) => createPrecipLayer(world, options); } // ---- The layer ------------------------------------------------------------ export function createPrecipLayer(world: World, options: PrecipLayerOptions): PrecipLayer { const group = new THREE.Group(); group.name = "precip"; const altitudeM = options.altitudeM ?? PRECIP_ALTITUDE_M; const crossfade = Math.max(0, options.crossfadeSeconds ?? PRECIP_CROSSFADE_SECONDS); // One unit quad, scaled and placed per field. Two triangles, for ever. const geometry = new THREE.PlaneGeometry(1, 1, 1, 1); geometry.rotateX(-Math.PI / 2); const material = new THREE.MeshBasicMaterial({ transparent: true, // Premultiplied, and it is not a detail. The raster is 1,596 texels blown // up across a 554-unit board with linear filtering, so every rain patch's // edge is a long interpolation between a coloured texel and a transparent // one. Straight alpha interpolates the *colour* toward black on the way, and // the first shot of this layer had a dark fringe round every echo and a hard // dark line along the board edge where the lattice is clipped. See `paint`. premultipliedAlpha: true, depthWrite: false, // Visible from underneath as well as from above — the drive and actor // cameras sit below the cloud base and look up through it. side: THREE.DoubleSide, /** * **One draw call, and without this it is two.** * * three.js renders a `transparent` + `DoubleSide` material in two passes by * default — back faces, then front — so that a closed transparent solid * composites correctly. Measured on the shipped board: the sheet cost 2 draw * calls of the 45 this round has to spend, for a single flat quad that * cannot overlap itself. `forceSinglePass` is the switch that says so, and * the number came out of `renderer.info.render.calls` and not out of * reasoning about it. */ forceSinglePass: true, // Not `AdditiveBlending`: rain is not light, and additive over a pale // Central Valley would turn a green echo white. blending: THREE.NormalBlending, }); const mesh = new THREE.Mesh(geometry, material); mesh.name = "precip-sheet"; mesh.renderOrder = PRECIP_RENDER_ORDER; mesh.frustumCulled = false; /** The scan being faded out, the scan being faded in, and how far along. */ let previous: Uint8Array | null = null; let current: Uint8Array | null = null; let texture: THREE.DataTexture | null = null; let blend = 1; let held: RadarField | null = null; let wet = 0; let daylight = 1; function releaseTexture(): void { if (texture === null) return; texture.dispose(); if (material.map === texture) material.map = null; texture = null; } function detach(): void { if (mesh.parent === group) group.remove(mesh); held = null; previous = null; current = null; wet = 0; blend = 1; releaseTexture(); material.needsUpdate = true; } /** Write `blend` of `current` over `previous` into the texture and upload it. */ function compose(): void { if (texture === null || current === null) return; const data = texture.image.data as Uint8Array; if (previous === null || blend >= 1 || previous.length !== current.length) { data.set(current); } else { const t = blend; for (let i = 0; i < data.length; i++) { // `| 0` rather than Math.round: this runs over 6,384 bytes and the // half-step it gives away is a fortieth of one alpha level. data[i] = ((previous[i] as number) + ((current[i] as number) - (previous[i] as number)) * t) | 0; } } texture.needsUpdate = true; } function setField(field: RadarField | null): void { const read = readable(field); if (read === null) { detach(); return; } const next = paint(read); const sameShape = held !== null && held.rows === read.rows && held.cols === read.cols; previous = sameShape && current !== null ? current : null; current = next.bytes; wet = next.wet; blend = previous === null || crossfade === 0 ? 1 : 0; held = read; if (texture === null || !sameShape) { releaseTexture(); texture = new THREE.DataTexture( new Uint8Array(read.rows * read.cols * 4), read.cols, read.rows, THREE.RGBAFormat, THREE.UnsignedByteType, ); // Linear, deliberately. A 27.8 km cell drawn with hard texel edges claims // a precision the product does not have; a soft one claims none. texture.magFilter = THREE.LinearFilter; texture.minFilter = THREE.LinearFilter; texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; texture.colorSpace = THREE.SRGBColorSpace; texture.generateMipmaps = false; material.map = texture; material.needsUpdate = true; } place(read); compose(); if (mesh.parent !== group) group.add(mesh); } /** * Sit the quad over the lattice's own footprint, not over the board's bounds. * * The lattice is the product's grid clipped to the board, so its edges are a * half-cell outside the outermost cell CENTRES. Getting this wrong by a half * cell puts every echo 14 km from where it was measured, which is invisible * and wrong. */ function place(field: RadarField): void { const south = field.minLat - field.cellLat / 2; const north = field.minLat + (field.rows - 0.5) * field.cellLat; const west = field.minLng - field.cellLng / 2; const east = field.minLng + (field.cols - 0.5) * field.cellLng; const [westX, southZ] = world.project(south, west); const [eastX, northZ] = world.project(north, east); mesh.scale.set(Math.abs(eastX - westX), 1, Math.abs(southZ - northZ)); mesh.position.set((westX + eastX) / 2, world.metres(altitudeM), (southZ + northZ) / 2); } return { group, setField, setLighting(state: LightingState): void { // Rain at night is dark, but a data overlay that disappears after sunset // is a defect rather than a mood. Floored well short of zero. daylight = PRECIP_NIGHT_OPACITY + (1 - PRECIP_NIGHT_OPACITY) * skyBrightness(state); material.opacity = daylight; }, tick(dt: number): void { if (current === null || blend >= 1 || crossfade === 0) return; if (!Number.isFinite(dt) || dt <= 0) return; blend = Math.min(1, blend + dt / crossfade); compose(); }, dispose(): void { detach(); geometry.dispose(); material.dispose(); }, wetTexels: () => wet, drawing: () => mesh.parent === group, }; } // ---- Painting ------------------------------------------------------------- /** * One field as RGBA bytes, row-major from the south-west cell centre. * * Row 0 is the southern row and lands at `v = 0`, which is the bottom edge of * the plane; after the geometry's `rotateX(-PI/2)` the plane's `-Y` edge is the * one at `+Z`, and `+Z` is south because `World.project` negates latitude. The * mapping is therefore the identity and needs no flip — which is worth stating, * because a texture that is upside down over a state this shape looks plausible. */ export function paint(field: RadarField): { bytes: Uint8Array; wet: number } { const bytes = new Uint8Array(field.rows * field.cols * 4); let wet = 0; for (let i = 0; i < field.dbz.length; i++) { const value = field.dbz[i]; const at = i * 4; if (value === null || value === undefined || !Number.isFinite(value)) { write(bytes, at, PRECIP_UNKNOWN_RGB, PRECIP_UNKNOWN_ALPHA); continue; } if (value < RADAR_RAIN_DBZ) continue; // zeroed already: dry is nothing at all write(bytes, at, radarRampRgb(value), alphaForDbz(value)); wet += 1; } return { bytes, wet }; } /** * One texel, **premultiplied**. * * The colour is scaled by its own alpha before it is written, which is what the * material is told to expect. Without it, linear filtering across the long * interpolation between a 50 dBZ texel and a transparent one walks the colour * down to black rather than fading it out, and every echo on the board gets a * dark halo and the clipped edge of the lattice gets a hard dark line. That was * visible in the first frame this layer ever produced and invisible in every * test. */ function write( bytes: Uint8Array, at: number, rgb: readonly [number, number, number], alpha: number, ): void { const a = clamp01(alpha); bytes[at] = Math.round(rgb[0] * a); bytes[at + 1] = Math.round(rgb[1] * a); bytes[at + 2] = Math.round(rgb[2] * a); bytes[at + 3] = Math.round(a * 255); } /** The alpha ramp, exported so a test can argue with the curve rather than the pixels. */ export function alphaForDbz(dbz: number): number { if (!Number.isFinite(dbz) || dbz < RADAR_RAIN_DBZ) return 0; const t = clamp01((dbz - RADAR_RAIN_DBZ) / (PRECIP_ALPHA_FULL_DBZ - RADAR_RAIN_DBZ)); return PRECIP_ALPHA_MIN + (PRECIP_ALPHA_MAX - PRECIP_ALPHA_MIN) * Math.pow(t, PRECIP_ALPHA_CURVE); } /** * A field this layer will draw, or `null`. * * Total by design: the consumer is a render loop, so a body from a server one * version ahead, a `dbz` array of the wrong length, or a field with nothing wet * in it all produce an empty sky rather than a throw or a rotated raster. */ function readable(field: RadarField | null | undefined): RadarField | null { if (field === null || field === undefined || typeof field !== "object") return null; const { rows, cols, dbz } = field; if (!Number.isFinite(rows) || !Number.isFinite(cols) || rows <= 0 || cols <= 0) return null; if (!Number.isFinite(field.cellLat) || !Number.isFinite(field.cellLng)) return null; if (field.cellLat <= 0 || field.cellLng <= 0) return null; if (!Number.isFinite(field.minLat) || !Number.isFinite(field.minLng)) return null; if (!Array.isArray(dbz) || dbz.length !== rows * cols) return null; for (const value of dbz) { if (typeof value === "number" && Number.isFinite(value) && value >= RADAR_RAIN_DBZ) return field; } // Nothing wet. Not an error and not a fault — it is nine days in ten. return null; } /** * How bright the sky is, 0 at midnight and 1 in daylight, from the rig alone. * * **Not `hemisphere.intensity`, which is the trap here.** It measures 1.33 at * 21 degrees below the horizon and 0.95 at noon — it goes *up* at night, because * `atmosphere.ts` compensates a moonlit scene by raising the fill. A layer that * read it as a day/night signal would run at full strength in the dark and dim * at midday, and it would pass a test written against invented numbers. Measured * on the real rig before this was written: `sky.top` is 0x0d1730 at 04:35Z and * 0x77a1cb at 20:00Z, which is the honest signal and the one the eye reads too. * * `PrecipLayer` has no `setSolarElevation` — unlike the migration field, whose * subject *is* the night — so the rig is the only clock it gets. */ function skyBrightness(state: LightingState): number { const packed = state.sky?.top ?? state.hemisphere?.sky ?? 0xffffff; const r = ((packed >> 16) & 0xff) / 255; const g = ((packed >> 8) & 0xff) / 255; const b = (packed & 0xff) / 255; const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; return clamp01((luminance - SKY_NIGHT_LUMINANCE) / (SKY_DAY_LUMINANCE - SKY_NIGHT_LUMINANCE)); } /** Measured off the shipped rig: 0x0d1730 at 04:35Z, 0x77a1cb at 20:00Z. */ const SKY_NIGHT_LUMINANCE = 0.09; const SKY_DAY_LUMINANCE = 0.58; function clamp01(value: number): number { return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0; }