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:
@@ -10,6 +10,7 @@ import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { LinkBudget } from '../../shared/astro/jump-links';
|
||||
import { HudDisplay } from '../hud/hud-dock.component';
|
||||
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
||||
import { JumpLinkRenderer } from './jump-link-renderer';
|
||||
@@ -425,7 +426,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
|
||||
describe('the jump-link graph', () => {
|
||||
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 };
|
||||
jumpRangePc: { set(rangePc: number): void };
|
||||
routeResult: { set(value: unknown): void };
|
||||
@@ -455,7 +456,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
const component = linkScene(links);
|
||||
await settle();
|
||||
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);
|
||||
expect(links).toHaveBeenCalledTimes(1);
|
||||
@@ -471,6 +472,19 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
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 () => {
|
||||
const links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0)));
|
||||
const component = linkScene(links);
|
||||
|
||||
@@ -39,6 +39,7 @@ import { SystemObjectCardComponent } from './system-object-card.component';
|
||||
import { RoutingClient } from './routing-client';
|
||||
import { colorIndexToRgb, FOCUS_RADIUS_PC, StarFieldRenderer, starRenderBudgetFromUrl, VIEW_MARGIN } from './star-field-renderer';
|
||||
import { BrightnessIndex, brightestWithin, brightnessIndex } from '../../shared/astro/brightest';
|
||||
import { LinkBudget } from '../../shared/astro/jump-links';
|
||||
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
||||
import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component';
|
||||
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.
|
||||
*/
|
||||
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
|
||||
@@ -1553,7 +1570,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
this.drawnJumpRangePc = rangePc;
|
||||
this.linkedStars = drawn;
|
||||
void this.routing.links(rangePc, drawn).then(
|
||||
void this.routing.links(rangePc, drawn, this.jumpLinkBudget()).then(
|
||||
(segments) => {
|
||||
if (this.drawnJumpRangePc === rangePc) {
|
||||
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. */
|
||||
private refreshObjectCard(): void {
|
||||
const id = this.pinnedBodyId ?? this.hoveredBodyId;
|
||||
|
||||
@@ -94,6 +94,16 @@ describe('RoutingClient without a worker', () => {
|
||||
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 () => {
|
||||
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]]);
|
||||
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(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.
|
||||
expect(worker.transferred).not.toContain(near.buffer);
|
||||
expect(worker.transferred).not.toContain(far.buffer);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { 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
|
||||
* the very same list of drawn stars: the star field replaces that list whenever the set changes, so
|
||||
* one array is one set, and comparing 70 000 indices would cost more than sharing could save.
|
||||
* Whether two requests ask the same question. A graph is the same when it is for the same range, the
|
||||
* same budget and the very same list of drawn stars: the star field replaces that list whenever the
|
||||
* 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 {
|
||||
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;
|
||||
}
|
||||
@@ -100,10 +110,10 @@ export class RoutingClient {
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc, drawn }).then((response) =>
|
||||
links(rangePc: number, drawn: Uint32Array, budget?: LinkBudget): Promise<Float32Array> {
|
||||
return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc, drawn, budget }).then((response) =>
|
||||
response.kind === 'links' ? response.segments : new Float32Array(0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,6 +191,27 @@ describe('jumpLinkSegments', () => {
|
||||
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', () => {
|
||||
// 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.
|
||||
|
||||
@@ -213,16 +213,32 @@ export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, to
|
||||
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
|
||||
* 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
|
||||
* 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
|
||||
* 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 length = 0;
|
||||
index.forEachPairWithin(rangePc, (a, b) => {
|
||||
@@ -238,7 +254,37 @@ export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number): Flo
|
||||
vertices[length++] = b.y;
|
||||
vertices[length++] = b.z;
|
||||
});
|
||||
// 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.
|
||||
return vertices.slice(0, length);
|
||||
if (!budget) {
|
||||
// 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.
|
||||
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,7 +7,7 @@
|
||||
* 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';
|
||||
|
||||
/** 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 =
|
||||
| { 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 =
|
||||
| { 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
|
||||
// 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);
|
||||
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);
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user