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..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 @@ -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(); } @@ -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 () => { 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); @@ -229,29 +237,177 @@ 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('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); + 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 () => { @@ -315,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 16aa303..94d7ea3 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'; @@ -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; @@ -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); @@ -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 // 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; 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,33 +798,82 @@ 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 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 { - // 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(); - this.starFieldPins = pins; - // The graph links the drawn stars, so a new set wants a new graph once it stops changing. - this.scheduleJumpLinks(); + 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({ centre, pinned: pinned(), hosts: this.hostStars }); + this.starFieldCamera = null; + this.starFieldPins = pins; + } + } 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(); + } } private updateLabels(camera: SceneCamera): void { @@ -1455,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); } /** @@ -1749,6 +1816,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);