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:
2026-09-16 15:12:46 +02:00
co-authored by Claude Opus 5
parent c3fcb2e481
commit 86e143131e
5 changed files with 67 additions and 15 deletions
@@ -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<number, number>;
/**
* 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;