The jump-link graph linked the whole catalogue: 3.7 million links at 8 pc, 7.4-7.8 s in the worker and a 443-515 ms frame on the main thread when they landed, and most of them between stars that were neither drawn nor clickable. A graph request now carries the star field's drawn stars, and the worker links only those, over an index of its own with cells as wide as the range. The scene asks again once a new drawn set has held still for 250 ms. The renderer is handed the graph's bounding sphere instead of computing it: three.js walked every vertex on the main thread in the first frame that drew a new graph, 48-55 ms at 8 pc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
103 lines
4.4 KiB
TypeScript
103 lines
4.4 KiB
TypeScript
import * as THREE from 'three/webgpu';
|
|
|
|
/** Faint, because there are tens of thousands of them and none is worth reading on its own. */
|
|
const LINK_OPACITY = 0.16;
|
|
/** The one route is the figure; the graph it is drawn on is the ground. */
|
|
const ROUTE_OPACITY = 0.9;
|
|
/**
|
|
* How far the graph falls back while a route is up. Near the Sun the catalogue is dense enough
|
|
* that the links are a solid haze, and a chain drawn through it would be one bright thread in a
|
|
* bright cloud; stepping the ground down is what makes the figure a figure.
|
|
*/
|
|
const GROUND_WHILE_ROUTED = 0.4;
|
|
|
|
export interface LinkPoint {
|
|
readonly x: number;
|
|
readonly y: number;
|
|
readonly z: number;
|
|
}
|
|
|
|
/**
|
|
* The jump-link graph, and one route through it.
|
|
*
|
|
* Both are line segments in the galaxy's parsec frame: the graph as a single buffer, because a
|
|
* pair of vertices per link is the cheapest way to draw a hundred thousand of them, and the
|
|
* route as a second, brighter one over the top. The route is a strip rather than a set of pairs
|
|
* so a chain of hops reads as one continuous thing.
|
|
*/
|
|
export class JumpLinkRenderer {
|
|
readonly object = new THREE.Group();
|
|
|
|
private readonly linkMaterial: THREE.LineBasicMaterial;
|
|
private readonly routeMaterial: THREE.LineBasicMaterial;
|
|
private readonly links: THREE.LineSegments;
|
|
private readonly route: THREE.Line;
|
|
private strength = 1;
|
|
private routed = false;
|
|
|
|
constructor(accent: THREE.ColorRepresentation) {
|
|
this.linkMaterial = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: LINK_OPACITY, depthWrite: false });
|
|
this.routeMaterial = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: ROUTE_OPACITY, depthWrite: false });
|
|
|
|
this.links = new THREE.LineSegments(new THREE.BufferGeometry(), this.linkMaterial);
|
|
this.route = new THREE.Line(new THREE.BufferGeometry(), this.routeMaterial);
|
|
// Both are rebuilt from scratch whenever they change, so their bounds are only ever right
|
|
// by accident between rebuilds; culling on a stale sphere drops the graph mid-pan.
|
|
this.links.frustumCulled = false;
|
|
this.route.frustumCulled = false;
|
|
this.object.add(this.links, this.route);
|
|
this.setSegments(new Float32Array(0));
|
|
this.setRoute([], () => undefined);
|
|
}
|
|
|
|
/** The graph, as vertex pairs: six floats a link, one end then the other. See `jumpLinkSegments`. */
|
|
setSegments(vertices: Float32Array): void {
|
|
this.replaceGeometry(this.links, vertices);
|
|
// Given rather than left for the renderer to compute: it wants a bounding sphere to sort by and,
|
|
// finding none, walks every vertex on the main thread in the first frame that draws the graph.
|
|
// The graph is never culled, and it sorts by its centre, where the catalogue is centred too.
|
|
this.links.geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), Infinity);
|
|
}
|
|
|
|
/** The chain to draw over the graph, departure first. Fewer than two stars draws nothing. */
|
|
setRoute(starIds: readonly number[], positionOf: (starId: number) => LinkPoint | undefined): void {
|
|
const points = starIds.map(positionOf).filter((point): point is LinkPoint => point !== undefined);
|
|
this.routed = points.length >= 2;
|
|
this.applyOpacity();
|
|
const vertices = new Float32Array(points.length < 2 ? 0 : points.length * 3);
|
|
points.forEach((point, i) => {
|
|
if (vertices.length > 0) {
|
|
vertices.set([point.x, point.y, point.z], i * 3);
|
|
}
|
|
});
|
|
this.replaceGeometry(this.route, vertices);
|
|
}
|
|
|
|
/** Crossfaded with the local layer: from outside the Galaxy the graph is a smear. */
|
|
setStrength(strength: number): void {
|
|
this.strength = THREE.MathUtils.clamp(strength, 0, 1);
|
|
this.applyOpacity();
|
|
this.object.visible = this.strength > 0;
|
|
}
|
|
|
|
private applyOpacity(): void {
|
|
this.linkMaterial.opacity = LINK_OPACITY * this.strength * (this.routed ? GROUND_WHILE_ROUTED : 1);
|
|
this.routeMaterial.opacity = ROUTE_OPACITY * this.strength;
|
|
}
|
|
|
|
dispose(): void {
|
|
this.links.geometry.dispose();
|
|
this.route.geometry.dispose();
|
|
this.linkMaterial.dispose();
|
|
this.routeMaterial.dispose();
|
|
}
|
|
|
|
private replaceGeometry(target: THREE.LineSegments | THREE.Line, vertices: Float32Array): void {
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
|
|
const previous = target.geometry;
|
|
target.geometry = geometry;
|
|
previous.dispose();
|
|
}
|
|
}
|