feat(sky): replace the blocky default sky box with catalogue stars

Cesium's default Tycho-2 sky box is six 1024² JPEGs stretched 3–6× around
the whole-globe view, so space read as an over-compressed JPEG (HAL-627).

The scene now draws its own sky in the Scene#skyBox slot: the 9,096 stars
of the Yale Bright Star Catalogue as point sprites sized in CSS pixels
(magnitude → width and opacity, B−V → colour), over a soft, star-free
Milky Way cube map built from ESO/S. Brunier's panorama (CC BY 4.0). Both
layers share one ICRF→Earth-fixed rotation at the scene clock, with the
TEME rotation filling in until Cesium's IAU 2006 tables load.

Assets total 482 KB, built by scripts/build-bright-stars.mjs and
scripts/build-milky-way.mjs. Frame time and the idle render governor are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-19 11:08:46 +04:00
1 parent 93ea088f45
commit 6549344946
20 files changed
+10644 -2

No files matched your search

+5
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
import { StarrySky } from '../sky/starrySky.js';
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
@@ -119,6 +120,9 @@ export function createApplicationViewer({ container, creditContainer }) {
selectionIndicator: false,
infoBox: false,
baseLayer: false,
// Replaced below: the default Tycho-2 cube map is too coarse for the
// whole-globe view and its JPEG blocks show around the Earth.
skyBox: false,
creditContainer,
msaaSamples: 4,
contextOptions: { webgl: { preserveDrawingBuffer: true } },
@@ -130,6 +134,7 @@ export function createApplicationViewer({ container, creditContainer }) {
// render loop. See app/atmosphereCompat.js.
applyModelAtmosphereWorkaround(viewer.scene);
viewer.scene.globe.show = false;
viewer.scene.skyBox = new StarrySky();
viewer.scene.skyAtmosphere.show = true;
viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18;
viewer.scene.skyAtmosphere.saturationShift = -0.12;
+7
View File
@@ -386,6 +386,13 @@ export const DATA_CREDITS = [
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
},
// ── Bundled snapshots ───────────────────────────────────────────
{
key: 'sky-background',
html:
'Stars: <a href="https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50" target="_blank" rel="noopener">Yale Bright Star Catalogue</a> ' +
'(Hoffleit &amp; Warren, via CDS). Milky Way: <a href="https://www.eso.org/public/images/eso0932a/" target="_blank" rel="noopener">ESO/S. Brunier</a> ' +
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>; stars removed, blurred and reprojected)',
},
{
key: 'dams',
html: 'Dams: Open Infrastructure Map',
+65
View File
@@ -0,0 +1,65 @@
# Sky background
The space behind the globe (`src/sky/starrySky.js`): catalogue stars drawn as
sharp point sprites over a soft Milky Way cube map. It replaces Cesium's
default Tycho-2 sky box, whose six 1024² JPEGs stretch 3–6× around the
whole-globe view and show compression blocks.
| File | Content | Size |
| ---- | ------- | ---- |
| `bright_stars.json` | 9,096 stars: J2000 RA/Dec, V magnitude, B−V | 235 KB |
| `milkyway_{px,nx,py,ny,pz,nz}.webp` | Milky Way glow, six 1024² cube faces, no stars | 247 KB |
## Stars — Yale Bright Star Catalogue
**Source:** Yale Bright Star Catalogue, 5th Revised Edition (Hoffleit &
Warren 1991), CDS VizieR catalogue
[V/50](https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50), file
`https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz` (sha256
`3dc44b1e90be8fbe5bcc7656032560f51275f985c7e3f783c9028e1838ec7bed`, fetched
2026-10-19).
**License:** public domain (Yale University Observatory / NASA Astronomical
Data Center). CDS asks for an acknowledgement; the in-app "Data attribution"
popover carries it.
**Build:** `node scripts/build-bright-stars.mjs [catalog.gz]` — every record
with a J2000 position (14 novae and non-stellar entries dropped), RA/Dec
rounded to 0.001°, magnitudes to 0.01. The renderer maps magnitude to sprite
width and opacity and B−V to a desaturated black-body colour
(`src/sky/stars.js`).
## Milky Way — ESO/S. Brunier
**Source:** "The Milky Way panorama", ESO image
[eso0932a](https://www.eso.org/public/images/eso0932a/), full-size original
`https://cdn.eso.org/images/original/eso0932a.tif` (6000×3000 sRGB TIFF, sha256
`10f209ab83e1fd89e7fa1ed70277ffc6ed19c43549f04ec637c6806d98aff035`, fetched
2026-10-19). Plate carrée in galactic coordinates, centred on the Galactic
Centre.
**License:** [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
(<https://www.eso.org/public/outreach/copyright/>). **Credit: ESO/S. Brunier.**
**Changes made** (CC BY 4.0 asks for them to be stated): resampled to
4096×2048, two planets (Jupiter, by position) painted over with the surrounding sky,
stars removed with a 7×7 morphological opening, blurred (σ = 0.4°),
reprojected from galactic coordinates to ICRF cube faces, black level set at
the darkest 25 % of the sky, gamma-encoded and saved as WebP. Build:
`node scripts/build-milky-way.mjs <eso0932a.tif>`.
The panorama is a photographic mosaic, not an astrometric map: bright stars
sit up to ~2° from their catalogue positions. That is invisible in a glow
blurred this much, and every sharp star on screen comes from the catalogue.
The NASA SVS "Deep Star Maps 2020" Milky Way layer was considered and not
used: it is derived from Gaia DR2, which ESA distributes under CC BY-NC 3.0
IGO with prior authorization required for commercial use.
## Frame
Both layers are in ICRF/J2000. The cube faces use the standard WebGL face
layout with +X towards the March equinox and +Z towards the north celestial
pole, uploaded without `flipY`. At runtime one rotation —
`Transforms.computeIcrfToFixedMatrix` at the scene clock — turns both into
Earth-fixed coordinates.
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+430
View File
@@ -0,0 +1,430 @@
import * as Cesium from 'cesium';
import { loadBundledJson } from '../data/bundledJson.js';
import { governorRequestRender } from '../renderGovernor.js';
import { buildStarVertices, STAR_VERTEX_FLOATS } from './stars.js';
/**
* Space background: catalogue stars drawn as point sprites over a soft Milky
* Way cube map, replacing Cesium's default Tycho-2 sky box (six 1024² JPEGs
* stretched 3–6× on screen, which read as compression blocks around the
* globe).
*
* Narrow, version-tested engine adapter. It takes the `Scene#skyBox` slot:
* Cesium calls `update(frameState, useHdr)` once per frame and executes the
* returned command first in the environment pass, before the atmosphere, sun,
* moon and every globe or tile command — so the sky stays behind everything
* without depth testing. Only `execute()` and `derivedCommands` of that
* command are read. An engine upgrade must revalidate this adapter.
*
* Both layers share one ICRF→Earth-fixed rotation (IAU 2006 precession,
* nutation and Earth rotation at the scene clock), so stars and the Milky
* Way agree with each other and with the real sky over the globe. Until
* Cesium's ICRF tables load, the TEME approximation the default sky box uses
* fills in; the arrival renders one frame through the idle governor.
*/
export const STARRY_SKY_CESIUM_VERSION = '1.138.0';
/** Opacity of the Milky Way glow over black; the stars carry the sky. */
export const MILKY_WAY_INTENSITY = 0.4;
/** Overall star brightness multiplier. */
export const STAR_INTENSITY = 1;
/** Clock change, in seconds, below which the sky rotation is reused. */
export const SKY_ROTATION_EPSILON_SECONDS = 1;
const STAR_PACK_URL = new URL(
'../data/local_data/sky/bright_stars.json',
import.meta.url,
);
const MILKY_WAY_FACE_URLS = Object.freeze({
positiveX: new URL(
'../data/local_data/sky/milkyway_px.webp',
import.meta.url,
),
negativeX: new URL(
'../data/local_data/sky/milkyway_nx.webp',
import.meta.url,
),
positiveY: new URL(
'../data/local_data/sky/milkyway_py.webp',
import.meta.url,
),
negativeY: new URL(
'../data/local_data/sky/milkyway_ny.webp',
import.meta.url,
),
positiveZ: new URL(
'../data/local_data/sky/milkyway_pz.webp',
import.meta.url,
),
negativeZ: new URL(
'../data/local_data/sky/milkyway_nz.webp',
import.meta.url,
),
});
// Directions, not points: w = 0 drops the camera translation, and z = w puts
// every fragment on the far plane, inside the clip volume at any frustum.
const SKY_DIRECTION_GLSL = `
uniform mat3 u_icrfToFixed;
vec4 skyClipPosition(vec3 icrfDirection)
{
vec3 eye = czm_viewRotation * (u_icrfToFixed * icrfDirection);
vec4 clip = czm_projection * vec4(eye, 0.0);
clip.z = clip.w * 0.99999;
return clip;
}
`;
const MILKY_WAY_VS = `${SKY_DIRECTION_GLSL}
in vec3 position;
out vec3 v_direction;
void main()
{
gl_Position = skyClipPosition(position);
v_direction = position;
}
`;
const MILKY_WAY_FS = `
uniform samplerCube u_milkyWay;
uniform float u_intensity;
in vec3 v_direction;
void main()
{
vec3 color = czm_textureCube(u_milkyWay, normalize(v_direction)).rgb * u_intensity;
// Interleaved-gradient dither: the dim glow spans only a few 8-bit steps.
float noise = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
color = max(color + (noise - 0.5) / 255.0, 0.0);
out_FragColor = vec4(czm_gammaCorrect(color), 1.0);
}
`;
const STAR_VS = `${SKY_DIRECTION_GLSL}
in vec3 position;
in float sigma;
in vec4 color;
out vec4 v_color;
out float v_sigma;
out float v_radius;
void main()
{
gl_Position = skyClipPosition(position);
// σ is in CSS pixels; the sprite holds ±3σ plus a pixel of margin.
v_sigma = sigma * czm_pixelRatio;
v_radius = ceil(3.0 * v_sigma) + 1.0;
gl_PointSize = 2.0 * v_radius;
v_color = color;
}
`;
const STAR_FS = `
uniform float u_intensity;
in vec4 v_color;
in float v_sigma;
in float v_radius;
void main()
{
vec2 offset = (gl_PointCoord - 0.5) * (2.0 * v_radius);
float alpha = v_color.a * u_intensity *
exp(-0.5 * dot(offset, offset) / (v_sigma * v_sigma));
if (alpha < 1.0 / 512.0) discard;
out_FragColor = vec4(czm_gammaCorrect(v_color.rgb) * alpha, alpha);
}
`;
/** Pick the ICRF→fixed rotation for `time`, or TEME while tables load. */
export function computeSkyRotation(
time,
result,
transforms = Cesium.Transforms,
) {
const icrf = transforms.computeIcrfToFixedMatrix(time, result);
if (icrf) return { matrix: icrf, precise: true };
return {
matrix: transforms.computeTemeToPseudoFixedMatrix(time, result),
precise: false,
};
}
/** Whether a cached rotation at `cachedTime` still serves `time`. */
export function isSkyRotationCurrent(cachedTime, time) {
return (
cachedTime !== undefined &&
Math.abs(Cesium.JulianDate.secondsDifference(time, cachedTime)) <
SKY_ROTATION_EPSILON_SECONDS
);
}
export class StarrySky {
/**
* @param {object} [options]
* @param {() => void} [options.requestRender] renders one frame when an
* asset or the precise rotation arrives; the idle governor by default.
*/
constructor({
requestRender = () => governorRequestRender('sky-assets'),
} = {}) {
/** Matches `SkyBox#show`. */
this.show = true;
this.milkyWayIntensity = MILKY_WAY_INTENSITY;
this.starIntensity = STAR_INTENSITY;
this._requestRender = requestRender;
this._rotation = Cesium.Matrix3.clone(Cesium.Matrix3.IDENTITY);
this._rotationTime = undefined;
this._rotationPrecise = false;
this._icrfRequest = undefined;
this._icrfUnavailable = false;
this._loading = false;
this._starData = undefined;
this._faceImages = undefined;
this._cubeMap = undefined;
this._milkyWayCommand = undefined;
this._milkyWayAttributes = undefined;
this._starCommand = undefined;
this._useHdr = undefined;
this._destroyed = false;
const uniforms = {
u_icrfToFixed: () => this._rotation,
};
this._milkyWayUniforms = {
...uniforms,
u_milkyWay: () => this._cubeMap,
u_intensity: () => this.milkyWayIntensity,
};
this._starUniforms = {
...uniforms,
u_intensity: () => this.starIntensity,
};
const commands = [];
this._commands = commands;
// The environment pass reads `derivedCommands` (log depth, HDR, picking)
// before calling `execute`; none apply to a far-plane background.
this._compositeCommand = {
derivedCommands: {},
execute(context, passState) {
for (const command of commands) command.execute(context, passState);
},
};
}
/** Start loading assets; failures leave a plain black sky. */
_load() {
if (this._loading) return;
this._loading = true;
loadBundledJson(STAR_PACK_URL)
.then((pack) => {
if (this._destroyed) return;
this._starData = buildStarVertices(pack);
this._requestRender();
})
.catch((error) =>
console.warn('[sky] star catalogue unavailable', error),
);
const faces = Object.entries(MILKY_WAY_FACE_URLS);
Promise.all(
faces.map(([, url]) => Cesium.Resource.fetchImage({ url: url.href })),
)
.then((images) => {
if (this._destroyed) return;
this._faceImages = Object.fromEntries(
faces.map(([face], index) => [face, images[index]]),
);
this._requestRender();
})
.catch((error) => console.warn('[sky] Milky Way unavailable', error));
}
_updateRotation(time) {
// An approximate rotation is final only while nothing better can come.
const settled =
this._rotationPrecise || this._icrfRequest || this._icrfUnavailable;
if (settled && isSkyRotationCurrent(this._rotationTime, time)) return;
const { precise } = computeSkyRotation(time, this._rotation);
this._rotationPrecise = precise;
this._rotationTime = Cesium.JulianDate.clone(time, this._rotationTime);
if (!precise) this._preloadIcrf(time);
}
/** Fetch Cesium's IAU 2006 tables around `time` once; retry on new spans. */
_preloadIcrf(time) {
if (this._icrfRequest || this._icrfUnavailable) return;
const interval = new Cesium.TimeInterval({
start: Cesium.JulianDate.addDays(time, -1, new Cesium.JulianDate()),
stop: Cesium.JulianDate.addDays(time, 1, new Cesium.JulianDate()),
});
this._icrfRequest = Promise.resolve(
Cesium.Transforms.preloadIcrfFixed(interval),
)
.then(
() => {
this._rotationTime = undefined;
},
(error) => {
// TEME is within a fraction of a degree; do not retry every frame.
this._icrfUnavailable = true;
console.warn('[sky] ICRF tables unavailable, using TEME', error);
},
)
.finally(() => {
this._icrfRequest = undefined;
if (!this._destroyed) this._requestRender();
});
}
_createMilkyWayCommand(context) {
const cubeMap = new Cesium.CubeMap({
context,
source: this._faceImages,
flipY: false,
});
cubeMap.generateMipmap();
cubeMap.sampler = new Cesium.Sampler({
minificationFilter: Cesium.TextureMinificationFilter.LINEAR_MIPMAP_LINEAR,
});
this._cubeMap = cubeMap;
this._faceImages = undefined;
const geometry = Cesium.BoxGeometry.createGeometry(
Cesium.BoxGeometry.fromDimensions({
dimensions: new Cesium.Cartesian3(2, 2, 2),
vertexFormat: Cesium.VertexFormat.POSITION_ONLY,
}),
);
const attributeLocations =
Cesium.GeometryPipeline.createAttributeLocations(geometry);
this._milkyWayCommand = new Cesium.DrawCommand({
owner: this,
vertexArray: Cesium.VertexArray.fromGeometry({
context,
geometry,
attributeLocations,
bufferUsage: Cesium.BufferUsage.STATIC_DRAW,
}),
renderState: Cesium.RenderState.fromCache({}),
uniformMap: this._milkyWayUniforms,
});
this._milkyWayAttributes = attributeLocations;
}
_createStarCommand(context) {
const { count, vertices } = this._starData;
this._starData = undefined;
if (count === 0) return;
const vertexBuffer = Cesium.Buffer.createVertexBuffer({
context,
typedArray: vertices,
usage: Cesium.BufferUsage.STATIC_DRAW,
});
const stride = STAR_VERTEX_FLOATS * Float32Array.BYTES_PER_ELEMENT;
const attribute = (index, components, offsetFloats) => ({
index,
vertexBuffer,
componentsPerAttribute: components,
componentDatatype: Cesium.ComponentDatatype.FLOAT,
offsetInBytes: offsetFloats * Float32Array.BYTES_PER_ELEMENT,
strideInBytes: stride,
});
this._starCommand = new Cesium.DrawCommand({
owner: this,
primitiveType: Cesium.PrimitiveType.POINTS,
count,
vertexArray: new Cesium.VertexArray({
context,
attributes: [
attribute(0, 3, 0),
attribute(1, 1, 3),
attribute(2, 4, 4),
],
}),
// Premultiplied additive: starlight adds to the Milky Way glow and
// leaves the framebuffer alpha alone.
renderState: Cesium.RenderState.fromCache({
blending: {
enabled: true,
equationRgb: Cesium.BlendEquation.ADD,
equationAlpha: Cesium.BlendEquation.ADD,
functionSourceRgb: Cesium.BlendFunction.ONE,
functionDestinationRgb: Cesium.BlendFunction.ONE,
functionSourceAlpha: Cesium.BlendFunction.ZERO,
functionDestinationAlpha: Cesium.BlendFunction.ONE,
},
}),
uniformMap: this._starUniforms,
});
}
_updateShaders(context, useHdr) {
if (this._useHdr === useHdr) return;
this._useHdr = useHdr;
const defines = useHdr ? ['HDR'] : [];
for (const [command, vs, fs, attributeLocations] of [
[
this._milkyWayCommand,
MILKY_WAY_VS,
MILKY_WAY_FS,
this._milkyWayAttributes,
],
[
this._starCommand,
STAR_VS,
STAR_FS,
{ position: 0, sigma: 1, color: 2 },
],
]) {
if (!command) continue;
command.shaderProgram?.destroy();
command.shaderProgram = Cesium.ShaderProgram.fromCache({
context,
vertexShaderSource: vs,
fragmentShaderSource: new Cesium.ShaderSource({
defines,
sources: [fs],
}),
attributeLocations,
});
}
}
/** `Scene#skyBox` contract: the command to draw this frame, if any. */
update(frameState, useHdr) {
if (!this.show) return undefined;
const { mode, passes, context } = frameState;
if (mode !== Cesium.SceneMode.SCENE3D) return undefined;
if (!passes.render) return undefined;
this._load();
let rebuilt = false;
if (this._faceImages && !this._milkyWayCommand) {
this._createMilkyWayCommand(context);
rebuilt = true;
}
if (this._starData && !this._starCommand) {
this._createStarCommand(context);
rebuilt = true;
}
if (rebuilt) this._useHdr = undefined;
this._updateShaders(context, Boolean(useHdr));
this._commands.length = 0;
if (this._milkyWayCommand) this._commands.push(this._milkyWayCommand);
if (this._starCommand) this._commands.push(this._starCommand);
if (this._commands.length === 0) return undefined;
this._updateRotation(frameState.time);
return this._compositeCommand;
}
isDestroyed() {
return this._destroyed;
}
destroy() {
for (const command of [this._milkyWayCommand, this._starCommand]) {
if (!command) continue;
command.vertexArray?.destroy();
command.shaderProgram?.destroy();
}
this._cubeMap = this._cubeMap?.destroy();
this._milkyWayCommand = undefined;
this._starCommand = undefined;
this._commands.length = 0;
this._destroyed = true;
return undefined;
}
}
+89
View File
@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
STARRY_SKY_CESIUM_VERSION,
SKY_ROTATION_EPSILON_SECONDS,
StarrySky,
computeSkyRotation,
isSkyRotationCurrent,
} from './starrySky.js';
test('the sky-box adapter was validated against this engine version', () => {
assert.equal(
Cesium.VERSION,
STARRY_SKY_CESIUM_VERSION,
'engine upgrades require revalidating the Scene#skyBox contract',
);
});
test('the ICRF rotation is preferred and TEME fills in while tables load', () => {
const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
const icrf = Cesium.Matrix3.fromRotationZ(0.3);
const teme = Cesium.Matrix3.fromRotationZ(0.2);
const precise = computeSkyRotation(time, new Cesium.Matrix3(), {
computeIcrfToFixedMatrix: (_, result) => Cesium.Matrix3.clone(icrf, result),
computeTemeToPseudoFixedMatrix: () => assert.fail('TEME not needed'),
});
assert.equal(precise.precise, true);
assert.ok(Cesium.Matrix3.equals(precise.matrix, icrf));
const approximate = computeSkyRotation(time, new Cesium.Matrix3(), {
computeIcrfToFixedMatrix: () => undefined,
computeTemeToPseudoFixedMatrix: (_, result) =>
Cesium.Matrix3.clone(teme, result),
});
assert.equal(approximate.precise, false);
assert.ok(Cesium.Matrix3.equals(approximate.matrix, teme));
});
test('the TEME fallback keeps the celestial pole on the Earth axis', () => {
// TEME only spins about Z; ICRF differs from it by precession (~0.36° by
// 2026), so the fallback sky is right up to a fraction of a degree.
const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
const teme = Cesium.Transforms.computeTemeToPseudoFixedMatrix(time);
const pole = Cesium.Matrix3.multiplyByVector(
teme,
Cesium.Cartesian3.UNIT_Z,
new Cesium.Cartesian3(),
);
assert.ok(Math.abs(pole.z - 1) < 1e-9);
});
test('the rotation is reused only within the clock epsilon', () => {
const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
const later = (seconds) =>
Cesium.JulianDate.addSeconds(time, seconds, new Cesium.JulianDate());
assert.equal(isSkyRotationCurrent(undefined, time), false);
assert.equal(isSkyRotationCurrent(time, later(0.5)), true);
assert.equal(isSkyRotationCurrent(time, later(-0.5)), true);
assert.equal(
isSkyRotationCurrent(time, later(SKY_ROTATION_EPSILON_SECONDS)),
false,
);
});
test('the sky draws nothing when hidden, outside 3D, or in a pick pass', () => {
const sky = new StarrySky({ requestRender: () => {} });
const frameState = (overrides) => ({
mode: Cesium.SceneMode.SCENE3D,
passes: { render: true },
...overrides,
});
sky._loading = true; // keep the test off the network
sky.show = false;
assert.equal(sky.update(frameState()), undefined);
sky.show = true;
assert.equal(
sky.update(frameState({ mode: Cesium.SceneMode.SCENE2D })),
undefined,
);
assert.equal(
sky.update(frameState({ passes: { render: false, pick: true } })),
undefined,
);
// Nothing has loaded yet, so even a render pass has no command.
assert.equal(sky.update(frameState()), undefined);
assert.equal(sky.isDestroyed(), false);
assert.equal(sky.destroy(), undefined);
assert.equal(sky.isDestroyed(), true);
});
+146
View File
@@ -0,0 +1,146 @@
/**
* Bright-star appearance: catalogue pack → GPU vertex data.
*
* Pure functions, no Cesium. The pack is `local_data/sky/bright_stars.json`
* (Yale Bright Star Catalogue, built by scripts/build-bright-stars.mjs): a
* flat `[raDeg, decDeg, vmag, bv, …]` array in ICRF/J2000.
*
* Every star is drawn as a point sprite holding a small Gaussian. Its width
* is set in CSS pixels and scaled by the device pixel ratio in the shader, so
* a star covers the same screen area at any DPR and stays a crisp dot rather
* than a texel stretched across several pixels.
*
* @module sky/stars
*/
/** Faintest catalogue magnitude; also the reference for brightness. */
export const STAR_LIMIT_MAGNITUDE = 6.5;
/** Gaussian σ of the faintest star, CSS pixels. */
export const STAR_MIN_SIGMA_PX = 0.55;
/** Gaussian σ cap for the brightest star, CSS pixels. */
export const STAR_MAX_SIGMA_PX = 1.35;
/** Peak opacity of the faintest star. */
export const STAR_MIN_ALPHA = 0.22;
/** B−V for stars the catalogue gives no colour (a solar-type white). */
export const STAR_DEFAULT_BV = 0.6;
/** Floats per star in the vertex buffer: xyz, σ, rgb, alpha. */
export const STAR_VERTEX_FLOATS = 8;
const DEG = Math.PI / 180;
// Fraction of the colour kept: the eye sees all but the most extreme stars
// as nearly white, and saturated dots read as UI markers on the globe.
const COLOR_SATURATION = 0.55;
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
/** ICRF unit vector for J2000 right ascension and declination in degrees. */
export function raDecToUnitVector(raDeg, decDeg) {
const ra = raDeg * DEG;
const dec = decDeg * DEG;
const cosDec = Math.cos(dec);
return [cosDec * Math.cos(ra), cosDec * Math.sin(ra), Math.sin(dec)];
}
/**
* Perceived brightness relative to the limit magnitude. Flux grows 2.512×
* per magnitude; the square root compresses Sirius-to-6.5m (≈ 1600×) into a
* range a display can show without the bright stars turning into discs.
*/
export function starBrightness(vmag) {
const magnitude = Number.isFinite(vmag) ? vmag : STAR_LIMIT_MAGNITUDE;
return (
10 **
(-0.2 * (Math.min(magnitude, STAR_LIMIT_MAGNITUDE) - STAR_LIMIT_MAGNITUDE))
);
}
/**
* Sprite parameters for a visual magnitude: `sigmaPx` is the Gaussian σ in
* CSS pixels and `alpha` its peak opacity. Faint stars keep the minimum width
* and fade; bright stars saturate and grow slowly.
*/
export function starAppearance(vmag) {
const brightness = starBrightness(vmag);
return {
sigmaPx: clamp(
STAR_MIN_SIGMA_PX * brightness ** 0.3,
STAR_MIN_SIGMA_PX,
STAR_MAX_SIGMA_PX,
),
alpha: clamp(STAR_MIN_ALPHA * brightness, STAR_MIN_ALPHA, 1),
};
}
/** Effective temperature from B−V (Ballesteros 2012). */
export function bvToTemperature(bv) {
const index = clamp(Number.isFinite(bv) ? bv : STAR_DEFAULT_BV, -0.4, 2.0);
return 4600 * (1 / (0.92 * index + 1.7) + 1 / (0.92 * index + 0.62));
}
/** Black-body colour of a temperature, sRGB 0..1 (Helland's fit). */
export function temperatureToRgb(kelvin) {
const t = clamp(kelvin, 1000, 40000) / 100;
const red = t <= 66 ? 255 : 329.698727446 * (t - 60) ** -0.1332047592;
const green =
t <= 66
? 99.4708025861 * Math.log(t) - 161.1195681661
: 288.1221695283 * (t - 60) ** -0.0755148492;
const blue =
t >= 66
? 255
: t <= 19
? 0
: 138.5177312231 * Math.log(t - 10) - 305.0447927307;
return [red, green, blue].map((channel) => clamp(channel, 0, 255) / 255);
}
/**
* Display colour for a B−V index: the black-body hue, desaturated towards
* white and normalized so the brightest channel is 1 (brightness belongs to
* the magnitude alone).
*/
export function bvToRgb(bv) {
const rgb = temperatureToRgb(bvToTemperature(bv)).map(
(channel) => 1 - COLOR_SATURATION + COLOR_SATURATION * channel,
);
const peak = Math.max(...rgb);
return rgb.map((channel) => channel / peak);
}
/**
* Decode a catalogue pack into interleaved vertex data, `STAR_VERTEX_FLOATS`
* per star. Malformed rows are skipped, never thrown on: a damaged pack must
* not take the globe down with it.
*/
export function buildStarVertices(pack) {
const stars = Array.isArray(pack?.stars) ? pack.stars : [];
const count = Math.floor(stars.length / 4);
const vertices = new Float32Array(count * STAR_VERTEX_FLOATS);
let written = 0;
for (let i = 0; i < count; i++) {
const [raDeg, decDeg, vmag, bv] = stars.slice(i * 4, i * 4 + 4);
if (
!Number.isFinite(raDeg) ||
!Number.isFinite(decDeg) ||
!Number.isFinite(vmag)
)
continue;
const [x, y, z] = raDecToUnitVector(raDeg, decDeg);
const { sigmaPx, alpha } = starAppearance(vmag);
const [r, g, b] = bvToRgb(bv);
vertices.set(
[x, y, z, sigmaPx, r, g, b, alpha],
written * STAR_VERTEX_FLOATS,
);
written += 1;
}
return {
count: written,
vertices:
written === count
? vertices
: vertices.slice(0, written * STAR_VERTEX_FLOATS),
};
}
+134
View File
@@ -0,0 +1,134 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, statSync } from 'node:fs';
import {
STAR_LIMIT_MAGNITUDE,
STAR_MAX_SIGMA_PX,
STAR_MIN_ALPHA,
STAR_MIN_SIGMA_PX,
STAR_VERTEX_FLOATS,
buildStarVertices,
bvToRgb,
bvToTemperature,
raDecToUnitVector,
starAppearance,
starBrightness,
} from './stars.js';
const close = (actual, expected, epsilon = 1e-9) =>
assert.ok(
Math.abs(actual - expected) <= epsilon,
`${actual} is not within ${epsilon} of ${expected}`,
);
const skyFile = (name) =>
new URL(`../data/local_data/sky/${name}`, import.meta.url);
test('right ascension and declination map onto the ICRF axes', () => {
const equinox = raDecToUnitVector(0, 0);
close(equinox[0], 1);
close(equinox[1], 0);
close(equinox[2], 0);
const sixHours = raDecToUnitVector(90, 0);
close(sixHours[0], 0);
close(sixHours[1], 1);
const pole = raDecToUnitVector(123, 90);
close(pole[2], 1);
const south = raDecToUnitVector(200, -30);
close(Math.hypot(...south), 1);
close(south[2], -0.5);
});
test('brightness grows with each magnitude and stops at the catalogue limit', () => {
close(starBrightness(STAR_LIMIT_MAGNITUDE), 1);
close(starBrightness(STAR_LIMIT_MAGNITUDE + 1.5), 1);
close(starBrightness(STAR_LIMIT_MAGNITUDE - 5), 10);
close(starBrightness(Number.NaN), 1);
});
test('brighter stars are never smaller or fainter, and both ends clamp', () => {
let previous = starAppearance(8);
assert.deepEqual(previous, {
sigmaPx: STAR_MIN_SIGMA_PX,
alpha: STAR_MIN_ALPHA,
});
for (let vmag = 7.5; vmag >= -1.5; vmag -= 0.25) {
const next = starAppearance(vmag);
assert.ok(next.sigmaPx >= previous.sigmaPx, `σ shrank at ${vmag}`);
assert.ok(next.alpha >= previous.alpha, `alpha dropped at ${vmag}`);
previous = next;
}
const sirius = starAppearance(-1.46);
assert.equal(sirius.alpha, 1);
assert.ok(sirius.sigmaPx <= STAR_MAX_SIGMA_PX);
assert.ok(sirius.sigmaPx > starAppearance(2).sigmaPx);
// Sprites stay point-like: ±3σ of the brightest star is a few pixels.
assert.ok(6 * STAR_MAX_SIGMA_PX < 10);
});
test('B−V colours run from blue-white to orange with the peak channel at 1', () => {
close(bvToTemperature(0.65), 5_800, 120);
close(bvToTemperature(0), 10_100, 200);
const hot = bvToRgb(-0.3);
const sunLike = bvToRgb(0.65);
const cool = bvToRgb(1.6);
for (const rgb of [hot, sunLike, cool]) {
close(Math.max(...rgb), 1);
assert.ok(Math.min(...rgb) > 0.5, 'colours stay desaturated');
}
assert.ok(hot[2] > hot[0], 'hot stars are blue');
assert.ok(cool[0] > cool[2], 'cool stars are orange');
assert.deepEqual(bvToRgb(null), bvToRgb(0.6));
assert.deepEqual(bvToRgb(9), bvToRgb(2));
});
test('vertex data is interleaved per star and skips malformed rows', () => {
const { count, vertices } = buildStarVertices({
stars: [0, 0, 1, 0.5, 'bad', 10, 2, 0, 90, 45, 3, null, 10, 20],
});
assert.equal(count, 2);
assert.equal(vertices.length, 2 * STAR_VERTEX_FLOATS);
assert.deepEqual(Array.from(vertices.subarray(0, 3)), [1, 0, 0]);
close(vertices[STAR_VERTEX_FLOATS + 1], Math.SQRT1_2, 1e-6);
close(vertices[STAR_VERTEX_FLOATS + 2], Math.SQRT1_2, 1e-6);
close(vertices[3], starAppearance(1).sigmaPx, 1e-6);
close(vertices[7], starAppearance(1).alpha, 1e-6);
assert.deepEqual(buildStarVertices(null), {
count: 0,
vertices: new Float32Array(0),
});
});
test('the committed catalogue holds the Yale Bright Star Catalogue', () => {
const pack = JSON.parse(readFileSync(skyFile('bright_stars.json'), 'utf8'));
assert.deepEqual(pack.meta.columns, ['raDeg', 'decDeg', 'vmag', 'bv']);
assert.equal(pack.stars.length, pack.meta.count * 4);
assert.equal(pack.meta.count, 9096);
let brightest = 0;
for (let i = 0; i < pack.stars.length; i += 4) {
const [ra, dec, vmag] = pack.stars.slice(i, i + 4);
assert.ok(ra >= 0 && ra < 360 && dec >= -90 && dec <= 90);
assert.ok(Number.isFinite(vmag));
if (vmag < pack.stars[brightest + 2]) brightest = i;
}
// Sirius, α CMa: 06h45m08.9s −16°42′58″, V = −1.46.
assert.deepEqual(
pack.stars.slice(brightest, brightest + 4),
[101.287, -16.716, -1.46, 0],
);
assert.equal(buildStarVertices(pack).count, pack.meta.count);
});
test('sky assets stay inside the 5 MB budget', () => {
const files = [
'bright_stars.json',
...['px', 'nx', 'py', 'ny', 'pz', 'nz'].map(
(face) => `milkyway_${face}.webp`,
),
];
const total = files.reduce(
(sum, name) => sum + statSync(skyFile(name)).size,
0,
);
assert.ok(total < 5 * 1024 * 1024, `${total} bytes`);
});
+107
View File
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildStarPack,
parseBscRecord,
} from '../../scripts/build-bright-stars.mjs';
import {
CUBE_FACES,
cubeFaceDirection,
directionToPlateCarree,
icrfToGalactic,
} from '../../scripts/build-milky-way.mjs';
import { raDecToUnitVector } from '../sky/stars.js';
// Verbatim CDS V/50 records: HR 1, Sirius (HR 2491) and a nova with no J2000
// position. Columns matter, so the padding is part of the fixture.
const HR_1 =
' 1 BD+44 4550 3 36042 46 000001.1+444022000509.9+451345114.44-16.88 6.70 +0.07 +0.08 A1Vn -0.012-0.018 -018 195 4.2 21.6AC 3';
const SIRIUS =
'2491 9Alp CMaBD-16 1591 48915151881 257I 5423 064044.6-163444064508.9-164258227.22-08.88-1.46 0.00 -0.05 -0.03 A1Vm -0.553-1.205 +.375-008SBO 13 10.3 11.2AB 4*';
const NOVA =
' 92 NOVA 1572 B Cas *';
const close = (actual, expected, epsilon = 1e-9) =>
assert.ok(
Math.abs(actual - expected) <= epsilon,
`${actual} is not within ${epsilon} of ${expected}`,
);
test('BSC5 records parse J2000 position, V magnitude and B−V', () => {
const sirius = parseBscRecord(SIRIUS);
assert.equal(sirius.hr, 2491);
close(sirius.raDeg, (6 + 45 / 60 + 8.9 / 3600) * 15);
close(sirius.decDeg, -(16 + 42 / 60 + 58 / 3600));
assert.equal(sirius.vmag, -1.46);
assert.equal(sirius.bv, 0);
const first = parseBscRecord(HR_1);
close(first.raDeg, (5 / 60 + 9.9 / 3600) * 15);
close(first.decDeg, 45 + 13 / 60 + 45 / 3600);
assert.equal(first.vmag, 6.7);
assert.equal(first.bv, 0.07);
assert.equal(parseBscRecord(NOVA), null);
});
test('the star pack is a flat, rounded column array that counts its drops', () => {
const pack = buildStarPack([HR_1, NOVA, SIRIUS, ''].join('\n'), {
fetched: '2026-10-19',
});
assert.deepEqual(
pack.stars,
[1.291, 45.229, 6.7, 0.07, 101.287, -16.716, -1.46, 0],
);
assert.equal(pack.meta.count, 2);
assert.equal(pack.meta.dropped, 1);
assert.equal(pack.meta.fetched, '2026-10-19');
});
test('cube faces use the WebGL major-axis layout', () => {
// Each face centre looks down its own axis …
const axes = {
positiveX: [1, 0, 0],
negativeX: [-1, 0, 0],
positiveY: [0, 1, 0],
negativeY: [0, -1, 0],
positiveZ: [0, 0, 1],
negativeZ: [0, 0, -1],
};
for (const face of CUBE_FACES)
assert.deepEqual(
cubeFaceDirection(face, 0.5, 0.5).map((v) => v + 0),
axes[face],
);
// … and the first texel row and column follow OpenGL ES 3.0 §3.8.10:
// on +X, s grows towards −Z and t towards −Y.
assert.deepEqual(cubeFaceDirection('positiveX', 0, 0), [1, 1, 1]);
assert.deepEqual(cubeFaceDirection('positiveZ', 1, 1), [1, -1, 1]);
assert.deepEqual(cubeFaceDirection('negativeY', 0, 1), [-1, -1, -1]);
assert.throws(() => cubeFaceDirection('up', 0, 0), RangeError);
});
test('plate carrée is centred on longitude 0, growing to the left, north up', () => {
assert.deepEqual(directionToPlateCarree(1, 0, 0), [0.5, 0.5]);
const sixHours = directionToPlateCarree(0, 1, 0);
close(sixHours[0], 0.25);
const eighteenHours = directionToPlateCarree(0, -1, 0);
close(eighteenHours[0], 0.75);
close(directionToPlateCarree(0, 0, 1)[1], 0);
close(directionToPlateCarree(0, 0, -1)[1], 1);
const twelveHours = directionToPlateCarree(-1, 0, 0);
assert.ok(twelveHours[0] === 0 || twelveHours[0] === 1);
});
test('the galactic rotation puts the Galactic Centre and pole where the IAU does', () => {
// Sgr A*-adjacent origin l = b = 0 at RA 266.405°, Dec −28.936° (J2000).
const centre = icrfToGalactic(raDecToUnitVector(266.40499, -28.93617));
close(centre[0], 1, 1e-6);
// North galactic pole at RA 192.85948°, Dec +27.12825°.
const pole = icrfToGalactic(raDecToUnitVector(192.85948, 27.12825));
close(pole[2], 1, 1e-6);
// ESO's panorama: the Large Magellanic Cloud (l ≈ 280.5°, b ≈ −32.9°) sits
// right of and below the centre.
const [u, v] = directionToPlateCarree(
...icrfToGalactic(raDecToUnitVector(80.894, -69.756)),
);
close(u, 0.5 + (360 - 280.47) / 360, 2e-3);
close(v, 0.5 + 32.89 / 180, 2e-3);
});