1
0

The office keeps its lights on, and something walks around under them

**Lights.** A sited office follows the real sun, and the real sun spends
half its time below the horizon — which was producing a technically
correct and completely useless picture: an unlit floor plate at midnight
in a building whose whole premise is that you can see who is at which
desk. `luminaires.ts` brings the diffusers up as the sun goes down and
reports one scalar for how much interior light there is; `withHouseLights`
adds it to the rig. CONTRACT §4's rule that a fitting emits no light is
kept in full — nothing here is a light source, and the rig still has one
owner.

**And they notice you.** A fitting within four metres of somebody walking
underneath brightens and fades back as they leave, which is what an
occupancy-sensed floor actually does at night. They are one `InstancedMesh`
sharing one material, so `emissiveIntensity` cannot vary between them —
`instanceColor` can, but three multiplies it into the diffuse term only, so
six lines of `onBeforeCompile` carry it into the emissive term as well. The
alternative was one mesh per fitting: forty draw calls of ceiling in a
building that spends about twenty on everything.

**Optimus.** A posable Gen-3 humanoid — eleven articulating joints, pale
shells over a dark frame, a black visor — with a walk cycle driven by
*distance travelled* rather than wall-clock, so the feet do not slide when
a robot slows down. Two per floor, derived from the pack's levels, so the
two-storey tower gets four and the hangar gets two without either pack
knowing robots exist. They wander between reachable points using
`Plan.blocked` — the collider the wall split already produces — and they
are deliberately **not** gated on `depth`: the build-time-exclusion rule is
about occupancy, and a robot is nobody.

**Starlinks stop being pixels.** The sixty-four nearest the centre of view
grow real geometry — a flat bus with ONE large solar array, which is the
actual signature and the thing everybody draws symmetrically and wrong —
fading in so there is no pop where a point becomes a mesh. Two draw calls.
The sun for their attitude comes from `solar.ts` and not from the rig,
because `atmosphere.ts` floors the light direction to keep the shadow
camera usable, and a sun ten degrees *down* is exactly the dusk geometry
that makes a pass visible.

**Aircraft** are airliners now — swept wings, nacelles, a fin — instead of
an arrowhead, still one shared geometry facing +Z as `flights.ts` requires.
**Clouds** drift over the board, driven by observed cover, lit by the rig
rather than by themselves.

Four modules were built by subagents and reviewed by another; every one
came back `needs-work` and the reviews were right. Fixed before wiring:

  - The walk cycle's arms were a quarter cycle out of step with its legs —
    the legs are cosine-shaped and the arms were on `sin`, so at the
    instant the left leg reached full forward the left shoulder was at dead
    neutral. Uncanny, and hard to name until it is pointed at.
  - Every Optimus shell used a `roundedBox` radius of 0.12–0.22, which that
    primitive turns into a near-circular cross-section — the figure was
    built out of lozenges, not panels. The rest of the library uses
    0.02–0.09.
  - The cloud material was `transparent` + `DoubleSide` without
    `forceSinglePass`, so three rendered it twice per frame *and* bumped
    `material.version` on each pass — rebuilding the program cache key
    forever, on the one layer that is fill-rate bound.
  - `starlinkMesh.dispose()` freed the geometries but not the
    `InstancedMesh`es, orphaning their instance buffers on every city
    switch.
  - The airliner's tailplane roots sat outside the tail cone and hung in
    free air over most of their chord.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 00:56:49 -07:00
parent 18dadda917
commit af0d4a7d57
13 changed files with 4917 additions and 42 deletions
+813
View File
@@ -0,0 +1,813 @@
/**
* 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";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import type { SatelliteFix } from "./satellites.ts";
const RAD = 180 / Math.PI;
/**
* The dome factor, restated.
*
* `satellites.ts` keeps `DOME_RADIUS_FACTOR = 1.05` private, 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 is a mirror and is meant to stop being one: see the wiring note. Export
* the constant from `satellites.ts`, import it here, and delete this.
*/
export const DOME_RADIUS_FACTOR = 1.05;
/**
* 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, so the comparator is total. */
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;
}
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. Ascending; `UNUSED_SCORE` sorts to the end. */
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(domeRadius: number): StarlinkMeshLayer {
const group = new THREE.Group();
group.name = "starlink-meshes";
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,
/**
* The array is a flat panel edge-on for part of every orbit, and a
* back-faced panel disappears entirely at the moment it is most
* foreshortened. It has two sides in reality — cells one way, substrate the
* other — and drawing both is a hundred and forty-four extra triangles
* across the whole layer.
*/
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[] = [];
// Scratch, all of it. Nothing in `update` allocates.
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;
}
/**
* 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 rest of the pool so the sort puts them past the end. The
// objects are kept; 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;
}
}
pool.sort(byScore);
const drawn = Math.min(found, MAX_MESHES);
for (let i = 0; i < drawn; i++) {
const candidate = pool[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` points from the earth to the satellite, so `radial` is
* near enough the direction from the satellite to the observer, and 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 four triangles because the slab alone is a
* shape with no side to it — the whole read of "belly pointing down" comes from
* being able to see which face is which at an oblique angle. 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 `flights.ts`'s `dartGeometry`, 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;
}
/** Ascending by angle off the view centre; released slots sort to the back. */
function byScore(a: Candidate, b: Candidate): number {
return a.score - b.score;
}
/**
* 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.
*/