Open the map out to the whole Milky Way

The map stopped at the catalogued 50 pc around the Sun — 0.33% of the
Galaxy's width — and looked like a point cloud with a search box.

Adds the galactic scale above it and the heads-up display the reference
map is built from.

The Galaxy is not a third coordinate space. It is the same parsec space
four orders of magnitude further out, so the model and the star field
crossfade against camera distance instead of switching, and the Sun stays
where it really is: 8.18 kpc out, on the Orion Spur, between the
Sagittarius and Perseus arms. The depth range scales with that distance —
one fixed near/far pair cannot both fly into a star and hold the Galaxy.

The structure in shared/astro/galaxy.ts is measured: the directions of the
centre and the north galactic pole, which fix the disc's 63 degree tilt
against the celestial equator; the Sun's galactocentric distance; and a
radius, azimuth and pitch angle per arm. The particles scattered around it
are not, and cannot be — dust hides the disc, so no catalogue holds the
Galaxy's stars. The view says so, and the model fades out before the
camera reaches the 50 pc where the real stars are.

The rest is the look: polar grids lying in the galactic plane with drop
lines from the Sun's neighbours, a scale ladder, a readout panel, range,
reticle and frame brackets. Two things had to give way for it. The
deep-sky shell is the sky as seen from here, so it dissolves rather than
letting the camera fly through a wall of nebulae, and so does the skybox,
which is a photograph taken from inside the thing now being viewed from
outside. Labels are picked by screen separation rather than distance
alone: the Sun's fifteen nearest neighbours are all inside four parsecs
and printed as one unreadable clump.

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 19:59:01 +00:00
parent 2f45fa7fef
commit 2e525fb5c3
19 changed files with 2166 additions and 46 deletions
@@ -0,0 +1,111 @@
import * as THREE from 'three/webgpu';
import { instancedBufferAttribute, smoothstep, uniform, uv, vec2 } from 'three/tsl';
import { GALACTIC_LANDMARKS, landmarkPositionPc } from '../../shared/astro/galaxy';
import { LabeledPoint } from './star-label-overlay';
import { generateMilkyWayParticles, GalaxyParticleCounts } from './milky-way-model';
/**
* Camera distances (parsecs from the Sun) between which the Galaxy model fades in. Below the
* near figure the view is the real, measured star field and the model is entirely hidden; above
* the far figure the model is at full strength and the 50 pc catalogue bubble is a single point.
*/
export const GALAXY_FADE_NEAR_PC = 400;
export const GALAXY_FADE_FAR_PC = 2500;
/** A unit quad centred on the origin — the billboard every particle 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;
}
/**
* Draws the Milky Way itself: the bar and bulge, five spiral arms, the smooth disc between them
* and a thin halo, as one instanced cloud of soft camera-facing billboards.
*
* The particles are **illustrative**. Their skeleton is not — arm radii, pitch angles, the
* Sun's galactocentric distance and the tilt of the disc against the sky are all measured
* quantities, and the model is built from them in `galaxy.ts`. What no catalogue can supply is
* the position of each star in the disc, because dust hides most of it from us, so the cloud
* around that skeleton is scattered rather than observed. The UI says so on the galactic level.
*
* Sizes are world-space here, unlike the star field's angular ones: these particles stand for
* clouds hundreds of parsecs across, so they should grow as the camera closes on them.
*/
export class MilkyWayRenderer {
readonly object: THREE.Mesh;
/** How many instances the model actually placed, after rejected samples. */
readonly particleCount: number;
private readonly geometry: THREE.InstancedBufferGeometry;
private readonly material: THREE.SpriteNodeMaterial;
private readonly fade = uniform(0);
private fadeValue = 0;
constructor(seed?: number, counts?: GalaxyParticleCounts) {
const particles = generateMilkyWayParticles(seed, counts);
this.particleCount = particles.count;
this.geometry = createQuadGeometry(particles.count);
const positionAttribute = new THREE.InstancedBufferAttribute(particles.positions, 3);
const colorAttribute = new THREE.InstancedBufferAttribute(particles.colors, 3);
const sizeAttribute = new THREE.InstancedBufferAttribute(particles.sizes, 1);
const alphaAttribute = new THREE.InstancedBufferAttribute(particles.alphas, 1);
this.material = new THREE.SpriteNodeMaterial({
transparent: true,
depthWrite: false,
depthTest: false,
blending: THREE.AdditiveBlending
});
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3');
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float');
this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3');
// A gentler falloff than the star field's: these are clouds, and the tight curve that makes
// a star read as a bright point makes a cloud read as a solid ball.
const radius = uv().sub(vec2(0.5)).length();
const falloff = smoothstep(0.0, 0.5, radius).oneMinus().pow(1.6);
this.material.opacityNode = falloff.mul(instancedBufferAttribute(alphaAttribute, 'float')).mul(this.fade);
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.
this.object.frustumCulled = false;
this.object.visible = false;
// Behind everything else: the model is a backdrop for the real data, never in front of it.
this.object.renderOrder = -1;
}
/**
* Crossfades the model against how far the camera has pulled back, and returns the resulting
* strength (0-1). The mesh is skipped outright at zero so the local view pays nothing for it.
*/
setViewerDistancePc(distancePc: number): number {
const t = (distancePc - GALAXY_FADE_NEAR_PC) / (GALAXY_FADE_FAR_PC - GALAXY_FADE_NEAR_PC);
this.fadeValue = Math.max(0, Math.min(1, t));
this.fade.value = this.fadeValue;
this.object.visible = this.fadeValue > 0;
return this.fadeValue;
}
get strength(): number {
return this.fadeValue;
}
/** Named structural landmarks — the centre, the Sun, and one label per arm. */
labelPoints(): readonly LabeledPoint[] {
return GALACTIC_LANDMARKS.map((landmark) => {
const position = landmarkPositionPc(landmark);
return { id: `galactic:${landmark.id}`, name: landmark.name, x: position.x, y: position.y, z: position.z };
});
}
dispose(): void {
this.object.removeFromParent();
this.geometry.dispose();
this.material.dispose();
}
}