From b071d87d8a5888c614175b457c24c3a21876ac62 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Wed, 16 Sep 2026 15:04:51 +0200 Subject: [PATCH 1/2] Answer the review: walk the brightness order in memory order, and stop at the fifteenth label The adversarial review confirmed a regression in this PR. Near the Sun, the label pass became three to four times slower than the scan and sort it replaced. Within about 11 pc of the Sun, and in any plan view zoomed tighter than that, the label radius clamps to 4 pc. That sphere holds a few dozen faint dwarfs deep in the brightness order, so the walk rarely finds fifteen stars to name and reads nearly the whole catalogue. Reading the star objects in brightness order jumps all over memory, so a full walk took 19-25 ms against the old 5-6 ms. The review also found that spreadLabels checked the label count at the top of its loop. After placing the fifteenth label it asked for a sixteenth candidate, which near the Sun can lie at the far end of the order. brightnessIndex now lays each star's position and id out beside the brightness order, in that order. The walk tests stars from those arrays in sequence and reads a star object only when it yields one. spreadLabels breaks straight after placing the fifteenth label. Measured on the real catalogue with the label logic reduced to what decides placement, camera at the given distance from the Sun (old sort / this PR as first pushed / now): 2 pc 4.9 / 24.7 / 2.5 ms 5 pc 5.7 / 23.0 / 3.1 ms 10 pc 6.3 / 18.2 / 0.95 ms 307 pc 22 / 0.01 / 0.00 ms (the opening view) The labels are identical in every case. Now faster than the old sort at every distance. A new scene test counts the candidates spreadLabels takes: exactly fifteen for fifteen labels. Negative controls, each caught: positions one axis off, ids in catalogue order, the selected star dropped, the radius edge excluded, and the count checked before taking a candidate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .../galaxy-system-scene.component.spec.ts | 24 ++++++++++ .../galaxy-system-scene.component.ts | 19 ++++---- src/app/shared/astro/brightest.spec.ts | 10 ++-- src/app/shared/astro/brightest.ts | 48 +++++++++++++++---- 4 files changed, 79 insertions(+), 22 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..b01499c 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 @@ -211,6 +211,30 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect(navigationStore.viewLevel()).toBe('galaxy'); }); + 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. + const component = fixture.componentInstance as unknown as { + spreadLabels(candidates: Iterable<{ id: number; name: string; x: number; y: number; z: number }>, camera: THREE.Camera, keepId: null): unknown[]; + }; + const camera = engine.getCamera(); + camera.updateMatrixWorld(true); + camera.updateProjectionMatrix(); + let pulled = 0; + const grid = function* () { + for (let row = 0; row < 5; row++) { + for (let column = 0; column < 5; column++) { + pulled++; + const point = new THREE.Vector3(-0.8 + column * 0.4, -0.8 + row * 0.4, 0.5).unproject(camera); + yield { id: row * 5 + column, name: `label-${pulled}`, x: point.x, y: point.y, z: point.z }; + } + } + }; + + expect(component.spreadLabels(grid(), camera, null)).toHaveLength(15); + expect(pulled).toBe(15); + }); + 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..8b20638 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -38,7 +38,7 @@ import { StarmapHudComponent } from './starmap-hud.component'; import { SystemObjectCardComponent } from './system-object-card.component'; import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer'; import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links'; -import { brightestWithin, brightnessOrder } from '../../shared/astro/brightest'; +import { BrightnessIndex, brightestWithin, brightnessIndex } from '../../shared/astro/brightest'; import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood'; import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component'; import { HostStarRings } from './host-star-rings'; @@ -358,7 +358,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { /** Stars with at least one catalogued body, which are the ones the map can be flown into. */ private starIdsWithBodies = new Set(); /** Catalogue indices, brightest first, for the labels to walk rather than sort. See `brightestWithin`. */ - private starsByBrightness: Uint32Array = new Uint32Array(0); + private starsByBrightness: BrightnessIndex = brightnessIndex([]); /** Stars alone, normalised once, for the two routing fields. Empty until the catalogue lands. */ private readonly starSearchIndex = signal([]); private milkyWay?: MilkyWayRenderer; @@ -521,7 +521,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.stars = stars; this.starsById = new Map(stars.map((star) => [star.id, star])); this.neighbourhood = new StarNeighbourhood(stars); - this.starsByBrightness = brightnessOrder(stars); + this.starsByBrightness = brightnessIndex(stars); this.starSearchIndex.set( buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id }))) ); @@ -785,8 +785,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // for anything with catalogued bodies: it is the one distinction the second line can draw that // the map cannot otherwise show, since it says which of these points is somewhere you can go. const starIdsWithBodies = this.starIdsWithBodies; - const candidates = function* (stars: readonly StarRecord[], order: Uint32Array): Generator { - for (const star of brightestWithin(stars, order, target, labelRadius, selectedId)) { + const candidates = function* (stars: readonly StarRecord[], index: BrightnessIndex): Generator { + for (const star of brightestWithin(stars, index, target, labelRadius, selectedId)) { yield { id: star.id, name: star.name, kind: starIdsWithBodies.has(star.id) ? 'System' : 'Star', x: star.x, y: star.y, z: star.z }; } }; @@ -821,10 +821,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { const projected = new THREE.Vector3(); for (const candidate of candidates) { - if (chosen.length >= LABEL_MAX_COUNT) { - break; - } - projected.set(candidate.x, candidate.y, candidate.z).project(camera); const isKept = candidate.id === keepId; // Offscreen or behind the camera. @@ -851,6 +847,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { placed.push(point); chosen.push({ ...candidate, side }); + // Here rather than at the top of the loop: there, taking the fifteenth label asked the + // candidates for a sixteenth first, and near the Sun finding one walks most of the catalogue. + if (chosen.length >= LABEL_MAX_COUNT) { + break; + } } return chosen; diff --git a/src/app/shared/astro/brightest.spec.ts b/src/app/shared/astro/brightest.spec.ts index 7264a9f..a196160 100644 --- a/src/app/shared/astro/brightest.spec.ts +++ b/src/app/shared/astro/brightest.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { brightestWithin, brightnessOrder } from './brightest'; +import { brightestWithin, brightnessIndex, brightnessOrder } from './brightest'; interface TestStar { id: number; @@ -46,11 +46,11 @@ describe('brightestWithin', () => { it('yields exactly what filtering and then sorting the catalogue did, in the same order', () => { const stars = cloud(3000); - const order = brightnessOrder(stars); + const index = brightnessIndex(stars); const centre = { x: 12, y: -30, z: 5 }; for (const [radius, alwaysId] of [[40, null], [15, 7 * 2999], [0, 7 * 11], [500, null]] as const) { - const lazy = Array.from(brightestWithin(stars, order, centre, radius, alwaysId), (star) => star.id); + const lazy = Array.from(brightestWithin(stars, index, centre, radius, alwaysId), (star) => star.id); expect(lazy).toEqual(filterThenSort(stars, centre, radius, alwaysId)); } }); @@ -61,7 +61,7 @@ describe('brightestWithin', () => { { id: 2, x: 3, y: 4.001, z: 0, magnitude: 0 } ]; - expect(Array.from(brightestWithin(stars, brightnessOrder(stars), { x: 0, y: 0, z: 0 }, 5, null), (star) => star.id)).toEqual([1]); + expect(Array.from(brightestWithin(stars, brightnessIndex(stars), { x: 0, y: 0, z: 0 }, 5, null), (star) => star.id)).toEqual([1]); }); it('reads no further than the caller takes', () => { @@ -77,7 +77,7 @@ describe('brightestWithin', () => { }); const taken: number[] = []; - for (const star of brightestWithin(counted, brightnessOrder(stars), { x: 0, y: 0, z: 0 }, 1000, null)) { + for (const star of brightestWithin(counted, brightnessIndex(stars), { x: 0, y: 0, z: 0 }, 1000, null)) { taken.push(star.id); if (taken.length === 15) { break; diff --git a/src/app/shared/astro/brightest.ts b/src/app/shared/astro/brightest.ts index 9b38d04..141f916 100644 --- a/src/app/shared/astro/brightest.ts +++ b/src/app/shared/astro/brightest.ts @@ -27,6 +27,38 @@ export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array return Uint32Array.from(stars.keys()).sort((a, b) => stars[a].magnitude - stars[b].magnitude); } +/** + * The brightness order, with each star's position and id laid out beside it in that order. + * + * A walk has to test every star it passes, and near the Sun it passes nearly all of them: a 4 pc + * label radius holds a few dozen stars, faint dwarfs deep in the order, so the walk rarely finds + * fifteen to name before the end. Reading the stars themselves in brightness order jumps all over + * the catalogue, and a full walk took 19-23 ms — slower than the scan and sort it replaced. Read + * from these arrays, laid out in the order they are walked, the same walk touches memory in + * sequence and reads a star only when it yields one. + */ +export interface BrightnessIndex { + /** Indices into the catalogue, brightest first. */ + readonly order: Uint32Array; + /** Positions in the same order, three to a star, at full precision so a star on a radius stays on it. */ + readonly positions: Float64Array; + readonly ids: Float64Array; +} + +export function brightnessIndex(stars: readonly T[]): BrightnessIndex { + const order = brightnessOrder(stars); + const positions = new Float64Array(order.length * 3); + const ids = new Float64Array(order.length); + order.forEach((index, at) => { + const star = stars[index]; + positions[at * 3] = star.x; + positions[at * 3 + 1] = star.y; + positions[at * 3 + 2] = star.z; + ids[at] = star.id; + }); + return { order, positions, ids }; +} + /** * The stars within `radiusPc` of `centre`, brightest first, plus the one star `alwaysId` names * wherever it is — handed over lazily, so a caller that stops after the first few pays for no @@ -34,19 +66,19 @@ export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array */ export function* brightestWithin( stars: readonly T[], - order: Uint32Array, + index: BrightnessIndex, centre: Positioned, radiusPc: number, alwaysId: number | null ): Generator { + const { order, positions, ids } = index; const radiusSq = radiusPc * radiusPc; - for (const index of order) { - const star = stars[index]; - const dx = star.x - centre.x; - const dy = star.y - centre.y; - const dz = star.z - centre.z; - if (dx * dx + dy * dy + dz * dz <= radiusSq || star.id === alwaysId) { - yield star; + for (let at = 0; at < order.length; at++) { + const dx = positions[at * 3] - centre.x; + const dy = positions[at * 3 + 1] - centre.y; + const dz = positions[at * 3 + 2] - centre.z; + if (dx * dx + dy * dy + dz * dz <= radiusSq || ids[at] === alwaysId) { + yield stars[order[at]]; } } } 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];