diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 297d21c..c57cb80 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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; diff --git a/src/app/features/galaxy-system/system-orbits-renderer.spec.ts b/src/app/features/galaxy-system/system-orbits-renderer.spec.ts index daa00af..b83c6b8 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.spec.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.spec.ts @@ -464,3 +464,29 @@ describe('exoplanet size without a measured radius', () => { expect(radiusOf({ radiusEarth: 1.88, massEarth: 2829 }) / EARTH_AU).toBeCloseTo(1.88, 2); }); }); + +describe("SystemOrbitsRenderer's star light", () => { + const lightOf = (renderer: SystemOrbitsRenderer): THREE.PointLight => { + let light: THREE.PointLight | undefined; + renderer.object.traverse((object) => (light ??= (object as THREE.PointLight).isPointLight ? (object as THREE.PointLight) : undefined)); + return light!; + }; + + it("is white at π from the Sun, or from a star with no temperature", () => { + for (const renderer of [new SystemOrbitsRenderer([], [], undefined, 1, 5772), new SystemOrbitsRenderer([], [exoplanet()])]) { + expect(lightOf(renderer).color.toArray()).toEqual([1, 1, 1]); + expect(lightOf(renderer).intensity).toBe(Math.PI); + renderer.dispose(); + } + }); + + it("lights an M dwarf's planets orange-red, at the same π", () => { + const renderer = new SystemOrbitsRenderer([], [exoplanet()], undefined, 5.5e-4, 2566); + const [r, g, b] = lightOf(renderer).color.toArray(); + expect(r).toBe(1); + expect(g).toBeLessThan(0.5); + expect(b).toBeLessThan(0.15); + expect(lightOf(renderer).intensity).toBe(Math.PI); + renderer.dispose(); + }); +}); diff --git a/src/app/features/galaxy-system/system-orbits-renderer.ts b/src/app/features/galaxy-system/system-orbits-renderer.ts index 508f9f9..89a009a 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.ts @@ -7,6 +7,7 @@ import { planetTexture } from '../../shared/rendering/procedural-planet-texture' import { bodyTexturePath, loadCachedTexture } from '../../shared/rendering/texture-catalog'; import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler'; import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates'; +import { blackbodyColor, SOLAR_EFFECTIVE_TEMPERATURE_K } from '../../shared/astro/stellar'; import { BodyRecord, OrbitalElements } from '../../shared/models/body.model'; import { bodyMarkerRadiusAu, systemGridRingsAu } from './system-framing'; import { PolarGridPlane, TetherField } from './grid-plane'; @@ -157,12 +158,18 @@ function buildMarker(id: string | undefined, kind: SystemMemberKind, radiusKm: n * floor makes for size. What the light does carry truthfully is which side is day: every body * shows its lit face toward the star, and the terminator falls where it really falls. * - * White, at π: a Lambertian surface returns intensity / π of its texture where the light falls - * square on it, so π gives back the photograph itself at the point facing the star, and less - * towards the limb. A warm tint or a smaller figure darkened the photographs below what they are. + * At π: a Lambertian surface returns intensity / π of its texture where the light falls square on + * it, so π gives back the photograph itself at the point facing the star, and less towards the + * limb. A smaller figure darkened the photographs below what they are. + * + * In the star's own colour, against the Sun's: the photographs were taken in sunlight, so the + * Sun's light is white and gives them back as they are, and another star's shifts them as its + * spectrum differs from the Sun's — a 2 566 K M dwarf's is (1, 0.44, 0.10), orange-red, and a + * 9 600 K A star's (0.52, 0.67, 1), blue. A star with no temperature is lit as the Sun. */ -function starLight(): THREE.PointLight { +function starLight(temperatureK: number | null | undefined): THREE.PointLight { const light = new THREE.PointLight(0xffffff, Math.PI, 0, 0); + light.color.setRGB(...blackbodyColor(temperatureK ?? SOLAR_EFFECTIVE_TEMPERATURE_K, SOLAR_EFFECTIVE_TEMPERATURE_K), THREE.LinearSRGBColorSpace); light.position.set(0, 0, 0); return light; } @@ -296,7 +303,9 @@ export class SystemOrbitsRenderer { * system is and therefore what it looks like. Omitted for a host that is not in the star * catalogue, leaving its bodies classified on size and density alone. */ - hostLuminositySolar?: number | null + hostLuminositySolar?: number | null, + /** The host star's effective temperature, which is the colour of the light it casts. */ + hostTemperatureK?: number | null ) { const members: SystemMember[] = []; const topLevelBodiesById = new Map(); @@ -394,7 +403,7 @@ export class SystemOrbitsRenderer { } // The star lights its own system. The star marker itself is unlit — it is the source, not a // surface — so nothing here changes how it is drawn. - this.object.add(starLight()); + this.object.add(starLight(hostTemperatureK)); } /** Recomputes every marker's position for the given Julian date. Call once per tick. */ diff --git a/src/app/shared/astro/stellar.spec.ts b/src/app/shared/astro/stellar.spec.ts index 118ad81..3caec55 100644 --- a/src/app/shared/astro/stellar.spec.ts +++ b/src/app/shared/astro/stellar.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { absoluteMagnitude, + blackbodyColor, bolometricCorrection, effectiveTemperatureK, luminositySolar, @@ -164,3 +165,29 @@ describe('radiusFromLuminositySolar', () => { } }); }); + +describe('blackbodyColor', () => { + /** As the display shows it: sRGB-encoded, 0 to 255. */ + const displayed = (rgb: readonly number[]) => rgb.map((v) => Math.round(255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055))); + + it('gives the colours of the stars against the display white', () => { + // Charity's blackbody colour table (CIE 1931 2°, D65): 2 900 K #ffb662, 5 800 K #fff1e7, 9 600 K #d3ddff. + for (const [temperatureK, expected] of [[2900, [255, 182, 98]], [5800, [255, 241, 231]], [9600, [211, 221, 255]]] as const) { + displayed(blackbodyColor(temperatureK)).forEach((channel, i) => expect(Math.abs(channel - expected[i])).toBeLessThanOrEqual(5)); + } + }); + + it("is white at the white point it is given, and an M dwarf's light orange-red against the Sun's", () => { + expect(blackbodyColor(SOLAR_EFFECTIVE_TEMPERATURE_K, SOLAR_EFFECTIVE_TEMPERATURE_K)).toEqual([1, 1, 1]); + const [r, g, b] = blackbodyColor(2566, SOLAR_EFFECTIVE_TEMPERATURE_K); + expect(r).toBe(1); + expect(g).toBeCloseTo(0.44, 2); + expect(b).toBeCloseTo(0.1, 2); + }); + + it('holds the ends of the fit, and never goes negative', () => { + expect(blackbodyColor(800)).toEqual(blackbodyColor(1667)); + expect(blackbodyColor(60000)).toEqual(blackbodyColor(25000)); + expect(Math.min(...blackbodyColor(1667))).toBe(0); + }); +}); diff --git a/src/app/shared/astro/stellar.ts b/src/app/shared/astro/stellar.ts index e946082..ac56995 100644 --- a/src/app/shared/astro/stellar.ts +++ b/src/app/shared/astro/stellar.ts @@ -159,3 +159,41 @@ export function effectiveTemperatureK(star: StellarPhotometry): number | null { export function radiusFromLuminositySolar(luminositySolar: number, temperatureK: number): number { return Math.sqrt(luminositySolar) / (temperatureK / SOLAR_EFFECTIVE_TEMPERATURE_K) ** 2; } + +/** The range Kim et al.'s fit to the Planckian locus covers; a temperature outside it is clamped. */ +const PLANCKIAN_LOCUS_MIN_K = 1667; +const PLANCKIAN_LOCUS_MAX_K = 25000; + +/** + * The colour of a blackbody at `temperatureK`, in linear sRGB with its brightest channel at 1: + * its chromaticity off the Planckian locus (Kim et al. 2002, the cubic fit to CIE 1931), then + * CIE XYZ to sRGB. Against the display's own white, D65, unless `whitePointK` names the blackbody + * that is to read as white — as the Sun's does for the photographs of its planets, which were + * taken in its light. + * + * At D65, a 2 900 K M dwarf is sRGB (255, 180, 103), the Sun (255, 241, 234), a 9 600 K A star + * (208, 219, 255): Charity's table, which integrates the Planck spectrum, gives (255, 182, 98), + * (255, 241, 231) at 5 800 K and (211, 221, 255). + */ +export function blackbodyColor(temperatureK: number, whitePointK?: number): [number, number, number] { + const rgb = blackbodyLinearSrgb(temperatureK); + const white = whitePointK === undefined ? [1, 1, 1] : blackbodyLinearSrgb(whitePointK); + const relative = rgb.map((channel, i) => channel / white[i]); + const brightest = Math.max(...relative); + return relative.map((channel) => channel / brightest) as [number, number, number]; +} + +function blackbodyLinearSrgb(temperatureK: number): number[] { + const t = 1000 / Math.min(Math.max(temperatureK, PLANCKIAN_LOCUS_MIN_K), PLANCKIAN_LOCUS_MAX_K); + const x = + t >= 0.25 ? -0.2661239 * t ** 3 - 0.2343589 * t ** 2 + 0.8776956 * t + 0.17991 : -3.0258469 * t ** 3 + 2.1070379 * t ** 2 + 0.2226347 * t + 0.24039; + const y = + t >= 1000 / 2222 + ? -1.1063814 * x ** 3 - 1.3481102 * x ** 2 + 2.18555832 * x - 0.20219683 + : t >= 0.25 + ? -0.9549476 * x ** 3 - 1.37418593 * x ** 2 + 2.09137015 * x - 0.16748867 + : 3.081758 * x ** 3 - 5.8733867 * x ** 2 + 3.75112997 * x - 0.37001483; + const [X, Y, Z] = [x / y, 1, (1 - x - y) / y]; + // Below 1 920 K the locus leaves the sRGB gamut, and blue comes out negative. + return [3.2406 * X - 1.5372 * Y - 0.4986 * Z, -0.9689 * X + 1.8758 * Y + 0.0415 * Z, 0.0557 * X - 0.204 * Y + 1.057 * Z].map((channel) => Math.max(channel, 0)); +} diff --git a/src/app/shared/rendering/texture-catalog.ts b/src/app/shared/rendering/texture-catalog.ts index 155cd6a..7e7350a 100644 --- a/src/app/shared/rendering/texture-catalog.ts +++ b/src/app/shared/rendering/texture-catalog.ts @@ -32,7 +32,16 @@ const BODY_TEXTURE_PATHS: Record = { moon: 'assets/textures/bodies/moon.jpg' }; -/** The Sun isn't a `BodyRecord` (it's the system's star marker), so it's looked up separately. */ +/** + * The Sun's surface, which every star's disc in the system view is drawn with, tinted to its own + * colour. Baked from the pack's 2048 by 1024 map (822 427 bytes) to 1024 by 512 in grey (31 306): + * the Sun reaches 216 px across at its closest approach on a 1080-line screen, and a sphere shows + * π times its diameter of the map round its equator, so this covers it to a 1440-line one. The + * tint supplies the colour. The map's brightness varied by 56 % rms, a mottled rock rather than a + * star; it is rescaled to 14 % rms about the display's white, of the order of the Sun's own + * granulation contrast, and the brighter half clipped there as in a photograph exposed for the + * disc, which leaves 6 %. Not a `BodyRecord`, so it is looked up separately. + */ export const SUN_TEXTURE_PATH = 'assets/textures/bodies/sun.jpg'; export const SATURN_RING_TEXTURE_PATH = 'assets/textures/bodies/saturn_ring.png'; export const MILKY_WAY_SKYBOX_PATH = 'assets/textures/skybox/milkyway.jpg'; diff --git a/src/assets/textures/bodies/sun.jpg b/src/assets/textures/bodies/sun.jpg index 9d787d8..b4b9aa5 100644 Binary files a/src/assets/textures/bodies/sun.jpg and b/src/assets/textures/bodies/sun.jpg differ