Draw the stars around wherever the view is, not only around the Sun

The star field draws a budget of the catalogue: everything within 25 pc of
the Sun, then the brightest of the rest. That choice was made once, at load,
around the Sun, and never again. On the Gaia catalogue it left most of the
map empty wherever the view went:

- a region 150 pc out drew 49 of the 442 stars within 25 pc of it;
- a plotted route ran through stars no one could see or click. Sol to
  Almach at 8 pc passes 19 stars and drew 6, Sol to Mirfak 11 of 26;
- a search for a faint star flew the camera to an empty point.

The drawn set now follows the view. The scene chooses it again at the label
cadence, once the orbit target has moved more than 5 pc or the pinned stars
have changed. The budget goes, in order, to the selected star and the stars
of a plotted route, then everything within 25 pc of where the view is
centred, then the same around the Sun, then the brightest of the rest. The
instance buffers hold the budget and are rewritten in place.

Checked in Chromium on WebGPU, framing Mirfak from 12 pc: with the set
chosen around the Sun, 122 of the 649 stars within 25 pc were drawn;
following the view, all 649. At the opening view the drawn set is the same
as before.

A refocus takes 9 ms in the browser (5 ms of it choosing). The first version took
16-36 ms in the browser, a visible hitch during a flight. Most of that time
went on walking the 423 651-star brightness order once per neighbourhood,
out of catalogue order, and on recomputing 70 000 colours. Now both
neighbourhoods are gathered in one pass in catalogue order and sorted on
their own, and colours and sizes are computed once for the whole catalogue.
The brightness order itself sorts a typed copy of the magnitudes, taking
83 ms at load instead of 104-139 ms.

STAR_RENDER_BUDGET is now 70 000, and its comment gives the measurements
behind it rather than "currently set to the whole catalogue", which stopped
being true when Gaia landed. At 1920 x 1080 on a Ryzen 7700X:

- on the RTX 4080, the whole catalogue costs the same 6.1 ms a frame as the
  budget;
- on the processor's two-core Radeon, standing in for an entry-level laptop,
  every 100 000 stars costs about 4 ms: 112 fps at the budget, 44 at the
  whole catalogue, and the same under WebGL2;
- drawn whole, the opening view turns into a grey wash that buries the
  labels and the host rings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-16 14:11:42 +02:00
co-authored by Claude Opus 5
parent 0a0b301807
commit 2d997e41db
5 changed files with 378 additions and 104 deletions
@@ -11,6 +11,7 @@ import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store'; import { NavigationStore } from '../../shared/state/navigation.store';
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component'; import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
import { StarFieldRenderer } from './star-field-renderer';
// jsdom does not implement ResizeObserver; the component only uses it to react to real // jsdom does not implement ResizeObserver; the component only uses it to react to real
// layout changes, which never happen in this headless test. // layout changes, which never happen in this headless test.
@@ -211,6 +212,33 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
expect(navigationStore.viewLevel()).toBe('galaxy'); expect(navigationStore.viewLevel()).toBe('galaxy');
}); });
it('chooses the drawn stars again once the view centre has moved, and not for a small drift', async () => {
const component = fixture.componentInstance as unknown as { controls: { target: THREE.Vector3 } };
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
component.controls.target.set(40, 0, 0);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(1);
expect(refocus.mock.calls[0][0].centre).toMatchObject({ x: 40, y: 0, z: 0 });
component.controls.target.set(42, 0, 0);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(1);
refocus.mockRestore();
});
it('keeps the stars of a plotted route drawn, and the selected star', async () => {
const component = fixture.componentInstance as unknown as { routeResult: { set(value: unknown): void } };
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
await advanceFrames(engine, 0.3);
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null });
await advanceFrames(engine, 0.3);
expect(refocus.mock.calls.at(-1)![0].pinnedIds).toEqual([SUN.id, PROXIMA.id]);
refocus.mockRestore();
});
it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => { it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => {
navigationStore.selectStar(SUN.id); navigationStore.selectStar(SUN.id);
await flushAsync(); await flushAsync();
@@ -36,7 +36,7 @@ import { RouteRequest, RouteResult, RouteStarOption } from '../hud/routes-panel.
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking'; import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking';
import { StarmapHudComponent } from './starmap-hud.component'; import { StarmapHudComponent } from './starmap-hud.component';
import { SystemObjectCardComponent } from './system-object-card.component'; import { SystemObjectCardComponent } from './system-object-card.component';
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer'; import { colorIndexToRgb, FOCUS_RADIUS_PC, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links'; import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links';
import { brightestWithin, brightnessOrder } from '../../shared/astro/brightest'; import { brightestWithin, brightnessOrder } from '../../shared/astro/brightest';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood'; import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
@@ -117,6 +117,12 @@ const HUD_ACCENT = 0x4dd7ff;
const DEEP_SKY_LABEL_COUNT = 12; const DEEP_SKY_LABEL_COUNT = 12;
/** How often (seconds) the visible label set is recomputed; doesn't need to be per-frame. */ /** How often (seconds) the visible label set is recomputed; doesn't need to be per-frame. */
const LABEL_UPDATE_INTERVAL_SECONDS = 0.2; const LABEL_UPDATE_INTERVAL_SECONDS = 0.2;
/**
* How far the view's centre may drift, in parsecs, before the star field chooses its stars again: a
* fifth of the radius it draws whole, so nothing within four fifths of it ever goes missing, and
* a slow pan does not rewrite the buffers every label pass.
*/
const STAR_FIELD_REFOCUS_PC = FOCUS_RADIUS_PC / 5;
/** Pointer travel (px) above which a press counts as an orbit drag rather than a selection. */ /** Pointer travel (px) above which a press counts as an orbit drag rather than a selection. */
const CLICK_DRAG_SLOP_PX = 5; const CLICK_DRAG_SLOP_PX = 5;
@@ -313,6 +319,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private controls?: OrbitControls; private controls?: OrbitControls;
private rig?: CameraRigController; private rig?: CameraRigController;
private starField?: StarFieldRenderer; private starField?: StarFieldRenderer;
/** Where the star field last chose its stars for, and which it was told to keep. See `refocusStarField`. */
private starFieldFocus: THREE.Vector3 | null = null;
private starFieldPins = '';
private hostRings?: HostStarRings; private hostRings?: HostStarRings;
/** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */ /** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */
private neighbourhood?: StarNeighbourhood; private neighbourhood?: StarNeighbourhood;
@@ -535,7 +544,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
(id): id is number => id !== null && id !== undefined (id): id is number => id !== null && id !== undefined
)); ));
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search)); this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search), this.starsByBrightness);
this.galaxyGroup.add(this.starField.object); this.galaxyGroup.add(this.starField.object);
this.hostRings = new HostStarRings(stars.filter((star) => this.starIdsWithBodies.has(star.id)), HUD_ACCENT); this.hostRings = new HostStarRings(stars.filter((star) => this.starIdsWithBodies.has(star.id)), HUD_ACCENT);
this.galaxyGroup.add(this.hostRings.object); this.galaxyGroup.add(this.hostRings.object);
@@ -611,6 +620,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) { if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
this.labelUpdateAccumulator = 0; this.labelUpdateAccumulator = 0;
if (this.galaxyGroup.visible) { if (this.galaxyGroup.visible) {
this.refocusStarField();
this.updateLabels(camera); this.updateLabels(camera);
} else if (this.systemGroup.visible) { } else if (this.systemGroup.visible) {
this.updateSystemLabels(camera); this.updateSystemLabels(camera);
@@ -760,6 +770,28 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
return scaleBar((2 * halfHeight) / heightPx, SCALE_BAR_MAX_PX, unit); return scaleBar((2 * halfHeight) / heightPx, SCALE_BAR_MAX_PX, unit);
} }
/**
* Keeps the drawn stars those around what the view is centred on, and the ones the map is
* pointing at: the selected star and the stars of a plotted route. Re-chosen only once the
* centre has moved far enough to matter, so any star within `FOCUS_RADIUS_PC - STAR_FIELD_REFOCUS_PC`
* of it is always drawn, however faint.
*/
private refocusStarField(): void {
if (!this.starField) {
return;
}
const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
const selectedId = this.navigationStore.selectedStarId();
const pinnedIds = [...(selectedId === null ? [] : [selectedId]), ...(this.routeResult()?.stars.map((star) => star.id) ?? [])];
const pins = pinnedIds.join();
if (this.starFieldFocus && this.starFieldFocus.distanceTo(centre) <= STAR_FIELD_REFOCUS_PC && pins === this.starFieldPins) {
return;
}
this.starField.refocus({ centre, pinnedIds });
this.starFieldFocus = centre.clone();
this.starFieldPins = pins;
}
private updateLabels(camera: SceneCamera): void { private updateLabels(camera: SceneCamera): void {
const radii = distanceRings(this.effectiveDistance(camera), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC); const radii = distanceRings(this.effectiveDistance(camera), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC);
if (radii.join() !== this.localGridRadii.join()) { if (radii.join() !== this.localGridRadii.join()) {
@@ -260,11 +260,6 @@ describe('selectDrawnStars', () => {
expect(drawn).toEqual([0, 2, 3]); expect(drawn).toEqual([0, 2, 3]);
}); });
it('returns catalogue indices in order, so positions can be subset alongside', () => {
const catalogue = Array.from({ length: 100 }, (_, i) => catalogueStar(i, 150, 100 - i));
const drawn = Array.from(selectDrawnStars(catalogue, 10));
expect(drawn).toEqual([...drawn].sort((a, b) => a - b));
});
}); });
describe('StarFieldRenderer render budget', () => { describe('StarFieldRenderer render budget', () => {
@@ -291,3 +286,113 @@ describe('StarFieldRenderer render budget', () => {
renderer.dispose(); renderer.dispose();
}); });
}); });
describe('selectDrawnStars around the view', () => {
/** 200 bright stars 240 pc out, enough to spend any small budget on their own. */
const brightFar = (from: number) => Array.from({ length: 200 }, (_, i) => catalogueStar(from + i, 240, 2));
it('draws a faint star near where the view is centred, however far that is from the Sun', () => {
const faint = catalogueStar(0, 150, 12);
const catalogue = [faint, ...brightFar(1)];
expect(Array.from(selectDrawnStars(catalogue, 20))).not.toContain(0);
expect(Array.from(selectDrawnStars(catalogue, 20, { centre: { x: 150, y: 0, z: 0 } }))).toContain(0);
});
it("keeps the Sun's neighbourhood drawn while the view looks elsewhere", () => {
const catalogue = [catalogueStar(0, 1.3, 11), catalogueStar(1, 150, 13), ...brightFar(2)];
expect(Array.from(selectDrawnStars(catalogue, 20, { centre: { x: 150, y: 0, z: 0 } })).slice(0, 2)).toEqual([1, 0]);
});
it('draws a pinned star wherever it is and however faint', () => {
const catalogue = [catalogueStar(0, 240, 14), ...brightFar(1)];
expect(Array.from(selectDrawnStars(catalogue, 20))).not.toContain(0);
expect(Array.from(selectDrawnStars(catalogue, 20, { pinned: [0] }))).toContain(0);
});
it('spends a budget too small for everything on the pinned stars, then the view, then the Sun, then the brightest', () => {
const catalogue = [catalogueStar(0, 1, 12), catalogueStar(1, 150, 13), catalogueStar(2, 240, 14), ...brightFar(3)];
const focus = { centre: { x: 150, y: 0, z: 0 }, pinned: [2] };
expect(Array.from(selectDrawnStars(catalogue, 4, focus))).toEqual([2, 1, 0, 3]);
expect(Array.from(selectDrawnStars(catalogue, 2, focus))).toEqual([2, 1]);
});
it('keeps the brightest part of a neighbourhood the budget cannot hold whole', () => {
const catalogue = [catalogueStar(0, 150, 9), catalogueStar(1, 151, 4), catalogueStar(2, 152, 11), catalogueStar(3, 153, 6), ...brightFar(4)];
expect(Array.from(selectDrawnStars(catalogue, 2, { centre: { x: 150, y: 0, z: 0 } }))).toEqual([1, 3]);
});
it('draws nothing twice when the view is centred on the Sun or pins a star already near it', () => {
const catalogue = [catalogueStar(0, 1, 12), catalogueStar(1, 2, 13), ...brightFar(2)];
const drawn = Array.from(selectDrawnStars(catalogue, 10, { centre: { x: 0, y: 0, z: 0 }, pinned: [0, 0, 1] }));
expect(new Set(drawn).size).toBe(drawn.length);
expect(drawn).toHaveLength(10);
});
});
describe('StarFieldRenderer refocus', () => {
const camera = testCamera();
/** A faint star straight ahead, 150 pc out, among bright ones well off to the side. */
const faintAhead = star({ id: 77, x: 0, y: 0, z: -150, magnitude: 13, colorIndex: 1.9 });
const catalogue = [faintAhead, ...Array.from({ length: 50 }, (_, i) => star({ id: 100 + i, x: 60, y: i, z: -40, magnitude: 1, colorIndex: -0.3 + i * 0.04 }))];
const positions = packPositions(catalogue);
it('draws and picks a faint star once the view is centred near it', () => {
const renderer = new StarFieldRenderer(catalogue, positions, 10);
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
renderer.refocus({ centre: { x: 0, y: 0, z: -140 } });
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBe(77);
expect((renderer.object.geometry as THREE.InstancedBufferGeometry).instanceCount).toBe(renderer.drawnCount);
renderer.dispose();
});
it('draws a star pinned by id, and passes over ids the catalogue does not hold', () => {
const renderer = new StarFieldRenderer(catalogue, positions, 10);
renderer.refocus({ pinnedIds: [123456, 77] });
const drawnIds = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i));
expect(drawnIds).toContain(77);
expect(renderer.drawnCount).toBe(10);
renderer.dispose();
});
it('gives each drawn star its own colour and size, wherever the refocus put it', () => {
const renderer = new StarFieldRenderer(catalogue, positions, 10);
renderer.refocus({ centre: { x: 0, y: 0, z: -140 }, pinnedIds: [120] });
const { colorAttribute, sizeAttribute } = renderer as unknown as { colorAttribute: THREE.InstancedBufferAttribute; sizeAttribute: THREE.InstancedBufferAttribute };
for (let instance = 0; instance < renderer.drawnCount; instance++) {
const drawnStar = catalogue.find((candidate) => candidate.id === renderer.starIdAt(instance))!;
const expected = colorIndexToRgb(drawnStar.colorIndex, drawnStar.spectralType);
expect(colorAttribute.getX(instance)).toBeCloseTo(expected.r, 5);
expect(colorAttribute.getZ(instance)).toBeCloseTo(expected.b, 5);
expect(sizeAttribute.getX(instance)).toBeGreaterThan(0);
}
const faintSlot = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i)).indexOf(77);
const brightSlot = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i)).indexOf(120);
expect(sizeAttribute.getX(brightSlot)).toBeGreaterThan(sizeAttribute.getX(faintSlot));
renderer.dispose();
});
it('drops a star from the drawn set, and from picking, once the view has moved away from it', () => {
// The subtle failure this guards: buffers rewritten for a new selection while picking still
// reads the old one would leave clickable ghosts where nothing is drawn.
const renderer = new StarFieldRenderer(catalogue, positions, 10);
renderer.refocus({ centre: { x: 0, y: 0, z: -140 } });
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBe(77);
renderer.refocus({ centre: { x: 0, y: 0, z: 0 } });
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
expect(Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i))).not.toContain(77);
renderer.dispose();
});
});
@@ -1,6 +1,7 @@
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl'; import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
import { brightnessOrder, Positioned } from '../../shared/astro/brightest';
import { spectralTypeToColorIndex } from '../../shared/astro/spectral'; import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
import { SceneCamera } from '../../core/engine/engine.service'; import { SceneCamera } from '../../core/engine/engine.service';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
@@ -25,25 +26,25 @@ const PICK_NDC_SLOP = 0.01;
/** /**
* How many stars the field draws at once, however many the catalogue holds. * 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 * The *data* is the catalogue and the *drawing* is a budget, and the two are allowed to differ:
* them is a cost paid every frame by every machine, and most of that cost buys 1.5-pixel dots. * everything still exists for search, for flying to, and for hosting planets. Which stars fill
* So the *data* is the catalogue and the *drawing* is a budget, and the two are allowed to * the budget follows the view; see {@link selectDrawnStars}.
* differ. Everything still exists for search, for flying to, and for hosting planets.
* *
* Currently set to the whole catalogue, which is what a GPU should be asked to do — this is one * The number is set by what the field looks like, before what it costs. The catalogue is
* instanced draw call, and a discrete card will not notice it. The budget still exists because * 423 651 stars since Gaia, and drawn whole the opening view is a grey wash: the additive
* the catalogue is meant to grow past what any machine should draw at once: Gaia alone could * blending of that many 1.5-pixel dots buries the labels, the rings on the planet hosts and the
* contribute a million stars, and at that point the selection below is what keeps the field * grid. At 150 000 the wash has begun; at this budget the view reads. Measured at 1920 × 1080 on
* legible rather than a grey wash. * a Ryzen 7700X, the cost argues the same way. An RTX 4080 draws the whole catalogue in the same
* * 6.1 ms a frame as this budget, so a discrete card does not notice. The processor's own
* Machines without a GPU do feel it. A software rasterizer measured here lost about a third of * two-core Radeon, standing in for an entry-level laptop, pays about 4 ms a frame for every
* its frame rate per 12000 stars drawn; if that matters for a deployment, this is the one number * 100 000 stars: 112 frames a second at this budget, 44 at the whole catalogue, and the same
* to turn down. * again under the WebGL2 fallback.
*/ */
export const STAR_RENDER_BUDGET = 68388; export const STAR_RENDER_BUDGET = 70_000;
/** /**
* Radius (parsecs) inside which every star is drawn regardless of brightness. * Radius (parsecs) around the Sun, and around wherever the view is centred, inside which every
* star is drawn regardless of brightness.
* *
* A pure brightness cut would be defensible — apparent magnitude is exactly "how visible this * 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 * is" — but it would drop the solar neighbourhood, because the nearest stars are overwhelmingly
@@ -51,11 +52,28 @@ export const STAR_RENDER_BUDGET = 68388;
* and the ones that hold the nearby planets, so the neighbourhood is kept whole and the budget * 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. * is spent on the brightest of everything beyond it.
* *
* Kept deliberately small against the catalogue's 250 pc reach. The guaranteed core occupies a * The same holds wherever the view is looking. Before the drawn set followed the view, a region
* thousandth of that volume, so a generous radius spends most of the budget inside it and draws * 150 pc out drew 49 of the 442 stars within this radius of it, and a route plotted there ran
* a dense knot surrounded by nothing — which is a worse picture than the smaller catalogue was. * through waypoints nobody could see or click: Sol to Almach at 8 pc passed 19 stars and drew 6.
*
* Kept deliberately small against the catalogue's reach. Around the Sun it holds 3 654 stars;
* a generous radius spends most of the budget inside it and draws a dense knot surrounded by
* nothing.
*/ */
export const ALWAYS_DRAWN_RADIUS_PC = 25; export const FOCUS_RADIUS_PC = 25;
/** What, besides the brightest stars, the field should be sure to draw. */
export interface DrawFocus {
/** Where the view is centred. Its neighbourhood is drawn whole, like the Sun's. */
readonly centre?: Positioned;
/**
* Catalogue indices drawn wherever they are and however faint: the selected star, the stars
* of a plotted route. Anything the map points at has to be there to be pointed at.
*/
readonly pinned?: readonly number[];
}
const SUN: Positioned = { x: 0, y: 0, z: 0 };
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0); 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 NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
@@ -101,7 +119,86 @@ function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometr
} }
/** /**
* Builds the galaxy-scale star field as instanced camera-facing billboards, one per HYG star, * Reads a render budget override off the page URL (`?stars=20000`), falling back to the default.
*
* Two uses, one real and one incidental. The real one is a deployment or a machine that cannot
* draw the whole catalogue — a number in a URL beats a rebuild. The incidental one is the
* end-to-end suite, which runs against a software rasterizer whose frame rate is two orders of
* magnitude below a real GPU's: those tests are checking navigation and state, and making them
* wait on a rasterizer measures nothing about the app.
*/
export function starRenderBudgetFromUrl(search: string, fallback = STAR_RENDER_BUDGET): number {
const requested = Number(new URLSearchParams(search).get('stars'));
return Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : fallback;
}
/**
* Chooses which stars to draw when the catalogue is larger than the budget. In order, until the
* budget is spent: the pinned stars, everything within {@link FOCUS_RADIUS_PC} of where the view
* is centred, everything within it of the Sun, then the brightest of the rest. Each neighbourhood
* is taken brightest first, so a budget too small to hold one whole keeps its most visible part.
*
* Returns indices into the original list, in the order they were chosen. `order` is the
* catalogue's brightness order, passed in when the caller already has it rather than sorted again
* on every call.
*/
export function selectDrawnStars(
stars: readonly StarRecord[],
budget = STAR_RENDER_BUDGET,
focus: DrawFocus = {},
order: Uint32Array = brightnessOrder(stars)
): Uint32Array {
if (stars.length <= budget) {
return Uint32Array.from(stars.keys());
}
const chosen = new Uint8Array(stars.length);
const selected: number[] = [];
const take = (index: number): void => {
if (!chosen[index] && selected.length < budget) {
chosen[index] = 1;
selected.push(index);
}
};
for (const index of focus.pinned ?? []) {
if (index >= 0 && index < stars.length) {
take(index);
}
}
// Both neighbourhoods in one pass in catalogue order, then each sorted brightest first. They hold
// a few thousand stars between them, so sorting them costs far less than walking the whole
// brightness order once per neighbourhood, which reads the catalogue out of order: 26 ms a
// refocus on the real catalogue, against 4 ms this way.
const radiusSq = FOCUS_RADIUS_PC * FOCUS_RADIUS_PC;
const centres = focus.centre ? [focus.centre, SUN] : [SUN];
const members = centres.map(() => [] as number[]);
for (let index = 0; index < stars.length; index++) {
const star = stars[index];
for (let which = 0; which < centres.length; which++) {
const dx = star.x - centres[which].x;
const dy = star.y - centres[which].y;
const dz = star.z - centres[which].z;
if (dx * dx + dy * dy + dz * dz <= radiusSq) {
members[which].push(index);
}
}
}
for (const neighbourhood of members) {
// Ties in catalogue order, as in the brightness order itself.
neighbourhood.sort((a, b) => stars[a].magnitude - stars[b].magnitude || a - b).forEach(take);
}
for (let at = 0; at < order.length && selected.length < budget; at++) {
take(order[at]);
}
return Uint32Array.from(selected);
}
/**
* Builds the galaxy-scale star field as instanced camera-facing billboards, one per drawn star,
* coloured by spectral index and sized by magnitude. * coloured by spectral index and sized by magnitude.
* *
* **Why billboards and not `THREE.Points`.** Point primitives are capped at a single pixel on * **Why billboards and not `THREE.Points`.** Point primitives are capped at a single pixel on
@@ -116,50 +213,12 @@ function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometr
* Sizes are angular (`sizeAttenuation = false`), so a star holds the same apparent size however * Sizes are angular (`sizeAttenuation = false`), so a star holds the same apparent size however
* close the camera gets. That is deliberate and physically right: real stars are unresolvable * 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. * 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 * The instance buffers hold the budget, not the catalogue, and are rewritten in place when
* them. Returns them in catalogue order rather than in selection order, purely so the drawn set * {@link refocus} changes which stars fill it.
* is stable and inspectable.
*/ */
/**
* Reads a render budget override off the page URL (`?stars=20000`), falling back to the default.
*
* Two uses, one real and one incidental. The real one is a deployment or a machine that cannot
* draw the whole catalogue — a number in a URL beats a rebuild. The incidental one is the
* end-to-end suite, which runs against a software rasterizer whose frame rate is two orders of
* magnitude below a real GPU's: those tests are checking navigation and state, and making them
* wait on a rasterizer measures nothing about the app.
*/
export function starRenderBudgetFromUrl(search: string, fallback = STAR_RENDER_BUDGET): number {
const requested = Number(new URLSearchParams(search).get('stars'));
return Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : fallback;
}
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 { export class StarFieldRenderer {
readonly object: THREE.Mesh; readonly object: THREE.Mesh;
/** How many of the catalogue's stars this field actually draws. */
readonly drawnCount: number;
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */ /** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
private readonly perspective = uniform(1); private readonly perspective = uniform(1);
@@ -167,39 +226,49 @@ export class StarFieldRenderer {
private readonly geometry: THREE.InstancedBufferGeometry; private readonly geometry: THREE.InstancedBufferGeometry;
private readonly material: THREE.SpriteNodeMaterial; private readonly material: THREE.SpriteNodeMaterial;
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */ private readonly budget: number;
private readonly stars: readonly StarRecord[]; private readonly order: Uint32Array;
/** Angular diameter per drawn star, in the same order as `stars` — reused for picking. */ /** Built the first time a star is pinned by id, since nothing else needs it. */
private readonly angularSizes: Float32Array; private indexById?: Map<number, number>;
/**
* Colour and angular size of every star in the catalogue, worked out once: a refocus then only
* copies them into the instances, 0.7 ms for the budget rather than 5.6 ms computing them again.
*/
private readonly catalogueColors: Float32Array;
private readonly catalogueSizes: Float32Array;
constructor(catalogue: readonly StarRecord[], cataloguePositions: Float32Array, budget = STAR_RENDER_BUDGET) { /** Per-instance data, `budget` long; the first `drawnCount` entries are live. */
const drawn = selectDrawnStars(catalogue, budget); private readonly positionAttribute: THREE.InstancedBufferAttribute;
this.stars = drawn.length === catalogue.length ? catalogue : Array.from(drawn, (index) => catalogue[index]); private readonly colorAttribute: THREE.InstancedBufferAttribute;
this.drawnCount = this.stars.length; private readonly sizeAttribute: THREE.InstancedBufferAttribute;
/** Catalogue index behind each live instance: the set that is drawn, and so the only set that can be clicked. */
private drawn: Uint32Array = new Uint32Array(0);
const stars = this.stars; constructor(
this.geometry = createQuadGeometry(stars.length); private readonly catalogue: readonly StarRecord[],
private readonly cataloguePositions: Float32Array,
budget = STAR_RENDER_BUDGET,
order?: Uint32Array
) {
this.budget = budget;
this.order = order ?? brightnessOrder(catalogue);
const capacity = Math.min(budget, catalogue.length);
this.geometry = createQuadGeometry(0);
const colors = new Float32Array(stars.length * 3); this.positionAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
this.angularSizes = new Float32Array(stars.length); this.colorAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
// Repacked only when the drawn set is a subset; otherwise the ETL's buffer is used as-is. this.sizeAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity), 1);
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) => { this.catalogueColors = new Float32Array(catalogue.length * 3);
this.catalogueSizes = new Float32Array(catalogue.length);
catalogue.forEach((star, index) => {
const color = colorIndexToRgb(star.colorIndex, star.spectralType); const color = colorIndexToRgb(star.colorIndex, star.spectralType);
colors[index * 3] = color.r; this.catalogueColors[index * 3] = color.r;
colors[index * 3 + 1] = color.g; this.catalogueColors[index * 3 + 1] = color.g;
colors[index * 3 + 2] = color.b; this.catalogueColors[index * 3 + 2] = color.b;
this.angularSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE; this.catalogueSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE;
}); });
const positionAttribute = new THREE.InstancedBufferAttribute(positions, 3);
const colorAttribute = new THREE.InstancedBufferAttribute(colors, 3);
const sizeAttribute = new THREE.InstancedBufferAttribute(this.angularSizes, 1);
this.material = new THREE.SpriteNodeMaterial({ this.material = new THREE.SpriteNodeMaterial({
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
@@ -212,15 +281,15 @@ export class StarFieldRenderer {
// a thousandth of a parsec — invisible. Doing the same arithmetic in the node graph, behind // a thousandth of a parsec — invisible. Doing the same arithmetic in the node graph, behind
// a uniform, lets one material serve both cameras without being recompiled between them. // a uniform, lets one material serve both cameras without being recompiled between them.
this.material.sizeAttenuation = true; this.material.sizeAttenuation = true;
const position = instancedBufferAttribute<'vec3'>(positionAttribute, 'vec3'); const position = instancedBufferAttribute<'vec3'>(this.positionAttribute, 'vec3');
const angularSize = instancedBufferAttribute<'float'>(sizeAttribute, 'float'); const angularSize = instancedBufferAttribute<'float'>(this.sizeAttribute, 'float');
this.material.positionNode = position; this.material.positionNode = position;
// Perspective: a star's world size is its angular size times how far away it is, which is // Perspective: a star's world size is its angular size times how far away it is, which is
// exactly what the built-in does. Orthographic: distance does not set apparent size at all, // exactly what the built-in does. Orthographic: distance does not set apparent size at all,
// the frustum does, so the same angular size is scaled by the frustum instead. // the frustum does, so the same angular size is scaled by the frustum instead.
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate(); const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective)); this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective));
this.material.colorNode = instancedBufferAttribute<'vec3'>(colorAttribute, 'vec3'); this.material.colorNode = instancedBufferAttribute<'vec3'>(this.colorAttribute, 'vec3');
// Soft radial falloff so each star is a small bright core inside a halo, rather than a // Soft radial falloff so each star is a small bright core inside a halo, rather than a
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre. // hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
const radius = uv().sub(vec2(0.5)).length(); const radius = uv().sub(vec2(0.5)).length();
@@ -230,6 +299,42 @@ export class StarFieldRenderer {
// The quad's own bounds sit at the origin and say nothing about where the instances are, // The quad's own bounds sit at the origin and say nothing about where the instances are,
// so leaving culling on would drop the whole field whenever the origin left the frustum. // so leaving culling on would drop the whole field whenever the origin left the frustum.
this.object.frustumCulled = false; this.object.frustumCulled = false;
this.refocus({});
}
/** How many of the catalogue's stars this field is drawing. */
get drawnCount(): number {
return this.drawn.length;
}
/**
* Chooses the drawn stars again for where the view now is, and rewrites the instance buffers
* with them. See {@link selectDrawnStars}.
*/
refocus(focus: { centre?: Positioned; pinnedIds?: readonly number[] }): void {
let pinned: number[] = [];
if (focus.pinnedIds?.length) {
this.indexById ??= new Map(this.catalogue.map((star, index) => [star.id, index]));
pinned = focus.pinnedIds.map((id) => this.indexById!.get(id)).filter((index): index is number => index !== undefined);
}
this.drawn = selectDrawnStars(this.catalogue, this.budget, { centre: focus.centre, pinned }, this.order);
const positions = this.positionAttribute.array as Float32Array;
const colors = this.colorAttribute.array as Float32Array;
const sizes = this.sizeAttribute.array as Float32Array;
this.drawn.forEach((catalogueIndex, instance) => {
for (let axis = 0; axis < 3; axis++) {
positions[instance * 3 + axis] = this.cataloguePositions[catalogueIndex * 3 + axis];
colors[instance * 3 + axis] = this.catalogueColors[catalogueIndex * 3 + axis];
}
sizes[instance] = this.catalogueSizes[catalogueIndex];
});
this.geometry.instanceCount = this.drawn.length;
this.positionAttribute.needsUpdate = true;
this.colorAttribute.needsUpdate = true;
this.sizeAttribute.needsUpdate = true;
} }
/** /**
@@ -245,9 +350,9 @@ export class StarFieldRenderer {
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360); this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
} }
/** Looks up the HYG star id for a given instance index. */ /** Looks up the star id for a given instance index. */
starIdAt(instanceIndex: number): number | undefined { starIdAt(instanceIndex: number): number | undefined {
return this.stars[instanceIndex]?.id; return instanceIndex >= 0 && instanceIndex < this.drawn.length ? this.catalogue[this.drawn[instanceIndex]].id : undefined;
} }
/** /**
@@ -269,13 +374,14 @@ export class StarFieldRenderer {
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera; const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360); const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360);
const projected = new THREE.Vector3(); const projected = new THREE.Vector3();
const positions = this.positionAttribute.array as Float32Array;
const sizes = this.sizeAttribute.array as Float32Array;
let bestIndex: number | undefined; let bestIndex: number | undefined;
let bestScore = Infinity; let bestScore = Infinity;
for (let index = 0; index < this.stars.length; index++) { for (let index = 0; index < this.drawn.length; index++) {
const star = this.stars[index]; projected.set(positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]).project(camera);
projected.set(star.x, star.y, star.z).project(camera);
// Outside the depth range means behind the camera or beyond the far plane; `project` // Outside the depth range means behind the camera or beyond the far plane; `project`
// mirrors points behind the camera onto the screen, so this guard is load-bearing. // mirrors points behind the camera onto the screen, so this guard is load-bearing.
if (projected.z < -1 || projected.z > 1) { if (projected.z < -1 || projected.z > 1) {
@@ -284,7 +390,7 @@ export class StarFieldRenderer {
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y, // A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular. // divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP; const ndcRadius = (0.5 * sizes[index]) / tanHalfFov + PICK_NDC_SLOP;
const dx = (projected.x - pointerNdc.x) * aspect; const dx = (projected.x - pointerNdc.x) * aspect;
const dy = projected.y - pointerNdc.y; const dy = projected.y - pointerNdc.y;
const score = Math.hypot(dx, dy) / ndcRadius; const score = Math.hypot(dx, dy) / ndcRadius;
@@ -295,7 +401,7 @@ export class StarFieldRenderer {
} }
} }
return bestIndex === undefined ? undefined : this.stars[bestIndex].id; return bestIndex === undefined ? undefined : this.starIdAt(bestIndex);
} }
dispose(): void { dispose(): void {
+4 -1
View File
@@ -24,7 +24,10 @@ export interface Positioned {
* sort is required to be stable, exactly as the sort of the stars themselves was. * sort is required to be stable, exactly as the sort of the stars themselves was.
*/ */
export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array { export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array {
return Uint32Array.from(stars.keys()).sort((a, b) => stars[a].magnitude - stars[b].magnitude); // Compared from a typed copy rather than off the stars: the sort reads two magnitudes per
// comparison, some eight million times for the whole catalogue: 83 ms this way, 104-139 ms reading them off the stars.
const magnitudes = Float64Array.from(stars, (star) => star.magnitude);
return Uint32Array.from(stars.keys()).sort((a, b) => magnitudes[a] - magnitudes[b]);
} }
/** /**