Rewrite the star field as instanced billboards

Plan step 3 promises glow and size driven by magnitude and spectral type, but
the star field was a THREE.Points cloud and the WebGPU backend — the renderer
this app targets — caps point primitives at a single pixel. Every one of the
8750 stars drew as an identical 1 px dot with a hard edge, discarding the
magnitude sizing entirely; the class comment already admitted sizeNode only did
anything on the WebGL2 fallback.

Each star is now an instanced camera-facing quad on a SpriteNodeMaterial, which
behaves the same on both backends. That material takes each instance's centre
from positionNode rather than from an instance matrix, so position, colour and
size ride on instanced buffer attributes and the mesh itself never moves. A
radial falloff in opacityNode gives each star a bright core inside a soft halo.

Sizes are angular rather than world-space. That keeps a star the same apparent
size at any camera distance, which is both what the old screen-space points did
and what is physically right: real stars are unresolvable point sources, so
apparent size follows brightness, not distance. World-space quads would instead
have made the whole field vanish at the camera's 2000 pc limit.

Picking had to be rebuilt. Billboarding happens in the vertex shader, so the
CPU-side geometry is one quad at the origin and Raycaster cannot see the star
field at all. Selection is now done in screen space against the size each star
is actually drawn at, which is strictly better than the fixed 1.2 pc world
radius it replaces — that radius was over-permissive up close and sub-pixel at
the far end of a 4000x camera range. Stars behind the camera need an explicit
depth guard, because project() mirrors them back onto the screen.

Two things only caught by running it. The colour attribute was declared with
node type 'color', which is not a GLSL type, so the generated shader failed to
compile — it has to be vec3. And the click tolerance was first written as a
floor on the drawn radius, which flattened every star to one hit size, since a
floor generous enough for the faintest star exceeds the brightest star's radius;
adding the slop instead keeps a brighter star the easier target.

Tests: 151 passing, up from 145. Verified in a real browser — shaders compile
clean and the Playwright click-to-select flight passes against the new picking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
Claude
2026-08-04 11:11:23 +00:00
parent 4aca223027
commit c1feb4b74e
4 changed files with 269 additions and 79 deletions
+9 -4
View File
@@ -27,10 +27,10 @@ npm run e2e:typecheck
## What's in it
**Galaxy view** — every HYG-catalogue star within 50 parsecs as a point field, positioned from
real RA/Dec/parallax, coloured by spectral index and sized by magnitude. Names label the stars
nearest the camera. Behind them sits a backdrop of notable deep-sky objects and a Milky Way
panorama.
**Galaxy view** — every HYG-catalogue star within 50 parsecs as instanced camera-facing
billboards, positioned from real RA/Dec/parallax, coloured by spectral index and sized by
magnitude. Names label the stars nearest the camera. Behind them sits a backdrop of notable
deep-sky objects and a Milky Way panorama.
**System view** — selecting a star flies the camera continuously into its system rather than
cutting to a new scene. The Sun gets the real solar-system bodies from JPL Horizons; other
@@ -47,6 +47,11 @@ same place an in-scene click would.
- **Rendering** runs on Three.js `WebGPURenderer`, which falls back to a WebGL2 backend
automatically. The render loop runs outside Angular's change detection.
- **Stars are billboards, not points.** The WebGPU backend caps point primitives at a single
pixel, so a points cloud renders every star as an identical dot regardless of magnitude. The
star field is instanced quads on a `SpriteNodeMaterial` instead, which behaves the same on
both backends. Their size is angular rather than world-space — real stars are unresolvable
point sources, so apparent size should follow brightness, not distance.
- **Two coordinate scales.** The galaxy view works in parsecs and the system view in AU —
about eight orders of magnitude apart, which wrecks float precision if rendered in one unit
space. The camera rig recentres the active star to the origin ("floating origin") and swaps
@@ -35,8 +35,6 @@ const LABEL_MAX_COUNT = 15;
const DEEP_SKY_LABEL_COUNT = 12;
/** How often (seconds) the visible label set is recomputed; doesn't need to be per-frame. */
const LABEL_UPDATE_INTERVAL_SECONDS = 0.2;
/** Raycast pick tolerance around each star point, in parsecs. */
const PICK_THRESHOLD_PC = 1.2;
/** Pointer travel (px) above which a press counts as an orbit drag rather than a selection. */
const CLICK_DRAG_SLOP_PX = 5;
@@ -227,7 +225,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const { width, height } = canvas.getBoundingClientRect();
this.labelOverlay.setSize(width, height);
this.raycaster.params.Points!.threshold = PICK_THRESHOLD_PC;
canvas.addEventListener('pointerdown', this.handlePointerDown);
canvas.addEventListener('click', this.handleClick);
this.observeResize(canvas);
@@ -304,18 +301,19 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.raycaster.setFromCamera(pointerNdc, camera);
if (this.currentStarId === null) {
this.handleGalaxyClick();
this.handleGalaxyClick(pointerNdc, camera);
} else {
this.handleSystemClick();
}
};
private handleGalaxyClick(): void {
private handleGalaxyClick(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): void {
if (!this.starField) {
return;
}
const [hit] = this.raycaster.intersectObject(this.starField.object);
const starId = hit?.index !== undefined ? this.starField.starIdAt(hit.index) : undefined;
// Screen-space rather than a raycast: the star field billboards in the vertex shader, so
// its CPU-side geometry is a single quad at the origin. See `StarFieldRenderer.pickAt`.
const starId = this.starField.pickAt(pointerNdc, camera);
if (starId !== undefined) {
this.navigationStore.selectStar(starId);
}
@@ -1,7 +1,8 @@
import * as THREE from 'three/webgpu';
import { describe, expect, it } from 'vitest';
import { StarRecord } from '../../shared/models/star.model';
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
import { colorIndexToRgb, magnitudeToPointSize, StarFieldRenderer } from './star-field-renderer';
function star(overrides: Partial<StarRecord> = {}): StarRecord {
return {
@@ -17,6 +18,20 @@ function star(overrides: Partial<StarRecord> = {}): StarRecord {
};
}
function packPositions(stars: readonly StarRecord[]): Float32Array {
return new Float32Array(stars.flatMap((s) => [s.x, s.y, s.z]));
}
/** A camera looking down -Z from the origin, framing everything in front of it. */
function testCamera(): THREE.PerspectiveCamera {
const camera = new THREE.PerspectiveCamera(55, 16 / 9, 0.01, 5000);
camera.position.set(0, 0, 0);
camera.lookAt(0, 0, -1);
camera.updateMatrixWorld(true);
camera.updateProjectionMatrix();
return camera;
}
describe('colorIndexToRgb', () => {
it('tints a hot, low-index star blue-white', () => {
const color = colorIndexToRgb(-0.3);
@@ -68,72 +83,143 @@ describe('colorIndexToRgb', () => {
expect(color.g).toBeCloseTo(1, 6);
expect(color.b).toBeCloseTo(1, 6);
});
it('is neutral when no spectral type is passed at all', () => {
const color = colorIndexToRgb(null);
expect(color.r).toBeCloseTo(color.b, 6);
});
});
it('prefers a measured index over the spectral type', () => {
// A measured index always wins, even if it disagrees with the classification.
const measured = colorIndexToRgb(-0.3, 'M5');
expect(measured.b).toBeGreaterThan(measured.r);
});
});
describe('magnitudeToPointSize', () => {
it('renders brighter stars larger', () => {
expect(magnitudeToPointSize(-1)).toBeGreaterThan(magnitudeToPointSize(12));
});
it('clamps outside the magnitude range rather than running away', () => {
expect(magnitudeToPointSize(-30)).toBe(magnitudeToPointSize(-2));
expect(magnitudeToPointSize(50)).toBe(magnitudeToPointSize(10));
});
});
describe('StarFieldRenderer', () => {
const stars = [star({ id: 10, name: 'A' }), star({ id: 20, name: 'B', colorIndex: null, spectralType: 'M4' })];
const positions = new Float32Array([0, 0, 0, 1, 2, 3]);
it('builds one vertex per star with position, colour and size attributes', () => {
const renderer = new StarFieldRenderer(stars, positions);
const geometry = renderer.object.geometry;
it('draws one instance per star from a single shared quad', () => {
const renderer = new StarFieldRenderer(stars, packPositions(stars));
const geometry = renderer.object.geometry as THREE.InstancedBufferGeometry;
expect(geometry.getAttribute('position').count).toBe(2);
expect(geometry.getAttribute('starColor').count).toBe(2);
expect(geometry.getAttribute('starSize').count).toBe(2);
expect(geometry.instanceCount).toBe(2);
// Four corners of one quad, reused by every instance.
expect(geometry.getAttribute('position').count).toBe(4);
renderer.dispose();
});
it('maps a vertex index back to its HYG star id', () => {
const renderer = new StarFieldRenderer(stars, positions);
it('never culls itself, since its geometry sits at the origin', () => {
// The quad's bounds say nothing about where the instances are, so culling would drop the
// entire field whenever the origin left the frustum.
const renderer = new StarFieldRenderer(stars, packPositions(stars));
expect(renderer.object.frustumCulled).toBe(false);
renderer.dispose();
});
it('maps an instance index back to its HYG star id', () => {
const renderer = new StarFieldRenderer(stars, packPositions(stars));
expect(renderer.starIdAt(0)).toBe(10);
expect(renderer.starIdAt(1)).toBe(20);
renderer.dispose();
});
it('returns undefined for an out-of-range index', () => {
const renderer = new StarFieldRenderer(stars, positions);
expect(renderer.starIdAt(99)).toBeUndefined();
expect(renderer.starIdAt(-1)).toBeUndefined();
renderer.dispose();
});
it('colours an unphotometered star from its spectral type', () => {
const renderer = new StarFieldRenderer(stars, positions);
const colors = renderer.object.geometry.getAttribute('starColor');
// Star B is an M4 with no measured index — it must come out red, not blue-white.
expect(colors.getX(1)).toBeGreaterThan(colors.getZ(1));
renderer.dispose();
});
it('renders brighter stars as larger points', () => {
const renderer = new StarFieldRenderer([star({ id: 1, magnitude: -1 }), star({ id: 2, magnitude: 12 })], new Float32Array(6));
const sizes = renderer.object.geometry.getAttribute('starSize');
expect(sizes.getX(0)).toBeGreaterThan(sizes.getX(1));
renderer.dispose();
});
it('handles an empty star field', () => {
const renderer = new StarFieldRenderer([], new Float32Array(0));
expect(renderer.object.geometry.getAttribute('position').count).toBe(0);
expect((renderer.object.geometry as THREE.InstancedBufferGeometry).instanceCount).toBe(0);
expect(renderer.starIdAt(0)).toBeUndefined();
renderer.dispose();
});
describe('pickAt', () => {
const camera = testCamera();
// Two stars straight ahead, one well off to the side.
const picked = [
star({ id: 1, name: 'Near', x: 0, y: 0, z: -10, magnitude: 1 }),
star({ id: 2, name: 'Far', x: 0, y: 0, z: -100, magnitude: 1 }),
star({ id: 3, name: 'Aside', x: 40, y: 0, z: -10, magnitude: 1 })
];
it('finds the star under the pointer', () => {
const renderer = new StarFieldRenderer(picked, packPositions(picked));
// Both Near and Far project to the screen centre; either is a correct hit.
expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera));
renderer.dispose();
});
it('returns undefined when the pointer is on empty sky', () => {
const renderer = new StarFieldRenderer(picked, packPositions(picked));
expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera)).toBeUndefined();
renderer.dispose();
});
it('ignores stars behind the camera', () => {
// `project()` mirrors points behind the camera back onto the screen, so without an
// explicit depth guard this star would be pickable at the centre of the view.
const behind = [star({ id: 7, x: 0, y: 0, z: 10 })];
const renderer = new StarFieldRenderer(behind, packPositions(behind));
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined();
renderer.dispose();
});
it('picks the star nearest the pointer when several are in view', () => {
const spread = [
star({ id: 1, x: 0, y: 0, z: -10 }),
star({ id: 2, x: 0, y: 2, z: -10 }),
star({ id: 3, x: 0, y: -2, z: -10 })
];
const renderer = new StarFieldRenderer(spread, packPositions(spread));
// Aim at where star 2 projects, and confirm we get it rather than its neighbours.
const target = new THREE.Vector3(0, 2, -10).project(camera);
expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera)).toBe(2);
renderer.dispose();
});
it('gives a brighter star a larger hit area than a faint one', () => {
const bright = [star({ id: 1, x: 0, y: 0, z: -10, magnitude: -1 })];
const faint = [star({ id: 2, x: 0, y: 0, z: -10, magnitude: 14 })];
const brightRenderer = new StarFieldRenderer(bright, packPositions(bright));
const faintRenderer = new StarFieldRenderer(faint, packPositions(faint));
// Walk outward from the centre until each stops being pickable.
const reach = (renderer: StarFieldRenderer): number => {
let offset = 0;
while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera) !== undefined) {
offset += 0.001;
}
return offset;
};
expect(reach(brightRenderer)).toBeGreaterThan(reach(faintRenderer));
brightRenderer.dispose();
faintRenderer.dispose();
});
it('keeps even the faintest star clickable', () => {
// A magnitude-15 star is drawn under 2 px across, so without the added slop the faint end
// of the catalogue would demand sub-pixel accuracy.
const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })];
const renderer = new StarFieldRenderer(faint, packPositions(faint));
expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera)).toBe(5);
renderer.dispose();
});
it('finds nothing in an empty field', () => {
const renderer = new StarFieldRenderer([], new Float32Array(0));
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined();
renderer.dispose();
});
});
});
@@ -1,12 +1,35 @@
import * as THREE from 'three/webgpu';
import { attribute } from 'three/tsl';
import { instancedBufferAttribute, smoothstep, uv, vec2 } from 'three/tsl';
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
import { StarRecord } from '../../shared/models/star.model';
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
const MIN_POINT_SIZE = 1.5;
const MAX_POINT_SIZE = 6;
/**
* Star size is expressed in pixels for readability, but the material works in angular size, so
* the two are related through the scene's vertical field of view and a reference viewport.
* Because the size is angular, a star keeps the same share of the screen at any window size —
* these pixel figures are exact only at this reference height.
*/
const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
const REFERENCE_FOV_DEGREES = 55;
const PIXELS_TO_ANGULAR_SIZE =
(2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
/**
* 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;
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);
@@ -32,58 +55,136 @@ export function colorIndexToRgb(colorIndex: number | null, spectralType?: string
}
/** Brighter stars (lower apparent magnitude) render as bigger points. */
function magnitudeToPointSize(magnitude: number): number {
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;
}
/**
* Builds a `THREE.Points` field from the ETL-generated star positions/index, using a TSL
* `PointsNodeMaterial` whose color/size are driven by per-vertex attributes derived from
* each star's spectral color index and magnitude.
* Builds the galaxy-scale star field as instanced camera-facing billboards, one per HYG star,
* coloured by spectral index and sized by magnitude.
*
* Note: per the Three.js WebGPU backend, point primitives are capped at 1px on native
* WebGPU — `sizeNode` only has a visible effect when `WebGPURenderer` has fallen back to
* its WebGL2 backend. Color variation works on both backends.
* **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.
*/
export class StarFieldRenderer {
readonly object: THREE.Points;
readonly object: THREE.Mesh;
private readonly geometry: THREE.BufferGeometry;
private readonly material: THREE.PointsNodeMaterial;
private readonly geometry: THREE.InstancedBufferGeometry;
private readonly material: THREE.SpriteNodeMaterial;
/** Angular diameter per star, in the same order as `stars` — reused for picking. */
private readonly angularSizes: Float32Array;
constructor(private readonly stars: readonly StarRecord[], positions: Float32Array) {
this.geometry = new THREE.BufferGeometry();
this.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
constructor(
private readonly stars: readonly StarRecord[],
positions: Float32Array
) {
this.geometry = createQuadGeometry(stars.length);
const colors = new Float32Array(stars.length * 3);
const sizes = new Float32Array(stars.length);
this.angularSizes = new Float32Array(stars.length);
stars.forEach((star, index) => {
const color = colorIndexToRgb(star.colorIndex, star.spectralType);
colors[index * 3] = color.r;
colors[index * 3 + 1] = color.g;
colors[index * 3 + 2] = color.b;
sizes[index] = magnitudeToPointSize(star.magnitude);
this.angularSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE;
});
this.geometry.setAttribute('starColor', new THREE.BufferAttribute(colors, 3));
this.geometry.setAttribute('starSize', new THREE.BufferAttribute(sizes, 1));
// `positions` is the ETL's packed buffer, already in the same order as `stars`.
const positionAttribute = new THREE.InstancedBufferAttribute(positions, 3);
const colorAttribute = new THREE.InstancedBufferAttribute(colors, 3);
const sizeAttribute = new THREE.InstancedBufferAttribute(this.angularSizes, 1);
this.material = new THREE.PointsNodeMaterial({
colorNode: attribute('starColor', 'vec3'),
sizeNode: attribute('starSize', 'float'),
sizeAttenuation: true,
this.material = new THREE.SpriteNodeMaterial({
transparent: true,
depthWrite: false
depthWrite: false,
blending: THREE.AdditiveBlending
});
this.material.sizeAttenuation = false;
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3');
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float');
this.material.colorNode = instancedBufferAttribute(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.Points(this.geometry, this.material);
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;
}
/** Looks up the HYG star id for a given geometry vertex index (e.g. from a raycast hit). */
starIdAt(vertexIndex: number): number | undefined {
return this.stars[vertexIndex]?.id;
/** Looks up the HYG star id for a given instance index. */
starIdAt(instanceIndex: number): number | undefined {
return this.stars[instanceIndex]?.id;
}
/**
* 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: THREE.PerspectiveCamera): number | undefined {
const tanHalfFov = Math.tan((camera.fov * Math.PI) / 360);
const projected = new THREE.Vector3();
let bestIndex: number | undefined;
let bestScore = Infinity;
for (let index = 0; index < this.stars.length; index++) {
const star = this.stars[index];
projected.set(star.x, star.y, star.z).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 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP;
const dx = (projected.x - pointerNdc.x) * camera.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.stars[bestIndex].id;
}
dispose(): void {