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,303 @@
import {
armRadiusPc,
BAR_HALF_LENGTH_PC,
BAR_HALF_THICKNESS_PC,
BAR_HALF_WIDTH_PC,
BAR_POSITION_ANGLE_DEG,
DISC_RADIUS_PC,
DISC_SCALE_HEIGHT_PC,
DISC_SCALE_LENGTH_PC,
galacticToEquatorial,
MILKY_WAY_ARMS,
ORION_SPUR,
SpiralArm,
SUN_GALACTOCENTRIC_RADIUS_PC,
SUN_HEIGHT_ABOVE_MIDPLANE_PC
} from '../../shared/astro/galaxy';
const DEG_TO_RAD = Math.PI / 180;
/**
* Particle budget per population. Between them these are ~46k instanced quads, which is the
* same order as the star field and draws in a single call.
*/
export interface GalaxyParticleCounts {
readonly arms: number;
readonly disc: number;
readonly bulge: number;
readonly halo: number;
}
export const DEFAULT_PARTICLE_COUNTS: GalaxyParticleCounts = {
arms: 24000,
disc: 12000,
bulge: 9000,
halo: 1600
};
/** Vertical scale height of the star-forming ridge in an arm — much thinner than the disc. */
const ARM_SCALE_HEIGHT_PC = 130;
/** Fraction of arm particles drawn as bright star-forming knots rather than ordinary field. */
const HII_REGION_FRACTION = 0.05;
/** Particle diameters in parsecs. These are cloud-sized on purpose: the model is haze, not stars. */
const ARM_SIZE_PC = { min: 70, max: 240 } as const;
const DISC_SIZE_PC = { min: 120, max: 420 } as const;
const BULGE_SIZE_PC = { min: 90, max: 300 } as const;
const HALO_SIZE_PC = { min: 110, max: 260 } as const;
const HII_SIZE_MULTIPLIER = 1.9;
/**
* The palette. Young blue-white stars trace the arms, star formation lights them pink, the
* bar and bulge are old and red, and the smooth disc between the arms is a dim yellow-white.
*/
const ARM_INNER_COLOR = [0.62, 0.74, 1.0] as const;
const ARM_OUTER_COLOR = [0.78, 0.86, 1.0] as const;
const HII_COLOR = [1.0, 0.48, 0.66] as const;
const BULGE_CORE_COLOR = [1.0, 0.87, 0.64] as const;
const BULGE_EDGE_COLOR = [1.0, 0.68, 0.38] as const;
const DISC_COLOR = [0.72, 0.74, 0.82] as const;
const HALO_COLOR = [0.55, 0.6, 0.78] as const;
export interface GalaxyParticles {
readonly count: number;
/** Equatorial-frame positions in parsecs from the Sun, packed xyz. */
readonly positions: Float32Array;
readonly colors: Float32Array;
/** World-space diameter, in parsecs. */
readonly sizes: Float32Array;
readonly alphas: Float32Array;
}
/**
* Small, fast, seedable PRNG (mulberry32). `Math.random` would do visually, but the model would
* then be different on every reload and untestable — this way the Galaxy is the same Galaxy
* every time, and a test can assert on where its particles land.
*/
export function createRandom(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Standard normal sample, by the polar form of Box-Muller. */
function gaussian(random: () => number): number {
let u = 0;
let v = 0;
let s = 0;
do {
u = random() * 2 - 1;
v = random() * 2 - 1;
s = u * u + v * v;
} while (s === 0 || s >= 1);
return u * Math.sqrt((-2 * Math.log(s)) / s);
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function lerpColor(a: readonly number[], b: readonly number[], t: number): [number, number, number] {
return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
}
/**
* Turns a galactocentric offset in the plane (heliocentric-parallel axes: +X toward the centre,
* +Y toward longitude 90) plus a height above the midplane into a heliocentric galactic
* position. The polar form of the same mapping lives in `galaxy.ts`; the bar is easier to write
* in Cartesian, so it gets this one.
*/
function fromCentreOffset(dx: number, dy: number, heightPc: number): { x: number; y: number; z: number } {
return { x: SUN_GALACTOCENTRIC_RADIUS_PC + dx, y: dy, z: heightPc - SUN_HEIGHT_ABOVE_MIDPLANE_PC };
}
interface Writer {
push(galactic: { x: number; y: number; z: number }, color: readonly number[], sizePc: number, alpha: number): void;
}
function createWriter(capacity: number): Writer & GalaxyParticles & { finish(): GalaxyParticles } {
const positions = new Float32Array(capacity * 3);
const colors = new Float32Array(capacity * 3);
const sizes = new Float32Array(capacity);
const alphas = new Float32Array(capacity);
let count = 0;
return {
positions,
colors,
sizes,
alphas,
get count() {
return count;
},
push(galactic, color, sizePc, alpha) {
// Everything is modelled in galactic coordinates and rotated once, here, into the frame
// the star field and orbits already share.
const equatorial = galacticToEquatorial(galactic);
positions[count * 3] = equatorial.x;
positions[count * 3 + 1] = equatorial.y;
positions[count * 3 + 2] = equatorial.z;
colors[count * 3] = color[0];
colors[count * 3 + 1] = color[1];
colors[count * 3 + 2] = color[2];
sizes[count] = sizePc;
alphas[count] = alpha;
count++;
},
finish() {
return { count, positions, colors, sizes, alphas };
}
};
}
/** Picks an arm, weighted, so the two grand-design arms dominate the minor ones. */
function pickArm(random: () => number, arms: readonly SpiralArm[]): SpiralArm {
const total = arms.reduce((sum, arm) => sum + arm.weight, 0);
let roll = random() * total;
for (const arm of arms) {
roll -= arm.weight;
if (roll <= 0) {
return arm;
}
}
return arms[arms.length - 1];
}
function addArmParticles(writer: Writer, random: () => number, count: number): void {
const arms = [...MILKY_WAY_ARMS, ORION_SPUR];
for (let i = 0; i < count; i++) {
const arm = pickArm(random, arms);
// Biased toward the start of the sweep, which is the inner, denser end of every arm.
const t = Math.pow(random(), 1.4);
const beta = lerp(arm.fromAzimuthDeg, arm.toAzimuthDeg, t);
const spineRadius = armRadiusPc(arm, beta);
if (spineRadius > DISC_RADIUS_PC || spineRadius < BAR_HALF_LENGTH_PC * 0.5) {
continue;
}
const offset = gaussian(random) * arm.widthPc;
const radius = spineRadius + offset;
if (radius <= 0) {
continue;
}
const height = gaussian(random) * ARM_SCALE_HEIGHT_PC;
const angle = beta * DEG_TO_RAD;
const galactic = fromCentreOffset(-radius * Math.cos(angle), radius * Math.sin(angle), height);
const radialT = Math.min(radius / DISC_RADIUS_PC, 1);
const isHii = random() < HII_REGION_FRACTION;
const color = isHii ? HII_COLOR : lerpColor(ARM_INNER_COLOR, ARM_OUTER_COLOR, radialT);
const size = lerp(ARM_SIZE_PC.min, ARM_SIZE_PC.max, random()) * (isHii ? HII_SIZE_MULTIPLIER : 1);
// Fades with radius (the arms thin out) and with distance off the spine (they have edges).
const ridgeFalloff = Math.exp(-(offset * offset) / (2 * arm.widthPc * arm.widthPc));
const alpha = (isHii ? 0.85 : 0.4) * ridgeFalloff * lerp(1, 0.35, radialT);
writer.push(galactic, color, size, alpha);
}
}
function addDiscParticles(writer: Writer, random: () => number, count: number): void {
for (let i = 0; i < count; i++) {
// Inverse-transform sample of an exponential disc, rejected past the visible edge.
const radius = -DISC_SCALE_LENGTH_PC * Math.log(1 - random());
// The inner cut is where the bulge takes over, not a hole: set it at the bar's short axis
// rather than its long one, or the model has a visible gap either side of the bar.
if (radius > DISC_RADIUS_PC || radius < BAR_HALF_WIDTH_PC) {
continue;
}
const angle = random() * Math.PI * 2;
const height = gaussian(random) * DISC_SCALE_HEIGHT_PC;
const galactic = fromCentreOffset(-radius * Math.cos(angle), radius * Math.sin(angle), height);
const radialT = Math.min(radius / DISC_RADIUS_PC, 1);
writer.push(galactic, DISC_COLOR, lerp(DISC_SIZE_PC.min, DISC_SIZE_PC.max, random()), lerp(0.16, 0.03, radialT));
}
}
/**
* Share of the bulge budget spent on the bar rather than on the rounder spheroid it sits inside.
* Both are needed: the bar alone leaves a void either side of its short axis, between it and the
* radius the arms and disc start at.
*/
const BAR_SHARE_OF_BULGE = 0.6;
/** Gaussian width of the inner spheroid, and how much it is flattened toward the disc. */
const SPHEROID_SIGMA_PC = 1150;
const SPHEROID_FLATTENING = 0.62;
function addBulgeParticles(writer: Writer, random: () => number, count: number): void {
const phi = BAR_POSITION_ANGLE_DEG * DEG_TO_RAD;
const alongX = -Math.cos(phi);
const alongY = Math.sin(phi);
const acrossX = Math.sin(phi);
const acrossY = Math.cos(phi);
for (let i = 0; i < count; i++) {
let dx: number;
let dy: number;
let height: number;
let distance: number;
if (random() < BAR_SHARE_OF_BULGE) {
// A triaxial Gaussian: long down the bar, narrow across it, flattened vertically.
const along = gaussian(random) * (BAR_HALF_LENGTH_PC / 2);
const across = gaussian(random) * (BAR_HALF_WIDTH_PC / 2);
height = gaussian(random) * (BAR_HALF_THICKNESS_PC / 2);
dx = along * alongX + across * acrossX;
dy = along * alongY + across * acrossY;
distance = Math.hypot(along, across, height);
} else {
dx = gaussian(random) * SPHEROID_SIGMA_PC;
dy = gaussian(random) * SPHEROID_SIGMA_PC;
height = gaussian(random) * SPHEROID_SIGMA_PC * SPHEROID_FLATTENING;
distance = Math.hypot(dx, dy, height);
}
const coreT = Math.min(distance / BAR_HALF_LENGTH_PC, 1);
writer.push(
fromCentreOffset(dx, dy, height),
lerpColor(BULGE_CORE_COLOR, BULGE_EDGE_COLOR, coreT),
lerp(BULGE_SIZE_PC.min, BULGE_SIZE_PC.max, random()),
lerp(0.3, 0.06, coreT)
);
}
}
function addHaloParticles(writer: Writer, random: () => number, count: number): void {
for (let i = 0; i < count; i++) {
// A thin spherical scatter (globular clusters and halo field) so the disc is not a bare
// plate floating in the void.
const radius = DISC_RADIUS_PC * (0.35 + 0.85 * Math.pow(random(), 0.7));
const cosTheta = random() * 2 - 1;
const sinTheta = Math.sqrt(1 - cosTheta * cosTheta);
const angle = random() * Math.PI * 2;
const galactic = fromCentreOffset(radius * sinTheta * Math.cos(angle), radius * sinTheta * Math.sin(angle), radius * cosTheta);
writer.push(galactic, HALO_COLOR, lerp(HALO_SIZE_PC.min, HALO_SIZE_PC.max, random()), 0.05);
}
}
/**
* Scatters the Milky Way's particle cloud around the structural model in `galaxy.ts` and returns
* it packed for instanced rendering, already rotated into the scene's equatorial frame.
*
* Some samples are rejected (an arm particle that lands inside the bar, a disc particle past the
* visible edge), so the returned `count` is a little below the requested budget — the arrays are
* allocated at capacity and the count says how much of them is live.
*/
export function generateMilkyWayParticles(seed = 20260804, counts: GalaxyParticleCounts = DEFAULT_PARTICLE_COUNTS): GalaxyParticles {
const random = createRandom(seed);
const writer = createWriter(counts.arms + counts.disc + counts.bulge + counts.halo);
addBulgeParticles(writer, random, counts.bulge);
addArmParticles(writer, random, counts.arms);
addDiscParticles(writer, random, counts.disc);
addHaloParticles(writer, random, counts.halo);
return writer.finish();
}