Give every star a limb-darkened surface in its own colour, and light its planets with it

Every star but the Sun was a flat disc of one colour, and the Sun wore the texture pack's orange
photograph, lit by the same white light as every other star's planets.

Every star now shares one TSL material (starSurfaceMaterial in the scene): the Sun's map in grey,
times the colour of a blackbody at the star's temperature, times a linear limb-darkening law,
1 - 0.6 (1 - mu), the Sun's coefficient in the visible. The temperature is the one the radius
uses: the archive's st_teff for a host, else the dwarf sequence at its colour or type, else
the Sun's.
The colour comes from blackbodyColor (stellar.ts): Kim et al.'s cubic fit to the Planckian locus,
then CIE XYZ to linear sRGB, brightest channel 1. At D65 it gives 2 900 K (255, 180, 103), 5 800 K
(255, 241, 235) and 9 600 K (208, 219, 255), against (255, 182, 98), (255, 241, 231) and
(211, 221, 255) in Charity's integrated blackbody table. The tint is a uniform, so the shader is
built once and not per system.

The star's PointLight takes the same colour against the Sun's, since the planets' photographs
were taken in sunlight: the Sun's light stays white at pi, TRAPPIST-1's (2 566 K) is
(1, 0.44, 0.10) and Proxima's (2 900 K) (1, 0.52, 0.17), Sirius's (0.52, 0.67, 1). The intensity
stays pi. No halo comes back.

sun.jpg was 2048 by 1024 and 822 427 bytes for a disc that reaches 216 px across at the Sun's
closest approach on a 1080-line screen. It is now 1024 by 512 in grey, 31 306 bytes, which covers
the disc to a 1440-line screen. Its brightness varied by 56 % rms, which made every star a mottled
rock; it is rescaled to 14 % rms about the display's white, of the order of the Sun's granulation
contrast, the brighter half clipped as in a photograph exposed for the disc (6 % rms remains).

Measured on the dev server (1600 by 1000, the camera at its closest approach):
- the disc's brightness against the law, from r/R 0.52 to 0.97: Sun 0.970/0.841/0.763/0.657/0.560
  against 0.927/0.829/0.755/0.645/0.554, ups And and Sirius the same to within 0.05;
- the disc's centre in sRGB: Sun (245, 233, 226), ups And (F8V, 6 157 K) (249, 239, 239),
  Sirius (199, 209, 243);
- the first system entry of a fresh page, three runs each in alternating blocks against the
  previous commit: Sol's longest task 90 ms before, 86 ms after, two tasks over 50 ms in every
  run either way; Proxima Centauri's none over 50 ms after, one run of six with a 53 ms task
  before. The long tasks on Sol's first entry predate this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 15:10:52 +02:00
co-authored by Claude Opus 5.5
parent 7213f987c4
commit 2242fe0a7f
7 changed files with 163 additions and 29 deletions
@@ -10,6 +10,7 @@ import {
} from '@angular/core';
import { Router } from '@angular/router';
import * as THREE from 'three/webgpu';
import { normalView, positionViewDirection, texture, uniform } from 'three/tsl';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import {
@@ -19,6 +20,7 @@ import {
galacticCentrePositionPc,
galacticToEquatorial,
} from '../../shared/astro/galaxy';
import { blackbodyColor, SOLAR_EFFECTIVE_TEMPERATURE_K } from '../../shared/astro/stellar';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService, SceneCamera } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model';
@@ -68,7 +70,6 @@ import { StarmapHudComponent } from './starmap-hud.component';
import { SystemObjectCardComponent } from './system-object-card.component';
import { RoutingClient } from './routing-client';
import {
colorIndexToRgb,
FOCUS_RADIUS_PC,
StarFieldRenderer,
starRenderBudgetFromUrl,
@@ -85,11 +86,37 @@ import { LabeledPoint, LabelSide, StarLabelOverlay } from './star-label-overlay'
import { SystemOrbitsRenderer } from './system-orbits-renderer';
import { catalogueCensus, starReadouts, starSubtitle } from './star-readouts';
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
const SOL_STAR_ID = 0;
/** Radius, in CSS pixels, below which a body in the system view is scaled up to be seen at all. */
const MIN_MARKER_PIXELS = 3;
/**
* The linear limb-darkening coefficient: a star's surface is I(μ) = I(1) (1 − u (1 − μ)) bright,
* where μ is the cosine of the angle between the line of sight and the surface normal. The Sun's
* is about 0.6 in the visible, so its limb is 40 % as bright as its centre. Taken for every star,
* although a hotter star's limb is somewhat brighter and a cooler one's darker.
*/
const LIMB_DARKENING = 0.6;
/**
* The surface every star is drawn with: the Sun's photograph in grey, in `tint` — the colour of a
* blackbody at the star's temperature — and darkened towards the limb. Unlit: it is the source.
*
* The pattern is the Sun's, standing in for a surface no telescope resolves on another star, at a
* contrast turned down to the Sun's own (see `SUN_TEXTURE_PATH`). Its colour was taken out, so
* the tint says what colour the star is rather than which filter the Sun was photographed in: the
* Sun itself comes out the warm white of 5 772 K, not the pack's orange.
*
* The tint is a uniform, so every star shares one shader, compiled on the first system entry.
*/
function starSurfaceMaterial(tint: THREE.Node<'color'>): THREE.MeshBasicNodeMaterial {
const material = new THREE.MeshBasicNodeMaterial();
const mu = normalView.dot(positionViewDirection).clamp(0, 1);
material.colorNode = texture(loadCachedTexture(SUN_TEXTURE_PATH))
.rgb.mul(tint)
.mul(mu.sub(1).mul(LIMB_DARKENING).add(1));
return material;
}
/**
* How far from what the camera is looking at a star can be and still be named, as a fraction of
* how far back the camera is — so the net widens as the view pulls out and closes as it dives
@@ -413,7 +440,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private readonly raycaster = new THREE.Raycaster();
private readonly galaxyGroup = new THREE.Group();
private readonly systemGroup = new THREE.Group();
private readonly starMarkerMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
/** The colour of the system's star, set on entering it; see `starSurfaceMaterial`. */
private readonly starTint = uniform(new THREE.Color(1, 1, 1));
/** One for every star, built on the first system entry, so its pipeline is compiled once. */
private starMarkerMaterial?: THREE.MeshBasicNodeMaterial;
/** Rebuilt per system, since every star has its own radius. */
private starMarkerGeometry?: THREE.SphereGeometry;
@@ -599,9 +629,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.tethers?.dispose();
this.labelOverlay?.dispose();
this.systemRenderer?.dispose();
(this.starMarker?.material as THREE.Material | undefined)?.dispose();
this.starMarkerGeometry?.dispose();
this.starMarkerMaterial.dispose();
this.starMarkerMaterial?.dispose();
this.engine.dispose();
}
@@ -2182,7 +2211,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.systemRenderer?.dispose();
if (this.starMarker) {
this.systemGroup.remove(this.starMarker);
(this.starMarker.material as THREE.Material).dispose();
}
const systemBodies = this.bodies.filter((body) => body.systemStarId === star.id);
@@ -2195,19 +2223,21 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// The star's luminosity, derived from its own catalogued magnitude and distance, is what
// decides how hot each body in the system is — and so what each of them looks like.
const hostLuminosity = luminosityOf(star);
// Every star at its own radius: the archive's for a planet host, otherwise derived from its
// colour and brightness — or the Sun's, for the 3 077 stars with no measured magnitude or with
// neither a colour nor a type, which the card then gives no radius. Its temperature is the
// colour of its disc and of the light it casts.
this.currentStarSurface = starSurfaceOf(star, systemExoplanets);
this.systemRenderer = new SystemOrbitsRenderer(
systemBodies,
systemExoplanets,
{ x: star.x, y: star.y, z: star.z },
hostLuminosity,
this.currentStarSurface.temperatureK,
);
this.systemGroup.add(this.systemRenderer.object);
this.applyDisplay(this.display());
// Every star at its own radius: the archive's for a planet host, otherwise derived from its
// colour and brightness — or the Sun's, for the 3 077 stars with no measured magnitude or with
// neither a colour nor a type, which the card then gives no radius.
this.currentStarSurface = starSurfaceOf(star, systemExoplanets);
const starRadiusAu = (this.currentStarSurface.radiusSolar ?? 1) * SUN_RADIUS_AU;
// Framed against the grid's outer ring rather than the outermost orbit — the ring is always
@@ -2225,20 +2255,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.starMarkerGeometry?.dispose();
this.starMarkerGeometry = new THREE.SphereGeometry(starRadiusAu, 64, 32);
const starMarkerMaterial = this.starMarkerMaterial.clone();
const starColor = colorIndexToRgb(star.colorIndex, star.spectralType);
if (star.id === SOL_STAR_ID) {
// The Sun is the only star we have (and could ever have) a real photograph of; every
// other point in the galaxy view is far too distant to be resolved as a disk.
starMarkerMaterial.map = loadCachedTexture(SUN_TEXTURE_PATH);
starMarkerMaterial.color.set(0xffffff);
} else {
starMarkerMaterial.color.copy(starColor);
}
this.starMarkerMaterial ??= starSurfaceMaterial(this.starTint);
this.starTint.value.setRGB(
...blackbodyColor(this.currentStarSurface.temperatureK ?? SOLAR_EFFECTIVE_TEMPERATURE_K),
THREE.LinearSRGBColorSpace,
);
// No halo. It was a sprite sized against the arrival frame — 1.12 AU for the Sun — so it
// stayed put as the camera closed in and ended up filling the screen with the flat gradient
// that was meant to dress the star, over the photograph underneath it.
this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial);
this.starMarker = new THREE.Mesh(this.starMarkerGeometry, this.starMarkerMaterial);
this.systemGroup.add(this.starMarker);
this.galaxyGroup.visible = false;