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
@@ -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);
}