diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index b01499c..1b94354 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -11,6 +11,7 @@ import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { StarRecord } from '../../shared/models/star.model'; import { NavigationStore } from '../../shared/state/navigation.store'; 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 // layout changes, which never happen in this headless test. @@ -22,7 +23,8 @@ import { GalaxySystemSceneComponent } from './galaxy-system-scene.component'; const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 }; const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y: 0, z: 0, magnitude: 4.4, spectralType: 'G2V', colorIndex: 0.7 }; -const PROXIMA: StarRecord = { id: 2, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 }; +// Its id deliberately differs from its place in STARS, so a lookup by id cannot pass for one by index. +const PROXIMA: StarRecord = { id: 42, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 }; const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA]; const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z])); @@ -211,6 +213,59 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { 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('does not choose the drawn stars again at load, where the renderer has just chosen them', async () => { + const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus'); + + await advanceFrames(engine, 0.6); + + expect(refocus).not.toHaveBeenCalled(); + refocus.mockRestore(); + }); + + it('leaves the drawn stars alone at galactic scale, however far the view centre sweeps', async () => { + const component = fixture.componentInstance as unknown as { controls: { target: THREE.Vector3 } }; + const camera = engine.getCamera(); + camera.position.set(0, 0, 30000); + await advanceFrames(engine, 0.3); + const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus'); + + component.controls.target.set(500, 0, 0); + await advanceFrames(engine, 0.3); + component.controls.target.set(1500, 0, 0); + await advanceFrames(engine, 0.3); + + expect(refocus).not.toHaveBeenCalled(); + 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); + + // As catalogue indices: the Sun is the first entry of STARS, Proxima the third. + expect(refocus.mock.calls.at(-1)![0].pinned).toEqual([0, 2]); + refocus.mockRestore(); + }); + it('asks for no more label candidates once the last label it will show is placed', () => { // Near the Sun a label candidate past the fifteenth can sit at the far end of the catalogue's // brightness order, so asking for one more than is used can cost a walk of the whole order. diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 8b20638..7feb5a6 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -36,7 +36,7 @@ import { RouteRequest, RouteResult, RouteStarOption } from '../hud/routes-panel. import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking'; import { StarmapHudComponent } from './starmap-hud.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 { BrightnessIndex, brightestWithin, brightnessIndex } from '../../shared/astro/brightest'; import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood'; @@ -117,6 +117,12 @@ const HUD_ACCENT = 0x4dd7ff; const DEEP_SKY_LABEL_COUNT = 12; /** How often (seconds) the visible label set is recomputed; doesn't need to be per-frame. */ 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. */ const CLICK_DRAG_SLOP_PX = 5; @@ -313,6 +319,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private controls?: OrbitControls; private rig?: CameraRigController; 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; /** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */ private neighbourhood?: StarNeighbourhood; @@ -535,7 +544,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { (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.order); + // It has just chosen around the Sun, which is where the view opens: the first label pass need not choose again. + this.starFieldFocus = GALAXY_OVERVIEW_TARGET.clone(); this.galaxyGroup.add(this.starField.object); this.hostRings = new HostStarRings(stars.filter((star) => this.starIdsWithBodies.has(star.id)), HUD_ACCENT); this.galaxyGroup.add(this.hostRings.object); @@ -611,6 +622,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) { this.labelUpdateAccumulator = 0; if (this.galaxyGroup.visible) { + this.refocusStarField(); this.updateLabels(camera); } else if (this.systemGroup.visible) { this.updateSystemLabels(camera); @@ -760,6 +772,34 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { 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 { + // At galactic scale the whole catalogue is a smudge a few pixels across, and the view's centre + // sweeps hundreds of parsecs a pass across empty space: nothing to choose, and nothing to see. + if (!this.starField || !this.neighbourhood || this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) { + 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; + } + // By catalogue index, through the lookup the neighbourhood already holds: building a second + // one of 423 651 entries on the first pin stalled the first flight of a session for 50-140 ms. + const neighbourhood = this.neighbourhood; + const pinned = pinnedIds.map((id) => neighbourhood.indexOf(id)).filter((index): index is number => index !== undefined); + this.starField.refocus({ centre, pinned }); + this.starFieldFocus = centre.clone(); + this.starFieldPins = pins; + } + private updateLabels(camera: SceneCamera): void { const radii = distanceRings(this.effectiveDistance(camera), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC); if (radii.join() !== this.localGridRadii.join()) { diff --git a/src/app/features/galaxy-system/star-field-renderer.spec.ts b/src/app/features/galaxy-system/star-field-renderer.spec.ts index ad56b43..5b7e2f0 100644 --- a/src/app/features/galaxy-system/star-field-renderer.spec.ts +++ b/src/app/features/galaxy-system/star-field-renderer.spec.ts @@ -260,11 +260,6 @@ describe('selectDrawnStars', () => { 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', () => { @@ -291,3 +286,126 @@ describe('StarFieldRenderer render budget', () => { 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 pinned star, and passes over an index past the end of the catalogue', () => { + const renderer = new StarFieldRenderer(catalogue, positions, 10); + + renderer.refocus({ pinned: [123456, 0] }); + + 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 }, pinned: [21] }); + 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('leaves the buffers alone when the drawn set has not changed, and rewrites them when it has', () => { + const renderer = new StarFieldRenderer(catalogue, positions, 10); + const { positionAttribute } = renderer as unknown as { positionAttribute: THREE.InstancedBufferAttribute }; + const version = positionAttribute.version; + + renderer.refocus({ centre: { x: 0, y: 0, z: 0 } }); + expect(positionAttribute.version).toBe(version); + + renderer.refocus({ centre: { x: 0, y: 0, z: -140 } }); + expect(positionAttribute.version).toBeGreaterThan(version); + 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(); + }); +}); diff --git a/src/app/features/galaxy-system/star-field-renderer.ts b/src/app/features/galaxy-system/star-field-renderer.ts index 8220d43..8379d0f 100644 --- a/src/app/features/galaxy-system/star-field-renderer.ts +++ b/src/app/features/galaxy-system/star-field-renderer.ts @@ -1,6 +1,7 @@ import * as THREE from 'three/webgpu'; 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 { SceneCamera } from '../../core/engine/engine.service'; 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. * - * 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 *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. Which stars fill + * the budget follows the view; see {@link selectDrawnStars}. * - * Currently set to the whole catalogue, which is what a GPU should be asked to do — this is one - * instanced draw call, and a discrete card will not notice it. The budget still exists because - * the catalogue is meant to grow past what any machine should draw at once: Gaia alone could - * contribute a million stars, and at that point the selection below is what keeps the field - * legible rather than a grey wash. - * - * Machines without a GPU do feel it. A software rasterizer measured here lost about a third of - * its frame rate per 12000 stars drawn; if that matters for a deployment, this is the one number - * to turn down. + * The number is set by what the field looks like, before what it costs. The catalogue is + * 423 651 stars since Gaia, and drawn whole the opening view is a grey wash: the additive + * blending of that many 1.5-pixel dots buries the labels, the rings on the planet hosts and the + * grid. At 150 000 the wash has begun; at this budget the view reads. Measured at 1920 × 1080 on + * 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 + * two-core Radeon, standing in for an entry-level laptop, pays about 4 ms a frame for every + * 100 000 stars: 112 frames a second at this budget, 44 at the whole catalogue, and the same + * 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 * 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 * 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. + * The same holds wherever the view is looking. Before the drawn set followed the view, a region + * 150 pc out drew 49 of the 442 stars within this radius of it, and a route plotted there ran + * 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 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. * * **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 * 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. + * The instance buffers hold the budget, not the catalogue, and are rewritten in place when + * {@link refocus} changes which stars fill it. */ -/** - * 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 { 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`. */ private readonly perspective = uniform(1); @@ -167,39 +226,47 @@ export class StarFieldRenderer { private readonly geometry: THREE.InstancedBufferGeometry; private readonly material: THREE.SpriteNodeMaterial; - /** 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; + private readonly budget: number; + private readonly order: Uint32Array; + /** + * 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) { - const drawn = selectDrawnStars(catalogue, budget); - this.stars = drawn.length === catalogue.length ? catalogue : Array.from(drawn, (index) => catalogue[index]); - this.drawnCount = this.stars.length; + /** Per-instance data, `budget` long; the first `drawnCount` entries are live. */ + private readonly positionAttribute: THREE.InstancedBufferAttribute; + private readonly colorAttribute: THREE.InstancedBufferAttribute; + 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; - this.geometry = createQuadGeometry(stars.length); + constructor( + 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.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)]); + this.positionAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3); + this.colorAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3); + this.sizeAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity), 1); - 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); - colors[index * 3] = color.r; - colors[index * 3 + 1] = color.g; - colors[index * 3 + 2] = color.b; - this.angularSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE; + this.catalogueColors[index * 3] = color.r; + this.catalogueColors[index * 3 + 1] = color.g; + this.catalogueColors[index * 3 + 2] = color.b; + 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({ transparent: true, depthWrite: false, @@ -212,15 +279,15 @@ export class StarFieldRenderer { // 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. this.material.sizeAttenuation = true; - const position = instancedBufferAttribute<'vec3'>(positionAttribute, 'vec3'); - const angularSize = instancedBufferAttribute<'float'>(sizeAttribute, 'float'); + const position = instancedBufferAttribute<'vec3'>(this.positionAttribute, 'vec3'); + const angularSize = instancedBufferAttribute<'float'>(this.sizeAttribute, 'float'); this.material.positionNode = position; // 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, // the frustum does, so the same angular size is scaled by the frustum instead. const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate(); 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 // hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre. const radius = uv().sub(vec2(0.5)).length(); @@ -230,6 +297,43 @@ export class StarFieldRenderer { // 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. 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: DrawFocus): void { + const drawn = selectDrawnStars(this.catalogue, this.budget, focus, this.order); + // The same stars in the same instances: the buffers already hold them, and a rewrite would + // upload 2 MB to the GPU for nothing — which a pan across empty space would do every pass. + if (drawn.length === this.drawn.length && drawn.every((index, instance) => index === this.drawn[instance])) { + return; + } + this.drawn = drawn; + + 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 +349,9 @@ export class StarFieldRenderer { 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 { - return this.stars[instanceIndex]?.id; + return instanceIndex >= 0 && instanceIndex < this.drawn.length ? this.catalogue[this.drawn[instanceIndex]].id : undefined; } /** @@ -269,13 +373,14 @@ export class StarFieldRenderer { const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera; const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360); const projected = new THREE.Vector3(); + const positions = this.positionAttribute.array as Float32Array; + const sizes = this.sizeAttribute.array as Float32Array; let bestIndex: number | undefined; let bestScore = Infinity; - for (let index = 0; index < this.stars.length; index++) { - const star = this.stars[index]; - projected.set(star.x, star.y, star.z).project(camera); + for (let index = 0; index < this.drawn.length; index++) { + projected.set(positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]).project(camera); // 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. if (projected.z < -1 || projected.z > 1) { @@ -284,7 +389,7 @@ export class StarFieldRenderer { // 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. - 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 dy = projected.y - pointerNdc.y; const score = Math.hypot(dx, dy) / ndcRadius; @@ -295,7 +400,7 @@ export class StarFieldRenderer { } } - return bestIndex === undefined ? undefined : this.stars[bestIndex].id; + return bestIndex === undefined ? undefined : this.starIdAt(bestIndex); } dispose(): void { diff --git a/src/app/shared/astro/brightest.ts b/src/app/shared/astro/brightest.ts index 141f916..6bdf9f3 100644 --- a/src/app/shared/astro/brightest.ts +++ b/src/app/shared/astro/brightest.ts @@ -24,7 +24,10 @@ export interface Positioned { * sort is required to be stable, exactly as the sort of the stars themselves was. */ 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]); } /** diff --git a/src/app/shared/astro/star-neighbourhood.ts b/src/app/shared/astro/star-neighbourhood.ts index a9139c0..8c12306 100644 --- a/src/app/shared/astro/star-neighbourhood.ts +++ b/src/app/shared/astro/star-neighbourhood.ts @@ -80,6 +80,11 @@ export class StarNeighbourhood { } /** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */ + /** Where the star this id names sits in the list the index was built from, or `undefined`. */ + indexOf(id: number): number | undefined { + return this.indexById.get(id); + } + point(id: number): StarPoint | undefined { const index = this.indexById.get(id); return index === undefined ? undefined : this.points[index];