diff --git a/scripts/build-milky-way.mjs b/scripts/build-milky-way.mjs index 4a482631d..19b2ea1e9 100644 --- a/scripts/build-milky-way.mjs +++ b/scripts/build-milky-way.mjs @@ -15,12 +15,12 @@ * * Transform (deterministic): * 1. Resample to WORK_WIDTH×WORK_WIDTH/2 and convert sRGB to linear light. - * 2. Paint out the planets the 2008–2010 exposures caught (PLANETS): they - * sit where today's sky has nothing and are far brighter than any star. - * 3. Remove stars with a morphological opening (minimum, then maximum, over + * 2. Remove stars with a morphological opening (minimum, then maximum, over * a (2·OPEN_RADIUS+1)² window): anything smaller than the window — every * star — drops to the surrounding sky; the renderer draws catalogue stars * itself, sharp and in the right place. + * 3. Paint out the planets the 2008–2010 exposures caught (PLANETS): they + * sit where today's sky has nothing and are far brighter than any star. * 4. Blur with a three-pass box approximation of a Gaussian, σ = BLUR_DEG on * the sky: rows wrap in longitude and widen by 1/cos(latitude) so the * blur stays round towards the poles. @@ -234,16 +234,18 @@ function galacticVector(l, b) { } /** - * Replace a disc of the sky with the mean of the ring just outside it, per - * channel. The blur that follows hides the seam. + * Replace a disc of the sky with the median of the ring just outside it, per + * channel. A median, not a mean: the planet's diffraction spikes reach far + * past the disc and would lift a mean well above the sky. The blur that + * follows hides the seam. */ function paintOut(planes, width, height, { l, b, radiusDeg }) { const centre = galacticVector(l, b); const inner = Math.cos((radiusDeg * Math.PI) / 180); - const outer = Math.cos(((radiusDeg + 0.5) * Math.PI) / 180); + const outer = Math.cos(((radiusDeg + 1) * Math.PI) / 180); const disc = []; const ring = []; - const span = Math.ceil(((radiusDeg + 0.5) / 180) * height); + const span = Math.ceil(((radiusDeg + 1) / 180) * height); const row = Math.round((0.5 - b / 180) * height); for (let y = Math.max(0, row - span); y < Math.min(height, row + span); y++) { const latitude = 90 - ((y + 0.5) * 180) / height; @@ -256,10 +258,9 @@ function paintOut(planes, width, height, { l, b, radiusDeg }) { } } for (const plane of planes) { - let sum = 0; - for (const index of ring) sum += plane[index]; - const mean = sum / ring.length; - for (const index of disc) plane[index] = mean; + const values = Float32Array.from(ring, (index) => plane[index]).sort(); + const median = values[values.length >> 1]; + for (const index of disc) plane[index] = median; } } @@ -323,12 +324,14 @@ async function main() { const { default: sharp } = await import('sharp'); const { width, height, planes } = await decodePanorama(sharp, file); const sigmaPx = (BLUR_DEG / 180) * height; - for (const planet of PLANETS) paintOut(planes, width, height, planet); for (const plane of planes) { extremumPlane(plane, width, height, OPEN_RADIUS, Math.min); extremumPlane(plane, width, height, OPEN_RADIUS, Math.max); - blurPlane(plane, width, height, sigmaPx); } + // After the opening: it sinks the starry sky to the dark gaps between + // stars, and a fill taken before it would stay brighter than the sky. + for (const planet of PLANETS) paintOut(planes, width, height, planet); + for (const plane of planes) blurPlane(plane, width, height, sigmaPx); const black = luminancePercentile(planes, BLACK_PERCENTILE); const white = luminancePercentile(planes, WHITE_PERCENTILE); let total = 0; diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 9024224fe..fe15d0ea0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1067,5 +1067,6 @@ "scripts/build-milky-way.mjs", "src/sky/stars.test.mjs", "src/sky/starrySky.test.mjs", - "src/tooling/skyAssets.test.mjs" + "src/tooling/skyAssets.test.mjs", + "src/tooling/skyAssets.paintOut.test.mjs" ] diff --git a/scripts/qa-sky-sun-moon.mjs b/scripts/qa-sky-sun-moon.mjs new file mode 100644 index 000000000..12ec7e780 --- /dev/null +++ b/scripts/qa-sky-sun-moon.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +/** + * qa-sky-sun-moon — the custom sky must keep Cesium's Sun and Moon. + * + * CesiumWidget creates `scene.sun` and `scene.moon` only when the `skyBox` + * option is not `false`, so replacing the sky box the wrong way silently + * removes the Sun disc and its bloom. Points the camera at the Sun from + * 22 000 km and fails unless both bodies exist; saves a screenshot. + * + * Usage: node scripts/qa-sky-sun-moon.mjs [--url http://localhost:4317] [--out qa-shots/sky-sun.png] + */ +import puppeteer from 'puppeteer'; +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; + +const argv = process.argv; +const option = (name, fallback) => + argv.includes(name) ? argv[argv.indexOf(name) + 1] : fallback; +const url = option('--url', 'http://localhost:4317'); +const out = option('--out', 'qa-shots/sky-sun.png'); +mkdirSync(path.dirname(out), { recursive: true }); + +const browser = await puppeteer.launch({ + headless: 'new', + args: ['--no-sandbox', '--ignore-gpu-blocklist'], +}); +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + await page.evaluateOnNewDocument(() => + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'), + ); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, { + timeout: 90_000, + }); + await new Promise((r) => setTimeout(r, 10_000)); + const info = await page.evaluate(async () => { + // The page's own Cesium instance, as Vite served it. + const entry = performance + .getEntriesByType('resource') + .map((e) => e.name) + .find((n) => /\.vite\/deps\/cesium\.js/.test(n)); + const C = await import(entry); + const { viewer } = window.__godsEyeView; + const scene = viewer.scene; + scene.camera.cancelFlight(); + const time = viewer.clock.currentTime; + const sun = C.Cartesian3.normalize( + C.Matrix3.multiplyByVector( + C.Transforms.computeTemeToPseudoFixedMatrix(time), + C.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame( + time, + ), + new C.Cartesian3(), + ), + new C.Cartesian3(), + ); + // Off to the side of the Earth, looking straight at the Sun. + const side = C.Cartesian3.normalize( + C.Cartesian3.cross(sun, C.Cartesian3.UNIT_Z, new C.Cartesian3()), + new C.Cartesian3(), + ); + const destination = C.Cartesian3.add( + C.Cartesian3.multiplyByScalar(side, 2.2e7, new C.Cartesian3()), + C.Cartesian3.multiplyByScalar(sun, -0.6e7, new C.Cartesian3()), + new C.Cartesian3(), + ); + const up = C.Cartesian3.normalize( + C.Cartesian3.cross( + C.Cartesian3.cross(sun, C.Cartesian3.UNIT_Z, new C.Cartesian3()), + sun, + new C.Cartesian3(), + ), + new C.Cartesian3(), + ); + scene.camera.setView({ destination, orientation: { direction: sun, up } }); + scene.requestRender(); + return { + sun: Boolean(scene.sun), + moon: Boolean(scene.moon), + skyBox: scene.skyBox?.constructor?.name, + }; + }); + await new Promise((r) => setTimeout(r, 4_000)); + await page.evaluate(() => window.__godsEyeView.viewer.scene.requestRender()); + await new Promise((r) => setTimeout(r, 1_500)); + await page.screenshot({ path: out }); + console.log(JSON.stringify({ url, ...info, screenshot: out })); + if (!info.sun || !info.moon) { + console.error('FAIL: scene.sun and scene.moon must both exist'); + process.exitCode = 1; + } +} finally { + await browser.close(); +} diff --git a/src/app/viewer.js b/src/app/viewer.js index 582ecd9c5..a09836775 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -120,9 +120,10 @@ 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, + // Instead of the default Tycho-2 cube map, whose JPEG blocks show around + // the whole globe. Passed here, not assigned later: `skyBox: false` would + // also stop CesiumWidget from creating the Sun and the Moon. + skyBox: new StarrySky(), creditContainer, msaaSamples: 4, contextOptions: { webgl: { preserveDrawingBuffer: true } }, @@ -134,7 +135,6 @@ 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; diff --git a/src/data/local_data/sky/README.md b/src/data/local_data/sky/README.md index 8c2d4a0d7..e224e7bb0 100644 --- a/src/data/local_data/sky/README.md +++ b/src/data/local_data/sky/README.md @@ -42,10 +42,11 @@ Centre. (). **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: +4096×2048, stars removed with a 7×7 morphological opening, two planets +(Jupiter, by position) painted over with the median of the surrounding sky, +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 `. The panorama is a photographic mosaic, not an astrometric map: bright stars diff --git a/src/data/local_data/sky/milkyway_nx.webp b/src/data/local_data/sky/milkyway_nx.webp index 6c6ca774a..d74db8e52 100644 Binary files a/src/data/local_data/sky/milkyway_nx.webp and b/src/data/local_data/sky/milkyway_nx.webp differ diff --git a/src/data/local_data/sky/milkyway_ny.webp b/src/data/local_data/sky/milkyway_ny.webp index 8968f251f..bec3fb4d3 100644 Binary files a/src/data/local_data/sky/milkyway_ny.webp and b/src/data/local_data/sky/milkyway_ny.webp differ diff --git a/src/data/local_data/sky/milkyway_nz.webp b/src/data/local_data/sky/milkyway_nz.webp index 31cc013e4..de654a4f2 100644 Binary files a/src/data/local_data/sky/milkyway_nz.webp and b/src/data/local_data/sky/milkyway_nz.webp differ diff --git a/src/data/local_data/sky/milkyway_px.webp b/src/data/local_data/sky/milkyway_px.webp index f268499d0..3c0aead6f 100644 Binary files a/src/data/local_data/sky/milkyway_px.webp and b/src/data/local_data/sky/milkyway_px.webp differ diff --git a/src/data/local_data/sky/milkyway_py.webp b/src/data/local_data/sky/milkyway_py.webp index 946ed839a..444093007 100644 Binary files a/src/data/local_data/sky/milkyway_py.webp and b/src/data/local_data/sky/milkyway_py.webp differ diff --git a/src/data/local_data/sky/milkyway_pz.webp b/src/data/local_data/sky/milkyway_pz.webp index a541fec69..8fecc2d52 100644 Binary files a/src/data/local_data/sky/milkyway_pz.webp and b/src/data/local_data/sky/milkyway_pz.webp differ diff --git a/src/sky/starrySky.js b/src/sky/starrySky.js index daa24fabf..7cdc3b5d7 100644 --- a/src/sky/starrySky.js +++ b/src/sky/starrySky.js @@ -174,6 +174,7 @@ export class StarrySky { this._rotationPrecise = false; this._icrfRequest = undefined; this._icrfUnavailable = false; + this._icrfMissing = undefined; this._loading = false; this._starData = undefined; this._faceImages = undefined; @@ -237,7 +238,7 @@ export class StarrySky { _updateRotation(time) { // An approximate rotation is final only while nothing better can come. const settled = - this._rotationPrecise || this._icrfRequest || this._icrfUnavailable; + this._rotationPrecise || this._icrfRequest || !this._canPreloadIcrf(time); if (settled && isSkyRotationCurrent(this._rotationTime, time)) return; const { precise } = computeSkyRotation(time, this._rotation); this._rotationPrecise = precise; @@ -245,9 +246,20 @@ export class StarrySky { if (!precise) this._preloadIcrf(time); } - /** Fetch Cesium's IAU 2006 tables around `time` once; retry on new spans. */ + /** Whether loading Cesium's IAU 2006 tables could still help at `time`. */ + _canPreloadIcrf(time) { + return ( + !this._icrfUnavailable && + !( + this._icrfMissing && + Cesium.TimeInterval.contains(this._icrfMissing, time) + ) + ); + } + + /** Fetch Cesium's IAU 2006 tables around `time` once per span. */ _preloadIcrf(time) { - if (this._icrfRequest || this._icrfUnavailable) return; + if (this._icrfRequest || !this._canPreloadIcrf(time)) return; const interval = new Cesium.TimeInterval({ start: Cesium.JulianDate.addDays(time, -1, new Cesium.JulianDate()), stop: Cesium.JulianDate.addDays(time, 1, new Cesium.JulianDate()), @@ -257,7 +269,14 @@ export class StarrySky { ) .then( () => { + // The tables cover roughly 1975–2050; outside them the preload + // resolves with nothing, and TEME stays for this span. + if (!Cesium.Transforms.computeIcrfToFixedMatrix(time)) { + this._icrfMissing = interval; + return; + } this._rotationTime = undefined; + if (!this._destroyed) this._requestRender(); }, (error) => { // TEME is within a fraction of a degree; do not retry every frame. @@ -267,7 +286,6 @@ export class StarrySky { ) .finally(() => { this._icrfRequest = undefined; - if (!this._destroyed) this._requestRender(); }); } diff --git a/src/sky/starrySky.test.mjs b/src/sky/starrySky.test.mjs index 6575b1121..10d483914 100644 --- a/src/sky/starrySky.test.mjs +++ b/src/sky/starrySky.test.mjs @@ -87,3 +87,31 @@ test('the sky draws nothing when hidden, outside 3D, or in a pick pass', () => { assert.equal(sky.destroy(), undefined); assert.equal(sky.isDestroyed(), true); }); + +const frame = () => new Promise((resolve) => setImmediate(resolve)); + +for (const iso of ['1970-01-01T00:00:00Z', '2060-01-01T00:00:00Z']) { + test(`a clock outside the IAU 2006 tables (${iso}) settles on TEME instead of re-rendering every frame`, async () => { + const time = Cesium.JulianDate.fromIso8601(iso); + let renders = 0; + let preloads = 0; + const original = Cesium.Transforms.preloadIcrfFixed; + Cesium.Transforms.preloadIcrfFixed = (interval) => { + preloads += 1; + return original(interval); + }; + try { + const sky = new StarrySky({ requestRender: () => (renders += 1) }); + for (let i = 0; i < 20; i += 1) { + sky._updateRotation(time); // what update() does once per frame + await frame(); + } + assert.ok( + renders <= 1 && preloads <= 1, + `20 frames at a frozen clock -> ${preloads} preloads, ${renders} render requests (expected <= 1 each)`, + ); + } finally { + Cesium.Transforms.preloadIcrfFixed = original; + } + }); +} diff --git a/src/styles/anime.js b/src/styles/anime.js index deb79c355..26504830b 100644 --- a/src/styles/anime.js +++ b/src/styles/anime.js @@ -12,6 +12,7 @@ export const animeShader = { }, fragmentShader: /* glsl */ ` uniform sampler2D colorTexture; + uniform sampler2D depthTexture; uniform vec2 colorTextureDimensions; uniform float intensity; uniform float saturation; @@ -38,9 +39,12 @@ export const animeShader = { vec2 texel = 1.0 / colorTextureDimensions; vec4 color = texture(colorTexture, uv); - // Cel-shade: quantize luminance into bands + // Cel-shade: quantize luminance into bands. Not the far plane: four + // bands turn the faint Milky Way behind the globe into blotches. Its + // packed depth reads a few 1e-7 short of 1.0. float luma = dot(color.rgb, vec3(0.299, 0.587, 0.114)); - float bands = mix(256.0, 4.0, intensity); + bool space = czm_readDepth(depthTexture, uv) > 0.99999; + float bands = space ? 256.0 : mix(256.0, 4.0, intensity); float celLuma = floor(luma * bands + 0.5) / bands; float lumaScale = (luma > 0.001) ? celLuma / luma : 1.0; vec3 celColor = color.rgb * lumaScale; diff --git a/src/tooling/skyAssets.paintOut.test.mjs b/src/tooling/skyAssets.paintOut.test.mjs new file mode 100644 index 000000000..ddb57589a --- /dev/null +++ b/src/tooling/skyAssets.paintOut.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import sharp from 'sharp'; +import { + CUBE_FACES, + FACE_FILE_SUFFIX, + PLANETS, + cubeFaceDirection, + icrfToGalactic, +} from '../../scripts/build-milky-way.mjs'; +import { raDecToUnitVector } from '../sky/stars.js'; + +const SKY = new URL('../data/local_data/sky/', import.meta.url); +// Allowed excess of the painted disc over the surrounding sky, in 8-bit +// levels of the encoded face: the blur and dither move values by a few. +const TOLERANCE = 4; + +test('painted-out planets leave no disc brighter than the sky around them', async () => { + const centres = PLANETS.map((planet) => ({ + planet, + vector: raDecToUnitVector(planet.l, planet.b), + disc: [], + ring: [], + })); + for (const face of CUBE_FACES) { + const { data, info } = await sharp( + new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname, + ) + .raw() + .toBuffer({ resolveWithObject: true }); + const size = info.width; + for (let j = 0; j < size; j++) { + for (let i = 0; i < size; i++) { + const d = cubeFaceDirection(face, (i + 0.5) / size, (j + 0.5) / size); + const n = Math.hypot(...d); + const g = icrfToGalactic(d.map((x) => x / n)); + for (const centre of centres) { + const cos = + g[0] * centre.vector[0] + + g[1] * centre.vector[1] + + g[2] * centre.vector[2]; + const degrees = (Math.acos(Math.min(1, cos)) * 180) / Math.PI; + const k = (j * size + i) * info.channels; + const luma = (data[k] + data[k + 1] + data[k + 2]) / 3; + if (degrees <= centre.planet.radiusDeg - 0.5) centre.disc.push(luma); + else if ( + degrees >= centre.planet.radiusDeg + 1 && + degrees <= centre.planet.radiusDeg + 3.5 + ) + centre.ring.push(luma); + } + } + } + } + const median = (values) => values.sort((a, b) => a - b)[values.length >> 1]; + for (const { planet, disc, ring } of centres) { + const discMean = disc.reduce((s, v) => s + v, 0) / disc.length; + const ringMedian = median(ring); + assert.ok( + discMean <= ringMedian + TOLERANCE, + `l=${planet.l} b=${planet.b}: disc mean ${discMean.toFixed(1)} vs surrounding sky ${ringMedian.toFixed(1)}`, + ); + } +});