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:
@@ -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 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 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.
|
||||
*/
|
||||
Reference in New Issue
Block a user