The adversarial review confirmed three costs this PR added, all reproduced in the browser. - The first pinned refocus stalled the first flight of a session. The renderer built its own id-to-index Map of 423 651 entries the first time a star was pinned, which is at the first selection, inside the approach flight. The worst frame was 47-103 ms, and the Map stayed as a second copy of a lookup the scene already had. The scene now pins by catalogue index, through the StarNeighbourhood it builds at load (new `indexOf`), and the renderer takes indices. First selection, measured in the browser: worst frame 18 ms. - At galactic scale every label pass rewrote the drawn set. The view centre sweeps hundreds of parsecs a pass there, far past any star, so each pass chose the same 70 000 stars again and uploaded 2 MB to the GPU: 11 times on the flight out to the Galaxy. The scene no longer refocuses at galactic scale, where the whole catalogue is a few pixels, and the renderer leaves its buffers alone when the drawn set is unchanged. Flight to the Galaxy: 2 refocuses, no frame over 50 ms. - At load the same set was chosen twice: once by the renderer's constructor around the Sun, and again by the first label pass, centred on the Sun. The scene now records the constructor's choice as the current focus. Tests: the buffers keep their version for an unchanged set, no refocus at load, none at galactic scale, and pins arrive as indices. Proxima's id in the scene spec now differs from its index, so a lookup by id cannot pass for one by index. Negative controls, each caught: an unchanged set rewritten anyway, a refocus at galactic scale, the boot choice not recorded, and pins passed as ids. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
411 lines
20 KiB
TypeScript
411 lines
20 KiB
TypeScript
import * as THREE from 'three/webgpu';
|
||
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
|
||
|
||
import { brightnessOrder, Positioned } from '../../shared/astro/brightest';
|
||
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
||
import { SceneCamera } from '../../core/engine/engine.service';
|
||
import { StarRecord } from '../../shared/models/star.model';
|
||
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
|
||
|
||
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
|
||
const MIN_POINT_SIZE = 1.5;
|
||
const MAX_POINT_SIZE = 6;
|
||
|
||
|
||
/**
|
||
* Extra click forgiveness added to a star's drawn radius, in NDC — roughly 4 px on the
|
||
* reference viewport.
|
||
*
|
||
* Added rather than used as a floor. Stars are drawn 1.5-6 px across, so any floor generous
|
||
* enough to make the faintest ones clickable would also exceed the brightest one's radius and
|
||
* flatten every star to the same hit area. Adding keeps the ordering intact: a brighter star is
|
||
* always the easier target, which is what the eye expects.
|
||
*/
|
||
const PICK_NDC_SLOP = 0.01;
|
||
|
||
/**
|
||
* How many stars the field draws at once, however many the catalogue holds.
|
||
*
|
||
* The *data* is the catalogue and the *drawing* is a budget, and the two are allowed to differ:
|
||
* everything still exists for search, for flying to, and for hosting planets. Which stars fill
|
||
* the budget follows the view; see {@link selectDrawnStars}.
|
||
*
|
||
* The number is set by what the field looks like, before what it costs. The catalogue is
|
||
* 423 651 stars since Gaia, and drawn whole the opening view is a grey wash: the additive
|
||
* blending of that many 1.5-pixel dots buries the labels, the rings on the planet hosts and the
|
||
* grid. At 150 000 the wash has begun; at this budget the view reads. Measured at 1920 × 1080 on
|
||
* a Ryzen 7700X, the cost argues the same way. An RTX 4080 draws the whole catalogue in the same
|
||
* 6.1 ms a frame as this budget, so a discrete card does not notice. The processor's own
|
||
* two-core Radeon, standing in for an entry-level laptop, pays about 4 ms a frame for every
|
||
* 100 000 stars: 112 frames a second at this budget, 44 at the whole catalogue, and the same
|
||
* again under the WebGL2 fallback.
|
||
*/
|
||
export const STAR_RENDER_BUDGET = 70_000;
|
||
|
||
/**
|
||
* Radius (parsecs) around the Sun, and around wherever the view is centred, inside which every
|
||
* star is drawn regardless of brightness.
|
||
*
|
||
* A pure brightness cut would be defensible — apparent magnitude is exactly "how visible this
|
||
* is" — but it would drop the solar neighbourhood, because the nearest stars are overwhelmingly
|
||
* faint red dwarfs. Proxima Centauri is magnitude 11. Those are the stars this map is most about
|
||
* and the ones that hold the nearby planets, so the neighbourhood is kept whole and the budget
|
||
* is spent on the brightest of everything beyond it.
|
||
*
|
||
* The same holds wherever the view is looking. Before the drawn set followed the view, a region
|
||
* 150 pc out drew 49 of the 442 stars within this radius of it, and a route plotted there ran
|
||
* through waypoints nobody could see or click: Sol to Almach at 8 pc passed 19 stars and drew 6.
|
||
*
|
||
* Kept deliberately small against the catalogue's reach. Around the Sun it holds 3 654 stars;
|
||
* a generous radius spends most of the budget inside it and draws a dense knot surrounded by
|
||
* nothing.
|
||
*/
|
||
export const FOCUS_RADIUS_PC = 25;
|
||
|
||
/** What, besides the brightest stars, the field should be sure to draw. */
|
||
export interface DrawFocus {
|
||
/** Where the view is centred. Its neighbourhood is drawn whole, like the Sun's. */
|
||
readonly centre?: Positioned;
|
||
/**
|
||
* Catalogue indices drawn wherever they are and however faint: the selected star, the stars
|
||
* of a plotted route. Anything the map points at has to be there to be pointed at.
|
||
*/
|
||
readonly pinned?: readonly number[];
|
||
}
|
||
|
||
const SUN: Positioned = { x: 0, y: 0, z: 0 };
|
||
|
||
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
|
||
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
|
||
const WARM_STAR_COLOR = new THREE.Color(1.0, 0.6, 0.35);
|
||
|
||
/**
|
||
* Crude but effective B-V color-index -> RGB tint: hot/blue stars (low/negative index) skew
|
||
* blue-white, cool/red stars (high index) skew orange-red, matching real spectral colors.
|
||
*
|
||
* `colorIndex` is `null` for the ~10% of stars HYG never photometered. Those fall back to a
|
||
* value derived from `spectralType`, and to neutral white only when the catalog records no
|
||
* classification at all — never to 0, which is itself a real color index meaning "hot A-type"
|
||
* and would paint several hundred red dwarfs blue-white.
|
||
*/
|
||
export function colorIndexToRgb(colorIndex: number | null, spectralType?: string): THREE.Color {
|
||
const resolved = colorIndex ?? spectralTypeToColorIndex(spectralType);
|
||
const color = new THREE.Color();
|
||
if (resolved === null) {
|
||
return color.copy(NEUTRAL_STAR_COLOR);
|
||
}
|
||
|
||
const t = THREE.MathUtils.clamp((resolved + 0.4) / 2.4, 0, 1);
|
||
return t < 0.5 ? color.lerpColors(COLD_STAR_COLOR, NEUTRAL_STAR_COLOR, t * 2) : color.lerpColors(NEUTRAL_STAR_COLOR, WARM_STAR_COLOR, (t - 0.5) * 2);
|
||
}
|
||
|
||
/** Brighter stars (lower apparent magnitude) render as bigger points. */
|
||
export function magnitudeToPointSize(magnitude: number): number {
|
||
const t = THREE.MathUtils.clamp(1 - (magnitude + 2) / 12, 0, 1);
|
||
return MIN_POINT_SIZE + t * (MAX_POINT_SIZE - MIN_POINT_SIZE);
|
||
}
|
||
|
||
/** A unit quad centred on the origin — the billboard every star instance is drawn on. */
|
||
function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometry {
|
||
const geometry = new THREE.InstancedBufferGeometry();
|
||
geometry.setAttribute(
|
||
'position',
|
||
new THREE.BufferAttribute(new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]), 3)
|
||
);
|
||
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
|
||
geometry.setIndex([0, 1, 2, 0, 2, 3]);
|
||
geometry.instanceCount = instanceCount;
|
||
return geometry;
|
||
}
|
||
|
||
/**
|
||
* Reads a render budget override off the page URL (`?stars=20000`), falling back to the default.
|
||
*
|
||
* Two uses, one real and one incidental. The real one is a deployment or a machine that cannot
|
||
* draw the whole catalogue — a number in a URL beats a rebuild. The incidental one is the
|
||
* end-to-end suite, which runs against a software rasterizer whose frame rate is two orders of
|
||
* magnitude below a real GPU's: those tests are checking navigation and state, and making them
|
||
* wait on a rasterizer measures nothing about the app.
|
||
*/
|
||
export function starRenderBudgetFromUrl(search: string, fallback = STAR_RENDER_BUDGET): number {
|
||
const requested = Number(new URLSearchParams(search).get('stars'));
|
||
return Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : fallback;
|
||
}
|
||
|
||
/**
|
||
* Chooses which stars to draw when the catalogue is larger than the budget. In order, until the
|
||
* budget is spent: the pinned stars, everything within {@link FOCUS_RADIUS_PC} of where the view
|
||
* is centred, everything within it of the Sun, then the brightest of the rest. Each neighbourhood
|
||
* is taken brightest first, so a budget too small to hold one whole keeps its most visible part.
|
||
*
|
||
* Returns indices into the original list, in the order they were chosen. `order` is the
|
||
* catalogue's brightness order, passed in when the caller already has it rather than sorted again
|
||
* on every call.
|
||
*/
|
||
export function selectDrawnStars(
|
||
stars: readonly StarRecord[],
|
||
budget = STAR_RENDER_BUDGET,
|
||
focus: DrawFocus = {},
|
||
order: Uint32Array = brightnessOrder(stars)
|
||
): Uint32Array {
|
||
if (stars.length <= budget) {
|
||
return Uint32Array.from(stars.keys());
|
||
}
|
||
|
||
const chosen = new Uint8Array(stars.length);
|
||
const selected: number[] = [];
|
||
const take = (index: number): void => {
|
||
if (!chosen[index] && selected.length < budget) {
|
||
chosen[index] = 1;
|
||
selected.push(index);
|
||
}
|
||
};
|
||
|
||
for (const index of focus.pinned ?? []) {
|
||
if (index >= 0 && index < stars.length) {
|
||
take(index);
|
||
}
|
||
}
|
||
|
||
// Both neighbourhoods in one pass in catalogue order, then each sorted brightest first. They hold
|
||
// a few thousand stars between them, so sorting them costs far less than walking the whole
|
||
// brightness order once per neighbourhood, which reads the catalogue out of order: 26 ms a
|
||
// refocus on the real catalogue, against 4 ms this way.
|
||
const radiusSq = FOCUS_RADIUS_PC * FOCUS_RADIUS_PC;
|
||
const centres = focus.centre ? [focus.centre, SUN] : [SUN];
|
||
const members = centres.map(() => [] as number[]);
|
||
for (let index = 0; index < stars.length; index++) {
|
||
const star = stars[index];
|
||
for (let which = 0; which < centres.length; which++) {
|
||
const dx = star.x - centres[which].x;
|
||
const dy = star.y - centres[which].y;
|
||
const dz = star.z - centres[which].z;
|
||
if (dx * dx + dy * dy + dz * dz <= radiusSq) {
|
||
members[which].push(index);
|
||
}
|
||
}
|
||
}
|
||
for (const neighbourhood of members) {
|
||
// Ties in catalogue order, as in the brightness order itself.
|
||
neighbourhood.sort((a, b) => stars[a].magnitude - stars[b].magnitude || a - b).forEach(take);
|
||
}
|
||
|
||
for (let at = 0; at < order.length && selected.length < budget; at++) {
|
||
take(order[at]);
|
||
}
|
||
|
||
return Uint32Array.from(selected);
|
||
}
|
||
|
||
/**
|
||
* Builds the galaxy-scale star field as instanced camera-facing billboards, one per drawn star,
|
||
* coloured by spectral index and sized by magnitude.
|
||
*
|
||
* **Why billboards and not `THREE.Points`.** Point primitives are capped at a single pixel on
|
||
* the WebGPU backend — which is the renderer this app targets — so a points cloud rendered
|
||
* every star as an identical 1 px dot no matter what `sizeNode` said, discarding both the
|
||
* magnitude sizing and any glow. Instanced quads render identically on both backends.
|
||
*
|
||
* `SpriteNodeMaterial` takes each instance's centre from `positionNode` rather than from an
|
||
* instance matrix (see its own documentation), so the per-star data rides on instanced buffer
|
||
* attributes and the mesh itself never moves.
|
||
*
|
||
* Sizes are angular (`sizeAttenuation = false`), so a star holds the same apparent size however
|
||
* close the camera gets. That is deliberate and physically right: real stars are unresolvable
|
||
* point sources, and their apparent size on screen is a function of brightness, not distance.
|
||
*
|
||
* The instance buffers hold the budget, not the catalogue, and are rewritten in place when
|
||
* {@link refocus} changes which stars fill it.
|
||
*/
|
||
export class StarFieldRenderer {
|
||
readonly object: THREE.Mesh;
|
||
|
||
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
|
||
private readonly perspective = uniform(1);
|
||
private readonly orthographicScale = uniform(float(0));
|
||
|
||
private readonly geometry: THREE.InstancedBufferGeometry;
|
||
private readonly material: THREE.SpriteNodeMaterial;
|
||
private readonly budget: number;
|
||
private readonly order: Uint32Array;
|
||
/**
|
||
* Colour and angular size of every star in the catalogue, worked out once: a refocus then only
|
||
* copies them into the instances, 0.7 ms for the budget rather than 5.6 ms computing them again.
|
||
*/
|
||
private readonly catalogueColors: Float32Array;
|
||
private readonly catalogueSizes: Float32Array;
|
||
|
||
/** Per-instance data, `budget` long; the first `drawnCount` entries are live. */
|
||
private readonly positionAttribute: THREE.InstancedBufferAttribute;
|
||
private readonly colorAttribute: THREE.InstancedBufferAttribute;
|
||
private readonly sizeAttribute: THREE.InstancedBufferAttribute;
|
||
/** Catalogue index behind each live instance: the set that is drawn, and so the only set that can be clicked. */
|
||
private drawn: Uint32Array = new Uint32Array(0);
|
||
|
||
constructor(
|
||
private readonly catalogue: readonly StarRecord[],
|
||
private readonly cataloguePositions: Float32Array,
|
||
budget = STAR_RENDER_BUDGET,
|
||
order?: Uint32Array
|
||
) {
|
||
this.budget = budget;
|
||
this.order = order ?? brightnessOrder(catalogue);
|
||
const capacity = Math.min(budget, catalogue.length);
|
||
this.geometry = createQuadGeometry(0);
|
||
|
||
this.positionAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
|
||
this.colorAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
|
||
this.sizeAttribute = new THREE.InstancedBufferAttribute(new Float32Array(capacity), 1);
|
||
|
||
this.catalogueColors = new Float32Array(catalogue.length * 3);
|
||
this.catalogueSizes = new Float32Array(catalogue.length);
|
||
catalogue.forEach((star, index) => {
|
||
const color = colorIndexToRgb(star.colorIndex, star.spectralType);
|
||
this.catalogueColors[index * 3] = color.r;
|
||
this.catalogueColors[index * 3 + 1] = color.g;
|
||
this.catalogueColors[index * 3 + 2] = color.b;
|
||
this.catalogueSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE;
|
||
});
|
||
|
||
this.material = new THREE.SpriteNodeMaterial({
|
||
transparent: true,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending
|
||
});
|
||
// The compensation that turns an angular size into a world size is done here rather than by
|
||
// `sizeAttenuation: false`, which three.js applies only when it is compiling against a
|
||
// perspective camera (SpriteNodeMaterial.js: `camera.isPerspectiveCamera && sizeAttenuation
|
||
// === false`). Under an orthographic one it is silently skipped and every star collapses to
|
||
// a thousandth of a parsec — invisible. Doing the same arithmetic in the node graph, behind
|
||
// a uniform, lets one material serve both cameras without being recompiled between them.
|
||
this.material.sizeAttenuation = true;
|
||
const position = instancedBufferAttribute<'vec3'>(this.positionAttribute, 'vec3');
|
||
const angularSize = instancedBufferAttribute<'float'>(this.sizeAttribute, 'float');
|
||
this.material.positionNode = position;
|
||
// Perspective: a star's world size is its angular size times how far away it is, which is
|
||
// exactly what the built-in does. Orthographic: distance does not set apparent size at all,
|
||
// the frustum does, so the same angular size is scaled by the frustum instead.
|
||
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
|
||
this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective));
|
||
this.material.colorNode = instancedBufferAttribute<'vec3'>(this.colorAttribute, 'vec3');
|
||
// Soft radial falloff so each star is a small bright core inside a halo, rather than a
|
||
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
|
||
const radius = uv().sub(vec2(0.5)).length();
|
||
this.material.opacityNode = smoothstep(0.0, 0.5, radius).oneMinus().pow(2.0);
|
||
|
||
this.object = new THREE.Mesh(this.geometry, this.material);
|
||
// The quad's own bounds sit at the origin and say nothing about where the instances are,
|
||
// so leaving culling on would drop the whole field whenever the origin left the frustum.
|
||
this.object.frustumCulled = false;
|
||
|
||
this.refocus({});
|
||
}
|
||
|
||
/** How many of the catalogue's stars this field is drawing. */
|
||
get drawnCount(): number {
|
||
return this.drawn.length;
|
||
}
|
||
|
||
/**
|
||
* Chooses the drawn stars again for where the view now is, and rewrites the instance buffers
|
||
* with them. See {@link selectDrawnStars}.
|
||
*/
|
||
refocus(focus: DrawFocus): void {
|
||
const drawn = selectDrawnStars(this.catalogue, this.budget, focus, this.order);
|
||
// The same stars in the same instances: the buffers already hold them, and a rewrite would
|
||
// upload 2 MB to the GPU for nothing — which a pan across empty space would do every pass.
|
||
if (drawn.length === this.drawn.length && drawn.every((index, instance) => index === this.drawn[instance])) {
|
||
return;
|
||
}
|
||
this.drawn = drawn;
|
||
|
||
const positions = this.positionAttribute.array as Float32Array;
|
||
const colors = this.colorAttribute.array as Float32Array;
|
||
const sizes = this.sizeAttribute.array as Float32Array;
|
||
this.drawn.forEach((catalogueIndex, instance) => {
|
||
for (let axis = 0; axis < 3; axis++) {
|
||
positions[instance * 3 + axis] = this.cataloguePositions[catalogueIndex * 3 + axis];
|
||
colors[instance * 3 + axis] = this.catalogueColors[catalogueIndex * 3 + axis];
|
||
}
|
||
sizes[instance] = this.catalogueSizes[catalogueIndex];
|
||
});
|
||
|
||
this.geometry.instanceCount = this.drawn.length;
|
||
this.positionAttribute.needsUpdate = true;
|
||
this.colorAttribute.needsUpdate = true;
|
||
this.sizeAttribute.needsUpdate = true;
|
||
}
|
||
|
||
/**
|
||
* Tells the field which projection it is being drawn under.
|
||
*
|
||
* `halfHeightWorld` is half the orthographic frustum's height in world units; `null` means a
|
||
* perspective camera, where a star's distance sets its apparent size on its own.
|
||
*/
|
||
setProjection(halfHeightWorld: number | null): void {
|
||
this.perspective.value = halfHeightWorld === null ? 1 : 0;
|
||
// The world size that subtends the same share of the viewport an angular size would under
|
||
// the reference field of view: `angular * halfHeight / tan(fov/2)`.
|
||
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
|
||
}
|
||
|
||
/** Looks up the star id for a given instance index. */
|
||
starIdAt(instanceIndex: number): number | undefined {
|
||
return instanceIndex >= 0 && instanceIndex < this.drawn.length ? this.catalogue[this.drawn[instanceIndex]].id : undefined;
|
||
}
|
||
|
||
/**
|
||
* The star under `pointerNdc`, or `undefined`.
|
||
*
|
||
* Billboarding happens in the vertex shader, so the CPU-side geometry is a single quad at the
|
||
* origin and `Raycaster` cannot see the star field at all. Picking is therefore done in screen
|
||
* space, which is also strictly better than the fixed world-space radius the points cloud
|
||
* needed: each star is tested against the size it is actually drawn at, so the hit area matches
|
||
* what the user sees at every zoom level instead of being over-permissive up close and
|
||
* sub-pixel at the far end of the camera's range.
|
||
*/
|
||
pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
|
||
// What a unit of angular size is worth on screen. Under perspective the field of view sets
|
||
// it. Under an orthographic camera the frustum does — but `setProjection` sized the sprite
|
||
// as `angular * halfHeight / tan(REFERENCE_FOV/2)` in the first place, so dividing back out
|
||
// by that same half-height leaves the reference field of view and nothing else. Both cases
|
||
// are therefore one formula over a different angle.
|
||
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
|
||
const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360);
|
||
const projected = new THREE.Vector3();
|
||
const positions = this.positionAttribute.array as Float32Array;
|
||
const sizes = this.sizeAttribute.array as Float32Array;
|
||
|
||
let bestIndex: number | undefined;
|
||
let bestScore = Infinity;
|
||
|
||
for (let index = 0; index < this.drawn.length; index++) {
|
||
projected.set(positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]).project(camera);
|
||
// Outside the depth range means behind the camera or beyond the far plane; `project`
|
||
// mirrors points behind the camera onto the screen, so this guard is load-bearing.
|
||
if (projected.z < -1 || projected.z > 1) {
|
||
continue;
|
||
}
|
||
|
||
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
||
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
||
const ndcRadius = (0.5 * sizes[index]) / tanHalfFov + PICK_NDC_SLOP;
|
||
const dx = (projected.x - pointerNdc.x) * aspect;
|
||
const dy = projected.y - pointerNdc.y;
|
||
const score = Math.hypot(dx, dy) / ndcRadius;
|
||
|
||
if (score <= 1 && score < bestScore) {
|
||
bestScore = score;
|
||
bestIndex = index;
|
||
}
|
||
}
|
||
|
||
return bestIndex === undefined ? undefined : this.starIdAt(bestIndex);
|
||
}
|
||
|
||
dispose(): void {
|
||
this.geometry.dispose();
|
||
this.material.dispose();
|
||
}
|
||
}
|