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
@@ -1,7 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import * as THREE from 'three/webgpu';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from 'vitest';
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
import { EngineService, EngineTickCallback } from '../../core/engine/engine.service';
@@ -10,6 +10,7 @@ import { DeepSkyRecord } from '../../shared/models/deepsky.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store';
import { HudDisplay } from '../hud/hud-dock.component';
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
import { JumpLinkRenderer } from './jump-link-renderer';
import { StarFieldRenderer } from './star-field-renderer';
@@ -97,14 +98,18 @@ class FakeEngineService {
setProjection(projection: 'perspective' | 'orthographic', distanceToTarget: number): void {
this.projection = projection;
this.orthographic.zoom = 1;
this.orthographic.position.copy(this.camera.position);
this.orthographic.quaternion.copy(this.camera.quaternion);
this.frameOrthographic(distanceToTarget);
}
frameOrthographic(distanceToTarget: number): void {
const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((this.camera.fov * Math.PI) / 360);
this.orthographic.top = halfHeight;
this.orthographic.bottom = -halfHeight;
this.orthographic.left = -halfHeight * this.camera.aspect;
this.orthographic.right = halfHeight * this.camera.aspect;
this.orthographic.zoom = 1;
this.orthographic.position.copy(this.camera.position);
this.orthographic.quaternion.copy(this.camera.quaternion);
this.orthographic.updateProjectionMatrix();
}
@@ -229,29 +234,130 @@ 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');
describe('the drawn stars, chosen for what the camera shows', () => {
type ViewScene = { controls: { target: THREE.Vector3; update(): void }; display: { update(change: (display: HudDisplay) => HudDisplay): void } };
let refocus: MockInstance<StarFieldRenderer['refocus']>;
beforeEach(() => {
refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
});
afterEach(() => refocus.mockRestore());
/** Swings the camera about the view's centre, around the scene's vertical, by `degrees`. */
function orbit(component: ViewScene, degrees: number): void {
const camera = engine.getCamera();
const target = component.controls.target;
camera.position.sub(target).applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(degrees)).add(target);
component.controls.update();
}
it('chooses them for the opening view on the first pass, planet hosts included', async () => {
await advanceFrames(engine, 0.6);
expect(refocus).not.toHaveBeenCalled();
refocus.mockRestore();
expect(refocus).toHaveBeenCalledTimes(1);
const [focus] = refocus.mock.calls[0];
expect(focus.view).toBeDefined();
// The Sun has Earth, so it is a host; the others have nothing catalogued.
expect(Array.from(focus.hosts ?? [])).toEqual([1, 0, 0]);
});
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);
it('chooses again once the camera has turned half the margin, and not for less', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
await advanceFrames(engine, 0.3);
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
orbit(component, 1);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(1);
orbit(component, 3);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(2);
});
it('chooses again once a pan has moved the view further than a fifth of the neighbourhood, and not for less', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
const camera = engine.getCamera();
// Camera and centre together, so the camera neither turns nor zooms.
const pan = (pc: number) => {
component.controls.target.x += pc;
camera.position.x += pc;
component.controls.update();
};
await advanceFrames(engine, 0.3);
pan(3);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(1);
pan(3);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(2);
});
it('chooses again once a zoom has changed the frame by half the margin, and not for less', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
const camera = engine.getCamera();
const dolly = (factor: number) => camera.position.sub(component.controls.target).multiplyScalar(factor).add(component.controls.target);
await advanceFrames(engine, 0.3);
dolly(0.95);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(1);
dolly(0.8);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(2);
});
it('chooses again for the plan view, where a small turn moves deep stars furthest', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
// About 10 pc of frame either side of the centre.
engine.getCamera().position.setLength(21.4);
component.controls.update();
await advanceFrames(engine, 0.3);
const beforePlan = refocus.mock.calls.length;
component.display.update((display) => ({ ...display, plan: true }));
TestBed.tick();
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(beforePlan + 1);
// Harmless under perspective; under the plan it moves a star 250 pc deep by 4 pc, against a 2.5 pc margin.
orbit(component, 1);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(beforePlan + 2);
});
it('chooses again when the projection changes under a pose that has not moved at all', async () => {
await advanceFrames(engine, 0.3);
const before = refocus.mock.calls.length;
// The same place, direction and frame height, but a box instead of a frustum, which frames other stars.
const perspective = engine.getPerspectiveCamera();
const plan = (engine as unknown as { orthographic: THREE.OrthographicCamera }).orthographic;
plan.position.copy(perspective.position);
plan.quaternion.copy(perspective.quaternion);
engine.projection = 'orthographic';
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(before + 1);
});
it('chooses once for the whole sky on the way out to the Galaxy, then leaves them alone', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
engine.getCamera().position.set(0, 0, 30000);
await advanceFrames(engine, 0.3);
const onArrival = refocus.mock.calls.length;
expect(refocus.mock.calls.at(-1)![0].view).toBeUndefined();
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();
expect(refocus).toHaveBeenCalledTimes(onArrival);
expect(refocus.mock.calls.filter(([focus]) => focus.view === undefined)).toHaveLength(1);
});
});
it('keeps the stars of a plotted route drawn, and the selected star', async () => {
@@ -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);
}
@@ -335,6 +335,76 @@ describe('selectDrawnStars around the view', () => {
});
});
describe('selectDrawnStars in view', () => {
/** A star anywhere, with a given apparent magnitude. */
const at = (id: number, x: number, y: number, z: number, magnitude: number) => star({ id, x, y, z, magnitude });
/** What the camera shows, as the scene hands it over. */
const viewOf = (camera: THREE.Camera) => new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
/** Bright stars far in front of `testCamera`, spread across its frame. */
const brightAhead = (from: number, count = 30) => Array.from({ length: count }, (_, i) => at(from + i, (i - count / 2) * 5, 0, -400, 2));
/** Bright stars behind `testCamera`, which only a selection blind to the view would draw. */
const brightBehind = (from: number, count = 30) => Array.from({ length: count }, (_, i) => at(from + i, (i - count / 2) * 5, 0, 400, 2));
it('draws only what is in view, and a pinned star wherever it is', () => {
const ahead = Array.from({ length: 5 }, (_, i) => at(i, i * 10, 0, -240, 12));
const pinnedBehind = at(5, 0, 0, 240, 14);
const catalogue = [...ahead, pinnedBehind, ...brightBehind(6)];
const drawn = Array.from(selectDrawnStars(catalogue, 20, { pinned: [5], view: viewOf(testCamera()) }));
expect(drawn).toEqual([5, 0, 1, 2, 3, 4]);
});
it('reaches a quarter of the frame past its edges, and no further', () => {
// At 100 pc in front of a 55° camera the frame's half-height is 52 pc: 1.2 of it is 62.5 pc, 1.3 is 67.7.
const halfHeight = 100 * Math.tan((55 * Math.PI) / 360);
const catalogue = [at(0, 0, 1.2 * halfHeight, -100, 12), at(1, 0, 1.3 * halfHeight, -100, 12), ...brightBehind(2)];
const drawn = Array.from(selectDrawnStars(catalogue, 20, { view: viewOf(testCamera()) }));
expect(drawn).toEqual([0]);
});
it("draws the neighbourhood of the view's centre ahead of brighter stars, but only the part in view", () => {
const camera = new THREE.PerspectiveCamera(55, 16 / 9, 0.01, 5000);
camera.position.set(0, 0, -140);
camera.lookAt(0, 0, -1000);
camera.updateMatrixWorld(true);
const memberAhead = at(0, 0, 0, -160, 14);
const memberBehind = at(1, 0, 0, -130, 14);
const catalogue = [memberAhead, memberBehind, ...Array.from({ length: 30 }, (_, i) => at(2 + i, (i - 15) * 5, 0, -600, 2))];
const drawn = Array.from(selectDrawnStars(catalogue, 20, { centre: { x: 0, y: 0, z: -150 }, view: viewOf(camera) }));
expect(drawn[0]).toBe(0);
expect(drawn).not.toContain(1);
});
it('draws the planet hosts in view first after the pinned stars, and not those out of view', () => {
const hostAhead = at(0, 0, 0, -240, 14);
const hostBehind = at(1, 0, 0, 240, 14);
const nearSun = at(2, 0, 0, -10, 13);
const catalogue = [hostAhead, hostBehind, nearSun, ...brightAhead(3)];
const hosts = Uint8Array.from(catalogue, (_, index) => (index < 2 ? 1 : 0));
const drawn = Array.from(selectDrawnStars(catalogue, 3, { hosts, view: viewOf(testCamera()) }));
expect(drawn).toEqual([0, 2, 3]);
});
it('frames a plan view as a box, however deep: behind the camera included', () => {
const plan = new THREE.OrthographicCamera(-10, 10, 10, -10, -5000, 5000);
plan.position.set(0, 0, 0);
plan.lookAt(0, 0, -1);
plan.updateMatrixWorld(true);
const catalogue = [at(0, 0, 0, 50, 12), at(1, 12, 0, -50, 12), at(2, 13, 0, -50, 12), ...Array.from({ length: 30 }, (_, i) => at(3 + i, 500, i, 0, 2))];
const drawn = Array.from(selectDrawnStars(catalogue, 20, { view: viewOf(plan) }));
expect(drawn).toEqual([0, 1]);
});
});
describe('StarFieldRenderer refocus', () => {
const camera = testCamera();
/** A faint star straight ahead, 150 pc out, among bright ones well off to the side. */
@@ -408,4 +478,20 @@ describe('StarFieldRenderer refocus', () => {
expect(Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i))).not.toContain(77);
renderer.dispose();
});
it('drops a star from the drawn set, and from picking, once the camera has turned away from it', () => {
const renderer = new StarFieldRenderer(catalogue, positions, 10);
const view = (from: THREE.Camera) => new THREE.Matrix4().multiplyMatrices(from.projectionMatrix, from.matrixWorldInverse);
renderer.refocus({ centre: { x: 0, y: 0, z: -140 }, view: view(camera) });
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBe(77);
const turned = testCamera();
turned.lookAt(0, 0, 1);
turned.updateMatrixWorld(true);
renderer.refocus({ centre: { x: 0, y: 0, z: -140 }, view: view(turned) });
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
expect(Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i))).not.toContain(77);
renderer.dispose();
});
});
@@ -44,7 +44,7 @@ export const STAR_RENDER_BUDGET = 70_000;
/**
* Radius (parsecs) around the Sun, and around wherever the view is centred, inside which every
* star is drawn regardless of brightness.
* star in view is drawn regardless of brightness.
*
* A pure brightness cut would be defensible — apparent magnitude is exactly "how visible this
* is" — but it would drop the solar neighbourhood, because the nearest stars are overwhelmingly
@@ -62,6 +62,17 @@ export const STAR_RENDER_BUDGET = 70_000;
*/
export const FOCUS_RADIUS_PC = 25;
/**
* How far past the edges of the frame the drawn stars reach, as a share of the frame's half-width
* and half-height: 5° beyond the top and bottom at the 50° field of view, 6° beyond each side.
*
* The drawn set is chosen for a camera pose and kept until the view has turned or moved half this
* far, so the margin is what is on screen by the time it is chosen again. Wider stays whole
* through faster turns but spends the budget off screen: at 30 pc from the Sun, where the budget
* binds, 0.25 leaves 52 000 of the 70 000 on screen and 0.5 only 44 000.
*/
export const VIEW_MARGIN = 0.25;
/** What, besides the brightest stars, the field should be sure to draw. */
export interface DrawFocus {
/** Where the view is centred. Its neighbourhood is drawn whole, like the Sun's. */
@@ -71,6 +82,17 @@ export interface DrawFocus {
* of a plotted route. Anything the map points at has to be there to be pointed at.
*/
readonly pinned?: readonly number[];
/**
* 1 for each catalogue index with known planets. Drawn next after the pinned stars, however
* faint: each carries a ring, and a ring around a star that is not drawn circles nothing that
* can be clicked.
*/
readonly hosts?: Uint8Array;
/**
* The camera's projection times its view matrix. Only stars inside its frame, widened by
* {@link VIEW_MARGIN}, are drawn, pinned stars aside; without it, the whole sky is in view.
*/
readonly view?: THREE.Matrix4;
}
const SUN: Positioned = { x: 0, y: 0, z: 0 };
@@ -134,9 +156,10 @@ export function starRenderBudgetFromUrl(search: string, fallback = STAR_RENDER_B
/**
* Chooses which stars to draw when the catalogue is larger than the budget. In order, until the
* budget is spent: the pinned stars, everything within {@link FOCUS_RADIUS_PC} of where the view
* is centred, everything within it of the Sun, then the brightest of the rest. Each neighbourhood
* is taken brightest first, so a budget too small to hold one whole keeps its most visible part.
* budget is spent: the pinned stars wherever they are, then of the stars in view, the planet
* hosts, everything within {@link FOCUS_RADIUS_PC} of where the view is centred, everything within
* it of the Sun, and the brightest of the rest. Each tier is taken brightest first, so a budget too
* small to hold one whole keeps its most visible part.
*
* Returns indices into the original list, in the order they were chosen. `index` is the
* catalogue's brightness index, passed in when the caller already has it rather than sorted again
@@ -154,11 +177,14 @@ export function selectDrawnStars(
// One walk of the brightness order, reading positions laid out in that order, sorts each tier
// brightest first as it goes: 2.4-3.1 ms on the real catalogue in Node, against 6.4-8.5 ms
// gathering both neighbourhoods in catalogue order and sorting them. Beyond both neighbourhoods
// only the first `budget` stars can ever be taken, so past those it only looks for members.
// gathering both neighbourhoods in catalogue order and sorting them. Of the stars in no earlier
// tier only the first `budget` in view can ever be taken, so past those it looks for the tiers.
const { order, positions } = index;
const radiusSq = FOCUS_RADIUS_PC * FOCUS_RADIUS_PC;
const centre = focus.centre ?? SUN;
const view = focus.view?.elements;
const reachScale = 1 + VIEW_MARGIN;
const hosts: number[] = [];
const nearCentre: number[] = [];
const nearSun: number[] = [];
const rest: number[] = [];
@@ -169,13 +195,21 @@ export function selectDrawnStars(
const dx = x - centre.x;
const dy = y - centre.y;
const dz = z - centre.z;
if (dx * dx + dy * dy + dz * dz <= radiusSq) {
nearCentre.push(order[at]);
} else if (x * x + y * y + z * z <= radiusSq) {
nearSun.push(order[at]);
} else if (rest.length < budget) {
rest.push(order[at]);
const isHost = focus.hosts?.[order[at]] === 1;
const inCentre = dx * dx + dy * dy + dz * dz <= radiusSq;
const inSun = x * x + y * y + z * z <= radiusSq;
if (!isHost && !inCentre && !inSun && rest.length >= budget) {
continue;
}
if (view) {
// In clip space: in frame when |x| and |y| are within w, widened by the margin. Behind a
// perspective camera w is negative, so nothing there passes; an orthographic camera's w is 1.
const reach = (view[3] * x + view[7] * y + view[11] * z + view[15]) * reachScale;
if (Math.abs(view[0] * x + view[4] * y + view[8] * z + view[12]) > reach || Math.abs(view[1] * x + view[5] * y + view[9] * z + view[13]) > reach) {
continue;
}
}
(isHost ? hosts : inCentre ? nearCentre : inSun ? nearSun : rest).push(order[at]);
}
const chosen = new Uint8Array(stars.length);
@@ -191,6 +225,7 @@ export function selectDrawnStars(
take(pinned);
}
}
hosts.forEach(take);
nearCentre.forEach(take);
nearSun.forEach(take);
rest.forEach(take);