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:
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user