b25f217e3e
The world stops being a simulation of California and starts being California. **THE PROMOTION GATE WAS THE FIRST COMMIT, BEFORE ANY ORANGE PIXEL EXISTED.** On today's live store the SoCal board contains 22 incidents. Every one has NULL acreage and fifteen are nameless LA County dispatch numbers. Drawn naively that is 22 orange marks over Los Angeles on a day nothing is burning — in a frame that contains no other warm colour, so one glyph would be the most salient object on the board and twenty-two would spend its credibility permanently. `acres >= 10 AND contained < 80 AND type != 'RX' AND last_seen = max(last_seen)` returns 0 on SoCal, exactly 5 on California, 0 on the Bay — same body, same day, three correct answers. The empty board is a deliverable, not a fallback: it says "No active fire on this board — CAL FIRE and WFIGS, just now", states that 21 records were gated and why, lists the largest fires burning OUTSIDE the frame with distances, and counts the hot pixels it is deliberately not drawing. **The privacy leak is structurally impossible rather than carefully avoided.** cloud-1 serves a projection; the four home-relative columns never leave that box. `observations.threat` was the one that nearly got through — it is `(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`, so with acreage and containment public it inverts to a distance circle around a house and three fires give an intersection. A grep of the built bundle for distance_km, bearing_deg, threat, 7762 and the street name returns nothing. **Deliberately not used, and both would have produced a confident wrong answer:** the store's `air` table retains only the last parameter of each poll, so all 78 rows read "Good" while the live feed reports ozone 101 "Unhealthy for Sensitive Groups" — haze driven off it would clear the sky during a smoke event. And `weather` is written only inside the NWS alerts loop, so a quiet day stores no wind at all. Tera's own per-region NWS wind is already correct and already what the clouds drift on. Satellite detections are drawn as evidence and never as incidents. The permanent industrial heat source 4.7 km from the owner's house is flagged persistent and dropped, asserted by a test that first proves it is present in the fixture. MODIS integer confidence and VIIRS string confidence are branched on `sat`. **The LA office is a twin.** Its entire authored second storey — Model Loft, Model Bay, The Materials Room, 430 lines nobody had ever stood in — is reachable on foot: a walker crosses level-1 to level-2 in 73 fixed steps, floorY 0 to 5, verified against the real pack rather than a synthetic plan. Its two studio devices read real hardware through a field-allowlisted bridge: mute, volume and reachability only. Never level, because there is no passive level upstream and obtaining one would record a room with people in it. Never dB, because upstream is gainPct across four different native scales. The bridge refuses all writes. Fixed at its root: an anonymous visitor was getting permanently at-rest instruments backing off against a 401. The tier moves into `createDeviceSource`, so anon gets the living simulator three file headers already promised. **Item 8 is closed, not fixed, and the correction is the point.** The Bay Area "stutter" was GPU power management — the card sat at 500 MHz of 2725 through every run that reproduced it, 4096/2048/1024/256 shadow maps all render in 1.21-1.31 ms, and two consecutive runs over a byte-identical dist gave 33.4 then 16.7. The allowance is removed and the cell is back to 16.7. Geometry is the gate; frame time is advisory. Item 7 was re-scoped after measuring: 1,069,006 of the Bay Area's 2,265,056 triangles were the second submission of the same buildings into the shadow pass. Mobile now has its own triangle caps and bay-area mobile draws 1,266,096. Also: bridges and the freeway corridor light up at night as emission, not lights — 1,614 deck lamps and 18 tower heads on the Bay in two draw calls. The single change that made US-101 legible was moving its edge lines from the lit material to the unlit one: retroreflective paint, the argument the SFO night frame already makes. California went 21,991 lamps to 4,051, clustered at the 17 town districts, because a rural interurban corridor genuinely is unlit. Tests 1137 -> 1340, server 280. All ten budget cells pass on first attempt with no cap raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
703 lines
30 KiB
TypeScript
703 lines
30 KiB
TypeScript
/**
|
||
* Satellites over the city — on a dome, because they cannot be anywhere else.
|
||
*
|
||
* Every other thing in this scene lives in projected metric space: a building is
|
||
* where the building is, an aircraft at 10,000 m is at `world.metres(10000)`
|
||
* above the ground it is over. That rule breaks completely here, and it is worth
|
||
* being explicit about how badly, because the alternative is a layer that renders
|
||
* nothing and looks broken.
|
||
*
|
||
* Starlink flies at about 550 km. The Bay Area board is ~94 m per scene unit with
|
||
* a 3.6× vertical exaggeration, so `world.metres(550_000)` is **21,000 scene
|
||
* units** — against a board 1,000 units across and a camera far plane at 3,000.
|
||
* The satellite is seven times beyond the horizon of the projection, over ground
|
||
* two thousand kilometres away that this city pack does not contain. There is no
|
||
* camera position from which the true placement is both visible and meaningful.
|
||
*
|
||
* So this layer draws **what you would see if you looked up**: each satellite at
|
||
* its real azimuth and elevation from the city centre, on a dome big enough to
|
||
* sit outside the buildings and inside the far plane. A dot due east at 30° above
|
||
* the horizon is genuinely due east at 30°. The dome's *radius* is arbitrary and
|
||
* carries no information; the direction carries all of it.
|
||
*
|
||
* That is not a compromise so much as the correct frame for the question. Nobody
|
||
* looking at a satellite layer wants to know its ECEF coordinates. They want to
|
||
* know whether one is passing over, where to look, and whether it is lit — and
|
||
* the last of those is why this bothers with `shadowFraction` rather than drawing
|
||
* every object the same brightness. A Starlink is visible to the naked eye when
|
||
* it is in sunlight while the ground below it is dark, which is the whole reason
|
||
* anybody ever noticed the constellation existed.
|
||
*
|
||
* ### Where the propagation happens, and why it is here
|
||
*
|
||
* The server sends element sets, not positions (`wire.ts`, `SatellitesBody`), for
|
||
* the same reason it sends a flight plan rather than aircraft: a TLE is already
|
||
* the closed form, SGP4 is the function that evaluates it, and every browser
|
||
* evaluating it agrees. One cacheable fetch every six hours replaces a poll.
|
||
*
|
||
* `satellite.js` is the second runtime dependency this package has ever taken,
|
||
* after three.js, and the bar it had to clear was "would hand-rolling this be
|
||
* better". SGP4 is a 1980 Fortran model with a specific set of drag and
|
||
* resonance terms, a published reference implementation, and a well-known list of
|
||
* ways a re-implementation goes subtly wrong; the library is MIT, is a direct
|
||
* translation of Vallado's C++, and is the one everybody checks against. Writing
|
||
* our own would have been a worse copy of it.
|
||
*/
|
||
|
||
import * as THREE from "three";
|
||
import {
|
||
eciToEcf,
|
||
ecfToLookAngles,
|
||
gstime,
|
||
jday,
|
||
propagate,
|
||
shadowFraction,
|
||
sunPos,
|
||
twoline2satrec,
|
||
type SatRec,
|
||
} from "satellite.js";
|
||
import type { City, SatelliteGroup } from "./types.ts";
|
||
|
||
/**
|
||
* One satellite's element set, as the catalogue takes it.
|
||
*
|
||
* Structurally `WireSatellite` from `server/wire.ts`, restated rather than
|
||
* imported for the same reason `SimRoute` is: the server must be able to build
|
||
* one without three.js becoming one of its dependencies.
|
||
*/
|
||
export interface SatelliteElements {
|
||
noradId: number;
|
||
name: string;
|
||
group: SatelliteGroup;
|
||
line1: string;
|
||
line2: string;
|
||
}
|
||
|
||
/** Where one satellite is in the observer's sky, right now. */
|
||
export interface SatelliteFix {
|
||
noradId: number;
|
||
name: string;
|
||
group: SatelliteGroup;
|
||
/** Radians clockwise from true north. */
|
||
azimuth: number;
|
||
/** Radians above the horizon. Only non-negative fixes are ever produced. */
|
||
elevation: number;
|
||
/** Observer to satellite, in kilometres. Straight-line, not ground track. */
|
||
rangeKm: number;
|
||
/**
|
||
* How much of the sun's disc the earth is covering, from the satellite's point
|
||
* of view. `0` is full sunlight, `1` is umbra, and the values in between are
|
||
* the penumbra — which is where the fade at the end of a Starlink train comes
|
||
* from, and is the reason this is a fraction rather than a boolean.
|
||
*/
|
||
shadow: number;
|
||
}
|
||
|
||
/**
|
||
* Observer height above the ellipsoid, in kilometres.
|
||
*
|
||
* Zero. The correction for a city at 50 m matters to a radar and not to a dot on
|
||
* a dome — it moves a look angle by well under a hundredth of a degree — and
|
||
* pretending otherwise would mean threading a ground elevation through here for
|
||
* no visible change.
|
||
*/
|
||
const OBSERVER_HEIGHT_KM = 0;
|
||
|
||
/**
|
||
* How much of a frame the rolling sweep may spend propagating.
|
||
*
|
||
* SGP4 costs roughly ten microseconds per object, so a six-thousand-object
|
||
* catalogue is about sixty milliseconds — four frames' worth, in one lump, and
|
||
* a visible hitch if it happens all at once. Doing it every frame is out of the
|
||
* question and doing it on a timer just moves the hitch somewhere less
|
||
* predictable.
|
||
*
|
||
* So the sweep is **time-budgeted and rolling**: each call propagates as many
|
||
* objects as fit in this budget and remembers where it stopped, wrapping around
|
||
* the catalogue continuously. Two milliseconds is under a seventh of a 60 Hz
|
||
* frame, and it walks six thousand objects in about half a second.
|
||
*
|
||
* The staleness that buys is the thing to check, and it is negligible: half a
|
||
* second at Starlink's ~0.8° per second of apparent motion overhead is under
|
||
* half a degree of arc. Nobody can see that. An aircraft interpolated half a
|
||
* second late would be visibly behind; a satellite is not, because the dome is
|
||
* angular and the angles barely move.
|
||
*
|
||
* (`satellite.js` ships a WASM `BulkPropagator` that would make this a
|
||
* non-question. It is not used here because it needs a binary loaded at runtime
|
||
* and a fallback path for when that fails, which is a lot of machinery to buy
|
||
* back two milliseconds a frame that are already accounted for.)
|
||
*/
|
||
const SWEEP_BUDGET_MS = 2;
|
||
|
||
/**
|
||
* The observer's own coordinates, in the radians `ecfToLookAngles` wants.
|
||
*
|
||
* Built once. `geodeticToEcf` would recompute the same three numbers on every
|
||
* call otherwise, several thousand times a second.
|
||
*/
|
||
interface Observer {
|
||
longitude: number;
|
||
latitude: number;
|
||
height: number;
|
||
}
|
||
|
||
/**
|
||
* A catalogue of element sets, propagated continuously, reporting what is up.
|
||
*
|
||
* Deliberately shaped like `SimulatedFlights` and `AdsbFlights` — construct with
|
||
* data, ask for the current state — but it is **not** a `FlightSource` and does
|
||
* not implement that interface. A `FlightSource.poll()` returns positions on the
|
||
* ground; this returns look angles on a dome, and collapsing the two into one
|
||
* interface would mean a renderer that could not tell which space it was in.
|
||
*/
|
||
export class SatelliteCatalogue {
|
||
private readonly records: { rec: SatRec; meta: SatelliteElements }[] = [];
|
||
private readonly observer: Observer;
|
||
|
||
/** Latest fix per NORAD id. Entries are deleted as they set below the horizon. */
|
||
private readonly current = new Map<number, SatelliteFix>();
|
||
|
||
/** Where the rolling sweep stopped last time. */
|
||
private cursor = 0;
|
||
|
||
/**
|
||
* How many objects the last full pass found above the horizon, for the panel.
|
||
* Counted rather than derived from `current.size` so that a partial sweep does
|
||
* not make the number jump around while it is still walking the catalogue.
|
||
*/
|
||
private lastPassVisible = 0;
|
||
private passVisible = 0;
|
||
|
||
constructor(elements: SatelliteElements[], center: City["center"]) {
|
||
for (const el of elements) {
|
||
// A TLE this build cannot read is one satellite missing, never a throw:
|
||
// the catalogue arrives over the network and one malformed line must not
|
||
// take the layer down.
|
||
//
|
||
// `rec.error` is necessary and **not sufficient**, which is worth stating
|
||
// because the obvious version of this check is wrong. `twoline2satrec`
|
||
// reads fixed columns with `parseFloat` and does not validate: hand it two
|
||
// lines of prose that merely start "1 " and "2 " and it returns `error: 0`
|
||
// with `NaN` in the orbital elements. Those propagate to `NaN` positions,
|
||
// which become `NaN` look angles, which land in the layer's vertex buffer
|
||
// — and one `NaN` vertex is enough to make a `Points` draw call render
|
||
// nothing at all. So the elements are checked for being numbers.
|
||
const rec = twoline2satrec(el.line1, el.line2);
|
||
if (rec.error !== 0) continue;
|
||
if (!Number.isFinite(rec.no) || !Number.isFinite(rec.inclo) || !Number.isFinite(rec.ecco)) {
|
||
continue;
|
||
}
|
||
this.records.push({ rec, meta: el });
|
||
}
|
||
|
||
this.observer = {
|
||
longitude: (center.lng * Math.PI) / 180,
|
||
latitude: (center.lat * Math.PI) / 180,
|
||
height: OBSERVER_HEIGHT_KM,
|
||
};
|
||
}
|
||
|
||
/** How many element sets this build could actually read. */
|
||
get size(): number {
|
||
return this.records.length;
|
||
}
|
||
|
||
/** How many were above the horizon at the end of the last complete pass. */
|
||
get visibleCount(): number {
|
||
return this.lastPassVisible;
|
||
}
|
||
|
||
/**
|
||
* Advance the rolling sweep and return everything currently above the horizon.
|
||
*
|
||
* `when` is passed in rather than read from the clock because godmode scrubs
|
||
* time — the whole panel exists to put the scene at an arbitrary instant, and
|
||
* a layer that quietly used `new Date()` would be the one thing on screen that
|
||
* ignored the scrubber. It is also what makes a time-lapse capture possible at
|
||
* all: `shots/` steps the clock rather than recording it.
|
||
*/
|
||
fixes(when: Date): SatelliteFix[] {
|
||
if (this.records.length === 0) return [];
|
||
|
||
const gmst = gstime(when);
|
||
// The sun moves a degree a day; computing its position once per sweep call
|
||
// rather than once per satellite is free accuracy-wise and saves a few
|
||
// thousand redundant evaluations.
|
||
const sun = sunPos(jday(when));
|
||
|
||
const deadline = performance.now() + SWEEP_BUDGET_MS;
|
||
let stepped = 0;
|
||
|
||
// At least one per call, so a machine so slow that `performance.now()` has
|
||
// already passed the deadline still makes progress instead of freezing the
|
||
// sky forever.
|
||
do {
|
||
const entry = this.records[this.cursor];
|
||
if (entry !== undefined) {
|
||
const fix = this.fixOne(entry.rec, entry.meta, when, gmst, sun.rsun);
|
||
if (fix === null) this.current.delete(entry.meta.noradId);
|
||
else {
|
||
this.current.set(entry.meta.noradId, fix);
|
||
this.passVisible += 1;
|
||
}
|
||
}
|
||
|
||
this.cursor += 1;
|
||
if (this.cursor >= this.records.length) {
|
||
// A pass completed: publish its count and start the next one's tally.
|
||
this.cursor = 0;
|
||
this.lastPassVisible = this.passVisible;
|
||
this.passVisible = 0;
|
||
}
|
||
stepped += 1;
|
||
} while (performance.now() < deadline && stepped < this.records.length);
|
||
|
||
return [...this.current.values()];
|
||
}
|
||
|
||
/** One satellite, or `null` if it is below the horizon or will not propagate. */
|
||
private fixOne(
|
||
rec: SatRec,
|
||
meta: SatelliteElements,
|
||
when: Date,
|
||
gmst: number,
|
||
sunEciAU: { x: number; y: number; z: number },
|
||
): SatelliteFix | null {
|
||
// `propagate` returns null for a decayed object and for an element set it
|
||
// cannot carry to this date — both of which are ordinary in a catalogue that
|
||
// is hours old, and neither of which is this layer's problem.
|
||
const state = propagate(rec, when);
|
||
const eci = state?.position;
|
||
if (eci === undefined || typeof eci === "boolean") return null;
|
||
|
||
const look = ecfToLookAngles(this.observer, eciToEcf(eci, gmst));
|
||
// Below the horizon is the common case by a wide margin — a few hundred of
|
||
// several thousand objects are up at any instant — so this returns before
|
||
// the shadow calculation rather than after it.
|
||
//
|
||
// `NaN < 0` is false, so this comparison alone would let a degenerate
|
||
// element set through to the vertex buffer. The constructor screens for that
|
||
// and this is the belt: an object can also decay or go numerically unstable
|
||
// partway through a session, long after it was admitted.
|
||
if (!(look.elevation >= 0)) return null;
|
||
|
||
return {
|
||
noradId: meta.noradId,
|
||
name: meta.name,
|
||
group: meta.group,
|
||
azimuth: look.azimuth,
|
||
elevation: look.elevation,
|
||
rangeKm: look.rangeSat,
|
||
shadow: shadowFraction(sunEciAU, eci),
|
||
};
|
||
}
|
||
}
|
||
|
||
// ---- Rendering ------------------------------------------------------------
|
||
|
||
export interface SatelliteLayer {
|
||
group: THREE.Group;
|
||
/** Redraw from a set of fixes. Cheap enough to call every frame, and is. */
|
||
update(fixes: SatelliteFix[]): void;
|
||
/**
|
||
* How dark the sky is, 0..1 — `nightFactor(sun.elevation)` from
|
||
* `atmosphere.ts`, and nothing else.
|
||
*
|
||
* This layer draws additively, which is correct at night and catastrophic in
|
||
* daylight: at 15:55 with the sun at +44° every dot was adding to an already
|
||
* bright sky and clipping to a hard white square, which is what a first-time
|
||
* visitor to the California board saw scattered across the frame before
|
||
* anything else registered. A satellite in daylight is not visible to the
|
||
* naked eye, so the honest alpha is zero — and the fade is the *same* curve
|
||
* `nightlights.ts` switches the city on with, so the sky does not empty at a
|
||
* different dusk from the one the windows light up at.
|
||
*/
|
||
setSkyDarkness(darkness: number): void;
|
||
/**
|
||
* Whether the layer draws at all. The catalogue keeps propagating either way —
|
||
* see `setVisible` for why that is deliberate rather than wasteful.
|
||
*/
|
||
setVisible(visible: boolean): void;
|
||
dispose(): void;
|
||
}
|
||
|
||
/**
|
||
* The dome's radius, as a fraction of **how far the board reaches from the scene
|
||
* origin** — `boardRadius` in `scene.ts`, not the board's width.
|
||
*
|
||
* That distinction was got wrong once and is worth stating plainly. Scene space
|
||
* is centred on `city.center`, and the Bay Area board runs forty kilometres down
|
||
* the peninsula from there, so its furthest corner is 0.94 spans from the origin
|
||
* while its half-diagonal is 0.65. A dome sized at 0.7 *spans* therefore sat
|
||
* **inside the southern third of its own city** — satellites rendering below the
|
||
* terrain and behind the hills, which looks like a depth bug and is a units bug.
|
||
*
|
||
* **You are meant to be able to get above this.** That is a deliberate reversal:
|
||
* an earlier version put the dome at 2.0 spans specifically so the camera could
|
||
* never leave it, on the theory that a sky you can step outside of is not a sky.
|
||
* That theory loses to the thing people actually want to look at — a
|
||
* constellation is an object, and the view of it from above, with the city
|
||
* underneath, is the shot. Standing inside a sphere of dots is a planetarium.
|
||
*
|
||
* So the geometry is worked out from the other end. The board's half-diagonal is
|
||
* 0.649 spans for San Francisco and 0.635 for the Southland, so 0.70 clears
|
||
* every corner of the city while sitting well inside the camera's new 2.0-span
|
||
* orbit. At the far end of the zoom the whole dome is in frame at this fov and
|
||
* the board still fills about two thirds of the screen height.
|
||
*
|
||
* Two bugs died with the old number, and both were invisible from the default
|
||
* pose. At 2.0 the dome reached 3.5 spans from the far side of the orbit against
|
||
* a far plane at 3.0, so roughly a sixth of the sky was being **clipped**; and
|
||
* everything past 2.8 spans was **fully fogged** by the city's own linear fog.
|
||
* The band where the constellation was both unclipped and unfogged did not
|
||
* overlap the band where it fitted on screen at all.
|
||
*/
|
||
export const DOME_RADIUS_FACTOR = 1.05;
|
||
|
||
/**
|
||
* How large a dot is drawn, in **pixels**, at any camera distance.
|
||
*
|
||
* `sizeAttenuation` is off, and that is the physically honest choice rather than
|
||
* a convenience. Everything on this dome is the same distance away and the dome
|
||
* is a stand-in for something 550 km up: a satellite does not get bigger because
|
||
* you zoomed the map in, and a point of light at effectively infinite distance
|
||
* has an apparent size set by the eye rather than by the range. Attenuation was
|
||
* on first and did the wrong thing twice over — it shrank the whole sky as the
|
||
* camera pulled back, and at the far end of the zoom it took a 324-object
|
||
* constellation down to specks a pixel across that read as noise in the sky
|
||
* texture.
|
||
*
|
||
* Three and a half pixels is about what an actual naked-eye Starlink looks like
|
||
* against a dark sky, which is the reference this is aiming at.
|
||
*/
|
||
const DOT_PIXELS = 3.5;
|
||
|
||
/**
|
||
* The range, in kilometres, at which an object is drawn at `DOT_PIXELS`, and how
|
||
* hard apparent size follows range.
|
||
*
|
||
* Every dot used to be the same object: the same size, the same square silhouette,
|
||
* the same colour per constellation, with alpha the only thing that varied. A few
|
||
* hundred pixel-identical squares on a sphere sample the pixel grid as a regular
|
||
* lattice, which is the moire this exists to break. The two cues that turn a
|
||
* lattice into a population are **size** and **something else in the sky to read
|
||
* it against**; the moon is the second, in `scenekit.ts`.
|
||
*
|
||
* The size cue has to come from something true or it is decoration, and there is
|
||
* exactly one such number already in a `SatelliteFix`: the slant range. It is
|
||
* genuinely what sets how bright a naked-eye pass looks, and on this catalogue it
|
||
* spans a factor of ninety — a station at 400 km against a navigation bird at
|
||
* 36,000. Rendered as an inverse square that would be a factor of 8,000 in
|
||
* brightness and most of the sky would vanish, so the exponent is 0.35: a gentle
|
||
* curve that puts a low pass at four and a half pixels, a Starlink overhead at
|
||
* three and a half, one near the horizon at about three, and a distant navigation
|
||
* satellite on the floor. Enough spread that no two neighbouring dots are the
|
||
* same, nowhere near enough for a Starlink train to smear.
|
||
*
|
||
* 800 km is the reference because it is a Starlink a little off zenith, which is
|
||
* the object `DOT_PIXELS` was chosen against in the first place.
|
||
*/
|
||
const RANGE_REFERENCE_KM = 800;
|
||
const RANGE_EXPONENT = 0.35;
|
||
|
||
/** Floor and ceiling on the drawn size, in pixels. Below 2 a dot is noise; above 6 it is a planet. */
|
||
const MIN_DOT_PIXELS = 2.1;
|
||
const MAX_DOT_PIXELS = 6;
|
||
|
||
/**
|
||
* How much of the drawn size an eclipsed object loses.
|
||
*
|
||
* A point source at the threshold of vision blooms: a bright one occupies more of
|
||
* the retina — and more of a sensor — than a faint one at the same true angular
|
||
* size, which is why stars on a photograph have magnitudes you can read off their
|
||
* diameters. So a fully lit satellite is drawn at its full size and one in the
|
||
* earth's shadow shrinks toward this, which is the same fact `SHADOW_ALPHA`
|
||
* already states about its brightness and reinforces rather than repeats.
|
||
*/
|
||
const SHADOW_SIZE = 0.72;
|
||
|
||
/** Ceiling on dots, so the buffers are allocated once and never grow. */
|
||
const MAX_DOTS = 4096;
|
||
|
||
/**
|
||
* Colour per constellation.
|
||
*
|
||
* Starlink is the one that gets a colour of its own, for the same reason it gets
|
||
* its own group in the wire type: it is what people are looking for. The rest are
|
||
* deliberately close to white — a sky where every object is a different hue is a
|
||
* chart, not a sky.
|
||
*/
|
||
const GROUP_COLORS: Record<SatelliteGroup, THREE.Color> = {
|
||
starlink: new THREE.Color(0xbfd8ff),
|
||
comms: new THREE.Color(0xd8e2ee),
|
||
navigation: new THREE.Color(0xe6e0cf),
|
||
station: new THREE.Color(0xfff0d0),
|
||
weather: new THREE.Color(0xd4ecdf),
|
||
other: new THREE.Color(0xdcdcdc),
|
||
};
|
||
|
||
/**
|
||
* Below this elevation a dot is faded out entirely.
|
||
*
|
||
* Not because the geometry is wrong down there but because it is *useless*: an
|
||
* object one degree above the horizon is behind the hills, behind the buildings,
|
||
* and behind more atmosphere than it can be seen through. Fading the last few
|
||
* degrees also hides the pop that a hard cut-off produces every time something
|
||
* rises, which on a busy constellation is several times a minute.
|
||
*/
|
||
const HORIZON_FADE_DEG = 8;
|
||
|
||
/**
|
||
* Alpha for an object in full shadow, relative to a sunlit one.
|
||
*
|
||
* Not zero. A satellite in the earth's shadow is genuinely invisible to the eye,
|
||
* and drawing nothing would be the physically honest choice — but this layer is
|
||
* also a map of what is overhead, and a sky that empties itself at local midnight
|
||
* reads as a broken feed rather than as a correct one. So an eclipsed object is
|
||
* drawn faintly: present, obviously not lit, and clearly a different thing from
|
||
* the one crossing above it in sunlight.
|
||
*/
|
||
const SHADOW_ALPHA = 0.16;
|
||
|
||
/**
|
||
* How large one object is drawn, in pixels.
|
||
*
|
||
* Exported and pure because it is the whole of the "satellites are objects rather
|
||
* than a lattice" claim, and a claim like that is worth a test rather than a
|
||
* screenshot: a regression here is a sky that quietly goes back to being graph
|
||
* paper, which nobody notices until somebody photographs it at dusk.
|
||
*
|
||
* Both inputs are already in a `SatelliteFix` and neither is invented. See
|
||
* `RANGE_REFERENCE_KM` for why the range curve is so gentle and `SHADOW_SIZE`
|
||
* for why an eclipsed object also shrinks.
|
||
*/
|
||
export function dotPixels(fix: Pick<SatelliteFix, "rangeKm" | "shadow">): number {
|
||
const range = Math.max(1, fix.rangeKm);
|
||
const lit = 1 - Math.min(1, Math.max(0, fix.shadow));
|
||
const scaled = DOT_PIXELS * Math.pow(RANGE_REFERENCE_KM / range, RANGE_EXPONENT);
|
||
const bloomed = scaled * (SHADOW_SIZE + (1 - SHADOW_SIZE) * lit);
|
||
return Math.min(MAX_DOT_PIXELS, Math.max(MIN_DOT_PIXELS, bloomed));
|
||
}
|
||
|
||
export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
|
||
const group = new THREE.Group();
|
||
group.name = "satellites";
|
||
|
||
const radius = boardRadius * DOME_RADIUS_FACTOR;
|
||
|
||
const positions = new Float32Array(MAX_DOTS * 3);
|
||
const colors = new Float32Array(MAX_DOTS * 4);
|
||
const sizes = new Float32Array(MAX_DOTS);
|
||
// Held as locals rather than looked up through `geo.attributes` on every
|
||
// update: the lookup is a string index into a dictionary typed as possibly
|
||
// holding nothing, and the alternative to keeping the references is a
|
||
// non-null assertion on the hot path twice a frame.
|
||
const positionAttr = new THREE.BufferAttribute(positions, 3);
|
||
const colorAttr = new THREE.BufferAttribute(colors, 4);
|
||
const sizeAttr = new THREE.BufferAttribute(sizes, 1);
|
||
const geo = new THREE.BufferGeometry();
|
||
geo.setAttribute("position", positionAttr);
|
||
geo.setAttribute("color", colorAttr);
|
||
geo.setAttribute("aSize", sizeAttr);
|
||
geo.setDrawRange(0, 0);
|
||
|
||
/**
|
||
* A hand-written points material, and the two reasons `PointsMaterial` could
|
||
* not stay.
|
||
*
|
||
* **Per-object size.** `PointsMaterial.size` is a uniform; there is no
|
||
* per-vertex size in it at all, and size is the cue that turns this lattice
|
||
* into a population. That alone forces a shader.
|
||
*
|
||
* **The silhouette.** An untextured point is a hard square — `gl_PointCoord`
|
||
* covers a square and nothing rounds it — so every object in the sky was a
|
||
* three-and-a-half-pixel axis-aligned box. Photographed at dusk with the camera
|
||
* tilted to the horizon, a few hundred of those read as graph paper. The round
|
||
* falloff below is computed analytically rather than sampled from a sprite,
|
||
* which is both cheaper and sharper at three pixels: a 64-texel sprite at this
|
||
* size is several mip levels down and comes back as a soft grey blur.
|
||
*
|
||
* Still one draw call, still one `Points`, still `MAX_DOTS` vertices. Nothing
|
||
* about the cost of this layer changed.
|
||
*/
|
||
const material = new THREE.ShaderMaterial({
|
||
uniforms: {
|
||
/**
|
||
* three multiplies `PointsMaterial.size` by the renderer's pixel ratio
|
||
* before uploading it, and `gl_PointSize` is in physical pixels — so a
|
||
* hand-written points shader that skips this draws dots at a third of the
|
||
* size on a 3x phone. Written from `onBeforeRender`, which is the only
|
||
* place this layer can see a renderer.
|
||
*/
|
||
uPixelRatio: { value: 1 },
|
||
},
|
||
vertexShader: `
|
||
attribute vec4 color;
|
||
attribute float aSize;
|
||
varying vec4 vColor;
|
||
void main() {
|
||
vColor = color;
|
||
// No size attenuation, deliberately: see DOT_PIXELS. Everything on this dome
|
||
// is the same distance away and stands for something 550 km up, so an object
|
||
// does not get bigger because the map was zoomed in.
|
||
gl_PointSize = aSize * uPixelRatio;
|
||
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
|
||
}
|
||
`,
|
||
fragmentShader: `
|
||
varying vec4 vColor;
|
||
void main() {
|
||
// gl_PointCoord runs 0..1 across a square; this is the distance from its
|
||
// centre in units of the half-width, so 1.0 is the inscribed circle.
|
||
vec2 offset = gl_PointCoord - vec2( 0.5 );
|
||
float r = length( offset ) * 2.0;
|
||
/*
|
||
* A bright core inside a soft halo, which is what a point source does to any
|
||
* optic including an eye. One smoothstep would give a flat disc with a
|
||
* feathered edge and would read as a bubble; the product of the two puts most
|
||
* of the energy in the middle pixel and lets the rest fall away, so a dot
|
||
* still looks like a dot at two pixels and like a small star at six.
|
||
*/
|
||
float core = 1.0 - smoothstep( 0.0, 0.55, r );
|
||
float halo = 1.0 - smoothstep( 0.35, 1.0, r );
|
||
float alpha = vColor.a * ( 0.65 * core + 0.35 * halo * halo );
|
||
if ( alpha <= 0.0 ) discard;
|
||
gl_FragColor = vec4( vColor.rgb, alpha );
|
||
}
|
||
`,
|
||
transparent: true,
|
||
// Dots are drawn over the sky and over each other; letting them write depth
|
||
// makes whichever drew first punch a hole in the ones behind, which on a
|
||
// dense constellation is most of them.
|
||
depthWrite: false,
|
||
// The sky is the darkest thing in the frame at the hour this layer matters,
|
||
// and additive blending is what makes a lit satellite read as a light source
|
||
// rather than as a grey sticker.
|
||
blending: THREE.AdditiveBlending,
|
||
/**
|
||
* Satellites are not in the weather.
|
||
*
|
||
* A material that opts into fog gets mixed toward the fog colour by
|
||
* distance, and the city runs a linear fog whose far plane is 2.8 board
|
||
* spans — so the constellation dimmed as the camera pulled back, exactly
|
||
* when more of it came into view. Haze is a property of the twelve
|
||
* kilometres of air a city sits in; an object 550 km up is on the far side of
|
||
* all of it. A `ShaderMaterial` has no fog unless its shader asks, so this is
|
||
* now true by construction rather than by a flag.
|
||
*/
|
||
fog: false,
|
||
});
|
||
|
||
const points = new THREE.Points(geo, material);
|
||
points.name = "satellite-dots";
|
||
points.onBeforeRender = (renderer) => {
|
||
material.uniforms.uPixelRatio!.value = renderer.getPixelRatio();
|
||
};
|
||
// The buffer is rewritten in scene space every update, so its bounding sphere
|
||
// is permanently stale and culling on it would cull the whole sky.
|
||
points.frustumCulled = false;
|
||
group.add(points);
|
||
|
||
const scratch = new THREE.Color();
|
||
|
||
/**
|
||
* The dome is centred on the board's origin and not on the camera.
|
||
*
|
||
* Centring it on the camera would keep every dot at a constant apparent size
|
||
* and would be the right call for a true skybox. It is the wrong call here,
|
||
* because this dome is *anchored to a place*: the look angles were computed for
|
||
* the city centre, so a dot means "from the middle of this board, look there".
|
||
* Following the camera would silently turn a measured direction into a
|
||
* decoration.
|
||
*/
|
||
function place(fix: SatelliteFix, into: THREE.Vector3): void {
|
||
const cosEl = Math.cos(fix.elevation);
|
||
// Azimuth is clockwise from north, and scene north is −Z with +X east —
|
||
// which is exactly `sin` on X and `−cos` on Z, with no sign fudge. The same
|
||
// convention `world.project` uses; see `District.gridAngle` for the other
|
||
// place this rule is stated.
|
||
into.set(
|
||
Math.sin(fix.azimuth) * cosEl * radius,
|
||
Math.sin(fix.elevation) * radius,
|
||
-Math.cos(fix.azimuth) * cosEl * radius,
|
||
);
|
||
}
|
||
|
||
const scratchVec = new THREE.Vector3();
|
||
|
||
/**
|
||
* 1 until somebody says otherwise, so a caller that never calls
|
||
* `setSkyDarkness` gets exactly the behaviour this layer had before it
|
||
* existed. A silent regression to an invisible sky would be worse than the
|
||
* defect being fixed.
|
||
*/
|
||
let skyDarkness = 1;
|
||
/** Whether the godmode switch wants this layer at all. Two questions, two flags. */
|
||
let wanted = true;
|
||
|
||
/** The hour outranks the switch: a god at noon still gets no white squares. */
|
||
function applyVisibility(): void {
|
||
// Below a fiftieth the dots contribute nothing a screen can show, and
|
||
// skipping the draw entirely is what makes the daytime cost of this layer
|
||
// zero rather than merely invisible.
|
||
group.visible = wanted && skyDarkness > 0.02;
|
||
}
|
||
|
||
function update(fixes: SatelliteFix[]): void {
|
||
let n = 0;
|
||
for (const fix of fixes) {
|
||
if (n >= MAX_DOTS) break;
|
||
|
||
const elevationDeg = (fix.elevation * 180) / Math.PI;
|
||
const horizon = Math.min(1, elevationDeg / HORIZON_FADE_DEG);
|
||
if (horizon <= 0) continue;
|
||
|
||
place(fix, scratchVec);
|
||
positions[n * 3] = scratchVec.x;
|
||
positions[n * 3 + 1] = scratchVec.y;
|
||
positions[n * 3 + 2] = scratchVec.z;
|
||
|
||
scratch.copy(GROUP_COLORS[fix.group] ?? GROUP_COLORS.other);
|
||
const lit = 1 - fix.shadow;
|
||
colors[n * 4] = scratch.r;
|
||
colors[n * 4 + 1] = scratch.g;
|
||
colors[n * 4 + 2] = scratch.b;
|
||
colors[n * 4 + 3] = horizon * (SHADOW_ALPHA + (1 - SHADOW_ALPHA) * lit) * skyDarkness;
|
||
sizes[n] = dotPixels(fix);
|
||
n += 1;
|
||
}
|
||
|
||
applyVisibility();
|
||
geo.setDrawRange(0, n);
|
||
positionAttr.needsUpdate = true;
|
||
colorAttr.needsUpdate = true;
|
||
sizeAttr.needsUpdate = true;
|
||
}
|
||
|
||
return {
|
||
group,
|
||
update,
|
||
setSkyDarkness(darkness) {
|
||
skyDarkness = Math.min(1, Math.max(0, darkness));
|
||
applyVisibility();
|
||
},
|
||
/**
|
||
* Hiding the layer stops it drawing and does **not** stop the catalogue
|
||
* propagating, which is the right way round: turning the sky back on should
|
||
* show where things are now, not resume a sweep from wherever it was
|
||
* abandoned and then crawl back into agreement with reality over the next
|
||
* half second. The propagation is two milliseconds a frame; correctness on
|
||
* re-entry is worth more than reclaiming it.
|
||
*/
|
||
setVisible(visible: boolean) {
|
||
wanted = visible;
|
||
applyVisibility();
|
||
},
|
||
dispose() {
|
||
geo.dispose();
|
||
material.dispose();
|
||
},
|
||
};
|
||
}
|