Merge pull request #27 from avalon-vanguard/feat/drawn-set-in-view

Draw what the camera shows: the budget goes to the stars in 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:
Senrokai
2026-09-17 18:06:12 +02:00
committed by GitHub
co-authored by Claude Opus 5
4 changed files with 424 additions and 64 deletions
@@ -1,7 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import * as THREE from 'three/webgpu'; 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 { DataLoaderService, StarField } from '../../core/data/data-loader.service';
import { EngineService, EngineTickCallback } from '../../core/engine/engine.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 { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store'; import { NavigationStore } from '../../shared/state/navigation.store';
import { HudDisplay } from '../hud/hud-dock.component';
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component'; import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
import { JumpLinkRenderer } from './jump-link-renderer'; import { JumpLinkRenderer } from './jump-link-renderer';
import { StarFieldRenderer } from './star-field-renderer'; import { StarFieldRenderer } from './star-field-renderer';
@@ -97,14 +98,18 @@ class FakeEngineService {
setProjection(projection: 'perspective' | 'orthographic', distanceToTarget: number): void { setProjection(projection: 'perspective' | 'orthographic', distanceToTarget: number): void {
this.projection = projection; 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); const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((this.camera.fov * Math.PI) / 360);
this.orthographic.top = halfHeight; this.orthographic.top = halfHeight;
this.orthographic.bottom = -halfHeight; this.orthographic.bottom = -halfHeight;
this.orthographic.left = -halfHeight * this.camera.aspect; this.orthographic.left = -halfHeight * this.camera.aspect;
this.orthographic.right = 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(); this.orthographic.updateProjectionMatrix();
} }
@@ -217,6 +222,9 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
it('chooses the drawn stars again once the view centre has moved, and not for a small drift', async () => { it('chooses the drawn stars again once the view centre has moved, and not for a small drift', async () => {
const component = fixture.componentInstance as unknown as { controls: { target: THREE.Vector3 } }; const component = fixture.componentInstance as unknown as { controls: { target: THREE.Vector3 } };
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus'); const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
// The first pass always chooses; what is under test is the move after it.
await advanceFrames(engine, 0.3);
refocus.mockClear();
component.controls.target.set(40, 0, 0); component.controls.target.set(40, 0, 0);
await advanceFrames(engine, 0.3); await advanceFrames(engine, 0.3);
@@ -229,29 +237,177 @@ 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 () => { describe('the drawn stars, chosen for what the camera shows', () => {
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus'); 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); await advanceFrames(engine, 0.6);
expect(refocus).not.toHaveBeenCalled(); expect(refocus).toHaveBeenCalledTimes(1);
refocus.mockRestore(); 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 () => { it('chooses again once the camera has turned half the margin, and not for less', async () => {
const component = fixture.componentInstance as unknown as { controls: { target: THREE.Vector3 } }; const component = fixture.componentInstance as unknown as ViewScene;
const camera = engine.getCamera();
camera.position.set(0, 0, 30000);
await advanceFrames(engine, 0.3); 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('holds a turn to the narrower side of a portrait frame', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
engine.getPerspectiveCamera().aspect = 0.4;
engine.getPerspectiveCamera().updateProjectionMatrix();
await advanceFrames(engine, 0.3);
// Inside half the margin above and below, past half of it at the sides.
orbit(component, 2);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(2);
});
it('keeps up with a flight frame by frame, from the frame it comes back into parsec space', async () => {
const component = fixture.componentInstance as unknown as ViewScene & { galaxyGroup: THREE.Group; rig: { isAnimating: boolean } };
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
refocus.mockClear();
navigationStore.selectStar(null);
await flushAsync();
let choicesOnReturningFrame = -1;
let flightFrames = 0;
let flightChoices = 0;
for (let frame = 0; frame < 80; frame++) {
const wasInSystem = !component.galaxyGroup.visible;
const before = refocus.mock.calls.length;
engine.tick(0.05);
await flushAsync(1);
if (wasInSystem && component.galaxyGroup.visible) {
choicesOnReturningFrame = refocus.mock.calls.length - before;
}
if (component.galaxyGroup.visible && component.rig.isAnimating) {
flightFrames++;
flightChoices += refocus.mock.calls.length - before;
}
}
// Chosen for the view in the very frame the camera jumps back, not up to a pass later.
expect(choicesOnReturningFrame).toBe(1);
// The return zooms out from inside the system to the opening view: more re-choices than one a
// pass could make, and every one of them for the view.
expect(flightChoices).toBeGreaterThan(Math.ceil((flightFrames * 0.05) / 0.2));
expect(refocus.mock.calls.every(([focus]) => focus.view !== undefined)).toBe(true);
});
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); component.controls.target.set(500, 0, 0);
await advanceFrames(engine, 0.3); await advanceFrames(engine, 0.3);
component.controls.target.set(1500, 0, 0); component.controls.target.set(1500, 0, 0);
await advanceFrames(engine, 0.3); await advanceFrames(engine, 0.3);
expect(refocus).not.toHaveBeenCalled(); expect(refocus).toHaveBeenCalledTimes(onArrival);
refocus.mockRestore(); 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 () => { it('keeps the stars of a plotted route drawn, and the selected star', async () => {
@@ -315,6 +471,20 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
expect(links).toHaveBeenCalledTimes(2); expect(links).toHaveBeenCalledTimes(2);
}); });
it('gives a view on the move a new graph at least every quarter second, rather than waiting for it to stop', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0)));
const component = linkScene(links);
await settle();
// A new drawn set about every 150 ms for a second, as an orbit makes one each pass.
for (let pass = 1; pass <= 7; pass++) {
await changeDrawnStars(component, pass * 40);
await new Promise((resolve) => setTimeout(resolve, 120));
}
expect(links.mock.calls.length).toBeGreaterThanOrEqual(3);
});
it('draws a late graph for the range still asked for, and not one for a range left behind', async () => { it('draws a late graph for the range still asked for, and not one for a range left behind', async () => {
const answers: Array<(segments: Float32Array) => void> = []; const answers: Array<(segments: Float32Array) => void> = [];
const setSegments = vi.spyOn(JumpLinkRenderer.prototype, 'setSegments'); const setSegments = vi.spyOn(JumpLinkRenderer.prototype, 'setSegments');
@@ -37,7 +37,7 @@ import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../sear
import { StarmapHudComponent } from './starmap-hud.component'; import { StarmapHudComponent } from './starmap-hud.component';
import { SystemObjectCardComponent } from './system-object-card.component'; import { SystemObjectCardComponent } from './system-object-card.component';
import { RoutingClient } from './routing-client'; 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 { BrightnessIndex, brightestWithin, brightnessIndex } from '../../shared/astro/brightest';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood'; import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component'; import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component';
@@ -72,8 +72,8 @@ const LABEL_EDGE_NDC = 0.7;
/** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */ /** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */
const LABEL_REACH_NDC = 0.3; const LABEL_REACH_NDC = 0.3;
/** /**
* How long the range control, and the set of drawn stars, have to be still before the graph is * How long the range control has to be still before the graph is rebuilt at its value, since a drag
* rebuilt for them: a drag emits per pixel, and a flight re-chooses the drawn stars every few passes. * emits per pixel; and how often at most a view on the move gets a graph for its new drawn stars.
*/ */
const JUMP_LINK_REBUILD_DELAY_MS = 250; const JUMP_LINK_REBUILD_DELAY_MS = 250;
@@ -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. */ /** How often (seconds) the visible label set is recomputed; doesn't need to be per-frame. */
const LABEL_UPDATE_INTERVAL_SECONDS = 0.2; 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 * The furthest the view's centre may drift, in parsecs, before the star field chooses its stars
* fifth of the radius it draws whole, so nothing within four fifths of it ever goes missing, and * again: a fifth of the radius it draws whole, so nothing within four fifths of it ever goes
* a slow pan does not rewrite the buffers every label pass. * missing. Closer in, half the frame's margin is the tighter limit. See `refocusStarField`.
*/ */
const STAR_FIELD_REFOCUS_PC = FOCUS_RADIUS_PC / 5; 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. */ /** 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 controls?: OrbitControls;
private rig?: CameraRigController; private rig?: CameraRigController;
private starField?: StarFieldRenderer; 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 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; private hostRings?: HostStarRings;
/** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */ /** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */
private neighbourhood?: StarNeighbourhood; 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); 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.hostStars = Uint8Array.from(stars, (star) => (this.starIdsWithBodies.has(star.id) ? 1 : 0));
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);
@@ -626,13 +635,19 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// Per-frame, unlike the labels: this is a handful of uniform writes, and it is what keeps // Per-frame, unlike the labels: this is a handful of uniform writes, and it is what keeps
// the zoom continuous rather than stepping between two discrete scales. // the zoom continuous rather than stepping between two discrete scales.
this.updateGalacticCrossfade(camera); this.updateGalacticCrossfade(camera);
// A flight turns and zooms far faster than a label pass: the return from a system zooms out
// forty-fold in a second. So while one is under way the drawn stars are checked every frame,
// and chosen again whenever the view has used up half the margin.
if (this.rig?.isAnimating) {
this.refocusStarField(camera);
}
} }
this.labelUpdateAccumulator += deltaSeconds; this.labelUpdateAccumulator += deltaSeconds;
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) { if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
this.labelUpdateAccumulator = 0; this.labelUpdateAccumulator = 0;
if (this.galaxyGroup.visible) { if (this.galaxyGroup.visible) {
this.refocusStarField(); this.refocusStarField(camera);
this.updateLabels(camera); this.updateLabels(camera);
} else if (this.systemGroup.visible) { } else if (this.systemGroup.visible) {
this.updateSystemLabels(camera); this.updateSystemLabels(camera);
@@ -783,34 +798,83 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
} }
/** /**
* Keeps the drawn stars those around what the view is centred on, and the ones the map is * Keeps the drawn stars those the camera shows: the ones the map is pointing at wherever they
* pointing at: the selected star and the stars of a plotted route. Re-chosen only once the * are, then of what is in frame, the planet hosts, the neighbourhoods of the view's centre and of
* centre has moved far enough to matter, so any star within `FOCUS_RADIUS_PC - STAR_FIELD_REFOCUS_PC` * the Sun, and the brightest. See `selectDrawnStars`.
* of it is always drawn, however faint. *
* 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 still outrun it, measured and accepted: 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. Flights are
* checked every frame instead of every pass; the galactic scale gets the whole sky.
*/ */
private refocusStarField(): void { private refocusStarField(camera: SceneCamera): void {
// At galactic scale the whole catalogue is a smudge a few pixels across, and the view's centre if (!this.starField || !this.neighbourhood) {
// 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 selectedId = this.navigationStore.selectedStarId(); const selectedId = this.navigationStore.selectedStarId();
const pinnedIds = [...(selectedId === null ? [] : [selectedId]), ...(this.routeResult()?.stars.map((star) => star.id) ?? [])]; const pinnedIds = [...(selectedId === null ? [] : [selectedId]), ...(this.routeResult()?.stars.map((star) => star.id) ?? [])];
const pins = pinnedIds.join(); 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 // 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. // 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 neighbourhood = this.neighbourhood;
const pinned = pinnedIds.map((id) => neighbourhood.indexOf(id)).filter((index): index is number => index !== undefined); 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 centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
const drawnBefore = this.starField.drawnStars;
// 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({ centre, pinned: pinned(), hosts: this.hostStars });
this.starFieldCamera = null;
this.starFieldPins = pins; this.starFieldPins = pins;
// The graph links the drawn stars, so a new set wants a new graph once it stops changing. }
} else {
const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(centre));
const perspective = this.engine.getPerspectiveCamera();
// The narrower of the frame's two half-extents: on a portrait screen the width, where the
// same share of margin is the fewest degrees and the fewest parsecs.
const narrowing = Math.min(1, perspective.aspect);
const marginPc = (VIEW_MARGIN / 2) * halfHeight * narrowing;
// 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; the survey edge stands in
// for the depth of the stars drawn.
const tanHalfFov = Math.tan((perspective.fov * Math.PI) / 360) * narrowing;
let turnLimit = (Math.atan((1 + VIEW_MARGIN) * tanHalfFov) - Math.atan(tanHalfFov)) / 2;
if (this.engine.currentProjection === 'orthographic') {
turnLimit = Math.min(turnLimit, marginPc / (SURVEY_EDGE_PC + centre.length()));
}
const held =
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, marginPc);
if (!held) {
// 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. Not one per pass while the
// view keeps moving, and not one pushed back by every pass either, or an orbit would never get
// one: at most one every `JUMP_LINK_REBUILD_DELAY_MS`.
if (this.starField.drawnStars !== drawnBefore && this.jumpLinkRebuild === undefined) {
this.scheduleJumpLinks(); this.scheduleJumpLinks();
} }
}
private updateLabels(camera: SceneCamera): void { private updateLabels(camera: SceneCamera): void {
const radii = distanceRings(this.effectiveDistance(camera), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC); const radii = distanceRings(this.effectiveDistance(camera), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC);
@@ -1455,7 +1519,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
/** Rebuilds the graph once the range and the drawn stars have held still. */ /** Rebuilds the graph once the range and the drawn stars have held still. */
private scheduleJumpLinks(): void { private scheduleJumpLinks(): void {
clearTimeout(this.jumpLinkRebuild); clearTimeout(this.jumpLinkRebuild);
this.jumpLinkRebuild = setTimeout(() => this.refreshJumpLinks(), JUMP_LINK_REBUILD_DELAY_MS); this.jumpLinkRebuild = setTimeout(() => {
this.jumpLinkRebuild = undefined;
this.refreshJumpLinks();
}, JUMP_LINK_REBUILD_DELAY_MS);
} }
/** /**
@@ -1749,6 +1816,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const { width, height } = entry.contentRect; const { width, height } = entry.contentRect;
this.engine.resize(width, height); this.engine.resize(width, height);
this.labelOverlay?.setSize(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); 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', () => { describe('StarFieldRenderer refocus', () => {
const camera = testCamera(); const camera = testCamera();
/** A faint star straight ahead, 150 pc out, among bright ones well off to the side. */ /** 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); expect(Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i))).not.toContain(77);
renderer.dispose(); 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 * 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 * 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 * 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; 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. */ /** What, besides the brightest stars, the field should be sure to draw. */
export interface DrawFocus { export interface DrawFocus {
/** Where the view is centred. Its neighbourhood is drawn whole, like the Sun's. */ /** 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. * of a plotted route. Anything the map points at has to be there to be pointed at.
*/ */
readonly pinned?: readonly number[]; 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 }; 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 * 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 * budget is spent: the pinned stars wherever they are, then of the stars in view, the planet
* is centred, everything within it of the Sun, then the brightest of the rest. Each neighbourhood * hosts, everything within {@link FOCUS_RADIUS_PC} of where the view is centred, everything within
* is taken brightest first, so a budget too small to hold one whole keeps its most visible part. * 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 * 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 * 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 // 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 // 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 // gathering both neighbourhoods in catalogue order and sorting them. Of the stars in no earlier
// only the first `budget` stars can ever be taken, so past those it only looks for members. // tier only the first `budget` in view can ever be taken, so past those it looks for the tiers.
const { order, positions } = index; const { order, positions } = index;
const radiusSq = FOCUS_RADIUS_PC * FOCUS_RADIUS_PC; const radiusSq = FOCUS_RADIUS_PC * FOCUS_RADIUS_PC;
const centre = focus.centre ?? SUN; const centre = focus.centre ?? SUN;
const view = focus.view?.elements;
const reachScale = 1 + VIEW_MARGIN;
const hosts: number[] = [];
const nearCentre: number[] = []; const nearCentre: number[] = [];
const nearSun: number[] = []; const nearSun: number[] = [];
const rest: number[] = []; const rest: number[] = [];
@@ -169,13 +195,21 @@ export function selectDrawnStars(
const dx = x - centre.x; const dx = x - centre.x;
const dy = y - centre.y; const dy = y - centre.y;
const dz = z - centre.z; const dz = z - centre.z;
if (dx * dx + dy * dy + dz * dz <= radiusSq) { const isHost = focus.hosts?.[order[at]] === 1;
nearCentre.push(order[at]); const inCentre = dx * dx + dy * dy + dz * dz <= radiusSq;
} else if (x * x + y * y + z * z <= radiusSq) { const inSun = x * x + y * y + z * z <= radiusSq;
nearSun.push(order[at]); if (!isHost && !inCentre && !inSun && rest.length >= budget) {
} else if (rest.length < budget) { continue;
rest.push(order[at]);
} }
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); const chosen = new Uint8Array(stars.length);
@@ -191,6 +225,7 @@ export function selectDrawnStars(
take(pinned); take(pinned);
} }
} }
hosts.forEach(take);
nearCentre.forEach(take); nearCentre.forEach(take);
nearSun.forEach(take); nearSun.forEach(take);
rest.forEach(take); rest.forEach(take);