Draw what the camera shows: the budget goes to the stars in view

The drawn set was two spheres, around the view's centre and around the Sun, then the brightest
stars anywhere, so most of the budget sat behind or beside the camera: at 30 pc from the Sun
15.8% of the drawn stars were on screen, at 5 pc 9.1%, in a plan view zoomed to 10 pc 3.8%.

The same tiers are now taken only from the camera's frame, widened by a quarter
(VIEW_MARGIN), with the planet hosts in view drawn first after the pinned stars, so every ring
circles a star that can be clicked. The set is chosen again at the label cadence once the view
has turned, zoomed or moved half the margin, switched projection or been resized, and once for
the whole sky on the way out to the Galaxy. Drawn stars on screen: 74-76% at 30 pc, 73-75% at
5 pc, 66% in the zoomed plan view, 91.5% at the opening view.

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-17 17:04:08 +02:00
co-authored by Claude Opus 5
parent 7064e34d02
commit 6cd0666067
4 changed files with 335 additions and 58 deletions
@@ -37,7 +37,7 @@ import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../sear
import { StarmapHudComponent } from './starmap-hud.component';
import { SystemObjectCardComponent } from './system-object-card.component';
import { RoutingClient } from './routing-client';
import { colorIndexToRgb, FOCUS_RADIUS_PC, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
import { colorIndexToRgb, FOCUS_RADIUS_PC, StarFieldRenderer, starRenderBudgetFromUrl, VIEW_MARGIN } from './star-field-renderer';
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';
@@ -121,9 +121,9 @@ 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.
* The furthest 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. Closer in, half the frame's margin is the tighter limit. See `refocusStarField`.
*/
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. */
@@ -323,9 +323,19 @@ 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;
/**
* The view the star field last chose its stars for, and which it was told to keep. See
* `refocusStarField`. `undefined` chooses again on the next pass; `null` means the last choice
* was made at galactic scale, for the whole sky.
*/
private starFieldCamera: SceneCamera | null | undefined;
private readonly starFieldQuaternion = new THREE.Quaternion();
private readonly starFieldFocus = new THREE.Vector3();
private starFieldHalfHeight = 0;
private starFieldPins = '';
private readonly starFieldView = new THREE.Matrix4();
/** 1 for each catalogue index with known planets, which the star field draws ahead of the rest in view. */
private hostStars = new Uint8Array(0);
private hostRings?: HostStarRings;
/** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */
private neighbourhood?: StarNeighbourhood;
@@ -555,8 +565,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
));
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search), this.starsByBrightness);
// 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.hostStars = Uint8Array.from(stars, (star) => (this.starIdsWithBodies.has(star.id) ? 1 : 0));
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);
@@ -632,7 +641,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
this.labelUpdateAccumulator = 0;
if (this.galaxyGroup.visible) {
this.refocusStarField();
this.refocusStarField(camera);
this.updateLabels(camera);
} else if (this.systemGroup.visible) {
this.updateSystemLabels(camera);
@@ -783,30 +792,69 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}
/**
* 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.
* Keeps the drawn stars those the camera shows: the ones the map is pointing at wherever they
* are, then of what is in frame, the planet hosts, the neighbourhoods of the view's centre and of
* the Sun, and the brightest. See `selectDrawnStars`.
*
* Chosen for a frame widened by `VIEW_MARGIN`, and chosen again, at the label cadence, once the
* view could have used up half that margin: turned, zoomed or moved by half of it, switched
* projection or resized. So a turn slower than a margin every two passes, about 25° a second,
* brings no empty edge into view. Two things outrun it: stars much nearer the camera than the
* view's centre, which an orbit sweeps across the frame faster than it turns, and deep stars
* under a zoomed-in plan view, which a turn moves by their depth.
*/
private refocusStarField(): void {
// 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) {
private refocusStarField(camera: SceneCamera): void {
if (!this.starField || !this.neighbourhood) {
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;
}
// 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();
const pinned = () => pinnedIds.map((id) => neighbourhood.indexOf(id)).filter((index): index is number => index !== undefined);
// At galactic scale the whole catalogue is a smudge a few pixels across, and the view sweeps
// hundreds of parsecs a pass: chosen once for the whole sky on the way out, then left alone,
// rather than frozen on whatever narrow frame the zoom-out last passed through.
if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) {
if (this.starFieldCamera !== null || pins !== this.starFieldPins) {
this.starField.refocus({ pinned: pinned(), hosts: this.hostStars });
this.starFieldCamera = null;
this.starFieldPins = pins;
this.scheduleJumpLinks();
}
return;
}
const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(centre));
// The turn that moves a star at the frame's edge half the margin further out. Under a plan
// view a turn moves a star by its depth times the angle instead, so the deepest star the
// catalogue draws sets the limit too.
const tanHalfFov = Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360);
let turnLimit = (Math.atan((1 + VIEW_MARGIN) * tanHalfFov) - Math.atan(tanHalfFov)) / 2;
if (this.engine.currentProjection === 'orthographic') {
turnLimit = Math.min(turnLimit, ((VIEW_MARGIN / 2) * halfHeight) / (SURVEY_EDGE_PC + centre.length()));
}
if (
camera === this.starFieldCamera &&
pins === this.starFieldPins &&
camera.quaternion.angleTo(this.starFieldQuaternion) <= turnLimit &&
Math.abs(halfHeight / this.starFieldHalfHeight - 1) <= VIEW_MARGIN / 2 &&
this.starFieldFocus.distanceTo(centre) <= Math.min(STAR_FIELD_REFOCUS_PC, (VIEW_MARGIN / 2) * halfHeight)
) {
return;
}
// The tick runs before the frame is drawn, so the camera's matrices can still be last frame's.
camera.updateMatrixWorld();
this.starFieldView.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
this.starField.refocus({ centre, pinned: pinned(), hosts: this.hostStars, view: this.starFieldView });
this.starFieldCamera = camera;
this.starFieldQuaternion.copy(camera.quaternion);
this.starFieldFocus.copy(centre);
this.starFieldHalfHeight = halfHeight;
this.starFieldPins = pins;
// The graph links the drawn stars, so a new set wants a new graph once it stops changing.
this.scheduleJumpLinks();
@@ -1749,6 +1797,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const { width, height } = entry.contentRect;
this.engine.resize(width, height);
this.labelOverlay?.setSize(width, height);
// A new shape of frame: the stars chosen for the old one no longer fill it.
this.starFieldCamera = undefined;
});
this.resizeObserver.observe(canvas);
}