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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user