1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/engine/starlinkMesh.ts
T
karti 51979feea0 Shadows land on the building, and the sky layers stop repeating themselves
**Shadows were the right size and pointed at nothing.** Last round fixed the
missing `updateProjectionMatrix()`, so the frustum finally became the size
every caller asks for — but nothing aimed it, and `sun.target` sits at the
world origin. A pack's origin is the **north-west corner of its slab**, so
for lumbridge-hq the box was off-centre by half the building: 14.4 m of a
48 m plate, about a third of the floor, fell outside the frustum and
neither cast nor received. Invisible while three's broken ±5 default made
shadows useless everywhere; obvious the moment they started working.

`SceneKitOptions` takes a `shadowTarget` now, both callers pass one, and
the light's target is added to the scene — which is the part that actually
matters, because `LightShadow.updateMatrices` reads `target.matrixWorld`
and an unparented `Object3D` is never reached by the traversal that
updates it. The sun is also placed relative to the target rather than the
origin, so light-to-target is exactly `sunDistance` for every direction,
which is the invariant each caller's `shadowNear`/`shadowFar` were chosen
against.

**`flights.ts` could not be tested, and that is why it was untested.** It
used a TypeScript parameter property — the one piece of TS syntax that
*emits code* rather than annotating a type — so Node's type stripping
refused the whole module. The bundler never cared, so nobody found out
until the first `node --test` file tried to import it. The module carrying
the worst bug this project has shipped was, by construction, the one
module that could not have a test. It has eleven now, including one that
fails if the live-aircraft repeat-skip is removed.

**Robots are on the plan panel** — a turned marker with a bow for heading,
in the one hue left that is neither the people-blue nor the camera-amber.

Review findings cleared across the four new sky/robot modules: a real
24 mm void at the ankle and an 8 mm hole through each forearm, a
per-frame allocation in the robot heading picker, a per-frame sort in the
starlink ranking, `uTime` growing unbounded until the cloud breath
quantises, and `DAY_REFERENCE`'s derivation which did not reproduce.

`createStarlinkMeshLayer` now takes a **board** radius — the same unit its
sibling takes — instead of a dome radius with nothing in the types to tell
them apart. That is the exact confusion that has already caused one real
bug here. `DOME_RADIUS_FACTOR` has one owner and is imported, not copied:
the points and the meshes must be on the same dome or a satellite that
grows geometry also jumps.

Several comments were wrong rather than merely stale — a fabricated claim
about `Object3D.clone`, a fabricated attribution to `Plan`, an inverted
`DoubleSide` argument, a triangle ledger citing a function that no longer
exists, and a defensive-call rationale that contradicted the paragraph
above it. In a codebase where the comments are the design record, those
are defects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 02:50:25 -07:00

953 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The few satellites you are actually looking at, drawn as satellites.
*
* `satellites.ts` renders the whole catalogue as one `THREE.Points` cloud at a
* fixed 3.5 screen pixels, and that is the right way to draw six thousand
* objects: a dot on a dome is a *direction*, which is the only thing about a
* satellite that survives the projection this engine has to make (read that
* file's header before this one — the argument for the dome is made there and is
* not repeated here). What a dot cannot do is say what the object *is*. Every
* point in that cloud looks like every other point, so a Starlink train reads as
* a line of specks, and the constellation whose entire visual signature is one
* enormous solar panel hanging off one side of a flat box reads as nothing at
* all.
*
* So this layer draws geometry for the handful nearest the middle of the view
* and leaves everything else as points. Two instanced meshes, sixty-four
* instances, two draw calls, and the dots go on being dots underneath.
*
* ### One array, on one side
*
* The shape is the point of the whole file, so it is worth being blunt about
* what it is not. The satellite everybody draws is a cube with two symmetric
* wings — the Hubble/comsat silhouette that has meant "spacecraft" since the
* seventies. A Starlink is not that and has never been that. It is a **flat
* rectangular bus** — flat because sixty of them stack in a fairing like plates,
* which is the design decision the entire constellation is built on — with a
* **single solar array** that unrolls from one edge and is two to three times
* the length of the bus it hangs off. The thing is profoundly lopsided, and that
* asymmetry is what you would recognise if you could see one.
*
* Drawing two symmetric wings here would be worse than drawing nothing, because
* it would be a confident, legible, wrong answer. The whole reason to promote a
* dot to a mesh is to say something true about the object.
*
* ### Deliberately, enormously, not to scale
*
* A Starlink is about ten metres across the deployed array, at a range of
* roughly 550 km. That is 1.8e-5 radians, near enough four arcseconds — at this
* scene's 42° field of view over a thousand-pixel canvas, **a fiftieth of a
* pixel**. There is no honest scale at which this layer draws anything at all.
*
* `satellites.ts` already made the same concession in the other direction: its
* dots are 3.5 px regardless of range, because an object at effectively infinite
* distance has an apparent size set by the eye and not by the geometry. This
* file takes that further and says so plainly. `SPAN_FRACTION` puts the drawn
* satellite at about a degree of arc — twice the moon, roughly twenty pixels,
* some nine hundred times its true angular size. The number was chosen as the
* smallest one at which the array-and-bus silhouette is still readable, and it
* carries exactly as much information as the dome's radius does, which is none.
*
* What *is* true is everything angular. The dome falsifies distance and
* preserves direction, so anything that can be expressed as an angle at the
* observer can still be right — and two of them are:
*
* - **The attitude.** A Starlink flies nadir-pointing, belly to the ground.
* Seen from here that is not the same as "belly toward the observer" except
* when it is directly overhead; at the horizon the nadir direction is 67°
* off the line of sight and you are looking at the thing nearly edge-on.
* `nadirOf` works that angle out from the range the fix already carries, so
* one overhead shows you its antenna face and one low in the north-west is
* a foreshortened sliver. That difference is free and it is real.
* - **The phase.** A satellite has phases for the same reason the moon does,
* and it is why a Starlink pass is a dusk-and-dawn event rather than a
* midnight one: at local midnight the object is in the earth's shadow, and
* at noon the sun is *behind* it from here and you are looking at its dark
* side. `phase` below is that geometry, and the array brightening as it
* approaches full is the flare people photograph.
*
* ### What this layer does not do
*
* It does not tell `satellites.ts` to stop drawing dots for the objects it has
* promoted, and it should not: the dot lands dead centre on the bus, is 3.5 px
* across against a mesh twenty px across, and reads as the specular glint off
* the chassis. Suppressing it would cost a coupling between two layers to make
* the picture slightly worse.
*/
import * as THREE from "three";
/**
* The dome radius factor is imported, never restated.
*
* The points and the meshes have to be on the **same** dome — a satellite that
* grows geometry must not also jump — and the only way for two modules to agree
* on a number is for one of them not to have a copy of it. `satellites.ts` owns
* the dome; this multiplies by what it says.
*/
import { DOME_RADIUS_FACTOR } from "./satellites.ts";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import type { SatelliteFix } from "./satellites.ts";
const RAD = 180 / Math.PI;
/**
* The dome factor, restated — and applied *here*, which is now the whole point
* of it.
*
* `satellites.ts` owns `DOME_RADIUS_FACTOR` and exports it, and the meshes have
* to land on **exactly** the shell the dots are on — not a similar one. Put them
* on different radii and the two layers agree only when the camera is at the
* scene origin; anywhere else the mesh separates from its own dot by parallax,
* which reads as a rendering fault rather than as a rounding error.
*
* This used to be exported so that `scene.ts` could do the multiplication on the
* way in, on the theory that a shared constant is what makes two layers agree.
* It is not: `createSatelliteLayer` takes a board radius, this layer took a
* *dome* radius, both are a bare `number`, and the only thing standing between
* the two units was a caller remembering which of them it was holding. That is
* the same shape of mistake that once put this constellation's dome *inside its
* own city* — a radius mistaken for a span, recorded at length in
* `DOME_RADIUS_FACTOR`'s note in `satellites.ts` — and it cost a rendering-bug
* hunt to find, because a sky on the wrong radius still looks like a sky. Now both
* entry points take the board radius and each multiplies for itself, so there is
* no unit to get wrong at the call site and no reason for anything outside this
* file to know this number exists.
*
* It is still a duplicate and still meant to stop being one: export the constant
* from `satellites.ts`, import it here, and delete this declaration. Until
* somebody does, the two have to be changed together.
*/
/**
* Ceiling on meshes, and the reason the layer is affordable at all.
*
* Sixty-four is well past what is ever in shot at once — a busy Starlink sky
* over one city is a few hundred objects spread over the whole hemisphere, of
* which the selection cone below takes maybe a dozen — so in practice the cap
* never binds and exists to bound the buffers. Both instance buffers are
* allocated once at this size and `count` is moved, which is the cheap
* operation; growing an `InstancedMesh` means building a new one.
*/
const MAX_MESHES = 64;
/**
* The selection cone, in degrees off the camera's own axis: full size inside
* `SELECT_FULL_DEG`, gone by `SELECT_EDGE_DEG`.
*
* Off the view *centre* rather than merely on screen, and that is the whole
* selection rule: the geometry should be where the user is looking. The scene's
* field of view is 42° vertical, so 12° is the middle third of the frame at full
* size and 26° reaches into the corners — a satellite drifting in from the edge
* has most of the screen width to grow across.
*
* Ranking by this angle rather than by range is deliberate. Every object on the
* dome is at the same radius, so range sorts by where the *camera* is and not by
* what it is aimed at, and an orbiting camera would see the meshes migrate
* around the sky for no reason the user could name.
*/
const SELECT_FULL_DEG = 12;
const SELECT_EDGE_DEG = 26;
/**
* How far from the camera a mesh survives, as a multiple of the dome radius.
*
* This is the other half of the fade, and it exists because the camera can get
* outside the dome — `scene.ts` puts `maxDistance` at 2.0 board spans against a
* dome at about 0.99, precisely so the constellation can be looked at from
* above. From out there the near side of the dome is about one radius away and
* still worth drawing; the far side is three, where a twenty-pixel satellite has
* become four pixels of noise sitting on top of a dot that says the same thing
* more clearly. So the far side goes back to being points.
*
* At the other end of the zoom the camera is near the origin, every point on the
* dome is one radius away, and this term is a constant 1 — it never interferes
* with the case it is not there for.
*/
const RANGE_FULL = 1.15;
const RANGE_EDGE = 2.2;
/**
* How many slots at the tail of the ranked list fade out, when the cap binds.
*
* Belt and braces. The cone fade already means the objects nearest the cut are
* the ones nearest the cone's edge and therefore already small — but that is a
* statement about a *typical* sky, and a genuinely dense cone would put sixty-
* fourth place somewhere near the middle of the screen at full size, popping in
* and out as the ordering churned. Applied only when there are more candidates
* than slots, so a sparse sky never sees it.
*/
const RANK_FADE_SLOTS = 8;
/**
* Tip-to-tip size of a drawn satellite, as a fraction of the dome radius.
*
* Since the dome radius is also roughly how far away these things are, this is
* very nearly the angular size in radians: 0.016 rad is 0.92°, about twenty
* pixels at this field of view. See the header for why that is nine hundred
* times too big and why the alternative is a layer that renders nothing.
*/
const SPAN_FRACTION = 0.016;
/**
* Elevation below which a satellite is not promoted, in degrees.
*
* The same number as `HORIZON_FADE_DEG` in `satellites.ts` and for the same
* reason — an object a degree up is behind the hills and behind more air than it
* can be seen through — restated because that constant is private too. It has to
* agree with the dot layer's or the mesh would fade in over a dot that was
* fading out.
*/
const HORIZON_FADE_DEG = 8;
/**
* Brightness of a satellite whose lit side is facing entirely away, relative to
* one at full phase.
*
* Not zero, for the reason `satellites.ts` gives for `SHADOW_ALPHA`: the
* physically honest answer is that you cannot see it, and a layer that draws
* nothing at noon reads as broken rather than as correct. Higher than that
* file's 0.16 because a shape has to be legible to be a shape, where a dot only
* has to be present.
*/
const PHASE_FLOOR = 0.42;
/**
* Brightness in the earth's umbra. Deliberately `SHADOW_ALPHA` from
* `satellites.ts`, so that a satellite entering eclipse dims by the same factor
* whether it is currently a dot or a mesh — the moment the two layers disagree
* about that is the moment a mesh crossing the terminator visibly steps.
*/
const ECLIPSE_FLOOR = 0.16;
/**
* The bus. Pale because it is: white thermal blanket and bare aluminium, which
* is the brightest thing on the spacecraft and most of what a naked-eye pass
* actually is.
*/
const BUS_COLOR = new THREE.Color(0xd7dde6);
/**
* The array, unlit and lit.
*
* Solar cells are the *darkest* part of any spacecraft — they are built to
* absorb, and they reflect under a tenth of what hits them — so the array is a
* near-black silhouette against a daylit sky, which is exactly the read the
* asymmetry needs. `ARRAY_GLINT` is the other half of the same fact: at high
* phase the cover glass throws a specular sheet back at the observer and the
* panel flares steely blue. Interpolated on the cube of the phase so the flare
* happens in the last part of the approach to full and not gradually across it.
*/
const ARRAY_COLOR = new THREE.Color(0x121a2c);
const ARRAY_GLINT = new THREE.Color(0x9db4d6);
/**
* The spacecraft, in metres of real spacecraft.
*
* Built at true proportions and shrunk by exactly one number (`scale`, below),
* so the lie about size lives in one place and the shape stays honest. Roughly
* three metres of bus against eight of array is the ratio that matters; the
* absolute figures are approximate and nothing downstream reads them as fact.
*
* Axes are the local frame every matrix below is built in: **+X is the boom**,
* along which the array deploys, **+Y is zenith** so that Y is the nadir face
* carrying the phased array, and +Z is what is left over.
*/
const BUS_LENGTH = 3.2;
const BUS_DEPTH = 1.6;
const BUS_THICK = 0.28;
const ARRAY_LENGTH = 8.4;
const ARRAY_WIDTH = 1.5;
const ARRAY_THICK = 0.06;
const BOOM_GAP = 0.6;
const BOOM_RADIUS = 0.08;
/** Centre of the array, in the same frame. It hangs off +X and only +X. */
const ARRAY_CENTRE_X = BUS_LENGTH / 2 + BOOM_GAP + ARRAY_LENGTH / 2;
/** Tip of the array to the far edge of the bus — what `SPAN_FRACTION` scales. */
const MODEL_SPAN = BUS_LENGTH + BOOM_GAP + ARRAY_LENGTH;
/**
* Sentinel for an unused candidate slot. Finite rather than `Infinity`, so a
* slot that ever did reach the ranking would sort to the back of it instead of
* poisoning an arithmetic comparison.
*/
const UNUSED_SCORE = 1e9;
/** Earth's mean radius, for the nadir angle. Sphere is plenty at one degree. */
const EARTH_RADIUS_KM = 6371;
/**
* A direction *toward* the sun in the engine's axes — exactly what
* `solar.ts`'s `sunDirection` returns, and structurally a `THREE.Vector3`, so a
* caller holding either can pass it straight in.
*/
export interface SunVector {
readonly x: number;
readonly y: number;
readonly z: number;
}
/**
* What the layer needs to exist, which is one number — passed as a *named* field
* and not as a positional argument, deliberately.
*
* The number is `scene.ts`'s `boardRadius`: how far the board reaches from the
* scene origin, exactly as `createSatelliteLayer` takes it, so the dots and the
* meshes are derived from one measurement by one constant. Two radii are in play
* inside this file and they differ by 5% — small enough that a mesh on the wrong
* one still draws, still looks like a satellite, and only separates from its own
* dot once the camera leaves the origin, which is the kind of bug that survives
* a screenshot. A positional `number` cannot tell the two apart. A field named
* `boardRadius` can, and a call site that was passing the other one stops
* compiling instead of quietly drawing a second, slightly larger sky.
*/
export interface StarlinkMeshOptions {
/** How far the board reaches from the scene origin. Not the board's width. */
readonly boardRadius: number;
}
export interface StarlinkMeshLayer {
group: THREE.Group;
/**
* Redraw from the same fix list `SatelliteLayer.update` is given.
*
* The camera is a parameter rather than something the layer remembers because
* the selection is a function of where it is aimed *this frame*, and the sun
* is a parameter for the same reason `SatelliteCatalogue.fixes` takes a
* `when`: godmode scrubs the clock, and a layer that quietly called
* `solarPosition(new Date())` would be the one thing in the scene still
* pointing its solar panels at yesterday afternoon.
*/
update(fixes: readonly SatelliteFix[], camera: THREE.Camera, sun: SunVector): void;
setVisible(visible: boolean): void;
dispose(): void;
}
/**
* One satellite that got through the filters, with everything the ranking and
* the draw need.
*
* These are pooled and reused rather than built per frame. `update` runs at 60
* Hz over a few hundred fixes, and a few hundred short-lived objects a frame is
* twenty thousand a second of pure garbage for a layer whose entire job is to
* be cheap enough to leave on.
*/
interface Candidate {
fix: SatelliteFix | null;
/** Degrees off the camera's axis. Lower ranks first; see `rankBest`. */
score: number;
/** 0 to 1. Drives the scale, which is how a mesh grows out of its own dot. */
fade: number;
/** Where on the dome it sits, in scene space. */
readonly at: THREE.Vector3;
}
export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkMeshLayer {
const group = new THREE.Group();
group.name = "starlink-meshes";
// The shell everything below is placed on and scaled against. Computed once,
// from the board radius, by the same constant `satellites.ts` uses on the same
// input — which is the whole of the agreement between the two layers.
const domeRadius = options.boardRadius * DOME_RADIUS_FACTOR;
const busGeometry = buildBus();
const arrayGeometry = buildArray();
/**
* `MeshBasicMaterial`, and the scene's lights are deliberately ignored.
*
* This is the one decision here that looks like laziness and is not. A
* `MeshLambertMaterial` would be lit by the city's rig — and that rig is a
* model of the light *at the ground*, which after sunset is a tenth of an
* intensity with the sun pushed below the horizon. A Starlink is visible
* precisely when the ground is dark and the satellite is not, so shading these
* with the city's sun would black out the constellation at exactly the hour it
* exists to be looked at, and light it in the middle of the day when it cannot
* be seen at all. Backwards in both directions.
*
* So the shading is computed per instance on the CPU — the phase term in
* `update` — and written into `instanceColor`, which a basic material
* multiplies straight into its diffuse. It costs one dot product per drawn
* satellite, of which there are at most sixty-four, and it is the only shading
* model in this file that has the satellite's own geometry to work from rather
* than the city's.
*
* `fog: false` for the reason `satellites.ts` states for its points and which
* is, if anything, stronger for a solid: the city's linear fog reaches its far
* plane at 2.8 board spans, so a mesh out on the dome would be mixed most of
* the way to the fog colour and the constellation would dim as the camera
* pulled back, exactly as more of it came into view. Haze belongs to the
* twelve kilometres of air a city sits in. This is 550 km above all of it.
*/
const busMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, fog: false });
const arrayMaterial = new THREE.MeshBasicMaterial({
color: 0xffffff,
fog: false,
/**
* Culling off. Not extra geometry — `side` is a rasteriser state and emits
* no triangles at all, so the arithmetic this comment used to carry ("a
* hundred and forty-four extra triangles across the whole layer") was
* measuring something that does not exist. What it costs is fill: the far
* faces of a box that would otherwise have been discarded before shading.
* For sixty-four objects twenty pixels across, under a `MeshBasicMaterial`
* that shades both faces the same flat instance colour, that is unmeasurable
* and invisible in both directions.
*
* Which is the honest status of this flag today: `buildArray` returns a
* closed `BoxGeometry`, and a closed body never shows its interior whether
* you cull or not. It is here for the case that geometry is one refactor
* from becoming — the panel is two centimetres thick on eight metres and the
* standing temptation is to demote it to a plane, at which point a
* front-side material makes it *vanish* for the half of every orbit it is
* turned away from you, which is exactly the half where its edge is the only
* thing telling you the satellite is not a dot. Keeping the flag costs
* nothing and removes the trap. Do not delete it because the box makes it
* redundant; delete it only along with the box.
*/
side: THREE.DoubleSide,
});
const bus = new THREE.InstancedMesh(busGeometry, busMaterial, MAX_MESHES);
const array = new THREE.InstancedMesh(arrayGeometry, arrayMaterial, MAX_MESHES);
bus.name = "starlink-bus";
array.name = "starlink-array";
for (const mesh of [bus, array]) {
/**
* `InstancedMesh` culls on a bounding sphere it computes **once** from the
* instance matrices and then caches. Every matrix here is rewritten every
* frame from a different set of satellites, so that sphere is stale from the
* second frame onward and culling on it would cull the layer at random. The
* cost of not culling is two draw calls that were going to happen anyway.
*/
mesh.frustumCulled = false;
mesh.count = 0;
group.add(mesh);
}
/**
* Metres of spacecraft to scene units. The one place the size lie is told.
*/
const scale = (SPAN_FRACTION * domeRadius) / MODEL_SPAN;
const pool: Candidate[] = [];
/**
* This frame's best `MAX_MESHES` candidates, ascending by score — references
* into `pool`, never copies. Allocated once here; `rankBest` refills the front
* of it every frame and nothing ever reads past what that returns.
*/
const ranked: (Candidate | undefined)[] = new Array<Candidate | undefined>(MAX_MESHES);
// Scratch, all of it. Once the pool has reached its high-water mark — a second
// or two after the first pass rises — `update` allocates nothing whatever, and
// that claim is only true because the ranking is `rankBest` and not
// `pool.sort`: V8's sort copies the array into a work buffer on every call, so
// a comparator-based sort of a few hundred entries is a few hundred words of
// garbage sixty times a second, from the one layer whose entire argument for
// existing is that it is cheap enough to leave on.
const eye = new THREE.Vector3();
const forward = new THREE.Vector3();
const sunDir = new THREE.Vector3();
const toSat = new THREE.Vector3();
const radial = new THREE.Vector3();
const nadir = new THREE.Vector3();
const zenith = new THREE.Vector3();
const boom = new THREE.Vector3();
const third = new THREE.Vector3();
const perpendicular = new THREE.Vector3();
const scaleVec = new THREE.Vector3();
const busMatrix = new THREE.Matrix4();
const arrayMatrix = new THREE.Matrix4();
const hinge = new THREE.Matrix4();
const tint = new THREE.Color();
function slot(index: number): Candidate {
const existing = pool[index];
if (existing !== undefined) return existing;
const made: Candidate = { fix: null, score: UNUSED_SCORE, fade: 0, at: new THREE.Vector3() };
pool.push(made);
return made;
}
/**
* Fill `ranked` with the lowest-scoring `min(found, MAX_MESHES)` of
* `pool[0..found)`, ascending, and return how many that was.
*
* A bounded insertion rather than a sort, for two reasons and not for speed on
* a typical sky. The first is the allocation above. The second is that a sort
* answers a question nobody asked: the draw loop reads the first `MAX_MESHES`
* entries and the rest is work whose result is thrown away, which on a dense
* pass over a Starlink train is most of the list. This walks the candidates
* once, rejects anything worse than the current sixty-fourth on a single
* compare, and only pays the shift when a candidate genuinely belongs in the
* window — so the cost tracks the size of the *window*, which is fixed, rather
* than the size of the sky, which is not.
*
* The worst case is a pool of exactly `MAX_MESHES` arriving in descending
* order, which is a full insertion sort: about two thousand pointer writes on
* a 64-entry array, once a frame, and still no allocation. The best case — the
* ordinary one, a dozen objects near the view centre — is a dozen compares.
*
* Order matters within the window as well as at its edge: `rank` in the draw
* loop fades the last few slots out, so "sixty-fourth" has to mean the
* sixty-fourth *best* and not merely one of the sixty-four.
*/
function rankBest(found: number): number {
const keep = Math.min(found, MAX_MESHES);
let held = 0;
for (let i = 0; i < found; i++) {
const candidate = pool[i];
if (candidate === undefined) continue;
if (held === keep) {
const worst = ranked[keep - 1];
if (worst !== undefined && candidate.score >= worst.score) continue;
// The one being displaced falls off the end of the window; dropping the
// count here is what keeps the shift below in bounds.
held -= 1;
}
let j = held;
while (j > 0) {
const above = ranked[j - 1];
if (above !== undefined && above.score <= candidate.score) break;
ranked[j] = above;
j -= 1;
}
ranked[j] = candidate;
held += 1;
}
return held;
}
/**
* Azimuth and elevation to a point on the dome.
*
* The same arithmetic as `satellites.ts`'s own `place`, restated because it is
* a closure in there, and it must stay identical: azimuth is clockwise from
* north, scene north is Z and east is +X, which is `sin` on X and `cos` on Z
* with no sign fudge anywhere. Get it wrong and the meshes are a mirror image
* of the dots they are supposed to be sitting on.
*/
function place(fix: SatelliteFix, into: THREE.Vector3): void {
const cosEl = Math.cos(fix.elevation);
into.set(
Math.sin(fix.azimuth) * cosEl * domeRadius,
Math.sin(fix.elevation) * domeRadius,
-Math.cos(fix.azimuth) * cosEl * domeRadius,
);
}
/**
* Which way is down, from the satellite's point of view, expressed as a
* direction in the observer's sky.
*
* Not `-radial`. That would be "point the belly at the middle of the board",
* which is right for a satellite at the zenith and increasingly wrong as it
* descends: the spacecraft's nadir points at the *earth's centre*, and the
* observer is not the earth's centre. The angle between the two — the nadir
* angle η, the same one a ground station's link budget is written in — grows
* to about 67° at the horizon for a 550 km orbit, which is the difference
* between seeing the antenna face and seeing the edge of the chassis.
*
* It falls out of the triangle centreobserversatellite with no new inputs,
* because the fix already carries the range. With Re the earth's radius, r the
* slant range and e the elevation, the satellite's geocentric radius is
*
* Rs² = Re² + r² + 2·Re·r·sin e
*
* (law of cosines, the interior angle at the observer being 90° + e), and then
* the law of sines gives sin η = Re·cos e / Rs directly. At e = 0 and 550 km
* that is 6371/6921 = 0.92, so η = 67°; at the zenith it is 0 and the belly
* genuinely does point at the observer.
*
* The rotation is in the vertical plane through the satellite, tilted from the
* line of sight *downward* — away from the zenith — because the sub-satellite
* point is further from the observer than the observer is from themselves. The
* cheap check: at e = 45° over the north this returns very nearly straight
* down with a slight lean back toward the south, which is where the ground
* under the satellite is relative to the ground under the viewer.
*/
function nadirOf(fix: SatelliteFix, up: THREE.Vector3, out: THREE.Vector3): void {
const rs = Math.sqrt(
EARTH_RADIUS_KM ** 2 +
fix.rangeKm ** 2 +
2 * EARTH_RADIUS_KM * fix.rangeKm * Math.sin(fix.elevation),
);
const eta =
rs > 0 ? Math.asin(clamp((EARTH_RADIUS_KM * Math.cos(fix.elevation)) / rs, 0, 1)) : 0;
// The line of sight, satellite to observer.
out.copy(up).negate();
// The downward-pointing unit vector perpendicular to it, in the vertical
// plane: Y with its component along the line of sight projected out.
perpendicular.set(0, -1, 0).addScaledVector(out, out.y);
const length = perpendicular.length();
// Zero only when the line of sight is itself vertical — the satellite is at
// the zenith — where η is zero as well and the answer is already correct.
if (length < 1e-6) return;
perpendicular.divideScalar(length);
out.multiplyScalar(Math.cos(eta)).addScaledVector(perpendicular, Math.sin(eta)).normalize();
}
function update(fixes: readonly SatelliteFix[], camera: THREE.Camera, sun: SunVector): void {
if (!group.visible) return;
/**
* Both of these call `updateWorldMatrix` on the way through, which matters:
* the renderer updates the world matrices during `render`, so a layer
* ticked before it is looking at last frame's camera. One frame of lag in a
* *position* is invisible; one frame of lag in a selection rule means the
* meshes trail the aim during an orbit, which is the artefact this layer
* would be blamed for.
*/
camera.getWorldPosition(eye);
camera.getWorldDirection(forward);
sunDir.set(sun.x, sun.y, sun.z);
// A zero sun direction has no meaning and would make every basis below
// degenerate. Straight up is arbitrary and keeps the geometry well-formed.
if (sunDir.lengthSq() < 1e-12) sunDir.set(0, 1, 0);
else sunDir.normalize();
let found = 0;
for (const fix of fixes) {
/**
* Starlink only, and the file is named for it.
*
* This shape is a specific spacecraft, not a generic satellite: a GPS bird
* is a drum with two wings and the ISS is neither. Drawing a Galileo
* satellite with a Starlink's single unrolled array would be the same
* error as the two-symmetric-wings clip-art, only pointed the other way.
* Every other group stays a dot, which claims nothing.
*/
if (fix.group !== "starlink") continue;
// `> 0` rather than `>= 0` and written to fail on NaN, for the reason
// `SatelliteCatalogue.fixOne` gives: a degenerate element set produces NaN
// look angles, and a NaN in an instance matrix takes out the whole
// instanced draw rather than one satellite.
const elevationDeg = fix.elevation * RAD;
if (!(elevationDeg > 0)) continue;
const horizon = Math.min(1, elevationDeg / HORIZON_FADE_DEG);
const candidate = slot(found);
place(fix, candidate.at);
toSat.subVectors(candidate.at, eye);
const distance = toSat.length();
// The camera standing exactly on a satellite has no direction to it. It
// cannot happen from any reachable pose; it costs one compare to make sure
// it cannot produce a NaN either.
if (distance < 1e-6) continue;
const offDeg = Math.acos(clamp(toSat.dot(forward) / distance, -1, 1)) * RAD;
const aim = falloff(offDeg, SELECT_FULL_DEG, SELECT_EDGE_DEG);
if (aim <= 0) continue;
const range = falloff(distance / domeRadius, RANGE_FULL, RANGE_EDGE);
if (range <= 0) continue;
candidate.fade = horizon * aim * range;
candidate.score = offDeg;
candidate.fix = fix;
found += 1;
}
// Release the tail of the pool. Nothing reads past `found` any more — the
// ranking walks `[0, found)` and the pool is never reordered — so this is no
// longer load-bearing for the selection; it is here so that a slot left over
// from a busy pass does not keep last frame's `SatelliteFix` alive for the
// lifetime of the layer. The objects themselves are kept, as always: only
// their claim on a slot is dropped.
for (let i = found; i < pool.length; i++) {
const stale = pool[i];
if (stale !== undefined) {
stale.fix = null;
stale.score = UNUSED_SCORE;
}
}
const drawn = rankBest(found);
for (let i = 0; i < drawn; i++) {
const candidate = ranked[i];
const fix = candidate?.fix;
if (candidate === undefined || !fix) continue;
radial.copy(candidate.at).normalize();
nadirOf(fix, radial, nadir);
zenith.copy(nadir).negate();
/**
* Yaw steering, which is what the real spacecraft does and what makes one
* hinge sufficient.
*
* The array has a single degree of freedom — it rotates about the boom —
* so it can only face the sun if the boom is perpendicular to the sun to
* begin with. A real satellite achieves that by rotating its whole body
* about the nadir axis as it goes round the orbit, which costs it nothing
* because nadir-pointing leaves that rotation free. Choosing the boom as
* `zenith × sun` is exactly that manoeuvre, solved in closed form: it is
* perpendicular to the nadir axis, so the bus is still belly-down, and
* perpendicular to the sun, so the hinge below can then aim the panel
* dead-on rather than approximately.
*
* The cross product collapses only when the sun is straight up from the
* satellite — the subsolar point — where the hinge angle comes out zero
* and any perpendicular gives the right answer anyway, which is why the
* fallback can be arbitrary.
*/
boom.crossVectors(zenith, sunDir);
if (boom.lengthSq() < 1e-8) anyPerpendicular(zenith, boom);
boom.normalize();
third.crossVectors(boom, zenith);
/**
* The hinge. The array's face is local +Y, so after a rotation of θ about
* the boom it points along cos θ · zenith + sin θ · third, and the θ that
* lands it on the sun is the arctangent of the sun's components in that
* plane. Because the boom was chosen perpendicular to the sun, the sun has
* no component outside the plane and this is exact rather than nearest.
*/
const theta = Math.atan2(sunDir.dot(third), sunDir.dot(zenith));
/**
* The fade is a *scale*, not an opacity, and that is what makes the
* transition from point to mesh invisible.
*
* Opacity was the obvious version and is worse in three ways: a standard
* material has no per-instance alpha, so it would have taken a shader
* patch; transparency would have forced `depthWrite: false` and let the
* bus and the array punch holes in each other; and a half-transparent
* satellite over a half-bright dot is a muddier picture than either. A
* mesh scaled to a fifth is *smaller than the dot it is standing on* and
* simply hides inside it, so the object grows out of its own point and
* shrinks back into it. `falloff` is a smoothstep, so the size ramp has
* zero derivative at both ends and there is no moment where it starts.
*
* `rank` is the same trick applied to the cap rather than to the cone:
* the last few slots of a list that has run out of room shrink away, so
* the object bumped by the sixty-fifth arrival was already tiny when it
* went. It is 1 whenever the cap is not binding, which is nearly always.
*/
const rank =
found > MAX_MESHES ? clamp((MAX_MESHES - i) / RANK_FADE_SLOTS, 0, 1) : 1;
scaleVec.setScalar(scale * candidate.fade * rank);
busMatrix.makeBasis(boom, zenith, third).scale(scaleVec).setPosition(candidate.at);
bus.setMatrixAt(i, busMatrix);
/**
* The array rides the same origin and basis as the bus with the hinge
* rotation inserted, and its offset down the boom is baked into its
* geometry rather than into this matrix — which is why rotating about the
* boom pivots the panel about the hinge instead of swinging it around the
* bus. Same position, same scale, one extra rotation.
*/
hinge.makeRotationX(theta);
arrayMatrix
.makeBasis(boom, zenith, third)
.multiply(hinge)
.scale(scaleVec)
.setPosition(candidate.at);
array.setMatrixAt(i, arrayMatrix);
/**
* Phase, exactly as for the moon: how much of the lit side is turned this
* way. `radial` is the satellite's position on the dome normalised, and
* the dome is centred on the **observer** — so it points from the observer
* to the satellite, the line of sight outward, and `radial` is the
* direction from the satellite back to the observer exactly rather than
* approximately. (It is emphatically *not* the geocentric radial, the
* earth's centre to the satellite: those two differ by the nadir angle η,
* which reaches 67° at the horizon and is the entire subject of `nadirOf`
* above. Using one where the other belongs is how the attitude and the
* phase would end up disagreeing about where the satellite is.)
*
* Its dot with the sun is the cosine of the phase angle. Positive when the sun
* is below the observer's horizon and the object is still in daylight,
* which is the entire observing window for a Starlink pass; zero at noon,
* when the sun is behind it from here and the side facing down is the side
* in shadow.
*
* (The sun's direction from 550 km up differs from its direction at the
* ground by about a thousandth of a degree, so the scene's own vector is
* used without correction.)
*/
const phase = clamp(-radial.dot(sunDir), 0, 1);
const lit = ECLIPSE_FLOOR + (1 - ECLIPSE_FLOOR) * (1 - clamp(fix.shadow, 0, 1));
const facing = PHASE_FLOOR + (1 - PHASE_FLOOR) * phase;
tint.copy(BUS_COLOR).multiplyScalar(facing * lit);
bus.setColorAt(i, tint);
tint.copy(ARRAY_COLOR).lerp(ARRAY_GLINT, phase ** 3).multiplyScalar(lit);
array.setColorAt(i, tint);
}
bus.count = drawn;
array.count = drawn;
bus.instanceMatrix.needsUpdate = true;
array.instanceMatrix.needsUpdate = true;
// Allocated lazily by the first `setColorAt`, which on a sky with nothing
// above the horizon has not happened yet.
if (bus.instanceColor) bus.instanceColor.needsUpdate = true;
if (array.instanceColor) array.instanceColor.needsUpdate = true;
}
return {
group,
update,
/**
* Unlike `SatelliteLayer.setVisible`, this one also stops the work — see the
* early return in `update`. The distinction is not an inconsistency: that
* layer keeps propagating while hidden because its state is a *sweep* that
* would otherwise resume half a catalogue behind reality. This layer holds
* no state between frames at all, so a hidden one has nothing to catch up
* on and the next visible frame is complete.
*/
setVisible(visible: boolean) {
group.visible = visible;
},
dispose() {
// The instanced meshes first. `InstancedMesh.dispose()` releases the
// per-instance matrix and colour buffers, which are the layer's own
// allocation and are not reached by disposing the geometry they wrap —
// two `Float32Array`s of 64 instances each, orphaned on the GL context on
// every city switch until this line existed.
bus.dispose();
array.dispose();
busGeometry.dispose();
arrayGeometry.dispose();
busMaterial.dispose();
arrayMaterial.dispose();
group.clear();
},
};
}
/**
* The bus: a flat slab, the phased-array antenna stepped out of its underside,
* and the boom stub the panel deploys along.
*
* The antenna step is worth its twelve triangles — it is a `BoxGeometry`, and a
* box is twelve however thin it is drawn; the four this comment used to claim
* were the count of the one face you can see — because the slab alone is a
* shape with no side to it, and the whole read of "belly pointing down" comes
* from being able to see which face is which at an oblique angle. That puts the
* bus at 48 triangles (12 chassis, 12 antenna, 24 for the six-sided capped stub)
* against the array's 12, so a drawn satellite is 60 and the whole layer at its
* sixty-four-instance ceiling is 3,840. The boom is in the
* bus rather than the array partly because it is structure rather than panel and
* takes the pale material, and partly because a cylinder lying along the hinge
* axis is invariant under the hinge rotation, so it looks identical either way
* and this way it costs no second matrix.
*/
function buildBus(): THREE.BufferGeometry {
const chassis = new THREE.BoxGeometry(BUS_LENGTH, BUS_THICK, BUS_DEPTH);
const antenna = new THREE.BoxGeometry(BUS_LENGTH * 0.78, BUS_THICK * 0.45, BUS_DEPTH * 0.72);
antenna.translate(0, -BUS_THICK * 0.6, 0);
const stub = new THREE.CylinderGeometry(BOOM_RADIUS, BOOM_RADIUS, BOOM_GAP * 1.4, 6);
// `CylinderGeometry` runs along +Y; the boom runs along +X.
stub.rotateZ(Math.PI / 2);
stub.translate(BUS_LENGTH / 2 + BOOM_GAP / 2, 0, 0);
const parts = [chassis, antenna, stub];
const merged = mergeGeometries(parts);
for (const part of parts) part.dispose();
if (merged) return merged;
// The same non-null dance as `aircraftGeometry.ts`'s `airlinerGeometry` — it
// was `flights.ts`'s `dartGeometry` when this was written, and that function no
// longer exists — for the same reason: three primitives out of the same
// library cannot disagree about their attributes, the signature permits it
// anyway, and a plain slab is a better failure than a missing layer.
return new THREE.BoxGeometry(BUS_LENGTH, BUS_THICK, BUS_DEPTH);
}
/**
* The array: one panel, on one side, offset down the boom in its own geometry so
* that the instance matrix can be a pure rotation about the hinge.
*
* A box rather than a plane. A plane would halve the triangles and is the
* obvious choice for something two centimetres thick at eight metres long — but
* the edge is what you see during the part of the orbit where the panel is
* turned away from you, and a zero-thickness panel vanishes completely at
* exactly that moment. Six centimetres of scene-space thickness is a fiction in
* the same way the overall size is, and it buys a silhouette that never
* disappears.
*/
function buildArray(): THREE.BufferGeometry {
const panel = new THREE.BoxGeometry(ARRAY_LENGTH, ARRAY_THICK, ARRAY_WIDTH);
panel.translate(ARRAY_CENTRE_X, 0, 0);
return panel;
}
/**
* 1 at or below `full`, 0 at or above `edge`, smoothstepped between — so both
* ends of every ramp in this file arrive with zero slope, which is the whole
* anti-pop argument in one function.
*/
function falloff(x: number, full: number, edge: number): number {
if (x <= full) return 1;
if (x >= edge) return 0;
const t = (x - full) / (edge - full);
return 1 - t * t * (3 - 2 * t);
}
/**
* Any unit vector perpendicular to `v`, for the one degenerate case where the
* caller genuinely does not care which. Crossed against whichever world axis `v`
* is least aligned with, because crossing against a near-parallel axis is how a
* "just pick one" helper returns a zero vector.
*/
function anyPerpendicular(v: THREE.Vector3, out: THREE.Vector3): void {
if (Math.abs(v.y) < 0.9) out.set(0, 1, 0).cross(v);
else out.set(1, 0, 0).cross(v);
out.normalize();
}
function clamp(x: number, lo: number, hi: number): number {
return x < lo ? lo : x > hi ? hi : x;
}
/**
* ---- Numbers a reviewer can check without running anything -----------------
*
* On the Bay Area board `boardRadius` is about 0.94 of a 1,003-unit span, so the
* dome is at 990 units and a drawn satellite is 15.8 of them tip to tip — about
* one and a half kilometres of city, at a
* range of roughly 1,000 units, which is 0.92° of arc or some twenty pixels of a
* 1,000-pixel canvas at this scene's 42° field of view.
*
* `nadirOf` at 550 km, checked against the numbers in its own derivation:
*
* elevation 90° range 550 km Rs 6921 η 0.0° belly at the observer
* elevation 45° range 749 km Rs 6921 η 40.6° belly nearly straight
* down, leaning back
* toward the observer
* elevation 20° range 1,294 km Rs 6921 η 59.9°
* elevation 0° range 2,704 km Rs 6921 η 67.0° seen edge-on
*
* Over the north those come out as nadir vectors of (0, 1, 0), (0, 0.997,
* 0.077), (0, 0.984, 0.176) and (0, 0.921, 0.391) — the lean being southward,
* back over the observer, and reaching 23° off vertical at the horizon.
*
* The orientation as a whole holds two invariants that are worth asserting if
* this ever grows a test: the bus's local Y lands exactly on the nadir vector
* (dot 1.0000), and the array's face lands exactly on the sun (dot 1.0000) for
* every azimuth, elevation and sun position, including the subsolar degeneracy
* where the boom has to be guessed. The basis is right-handed throughout
* (determinant +1), so nothing is drawn inside out.
*
* `phase` for a satellite at the zenith is `sin(sun elevation)`: 0 with the sun
* anywhere above the horizon, 0.5 with it 30° down, 1 at solar midnight — at
* which point the same satellite is in the earth's shadow and `lit` has taken it
* to 0.16 anyway. The band where a Starlink is both at high phase and out of
* eclipse is the hour or so after sunset and before sunrise, which is when
* anybody has ever seen one.
*/