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];