Merge pull request #21 from avalon-vanguard/star-map/perf/label-scan
Name the brightest stars by walking one order, instead of sorting 60 000 five times a second Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -211,6 +211,30 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
expect(navigationStore.viewLevel()).toBe('galaxy');
|
expect(navigationStore.viewLevel()).toBe('galaxy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
// brightness order, so asking for one more than is used can cost a walk of the whole order.
|
||||||
|
const component = fixture.componentInstance as unknown as {
|
||||||
|
spreadLabels(candidates: Iterable<{ id: number; name: string; x: number; y: number; z: number }>, camera: THREE.Camera, keepId: null): unknown[];
|
||||||
|
};
|
||||||
|
const camera = engine.getCamera();
|
||||||
|
camera.updateMatrixWorld(true);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
let pulled = 0;
|
||||||
|
const grid = function* () {
|
||||||
|
for (let row = 0; row < 5; row++) {
|
||||||
|
for (let column = 0; column < 5; column++) {
|
||||||
|
pulled++;
|
||||||
|
const point = new THREE.Vector3(-0.8 + column * 0.4, -0.8 + row * 0.4, 0.5).unproject(camera);
|
||||||
|
yield { id: row * 5 + column, name: `label-${pulled}`, x: point.x, y: point.y, z: point.z };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(component.spreadLabels(grid(), camera, null)).toHaveLength(15);
|
||||||
|
expect(pulled).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => {
|
it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => {
|
||||||
navigationStore.selectStar(SUN.id);
|
navigationStore.selectStar(SUN.id);
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ 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, 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 { 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';
|
||||||
import { HostStarRings } from './host-star-rings';
|
import { HostStarRings } from './host-star-rings';
|
||||||
@@ -356,6 +357,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
private deepSkyLabels: readonly LabeledPoint[] = [];
|
private deepSkyLabels: readonly LabeledPoint[] = [];
|
||||||
/** Stars with at least one catalogued body, which are the ones the map can be flown into. */
|
/** Stars with at least one catalogued body, which are the ones the map can be flown into. */
|
||||||
private starIdsWithBodies = new Set<number>();
|
private starIdsWithBodies = new Set<number>();
|
||||||
|
/** Catalogue indices, brightest first, for the labels to walk rather than sort. See `brightestWithin`. */
|
||||||
|
private starsByBrightness: BrightnessIndex = brightnessIndex([]);
|
||||||
/** Stars alone, normalised once, for the two routing fields. Empty until the catalogue lands. */
|
/** Stars alone, normalised once, for the two routing fields. Empty until the catalogue lands. */
|
||||||
private readonly starSearchIndex = signal<IndexedSearchEntry[]>([]);
|
private readonly starSearchIndex = signal<IndexedSearchEntry[]>([]);
|
||||||
private milkyWay?: MilkyWayRenderer;
|
private milkyWay?: MilkyWayRenderer;
|
||||||
@@ -518,6 +521,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.stars = stars;
|
this.stars = stars;
|
||||||
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
||||||
this.neighbourhood = new StarNeighbourhood(stars);
|
this.neighbourhood = new StarNeighbourhood(stars);
|
||||||
|
this.starsByBrightness = brightnessIndex(stars);
|
||||||
this.starSearchIndex.set(
|
this.starSearchIndex.set(
|
||||||
buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id })))
|
buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id })))
|
||||||
);
|
);
|
||||||
@@ -766,47 +770,27 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
// orbit distance, so a camera-relative rule names the stars closest to the near edge of the
|
// orbit distance, so a camera-relative rule names the stars closest to the near edge of the
|
||||||
// view — a ring of labels around the outside of the thing the user is actually looking at.
|
// view — a ring of labels around the outside of the thing the user is actually looking at.
|
||||||
const target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
|
const target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
|
||||||
const { x: cx, y: cy, z: cz } = target;
|
|
||||||
const orbitDistance = (this.controls ? this.effectiveDistance(camera) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE;
|
const orbitDistance = (this.controls ? this.effectiveDistance(camera) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE;
|
||||||
const labelRadius = THREE.MathUtils.clamp(orbitDistance, MIN_LABEL_RADIUS_PC, MAX_LABEL_RADIUS_PC);
|
const labelRadius = THREE.MathUtils.clamp(orbitDistance, MIN_LABEL_RADIUS_PC, MAX_LABEL_RADIUS_PC);
|
||||||
const maxDistanceSq = labelRadius * labelRadius;
|
|
||||||
|
|
||||||
const candidates: Array<{ star: StarRecord; distanceSq: number }> = [];
|
|
||||||
for (const star of this.stars) {
|
|
||||||
const dx = star.x - cx;
|
|
||||||
const dy = star.y - cy;
|
|
||||||
const dz = star.z - cz;
|
|
||||||
const distanceSq = dx * dx + dy * dy + dz * dz;
|
|
||||||
if (distanceSq <= maxDistanceSq || star.id === selectedId) {
|
|
||||||
candidates.push({ star, distanceSq });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Individual star names mean nothing once the whole Galaxy is in frame — at that range the
|
||||||
|
// entire catalogue is inside one pixel — so the labels hand over to the structural ones.
|
||||||
|
const isGalactic = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD;
|
||||||
// Brightest first, not nearest first. Proximity was the right ranking when the catalogue was
|
// Brightest first, not nearest first. Proximity was the right ranking when the catalogue was
|
||||||
// a 50 pc bubble and everything in it was equally worth naming; across 250 pc it labels a
|
// a 50 pc bubble and everything in it was equally worth naming; across 250 pc it labels a
|
||||||
// clump of whatever happens to be closest to the middle of the screen and never names the
|
// clump of whatever happens to be closest to the middle of the screen and never names the
|
||||||
// stars that are actually prominent. Brightness is what makes a star worth a name.
|
// stars that are actually prominent. Brightness is what makes a star worth a name.
|
||||||
candidates.sort((a, b) => a.star.magnitude - b.star.magnitude);
|
//
|
||||||
// Individual star names mean nothing once the whole Galaxy is in frame — at that range the
|
// Walked lazily, and only as far as it takes to place the labels. "System" rather than "Star"
|
||||||
// entire catalogue is inside one pixel — so the labels hand over to the structural ones.
|
// for anything with catalogued bodies: it is the one distinction the second line can draw that
|
||||||
const isGalactic = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD;
|
// the map cannot otherwise show, since it says which of these points is somewhere you can go.
|
||||||
// "System" rather than "Star" for anything with catalogued bodies: it is the one distinction
|
const starIdsWithBodies = this.starIdsWithBodies;
|
||||||
// the second line can draw that the map cannot otherwise show, since it says which of these
|
const candidates = function* (stars: readonly StarRecord[], index: BrightnessIndex): Generator<LabeledPoint> {
|
||||||
// points is somewhere you can actually go.
|
for (const star of brightestWithin(stars, index, target, labelRadius, selectedId)) {
|
||||||
const starLabels: LabeledPoint[] = isGalactic
|
yield { id: star.id, name: star.name, kind: starIdsWithBodies.has(star.id) ? 'System' : 'Star', x: star.x, y: star.y, z: star.z };
|
||||||
? []
|
}
|
||||||
: this.spreadLabels(
|
};
|
||||||
candidates.map(({ star }) => ({
|
const starLabels: LabeledPoint[] = isGalactic ? [] : this.spreadLabels(candidates(this.stars, this.starsByBrightness), camera, selectedId);
|
||||||
id: star.id,
|
|
||||||
name: star.name,
|
|
||||||
kind: this.starIdsWithBodies.has(star.id) ? 'System' : 'Star',
|
|
||||||
x: star.x,
|
|
||||||
y: star.y,
|
|
||||||
z: star.z
|
|
||||||
})),
|
|
||||||
camera,
|
|
||||||
selectedId
|
|
||||||
);
|
|
||||||
const backdropLabels = isGalactic ? this.galacticLabels : this.deepSkyLabels;
|
const backdropLabels = isGalactic ? this.galacticLabels : this.deepSkyLabels;
|
||||||
const ringLabels = isGalactic || !this.display().grid ? [] : this.ringLabels(camera);
|
const ringLabels = isGalactic || !this.display().grid ? [] : this.ringLabels(camera);
|
||||||
this.labelOverlay?.update([...starLabels, ...ringLabels, ...backdropLabels]);
|
this.labelOverlay?.update([...starLabels, ...ringLabels, ...backdropLabels]);
|
||||||
@@ -831,16 +815,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
return canvas.clientHeight > 0 ? canvas.clientWidth / canvas.clientHeight : 1;
|
return canvas.clientHeight > 0 ? canvas.clientWidth / canvas.clientHeight : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private spreadLabels(candidates: readonly LabeledPoint[], camera: SceneCamera, keepId: number | string | null): LabeledPoint[] {
|
private spreadLabels(candidates: Iterable<LabeledPoint>, camera: SceneCamera, keepId: number | string | null): LabeledPoint[] {
|
||||||
const placed: THREE.Vector2[] = [];
|
const placed: THREE.Vector2[] = [];
|
||||||
const chosen: LabeledPoint[] = [];
|
const chosen: LabeledPoint[] = [];
|
||||||
const projected = new THREE.Vector3();
|
const projected = new THREE.Vector3();
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (chosen.length >= LABEL_MAX_COUNT) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
projected.set(candidate.x, candidate.y, candidate.z).project(camera);
|
projected.set(candidate.x, candidate.y, candidate.z).project(camera);
|
||||||
const isKept = candidate.id === keepId;
|
const isKept = candidate.id === keepId;
|
||||||
// Offscreen or behind the camera.
|
// Offscreen or behind the camera.
|
||||||
@@ -867,6 +847,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
placed.push(point);
|
placed.push(point);
|
||||||
chosen.push({ ...candidate, side });
|
chosen.push({ ...candidate, side });
|
||||||
|
// Here rather than at the top of the loop: there, taking the fifteenth label asked the
|
||||||
|
// candidates for a sixteenth first, and near the Sun finding one walks most of the catalogue.
|
||||||
|
if (chosen.length >= LABEL_MAX_COUNT) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return chosen;
|
return chosen;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { brightestWithin, brightnessIndex, brightnessOrder } from './brightest';
|
||||||
|
|
||||||
|
interface TestStar {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
magnitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pseudo-random cloud with repeated magnitudes, so ties are exercised. */
|
||||||
|
function cloud(count: number): TestStar[] {
|
||||||
|
let seed = 5;
|
||||||
|
const random = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||||
|
return Array.from({ length: count }, (_, id) => ({
|
||||||
|
id: id * 7,
|
||||||
|
x: random() * 200 - 100,
|
||||||
|
y: random() * 200 - 100,
|
||||||
|
z: random() * 200 - 100,
|
||||||
|
magnitude: Math.round(random() * 40) / 4
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('brightnessOrder', () => {
|
||||||
|
it('puts the brightest first and keeps catalogue order among equals', () => {
|
||||||
|
const stars = [{ magnitude: 5 }, { magnitude: -1 }, { magnitude: 5 }, { magnitude: 2 }];
|
||||||
|
|
||||||
|
expect(Array.from(brightnessOrder(stars))).toEqual([1, 3, 0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders nothing for an empty catalogue', () => {
|
||||||
|
expect(brightnessOrder([])).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('brightestWithin', () => {
|
||||||
|
// What the labels used to do on every pass: filter the whole catalogue, then sort what was left.
|
||||||
|
function filterThenSort(stars: TestStar[], centre: { x: number; y: number; z: number }, radius: number, alwaysId: number | null): number[] {
|
||||||
|
return stars
|
||||||
|
.filter((star) => Math.hypot(star.x - centre.x, star.y - centre.y, star.z - centre.z) <= radius || star.id === alwaysId)
|
||||||
|
.sort((a, b) => a.magnitude - b.magnitude)
|
||||||
|
.map((star) => star.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('yields exactly what filtering and then sorting the catalogue did, in the same order', () => {
|
||||||
|
const stars = cloud(3000);
|
||||||
|
const index = brightnessIndex(stars);
|
||||||
|
const centre = { x: 12, y: -30, z: 5 };
|
||||||
|
|
||||||
|
for (const [radius, alwaysId] of [[40, null], [15, 7 * 2999], [0, 7 * 11], [500, null]] as const) {
|
||||||
|
const lazy = Array.from(brightestWithin(stars, index, centre, radius, alwaysId), (star) => star.id);
|
||||||
|
expect(lazy).toEqual(filterThenSort(stars, centre, radius, alwaysId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes a star lying exactly on the radius, as the scan it replaced did', () => {
|
||||||
|
const stars = [
|
||||||
|
{ id: 1, x: 3, y: 4, z: 0, magnitude: 1 },
|
||||||
|
{ id: 2, x: 3, y: 4.001, z: 0, magnitude: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(Array.from(brightestWithin(stars, brightnessIndex(stars), { x: 0, y: 0, z: 0 }, 5, null), (star) => star.id)).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads no further than the caller takes', () => {
|
||||||
|
const stars = cloud(3000);
|
||||||
|
let read = 0;
|
||||||
|
const counted = new Proxy(stars, {
|
||||||
|
get(target, key, receiver) {
|
||||||
|
if (typeof key === 'string' && /^\d+$/.test(key)) {
|
||||||
|
read++;
|
||||||
|
}
|
||||||
|
return Reflect.get(target, key, receiver);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const taken: number[] = [];
|
||||||
|
for (const star of brightestWithin(counted, brightnessIndex(stars), { x: 0, y: 0, z: 0 }, 1000, null)) {
|
||||||
|
taken.push(star.id);
|
||||||
|
if (taken.length === 15) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(taken).toHaveLength(15);
|
||||||
|
expect(read).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* The catalogue in order of brightness, worked out once and walked as often as needed.
|
||||||
|
*
|
||||||
|
* Two parts of the map want "the brightest stars in this region": the labels, which name about
|
||||||
|
* fifteen of them five times a second, and the star field, which draws a budget of them. Sorting
|
||||||
|
* the region each time is paid for every star in it. At the opening view the label region holds
|
||||||
|
* some 60 000 stars, and sorting them to name fifteen took 55-70 ms a pass, a stall five times
|
||||||
|
* a second on any machine. Walking one shared order and stopping when enough have been taken
|
||||||
|
* costs only the stars looked at before that.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BrightnessRanked {
|
||||||
|
readonly magnitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Positioned {
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
readonly z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indices into `stars`, brightest (lowest magnitude) first. Ties keep catalogue order: typed-array
|
||||||
|
* sort is required to be stable, exactly as the sort of the stars themselves was.
|
||||||
|
*/
|
||||||
|
export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array {
|
||||||
|
return Uint32Array.from(stars.keys()).sort((a, b) => stars[a].magnitude - stars[b].magnitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The brightness order, with each star's position and id laid out beside it in that order.
|
||||||
|
*
|
||||||
|
* A walk has to test every star it passes, and near the Sun it passes nearly all of them: a 4 pc
|
||||||
|
* label radius holds a few dozen stars, faint dwarfs deep in the order, so the walk rarely finds
|
||||||
|
* fifteen to name before the end. Reading the stars themselves in brightness order jumps all over
|
||||||
|
* the catalogue, and a full walk took 19-23 ms — slower than the scan and sort it replaced. Read
|
||||||
|
* from these arrays, laid out in the order they are walked, the same walk touches memory in
|
||||||
|
* sequence and reads a star only when it yields one.
|
||||||
|
*/
|
||||||
|
export interface BrightnessIndex {
|
||||||
|
/** Indices into the catalogue, brightest first. */
|
||||||
|
readonly order: Uint32Array;
|
||||||
|
/** Positions in the same order, three to a star, at full precision so a star on a radius stays on it. */
|
||||||
|
readonly positions: Float64Array;
|
||||||
|
readonly ids: Float64Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brightnessIndex<T extends BrightnessRanked & Positioned & { readonly id: number }>(stars: readonly T[]): BrightnessIndex {
|
||||||
|
const order = brightnessOrder(stars);
|
||||||
|
const positions = new Float64Array(order.length * 3);
|
||||||
|
const ids = new Float64Array(order.length);
|
||||||
|
order.forEach((index, at) => {
|
||||||
|
const star = stars[index];
|
||||||
|
positions[at * 3] = star.x;
|
||||||
|
positions[at * 3 + 1] = star.y;
|
||||||
|
positions[at * 3 + 2] = star.z;
|
||||||
|
ids[at] = star.id;
|
||||||
|
});
|
||||||
|
return { order, positions, ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stars within `radiusPc` of `centre`, brightest first, plus the one star `alwaysId` names
|
||||||
|
* wherever it is — handed over lazily, so a caller that stops after the first few pays for no
|
||||||
|
* more than it read.
|
||||||
|
*/
|
||||||
|
export function* brightestWithin<T extends BrightnessRanked & Positioned & { readonly id: number }>(
|
||||||
|
stars: readonly T[],
|
||||||
|
index: BrightnessIndex,
|
||||||
|
centre: Positioned,
|
||||||
|
radiusPc: number,
|
||||||
|
alwaysId: number | null
|
||||||
|
): Generator<T> {
|
||||||
|
const { order, positions, ids } = index;
|
||||||
|
const radiusSq = radiusPc * radiusPc;
|
||||||
|
for (let at = 0; at < order.length; at++) {
|
||||||
|
const dx = positions[at * 3] - centre.x;
|
||||||
|
const dy = positions[at * 3 + 1] - centre.y;
|
||||||
|
const dz = positions[at * 3 + 2] - centre.z;
|
||||||
|
if (dx * dx + dy * dy + dz * dz <= radiusSq || ids[at] === alwaysId) {
|
||||||
|
yield stars[order[at]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user