Until now this was built from memory — the reference site is blocked by this environment's egress policy, so the resemblance was asserted rather than checked. Five screenshots of the real thing arrived, and this is what comparing against them changed. Labels say what a thing is, not just what it is called. Every label is now two lines: the name, then its type in smaller, wider-tracked, dimmer capitals. This is the single most characteristic element of the reference and it appears in every frame of it. It also settles a real ambiguity — in a map that mixes scales, "Orion" is an arm, a nebula and a constellation, and nothing about a bare name said which one a label pointed at. For stars the type line distinguishes "System" from "Star", which is the one thing it can say that the map could not otherwise show: which points are somewhere you can actually go. The system view had no body labels at all, where the reference labels every planet. It does now, which turned out to need two supporting changes. The overlay had only ever added and removed labels, never moved them, because stars do not move; planets do, so an existing label is now repositioned rather than left where the body used to be. And the inner four planets printed on top of each other in exactly the clump the star labels were already spread to avoid — so that logic is now shared rather than duplicated, with system bodies ordered outermost-first. Closing in reverses it by itself: the outer orbits leave the frame, their labels drop, and the inner planets take the space. The chrome follows the reference's layout. The scale ladder is a row of chamfered tabs at the top left rather than a vertical list of diamonds at the middle left, and a nameplate across the top centre says what the view is holding. The centre reticle is a hexagon, which is how the reference locks onto a body, and stays distinct from the rectangular panel chrome. Not copied: the ARK/RSI logos, wordmarks, and the bottom-right tool tabs. The first two are someone else's brand, and the third would be four tabs opening features this app does not have. Two e2e assertions moved off bare text matches onto the readout panel's own title. The nameplate names the same thing the panel does, so "is 'Local Stars' on screen" became ambiguous — the assertion, not the design, was what had to give. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
112 lines
5.2 KiB
TypeScript
112 lines
5.2 KiB
TypeScript
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, kind: landmark.kind, x: position.x, y: position.y, z: position.z };
|
|
});
|
|
}
|
|
|
|
dispose(): void {
|
|
this.object.removeFromParent();
|
|
this.geometry.dispose();
|
|
this.material.dispose();
|
|
}
|
|
}
|