Tint each star in the field the colour its own disc is drawn in, a blackbody at its temperature

4191b70 carried BP−RP to the dwarf's B−V before the field's tint ramp read it, and said the Gaia
stars had been tinted redder than their own disc in the system view. They had been paler: the ramp
was white at B−V 0.8, a K0 dwarf, where a blackbody against the display's D65 is white near 6 500
K, B−V 0.44, and its red end, (1, 0.6, 0.35), was paler than an M dwarf's disc. After it 245 850 of
the 376 660 BP−RP stars were tinted bluish, 227 797 of them while their disc was warm. The field
now takes blackbodyColor at effectiveTemperatureK, the same function and the same temperature the
disc is drawn with, so a giant is tinted at its type's temperature too.

Over the shipped catalogue: stars bluish in the field with a warm disc 227 797 → 0, bluish at all
245 850 → 17 931, mean RGB distance from each star's field tint to its disc 0.250 → 0.001 (what is
left is the tint being cached at the nearest 10 K, which moves no channel by more than 0.0014). In
the app on :4302, field against disc after entering each star: Gaia DR3 6361559602963567744 (BP−RP
0.882, 5 543 K) (0.974, 0.982, 1) → (1, 0.854, 0.764) against (1, 0.854, 0.765); Gaia DR3
2026408043220034176 (1.84, 3 850 K) (1, 0.793, 0.664) → (1, 0.629, 0.34), the disc's own; HD 13531
(G0, B−V 0.70) (0.971, 0.979, 1) → (1, 0.86, 0.779), its disc's.

A blackbody per star cost 100 ms, and the scan of the table rows another 70, so the tint is cached
per 10 K and the table is bisected. Building the star field over the whole catalogue in the app
took 176-196 ms before and 151-159 after, four runs each.

Tests: a new case tints a G2 dwarf warm, exactly its 5 770 K blackbody, and Antares at the
temperature its type gives; the BP−RP render test now also checks an M0's blackbody. Controls: a
giant tinted at its colour's temperature, the tint taken against a bluer white than the disc's,
and a cache a hundred times too coarse each fail the named test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 00:00:43 +02:00
co-authored by Claude Opus 5.5
parent a8f394cf57
commit 95ffb009a2
3 changed files with 52 additions and 23 deletions
@@ -2,7 +2,7 @@ import * as THREE from 'three/webgpu';
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
import { BrightnessIndex, brightnessIndex, Positioned } from '../../shared/astro/brightest';
import { dwarfSequenceAtColor, spectralTypeToColorIndex } from '../../shared/astro/spectral';
import { blackbodyColor, effectiveTemperatureK } from '../../shared/astro/stellar';
import { SceneCamera } from '../../core/engine/engine.service';
import { StarRecord } from '../../shared/models/star.model';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
@@ -97,36 +97,40 @@ export interface DrawFocus {
const SUN: Positioned = { x: 0, y: 0, z: 0 };
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
const WARM_STAR_COLOR = new THREE.Color(1.0, 0.6, 0.35);
/**
* Crude but effective B-V color-index -> RGB tint: hot/blue stars (low/negative index) skew
* blue-white, cool/red stars (high index) skew orange-red, matching real spectral colors.
* A star's tint in the field: the colour of a blackbody at its effective temperature against the
* display's white — the tint its own disc is drawn in, in its system. The temperature is the one
* the disc is drawn at (`effectiveTemperatureK`): off the dwarf sequence at the star's colour, in
* B−V or Gaia's BP−RP, off the type where there is no colour, and off the type for a giant.
*
* `colorIndex` is `null` for the ~10% of stars HYG never photometered. Those fall back to a
* value derived from `spectralType`, and to neutral white only when the catalog records no
* classification at all — never to 0, which is itself a real color index meaning "hot A-type"
* and would paint several hundred red dwarfs blue-white.
* `colorIndex` is `null` for the ~10% of stars HYG never photometered. Those fall back to their
* `spectralType`, and to neutral white only when the catalog records no classification at all —
* never to 0, which is itself a real color index meaning "hot A-type" and would paint several
* hundred red dwarfs blue-white.
*
* A Gaia BP−RP is carried to the B−V of the dwarf of that colour first. It is the larger of the
* two for the same star — 0.82 against 0.65 for a G2 dwarf, 1.84 against 1.42 for an M0 — and read
* as B−V it tinted the 376 703 stars measured in it, 83 % of the map, as a K2 where they were a G7
* at the median, and an M0 dwarf as an M5.
* It was a ramp in B−V, white at 0.8, a K0 dwarf, where a blackbody against D65 is white at about
* 6 500 K, B−V 0.44: of the 376 660 stars measured in BP−RP, 245 850 were tinted bluish, 227 797 of
* them while their disc was warm, and a G2 dwarf (0.956, 0.969, 1) beside its disc's (1, 0.878,
* 0.821). Its red end, (1, 0.6, 0.35), was paler than an M dwarf's disc.
*/
export function colorIndexToRgb(colorIndex: number | null, spectralType?: string, colorSystem?: 'B-V' | 'BP-RP'): THREE.Color {
const measured = colorIndex !== null && colorSystem === 'BP-RP' ? dwarfSequenceAtColor(colorIndex, 'BP-RP', true)!.bMinusV : colorIndex;
const resolved = measured ?? spectralTypeToColorIndex(spectralType);
const color = new THREE.Color();
if (resolved === null) {
return color.copy(NEUTRAL_STAR_COLOR);
// The distance is only there to say the star is not the Sun; the temperature reads none of the rest.
const temperatureK = effectiveTemperatureK({ magnitude: 0, distancePc: 1, spectralType, colorIndex, colorSystem });
if (temperatureK === null) {
return new THREE.Color().copy(NEUTRAL_STAR_COLOR);
}
const t = THREE.MathUtils.clamp((resolved + 0.4) / 2.4, 0, 1);
return t < 0.5 ? color.lerpColors(COLD_STAR_COLOR, NEUTRAL_STAR_COLOR, t * 2) : color.lerpColors(NEUTRAL_STAR_COLOR, WARM_STAR_COLOR, (t - 0.5) * 2);
// ponytail: the colour at the nearest 10 K, computed once. Rounding moves no channel by more than
// 0.0014, and computing it for each of the 455 571 stars took 100 ms of the boot.
const step = Math.round(temperatureK / BLACKBODY_STEP_K);
const tint = (BLACKBODY_TINTS[step] ??= blackbodyColor(step * BLACKBODY_STEP_K));
return new THREE.Color(tint[0], tint[1], tint[2]);
}
const BLACKBODY_STEP_K = 10;
const BLACKBODY_TINTS: [number, number, number][] = [];
/** Brighter stars (lower apparent magnitude) render as bigger points. */
export function magnitudeToPointSize(magnitude: number): number {
const t = THREE.MathUtils.clamp(1 - (magnitude + 2) / 12, 0, 1);