From 6cd0666067f3297d4be640988f10005c6b05b966 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Thu, 17 Sep 2026 17:04:08 +0200 Subject: [PATCH 1/2] 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) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .../galaxy-system-scene.component.spec.ts | 150 +++++++++++++++--- .../galaxy-system-scene.component.ts | 98 +++++++++--- .../galaxy-system/star-field-renderer.spec.ts | 86 ++++++++++ .../galaxy-system/star-field-renderer.ts | 59 +++++-- 4 files changed, 335 insertions(+), 58 deletions(-) diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index 5b4173d..480be3d 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -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; - await advanceFrames(engine, 0.6); + beforeEach(() => { + refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus'); + }); + afterEach(() => refocus.mockRestore()); - expect(refocus).not.toHaveBeenCalled(); - 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('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); - await advanceFrames(engine, 0.3); - const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus'); + it('chooses them for the opening view on the first pass, planet hosts included', async () => { + await advanceFrames(engine, 0.6); - 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).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]); + }); - expect(refocus).not.toHaveBeenCalled(); - refocus.mockRestore(); + 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); + + 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).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 () => { diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 16aa303..f6f8f00 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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); } diff --git a/src/app/features/galaxy-system/star-field-renderer.spec.ts b/src/app/features/galaxy-system/star-field-renderer.spec.ts index 5b7e2f0..53b9ca2 100644 --- a/src/app/features/galaxy-system/star-field-renderer.spec.ts +++ b/src/app/features/galaxy-system/star-field-renderer.spec.ts @@ -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(); + }); }); diff --git a/src/app/features/galaxy-system/star-field-renderer.ts b/src/app/features/galaxy-system/star-field-renderer.ts index 51706d4..4014bf6 100644 --- a/src/app/features/galaxy-system/star-field-renderer.ts +++ b/src/app/features/galaxy-system/star-field-renderer.ts @@ -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); From 9631ddf0a476c7d8687902ef7e19deeb002eb3c6 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Thu, 17 Sep 2026 17:57:29 +0200 Subject: [PATCH 2/2] Answer the review: keep up with flights frame by frame, respect portrait frames, and let links follow an orbit - Flights: the drawn stars were checked once a label pass, and a flight outruns that. Leaving a system jumps the camera to face another way, then zooms out forty-fold in a second: 74-83% of the stars that belong on screen were missing on the first frames back in parsec space, 33-50% before each re-choice on the way out. While the rig animates, the check now runs every frame. Probe on real flights (Gl 806, Barnard's Star, out): 0.90-1.00 of a fresh choice drawn on screen in flight, 1.00 on the frame of the jump; frame p95 12.2 ms, no long tasks. Choosing for the whole sky during flights was tried first and measured worse (0.15-0.18). - Portrait frames: the turn and pan limits use the narrower half-extent, not the height. - Links: a new drawn set arms the rebuild timer only when none is pending, so a continuous orbit gets a graph at most every 250 ms instead of never; an unchanged set asks for nothing. - The centre-move test lets the first pass happen before moving the centre. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .../galaxy-system-scene.component.spec.ts | 64 +++++++++++++ .../galaxy-system-scene.component.ts | 95 +++++++++++-------- 2 files changed, 121 insertions(+), 38 deletions(-) diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index 480be3d..a61c1e8 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -222,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 () => { const component = fixture.componentInstance as unknown as { controls: { target: THREE.Vector3 } }; 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); await advanceFrames(engine, 0.3); @@ -343,6 +346,53 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { 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); @@ -421,6 +471,20 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { 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 () => { const answers: Array<(segments: Float32Array) => void> = []; const setSegments = vi.spyOn(JumpLinkRenderer.prototype, 'setSegments'); diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index f6f8f00..94d7ea3 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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). */ 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 - * rebuilt for them: a drag emits per pixel, and a flight re-chooses the drawn stars every few passes. + * How long the range control has to be still before the graph is rebuilt at its value, since a drag + * 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; @@ -635,6 +635,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // 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. 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; @@ -799,9 +805,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { * 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. + * 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(camera: SceneCamera): void { if (!this.starField || !this.neighbourhood) { @@ -815,49 +822,58 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { const neighbourhood = this.neighbourhood; const pinned = () => pinnedIds.map((id) => neighbourhood.indexOf(id)).filter((index): index is number => index !== undefined); + 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({ pinned: pinned(), hosts: this.hostStars }); + this.starField.refocus({ centre, pinned: pinned(), hosts: this.hostStars }); this.starFieldCamera = null; this.starFieldPins = pins; - this.scheduleJumpLinks(); } - return; + } 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; + } } - 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())); + // 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(); } - 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(); } private updateLabels(camera: SceneCamera): void { @@ -1503,7 +1519,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { /** Rebuilds the graph once the range and the drawn stars have held still. */ private scheduleJumpLinks(): void { 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); } /**