/** * 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(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 centre–observer–satellite 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. */