Name the brightest stars by walking one order, instead of sorting 60 000 five times a second
The star labels are refreshed every 0.2 s. Each pass filtered the whole catalogue to the stars within the label radius, sorted them by magnitude, and turned every one into a label object, all to place at most fifteen. At the opening view the radius holds about 60 000 stars, so each pass was a 55-70 ms task on the main thread. A CPU profile of the opening view, on a Ryzen 7700X with an RTX 4080, counted 29 tasks over 50 ms in 6.7 s, one every 230 ms; updateLabels took 23% of the main thread. That is the stutter the frame-time bench measured on every GPU and every render budget. The catalogue is now sorted by brightness once, when it loads. brightestWithin walks that order and hands stars over lazily, and spreadLabels already stopped once it had placed fifteen labels, so a pass reads only the stars it looks at. The output is the same as before: the same stars, in the same order, with ties in catalogue order, the selected star named wherever it is, and a star exactly on the radius included. The spec checks it against the filter-then-sort it replaces. Stars are no longer scanned at all when the view is at galactic scale, where the result was thrown away. Profiled again on the same view: 0 tasks over 50 ms, and the scene's per-frame work over the window dropped from 2 028 ms to 342 ms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { brightestWithin, 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 order = brightnessOrder(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, order, 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, brightnessOrder(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, brightnessOrder(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,52 @@
|
||||
/**
|
||||
* 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 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[],
|
||||
order: Uint32Array,
|
||||
centre: Positioned,
|
||||
radiusPc: number,
|
||||
alwaysId: number | null
|
||||
): Generator<T> {
|
||||
const radiusSq = radiusPc * radiusPc;
|
||||
for (const index of order) {
|
||||
const star = stars[index];
|
||||
const dx = star.x - centre.x;
|
||||
const dy = star.y - centre.y;
|
||||
const dz = star.z - centre.z;
|
||||
if (dx * dx + dy * dy + dz * dz <= radiusSq || star.id === alwaysId) {
|
||||
yield star;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user