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