fix(sky): ease the Milky Way black level in at the median sky
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]>
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
- Replace the blocky JPEG sky around the globe with the 9,096 stars of the
|
- Replace the blocky JPEG sky around the globe with the 9,096 stars of the
|
||||||
Yale Bright Star Catalogue, drawn as sharp points where each star really is
|
Yale Bright Star Catalogue, drawn as sharp points at their catalogue
|
||||||
at the scene clock, over a soft Milky Way built from ESO/S. Brunier's
|
positions for the scene clock, over a soft Milky Way built from ESO/S.
|
||||||
panorama (CC BY 4.0). The Sun and Moon stay; the new assets weigh 482 KB
|
Brunier's panorama (CC BY 4.0). The Sun and Moon stay, the anime style fades
|
||||||
(gekh, #1243).
|
the Milky Way out, and the new assets weigh 319 kB (gekh, #1243).
|
||||||
@@ -29,9 +29,9 @@
|
|||||||
* axes as the cube axes — +X to the March equinox, +Z to the north
|
* axes as the cube axes — +X to the March equinox, +Z to the north
|
||||||
* celestial pole — rotating each direction into galactic coordinates
|
* celestial pole — rotating each direction into galactic coordinates
|
||||||
* with the IAU J2000 matrix.
|
* with the IAU J2000 matrix.
|
||||||
* 6. Map the darkest 25 % of the sky to black and the brightest 0.01 % to
|
* 6. Map the median sky to black, easing in with a soft knee, and the
|
||||||
* white, encode with gamma 1/2.2 and ±0.5 LSB dither against banding;
|
* brightest 0.01 % to white (on luminance, keeping each pixel's hue),
|
||||||
* WebP.
|
* encode with gamma 1/2.2 and ±0.5 LSB dither against banding; WebP.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* node scripts/build-milky-way.mjs <eso0932a.tif>
|
* node scripts/build-milky-way.mjs <eso0932a.tif>
|
||||||
@@ -46,10 +46,12 @@ export const BLUR_DEG = 0.4;
|
|||||||
const WORK_WIDTH = 4096;
|
const WORK_WIDTH = 4096;
|
||||||
const OPEN_RADIUS = 3;
|
const OPEN_RADIUS = 3;
|
||||||
const WEBP_QUALITY = 96;
|
const WEBP_QUALITY = 96;
|
||||||
// The darkest quarter of the sky is the black level: away from the galactic
|
// The median sky is the black level: away from the galactic plane the
|
||||||
// plane the panorama carries a mottled glow of unresolved stars that would
|
// panorama carries a mottled glow of unresolved stars and dust that reads as
|
||||||
// read as grey fog around the globe.
|
// grey blotches around the globe. A hard cut there draws edges of its own, so
|
||||||
const BLACK_PERCENTILE = 0.25;
|
// 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 WHITE_PERCENTILE = 0.9999;
|
||||||
const OUT_DIR = path.join(
|
const OUT_DIR = path.join(
|
||||||
path.dirname(fileURLToPath(import.meta.url)),
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
@@ -289,6 +291,15 @@ function dither(index, channel) {
|
|||||||
return (h >>> 0) / 4294967296 - 0.5;
|
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. */
|
/** Value at `fraction` of the sorted luminance. */
|
||||||
function luminancePercentile(planes, fraction) {
|
function luminancePercentile(planes, fraction) {
|
||||||
const [r, g, b] = planes;
|
const [r, g, b] = planes;
|
||||||
@@ -346,12 +357,14 @@ async function main() {
|
|||||||
);
|
);
|
||||||
const [u, v] = directionToPlateCarree(...icrfToGalactic(direction));
|
const [u, v] = directionToPlateCarree(...icrfToGalactic(direction));
|
||||||
const index = j * FACE_SIZE + i;
|
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++) {
|
for (let c = 0; c < 3; c++) {
|
||||||
const linear = Math.max(
|
// Scale every channel by the luminance curve to keep the hue.
|
||||||
(samplePlane(planes[c], width, height, u, v) - black) /
|
const linear = luminance > 0 ? (rgb[c] / luminance) * level : 0;
|
||||||
(white - black),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const encoded = Math.min(linear, 1) ** (1 / 2.2) * 255;
|
const encoded = Math.min(linear, 1) ** (1 / 2.2) * 255;
|
||||||
pixels[index * 3 + c] = Math.min(
|
pixels[index * 3 + c] = Math.min(
|
||||||
255,
|
255,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ whole-globe view and show compression blocks.
|
|||||||
| File | Content | Size |
|
| File | Content | Size |
|
||||||
| ---- | ------- | ---- |
|
| ---- | ------- | ---- |
|
||||||
| `bright_stars.json` | 9,096 stars: J2000 RA/Dec, V magnitude, B−V | 235 KB |
|
| `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 |
|
| `milkyway_{px,nx,py,ny,pz,nz}.webp` | Milky Way glow, six 1024² cube faces, no stars | 85 KB |
|
||||||
|
|
||||||
## Stars — Yale Bright Star Catalogue
|
## Stars — Yale Bright Star Catalogue
|
||||||
|
|
||||||
@@ -45,7 +45,8 @@ Centre.
|
|||||||
4096×2048, stars removed with a 7×7 morphological opening, two planets
|
4096×2048, stars removed with a 7×7 morphological opening, two planets
|
||||||
(Jupiter, by position) painted over with the median of the surrounding sky,
|
(Jupiter, by position) painted over with the median of the surrounding sky,
|
||||||
blurred (σ = 0.4°), reprojected from galactic coordinates to ICRF cube faces,
|
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
|
black level set at the median sky with a soft knee (the faint glow away
|
||||||
|
from the galactic plane fades to black), gamma-encoded and saved as
|
||||||
WebP. Build:
|
WebP. Build:
|
||||||
`node scripts/build-milky-way.mjs <eso0932a.tif>`.
|
`node scripts/build-milky-way.mjs <eso0932a.tif>`.
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 12 KiB |
@@ -9,6 +9,7 @@ import {
|
|||||||
cubeFaceDirection,
|
cubeFaceDirection,
|
||||||
directionToPlateCarree,
|
directionToPlateCarree,
|
||||||
icrfToGalactic,
|
icrfToGalactic,
|
||||||
|
softBlack,
|
||||||
} from '../../scripts/build-milky-way.mjs';
|
} from '../../scripts/build-milky-way.mjs';
|
||||||
import { raDecToUnitVector } from '../sky/stars.js';
|
import { raDecToUnitVector } from '../sky/stars.js';
|
||||||
|
|
||||||
@@ -105,3 +106,18 @@ test('the galactic rotation puts the Galactic Centre and pole where the IAU does
|
|||||||
close(u, 0.5 + (360 - 280.47) / 360, 2e-3);
|
close(u, 0.5 + (360 - 280.47) / 360, 2e-3);
|
||||||
close(v, 0.5 + 32.89 / 180, 2e-3);
|
close(v, 0.5 + 32.89 / 180, 2e-3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the black level eases in instead of cutting the faint sky into contours', () => {
|
||||||
|
assert.equal(softBlack(-0.2), 0);
|
||||||
|
assert.equal(softBlack(0), 0);
|
||||||
|
// Continuous, rising, with no step at the black level.
|
||||||
|
let previous = 0;
|
||||||
|
for (let x = 0.001; x <= 1; x += 0.001) {
|
||||||
|
const y = softBlack(x);
|
||||||
|
assert.ok(y > previous && y - previous < 0.0011, `step at ${x.toFixed(3)}`);
|
||||||
|
previous = y;
|
||||||
|
}
|
||||||
|
// Starts flat, then joins the straight line just below it.
|
||||||
|
assert.ok(softBlack(0.003) < 0.0003);
|
||||||
|
close(softBlack(1), 1 - 0.03, 1e-3);
|
||||||
|
});
|
||||||