Merge pull request #22 from avalon-vanguard/star-map/feat/drawn-set-follows-view
Draw the stars around wherever the view is, not only around the Sun Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -11,6 +11,7 @@ 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 { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
||||||
|
import { StarFieldRenderer } from './star-field-renderer';
|
||||||
|
|
||||||
// jsdom does not implement ResizeObserver; the component only uses it to react to real
|
// jsdom does not implement ResizeObserver; the component only uses it to react to real
|
||||||
// layout changes, which never happen in this headless test.
|
// layout changes, which never happen in this headless test.
|
||||||
@@ -22,7 +23,8 @@ import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
|||||||
|
|
||||||
const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 };
|
const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 };
|
||||||
const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y: 0, z: 0, magnitude: 4.4, spectralType: 'G2V', colorIndex: 0.7 };
|
const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y: 0, z: 0, magnitude: 4.4, spectralType: 'G2V', colorIndex: 0.7 };
|
||||||
const PROXIMA: StarRecord = { id: 2, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
|
// Its id deliberately differs from its place in STARS, so a lookup by id cannot pass for one by index.
|
||||||
|
const PROXIMA: StarRecord = { id: 42, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
|
||||||
|
|
||||||
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
|
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
|
||||||
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
||||||
@@ -211,6 +213,59 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
expect(navigationStore.viewLevel()).toBe('galaxy');
|
expect(navigationStore.viewLevel()).toBe('galaxy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
component.controls.target.set(40, 0, 0);
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
expect(refocus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(refocus.mock.calls[0][0].centre).toMatchObject({ x: 40, y: 0, z: 0 });
|
||||||
|
|
||||||
|
component.controls.target.set(42, 0, 0);
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
expect(refocus).toHaveBeenCalledTimes(1);
|
||||||
|
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');
|
||||||
|
|
||||||
|
await advanceFrames(engine, 0.6);
|
||||||
|
|
||||||
|
expect(refocus).not.toHaveBeenCalled();
|
||||||
|
refocus.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
component.controls.target.set(500, 0, 0);
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
component.controls.target.set(1500, 0, 0);
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
|
||||||
|
expect(refocus).not.toHaveBeenCalled();
|
||||||
|
refocus.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the stars of a plotted route drawn, and the selected star', async () => {
|
||||||
|
const component = fixture.componentInstance as unknown as { routeResult: { set(value: unknown): void } };
|
||||||
|
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
|
||||||
|
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null });
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
|
||||||
|
// As catalogue indices: the Sun is the first entry of STARS, Proxima the third.
|
||||||
|
expect(refocus.mock.calls.at(-1)![0].pinned).toEqual([0, 2]);
|
||||||
|
refocus.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it('asks for no more label candidates once the last label it will show is placed', () => {
|
it('asks for no more label candidates once the last label it will show is placed', () => {
|
||||||
// Near the Sun a label candidate past the fifteenth can sit at the far end of the catalogue's
|
// Near the Sun a label candidate past the fifteenth can sit at the far end of the catalogue's
|
||||||
// brightness order, so asking for one more than is used can cost a walk of the whole order.
|
// brightness order, so asking for one more than is used can cost a walk of the whole order.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { RouteRequest, RouteResult, RouteStarOption } from '../hud/routes-panel.
|
|||||||
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking';
|
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking';
|
||||||
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 { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
|
import { colorIndexToRgb, FOCUS_RADIUS_PC, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
|
||||||
import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links';
|
import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links';
|
||||||
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';
|
||||||
@@ -117,6 +117,12 @@ const HUD_ACCENT = 0x4dd7ff;
|
|||||||
const DEEP_SKY_LABEL_COUNT = 12;
|
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
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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. */
|
||||||
const CLICK_DRAG_SLOP_PX = 5;
|
const CLICK_DRAG_SLOP_PX = 5;
|
||||||
|
|
||||||
@@ -313,6 +319,9 @@ 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;
|
||||||
|
private starFieldPins = '';
|
||||||
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;
|
||||||
@@ -535,7 +544,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
(id): id is number => id !== null && id !== undefined
|
(id): id is number => id !== null && id !== undefined
|
||||||
));
|
));
|
||||||
|
|
||||||
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search));
|
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search), this.starsByBrightness.order);
|
||||||
|
// 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.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);
|
||||||
@@ -611,6 +622,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
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.updateLabels(camera);
|
this.updateLabels(camera);
|
||||||
} else if (this.systemGroup.visible) {
|
} else if (this.systemGroup.visible) {
|
||||||
this.updateSystemLabels(camera);
|
this.updateSystemLabels(camera);
|
||||||
@@ -760,6 +772,34 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
return scaleBar((2 * halfHeight) / heightPx, SCALE_BAR_MAX_PX, unit);
|
return scaleBar((2 * halfHeight) / heightPx, SCALE_BAR_MAX_PX, unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
if (radii.join() !== this.localGridRadii.join()) {
|
if (radii.join() !== this.localGridRadii.join()) {
|
||||||
|
|||||||
@@ -260,11 +260,6 @@ describe('selectDrawnStars', () => {
|
|||||||
expect(drawn).toEqual([0, 2, 3]);
|
expect(drawn).toEqual([0, 2, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns catalogue indices in order, so positions can be subset alongside', () => {
|
|
||||||
const catalogue = Array.from({ length: 100 }, (_, i) => catalogueStar(i, 150, 100 - i));
|
|
||||||
const drawn = Array.from(selectDrawnStars(catalogue, 10));
|
|
||||||
expect(drawn).toEqual([...drawn].sort((a, b) => a - b));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('StarFieldRenderer render budget', () => {
|
describe('StarFieldRenderer render budget', () => {
|
||||||
@@ -291,3 +286,126 @@ describe('StarFieldRenderer render budget', () => {
|
|||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('selectDrawnStars around the view', () => {
|
||||||
|
/** 200 bright stars 240 pc out, enough to spend any small budget on their own. */
|
||||||
|
const brightFar = (from: number) => Array.from({ length: 200 }, (_, i) => catalogueStar(from + i, 240, 2));
|
||||||
|
|
||||||
|
it('draws a faint star near where the view is centred, however far that is from the Sun', () => {
|
||||||
|
const faint = catalogueStar(0, 150, 12);
|
||||||
|
const catalogue = [faint, ...brightFar(1)];
|
||||||
|
|
||||||
|
expect(Array.from(selectDrawnStars(catalogue, 20))).not.toContain(0);
|
||||||
|
expect(Array.from(selectDrawnStars(catalogue, 20, { centre: { x: 150, y: 0, z: 0 } }))).toContain(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the Sun's neighbourhood drawn while the view looks elsewhere", () => {
|
||||||
|
const catalogue = [catalogueStar(0, 1.3, 11), catalogueStar(1, 150, 13), ...brightFar(2)];
|
||||||
|
|
||||||
|
expect(Array.from(selectDrawnStars(catalogue, 20, { centre: { x: 150, y: 0, z: 0 } })).slice(0, 2)).toEqual([1, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a pinned star wherever it is and however faint', () => {
|
||||||
|
const catalogue = [catalogueStar(0, 240, 14), ...brightFar(1)];
|
||||||
|
|
||||||
|
expect(Array.from(selectDrawnStars(catalogue, 20))).not.toContain(0);
|
||||||
|
expect(Array.from(selectDrawnStars(catalogue, 20, { pinned: [0] }))).toContain(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spends a budget too small for everything on the pinned stars, then the view, then the Sun, then the brightest', () => {
|
||||||
|
const catalogue = [catalogueStar(0, 1, 12), catalogueStar(1, 150, 13), catalogueStar(2, 240, 14), ...brightFar(3)];
|
||||||
|
const focus = { centre: { x: 150, y: 0, z: 0 }, pinned: [2] };
|
||||||
|
|
||||||
|
expect(Array.from(selectDrawnStars(catalogue, 4, focus))).toEqual([2, 1, 0, 3]);
|
||||||
|
expect(Array.from(selectDrawnStars(catalogue, 2, focus))).toEqual([2, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the brightest part of a neighbourhood the budget cannot hold whole', () => {
|
||||||
|
const catalogue = [catalogueStar(0, 150, 9), catalogueStar(1, 151, 4), catalogueStar(2, 152, 11), catalogueStar(3, 153, 6), ...brightFar(4)];
|
||||||
|
|
||||||
|
expect(Array.from(selectDrawnStars(catalogue, 2, { centre: { x: 150, y: 0, z: 0 } }))).toEqual([1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws nothing twice when the view is centred on the Sun or pins a star already near it', () => {
|
||||||
|
const catalogue = [catalogueStar(0, 1, 12), catalogueStar(1, 2, 13), ...brightFar(2)];
|
||||||
|
const drawn = Array.from(selectDrawnStars(catalogue, 10, { centre: { x: 0, y: 0, z: 0 }, pinned: [0, 0, 1] }));
|
||||||
|
|
||||||
|
expect(new Set(drawn).size).toBe(drawn.length);
|
||||||
|
expect(drawn).toHaveLength(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StarFieldRenderer refocus', () => {
|
||||||
|
const camera = testCamera();
|
||||||
|
/** A faint star straight ahead, 150 pc out, among bright ones well off to the side. */
|
||||||
|
const faintAhead = star({ id: 77, x: 0, y: 0, z: -150, magnitude: 13, colorIndex: 1.9 });
|
||||||
|
const catalogue = [faintAhead, ...Array.from({ length: 50 }, (_, i) => star({ id: 100 + i, x: 60, y: i, z: -40, magnitude: 1, colorIndex: -0.3 + i * 0.04 }))];
|
||||||
|
const positions = packPositions(catalogue);
|
||||||
|
|
||||||
|
it('draws and picks a faint star once the view is centred near it', () => {
|
||||||
|
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||||
|
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
|
||||||
|
|
||||||
|
renderer.refocus({ centre: { x: 0, y: 0, z: -140 } });
|
||||||
|
|
||||||
|
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBe(77);
|
||||||
|
expect((renderer.object.geometry as THREE.InstancedBufferGeometry).instanceCount).toBe(renderer.drawnCount);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a pinned star, and passes over an index past the end of the catalogue', () => {
|
||||||
|
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||||
|
|
||||||
|
renderer.refocus({ pinned: [123456, 0] });
|
||||||
|
|
||||||
|
const drawnIds = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i));
|
||||||
|
expect(drawnIds).toContain(77);
|
||||||
|
expect(renderer.drawnCount).toBe(10);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives each drawn star its own colour and size, wherever the refocus put it', () => {
|
||||||
|
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||||
|
renderer.refocus({ centre: { x: 0, y: 0, z: -140 }, pinned: [21] });
|
||||||
|
const { colorAttribute, sizeAttribute } = renderer as unknown as { colorAttribute: THREE.InstancedBufferAttribute; sizeAttribute: THREE.InstancedBufferAttribute };
|
||||||
|
|
||||||
|
for (let instance = 0; instance < renderer.drawnCount; instance++) {
|
||||||
|
const drawnStar = catalogue.find((candidate) => candidate.id === renderer.starIdAt(instance))!;
|
||||||
|
const expected = colorIndexToRgb(drawnStar.colorIndex, drawnStar.spectralType);
|
||||||
|
expect(colorAttribute.getX(instance)).toBeCloseTo(expected.r, 5);
|
||||||
|
expect(colorAttribute.getZ(instance)).toBeCloseTo(expected.b, 5);
|
||||||
|
expect(sizeAttribute.getX(instance)).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
const faintSlot = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i)).indexOf(77);
|
||||||
|
const brightSlot = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i)).indexOf(120);
|
||||||
|
expect(sizeAttribute.getX(brightSlot)).toBeGreaterThan(sizeAttribute.getX(faintSlot));
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the buffers alone when the drawn set has not changed, and rewrites them when it has', () => {
|
||||||
|
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||||
|
const { positionAttribute } = renderer as unknown as { positionAttribute: THREE.InstancedBufferAttribute };
|
||||||
|
const version = positionAttribute.version;
|
||||||
|
|
||||||
|
renderer.refocus({ centre: { x: 0, y: 0, z: 0 } });
|
||||||
|
expect(positionAttribute.version).toBe(version);
|
||||||
|
|
||||||
|
renderer.refocus({ centre: { x: 0, y: 0, z: -140 } });
|
||||||
|
expect(positionAttribute.version).toBeGreaterThan(version);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a star from the drawn set, and from picking, once the view has moved away from it', () => {
|
||||||
|
// The subtle failure this guards: buffers rewritten for a new selection while picking still
|
||||||
|
// reads the old one would leave clickable ghosts where nothing is drawn.
|
||||||
|
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||||
|
renderer.refocus({ centre: { x: 0, y: 0, z: -140 } });
|
||||||
|
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBe(77);
|
||||||
|
|
||||||
|
renderer.refocus({ centre: { x: 0, y: 0, z: 0 } });
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
|
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
|
||||||
|
|
||||||
|
import { brightnessOrder, Positioned } from '../../shared/astro/brightest';
|
||||||
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
||||||
import { SceneCamera } from '../../core/engine/engine.service';
|
import { SceneCamera } from '../../core/engine/engine.service';
|
||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
@@ -25,25 +26,25 @@ const PICK_NDC_SLOP = 0.01;
|
|||||||
/**
|
/**
|
||||||
* How many stars the field draws at once, however many the catalogue holds.
|
* How many stars the field draws at once, however many the catalogue holds.
|
||||||
*
|
*
|
||||||
* The catalogue reaches as far as its parallaxes do — 68388 stars at 250 pc — but drawing all of
|
* The *data* is the catalogue and the *drawing* is a budget, and the two are allowed to differ:
|
||||||
* them is a cost paid every frame by every machine, and most of that cost buys 1.5-pixel dots.
|
* everything still exists for search, for flying to, and for hosting planets. Which stars fill
|
||||||
* So the *data* is the catalogue and the *drawing* is a budget, and the two are allowed to
|
* the budget follows the view; see {@link selectDrawnStars}.
|
||||||
* differ. Everything still exists for search, for flying to, and for hosting planets.
|
|
||||||
*
|
*
|
||||||
* Currently set to the whole catalogue, which is what a GPU should be asked to do — this is one
|
* The number is set by what the field looks like, before what it costs. The catalogue is
|
||||||
* instanced draw call, and a discrete card will not notice it. The budget still exists because
|
* 423 651 stars since Gaia, and drawn whole the opening view is a grey wash: the additive
|
||||||
* the catalogue is meant to grow past what any machine should draw at once: Gaia alone could
|
* blending of that many 1.5-pixel dots buries the labels, the rings on the planet hosts and the
|
||||||
* contribute a million stars, and at that point the selection below is what keeps the field
|
* grid. At 150 000 the wash has begun; at this budget the view reads. Measured at 1920 × 1080 on
|
||||||
* legible rather than a grey wash.
|
* a Ryzen 7700X, the cost argues the same way. An RTX 4080 draws the whole catalogue in the same
|
||||||
*
|
* 6.1 ms a frame as this budget, so a discrete card does not notice. The processor's own
|
||||||
* Machines without a GPU do feel it. A software rasterizer measured here lost about a third of
|
* two-core Radeon, standing in for an entry-level laptop, pays about 4 ms a frame for every
|
||||||
* its frame rate per 12000 stars drawn; if that matters for a deployment, this is the one number
|
* 100 000 stars: 112 frames a second at this budget, 44 at the whole catalogue, and the same
|
||||||
* to turn down.
|
* again under the WebGL2 fallback.
|
||||||
*/
|
*/
|
||||||
export const STAR_RENDER_BUDGET = 68388;
|
export const STAR_RENDER_BUDGET = 70_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Radius (parsecs) inside which every star is drawn regardless of brightness.
|
* Radius (parsecs) around the Sun, and around wherever the view is centred, inside which every
|
||||||
|
* star 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
|
||||||
@@ -51,11 +52,28 @@ export const STAR_RENDER_BUDGET = 68388;
|
|||||||
* and the ones that hold the nearby planets, so the neighbourhood is kept whole and the budget
|
* and the ones that hold the nearby planets, so the neighbourhood is kept whole and the budget
|
||||||
* is spent on the brightest of everything beyond it.
|
* is spent on the brightest of everything beyond it.
|
||||||
*
|
*
|
||||||
* Kept deliberately small against the catalogue's 250 pc reach. The guaranteed core occupies a
|
* The same holds wherever the view is looking. Before the drawn set followed the view, a region
|
||||||
* thousandth of that volume, so a generous radius spends most of the budget inside it and draws
|
* 150 pc out drew 49 of the 442 stars within this radius of it, and a route plotted there ran
|
||||||
* a dense knot surrounded by nothing — which is a worse picture than the smaller catalogue was.
|
* through waypoints nobody could see or click: Sol to Almach at 8 pc passed 19 stars and drew 6.
|
||||||
|
*
|
||||||
|
* Kept deliberately small against the catalogue's reach. Around the Sun it holds 3 654 stars;
|
||||||
|
* a generous radius spends most of the budget inside it and draws a dense knot surrounded by
|
||||||
|
* nothing.
|
||||||
*/
|
*/
|
||||||
export const ALWAYS_DRAWN_RADIUS_PC = 25;
|
export const FOCUS_RADIUS_PC = 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. */
|
||||||
|
readonly centre?: Positioned;
|
||||||
|
/**
|
||||||
|
* Catalogue indices drawn wherever they are and however faint: the selected star, the stars
|
||||||
|
* of a plotted route. Anything the map points at has to be there to be pointed at.
|
||||||
|
*/
|
||||||
|
readonly pinned?: readonly number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUN: Positioned = { x: 0, y: 0, z: 0 };
|
||||||
|
|
||||||
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
|
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
|
||||||
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
|
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
|
||||||
@@ -101,7 +119,86 @@ function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometr
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the galaxy-scale star field as instanced camera-facing billboards, one per HYG star,
|
* Reads a render budget override off the page URL (`?stars=20000`), falling back to the default.
|
||||||
|
*
|
||||||
|
* Two uses, one real and one incidental. The real one is a deployment or a machine that cannot
|
||||||
|
* draw the whole catalogue — a number in a URL beats a rebuild. The incidental one is the
|
||||||
|
* end-to-end suite, which runs against a software rasterizer whose frame rate is two orders of
|
||||||
|
* magnitude below a real GPU's: those tests are checking navigation and state, and making them
|
||||||
|
* wait on a rasterizer measures nothing about the app.
|
||||||
|
*/
|
||||||
|
export function starRenderBudgetFromUrl(search: string, fallback = STAR_RENDER_BUDGET): number {
|
||||||
|
const requested = Number(new URLSearchParams(search).get('stars'));
|
||||||
|
return Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Returns indices into the original list, in the order they were chosen. `order` is the
|
||||||
|
* catalogue's brightness order, passed in when the caller already has it rather than sorted again
|
||||||
|
* on every call.
|
||||||
|
*/
|
||||||
|
export function selectDrawnStars(
|
||||||
|
stars: readonly StarRecord[],
|
||||||
|
budget = STAR_RENDER_BUDGET,
|
||||||
|
focus: DrawFocus = {},
|
||||||
|
order: Uint32Array = brightnessOrder(stars)
|
||||||
|
): Uint32Array {
|
||||||
|
if (stars.length <= budget) {
|
||||||
|
return Uint32Array.from(stars.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosen = new Uint8Array(stars.length);
|
||||||
|
const selected: number[] = [];
|
||||||
|
const take = (index: number): void => {
|
||||||
|
if (!chosen[index] && selected.length < budget) {
|
||||||
|
chosen[index] = 1;
|
||||||
|
selected.push(index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const index of focus.pinned ?? []) {
|
||||||
|
if (index >= 0 && index < stars.length) {
|
||||||
|
take(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both neighbourhoods in one pass in catalogue order, then each sorted brightest first. They hold
|
||||||
|
// a few thousand stars between them, so sorting them costs far less than walking the whole
|
||||||
|
// brightness order once per neighbourhood, which reads the catalogue out of order: 26 ms a
|
||||||
|
// refocus on the real catalogue, against 4 ms this way.
|
||||||
|
const radiusSq = FOCUS_RADIUS_PC * FOCUS_RADIUS_PC;
|
||||||
|
const centres = focus.centre ? [focus.centre, SUN] : [SUN];
|
||||||
|
const members = centres.map(() => [] as number[]);
|
||||||
|
for (let index = 0; index < stars.length; index++) {
|
||||||
|
const star = stars[index];
|
||||||
|
for (let which = 0; which < centres.length; which++) {
|
||||||
|
const dx = star.x - centres[which].x;
|
||||||
|
const dy = star.y - centres[which].y;
|
||||||
|
const dz = star.z - centres[which].z;
|
||||||
|
if (dx * dx + dy * dy + dz * dz <= radiusSq) {
|
||||||
|
members[which].push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const neighbourhood of members) {
|
||||||
|
// Ties in catalogue order, as in the brightness order itself.
|
||||||
|
neighbourhood.sort((a, b) => stars[a].magnitude - stars[b].magnitude || a - b).forEach(take);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let at = 0; at < order.length && selected.length < budget; at++) {
|
||||||
|
take(order[at]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uint32Array.from(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the galaxy-scale star field as instanced camera-facing billboards, one per drawn star,
|
||||||
* coloured by spectral index and sized by magnitude.
|
* coloured by spectral index and sized by magnitude.
|
||||||
*
|
*
|
||||||
* **Why billboards and not `THREE.Points`.** Point primitives are capped at a single pixel on
|
* **Why billboards and not `THREE.Points`.** Point primitives are capped at a single pixel on
|
||||||
@@ -116,50 +213,12 @@ function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometr
|
|||||||
* Sizes are angular (`sizeAttenuation = false`), so a star holds the same apparent size however
|
* Sizes are angular (`sizeAttenuation = false`), so a star holds the same apparent size however
|
||||||
* close the camera gets. That is deliberate and physically right: real stars are unresolvable
|
* close the camera gets. That is deliberate and physically right: real stars are unresolvable
|
||||||
* point sources, and their apparent size on screen is a function of brightness, not distance.
|
* point sources, and their apparent size on screen is a function of brightness, not distance.
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Chooses which stars to draw when the catalogue is larger than the budget: everything inside
|
|
||||||
* the neighbourhood radius, then the brightest of the rest until the budget is spent.
|
|
||||||
*
|
*
|
||||||
* Returns indices into the original list, so the caller can subset the positions that go with
|
* The instance buffers hold the budget, not the catalogue, and are rewritten in place when
|
||||||
* them. Returns them in catalogue order rather than in selection order, purely so the drawn set
|
* {@link refocus} changes which stars fill it.
|
||||||
* is stable and inspectable.
|
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* Reads a render budget override off the page URL (`?stars=20000`), falling back to the default.
|
|
||||||
*
|
|
||||||
* Two uses, one real and one incidental. The real one is a deployment or a machine that cannot
|
|
||||||
* draw the whole catalogue — a number in a URL beats a rebuild. The incidental one is the
|
|
||||||
* end-to-end suite, which runs against a software rasterizer whose frame rate is two orders of
|
|
||||||
* magnitude below a real GPU's: those tests are checking navigation and state, and making them
|
|
||||||
* wait on a rasterizer measures nothing about the app.
|
|
||||||
*/
|
|
||||||
export function starRenderBudgetFromUrl(search: string, fallback = STAR_RENDER_BUDGET): number {
|
|
||||||
const requested = Number(new URLSearchParams(search).get('stars'));
|
|
||||||
return Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectDrawnStars(stars: readonly StarRecord[], budget = STAR_RENDER_BUDGET): Uint32Array {
|
|
||||||
if (stars.length <= budget) {
|
|
||||||
return Uint32Array.from(stars.keys());
|
|
||||||
}
|
|
||||||
|
|
||||||
const near: number[] = [];
|
|
||||||
const far: number[] = [];
|
|
||||||
stars.forEach((star, index) => {
|
|
||||||
(Math.hypot(star.x, star.y, star.z) <= ALWAYS_DRAWN_RADIUS_PC ? near : far).push(index);
|
|
||||||
});
|
|
||||||
|
|
||||||
far.sort((a, b) => stars[a].magnitude - stars[b].magnitude);
|
|
||||||
const selected = near.concat(far.slice(0, Math.max(0, budget - near.length)));
|
|
||||||
selected.sort((a, b) => a - b);
|
|
||||||
return Uint32Array.from(selected);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StarFieldRenderer {
|
export class StarFieldRenderer {
|
||||||
readonly object: THREE.Mesh;
|
readonly object: THREE.Mesh;
|
||||||
/** How many of the catalogue's stars this field actually draws. */
|
|
||||||
readonly drawnCount: number;
|
|
||||||
|
|
||||||
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
|
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
|
||||||
private readonly perspective = uniform(1);
|
private readonly perspective = uniform(1);
|
||||||
@@ -167,39 +226,47 @@ export class StarFieldRenderer {
|
|||||||
|
|
||||||
private readonly geometry: THREE.InstancedBufferGeometry;
|
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||||
private readonly material: THREE.SpriteNodeMaterial;
|
private readonly material: THREE.SpriteNodeMaterial;
|
||||||
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */
|
private readonly budget: number;
|
||||||
private readonly stars: readonly StarRecord[];
|
private readonly order: Uint32Array;
|
||||||
/** Angular diameter per drawn star, in the same order as `stars` — reused for picking. */
|
/**
|
||||||
private readonly angularSizes: Float32Array;
|
* Colour and angular size of every star in the catalogue, worked out once: a refocus then only
|
||||||
|
* copies them into the instances, 0.7 ms for the budget rather than 5.6 ms computing them again.
|
||||||
|
*/
|
||||||
|
private readonly catalogueColors: Float32Array;
|
||||||
|
private readonly catalogueSizes: Float32Array;
|
||||||
|
|
||||||
constructor(catalogue: readonly StarRecord[], cataloguePositions: Float32Array, budget = STAR_RENDER_BUDGET) {
|
/** Per-instance data, `budget` long; the first `drawnCount` entries are live. */
|
||||||
const drawn = selectDrawnStars(catalogue, budget);
|
private readonly positionAttribute: THREE.InstancedBufferAttribute;
|
||||||
this.stars = drawn.length === catalogue.length ? catalogue : Array.from(drawn, (index) => catalogue[index]);
|
private readonly colorAttribute: THREE.InstancedBufferAttribute;
|
||||||
this.drawnCount = this.stars.length;
|
private readonly sizeAttribute: THREE.InstancedBufferAttribute;
|
||||||
|
/** Catalogue index behind each live instance: the set that is drawn, and so the only set that can be clicked. */
|
||||||
|
private drawn: Uint32Array = new Uint32Array(0);
|
||||||
|
|
||||||
const stars = this.stars;
|
constructor(
|
||||||
this.geometry = createQuadGeometry(stars.length);
|
private readonly catalogue: readonly StarRecord[],
|
||||||
|
private readonly cataloguePositions: Float32Array,
|
||||||
|
budget = STAR_RENDER_BUDGET,
|
||||||
|
order?: Uint32Array
|
||||||
|
) {
|
||||||
|
this.budget = budget;
|
||||||
|
this.order = order ?? brightnessOrder(catalogue);
|
||||||
|
const capacity = Math.min(budget, catalogue.length);
|
||||||
|
this.geometry = createQuadGeometry(0);
|
||||||
|
|
||||||
const colors = new Float32Array(stars.length * 3);
|
this.positionAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
|
||||||
this.angularSizes = new Float32Array(stars.length);
|
this.colorAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
|
||||||
// Repacked only when the drawn set is a subset; otherwise the ETL's buffer is used as-is.
|
this.sizeAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity), 1);
|
||||||
const positions =
|
|
||||||
drawn.length === catalogue.length
|
|
||||||
? cataloguePositions
|
|
||||||
: Float32Array.from({ length: drawn.length * 3 }, (_, i) => cataloguePositions[drawn[(i / 3) | 0] * 3 + (i % 3)]);
|
|
||||||
|
|
||||||
stars.forEach((star, index) => {
|
this.catalogueColors = new Float32Array(catalogue.length * 3);
|
||||||
|
this.catalogueSizes = new Float32Array(catalogue.length);
|
||||||
|
catalogue.forEach((star, index) => {
|
||||||
const color = colorIndexToRgb(star.colorIndex, star.spectralType);
|
const color = colorIndexToRgb(star.colorIndex, star.spectralType);
|
||||||
colors[index * 3] = color.r;
|
this.catalogueColors[index * 3] = color.r;
|
||||||
colors[index * 3 + 1] = color.g;
|
this.catalogueColors[index * 3 + 1] = color.g;
|
||||||
colors[index * 3 + 2] = color.b;
|
this.catalogueColors[index * 3 + 2] = color.b;
|
||||||
this.angularSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE;
|
this.catalogueSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE;
|
||||||
});
|
});
|
||||||
|
|
||||||
const positionAttribute = new THREE.InstancedBufferAttribute(positions, 3);
|
|
||||||
const colorAttribute = new THREE.InstancedBufferAttribute(colors, 3);
|
|
||||||
const sizeAttribute = new THREE.InstancedBufferAttribute(this.angularSizes, 1);
|
|
||||||
|
|
||||||
this.material = new THREE.SpriteNodeMaterial({
|
this.material = new THREE.SpriteNodeMaterial({
|
||||||
transparent: true,
|
transparent: true,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
@@ -212,15 +279,15 @@ export class StarFieldRenderer {
|
|||||||
// a thousandth of a parsec — invisible. Doing the same arithmetic in the node graph, behind
|
// a thousandth of a parsec — invisible. Doing the same arithmetic in the node graph, behind
|
||||||
// a uniform, lets one material serve both cameras without being recompiled between them.
|
// a uniform, lets one material serve both cameras without being recompiled between them.
|
||||||
this.material.sizeAttenuation = true;
|
this.material.sizeAttenuation = true;
|
||||||
const position = instancedBufferAttribute<'vec3'>(positionAttribute, 'vec3');
|
const position = instancedBufferAttribute<'vec3'>(this.positionAttribute, 'vec3');
|
||||||
const angularSize = instancedBufferAttribute<'float'>(sizeAttribute, 'float');
|
const angularSize = instancedBufferAttribute<'float'>(this.sizeAttribute, 'float');
|
||||||
this.material.positionNode = position;
|
this.material.positionNode = position;
|
||||||
// Perspective: a star's world size is its angular size times how far away it is, which is
|
// Perspective: a star's world size is its angular size times how far away it is, which is
|
||||||
// exactly what the built-in does. Orthographic: distance does not set apparent size at all,
|
// exactly what the built-in does. Orthographic: distance does not set apparent size at all,
|
||||||
// the frustum does, so the same angular size is scaled by the frustum instead.
|
// the frustum does, so the same angular size is scaled by the frustum instead.
|
||||||
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
|
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
|
||||||
this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective));
|
this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective));
|
||||||
this.material.colorNode = instancedBufferAttribute<'vec3'>(colorAttribute, 'vec3');
|
this.material.colorNode = instancedBufferAttribute<'vec3'>(this.colorAttribute, 'vec3');
|
||||||
// Soft radial falloff so each star is a small bright core inside a halo, rather than a
|
// Soft radial falloff so each star is a small bright core inside a halo, rather than a
|
||||||
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
|
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
|
||||||
const radius = uv().sub(vec2(0.5)).length();
|
const radius = uv().sub(vec2(0.5)).length();
|
||||||
@@ -230,6 +297,43 @@ export class StarFieldRenderer {
|
|||||||
// The quad's own bounds sit at the origin and say nothing about where the instances are,
|
// The quad's own bounds sit at the origin and say nothing about where the instances are,
|
||||||
// so leaving culling on would drop the whole field whenever the origin left the frustum.
|
// so leaving culling on would drop the whole field whenever the origin left the frustum.
|
||||||
this.object.frustumCulled = false;
|
this.object.frustumCulled = false;
|
||||||
|
|
||||||
|
this.refocus({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many of the catalogue's stars this field is drawing. */
|
||||||
|
get drawnCount(): number {
|
||||||
|
return this.drawn.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chooses the drawn stars again for where the view now is, and rewrites the instance buffers
|
||||||
|
* with them. See {@link selectDrawnStars}.
|
||||||
|
*/
|
||||||
|
refocus(focus: DrawFocus): void {
|
||||||
|
const drawn = selectDrawnStars(this.catalogue, this.budget, focus, this.order);
|
||||||
|
// The same stars in the same instances: the buffers already hold them, and a rewrite would
|
||||||
|
// upload 2 MB to the GPU for nothing — which a pan across empty space would do every pass.
|
||||||
|
if (drawn.length === this.drawn.length && drawn.every((index, instance) => index === this.drawn[instance])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.drawn = drawn;
|
||||||
|
|
||||||
|
const positions = this.positionAttribute.array as Float32Array;
|
||||||
|
const colors = this.colorAttribute.array as Float32Array;
|
||||||
|
const sizes = this.sizeAttribute.array as Float32Array;
|
||||||
|
this.drawn.forEach((catalogueIndex, instance) => {
|
||||||
|
for (let axis = 0; axis < 3; axis++) {
|
||||||
|
positions[instance * 3 + axis] = this.cataloguePositions[catalogueIndex * 3 + axis];
|
||||||
|
colors[instance * 3 + axis] = this.catalogueColors[catalogueIndex * 3 + axis];
|
||||||
|
}
|
||||||
|
sizes[instance] = this.catalogueSizes[catalogueIndex];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.geometry.instanceCount = this.drawn.length;
|
||||||
|
this.positionAttribute.needsUpdate = true;
|
||||||
|
this.colorAttribute.needsUpdate = true;
|
||||||
|
this.sizeAttribute.needsUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,9 +349,9 @@ export class StarFieldRenderer {
|
|||||||
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
|
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Looks up the HYG star id for a given instance index. */
|
/** Looks up the star id for a given instance index. */
|
||||||
starIdAt(instanceIndex: number): number | undefined {
|
starIdAt(instanceIndex: number): number | undefined {
|
||||||
return this.stars[instanceIndex]?.id;
|
return instanceIndex >= 0 && instanceIndex < this.drawn.length ? this.catalogue[this.drawn[instanceIndex]].id : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -269,13 +373,14 @@ export class StarFieldRenderer {
|
|||||||
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
|
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
|
||||||
const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360);
|
const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360);
|
||||||
const projected = new THREE.Vector3();
|
const projected = new THREE.Vector3();
|
||||||
|
const positions = this.positionAttribute.array as Float32Array;
|
||||||
|
const sizes = this.sizeAttribute.array as Float32Array;
|
||||||
|
|
||||||
let bestIndex: number | undefined;
|
let bestIndex: number | undefined;
|
||||||
let bestScore = Infinity;
|
let bestScore = Infinity;
|
||||||
|
|
||||||
for (let index = 0; index < this.stars.length; index++) {
|
for (let index = 0; index < this.drawn.length; index++) {
|
||||||
const star = this.stars[index];
|
projected.set(positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]).project(camera);
|
||||||
projected.set(star.x, star.y, star.z).project(camera);
|
|
||||||
// Outside the depth range means behind the camera or beyond the far plane; `project`
|
// Outside the depth range means behind the camera or beyond the far plane; `project`
|
||||||
// mirrors points behind the camera onto the screen, so this guard is load-bearing.
|
// mirrors points behind the camera onto the screen, so this guard is load-bearing.
|
||||||
if (projected.z < -1 || projected.z > 1) {
|
if (projected.z < -1 || projected.z > 1) {
|
||||||
@@ -284,7 +389,7 @@ export class StarFieldRenderer {
|
|||||||
|
|
||||||
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
||||||
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
||||||
const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP;
|
const ndcRadius = (0.5 * sizes[index]) / tanHalfFov + PICK_NDC_SLOP;
|
||||||
const dx = (projected.x - pointerNdc.x) * aspect;
|
const dx = (projected.x - pointerNdc.x) * aspect;
|
||||||
const dy = projected.y - pointerNdc.y;
|
const dy = projected.y - pointerNdc.y;
|
||||||
const score = Math.hypot(dx, dy) / ndcRadius;
|
const score = Math.hypot(dx, dy) / ndcRadius;
|
||||||
@@ -295,7 +400,7 @@ export class StarFieldRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestIndex === undefined ? undefined : this.stars[bestIndex].id;
|
return bestIndex === undefined ? undefined : this.starIdAt(bestIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export interface Positioned {
|
|||||||
* sort is required to be stable, exactly as the sort of the stars themselves was.
|
* sort is required to be stable, exactly as the sort of the stars themselves was.
|
||||||
*/
|
*/
|
||||||
export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array {
|
export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array {
|
||||||
return Uint32Array.from(stars.keys()).sort((a, b) => stars[a].magnitude - stars[b].magnitude);
|
// Compared from a typed copy rather than off the stars: the sort reads two magnitudes per
|
||||||
|
// comparison, some eight million times for the whole catalogue: 83 ms this way, 104-139 ms reading them off the stars.
|
||||||
|
const magnitudes = Float64Array.from(stars, (star) => star.magnitude);
|
||||||
|
return Uint32Array.from(stars.keys()).sort((a, b) => magnitudes[a] - magnitudes[b]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ export class StarNeighbourhood {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */
|
/** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */
|
||||||
|
/** Where the star this id names sits in the list the index was built from, or `undefined`. */
|
||||||
|
indexOf(id: number): number | undefined {
|
||||||
|
return this.indexById.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
point(id: number): StarPoint | undefined {
|
point(id: number): StarPoint | undefined {
|
||||||
const index = this.indexById.get(id);
|
const index = this.indexById.get(id);
|
||||||
return index === undefined ? undefined : this.points[index];
|
return index === undefined ? undefined : this.points[index];
|
||||||
|
|||||||
Reference in New Issue
Block a user