acf4d1a510
**Stage 1 of one California.** The owner's complaint had two halves and this is
the first: the state board was a CROPPED SLAB. `california.ts` stopped at 38.05 N,
so the board disagreed with its own minimap about the shape of California in a
single frame, and Bug Fire's 93,733 acres burned off-frame while the panel said
all clear. Bounds now run 32.50-42.05 N / -124.50 to -114.0 W — Cape Mendocino,
the ruled Oregon parallel, the 120th-meridian corner into the Nevada diagonal.
**And it got cheaper.** 391,169 triangles to 375,351, while gaining the North
Coast, the Sacramento Valley, the Klamath knot, the Cascade arc, Shasta at 4,320 m
and Lassen at 3,190 m. Extending the bounds alone would have doubled the lattice
to 168,813 points and blown the mobile cap; coarsening cellLat 0.022 -> 0.0312 and
cellLng 0.027 -> 0.0383 holds it at ~83,800. The cell as a FRACTION of the board
moves 0.0030 -> 0.0033 — unchanged in frame — because the camera retreats to frame
whatever it is given. That argument was already written in the pack's own comment.
The second half — three boards becoming one world you zoom through — is NOT here.
Merging at Bay density would be 34.04M triangles, 13x the highest budget, and
merging at SoCal density would downgrade San Francisco from 40 m lots to 164 m.
Both delete the board every marketing still is shot from. `sf.ts` and `socal.ts`
are untouched by design.
**Aerial perspective, which the state board could not have had before.** The old
fog started at 1.15 board spans = 944 km, on a board whose longest diagonal is
820 km — so no pixel could ever be fogged. Fog now responds to camera altitude,
clamped to the authored pair as a ceiling.
`Atmosphere.aerial(env, view)` is a second pure method returning `{ near, far }`
and **deliberately no colour**. That is structural, not stylistic: it is why a
future camera-dependent term cannot reach `environmentKey()`'s colour fingerprint
and start rebuilding the PMREM cubemap on every camera step. Coarsening the
fingerprint instead would have hidden one instance and armed the mechanism. A
mutation-tested seam guard fails if anyone merges the two paths back together.
**The port.** Terminal Island rendered as a bare tan polygon with generic white
blocks while the chapter text called it the busiest port complex in the
hemisphere. Now six container yards drawn as canvas atlases, 56 gantry cranes at
varied boom angles, the 13 km San Pedro breakwater, the dredged channel. Five
buckets merging ACROSS ports the way airports.ts merges across fields, so a
second complex costs no extra draws: +11 draws and +4,377 triangles for all of it.
At vertical exaggeration 3.4 a 130 m gantry is 1.132 units tall against a 400 m
ship's 1.024 long — the crane is the taller object, and it is what makes a port
read as a port from altitude.
**Ships, and the wake carries the information.** Moored hulls have no foam,
verified at three terminals; a tug under way in the Main Channel trails a clean
Kelvin V. One hull geometry, one InstancedMesh, orientation from the BERTH rather
than the wire. The AIS gate strips sog 102.3, heading 511 and cog 360 — all mean
"not available" — with an explicit test that cog 358.7 SURVIVES, because a naive
range check on cog eats real headings near north.
"Empty or full" is not in AIS position reports and is not invented per ship. The
honest answer is at port level and is a better story: 348,691 of 460,467 boxes
left Los Angeles empty in July 2026, corroborated by FBX01 $7,491 inbound against
FBX02 $347 outbound.
**Radar and birds ship dark, and say why.** California is 0.47% wet and migration
is nocturnal and seasonal, so both layers have nothing to say on most days. The
panel reads "No radar feed is configured, so this board draws no weather. That is
a fact about this box, not about the sky."
Also recorded, and it matters beyond this commit: **the GPU on amd-server never
leaves 500 MHz of a possible 2725**, traced across 80 seconds of sustained load.
`bay-area/desktop` is fragment-bound at that clock and sits on the vsync deadline,
so a trivial change in fragment work flips it between 16.8 and 33.3 with geometry
identical to the digit. Every frame-time number measured on this box is a floor.
Two investigations reached two different wrong conclusions from single-run
comparisons before this was traced. Geometry is the gate; frame time is advisory.
No cap was raised.
Tests 1,340 -> 1,540, server 280 -> 295.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
509 lines
21 KiB
TypeScript
509 lines
21 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|