diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index a61c1e8..b861e2d 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -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; route(): Promise; dispose(): void }; + routing: { links(rangePc: number, drawn: Uint32Array, budget?: LinkBudget): Promise; route(): Promise; 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); diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 94d7ea3..1598c40 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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; diff --git a/src/app/features/galaxy-system/routing-client.spec.ts b/src/app/features/galaxy-system/routing-client.spec.ts index 03c2b0e..2d05f31 100644 --- a/src/app/features/galaxy-system/routing-client.spec.ts +++ b/src/app/features/galaxy-system/routing-client.spec.ts @@ -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); diff --git a/src/app/features/galaxy-system/routing-client.ts b/src/app/features/galaxy-system/routing-client.ts index f165898..3677913 100644 --- a/src/app/features/galaxy-system/routing-client.ts +++ b/src/app/features/galaxy-system/routing-client.ts @@ -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 { - return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc, drawn }).then((response) => + links(rangePc: number, drawn: Uint32Array, budget?: LinkBudget): Promise { + return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc, drawn, budget }).then((response) => response.kind === 'links' ? response.segments : new Float32Array(0) ); } diff --git a/src/app/shared/astro/jump-links.spec.ts b/src/app/shared/astro/jump-links.spec.ts index b47520e..6aae244 100644 --- a/src/app/shared/astro/jump-links.spec.ts +++ b/src/app/shared/astro/jump-links.spec.ts @@ -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. diff --git a/src/app/shared/astro/jump-links.ts b/src/app/shared/astro/jump-links.ts index 2709818..d854b83 100644 --- a/src/app/shared/astro/jump-links.ts +++ b/src/app/shared/astro/jump-links.ts @@ -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); } diff --git a/src/app/shared/astro/routing.ts b/src/app/shared/astro/routing.ts index cb2dc6f..f917d7c 100644 --- a/src/app/shared/astro/routing.ts +++ b/src/app/shared/astro/routing.ts @@ -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 {