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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user