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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-16 14:11:42 +02:00
co-authored by Claude Opus 5
parent 0a0b301807
commit 2d997e41db
5 changed files with 378 additions and 104 deletions
@@ -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()) {