mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Away from the galactic plane the ESO panorama keeps a mottled glow of unresolved stars and dust. With the black level at the darkest quarter of the sky and a hard cut, it showed as grey clouds with black holes around the globe. The black level now sits at the median sky and eases in with a soft knee on luminance, keeping each pixel's hue: high galactic latitudes are black, the band keeps its glow. Faces shrink from 247 to 85 KB. The changelog fragment also notes the anime fade and the new asset weight. Co-Authored-By: Claude Opus 5.5 <[email protected]>
389 lines
14 KiB
JavaScript
389 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Build the soft Milky Way cube map in src/data/local_data/sky/ from the ESO
|
||
* all-sky Milky Way panorama.
|
||
*
|
||
* Source page: https://www.eso.org/public/images/eso0932a/
|
||
* Fetched: https://cdn.eso.org/images/original/eso0932a.tif
|
||
* (6000×3000 8-bit sRGB TIFF, 27.7 MB; sha256 in README.md)
|
||
* Credit: ESO/S. Brunier
|
||
* License: CC BY 4.0 (https://www.eso.org/public/outreach/copyright/).
|
||
* Modified: stars removed, blurred, reprojected to ICRF cube
|
||
* faces and tone-mapped, as below.
|
||
* Frame: plate carrée in galactic coordinates, centred on the Galactic
|
||
* Centre, galactic longitude increasing to the left.
|
||
*
|
||
* Transform (deterministic):
|
||
* 1. Resample to WORK_WIDTH×WORK_WIDTH/2 and convert sRGB to linear light.
|
||
* 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.
|
||
* 5. Resample into six FACE_SIZE² cube faces in the standard WebGL face
|
||
* orientation (the renderer uploads them without flipY), with the ICRF
|
||
* axes as the cube axes — +X to the March equinox, +Z to the north
|
||
* celestial pole — rotating each direction into galactic coordinates
|
||
* with the IAU J2000 matrix.
|
||
* 6. Map the median sky to black, easing in with a soft knee, and the
|
||
* brightest 0.01 % to white (on luminance, keeping each pixel's hue),
|
||
* encode with gamma 1/2.2 and ±0.5 LSB dither against banding; WebP.
|
||
*
|
||
* Usage:
|
||
* node scripts/build-milky-way.mjs <eso0932a.tif>
|
||
* Needs the sharp dev dependency.
|
||
*/
|
||
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
export const FACE_SIZE = 1024;
|
||
export const BLUR_DEG = 0.4;
|
||
const WORK_WIDTH = 4096;
|
||
const OPEN_RADIUS = 3;
|
||
const WEBP_QUALITY = 96;
|
||
// The median sky is the black level: away from the galactic plane the
|
||
// panorama carries a mottled glow of unresolved stars and dust that reads as
|
||
// grey blotches around the globe. A hard cut there draws edges of its own, so
|
||
// the level eases in over SOFT_KNEE (a fraction of the black-to-white range).
|
||
const BLACK_PERCENTILE = 0.5;
|
||
const SOFT_KNEE = 0.03;
|
||
const WHITE_PERCENTILE = 0.9999;
|
||
const OUT_DIR = path.join(
|
||
path.dirname(fileURLToPath(import.meta.url)),
|
||
'..',
|
||
'src',
|
||
'data',
|
||
'local_data',
|
||
'sky',
|
||
);
|
||
|
||
/**
|
||
* Bright planets in the panorama, galactic longitude/latitude in degrees and
|
||
* the radius painted over with the surrounding sky. By position both are
|
||
* Jupiter, in Sagittarius (2008) and Aquarius (2010); no catalogue star lies
|
||
* under either.
|
||
*/
|
||
export const PLANETS = Object.freeze([
|
||
{ l: 13.4, b: -16.6, radiusDeg: 2.5 },
|
||
{ l: 69.0, b: -58.4, radiusDeg: 2.5 },
|
||
]);
|
||
|
||
/** Cube faces in WebGL upload order. */
|
||
export const CUBE_FACES = Object.freeze([
|
||
'positiveX',
|
||
'negativeX',
|
||
'positiveY',
|
||
'negativeY',
|
||
'positiveZ',
|
||
'negativeZ',
|
||
]);
|
||
export const FACE_FILE_SUFFIX = Object.freeze({
|
||
positiveX: 'px',
|
||
negativeX: 'nx',
|
||
positiveY: 'py',
|
||
negativeY: 'ny',
|
||
positiveZ: 'pz',
|
||
negativeZ: 'nz',
|
||
});
|
||
|
||
/**
|
||
* ICRF (J2000 equatorial) → galactic rotation, rows = galactic axes
|
||
* (Hipparcos catalogue, ESA SP-1200 vol. 1 §1.5.3).
|
||
*/
|
||
export const ICRF_TO_GALACTIC = Object.freeze([
|
||
[-0.0548755604162154, -0.873437090234885, -0.4838350155487132],
|
||
[0.4941094278755837, -0.4448296299600112, 0.7469822444972189],
|
||
[-0.867666149019004, -0.1980763734312015, 0.4559837761750669],
|
||
]);
|
||
|
||
/**
|
||
* Direction (not normalized) that the WebGL cube-map lookup sends to texel
|
||
* coordinate (s, t) ∈ [0, 1]² of `face`, t = 0 being the first image row.
|
||
* Inverse of the OpenGL ES 3.0 §3.8.10 major-axis table.
|
||
*/
|
||
export function cubeFaceDirection(face, s, t) {
|
||
const sc = 2 * s - 1;
|
||
const tc = 2 * t - 1;
|
||
switch (face) {
|
||
case 'positiveX':
|
||
return [1, -tc, -sc];
|
||
case 'negativeX':
|
||
return [-1, -tc, sc];
|
||
case 'positiveY':
|
||
return [sc, 1, tc];
|
||
case 'negativeY':
|
||
return [sc, -1, -tc];
|
||
case 'positiveZ':
|
||
return [sc, -tc, 1];
|
||
case 'negativeZ':
|
||
return [-sc, -tc, -1];
|
||
default:
|
||
throw new RangeError(`Unknown cube face ${face}`);
|
||
}
|
||
}
|
||
|
||
/** Rotate an ICRF direction into galactic coordinates. */
|
||
export function icrfToGalactic([x, y, z]) {
|
||
return ICRF_TO_GALACTIC.map((row) => row[0] * x + row[1] * y + row[2] * z);
|
||
}
|
||
|
||
/**
|
||
* Map a direction to (u, v) ∈ [0, 1)² of a plate carrée centred on
|
||
* longitude 0 with longitude increasing to the left and v = 0 at +90°.
|
||
*/
|
||
export function directionToPlateCarree(x, y, z) {
|
||
const longitude = Math.atan2(y, x);
|
||
const latitude = Math.atan2(z, Math.hypot(x, y));
|
||
let u = 0.5 - longitude / (2 * Math.PI);
|
||
u -= Math.floor(u);
|
||
return [u, 0.5 - latitude / Math.PI];
|
||
}
|
||
|
||
/** Box widths whose three passes approximate a Gaussian of `sigma` (Kovesi). */
|
||
function boxRadii(sigma) {
|
||
const ideal = Math.sqrt((12 * sigma * sigma) / 3 + 1);
|
||
let lower = Math.floor(ideal);
|
||
if (lower % 2 === 0) lower -= 1;
|
||
const upper = lower + 2;
|
||
const m = Math.round(
|
||
(12 * sigma * sigma - 3 * lower * lower - 12 * lower - 9) /
|
||
(-4 * lower - 4),
|
||
);
|
||
return [0, 1, 2].map((i) => ((i < m ? lower : upper) - 1) / 2);
|
||
}
|
||
|
||
/** Index along a line, wrapped or clamped at the ends. */
|
||
function lineIndex(i, length, wrap) {
|
||
if (wrap) return ((i % length) + length) % length;
|
||
return i < 0 ? 0 : i >= length ? length - 1 : i;
|
||
}
|
||
|
||
/** Running-sum box blur of one strided line. */
|
||
function boxLine(src, dst, offset, stride, length, radius, wrap) {
|
||
if (radius < 1) {
|
||
for (let i = 0; i < length; i++)
|
||
dst[offset + i * stride] = src[offset + i * stride];
|
||
return;
|
||
}
|
||
const r = wrap ? Math.min(radius, Math.floor((length - 1) / 2)) : radius;
|
||
const at = (i) => src[offset + lineIndex(i, length, wrap) * stride];
|
||
let sum = 0;
|
||
for (let i = -r; i <= r; i++) sum += at(i);
|
||
const scale = 1 / (2 * r + 1);
|
||
for (let i = 0; i < length; i++) {
|
||
dst[offset + i * stride] = sum * scale;
|
||
sum += at(i + r + 1) - at(i - r);
|
||
}
|
||
}
|
||
|
||
/** Minimum or maximum over a (2r+1) window of one strided line. */
|
||
function extremumLine(src, dst, offset, stride, length, radius, wrap, pick) {
|
||
for (let i = 0; i < length; i++) {
|
||
let value = src[offset + lineIndex(i - radius, length, wrap) * stride];
|
||
for (let k = -radius + 1; k <= radius; k++)
|
||
value = pick(
|
||
value,
|
||
src[offset + lineIndex(i + k, length, wrap) * stride],
|
||
);
|
||
dst[offset + i * stride] = value;
|
||
}
|
||
}
|
||
|
||
/** Separable minimum or maximum filter over a plate-carrée plane, in place. */
|
||
function extremumPlane(plane, width, height, radius, pick) {
|
||
const scratch = new Float32Array(plane.length);
|
||
for (let y = 0; y < height; y++)
|
||
extremumLine(plane, scratch, y * width, 1, width, radius, true, pick);
|
||
for (let x = 0; x < width; x++)
|
||
extremumLine(scratch, plane, x, width, height, radius, false, pick);
|
||
}
|
||
|
||
/** Blur one plate-carrée channel in place, round on the sphere. */
|
||
function blurPlane(plane, width, height, sigmaPx) {
|
||
const scratch = new Float32Array(plane.length);
|
||
for (let y = 0; y < height; y++) {
|
||
const latitude = (0.5 - (y + 0.5) / height) * Math.PI;
|
||
const sigmaX = Math.min(
|
||
width / 4,
|
||
sigmaPx / Math.max(Math.cos(latitude), 1e-3),
|
||
);
|
||
for (const radius of boxRadii(sigmaX)) {
|
||
boxLine(plane, scratch, y * width, 1, width, Math.round(radius), true);
|
||
plane.set(scratch.subarray(y * width, (y + 1) * width), y * width);
|
||
}
|
||
}
|
||
for (const radius of boxRadii(sigmaPx)) {
|
||
for (let x = 0; x < width; x++)
|
||
boxLine(plane, scratch, x, width, height, Math.round(radius), false);
|
||
plane.set(scratch);
|
||
}
|
||
}
|
||
|
||
/** Unit vector for galactic longitude and latitude in degrees. */
|
||
function galacticVector(l, b) {
|
||
const lr = (l * Math.PI) / 180;
|
||
const br = (b * Math.PI) / 180;
|
||
return [
|
||
Math.cos(br) * Math.cos(lr),
|
||
Math.cos(br) * Math.sin(lr),
|
||
Math.sin(br),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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 + 1) * Math.PI) / 180);
|
||
const disc = [];
|
||
const ring = [];
|
||
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;
|
||
for (let x = 0; x < width; x++) {
|
||
const longitude = (0.5 - (x + 0.5) / width) * 360;
|
||
const p = galacticVector(longitude, latitude);
|
||
const cos = p[0] * centre[0] + p[1] * centre[1] + p[2] * centre[2];
|
||
if (cos >= inner) disc.push(y * width + x);
|
||
else if (cos >= outer) ring.push(y * width + x);
|
||
}
|
||
}
|
||
for (const plane of planes) {
|
||
const values = Float32Array.from(ring, (index) => plane[index]).sort();
|
||
const median = values[values.length >> 1];
|
||
for (const index of disc) plane[index] = median;
|
||
}
|
||
}
|
||
|
||
/** Bilinear sample with longitude wrap and latitude clamp. */
|
||
function samplePlane(plane, width, height, u, v) {
|
||
const fx = u * width - 0.5;
|
||
const fy = Math.min(Math.max(v * height - 0.5, 0), height - 1);
|
||
const x0 = Math.floor(fx);
|
||
const y0 = Math.floor(fy);
|
||
const ax = fx - x0;
|
||
const ay = fy - y0;
|
||
const xa = ((x0 % width) + width) % width;
|
||
const xb = (xa + 1) % width;
|
||
const yb = Math.min(y0 + 1, height - 1);
|
||
const top = plane[y0 * width + xa] * (1 - ax) + plane[y0 * width + xb] * ax;
|
||
const bottom =
|
||
plane[yb * width + xa] * (1 - ax) + plane[yb * width + xb] * ax;
|
||
return top * (1 - ay) + bottom * ay;
|
||
}
|
||
|
||
/** Deterministic hash noise in [-0.5, 0.5) for dithering. */
|
||
function dither(index, channel) {
|
||
let h = (index * 374761393 + channel * 668265263) | 0;
|
||
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||
h ^= h >>> 16;
|
||
return (h >>> 0) / 4294967296 - 0.5;
|
||
}
|
||
|
||
/**
|
||
* Black level with a soft knee: 0 at and below the level, then a quadratic
|
||
* ease that joins the straight line, so faint sky fades out instead of
|
||
* ending in a contour.
|
||
*/
|
||
export function softBlack(x, knee = SOFT_KNEE) {
|
||
return x <= 0 ? 0 : (x * x) / (x + knee);
|
||
}
|
||
|
||
/** Value at `fraction` of the sorted luminance. */
|
||
function luminancePercentile(planes, fraction) {
|
||
const [r, g, b] = planes;
|
||
const lum = new Float32Array(r.length);
|
||
for (let i = 0; i < lum.length; i++) lum[i] = (r[i] + g[i] + b[i]) / 3;
|
||
lum.sort();
|
||
return lum[Math.min(lum.length - 1, Math.floor(fraction * lum.length))];
|
||
}
|
||
|
||
const srgbToLinear = (value) =>
|
||
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||
|
||
async function decodePanorama(sharp, file) {
|
||
const width = WORK_WIDTH;
|
||
const height = WORK_WIDTH / 2;
|
||
const raw = await sharp(file)
|
||
.resize(width, height, { fit: 'fill', kernel: 'lanczos3' })
|
||
.removeAlpha()
|
||
.raw()
|
||
.toBuffer();
|
||
const lut = Float32Array.from({ length: 256 }, (_, i) =>
|
||
srgbToLinear(i / 255),
|
||
);
|
||
const planes = [0, 1, 2].map(() => new Float32Array(width * height));
|
||
for (let i = 0; i < width * height; i++)
|
||
for (let c = 0; c < 3; c++) planes[c][i] = lut[raw[i * 3 + c]];
|
||
return { width, height, planes };
|
||
}
|
||
|
||
async function main() {
|
||
const [file] = process.argv.slice(2);
|
||
if (!file) throw new Error('usage: build-milky-way.mjs <eso0932a.tif>');
|
||
const { default: sharp } = await import('sharp');
|
||
const { width, height, planes } = await decodePanorama(sharp, file);
|
||
const sigmaPx = (BLUR_DEG / 180) * height;
|
||
for (const plane of planes) {
|
||
extremumPlane(plane, width, height, OPEN_RADIUS, Math.min);
|
||
extremumPlane(plane, width, height, OPEN_RADIUS, Math.max);
|
||
}
|
||
// 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;
|
||
for (const face of CUBE_FACES) {
|
||
const pixels = Buffer.alloc(FACE_SIZE * FACE_SIZE * 3);
|
||
for (let j = 0; j < FACE_SIZE; j++) {
|
||
for (let i = 0; i < FACE_SIZE; i++) {
|
||
const direction = cubeFaceDirection(
|
||
face,
|
||
(i + 0.5) / FACE_SIZE,
|
||
(j + 0.5) / FACE_SIZE,
|
||
);
|
||
const [u, v] = directionToPlateCarree(...icrfToGalactic(direction));
|
||
const index = j * FACE_SIZE + i;
|
||
const rgb = [0, 1, 2].map((c) =>
|
||
samplePlane(planes[c], width, height, u, v),
|
||
);
|
||
const luminance = (rgb[0] + rgb[1] + rgb[2]) / 3;
|
||
const level = softBlack((luminance - black) / (white - black));
|
||
for (let c = 0; c < 3; c++) {
|
||
// Scale every channel by the luminance curve to keep the hue.
|
||
const linear = luminance > 0 ? (rgb[c] / luminance) * level : 0;
|
||
const encoded = Math.min(linear, 1) ** (1 / 2.2) * 255;
|
||
pixels[index * 3 + c] = Math.min(
|
||
255,
|
||
Math.max(0, Math.round(encoded + dither(index, c))),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
const out = path.join(OUT_DIR, `milkyway_${FACE_FILE_SUFFIX[face]}.webp`);
|
||
const info = await sharp(pixels, {
|
||
raw: { width: FACE_SIZE, height: FACE_SIZE, channels: 3 },
|
||
})
|
||
.webp({ quality: WEBP_QUALITY, smartSubsample: true, effort: 6 })
|
||
.toFile(out);
|
||
total += info.size;
|
||
console.log(`${path.relative(process.cwd(), out)}: ${info.size} bytes`);
|
||
}
|
||
console.log(`total ${total} bytes`);
|
||
}
|
||
|
||
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|