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
@@ -1,6 +1,7 @@
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { blackbodyColor, effectiveTemperatureK } from '../../shared/astro/stellar';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { colorIndexToRgb, magnitudeToPointSize, selectDrawnStars, StarFieldRenderer } from './star-field-renderer'; import { colorIndexToRgb, magnitudeToPointSize, selectDrawnStars, StarFieldRenderer } from './star-field-renderer';
@@ -85,6 +86,17 @@ describe('colorIndexToRgb', () => {
}); });
}); });
it('tints a G dwarf warm, in the colour its own disc is drawn in, and a giant at the temperature its type gives', () => {
// G2 V: a blackbody at 5 770 K against D65, (1, 0.878, 0.821). The B−V ramp this replaced was
// white at 0.8 and gave it (0.956, 0.969, 1), bluish beside its own disc.
const g2 = colorIndexToRgb(0.823, 'Unknown', 'BP-RP');
expect(g2.r).toBeGreaterThan(g2.b);
expect([g2.r, g2.g, g2.b].map((channel) => channel.toFixed(5))).toEqual(blackbodyColor(5770).map((channel) => channel.toFixed(5)));
const antares = { magnitude: 1.06, distancePc: 169.78, spectralType: 'M1Ib + B2.5V', colorIndex: 1.865, colorSystem: 'B-V' } as const;
const giant = colorIndexToRgb(antares.colorIndex, antares.spectralType, antares.colorSystem);
blackbodyColor(effectiveTemperatureK(antares)!).forEach((channel, i) => expect([giant.r, giant.g, giant.b][i]).toBeCloseTo(channel, 2));
});
it('reads a BP−RP colour as the B−V of the dwarf of that colour, so a type tints alike in either', () => { it('reads a BP−RP colour as the B−V of the dwarf of that colour, so a type tints alike in either', () => {
// G2 V is B−V 0.65 or BP−RP 0.823, M0 V 1.42 or 1.84 (Pecaut & Mamajek). // G2 V is B−V 0.65 or BP−RP 0.823, M0 V 1.42 or 1.84 (Pecaut & Mamajek).
for (const [bMinusV, bpRp] of [[0.65, 0.823], [1.42, 1.84]]) { for (const [bMinusV, bpRp] of [[0.65, 0.823], [1.42, 1.84]]) {
@@ -100,6 +112,7 @@ describe('colorIndexToRgb', () => {
renderer.refocus({ centre: { x: 0, y: 0, z: 0 } }); renderer.refocus({ centre: { x: 0, y: 0, z: 0 } });
const { colorAttribute } = renderer as unknown as { colorAttribute: THREE.InstancedBufferAttribute }; const { colorAttribute } = renderer as unknown as { colorAttribute: THREE.InstancedBufferAttribute };
expect(colorAttribute.getZ(0)).toBeCloseTo(colorIndexToRgb(1.42).b, 5); expect(colorAttribute.getZ(0)).toBeCloseTo(colorIndexToRgb(1.42).b, 5);
expect(colorAttribute.getZ(0)).toBeCloseTo(blackbodyColor(3850)[2], 5);
renderer.dispose(); renderer.dispose();
}); });
@@ -2,7 +2,7 @@ import * as THREE from 'three/webgpu';
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl'; import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
import { BrightnessIndex, brightnessIndex, Positioned } from '../../shared/astro/brightest'; 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 { SceneCamera } from '../../core/engine/engine.service';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size'; import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
@@ -97,35 +97,39 @@ export interface DrawFocus {
const SUN: Positioned = { x: 0, y: 0, z: 0 }; 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 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 * A star's tint in the field: the colour of a blackbody at its effective temperature against the
* blue-white, cool/red stars (high index) skew orange-red, matching real spectral colors. * 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 * `colorIndex` is `null` for the ~10% of stars HYG never photometered. Those fall back to their
* value derived from `spectralType`, and to neutral white only when the catalog records no * `spectralType`, and to neutral white only when the catalog records no classification at all —
* classification at all — never to 0, which is itself a real color index meaning "hot A-type" * never to 0, which is itself a real color index meaning "hot A-type" and would paint several
* and would paint several hundred red dwarfs blue-white. * 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 * It was a ramp in B−V, white at 0.8, a K0 dwarf, where a blackbody against D65 is white at about
* two for the same star — 0.82 against 0.65 for a G2 dwarf, 1.84 against 1.42 for an M0 — and read * 6 500 K, B−V 0.44: of the 376 660 stars measured in BP−RP, 245 850 were tinted bluish, 227 797 of
* as B−V it tinted the 376 703 stars measured in it, 83 % of the map, as a K2 where they were a G7 * them while their disc was warm, and a G2 dwarf (0.956, 0.969, 1) beside its disc's (1, 0.878,
* at the median, and an M0 dwarf as an M5. * 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 { 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; // The distance is only there to say the star is not the Sun; the temperature reads none of the rest.
const resolved = measured ?? spectralTypeToColorIndex(spectralType); const temperatureK = effectiveTemperatureK({ magnitude: 0, distancePc: 1, spectralType, colorIndex, colorSystem });
const color = new THREE.Color(); if (temperatureK === null) {
if (resolved === null) { return new THREE.Color().copy(NEUTRAL_STAR_COLOR);
return color.copy(NEUTRAL_STAR_COLOR); }
// 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 t = THREE.MathUtils.clamp((resolved + 0.4) / 2.4, 0, 1); const BLACKBODY_STEP_K = 10;
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); const BLACKBODY_TINTS: [number, number, number][] = [];
}
/** Brighter stars (lower apparent magnitude) render as bigger points. */ /** Brighter stars (lower apparent magnitude) render as bigger points. */
export function magnitudeToPointSize(magnitude: number): number { export function magnitudeToPointSize(magnitude: number): number {
+14 -2
View File
@@ -271,8 +271,20 @@ export function dwarfSequenceAtTemperature(temperatureK: number): DwarfSequenceP
/** The sequence where `key`, rising down `rows`, reaches `value`: linear between the two rows either side, the end row past either end. */ /** The sequence where `key`, rising down `rows`, reaches `value`: linear between the two rows either side, the end row past either end. */
function sequenceWhere(rows: readonly SequenceRow[], key: (row: SequenceRow) => number, value: number): DwarfSequencePoint { function sequenceWhere(rows: readonly SequenceRow[], key: (row: SequenceRow) => number, value: number): DwarfSequencePoint {
const next = rows.findIndex((row) => key(row) >= value); // Bisected, not scanned: the star field reads it for each of the 455 571 stars, and a scan of the
const [first, second] = next === -1 ? [rows[rows.length - 2], rows[rows.length - 1]] : [rows[Math.max(next, 1) - 1], rows[Math.max(next, 1)]]; // rows took 200 ms of that.
let low = 1;
let high = rows.length - 1;
while (low < high) {
const middle = (low + high) >> 1;
if (key(rows[middle]) >= value) {
high = middle;
} else {
low = middle + 1;
}
}
const first = rows[low - 1];
const second = rows[low];
const t = Math.min(Math.max((value - key(first)) / (key(second) - key(first)), 0), 1); const t = Math.min(Math.max((value - key(first)) / (key(second) - key(first)), 0), 1);
const lerp = (from: number, to: number): number => from + (to - from) * t; const lerp = (from: number, to: number): number => from + (to - from) * t;
return { return {