Widen the star catalogue, and separate what is drawn from what is known

The map held 8750 stars within 50 pc and rendered 371 systems. Both were
lower than they needed to be, for different reasons.

The star catalogue was capped by its own encoding as much as by the
cutoff: one JSON object per star, eight key names repeated each time, 157
bytes a star. At the range HYG actually reaches that is 17 MB to download
and parse before the first frame. So the numbers move into two binary
column stores — positions in stars.bin, which the GPU is handed verbatim,
and id/magnitude/colour/spectral index in stars-meta.bin — and the JSON
keeps only the strings, with 2600 distinct spectral classifications
collapsed to a dictionary. The layout is defined once, in star-catalog.ts,
and the ETL and the app both use it, so the writer and the reader cannot
drift.

The cutoff then goes to 250 pc: 68388 stars, 7.8x as many for 1.7x the
bytes. That is where HYG's measurements stop rather than a round number —
98.6% of its rows are Hipparcos, whose parallaxes are good to about a
milliarcsecond, so beyond 250 pc it would be plotting noise.

Drawing all of them is a separate question from knowing them, and it is
answered separately. The field draws a budget: every star inside 25 pc,
because the nearest are faint red dwarfs and Proxima Centauri is magnitude
11, then the brightest of everything beyond. Search, navigation and the
planet cross-reference still see the whole catalogue. A real GPU would
draw all 68388 without noticing; the budget is for the machines that would
not, and it is one constant.

Systems were limited by something else entirely. The archive data already
shipped named 4735 host stars and only 388 resolved, because the rest lay
outside a 50 pc catalogue — and the cross-reference kept only its own
result, so redoing it meant re-downloading an archive that is not
reachable from here. Host coordinates are now stored with each planet, and
the match is re-resolved at build time against whatever catalogue the run
produced. Even name matching alone, which needs no coordinates and so
works on the records already shipped, rescues 335 planets across 238
systems: 371 renderable systems become 609.

Two selection rules were tuned for a 50 pc bubble and no longer fit.
Tethers followed the Sun's nearest neighbours, which are a speck at this
range, and now follow the brightest; labels were ranked by proximity,
which named whatever sat nearest the middle of the screen, and are now
ranked by brightness — so the view names Canopus, Achernar and Spica
rather than a clump of catalogue designations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
Claude
2026-08-05 08:35:41 +00:00
parent 029162ff52
commit 29fd92d118
20 changed files with 692 additions and 49 deletions
@@ -30,6 +30,36 @@ const PIXELS_TO_ANGULAR_SIZE =
*/
const PICK_NDC_SLOP = 0.01;
/**
* How many stars the field draws at once, however many the catalogue holds.
*
* The catalogue reaches as far as its parallaxes do — 68388 stars at 250 pc — but drawing all of
* them is a cost paid every frame by every machine, and most of that cost buys 1.5-pixel dots.
* So the *data* is the catalogue and the *drawing* is a budget, and the two are allowed to
* differ. Everything still exists for search, for flying to, and for hosting planets.
*
* The figure is set low deliberately. A real GPU would draw the whole catalogue without
* noticing — this is one instanced draw call — but the value that matters is what a weak one
* does, and the software rasterizer this was measured against costs a third of its frame rate
* per 12000 stars. Raise it freely on hardware that can take it; nothing else depends on it.
*/
export const STAR_RENDER_BUDGET = 12000;
/**
* Radius (parsecs) inside which every star is drawn regardless of brightness.
*
* A pure brightness cut would be defensible — apparent magnitude is exactly "how visible this
* is" — but it would drop the solar neighbourhood, because the nearest stars are overwhelmingly
* faint red dwarfs. Proxima Centauri is magnitude 11. Those are the stars this map is most about
* and the ones that hold the nearby planets, so the neighbourhood is kept whole and the budget
* is spent on the brightest of everything beyond it.
*
* Kept deliberately small against the catalogue's 250 pc reach. The guaranteed core occupies a
* thousandth of that volume, so a generous radius spends most of the budget inside it and draws
* a dense knot surrounded by nothing — which is a worse picture than the smaller catalogue was.
*/
export const ALWAYS_DRAWN_RADIUS_PC = 25;
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
const WARM_STAR_COLOR = new THREE.Color(1.0, 0.6, 0.35);
@@ -90,22 +120,58 @@ function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometr
* close the camera gets. That is deliberate and physically right: real stars are unresolvable
* point sources, and their apparent size on screen is a function of brightness, not distance.
*/
/**
* Chooses which stars to draw when the catalogue is larger than the budget: everything inside
* the neighbourhood radius, then the brightest of the rest until the budget is spent.
*
* Returns indices into the original list, so the caller can subset the positions that go with
* them. Returns them in catalogue order rather than in selection order, purely so the drawn set
* is stable and inspectable.
*/
export function selectDrawnStars(stars: readonly StarRecord[], budget = STAR_RENDER_BUDGET): Uint32Array {
if (stars.length <= budget) {
return Uint32Array.from(stars.keys());
}
const near: number[] = [];
const far: number[] = [];
stars.forEach((star, index) => {
(Math.hypot(star.x, star.y, star.z) <= ALWAYS_DRAWN_RADIUS_PC ? near : far).push(index);
});
far.sort((a, b) => stars[a].magnitude - stars[b].magnitude);
const selected = near.concat(far.slice(0, Math.max(0, budget - near.length)));
selected.sort((a, b) => a - b);
return Uint32Array.from(selected);
}
export class StarFieldRenderer {
readonly object: THREE.Mesh;
/** How many of the catalogue's stars this field actually draws. */
readonly drawnCount: number;
private readonly geometry: THREE.InstancedBufferGeometry;
private readonly material: THREE.SpriteNodeMaterial;
/** Angular diameter per star, in the same order as `stars` — reused for picking. */
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */
private readonly stars: readonly StarRecord[];
/** Angular diameter per drawn star, in the same order as `stars` — reused for picking. */
private readonly angularSizes: Float32Array;
constructor(
private readonly stars: readonly StarRecord[],
positions: Float32Array
) {
constructor(catalogue: readonly StarRecord[], cataloguePositions: Float32Array, budget = STAR_RENDER_BUDGET) {
const drawn = selectDrawnStars(catalogue, budget);
this.stars = drawn.length === catalogue.length ? catalogue : Array.from(drawn, (index) => catalogue[index]);
this.drawnCount = this.stars.length;
const stars = this.stars;
this.geometry = createQuadGeometry(stars.length);
const colors = new Float32Array(stars.length * 3);
this.angularSizes = new Float32Array(stars.length);
// Repacked only when the drawn set is a subset; otherwise the ETL's buffer is used as-is.
const positions =
drawn.length === catalogue.length
? cataloguePositions
: Float32Array.from({ length: drawn.length * 3 }, (_, i) => cataloguePositions[drawn[(i / 3) | 0] * 3 + (i % 3)]);
stars.forEach((star, index) => {
const color = colorIndexToRgb(star.colorIndex, star.spectralType);
@@ -115,7 +181,6 @@ export class StarFieldRenderer {
this.angularSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE;
});
// `positions` is the ETL's packed buffer, already in the same order as `stars`.
const positionAttribute = new THREE.InstancedBufferAttribute(positions, 3);
const colorAttribute = new THREE.InstancedBufferAttribute(colors, 3);
const sizeAttribute = new THREE.InstancedBufferAttribute(this.angularSizes, 1);