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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -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 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 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 STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
|
||||||
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
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();
|
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 () => {
|
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 component = fixture.componentInstance as unknown as { routeResult: { set(value: unknown): void } };
|
||||||
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
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 });
|
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);
|
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();
|
refocus.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -545,6 +545,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
));
|
));
|
||||||
|
|
||||||
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search), this.starsByBrightness.order);
|
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.galaxyGroup.add(this.starField.object);
|
||||||
this.hostRings = new HostStarRings(stars.filter((star) => this.starIdsWithBodies.has(star.id)), HUD_ACCENT);
|
this.hostRings = new HostStarRings(stars.filter((star) => this.starIdsWithBodies.has(star.id)), HUD_ACCENT);
|
||||||
this.galaxyGroup.add(this.hostRings.object);
|
this.galaxyGroup.add(this.hostRings.object);
|
||||||
@@ -777,7 +779,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
* of it is always drawn, however faint.
|
* of it is always drawn, however faint.
|
||||||
*/
|
*/
|
||||||
private refocusStarField(): void {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
|
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) {
|
if (this.starFieldFocus && this.starFieldFocus.distanceTo(centre) <= STAR_FIELD_REFOCUS_PC && pins === this.starFieldPins) {
|
||||||
return;
|
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.starFieldFocus = centre.clone();
|
||||||
this.starFieldPins = pins;
|
this.starFieldPins = pins;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,10 +353,10 @@ describe('StarFieldRenderer refocus', () => {
|
|||||||
renderer.dispose();
|
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);
|
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));
|
const drawnIds = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i));
|
||||||
expect(drawnIds).toContain(77);
|
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', () => {
|
it('gives each drawn star its own colour and size, wherever the refocus put it', () => {
|
||||||
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
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 };
|
const { colorAttribute, sizeAttribute } = renderer as unknown as { colorAttribute: THREE.InstancedBufferAttribute; sizeAttribute: THREE.InstancedBufferAttribute };
|
||||||
|
|
||||||
for (let instance = 0; instance < renderer.drawnCount; instance++) {
|
for (let instance = 0; instance < renderer.drawnCount; instance++) {
|
||||||
@@ -382,6 +382,19 @@ describe('StarFieldRenderer refocus', () => {
|
|||||||
renderer.dispose();
|
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', () => {
|
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
|
// 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.
|
// reads the old one would leave clickable ghosts where nothing is drawn.
|
||||||
|
|||||||
@@ -228,8 +228,6 @@ export class StarFieldRenderer {
|
|||||||
private readonly material: THREE.SpriteNodeMaterial;
|
private readonly material: THREE.SpriteNodeMaterial;
|
||||||
private readonly budget: number;
|
private readonly budget: number;
|
||||||
private readonly order: Uint32Array;
|
private readonly order: Uint32Array;
|
||||||
/** Built the first time a star is pinned by id, since nothing else needs it. */
|
|
||||||
private indexById?: Map<number, number>;
|
|
||||||
/**
|
/**
|
||||||
* Colour and angular size of every star in the catalogue, worked out once: a refocus then only
|
* 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.
|
* 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
|
* Chooses the drawn stars again for where the view now is, and rewrites the instance buffers
|
||||||
* with them. See {@link selectDrawnStars}.
|
* with them. See {@link selectDrawnStars}.
|
||||||
*/
|
*/
|
||||||
refocus(focus: { centre?: Positioned; pinnedIds?: readonly number[] }): void {
|
refocus(focus: DrawFocus): void {
|
||||||
let pinned: number[] = [];
|
const drawn = selectDrawnStars(this.catalogue, this.budget, focus, this.order);
|
||||||
if (focus.pinnedIds?.length) {
|
// The same stars in the same instances: the buffers already hold them, and a rewrite would
|
||||||
this.indexById ??= new Map(this.catalogue.map((star, index) => [star.id, index]));
|
// upload 2 MB to the GPU for nothing — which a pan across empty space would do every pass.
|
||||||
pinned = focus.pinnedIds.map((id) => this.indexById!.get(id)).filter((index): index is number => index !== undefined);
|
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 positions = this.positionAttribute.array as Float32Array;
|
||||||
const colors = this.colorAttribute.array as Float32Array;
|
const colors = this.colorAttribute.array as Float32Array;
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ export class StarNeighbourhood {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */
|
/** 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 {
|
point(id: number): StarPoint | undefined {
|
||||||
const index = this.indexById.get(id);
|
const index = this.indexById.get(id);
|
||||||
return index === undefined ? undefined : this.points[index];
|
return index === undefined ? undefined : this.points[index];
|
||||||
|
|||||||
Reference in New Issue
Block a user