Budget the jump-link layer in pixels of line, nearest the view's centre first

With the drawn set following the view, the layer at 8 pc cost the integrated Radeon 503 ms a frame
at 30 pc from the Sun. Measured, the cost follows the length of line on screen (about 10 ms per
million pixels near the Sun), not the number of links: 100 000 links were 12 ms at the opening
view, 25 000 were 61 ms at 30 pc. So the budget is a length: a million pixels, turned into parsecs
at the depth the view is centred on, spent on the links nearest that centre by their nearer end.
Orbiting with links at 8 pc on the iGPU: 12-18 ms p50 at every pose measured, no long tasks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-17 18:23:55 +02:00
co-authored by Claude Opus 5
parent 45d5209433
commit bd5e9d4b0d
7 changed files with 161 additions and 20 deletions
@@ -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 { LinkBudget } from '../../shared/astro/jump-links';
import { HudDisplay } from '../hud/hud-dock.component'; 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';
@@ -425,7 +426,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
describe('the jump-link graph', () => { describe('the jump-link graph', () => {
type LinkScene = { type LinkScene = {
routing: { links(rangePc: number, drawn: Uint32Array): Promise<Float32Array>; route(): Promise<never>; dispose(): void }; routing: { links(rangePc: number, drawn: Uint32Array, budget?: LinkBudget): Promise<Float32Array>; route(): Promise<never>; dispose(): void };
display: { update(change: (display: { jumpLinks: boolean }) => unknown): void }; display: { update(change: (display: { jumpLinks: boolean }) => unknown): void };
jumpRangePc: { set(rangePc: number): void }; jumpRangePc: { set(rangePc: number): void };
routeResult: { set(value: unknown): void }; routeResult: { set(value: unknown): void };
@@ -455,7 +456,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
const component = linkScene(links); const component = linkScene(links);
await settle(); await settle();
expect(links).toHaveBeenCalledTimes(1); expect(links).toHaveBeenCalledTimes(1);
expect(links.mock.calls[0]).toEqual([3, component.starField.drawnStars]); expect(links.mock.calls[0].slice(0, 2)).toEqual([3, component.starField.drawnStars]);
await changeDrawnStars(component, 40); await changeDrawnStars(component, 40);
expect(links).toHaveBeenCalledTimes(1); expect(links).toHaveBeenCalledTimes(1);
@@ -471,6 +472,19 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
expect(links).toHaveBeenCalledTimes(2); expect(links).toHaveBeenCalledTimes(2);
}); });
it('asks for as much of the graph as a million pixels of line make, around where the view is centred', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0)));
Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1080 });
linkScene(links);
await settle();
const budget = links.mock.calls[0][2];
// The view opens centred on the Sun: its frame's half-height there, over 540 pixels, is a pixel's worth of parsecs.
const halfHeight = engine.getCamera().position.length() * Math.tan((50 * Math.PI) / 360);
expect(budget?.centre).toEqual({ x: 0, y: 0, z: 0 });
expect(budget?.lengthPc).toBeCloseTo((1_000_000 * halfHeight) / 540, 3);
});
it('gives a view on the move a new graph at least every quarter second, rather than waiting for it to stop', async () => { 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 links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0)));
const component = linkScene(links); const component = linkScene(links);
@@ -39,6 +39,7 @@ import { SystemObjectCardComponent } from './system-object-card.component';
import { RoutingClient } from './routing-client'; import { RoutingClient } from './routing-client';
import { colorIndexToRgb, FOCUS_RADIUS_PC, StarFieldRenderer, starRenderBudgetFromUrl, VIEW_MARGIN } 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 { LinkBudget } from '../../shared/astro/jump-links';
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';
@@ -76,6 +77,22 @@ const LABEL_REACH_NDC = 0.3;
* emits per pixel; and how often at most a view on the move gets a graph for its new drawn stars. * 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;
/**
* How much jump-link line the layer draws, in pixels of length on screen: about a million, measured
* where lines are longest.
*
* What a graph costs to draw is its length on screen, not its number of links: every pixel of it is
* blended over whatever is already there. On the Ryzen 7700X's integrated Radeon, standing in for an
* entry-level laptop, at 1920 × 1080 with the range at 8 pc:
* - near the Sun, about 10 ms a frame per million pixels. At 30 pc from it, 25 000 links were 4.8
* million pixels and 60 ms; 5 000 were 0.9 million and 18 ms, about 55 frames a second;
* - at the opening view, where the links are short, 100 000 links were 1.8 million pixels and 12 ms.
*
* So a count could not serve both: the budget is a length, turned into parsecs at the depth the view
* is centred on, and spent on the links nearest that centre. The RTX 4080 draws every graph in the
* same 6 ms, but the budget is the same everywhere, like the stars'.
*/
const JUMP_LINK_PIXEL_BUDGET = 1_000_000;
/** /**
* How far in or out the plan view may be zoomed from the extent its distance frames. Under a * How far in or out the plan view may be zoomed from the extent its distance frames. Under a
@@ -1553,7 +1570,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
} }
this.drawnJumpRangePc = rangePc; this.drawnJumpRangePc = rangePc;
this.linkedStars = drawn; this.linkedStars = drawn;
void this.routing.links(rangePc, drawn).then( void this.routing.links(rangePc, drawn, this.jumpLinkBudget()).then(
(segments) => { (segments) => {
if (this.drawnJumpRangePc === rangePc) { if (this.drawnJumpRangePc === rangePc) {
this.jumpLinks?.setSegments(segments); this.jumpLinks?.setSegments(segments);
@@ -1570,6 +1587,20 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
); );
} }
/**
* How much of the graph to draw: the links nearest the view's centre, up to the length that
* `JUMP_LINK_PIXEL_BUDGET` pixels of line make at that depth. None without a canvas to measure.
*/
private jumpLinkBudget(): LinkBudget | undefined {
const heightPx = this.canvasRef().nativeElement.clientHeight;
if (heightPx === 0) {
return undefined;
}
const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
const halfHeight = this.engine.visibleHalfHeight(this.engine.getCamera().position.distanceTo(centre));
return { centre: { x: centre.x, y: centre.y, z: centre.z }, lengthPc: (JUMP_LINK_PIXEL_BUDGET * 2 * halfHeight) / heightPx };
}
/** A pinned body wins over a hovered one, so the card does not change under the pointer. */ /** A pinned body wins over a hovered one, so the card does not change under the pointer. */
private refreshObjectCard(): void { private refreshObjectCard(): void {
const id = this.pinnedBodyId ?? this.hoveredBodyId; const id = this.pinnedBodyId ?? this.hoveredBodyId;
@@ -94,6 +94,16 @@ describe('RoutingClient without a worker', () => {
client.dispose(); client.dispose();
}); });
it('keeps only the links its budget holds, nearest the centre first', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
// Stars at x = 0 to 4 a parsec apart: from a centre at 3.9, one and a half parsecs is the link 3-4 alone.
const segments = await client.links(1.5, ALL, { centre: { x: 3.9, y: 0, z: 0 }, lengthPc: 1.5 });
expect(Array.from(segments)).toEqual([3, 0, 0, 4, 0, 0]);
client.dispose();
});
it('links only the stars it is told are drawn', async () => { it('links only the stars it is told are drawn', async () => {
const client = new RoutingClient(STARS, POSITIONS, index); const client = new RoutingClient(STARS, POSITIONS, index);
@@ -196,6 +206,12 @@ describe('RoutingClient with a worker', () => {
expect(worker.requests.map((request) => request.kind === 'links' && Array.from(request.drawn))).toEqual([[0, 1, 2], [3, 4, 5]]); expect(worker.requests.map((request) => request.kind === 'links' && Array.from(request.drawn))).toEqual([[0, 1, 2], [3, 4, 5]]);
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(12) }); worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(12) });
await expect(second).resolves.toHaveLength(12); await expect(second).resolves.toHaveLength(12);
// The same list at a different budget is a different graph.
void client.links(3, far, { centre: { x: 1, y: 0, z: 0 }, lengthPc: 10 });
void client.links(3, far, { centre: { x: 1, y: 0, z: 0 }, lengthPc: 20 });
worker.answer({ kind: 'links', requestId: worker.requests[2].requestId, segments: new Float32Array(0) });
await flush();
expect(worker.requests.map((request) => request.kind === 'links' && request.budget?.lengthPc)).toEqual([undefined, undefined, 10, 20]);
// The star field goes on drawing and picking from these lists, so they are copied, not moved. // The star field goes on drawing and picking from these lists, so they are copied, not moved.
expect(worker.transferred).not.toContain(near.buffer); expect(worker.transferred).not.toContain(near.buffer);
expect(worker.transferred).not.toContain(far.buffer); expect(worker.transferred).not.toContain(far.buffer);
@@ -1,5 +1,5 @@
import { answerRouting, RoutingRequest, RoutingResponse } from '../../shared/astro/routing'; import { answerRouting, RoutingRequest, RoutingResponse } from '../../shared/astro/routing';
import { Route } from '../../shared/astro/jump-links'; import { LinkBudget, Route } from '../../shared/astro/jump-links';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood'; import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
@@ -34,13 +34,23 @@ function outstanding(request: RoutingRequest): Outstanding {
} }
/** /**
* Whether two requests ask the same question. A graph is the same when it is for the same range and * Whether two requests ask the same question. A graph is the same when it is for the same range, the
* the very same list of drawn stars: the star field replaces that list whenever the set changes, so * same budget and the very same list of drawn stars: the star field replaces that list whenever the
* one array is one set, and comparing 70 000 indices would cost more than sharing could save. * set changes, so one array is one set, and comparing 70 000 indices would cost more than sharing
* could save.
*/ */
function asksTheSame(a: RoutingRequest, b: RoutingRequest): boolean { function asksTheSame(a: RoutingRequest, b: RoutingRequest): boolean {
if (a.kind === 'links' || b.kind === 'links') { if (a.kind === 'links' || b.kind === 'links') {
return a.kind === 'links' && b.kind === 'links' && a.rangePc === b.rangePc && a.drawn === b.drawn; return (
a.kind === 'links' &&
b.kind === 'links' &&
a.rangePc === b.rangePc &&
a.drawn === b.drawn &&
a.budget?.lengthPc === b.budget?.lengthPc &&
a.budget?.centre.x === b.budget?.centre.x &&
a.budget?.centre.y === b.budget?.centre.y &&
a.budget?.centre.z === b.budget?.centre.z
);
} }
return a.fromId === b.fromId && a.toId === b.toId && a.rangePc === b.rangePc && a.ceilingPc === b.ceilingPc; return a.fromId === b.fromId && a.toId === b.toId && a.rangePc === b.rangePc && a.ceilingPc === b.ceilingPc;
} }
@@ -100,10 +110,10 @@ export class RoutingClient {
/** /**
* Vertex pairs for every link within `rangePc` between two of the `drawn` stars (catalogue * Vertex pairs for every link within `rangePc` between two of the `drawn` stars (catalogue
* indices), three floats to an end. * indices), three floats to an end; only those nearest the budget's centre that fit it, if given.
*/ */
links(rangePc: number, drawn: Uint32Array): Promise<Float32Array> { links(rangePc: number, drawn: Uint32Array, budget?: LinkBudget): Promise<Float32Array> {
return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc, drawn }).then((response) => return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc, drawn, budget }).then((response) =>
response.kind === 'links' ? response.segments : new Float32Array(0) response.kind === 'links' ? response.segments : new Float32Array(0)
); );
} }
+21
View File
@@ -191,6 +191,27 @@ describe('jumpLinkSegments', () => {
expect(jumpLinkSegments(chain(4), 0)).toHaveLength(0); expect(jumpLinkSegments(chain(4), 0)).toHaveLength(0);
}); });
it('keeps the links nearest the centre first, for as much length as the budget holds', () => {
// A parsec apart from 0 to 20, the centre at 10.3. By nearer end: 9-10 and 10-11 (0.3 away),
// then 11-12 (0.7), then 8-9 (1.3). Three parsecs of them fit in 3.5; a fourth would not.
const budget = { centre: { x: 10.3, y: 0, z: 0 }, lengthPc: 3.5 };
const segments = jumpLinkSegments(chain(21), 1.5, budget);
expect(linksDrawn(segments, chainPoints(21)).sort()).toEqual(['10-11', '11-12', '9-10']);
expect(segments.buffer.byteLength).toBe(segments.byteLength);
});
it('counts the budget in parsecs of link, not in links', () => {
// Stars at 0, 1 and 3: a 2 pc link nearest the centre, then a 1 pc one. Two and a half parsecs
// hold the first and not both, though two links would fit a count of two and a half.
const points: StarPoint[] = [{ id: 0, x: 0, y: 0, z: 0 }, { id: 1, x: 1, y: 0, z: 0 }, { id: 2, x: 3, y: 0, z: 0 }];
const segments = jumpLinkSegments(index(points), 2.5, { centre: { x: 3, y: 0, z: 0 }, lengthPc: 2.5 });
expect(linksDrawn(segments, points)).toEqual(['1-2']);
});
it('grows past its first buffer without losing a link', () => { it('grows past its first buffer without losing a link', () => {
// 5 000 stars a tenth of a parsec apart, ten neighbours each way in range: some 50 000 links, far past // 5 000 stars a tenth of a parsec apart, ten neighbours each way in range: some 50 000 links, far past
// the 4 096 the buffer starts with, so it has to grow several times. // the 4 096 the buffer starts with, so it has to grow several times.
+48 -2
View File
@@ -213,16 +213,32 @@ export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, to
return reachable; return reachable;
} }
/** How much of a graph to keep: the links nearest a point, up to a total length. */
export interface LinkBudget {
/** Links are kept in order of how near their nearer end is to this point. */
readonly centre: { readonly x: number; readonly y: number; readonly z: number };
/** The most the kept links may add up to, end to end, in parsecs. */
readonly lengthPc: number;
}
/**
* Keys pack a link's nearer-end distance, in thousandths of a parsec, above its index, so one
* numeric sort of plain doubles orders the links nearest first: room for four million links and
* two thousand kiloparsecs, inside a double's exact integers.
*/
const LINK_INDEX_SPAN = 2 ** 22;
/** /**
* Every link within `rangePc` between two of the stars `index` holds, each pair once, as vertex * Every link within `rangePc` between two of the stars `index` holds, each pair once, as vertex
* pairs ready to draw: six floats a link, one end then the other. * pairs ready to draw: six floats a link, one end then the other. With a `budget`, only the links
* nearest its centre, as many as fit its length.
* *
* For drawing the graph, which is the only thing that wants all of it: routing asks for a star's * For drawing the graph, which is the only thing that wants all of it: routing asks for a star's
* neighbours as it reaches that star and never builds this. Written straight into floats rather * neighbours as it reaches that star and never builds this. Written straight into floats rather
* than collected as link objects first, since at 8 pc the drawn stars alone have hundreds of * than collected as link objects first, since at 8 pc the drawn stars alone have hundreds of
* thousands of links, and the whole catalogue 3.7 million. * thousands of links, and the whole catalogue 3.7 million.
*/ */
export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number): Float32Array { export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number, budget?: LinkBudget): Float32Array {
let vertices = new Float32Array(6 * 4096); let vertices = new Float32Array(6 * 4096);
let length = 0; let length = 0;
index.forEachPairWithin(rangePc, (a, b) => { index.forEachPairWithin(rangePc, (a, b) => {
@@ -238,7 +254,37 @@ export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number): Flo
vertices[length++] = b.y; vertices[length++] = b.y;
vertices[length++] = b.z; vertices[length++] = b.z;
}); });
if (!budget) {
// Exact length rather than a view on the grown buffer: the answer is transferred whole, and a // Exact length rather than a view on the grown buffer: the answer is transferred whole, and a
// view would carry up to as much again in unused capacity with it. // view would carry up to as much again in unused capacity with it.
return vertices.slice(0, length); return vertices.slice(0, length);
}
const { centre } = budget;
const count = length / 6;
const keys = new Float64Array(count);
for (let link = 0; link < count; link++) {
const at = link * 6;
const nearer = Math.min(
Math.hypot(vertices[at] - centre.x, vertices[at + 1] - centre.y, vertices[at + 2] - centre.z),
Math.hypot(vertices[at + 3] - centre.x, vertices[at + 4] - centre.y, vertices[at + 5] - centre.z)
);
keys[link] = Math.floor(nearer * 1000) * LINK_INDEX_SPAN + link;
}
keys.sort();
const kept = new Float32Array(length);
let keptLength = 0;
let totalPc = 0;
for (const key of keys) {
const at = (key % LINK_INDEX_SPAN) * 6;
const linkPc = Math.hypot(vertices[at + 3] - vertices[at], vertices[at + 4] - vertices[at + 1], vertices[at + 5] - vertices[at + 2]);
if (totalPc + linkPc > budget.lengthPc) {
break;
}
totalPc += linkPc;
kept.set(vertices.subarray(at, at + 6), keptLength);
keptLength += 6;
}
return kept.slice(0, keptLength);
} }
+7 -4
View File
@@ -7,7 +7,7 @@
* answers them in place where one does not. * answers them in place where one does not.
*/ */
import { jumpLinkSegments, minimumRangeBetween, Route, routeBetween } from './jump-links'; import { jumpLinkSegments, LinkBudget, minimumRangeBetween, Route, routeBetween } from './jump-links';
import { StarNeighbourhood } from './star-neighbourhood'; import { StarNeighbourhood } from './star-neighbourhood';
/** The catalogue, sent once: ids, and positions packed three to a star in the same order. */ /** The catalogue, sent once: ids, and positions packed three to a star in the same order. */
@@ -19,8 +19,11 @@ export interface RoutingCatalogue {
export type RoutingRequest = export type RoutingRequest =
| { readonly kind: 'route'; readonly requestId: number; readonly fromId: number; readonly toId: number; readonly rangePc: number; readonly ceilingPc: number } | { readonly kind: 'route'; readonly requestId: number; readonly fromId: number; readonly toId: number; readonly rangePc: number; readonly ceilingPc: number }
/** `drawn` is the stars the map is drawing, as positions in the catalogue that was sent: only they are linked. */ /**
| { readonly kind: 'links'; readonly requestId: number; readonly rangePc: number; readonly drawn: Uint32Array }; * `drawn` is the stars the map is drawing, as positions in the catalogue that was sent: only they
* are linked. `budget`, where given, keeps only the links nearest the view that fit its length.
*/
| { readonly kind: 'links'; readonly requestId: number; readonly rangePc: number; readonly drawn: Uint32Array; readonly budget?: LinkBudget };
export type RoutingResponse = export type RoutingResponse =
| { readonly kind: 'route'; readonly requestId: number; readonly route: Route | null; readonly neededRangePc: number | null } | { readonly kind: 'route'; readonly requestId: number; readonly route: Route | null; readonly neededRangePc: number | null }
@@ -42,7 +45,7 @@ export function answerRouting(index: StarNeighbourhood, request: RoutingRequest)
// An index of its own over the drawn stars, in cells as wide as the range, so each cell is // An index of its own over the drawn stars, in cells as wide as the range, so each cell is
// paired with its immediate neighbours only: 14 cells a cell at 8 pc rather than 63. // paired with its immediate neighbours only: 14 cells a cell at 8 pc rather than 63.
const drawn = new StarNeighbourhood(Array.from(request.drawn, (at) => index.pointAt(at)), request.rangePc); const drawn = new StarNeighbourhood(Array.from(request.drawn, (at) => index.pointAt(at)), request.rangePc);
return { kind: 'links', requestId: request.requestId, segments: jumpLinkSegments(drawn, request.rangePc) }; return { kind: 'links', requestId: request.requestId, segments: jumpLinkSegments(drawn, request.rangePc, request.budget) };
} }
const route = routeBetween(index, request.fromId, request.toId, request.rangePc); const route = routeBetween(index, request.fromId, request.toId, request.rangePc);
return { return {