Merge pull request #28 from avalon-vanguard/perf/link-budget
Budget the jump-link layer in pixels of line, nearest the view's centre first 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';
|
||||
@@ -132,6 +133,13 @@ class FakeEngineService {
|
||||
|
||||
resize(): void {}
|
||||
|
||||
/** The canvas's device pixels per CSS pixel, as the renderer was told. */
|
||||
pixelRatio = 1;
|
||||
|
||||
getRenderer(): { getPixelRatio(): number } {
|
||||
return { getPixelRatio: () => this.pixelRatio };
|
||||
}
|
||||
|
||||
/** Test helper: simulates one rendered frame by invoking every registered tick callback. */
|
||||
tick(deltaSeconds: number): void {
|
||||
for (const callback of this.tickCallbacks) {
|
||||
@@ -425,7 +433,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 +463,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 +479,76 @@ 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 });
|
||||
// A screen scaled to 200%: 1080 CSS pixels are 2160 drawn ones, and the lines are drawn in those.
|
||||
engine.pixelRatio = 2;
|
||||
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 1080 drawn 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) / 1080, 3);
|
||||
});
|
||||
|
||||
it('asks again once the view has zoomed past the budget it asked with, though the drawn stars are the same', async () => {
|
||||
// All three stars fit the star budget, so the drawn set never changes: only the budget can.
|
||||
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 });
|
||||
const component = linkScene(links);
|
||||
await advanceFrames(engine, 0.3);
|
||||
await settle();
|
||||
const asked = links.mock.calls.length;
|
||||
|
||||
const camera = engine.getCamera();
|
||||
camera.position.sub(component.controls.target).multiplyScalar(0.5).add(component.controls.target);
|
||||
await advanceFrames(engine, 0.3);
|
||||
await settle();
|
||||
|
||||
expect(links.mock.calls.length).toBe(asked + 1);
|
||||
expect(links.mock.calls.at(-1)![1]).toBe(links.mock.calls[0][1]);
|
||||
});
|
||||
|
||||
it('asks for no graph from inside a system, where distances are in astronomical units', async () => {
|
||||
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0)));
|
||||
navigationStore.selectStar(SUN.id);
|
||||
await flushAsync();
|
||||
await advanceFrames(engine, 2.5);
|
||||
|
||||
linkScene(links);
|
||||
await settle();
|
||||
|
||||
expect(links).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps what it asked for when an older request it replaced is rejected', async () => {
|
||||
// Off and on again while a graph is still waiting: the waiting one is replaced, and its
|
||||
// rejection must not be taken for the request that replaced it.
|
||||
const pending: Array<{ resolve: (segments: Float32Array) => void; reject: (error: Error) => void }> = [];
|
||||
const setSegments = vi.spyOn(JumpLinkRenderer.prototype, 'setSegments');
|
||||
const component = linkScene(() => new Promise<Float32Array>((resolve, reject) => pending.push({ resolve, reject })));
|
||||
await settle();
|
||||
component.display.update((display) => ({ ...display, jumpLinks: false }));
|
||||
TestBed.tick();
|
||||
await settle();
|
||||
component.display.update((display) => ({ ...display, jumpLinks: true }));
|
||||
TestBed.tick();
|
||||
await settle();
|
||||
expect(pending).toHaveLength(2);
|
||||
|
||||
pending[0].reject(new Error('Superseded by a newer request'));
|
||||
await flushAsync();
|
||||
const graph = new Float32Array(6);
|
||||
pending[1].resolve(graph);
|
||||
await flushAsync();
|
||||
|
||||
expect(setSegments).toHaveBeenLastCalledWith(graph);
|
||||
setSegments.mockRestore();
|
||||
});
|
||||
|
||||
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,35 @@ 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;
|
||||
/**
|
||||
* Whether a graph asked for with one budget still serves another: the same, unless the view has
|
||||
* zoomed by more than half its margin or its centre has moved by more than a fifth of the
|
||||
* neighbourhood drawn whole.
|
||||
*/
|
||||
function servesTheSame(asked: LinkBudget | undefined, now: LinkBudget | undefined): boolean {
|
||||
if (!asked || !now) {
|
||||
return asked === now;
|
||||
}
|
||||
const moved = Math.hypot(now.centre.x - asked.centre.x, now.centre.y - asked.centre.y, now.centre.z - asked.centre.z);
|
||||
return Math.abs(now.lengthPc / asked.lengthPc - 1) <= VIEW_MARGIN / 2 && moved <= STAR_FIELD_REFOCUS_PC;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -371,6 +401,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
/** The range and the stars the drawn graph was last asked for, so a rebuild is skipped when neither moved. */
|
||||
private drawnJumpRangePc: number | null = null;
|
||||
private linkedStars: Uint32Array | null = null;
|
||||
private linkedBudget: LinkBudget | undefined;
|
||||
/** Counts graph requests, so a rejection can tell whether it is for the latest one. */
|
||||
private linkRequest = 0;
|
||||
private jumpLinkRebuild?: ReturnType<typeof setTimeout>;
|
||||
/** The current system's neighbours, resolved on arrival: id, name, distance and bearing. */
|
||||
private neighbours: readonly { star: StarRecord; distancePc: number; direction: THREE.Vector3 }[] = [];
|
||||
@@ -823,7 +856,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
const pinned = () => pinnedIds.map((id) => neighbourhood.indexOf(id)).filter((index): index is number => index !== undefined);
|
||||
|
||||
const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
|
||||
const drawnBefore = this.starField.drawnStars;
|
||||
let chose = false;
|
||||
|
||||
// At galactic scale the whole catalogue is a smudge a few pixels across, and the view sweeps
|
||||
// hundreds of parsecs a pass: chosen once for the whole sky on the way out, then left alone,
|
||||
@@ -833,6 +866,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.starField.refocus({ centre, pinned: pinned(), hosts: this.hostStars });
|
||||
this.starFieldCamera = null;
|
||||
this.starFieldPins = pins;
|
||||
chose = true;
|
||||
}
|
||||
} else {
|
||||
const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(centre));
|
||||
@@ -865,13 +899,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.starFieldFocus.copy(centre);
|
||||
this.starFieldHalfHeight = halfHeight;
|
||||
this.starFieldPins = pins;
|
||||
chose = true;
|
||||
}
|
||||
}
|
||||
|
||||
// The graph links the drawn stars, so a new set wants a new graph. Not one per pass while the
|
||||
// view keeps moving, and not one pushed back by every pass either, or an orbit would never get
|
||||
// one: at most one every `JUMP_LINK_REBUILD_DELAY_MS`.
|
||||
if (this.starField.drawnStars !== drawnBefore && this.jumpLinkRebuild === undefined) {
|
||||
// The graph links the drawn stars, and spends its budget around the view's centre, so a view that
|
||||
// has moved may want a new one; `refreshJumpLinks` asks only if the stars or the budget changed.
|
||||
// Not one per pass while the view keeps moving, and not one pushed back by every pass either, or
|
||||
// an orbit would never get one: at most one every `JUMP_LINK_REBUILD_DELAY_MS`.
|
||||
if (chose && this.jumpLinkRebuild === undefined) {
|
||||
this.scheduleJumpLinks();
|
||||
}
|
||||
}
|
||||
@@ -1544,32 +1580,59 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.jumpLinks.setSegments(new Float32Array(0));
|
||||
this.drawnJumpRangePc = null;
|
||||
this.linkedStars = null;
|
||||
this.linkedBudget = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Asked for in parsec space only: inside a system the camera and its centre are in astronomical
|
||||
// units about the system's own origin, which would make a budget of the wrong size in the wrong
|
||||
// place. The flight back out chooses the drawn stars again, and that asks.
|
||||
if (!this.galaxyGroup.visible) {
|
||||
return;
|
||||
}
|
||||
const drawn = this.starField.drawnStars;
|
||||
if (this.drawnJumpRangePc === rangePc && this.linkedStars === drawn) {
|
||||
const budget = this.jumpLinkBudget();
|
||||
if (this.drawnJumpRangePc === rangePc && this.linkedStars === drawn && servesTheSame(this.linkedBudget, budget)) {
|
||||
return;
|
||||
}
|
||||
this.drawnJumpRangePc = rangePc;
|
||||
this.linkedStars = drawn;
|
||||
void this.routing.links(rangePc, drawn).then(
|
||||
this.linkedBudget = budget;
|
||||
const request = ++this.linkRequest;
|
||||
void this.routing.links(rangePc, drawn, budget).then(
|
||||
(segments) => {
|
||||
if (this.drawnJumpRangePc === rangePc) {
|
||||
this.jumpLinks?.setSegments(segments);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Replaced by a newer request, or failed. Either way this graph is not drawn, and must not
|
||||
// be remembered as if it were, or asking for it again would be skipped.
|
||||
if (this.drawnJumpRangePc === rangePc && this.linkedStars === drawn) {
|
||||
// Replaced by a newer request, or failed. Only the latest request's rejection means no graph
|
||||
// is on its way; then nothing is remembered as drawn, so asking again is not skipped. An older
|
||||
// one's says nothing about the request that replaced it, which may ask the same thing.
|
||||
if (request === this.linkRequest) {
|
||||
this.drawnJumpRangePc = null;
|
||||
this.linkedStars = null;
|
||||
this.linkedBudget = undefined;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// In the pixels the lines are drawn in, not in CSS pixels: a scaled or HiDPI screen draws more of them.
|
||||
const heightPx = this.canvasRef().nativeElement.clientHeight * this.engine.getRenderer().getPixelRatio();
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,6 +168,30 @@ function linksDrawn(segments: Float32Array, points: readonly StarPoint[]): strin
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a budget should keep, worked out the slow way: every link sorted by how near its nearer end
|
||||
* is to the centre, then taken until one does not fit. Lengths and distances as the float32 buffer
|
||||
* holds them.
|
||||
*/
|
||||
function nearestFirst(points: readonly StarPoint[], rangePc: number, centre: { x: number; y: number; z: number }, lengthPc: number): string[] {
|
||||
const all = jumpLinkSegments(index([...points]), rangePc);
|
||||
const links = Array.from({ length: all.length / 6 }, (_, link) => {
|
||||
const v = Array.from(all.subarray(link * 6, link * 6 + 6));
|
||||
const nearer = Math.fround(Math.sqrt(Math.min((v[0] - centre.x) ** 2 + (v[1] - centre.y) ** 2 + (v[2] - centre.z) ** 2, (v[3] - centre.x) ** 2 + (v[4] - centre.y) ** 2 + (v[5] - centre.z) ** 2)));
|
||||
return { link, nearer, length: Math.fround(Math.hypot(v[3] - v[0], v[4] - v[1], v[5] - v[2])), key: linksDrawn(all.subarray(link * 6, link * 6 + 6), points)[0] };
|
||||
}).sort((a, b) => a.nearer - b.nearer || a.link - b.link);
|
||||
const kept: string[] = [];
|
||||
let total = 0;
|
||||
for (const { length, key } of links) {
|
||||
if (total + length > lengthPc) {
|
||||
break;
|
||||
}
|
||||
total += length;
|
||||
kept.push(key);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Stars a parsec apart along x, as points, for reading a segment buffer back. */
|
||||
function chainPoints(count: number): StarPoint[] {
|
||||
return Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
|
||||
@@ -191,6 +215,58 @@ 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('keeps exactly the links a full nearest-first sort would, without sorting them all', () => {
|
||||
let seed = 7;
|
||||
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 40 - 20;
|
||||
const points: StarPoint[] = Array.from({ length: 600 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
|
||||
const centre = { x: 3, y: -2, z: 1 };
|
||||
|
||||
for (const lengthPc of [0, 5, 60, 900, 4000, 1e9]) {
|
||||
expect(linksDrawn(jumpLinkSegments(index(points), 4, { centre, lengthPc }), points).sort()).toEqual(nearestFirst(points, 4, centre, lengthPc).sort());
|
||||
}
|
||||
});
|
||||
|
||||
it('sorts the distance band the budget runs out in, and stops at the first link there that does not fit', () => {
|
||||
// One pair 4 kpc out makes each band about a parsec deep, so dozens of short links near the
|
||||
// centre share the band the budget ends in, in whatever order the grid walks them.
|
||||
let seed = 3;
|
||||
const random = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||
const points: StarPoint[] = [{ id: 0, x: 4000, y: 0, z: 0 }, { id: 1, x: 4000.03, y: 0, z: 0 }];
|
||||
for (let pair = 0; pair < 40; pair++) {
|
||||
const r = 0.05 + random() * 0.9;
|
||||
const theta = random() * Math.PI * 2;
|
||||
const x = r * Math.cos(theta);
|
||||
const y = r * Math.sin(theta);
|
||||
points.push({ id: 2 + pair * 2, x, y, z: 0 }, { id: 3 + pair * 2, x, y, z: 0.005 + random() * 0.04 });
|
||||
}
|
||||
const centre = { x: 0, y: 0, z: 0 };
|
||||
|
||||
for (const lengthPc of [0.1, 0.3, 0.5]) {
|
||||
expect(linksDrawn(jumpLinkSegments(index(points), 0.05, { centre, lengthPc }), points).sort()).toEqual(nearestFirst(points, 0.05, centre, lengthPc).sort());
|
||||
}
|
||||
});
|
||||
|
||||
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,31 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many distance bands a budgeted graph is split into to find where its budget runs out, so that
|
||||
* only the links in that one band are sorted rather than all of them.
|
||||
*/
|
||||
const DISTANCE_BANDS = 4096;
|
||||
|
||||
/**
|
||||
* 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 +253,71 @@ export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number): Flo
|
||||
vertices[length++] = b.y;
|
||||
vertices[length++] = b.z;
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
// Each link's nearer end's distance from the centre, and its length.
|
||||
const { centre } = budget;
|
||||
const count = length / 6;
|
||||
const nearness = new Float32Array(count);
|
||||
const lengths = new Float32Array(count);
|
||||
let totalPc = 0;
|
||||
let farthest = 0;
|
||||
for (let link = 0; link < count; link++) {
|
||||
const at = link * 6;
|
||||
const ax = vertices[at] - centre.x;
|
||||
const ay = vertices[at + 1] - centre.y;
|
||||
const az = vertices[at + 2] - centre.z;
|
||||
const bx = vertices[at + 3] - centre.x;
|
||||
const by = vertices[at + 4] - centre.y;
|
||||
const bz = vertices[at + 5] - centre.z;
|
||||
nearness[link] = Math.sqrt(Math.min(ax * ax + ay * ay + az * az, bx * bx + by * by + bz * bz));
|
||||
lengths[link] = Math.hypot(bx - ax, by - ay, bz - az);
|
||||
totalPc += lengths[link];
|
||||
farthest = Math.max(farthest, nearness[link]);
|
||||
}
|
||||
if (totalPc <= budget.lengthPc) {
|
||||
return vertices.slice(0, length);
|
||||
}
|
||||
|
||||
// Nearest first, without sorting them all: every link in the bands before the one where the budget
|
||||
// runs out fits, and only that band's links are sorted to see how many of them do. Sorting all
|
||||
// 730 000 links at 30 pc from the Sun to keep 4 400 doubled the time a graph took in the worker.
|
||||
const bands = new Uint16Array(count);
|
||||
const bandLengths = new Float64Array(DISTANCE_BANDS);
|
||||
const bandsPerPc = farthest > 0 ? DISTANCE_BANDS / farthest : 0;
|
||||
for (let link = 0; link < count; link++) {
|
||||
bands[link] = Math.min(DISTANCE_BANDS - 1, Math.floor(nearness[link] * bandsPerPc));
|
||||
bandLengths[bands[link]] += lengths[link];
|
||||
}
|
||||
let lastBand = 0;
|
||||
let keptPc = 0;
|
||||
while (keptPc + bandLengths[lastBand] <= budget.lengthPc) {
|
||||
keptPc += bandLengths[lastBand++];
|
||||
}
|
||||
const keptLinks: number[] = [];
|
||||
const boundary: number[] = [];
|
||||
for (let link = 0; link < count; link++) {
|
||||
const band = bands[link];
|
||||
if (band < lastBand) {
|
||||
keptLinks.push(link);
|
||||
} else if (band === lastBand) {
|
||||
boundary.push(link);
|
||||
}
|
||||
}
|
||||
boundary.sort((a, b) => nearness[a] - nearness[b] || a - b);
|
||||
for (const link of boundary) {
|
||||
if (keptPc + lengths[link] > budget.lengthPc) {
|
||||
break;
|
||||
}
|
||||
keptPc += lengths[link];
|
||||
keptLinks.push(link);
|
||||
}
|
||||
|
||||
const kept = new Float32Array(keptLinks.length * 6);
|
||||
keptLinks.forEach((link, at) => kept.set(vertices.subarray(link * 6, link * 6 + 6), at * 6));
|
||||
return kept;
|
||||
}
|
||||
|
||||
@@ -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