From 2d997e41dbb68a652e4a77a0fe053c07573ab44c Mon Sep 17 00:00:00 2001 From: Senrokai Date: Wed, 16 Sep 2026 14:11:42 +0200 Subject: [PATCH 1/2] 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) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .../galaxy-system-scene.component.spec.ts | 28 ++ .../galaxy-system-scene.component.ts | 36 ++- .../galaxy-system/star-field-renderer.spec.ts | 115 ++++++- .../galaxy-system/star-field-renderer.ts | 298 ++++++++++++------ src/app/shared/astro/brightest.ts | 5 +- 5 files changed, 378 insertions(+), 104 deletions(-) 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 d165b1c..13ebe9f 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. @@ -211,6 +212,33 @@ 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('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 () => { navigationStore.selectStar(SUN.id); await flushAsync(); 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 63bf8a0..c6a2d14 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 { brightestWithin, brightnessOrder } 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,7 @@ 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); 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 +620,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 +770,28 @@ 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 { + 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 { 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..96264d2 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,113 @@ 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 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(); + }); +}); diff --git a/src/app/features/galaxy-system/star-field-renderer.ts b/src/app/features/galaxy-system/star-field-renderer.ts index 8220d43..770d8a9 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,49 @@ 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; + /** Built the first time a star is pinned by id, since nothing else needs it. */ + private indexById?: Map; + /** + * 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 +281,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 +299,42 @@ 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: { 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); } - /** 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 +374,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 +390,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 +401,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 9b38d04..5ec645d 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]); } /** From 86e143131ea2a596016403747c2cd218fdac3f0e Mon Sep 17 00:00:00 2001 From: Senrokai Date: Wed, 16 Sep 2026 15:12:46 +0200 Subject: [PATCH 2/2] Answer the review: pin by the index the neighbourhood holds, and choose again only when it can matter The adversarial review confirmed three costs this PR added, all reproduced in the browser. - The first pinned refocus stalled the first flight of a session. The renderer built its own id-to-index Map of 423 651 entries the first time a star was pinned, which is at the first selection, inside the approach flight. The worst frame was 47-103 ms, and the Map stayed as a second copy of a lookup the scene already had. The scene now pins by catalogue index, through the StarNeighbourhood it builds at load (new `indexOf`), and the renderer takes indices. First selection, measured in the browser: worst frame 18 ms. - At galactic scale every label pass rewrote the drawn set. The view centre sweeps hundreds of parsecs a pass there, far past any star, so each pass chose the same 70 000 stars again and uploaded 2 MB to the GPU: 11 times on the flight out to the Galaxy. The scene no longer refocuses at galactic scale, where the whole catalogue is a few pixels, and the renderer leaves its buffers alone when the drawn set is unchanged. Flight to the Galaxy: 2 refocuses, no frame over 50 ms. - At load the same set was chosen twice: once by the renderer's constructor around the Sun, and again by the first label pass, centred on the Sun. The scene now records the constructor's choice as the current focus. Tests: the buffers keep their version for an unchanged set, no refocus at load, none at galactic scale, and pins arrive as indices. Proxima's id in the scene spec now differs from its index, so a lookup by id cannot pass for one by index. Negative controls, each caught: an unchanged set rewritten anyway, a refocus at galactic scale, the boot choice not recorded, and pins passed as ids. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .../galaxy-system-scene.component.spec.ts | 31 +++++++++++++++++-- .../galaxy-system-scene.component.ts | 12 +++++-- .../galaxy-system/star-field-renderer.spec.ts | 19 ++++++++++-- .../galaxy-system/star-field-renderer.ts | 15 +++++---- src/app/shared/astro/star-neighbourhood.ts | 5 +++ 5 files changed, 67 insertions(+), 15 deletions(-) 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 333d567..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 @@ -23,7 +23,8 @@ import { StarFieldRenderer } from './star-field-renderer'; 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])); @@ -227,6 +228,31 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { 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'); @@ -235,7 +261,8 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { 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]); + // 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(); }); 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 bbb5723..7feb5a6 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -545,6 +545,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { )); 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); @@ -777,7 +779,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { * of it is always drawn, however faint. */ private refocusStarField(): void { - if (!this.starField) { + // 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; @@ -787,7 +791,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { if (this.starFieldFocus && this.starFieldFocus.distanceTo(centre) <= STAR_FIELD_REFOCUS_PC && pins === this.starFieldPins) { return; } - this.starField.refocus({ centre, pinnedIds }); + // 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; } 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 96264d2..5b7e2f0 100644 --- a/src/app/features/galaxy-system/star-field-renderer.spec.ts +++ b/src/app/features/galaxy-system/star-field-renderer.spec.ts @@ -353,10 +353,10 @@ describe('StarFieldRenderer refocus', () => { renderer.dispose(); }); - it('draws a star pinned by id, and passes over ids the catalogue does not hold', () => { + 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({ pinnedIds: [123456, 77] }); + renderer.refocus({ pinned: [123456, 0] }); const drawnIds = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i)); expect(drawnIds).toContain(77); @@ -366,7 +366,7 @@ describe('StarFieldRenderer refocus', () => { 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] }); + 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++) { @@ -382,6 +382,19 @@ describe('StarFieldRenderer refocus', () => { 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. diff --git a/src/app/features/galaxy-system/star-field-renderer.ts b/src/app/features/galaxy-system/star-field-renderer.ts index 770d8a9..8379d0f 100644 --- a/src/app/features/galaxy-system/star-field-renderer.ts +++ b/src/app/features/galaxy-system/star-field-renderer.ts @@ -228,8 +228,6 @@ export class StarFieldRenderer { private readonly material: THREE.SpriteNodeMaterial; private readonly budget: number; private readonly order: Uint32Array; - /** Built the first time a star is pinned by id, since nothing else needs it. */ - private indexById?: Map; /** * 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. @@ -312,13 +310,14 @@ export class StarFieldRenderer { * 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); + 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 = selectDrawnStars(this.catalogue, this.budget, { centre: focus.centre, pinned }, this.order); + this.drawn = drawn; const positions = this.positionAttribute.array as Float32Array; const colors = this.colorAttribute.array as Float32Array; 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];