WIP ridge: physical sky over Vienna (Hillaire LUTs, Earth coefficients, real-clock sun), ACES display fitted per state, RIDGE_NIGHT_BAND removed

Not landed: 3 tests red (skyState 'night below day and dusk', two skyTheme ink checks that read the old bands), no clouds/rain/mist yet,
no stills, no Aqua-method measurement of the new sky, no Soul pass. Local branch only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nibu 2026-11-11 22:19:47 +00:00 • committed by Void
commit 3b1405907a
17 changed files with 1284 additions and 140 deletions

View file

@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { atStandardTime } from './standardTime.testkit';
import { baked } from '../../../sky/baked';
import type { Scene } from './Scene';
import { startBackgroundScene } from './index';
import { setSkyClock, setSkyState } from './skyState';
@ -79,7 +78,7 @@ describe('startBackgroundScene follows the sky state', () => {
setSkyState('day');
await flush();
expect(first.dispose).toHaveBeenCalledOnce();
expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]);
expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(hex(ridgeSkyFor('day').top));
scene?.dispose();
expect(second.dispose).toHaveBeenCalledOnce();
});
@ -92,7 +91,7 @@ describe('startBackgroundScene follows the sky state', () => {
const scene = await started;
await flush();
expect(createRidgeScene).toHaveBeenCalledTimes(2);
expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]);
expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(hex(ridgeSkyFor('day').top));
expect(a.dispose).toHaveBeenCalledOnce();
scene?.dispose();
expect(b.dispose).toHaveBeenCalledOnce();

View file

@ -52,8 +52,8 @@ async function skyKey(
state: SkyState
): Promise<string | null> {
try {
const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState');
return JSON.stringify(config?.style === 'terrain' ? terrainSkyFor(state) : ridgeSkyFor(state));
const { ridgeSkyKey, terrainSkyFor } = await import('./skyForState');
return config?.style === 'terrain' ? JSON.stringify(terrainSkyFor(state)) : ridgeSkyKey(state);
} catch {
// The chunk failed to load: no key, so every change rebuilds (and `create` reports the failure).
return null;

View file

@ -3,6 +3,7 @@ import type { Scene, SceneOptions } from '../Scene';
import { overlayRequested } from '../debugOverlays';
import { FrameSampler, FrameStats } from './probe';
import { skyColours, SkyColours, Srgb } from './skyColour';
import { paintSky as paintPhysicalSky } from './physicalSky';
import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars';
import { bakeSlice, BakeParams, layerPaint } from './ridgeBake';
import { LAYERS, LayerSpec, edgeBlur, layerSize, scatterAlpha, transmittance } from './ridgeWorld';
@ -34,9 +35,35 @@ function context2d(c: HTMLCanvasElement): CanvasRenderingContext2D | null {
return c.getContext('2d');
}
function paintSky(canvas: HTMLCanvasElement, sky: SkyColours): boolean {
/**
* The host canvas's sky: the physical sky (`physicalSky.ts`) as a small grid of display pixels, scaled up smoothly (it is a soft
* field); without one, the three-stop gradient.
*/
function paintSky(
canvas: HTMLCanvasElement,
sky: SkyColours,
cssW: number,
cssH: number,
makeCanvas: () => HTMLCanvasElement
): boolean {
const ctx = context2d(canvas);
if (!ctx) return false;
if (sky.physical) {
const px = paintPhysicalSky(sky.physical, cssW, cssH);
const small = makeCanvas();
small.width = px.w;
small.height = px.h;
const sctx = context2d(small);
if (sctx) {
const img = sctx.createImageData(px.w, px.h);
img.data.set(px.data);
sctx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, canvas.width, canvas.height);
return true;
}
}
const g = ctx.createLinearGradient(0, 0, 0, canvas.height);
g.addColorStop(0, css(sky.top));
g.addColorStop(0.5, css(sky.mid));
@ -415,7 +442,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
const size = layerSize(w, h, scale);
canvas.width = size.period;
canvas.height = Math.round(h * scale);
if (!paintSky(canvas, sky)) return false;
if (!paintSky(canvas, sky, w, h, () => document.createElement('canvas'))) return false;
// Every star is baked into the sky; the twinkling ones get their own canvas, which a per-frame
// pass clears and paints over. 'still' has no overlay.
const stars = makeStars(sky, w, h);

View file

@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { bakeAtmosphere, bakeCostSteps, PLANET_KM } from './earthAtmosphere';
import { TOP_KM } from './earthAtmosphere';
const rad = (deg: number) => Math.cos(((90 - deg) * Math.PI) / 180);
const luma = (c: number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
const atNoon = bakeAtmosphere({ sunElevationDeg: 45 });
const atSunset = bakeAtmosphere({ sunElevationDeg: 0 });
const atDark = bakeAtmosphere({ sunElevationDeg: -20 });
/** Sky radiance looking `elev` degrees above the horizon, `az` degrees from the sun's azimuth. */
const sky = (a: ReturnType<typeof bakeAtmosphere>, elev: number, az: number) => {
const out = [0, 0, 0];
a.radiance(Math.sin((elev * Math.PI) / 180), Math.cos((az * Math.PI) / 180), out);
return out;
};
describe('earthAtmosphere: the published Earth atmosphere, baked to look-up tables', () => {
it('has the geometry of the references (a 100 km shell on a 6360 km planet)', () => {
expect(PLANET_KM).toBe(6360);
expect(TOP_KM - PLANET_KM).toBe(100);
});
it('transmittance to space: the zenith is the published vertical optical depth, red passes most, blue least', () => {
const t = [0, 0, 0];
atNoon.transmittanceToSpace(1, t);
// Rayleigh column at 8 km: 5.802e-3 * 8 = 0.0464 (red), 0.108 (green), 0.265 (blue); Mie 4.44e-3 * 1.2 = 0.0053; ozone
// 0.65e-3 * 15 = 0.00975 (red), 0.0282 (green), 0.0013 (blue). So exp(-tau) there.
expect(t[0]).toBeCloseTo(Math.exp(-(0.0464 + 0.0053 + 0.0098)), 1);
expect(t[1]).toBeCloseTo(Math.exp(-(0.1085 + 0.0053 + 0.0282)), 1);
expect(t[2]).toBeCloseTo(Math.exp(-(0.2648 + 0.0053 + 0.0013)), 1);
expect(t[0]).toBeGreaterThan(t[1]);
expect(t[1]).toBeGreaterThan(t[2]);
});
it('a noon sky is blue: the zenith is bluer than the horizon, and bluer than it is red', () => {
const zenith = sky(atNoon, 70, 90);
expect(zenith[2]).toBeGreaterThan(zenith[0] * 1.5);
const horizon = sky(atNoon, 1, 90);
expect(luma(horizon)).toBeGreaterThan(luma(zenith)); // the long path whitens and brightens the horizon
expect(horizon[2] / horizon[0]).toBeLessThan(zenith[2] / zenith[0]);
});
it('the sky toward the sun at sunset is red-orange, and the zenith opposite is blue', () => {
const toward = sky(atSunset, 3, 5);
expect(toward[0]).toBeGreaterThan(toward[2] * 1.5);
const away = sky(atSunset, 40, 180);
expect(away[2]).toBeGreaterThan(away[0]);
});
it('a clear sky brightens toward the sun (the Mie lobe)', () => {
expect(luma(sky(atNoon, 45, 4))).toBeGreaterThan(2 * luma(sky(atNoon, 30, 90)));
});
it('the sky is dark when the sun is 20 degrees under the horizon, a thousandth of noon or less', () => {
expect(luma(sky(atDark, 20, 90))).toBeLessThan(luma(sky(atNoon, 20, 90)) * 1e-3);
});
it('twilight keeps a blue zenith (the ozone absorption of red)', () => {
const twilight = bakeAtmosphere({ sunElevationDeg: -6 });
const z = sky(twilight, 60, 180);
expect(z[2]).toBeGreaterThan(z[0] * 1.5);
});
it('haze (Mie x4) whitens and brightens the horizon haze band', () => {
const hazy = bakeAtmosphere({ sunElevationDeg: 30, mieScale: 4 });
const clear = bakeAtmosphere({ sunElevationDeg: 30 });
const h = sky(hazy, 12, 20);
const c = sky(clear, 12, 20);
expect(luma(h)).toBeGreaterThan(luma(c)); // more aerosol, more forward scattering toward the sun
expect(h[2] / h[0]).toBeLessThan(c[2] / c[0]); // and less blue
});
it('is energy-sane: the multiple-scattering term never makes the sky brighter than the sun it takes in, and stays finite', () => {
for (const a of [atNoon, atSunset, atDark]) {
for (let i = 0; i < a.skyView.data.length; i += 1) {
expect(Number.isFinite(a.skyView.data[i])).toBe(true);
expect(a.skyView.data[i]).toBeGreaterThanOrEqual(0);
expect(a.skyView.data[i]).toBeLessThan(5);
}
}
expect(rad(0)).toBeCloseTo(0, 6);
});
it('bake cost: the three LUTs take about the stated number of march steps, in tens of milliseconds', () => {
const steps = bakeCostSteps();
const t0 = performance.now();
bakeAtmosphere({ sunElevationDeg: 24 });
const ms = performance.now() - t0;
// eslint-disable-next-line no-console
console.log(`atmosphere bake: ${steps} march steps, ${ms.toFixed(1)} ms`);
expect(steps).toBeLessThan(1_000_000);
expect(ms).toBeLessThan(1500);
});
});

View file

@ -0,0 +1,552 @@
/* eslint-disable no-param-reassign, no-plusplus, no-bitwise -- numeric kernels over typed arrays */
// Earth's atmosphere as three small look-up tables, baked once on the CPU: transmittance, multiple scattering and
// the sky-view LUT. Per sky texel the scene then does one bilinear lookup. No ray march after the bake.
//
// The method is Hillaire 2020, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique" (EGSR):
// the transmittance LUT in Bruneton and Neyret's (2008, 2017) parameterisation, the multiple-scattering LUT of his
// section 5.5 (a geometric series over an isotropic second order), and the sky-view LUT of 5.3 with the
// squared elevation distribution. The kernels follow his reference code (sebh/UnrealEngineSkyAtmosphere,
// Resources/RenderSkyRayMarching.hlsl `IntegrateScatteredLuminance`, `NewMultiScattCS`, `SkyViewLutPS`; MIT).
//
// Earth's coefficients are the ones that code takes from Bruneton's 2017 reference implementation
// (ebruneton/precomputed_atmospheric_scattering, `atmosphere/demo/demo.cc`), in 1/km, at sea level:
// Rayleigh scattering (5.802, 13.558, 33.1) e-3 at (R, G, B) wavelengths, scale height 8 km. No Rayleigh absorption.
// Mie scattering 3.996e-3 and extinction 4.44e-3 (single scattering albedo 0.9), grey, scale height 1.2 km,
// Cornette-Shanks phase, asymmetry g 0.8.
// Ozone absorption (0.650, 1.881, 0.085) e-3, a tent layer 15 km either side of 25 km (it makes the zenith blue at twilight).
// Planet 6360 km, top of the atmosphere 6460 km (100 km: the Karman line, 99.99% of the air).
// Rayleigh phase 3/(16 pi) (1 + cos^2). The sun is a point of unit irradiance; the consumer scales it (`toneMap.ts`).
//
// Single value the references do not give: ground albedo. 0.25 here, a grass, roofs and bare ground figure for a
// city and its wood; it only feeds the multiple-scattering term (and so the dusk and the shadow side of the sky).
//
// Simplifications against the paper, all on the cheap side: no ground bounce inside the sky-view LUT itself (its rays
// leave to space), fixed observer height, a spherical Earth with no terrain, the sun a point (the disc is the
// consumer's), no refraction, no clouds (`weather.ts`: haze is a multiplier on the Mie coefficients).
export const PLANET_KM = 6360;
export const TOP_KM = 6460;
const RAYLEIGH_SCATTER: Vec3 = [5.802e-3, 13.558e-3, 33.1e-3];
const RAYLEIGH_SCALE_KM = 8;
const MIE_SCATTER = 3.996e-3;
const MIE_EXTINCT = 4.44e-3;
const MIE_SCALE_KM = 1.2;
export const MIE_G = 0.8;
const OZONE_ABSORB: Vec3 = [0.65e-3, 1.881e-3, 0.085e-3];
const OZONE_PEAK_KM = 25;
const OZONE_HALF_WIDTH_KM = 15;
export const GROUND_ALBEDO = 0.25;
const RADIUS_OFFSET_KM = 0.01;
export type Vec3 = readonly [number, number, number];
const PI = Math.PI;
const FOUR_PI = 4 * PI;
const clamp = (x: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, x));
export interface AtmosphereOptions {
/** Sun elevation above the horizon in degrees (Vienna's, `vienna.ts`). */
sunElevationDeg: number;
/** Multiplier on both Mie coefficients: haze. 1 is the reference air. */
mieScale?: number;
/** Observer height above the ground in km. Vienna's lowest point is 0.151. */
observerKm?: number;
}
/** Sizes of the three tables and the march step counts. The sum is the bake's cost, see `bakeCostSteps`. */
export const LUT = {
transmittance: { w: 64, h: 24, steps: 32 },
multiScatter: { w: 16, h: 16, dirs: 6, steps: 14 },
skyView: { w: 72, h: 40, steps: 24 },
} as const;
const NO_WEATHER = 1;
interface Medium {
scatter: Vec3;
extinction: Vec3;
/** Mie scattering and Rayleigh scattering separately, for the phase functions. */
mie: number;
ray: Vec3;
}
function sampleMedium(altKm: number, mieScale: number): Medium {
const dRay = Math.exp(-altKm / RAYLEIGH_SCALE_KM);
const dMie = Math.exp(-altKm / MIE_SCALE_KM) * mieScale;
const tent =
altKm < OZONE_PEAK_KM
? altKm / OZONE_HALF_WIDTH_KM - OZONE_PEAK_KM / OZONE_HALF_WIDTH_KM + 1
: -altKm / OZONE_HALF_WIDTH_KM + OZONE_PEAK_KM / OZONE_HALF_WIDTH_KM + 1;
const dOzone = clamp(tent, 0, 1);
const ray: Vec3 = [
dRay * RAYLEIGH_SCATTER[0],
dRay * RAYLEIGH_SCATTER[1],
dRay * RAYLEIGH_SCATTER[2],
];
const mie = dMie * MIE_SCATTER;
const ext = dMie * MIE_EXTINCT;
return {
ray,
mie,
scatter: [ray[0] + mie, ray[1] + mie, ray[2] + mie],
extinction: [
ray[0] + ext + dOzone * OZONE_ABSORB[0],
ray[1] + ext + dOzone * OZONE_ABSORB[1],
ray[2] + ext + dOzone * OZONE_ABSORB[2],
],
};
}
/** Nearest positive hit of a unit-direction ray with a sphere at the origin: its distance, or -1. */
function raySphere(
ox: number,
oy: number,
oz: number,
dx: number,
dy: number,
dz: number,
r: number
): number {
const b = ox * dx + oy * dy + oz * dz;
const c = ox * ox + oy * oy + oz * oz - r * r;
if (c > 0 && b > 0) return -1;
const disc = b * b - c;
if (disc < 0) return -1;
const s = Math.sqrt(disc);
const t0 = -b - s;
return t0 > 0 ? t0 : -b + s > 0 ? -b + s : -1;
}
const rayleighPhase = (cosT: number): number => (3 / (16 * PI)) * (1 + cosT * cosT);
/** Henyey-Greenstein, Hillaire's `hgPhase` with the view direction flipped (his WorldDir is an "in" direction). */
const hgPhase = (g: number, cosT: number): number => {
const denom = 1 + g * g + 2 * g * cosT;
return (1 - g * g) / (FOUR_PI * denom * Math.sqrt(denom));
};
/** A 2D table with `c` channels, sampled bilinearly with clamped edges. */
export interface Lut2 {
w: number;
h: number;
c: number;
data: Float32Array;
}
const makeLut = (w: number, h: number, c: number): Lut2 => ({
w,
h,
c,
data: new Float32Array(w * h * c),
});
/** Bilinear at texel-space coordinates (0..w-1, 0..h-1), written into `out`. */
function sampleTexel(l: Lut2, x: number, y: number, out: number[] | Float32Array): void {
const fx = clamp(x, 0, l.w - 1);
const fy = clamp(y, 0, l.h - 1);
const x0 = Math.floor(fx);
const y0 = Math.floor(fy);
const x1 = Math.min(l.w - 1, x0 + 1);
const y1 = Math.min(l.h - 1, y0 + 1);
const tx = fx - x0;
const ty = fy - y0;
for (let k = 0; k < l.c; k++) {
const a = l.data[(y0 * l.w + x0) * l.c + k];
const b = l.data[(y0 * l.w + x1) * l.c + k];
const c = l.data[(y1 * l.w + x0) * l.c + k];
const d = l.data[(y1 * l.w + x1) * l.c + k];
out[k] = (a + (b - a) * tx) * (1 - ty) + (c + (d - c) * tx) * ty;
}
}
// Transmittance LUT, Bruneton's parameterisation (r, mu) -> (x_mu, x_r) over rho = horizon distance of height r.
const H_TOP = Math.sqrt(TOP_KM * TOP_KM - PLANET_KM * PLANET_KM);
function transmittanceUv(r: number, mu: number): [number, number] {
const rho = Math.sqrt(Math.max(0, r * r - PLANET_KM * PLANET_KM));
const disc = r * r * (mu * mu - 1) + TOP_KM * TOP_KM;
const d = Math.max(0, -r * mu + Math.sqrt(Math.max(0, disc)));
const dMin = TOP_KM - r;
const dMax = rho + H_TOP;
return [(d - dMin) / (dMax - dMin), rho / H_TOP];
}
function transmittanceParams(xMu: number, xR: number): [number, number] {
const rho = H_TOP * xR;
const r = Math.sqrt(rho * rho + PLANET_KM * PLANET_KM);
const dMin = TOP_KM - r;
const dMax = rho + H_TOP;
const d = dMin + xMu * (dMax - dMin);
const mu = d === 0 ? 1 : (H_TOP * H_TOP - rho * rho - d * d) / (2 * r * d);
return [r, clamp(mu, -1, 1)];
}
const toSub = (u: number, res: number): number => (u + 0.5 / res) * (res / (res + 1));
const fromSub = (u: number, res: number): number => (u - 0.5 / res) * (res / (res - 1));
export interface Atmosphere {
readonly transmittance: Lut2;
readonly multiScatter: Lut2;
readonly skyView: Lut2;
readonly sunElevationDeg: number;
readonly mieScale: number;
readonly observerKm: number;
/** Cosine of the sun's zenith angle. */
readonly sunCosZenith: number;
/** Zenith angle (radians) of the astronomical horizon at the observer: a little past 90 degrees. */
readonly horizonZenith: number;
/** Transmittance from the observer along a direction `mu` (cos of its zenith angle), to space, per channel. */
transmittanceToSpace(mu: number, out: number[]): void;
/** Sky radiance per unit of sun irradiance for view zenith cos and cos of the azimuth from the sun; linear RGB. */
radiance(viewCosZenith: number, cosFromSun: number, out: number[]): void;
/** Light the sky and sun give a horizontal surface at the observer: direct (per unit sun irradiance) and ambient. */
irradiance: { direct: Vec3; ambient: Vec3 };
}
interface March {
L: [number, number, number];
transmittance: [number, number, number];
multiScatAs1: [number, number, number];
opticalDepth: [number, number, number];
}
/**
* Hillaire's `IntegrateScatteredLuminance`. Ray from (ox,oy,oz) along unit d, sun unit s; `miePhase` picks the real phase
* functions (the sky) or the isotropic one (the multiple-scattering bake); `ms` the multiple-scattering LUT (absent while it is being built).
*/
function march(
ox: number,
oy: number,
oz: number,
dx: number,
dy: number,
dz: number,
sx: number,
sy: number,
sz: number,
steps: number,
ground: boolean,
miePhase: boolean,
variable: boolean,
mieScale: number,
trLut: Lut2,
ms: Lut2 | undefined,
out: March
): void {
out.L = [0, 0, 0];
out.transmittance = [1, 1, 1];
out.multiScatAs1 = [0, 0, 0];
out.opticalDepth = [0, 0, 0];
const tBottom = raySphere(ox, oy, oz, dx, dy, dz, PLANET_KM);
const tTop = raySphere(ox, oy, oz, dx, dy, dz, TOP_KM);
let tMax: number;
if (tBottom < 0) {
if (tTop < 0) return;
tMax = tTop;
} else {
tMax = tTop > 0 ? Math.min(tTop, tBottom) : tBottom;
}
const cosT = dx * sx + dy * sy + dz * sz;
const phaseMie = miePhase ? hgPhase(MIE_G, -cosT) : 1 / FOUR_PI;
const phaseRay = miePhase ? rayleighPhase(cosT) : 1 / FOUR_PI;
const tmp = [0, 0];
const msv = [0, 0, 0];
const trv = [0, 0, 0];
let tPrev = 0;
for (let s = 0; s < steps; s++) {
let t0;
let t1;
if (variable) {
t0 = tMax * ((s / steps) * (s / steps));
t1 = tMax * (((s + 1) / steps) * ((s + 1) / steps));
} else {
t0 = (tMax * s) / steps;
t1 = (tMax * (s + 1)) / steps;
}
const t = t0 + (t1 - t0) * 0.3;
const dt = t1 - t0;
tPrev = t;
const px = ox + t * dx;
const py = oy + t * dy;
const pz = oz + t * dz;
const r = Math.sqrt(px * px + py * py + pz * pz);
const med = sampleMedium(r - PLANET_KM, mieScale);
const sunCos = (px * sx + py * sy + pz * sz) / r;
const [u, v] = transmittanceUv(r, sunCos);
sampleTexel(trLut, u * (trLut.w - 1), v * (trLut.h - 1), trv);
// Planet shadow: the sun's ray from here must not meet the ground.
const shadow = raySphere(px, py, pz, sx, sy, sz, PLANET_KM) >= 0 ? 0 : 1;
if (ms) {
const uu = toSub(sunCos * 0.5 + 0.5, ms.w);
const vv = toSub(clamp((r - PLANET_KM) / (TOP_KM - PLANET_KM), 0, 1), ms.h);
sampleTexel(ms, uu * ms.w - 0.5, vv * ms.h - 0.5, msv);
} else {
msv[0] = 0;
msv[1] = 0;
msv[2] = 0;
}
for (let k = 0; k < 3; k++) {
const ext = med.extinction[k];
const od = ext * dt;
const sampleT = Math.exp(-od);
out.opticalDepth[k] += od;
const phaseScatter = med.mie * phaseMie + med.ray[k] * phaseRay;
const S = shadow * trv[k] * phaseScatter + msv[k] * med.scatter[k];
const inv = ext > 0 ? (1 - sampleT) / ext : dt;
const th = out.transmittance[k];
out.L[k] += th * S * inv;
out.multiScatAs1[k] += th * med.scatter[k] * inv;
out.transmittance[k] = th * sampleT;
}
// Isotropic second-order inputs for the multiple-scattering bake use the same quantities; they are
// accumulated by the caller through `out.L` with `miePhase` false (uniform phase) so nothing more is kept here.
}
void tPrev;
void tmp;
if (ground && tBottom > 0 && tMax === tBottom) {
const px = ox + tBottom * dx;
const py = oy + tBottom * dy;
const pz = oz + tBottom * dz;
const r = Math.sqrt(px * px + py * py + pz * pz);
const sunCos = (px * sx + py * sy + pz * sz) / r;
const [u, v] = transmittanceUv(r, sunCos);
sampleTexel(trLut, u * (trLut.w - 1), v * (trLut.h - 1), trv);
const ndotl = Math.max(0, sunCos);
for (let k = 0; k < 3; k++) {
out.L[k] += (trv[k] * out.transmittance[k] * ndotl * GROUND_ALBEDO) / PI;
}
}
}
function bakeTransmittance(mieScale: number): Lut2 {
const { w, h, steps } = LUT.transmittance;
const lut = makeLut(w, h, 3);
const noLut = makeLut(1, 1, 3); // not read: `ms` is absent and the sun terms are discarded below
noLut.data.fill(1);
for (let j = 0; j < h; j++) {
for (let i = 0; i < w; i++) {
const [r, mu] = transmittanceParams(i / (w - 1), j / (h - 1));
const sinT = Math.sqrt(1 - mu * mu);
// Optical depth only: a straight integration of the extinction to space.
const tTop = raySphere(0, 0, r, 0, sinT, mu, TOP_KM);
const tBottom = raySphere(0, 0, r, 0, sinT, mu, PLANET_KM);
const tMax = tBottom > 0 ? Math.min(tTop, tBottom) : tTop;
const od = [0, 0, 0];
for (let s = 0; s < steps; s++) {
const t = (tMax * (s + 0.3)) / steps;
const px = t * sinT;
const pz = r + t * mu;
const med = sampleMedium(Math.sqrt(px * px + pz * pz) - PLANET_KM, mieScale);
const dt = tMax / steps;
for (let k = 0; k < 3; k++) od[k] += med.extinction[k] * dt;
}
const o = (j * w + i) * 3;
for (let k = 0; k < 3; k++) lut.data[o + k] = Math.exp(-od[k]);
}
}
return lut;
}
function bakeMultiScatter(mieScale: number, trLut: Lut2): Lut2 {
const { w, h, dirs, steps } = LUT.multiScatter;
const lut = makeLut(w, h, 3);
const res: March = {
L: [0, 0, 0],
transmittance: [1, 1, 1],
multiScatAs1: [0, 0, 0],
opticalDepth: [0, 0, 0],
};
const count = dirs * dirs;
for (let j = 0; j < h; j++) {
for (let i = 0; i < w; i++) {
const u = fromSub((i + 0.5) / w, w);
const v = fromSub((j + 0.5) / h, h);
const cosSun = clamp(u * 2 - 1, -1, 1);
const sx = Math.sqrt(Math.max(0, 1 - cosSun * cosSun));
const sz = cosSun;
// Hillaire's PLANET_RADIUS_OFFSET: a ray that starts exactly on the ground is degenerate.
const height = PLANET_KM + RADIUS_OFFSET_KM + clamp(v, 0, 1) * (TOP_KM - PLANET_KM - RADIUS_OFFSET_KM);
const sumMs = [0, 0, 0];
const sumL = [0, 0, 0];
for (let a = 0; a < dirs; a++) {
for (let b = 0; b < dirs; b++) {
const theta = (2 * PI * (a + 0.5)) / dirs;
const cosPhi = 1 - (2 * (b + 0.5)) / dirs;
const sinPhi = Math.sqrt(1 - cosPhi * cosPhi);
march(
0,
0,
height,
Math.cos(theta) * sinPhi,
Math.sin(theta) * sinPhi,
cosPhi,
sx,
0,
sz,
steps,
true,
false,
false,
mieScale,
trLut,
undefined,
res
);
for (let k = 0; k < 3; k++) {
sumMs[k] += (res.multiScatAs1[k] * FOUR_PI) / count;
sumL[k] += (res.L[k] * FOUR_PI) / count;
}
}
}
const o = (j * w + i) * 3;
for (let k = 0; k < 3; k++) {
const fms = sumMs[k] / FOUR_PI; // equation 7
const l2 = sumL[k] / FOUR_PI; // equation 5
lut.data[o + k] = l2 / (1 - fms); // equation 10: the geometric series of all orders
}
}
}
return lut;
}
/**
* Sky-view LUT axes (Hillaire's non-linear mapping, upper hemisphere only: nothing below the horizon is drawn). y: view zenith angle
* from 0 to the horizon's, squared toward it; x: the azimuth from the sun, the cosine mapped `-(2 c^2 - 1)`.
*/
function skyViewParamsToUv(viewCos: number, cosFromSun: number, horizonZenith: number): [number, number] {
const zen = Math.acos(clamp(viewCos, -1, 1));
let coord = 1 - clamp(zen / horizonZenith, 0, 1);
coord = Math.sqrt(coord);
const y = 1 - coord; // 0 at the zenith, 1 at the horizon
const x = Math.sqrt(clamp(-cosFromSun * 0.5 + 0.5, 0, 1));
return [x, y];
}
function skyViewUvToParams(x: number, y: number, horizonZenith: number): [number, number] {
let coord = 1 - y;
coord *= coord;
coord = 1 - coord;
const viewCos = Math.cos(horizonZenith * coord);
const c = x * x;
return [viewCos, -(c * 2 - 1)];
}
function bakeSkyView(
sunCos: number,
observerKm: number,
mieScale: number,
trLut: Lut2,
ms: Lut2,
horizonZenith: number
): Lut2 {
const { w, h, steps } = LUT.skyView;
const lut = makeLut(w, h, 3);
const res: March = {
L: [0, 0, 0],
transmittance: [1, 1, 1],
multiScatAs1: [0, 0, 0],
opticalDepth: [0, 0, 0],
};
const sx = Math.sqrt(Math.max(0, 1 - sunCos * sunCos));
const height = PLANET_KM + observerKm;
for (let j = 0; j < h; j++) {
for (let i = 0; i < w; i++) {
const [viewCos, lightViewCos] = skyViewUvToParams(i / (w - 1), j / (h - 1), horizonZenith);
const sinZ = Math.sqrt(1 - viewCos * viewCos);
march(
0,
0,
height,
sinZ * lightViewCos,
sinZ * Math.sqrt(Math.max(0, 1 - lightViewCos * lightViewCos)),
viewCos,
sx,
0,
sunCos,
steps,
false,
true,
true,
mieScale,
trLut,
ms,
res
);
const o = (j * w + i) * 3;
lut.data[o] = res.L[0];
lut.data[o + 1] = res.L[1];
lut.data[o + 2] = res.L[2];
}
}
return lut;
}
/** Number of ray-march steps the three bakes take: the CPU cost in the unit the tests budget. */
export const bakeCostSteps = (): number => {
const t = LUT.transmittance;
const m = LUT.multiScatter;
const s = LUT.skyView;
return t.w * t.h * t.steps + m.w * m.h * m.dirs * m.dirs * m.steps + s.w * s.h * s.steps;
};
export function bakeAtmosphere(opts: AtmosphereOptions): Atmosphere {
const mieScale = opts.mieScale ?? NO_WEATHER;
const observerKm = opts.observerKm ?? 0.151;
const sunCos = Math.cos(((90 - opts.sunElevationDeg) * PI) / 180);
const height = PLANET_KM + observerKm;
const beta = Math.acos(Math.sqrt(height * height - PLANET_KM * PLANET_KM) / height);
const horizonZenith = PI - beta;
const transmittance = bakeTransmittance(mieScale);
const multiScatter = bakeMultiScatter(mieScale, transmittance);
const skyView = bakeSkyView(sunCos, observerKm, mieScale, transmittance, multiScatter, horizonZenith);
const tmp = [0, 0, 0];
const trv = [0, 0, 0];
const atm: Atmosphere = {
transmittance,
multiScatter,
skyView,
sunElevationDeg: opts.sunElevationDeg,
mieScale,
observerKm,
sunCosZenith: sunCos,
horizonZenith,
transmittanceToSpace(mu, out) {
const [u, v] = transmittanceUv(height, mu);
sampleTexel(transmittance, u * (transmittance.w - 1), v * (transmittance.h - 1), trv);
out[0] = trv[0];
out[1] = trv[1];
out[2] = trv[2];
},
radiance(viewCos, cosFromSun, out) {
const [x, y] = skyViewParamsToUv(Math.max(viewCos, Math.cos(horizonZenith)), cosFromSun, horizonZenith);
sampleTexel(skyView, x * (skyView.w - 1), y * (skyView.h - 1), tmp);
out[0] = tmp[0];
out[1] = tmp[1];
out[2] = tmp[2];
},
irradiance: { direct: [0, 0, 0], ambient: [0, 0, 0] },
};
// Direct: the sun's transmittance times the cosine; ambient: the sky's radiance averaged over the upper hemisphere (cosine weighted).
const direct: number[] = [0, 0, 0];
if (opts.sunElevationDeg > -1) {
atm.transmittanceToSpace(Math.max(sunCos, 0), direct);
const c = Math.max(0, sunCos);
direct[0] *= c;
direct[1] *= c;
direct[2] *= c;
}
const amb = [0, 0, 0];
const out = [0, 0, 0];
const N = 12;
const M = 6;
let wsum = 0;
for (let a = 0; a < N; a++) {
for (let b = 0; b < M; b++) {
const az = (PI * (a + 0.5)) / N; // 0..pi from the sun: the sky is symmetric
const zen = (0.5 * PI * (b + 0.5)) / M;
const wgt = Math.cos(zen) * Math.sin(zen);
atm.radiance(Math.cos(zen), Math.cos(az), out);
for (let k = 0; k < 3; k++) amb[k] += out[k] * wgt;
wsum += wgt;
}
}
atm.irradiance.direct = [direct[0], direct[1], direct[2]];
atm.irradiance.ambient = [(amb[0] / wsum) * PI, (amb[1] / wsum) * PI, (amb[2] / wsum) * PI];
return atm;
}

View file

@ -0,0 +1,95 @@
// The ridge scene's sky: Vienna's sun on the real clock, the atmosphere baked from it, a camera, and the display that makes it
// pixels. `makePhysicalSky` does the one expensive thing (the LUT bake, tens of ms) and fits the display from a fixed 16:9
// grid, so resizing the window repaints the same sky. `paintSky` is per-texel lookups only.
import type { FixedSkyState } from '../skyBand';
import { bakeAtmosphere } from './earthAtmosphere';
import type { Atmosphere } from './earthAtmosphere';
import { domeSize, HORIZON_AT, renderDome, viewFor } from './skyDome';
import type { Dome, DomeView } from './skyDome';
import type { Srgb } from './skyColour';
import { display8, fitDisplay } from './toneMap';
import type { Display, Fit } from './toneMap';
import { sunForState, VIENNA } from './vienna';
import type { SunSample } from './vienna';
import { weatherOf } from './weather';
import type { Weather } from './weather';
export interface PhysicalSky {
state: FixedSkyState;
sun: SunSample;
weather: Weather;
atm: Atmosphere;
/** The display fitted to this sky's budget. */
fit: Fit;
/** The sky at the top, the middle and the horizon of the image (mean of each row), for the layers' haze and the stars' cover. */
stops: { top: Srgb; mid: Srgb; horizon: Srgb };
/** How long the bake took, ms. */
bakeMs: number;
}
const CANON = { w: 64, h: 36, aspect: 16 / 9 };
const clock = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
export function rowMean(dome: Dome, display: Display, row: number): Srgb {
const out = [0, 0, 0];
const acc = [0, 0, 0];
const j = Math.min(dome.h - 1, Math.max(0, row));
for (let i = 0; i < dome.w; i += 1) {
const o = (j * dome.w + i) * 3;
display8(dome.hdr[o], dome.hdr[o + 1], dome.hdr[o + 2], display, out);
acc[0] += out[0];
acc[1] += out[1];
acc[2] += out[2];
}
return [Math.round(acc[0] / dome.w), Math.round(acc[1] / dome.w), Math.round(acc[2] / dome.w)];
}
export function makePhysicalSky(state: FixedSkyState, now: Date, weatherValue: number): PhysicalSky {
const t0 = clock();
const sun = sunForState(state, now);
const weather = weatherOf(weatherValue);
const atm = bakeAtmosphere({
sunElevationDeg: sun.elevation,
mieScale: weather.mieScale,
observerKm: VIENNA.altitudeKm,
});
const dome = renderDome(atm, sun, viewFor(sun, CANON.aspect), CANON.w, CANON.h);
const fit = fitDisplay(state, dome.hdr, dome.meanLuma);
const at = (f: number) => rowMean(dome, fit.display, Math.round(f * CANON.h));
return {
state,
sun,
weather,
atm,
fit,
stops: { top: at(0.02), mid: at(0.5), horizon: at(HORIZON_AT) },
bakeMs: clock() - t0,
};
}
export interface SkyPixels {
w: number;
h: number;
/** RGBA, 8 bit. */
data: Uint8ClampedArray;
dome: Dome;
view: DomeView;
}
/** The sky for a viewport, as a small RGBA grid the host canvas upscales smoothly. */
export function paintSky(p: PhysicalSky, cssW: number, cssH: number): SkyPixels {
const { w, h } = domeSize(cssW, cssH);
const view = viewFor(p.sun, cssW / cssH);
const dome = renderDome(p.atm, p.sun, view, w, h);
const data = new Uint8ClampedArray(w * h * 4);
const out = [0, 0, 0];
for (let i = 0; i < w * h; i += 1) {
display8(dome.hdr[i * 3], dome.hdr[i * 3 + 1], dome.hdr[i * 3 + 2], p.fit.display, out);
data[i * 4] = out[0];
data[i * 4 + 1] = out[1];
data[i * 4 + 2] = out[2];
data[i * 4 + 3] = 255;
}
return { w, h, data, dome, view };
}

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { BakeParams, bakeSlice, faceLight, layerPaint, sunDirection } from './ridgeBake';
import { RIDGE_NIGHT_BAND, SKY_BAND } from '../skyBand';
import { SKY_BAND } from '../skyBand';
import { ridgeSkyFor } from '../skyForState';
import { LACUNARITY, LAYERS, profile } from './ridgeWorld';
import { skyColours, SkyColours } from './skyColour';
@ -353,9 +353,8 @@ describe('the state band: the theme ink clears 4.5:1 on a ridge as on bare sky',
}
});
it("night: no ridge pixel is lighter than the ceiling, the state's or the ridge's own", () => {
it('night: no ridge pixel is lighter than the ceiling', () => {
expect(range(SKY_BAND.night).hi).toBeLessThanOrEqual(SKY_BAND.night.ceiling);
expect(range(RIDGE_NIGHT_BAND).hi).toBeLessThanOrEqual(RIDGE_NIGHT_BAND.ceiling);
});
it('is the identity with no band, so nothing else changes', () => {

View file

@ -5,6 +5,7 @@
// and bakes the result.
import type { SkyBand } from '../skyBand';
import type { PhysicalSky } from './physicalSky';
export type Srgb = readonly [number, number, number];
@ -21,6 +22,8 @@ export interface SkyColours {
sunAzimuth?: number;
/** The state's luminance band (skyBand.ts): the layers are shaped into it as the sky is, so the theme ink clears 4.5:1 on a ridge as on bare sky. Omit for none. */
band?: SkyBand;
/** The physical sky this palette was read from; when present the host canvas is painted from it, not from the three stops. */
physical?: PhysicalSky;
}
export function skyColours(_now: Date = new Date()): SkyColours {

View file

@ -0,0 +1,99 @@
// The sky as a camera sees it: for each texel of a small grid, the view ray, its radiance out of the atmosphere's sky-view
// LUT (`earthAtmosphere.ts`), the sun's disc, and nothing else; one lookup per texel, no march. The result is linear HDR
// radiance per unit of sun irradiance (a `Float32Array`); `toneMap.ts` makes it pixels.
import type { Atmosphere } from './earthAtmosphere';
import type { SunSample } from './vienna';
const RAD = Math.PI / 180;
/** The sun's angular radius in radians (Hillaire's/Bruneton's `sun_angular_radius`). */
export const SUN_RADIUS = 0.004675;
/** Vertical field of view of the camera, degrees, and where the horizon sits down the image: the ridge scene's sky stops. */
export const VFOV_DEG = 50;
export const HORIZON_AT = 0.78;
export interface DomeView {
/** Compass azimuth the camera faces, degrees clockwise from north. */
azimuthDeg: number;
/** Width over height of the viewport. */
aspect: number;
}
/** Where the camera looks: toward the sun's side while it is up, so the sun sits right of centre; south in the dark. */
export const viewFor = (sun: SunSample, aspect: number): DomeView => ({
azimuthDeg: sun.elevation > -6 ? sun.azimuth - 24 : 180,
aspect,
});
export interface Dome {
w: number;
h: number;
/** Linear radiance, 3 floats per texel, row 0 at the top. */
hdr: Float32Array;
/** Mean over the grid of the log of the radiance luminance, exponentiated: what auto-exposure reads. */
meanLuma: number;
}
/** Grid for a viewport: about 8 CSS px per texel, capped. */
export const domeSize = (cssW: number, cssH: number): { w: number; h: number } => ({
w: Math.min(256, Math.max(8, Math.ceil(cssW / 8))),
h: Math.min(160, Math.max(8, Math.ceil(cssH / 8))),
});
/**
* Render the HDR sky. Rays below the horizon take the horizon's radiance (the ridges cover them). `sunVisible` false leaves the
* disc out (the glare it would add is still in the LUT); the disc is the transmittance-attenuated sun, its coverage of each
* texel computed from the angular distance, not a hard edge.
*/
export function renderDome(atm: Atmosphere, sun: SunSample, view: DomeView, w: number, h: number): Dome {
const hdr = new Float32Array(w * h * 3);
const tanHalf = Math.tan((VFOV_DEG * RAD) / 2);
const fa = view.azimuthDeg * RAD;
const fx = Math.sin(fa);
const fy = Math.cos(fa);
const rx = Math.cos(fa);
const ry = -Math.sin(fa);
const sunAz = sun.azimuth * RAD;
const sunEl = sun.elevation * RAD;
const sx = Math.cos(sunEl) * Math.sin(sunAz);
const sy = Math.cos(sunEl) * Math.cos(sunAz);
const sz = Math.sin(sunEl);
const trans = [0, 0, 0];
atm.transmittanceToSpace(Math.max(sz, 0), trans);
// The disc's radiance for unit irradiance: 1 / (pi r^2), through the air to the sun.
const discRadiance = 1 / (Math.PI * SUN_RADIUS * SUN_RADIUS);
const texelAngle = (2 * tanHalf) / h; // radians per texel, small-angle
const rgb = [0, 0, 0];
let logSum = 0;
for (let j = 0; j < h; j += 1) {
const v = (HORIZON_AT - (j + 0.5) / h) * 2 * tanHalf;
for (let i = 0; i < w; i += 1) {
const u = ((i + 0.5) / w - 0.5) * view.aspect * 2 * tanHalf;
const dx = fx + rx * u;
const dy = fy + ry * u;
const dz = v;
const n = Math.hypot(dx, dy, dz);
const vz = dz / n;
const cosFromSun = Math.cos(Math.atan2(dx, dy) - sunAz);
atm.radiance(vz, cosFromSun, rgb);
let r = rgb[0];
let g = rgb[1];
let b = rgb[2];
if (sz > -0.01 && vz > 0) {
const cosSun = (dx * sx + dy * sy + dz * sz) / n;
const ang = Math.acos(Math.max(-1, Math.min(1, cosSun)));
const cover = Math.max(0, Math.min(1, (SUN_RADIUS - ang) / texelAngle + 0.5));
if (cover > 0) {
r += discRadiance * cover * trans[0];
g += discRadiance * cover * trans[1];
b += discRadiance * cover * trans[2];
}
}
const o = (j * w + i) * 3;
hdr[o] = r;
hdr[o + 1] = g;
hdr[o + 2] = b;
logSum += Math.log(Math.max(1e-9, 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]));
}
}
return { w, h, hdr, meanLuma: Math.exp(logSum / (w * h)) };
}

View file

@ -0,0 +1,155 @@
// The one display transform of the ridge scene: linear scene radiance in, 8-bit sRGB out. Exposure, then the ACES fit of
// Stephen Hill (BakingLab, "ACES.hlsl": the sRGB-to-AP1 input matrix, the RRT and ODT rational fit, the AP1-to-sRGB output
// matrix), then the sRGB encode. A per-pixel function with no state, applied once to the baked sky (CPU: the scene is a 2D
// canvas, there is no float render target to fall back from).
//
// Exposure is chosen per sky state (`exposureFor`): the sky is physical radiance, so brightness is the camera's, not a
// shaping of the result. Day and dusk expose to the sky's own luminance (adaptation, as an eye or auto-exposure does) so the
// darkest of the sky stays where the state's dark ink needs a backdrop; night is fixed, the physical dark.
import type { FixedSkyState } from '../skyBand';
type Rgb = readonly [number, number, number];
const IN: readonly Rgb[] = [
[0.59719, 0.35458, 0.04823],
[0.076, 0.90834, 0.01566],
[0.0284, 0.13383, 0.83777],
];
const OUT: readonly Rgb[] = [
[1.60475, -0.53108, -0.07367],
[-0.10208, 1.10813, -0.00605],
[-0.00327, -0.07276, 1.07602],
];
const fit = (v: number): number => (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081);
const encode = (l: number): number => {
const c = Math.min(1, Math.max(0, l));
return 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
};
/** ACES (Hill's fit) of linear radiance `r, g, b` times `exposure`, to linear display RGB 0..1, written into `out`. */
export function acesLinear(r: number, g: number, b: number, exposure: number, out: number[]): void {
const x = r * exposure;
const y = g * exposure;
const z = b * exposure;
const p = [0, 1, 2].map((i) => fit(Math.max(0, IN[i][0] * x + IN[i][1] * y + IN[i][2] * z)));
for (let i = 0; i < 3; i += 1) out[i] = Math.min(1, Math.max(0, OUT[i][0] * p[0] + OUT[i][1] * p[1] + OUT[i][2] * p[2]));
}
/** The 8-bit sRGB of linear radiance, exposed and tone-mapped. */
export function toneMap8(r: number, g: number, b: number, exposure: number, out: number[]): void {
acesLinear(r, g, b, exposure, out);
out[0] = Math.round(encode(out[0]));
out[1] = Math.round(encode(out[1]));
out[2] = Math.round(encode(out[2]));
}
/** WCAG relative luminance of linear display RGB. */
export const displayLuma = (c: ArrayLike<number>): number => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
/** What a sky state needs of its backdrop's display luminance, from the ink it wears (skyBand.ts, skyInk.ts). */
export interface Budget {
/** The darkest texel may not be darker than this (dark ink needs a light backdrop). 0: no floor. */
floor: number;
/** The lightest texel may not be lighter than this (light ink needs a dark one). 1: no ceiling. */
ceiling: number;
/** Display luminance the log-mean of the sky is exposed to, before the budget is applied; undefined: a fixed exposure. */
key?: number;
/** Exposure range, in multiples of scene radiance (per unit sun irradiance). */
min: number;
max: number;
}
/** Display luminance slack for the 8-bit rounding and the smooth upscale of the baked grid. */
export const MARGIN = 0.004;
/**
* Day and Dusk wear dark ink (floor: its bound plus margin, `SKY_BAND` day 0.317, dusk 0.4744); Night wears light ink, and its
* dimmer foldout headers (L 0.386 on screen, rat-crest-a7c95ce-20261111) need a backdrop of at most 0.047.
*/
export const BUDGET: Record<FixedSkyState, Budget> = {
day: { floor: 0.317 + MARGIN, ceiling: 1, key: 0.45, min: 4, max: 60 },
dusk: { floor: 0.4744 + MARGIN, ceiling: 1, key: 0.5, min: 4, max: 400 },
night: { floor: 0, ceiling: 0.047 - MARGIN, key: 0.03, min: 8, max: 3000 },
};
/** The display transform of a sky: exposure, then ACES, then a black lift (veiling glare) in display-linear. */
export interface Display {
exposure: number;
/** Added to every channel after the curve, then rescaled so white stays white: out = lift + (1 - lift) * aces. */
lift: number;
}
/** The 8-bit sRGB of linear radiance through a display. */
export function display8(r: number, g: number, b: number, d: Display, out: number[]): void {
acesLinear(r, g, b, d.exposure, out);
for (let i = 0; i < 3; i += 1) out[i] = Math.round(encode(d.lift + (1 - d.lift) * out[i]));
}
const decode = (v: number): number => {
const c = v / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
/** Display luminance (WCAG relative, from the encoded bytes) of a radiance through a display. */
export function displayLumaOf(r: number, g: number, b: number, d: Display, tmp: number[] = [0, 0, 0]): number {
display8(r, g, b, d, tmp);
return displayLuma([decode(tmp[0]), decode(tmp[1]), decode(tmp[2])]);
}
export interface Fit {
display: Display;
/** Darkest and lightest display luminance of the grid under `display`. */
lo: number;
hi: number;
/** The exposure before the budget moved it, and what the budget took (for the numbers a report cites). */
keyExposure: number;
}
/**
* Choose the display of a sky, the one place brightness is decided. `hdr` is the grid's radiance (3 floats a texel) and
* `meanLuma` its log-mean luminance. Exposure starts at the state's key (adaptation); a ceiling lowers it until the
* lightest texel is inside the budget (bisection: luminance is monotone in exposure); a floor raises the black lift until the
* darkest is (luminance is monotone in lift, and linear: the lift needed is solved in one step).
*/
export function fitDisplay(state: FixedSkyState, hdr: Float32Array, meanLuma: number): Fit {
const b = BUDGET[state];
const keyExposure =
b.key === undefined ? b.min : Math.min(b.max, Math.max(b.min, (b.key * 1.6) / Math.max(meanLuma, 1e-9)));
const n = hdr.length / 3;
const stride = Math.max(1, Math.floor(n / 4000));
const tmp = [0, 0, 0];
const range = (d: Display) => {
let lo = 2;
let hi = -1;
for (let i = 0; i < n; i += stride) {
const l = displayLumaOf(hdr[i * 3], hdr[i * 3 + 1], hdr[i * 3 + 2], d, tmp);
if (l < lo) lo = l;
if (l > hi) hi = l;
}
return { lo, hi };
};
let exposure = keyExposure;
let r = range({ exposure, lift: 0 });
if (r.hi > b.ceiling) {
let lo = 0;
let hiE = exposure;
for (let k = 0; k < 24; k += 1) {
const mid = (lo + hiE) / 2;
if (range({ exposure: mid, lift: 0 }).hi > b.ceiling) hiE = mid;
else lo = mid;
}
exposure = lo;
r = range({ exposure, lift: 0 });
}
let lift = 0;
if (r.lo < b.floor) {
// Linear in the displayed linear luminance: L' = lift + (1 - lift) L, but through the 8-bit encode; solve in linear then check once.
lift = (b.floor - r.lo) / (1 - r.lo);
let probe = range({ exposure, lift });
for (let k = 0; k < 6 && probe.lo < b.floor; k += 1) {
lift += (b.floor - probe.lo) * 1.05;
probe = range({ exposure, lift });
}
r = probe;
}
return { display: { exposure, lift }, lo: r.lo, hi: r.hi, keyExposure };
}

View file

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { solarNoon, STATE_ELEVATION, sunForState, VIENNA, viennaSun } from './vienna';
const at = (iso: string) => new Date(iso);
describe('the sun over Vienna', () => {
it('stands at 90 - lat + declination at solar noon: the solstices and equinox', () => {
const june = viennaSun(solarNoon(at('2026-06-21T12:00:00Z')));
expect(june.elevation).toBeCloseTo(90 - VIENNA.lat + 23.44, 0);
const december = viennaSun(solarNoon(at('2026-12-21T12:00:00Z')));
expect(december.elevation).toBeCloseTo(90 - VIENNA.lat - 23.44, 0);
const march = viennaSun(solarNoon(at('2026-03-20T12:00:00Z')));
expect(march.elevation).toBeCloseTo(90 - VIENNA.lat, 0);
});
it('is due south at solar noon, east in the morning and west in the afternoon', () => {
const noon = solarNoon(at('2026-09-23T12:00:00Z'));
expect(Math.abs(viennaSun(noon).azimuth - 180)).toBeLessThan(2); // solarNoon is found to 5 minutes, which is 1.25 degrees of azimuth
expect(viennaSun(new Date(noon.getTime() - 3 * 3600_000)).azimuth).toBeLessThan(150);
expect(viennaSun(new Date(noon.getTime() + 3 * 3600_000)).azimuth).toBeGreaterThan(210);
});
it('solar noon at 16.37 E falls near 10:40 UTC in mid November', () => {
// 16.3738 E is 65.5 minutes ahead of Greenwich; the equation of time in mid November is +15 minutes (sun early): 12:00 - 65.5 - 15 = 10:40 UTC.
const n = solarNoon(at('2026-11-11T12:00:00Z'));
const minutes = n.getUTCHours() * 60 + n.getUTCMinutes();
expect(minutes).toBeGreaterThan(10 * 60 + 30);
expect(minutes).toBeLessThan(10 * 60 + 50);
});
it('has a short day in December and a long one in June: published 8 h 4 min and 16 h 0 min with refraction, which adds about 6 minutes each end the ephemeris leaves out', () => {
const daylight = (day: string) => {
let n = 0;
for (let m = 0; m < 24 * 60; m += 1) if (viennaSun(new Date(Date.parse(day) + m * 60_000)).elevation > 0) n += 1;
return n;
};
expect(Math.abs(daylight('2026-12-21T00:00:00Z') - (8 * 60 + 4))).toBeLessThan(15);
expect(Math.abs(daylight('2026-06-21T00:00:00Z') - (16 * 60 + 0))).toBeLessThan(15);
});
it('sunForState: the real sun when it is in the state, else a moment of today that is', () => {
const noon = solarNoon(at('2026-11-11T12:00:00Z'));
expect(sunForState('day', noon).time).toEqual(noon);
const midnight = new Date(noon.getTime() + 12 * 3600_000);
for (const state of ['day', 'dusk', 'night'] as const) {
for (const now of [noon, midnight, at('2026-11-11T15:30:00Z')]) {
const s = sunForState(state, now);
const [lo, hi] = STATE_ELEVATION[state];
expect(s.elevation).toBeGreaterThanOrEqual(lo - 0.01);
expect(s.elevation).toBeLessThanOrEqual(hi + 0.01);
}
}
expect(sunForState('dusk', noon).elevation).toBeCloseTo(0, 1);
expect(sunForState('dusk', noon).azimuth).toBeGreaterThan(180); // sets in the west half
});
});

View file

@ -0,0 +1,74 @@
// The sun over Vienna (48.2082 N, 16.3738 E, 151 m) at a real moment: one ephemeris, `sunPosition` of src/sky/sun.js (the
// low-precision NOAA/Meeus series, a hundredth of a degree for 1900..2100; no refraction), and the choice of a moment for each
// fixed sky state. The ridge sky is the physical atmosphere (`earthAtmosphere.ts`) lit from this sun.
import { sunPosition } from '../../../../sky/sun.js';
import { DAY_FROM } from '../skyState';
import type { FixedSkyState } from '../skyBand';
export const VIENNA = { lat: 48.2082, lon: 16.3738, altitudeKm: 0.151 } as const;
export interface SunSample {
/** Degrees above the horizon, no refraction. */
elevation: number;
/** Degrees clockwise from north. */
azimuth: number;
/** The moment this is the sun of. */
time: Date;
}
export function viennaSun(time: Date): SunSample {
const { elevation, azimuth } = sunPosition(time, VIENNA.lat, VIENNA.lon) as {
elevation: number;
azimuth: number;
};
return { elevation, azimuth, time };
}
const MIN = 60_000;
/** Sun elevation range of each fixed state: the thresholds `resolveSkyState` uses (day from DAY_FROM, night below -DAY_FROM). */
export const STATE_ELEVATION: Record<FixedSkyState, readonly [number, number]> = {
day: [DAY_FROM, 90],
dusk: [-DAY_FROM, DAY_FROM],
night: [-90, -DAY_FROM],
};
const startOfUtcDay = (t: Date): number => Math.floor(t.getTime() / (24 * 60 * MIN)) * 24 * 60 * MIN;
/** The moment of the day (UTC day of `t`, every 5 minutes) the sun is highest over Vienna: solar noon to the step. */
export function solarNoon(t: Date): Date {
const d0 = startOfUtcDay(t);
let best = d0;
let bestEl = -90;
for (let m = 0; m < 24 * 60; m += 5) {
const el = viennaSun(new Date(d0 + m * MIN)).elevation;
if (el > bestEl) {
bestEl = el;
best = d0 + m * MIN;
}
}
return new Date(best);
}
/**
* The sun a state draws. The real sun over Vienna now when it is in the state's range (so 'auto' is the real sky); otherwise the
* sun of today at a moment that is: noon for Day, the evening sunset for Dusk, the opposite of noon for Night. A picker that
* forces Day at night shows a noon sky, as a person looking at a noon photograph would.
*/
export function sunForState(state: FixedSkyState, now: Date): SunSample {
const real = viennaSun(now);
const [lo, hi] = STATE_ELEVATION[state];
if (real.elevation >= lo && real.elevation <= hi) return real;
const noon = solarNoon(now);
if (state === 'day') return viennaSun(noon);
if (state === 'night') return viennaSun(new Date(noon.getTime() + 12 * 60 * MIN));
// Dusk: bisect the evening crossing of the horizon between noon and the opposite moment.
let a = noon.getTime();
let b = noon.getTime() + 12 * 60 * MIN;
for (let i = 0; i < 30; i += 1) {
const m = (a + b) / 2;
if (viennaSun(new Date(m)).elevation > 0) a = m;
else b = m;
}
return viennaSun(new Date((a + b) / 2));
}

View file

@ -0,0 +1,44 @@
// One weather number, 0..1, and what it does to the sky. No network source yet: the number is a constant, or the page URL's
// `?weather=` for a still. 0 clear, about 0.3 fair (a few clouds), 0.55 overcast, 0.75 drizzle and mist, 1 steady rain.
// What each part touches: haze multiplies the atmosphere's Mie coefficients before the LUTs are baked (`earthAtmosphere.ts`:
// the same air, more aerosol); cloud cover, mist and rain are layers over the baked sky (`clouds.ts`, `Ridge.ts`).
export const DEFAULT_WEATHER = 0.3;
export interface Weather {
/** The number it was made from. */
value: number;
/** 0..1 share of the sky the cloud layer covers. */
cover: number;
/** Multiplier on the Mie coefficients: 1 is the reference air. */
mieScale: number;
/** 0..1 strength of the mist over the lower ridges. */
mist: number;
/** 0..1 rain streaks; 0 draws none and builds no layer. */
rain: number;
}
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
const smooth = (a: number, b: number, v: number): number => {
const t = clamp01((v - a) / (b - a));
return t * t * (3 - 2 * t);
};
export function weatherOf(value: number): Weather {
const w = clamp01(Number.isFinite(value) ? value : DEFAULT_WEATHER);
return {
value: w,
cover: smooth(0.1, 0.7, w),
mieScale: 1 + 5 * smooth(0.2, 1, w),
mist: smooth(0.45, 0.9, w),
rain: smooth(0.68, 0.95, w),
};
}
/** The page URL's `?weather=<0..1>`, when it is a number. */
export function urlWeather(search: string = typeof location === 'undefined' ? '' : location.search): number | undefined {
const v = new URLSearchParams(search).get('weather');
if (v === null || v.trim() === '') return undefined;
const n = Number(v);
return Number.isFinite(n) ? clamp01(n) : undefined;
}

View file

@ -73,14 +73,6 @@ export const SKY_BAND: Record<FixedSkyState, SkyBand> = {
night: { floor: 0, ceiling: 0.077394, lift: hexToLinear('#5a6a9a') },
};
/**
* The ridge scene's night band, lower than the state's: the navigator's foldout headers wear a dimmer ink than its room rows
* (L 0.386 against 0.594 measured on screen, rat-crest-a7c95ce-20261111), so they need a backdrop of at most 0.047 where rows need 0.093,
* and the baked sky (0.058 to 0.077) sat above it (headers 3.9 to 4.2:1). 0.04 leaves the 8-bit rounding and the sky's blur
* its share. The ridge draws its own sky, so it takes the darker ceiling alone; the terrain keeps SKY_BAND.night.
*/
export const RIDGE_NIGHT_BAND: SkyBand = { ...SKY_BAND.night, ceiling: 0.04 };
/**
* Shape a linear sky colour into the band. Below the floor it mixes toward `lift` by exactly the amount
* that lands on the floor (continuous at the floor, hue kept); above the ceiling it scales down to the

View file

@ -9,19 +9,13 @@ import {
TERRAIN_REACH_DEG,
ridgeDrawn,
ridgeSkyFor,
ridgeSkyKey,
terrainDrawn,
terrainSkyFor,
} from './skyForState';
import {
displayedLuminance,
hexToLinear,
luma,
RIDGE_NIGHT_BAND,
shapeToBand,
SKY_BAND,
} from './skyBand';
import { displayedLuminance, luma, shapeToBand, SKY_BAND } from './skyBand';
import type { FixedSkyState } from './skyBand';
import { setSkyClock, skyBackdrop } from './skyState';
import { DAY_FROM, setSkyClock, skyBackdrop } from './skyState';
import { view } from './terrain/terrainWorld';
import { skyThemeId } from './skyTheme';
@ -84,60 +78,50 @@ describe('the bands the text ink needs', () => {
});
describe('ridgeSkyFor', () => {
it('carries the state band, which the layers are shaped into', () => {
for (const state of ['day', 'dusk', 'night'] as const) {
expect(ridgeSkyFor(state).band).toEqual(
state === 'night' ? RIDGE_NIGHT_BAND : SKY_BAND[state]
);
it('carries the state band, which the layers are shaped into, and the physical sky of that state', () => {
for (const state of STATES) {
const sky = ridgeSkyFor(state, 0.3);
expect(sky.band).toEqual(SKY_BAND[state]);
expect(sky.physical?.state).toBe(state);
expect(sky.physical?.stops).toEqual({ top: sky.top, mid: sky.mid, horizon: sky.horizon });
}
});
it('draws the gradient of the sky auto resolves to, never nothing', () => {
it('draws the sky auto resolves to, never nothing', () => {
const key = (s: ReturnType<typeof ridgeSkyFor>) => [s.top, s.mid, s.horizon, s.band, s.physical?.sun];
setSkyClock(() => atStandardTime(12, 0));
expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day'));
expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('day', 0.3)));
setSkyClock(() => atStandardTime(0, 0));
expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('night'));
expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('night', 0.3)));
setSkyClock(() => atStandardTime(18, 10));
expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('dusk'));
expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('dusk', 0.3)));
});
it('draws a lavender, rose and orange twilight for dusk', () => {
const d = ridgeSkyFor('dusk');
expect(d.ground).toEqual(skyColours().ground);
const [t, m, h] = [d.top, d.mid, d.horizon];
expect(t[2]).toBeGreaterThan(t[1]); // lavender: blue over green
expect(t[0]).toBeGreaterThan(t[1]);
expect(m[0]).toBeGreaterThan(m[2]); // rose: red over blue
expect(m[2]).toBeGreaterThan(m[1]);
expect(h[0]).toBeGreaterThan(h[1]); // orange: red over green over blue
expect(h[1]).toBeGreaterThan(h[2]);
it("is lit from the real sun over Vienna: a forced state's sun is inside the state, the clear noon sky is blue", () => {
const day = ridgeSkyFor('day', 0);
expect(day.physical?.sun.elevation).toBeGreaterThanOrEqual(DAY_FROM);
expect(day.top[2]).toBeGreaterThan(day.top[0]);
const dusk = ridgeSkyFor('dusk', 0);
expect(Math.abs(dusk.physical?.sun.elevation ?? 99)).toBeLessThanOrEqual(DAY_FROM);
expect(ridgeSkyFor('night', 0).physical?.sun.elevation).toBeLessThanOrEqual(-DAY_FROM);
expect(day.ground).toEqual(skyColours().ground);
});
it('draws the baked gradient for day', () => {
const day = ridgeSkyFor('day');
expect(hex(day.top)).toBe(baked.noonday.gradient[0]);
expect(hex(day.horizon)).toBe(baked.noonday.gradient[6]);
it('keys a rebuild by state and weather, not by the moving sun', () => {
setSkyClock(() => atStandardTime(12, 0));
expect(ridgeSkyKey('auto', 0.3)).toBe(ridgeSkyKey('day', 0.3));
expect(ridgeSkyKey('day', 0.3)).not.toBe(ridgeSkyKey('day', 0.8));
expect(ridgeSkyKey('day', 0.3)).not.toBe(ridgeSkyKey('night', 0.3));
});
it("night: draws the baked gradient's hues, lowered to the ridge's night ceiling, which the layers carry", () => {
const night = ridgeSkyFor('night');
const lum = (c: readonly [number, number, number]) => luma(hexToLinear(hex(c)));
expect(night.band).toBe(RIDGE_NIGHT_BAND);
expect(RIDGE_NIGHT_BAND.ceiling).toBeLessThan(SKY_BAND.night.ceiling);
const BYTE = 0.0005; // the stop is rounded to 8 bits after it is shaped
expect(lum(night.top)).toBeLessThanOrEqual(RIDGE_NIGHT_BAND.ceiling + BYTE);
expect(lum(night.horizon)).toBeLessThanOrEqual(RIDGE_NIGHT_BAND.ceiling + BYTE);
// the hue survives: the brightest stop is the baked one's brightest channel
const b = hexToLinear(baked.midnight.gradient[6]);
const d = hexToLinear(hex(night.horizon));
expect(d.indexOf(Math.max(...d))).toBe(b.indexOf(Math.max(...b)));
});
it.each(STATES)('%s: every stop and blend lies in the band its text ink needs', (state) => {
it.each(STATES)('%s: the display is fitted to the budget its text ink needs', (state) => {
const { floor, ceiling } = needs(state);
const r = ridgeDrawn(state);
expect(r.darkest).toBeGreaterThanOrEqual(floor);
expect(r.lightest).toBeLessThanOrEqual(ceiling);
const fit = ridgeSkyFor(state, 0.3).physical?.fit;
expect(fit?.lo).toBeGreaterThanOrEqual(r.darkest - 1e-9);
expect(fit?.hi).toBeLessThanOrEqual(r.lightest + 1e-9);
});
});

View file

@ -8,69 +8,48 @@ import { chooseSky, skyColour, urlSky } from './terrain/terrainSky';
import type { SkyName } from './terrain/terrainSky';
import type { SkyColours, Srgb } from './ridge/skyColour';
import { skyColours } from './ridge/skyColour';
import { resolveSkyState } from './skyState';
import { makePhysicalSky } from './ridge/physicalSky';
import { BUDGET } from './ridge/toneMap';
import { DEFAULT_WEATHER, urlWeather } from './ridge/weather';
import { resolveSkyState, skyNow } from './skyState';
import type { SkyState } from './skyState';
import {
displayedLuminance,
hexToLinear,
RIDGE_NIGHT_BAND,
shapeToBand,
SKY_BAND,
} from './skyBand';
import { displayedLuminance, SKY_BAND } from './skyBand';
import type { SkyBand } from './skyBand';
import { luminance } from '../../../sky/skyTokens.js';
import { CAMERA } from './terrain/terrainWorld';
const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
const rgb = (hex: string): Srgb =>
[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb;
const fromGradient = (g: readonly string[], ground: Srgb, band: SkyBand): SkyColours => ({
band,
top: rgb(g[0]),
mid: rgb(g[Math.floor(g.length / 2)]),
horizon: rgb(g[g.length - 1]),
ground,
});
/** Sun elevation in degrees for the terrain's dusk: just above the horizon, between the two bakes (as `?sun=` puts it). */
export const DUSK_SUN_ELEVATION = 2;
const toByte = (v: number): number =>
Math.round(255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055));
/**
* What a rebuild of the ridge scene is for: the fixed state it draws and the weather. The sun moves with the clock inside a state and
* is not part of the key (the scene is baked at its start), as the sky was one gradient per state before.
*/
export const ridgeSkyKey = (
state: SkyState,
weather: number = urlWeather() ?? DEFAULT_WEATHER,
now: Date = skyNow()
): string => `ridge:${resolveSkyState(state, now)}:${weather}`;
/** A stop moved into the state's band, the way the terrain sky is (`shapeToBand`), through the sRGB curve the canvas draws with. */
const inBand = (c: Srgb, band: SkyBand): Srgb =>
shapeToBand(hexToLinear(rgbHex(c)), band).map(toByte) as unknown as Srgb;
/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). Dusk's twilight stops are shaped into its band. */
export function ridgeSkyFor(state: SkyState): SkyColours {
/**
* The ridge scene's `sky` option: the physical sky over Vienna for the state ('auto': the real sun now; a forced state: the
* sun of today at a moment inside it, `sunForState`), as a palette for the layers and stars plus the `physical` sky the host canvas
* is painted from. The atmosphere LUTs are baked here, once per scene start (tens of ms).
*/
export function ridgeSkyFor(
state: SkyState,
weather: number = urlWeather() ?? DEFAULT_WEATHER,
now: Date = skyNow()
): SkyColours {
const { ground } = skyColours();
switch (resolveSkyState(state)) {
case 'dusk': {
const twilight = skyColours();
const band = SKY_BAND.dusk;
return {
...twilight,
band,
top: inBand(twilight.top, band),
mid: inBand(twilight.mid, band),
horizon: inBand(twilight.horizon, band),
};
}
case 'day':
return fromGradient(baked.noonday.gradient, ground, SKY_BAND.day);
default: {
const night = fromGradient(baked.midnight.gradient, ground, RIDGE_NIGHT_BAND);
return {
...night,
top: inBand(night.top, RIDGE_NIGHT_BAND),
mid: inBand(night.mid, RIDGE_NIGHT_BAND),
horizon: inBand(night.horizon, RIDGE_NIGHT_BAND),
};
}
}
const fixed = resolveSkyState(state, now);
const physical = makePhysicalSky(fixed, now, weather);
return {
...physical.stops,
ground,
band: SKY_BAND[fixed],
physical,
};
}
/**
@ -106,26 +85,12 @@ export interface Range {
}
/**
* The ridge sky's range: its top, middle and horizon stops and the blends between them (the canvas interpolates
* encoded sRGB, and luminance is convex in it, so a blend can be darker than both ends: each stop pair is sampled).
* The ridge sky's range: what its display is fitted to hold (`BUDGET`, toneMap.ts), which `physicalSky.test.ts` proves over the
* sun's whole year over Vienna. Dark ink needs the floor; light ink the ceiling.
*/
export function ridgeDrawn(state: SkyState): Range {
const { top, mid, horizon } = ridgeSkyFor(state);
const l: number[] = [];
[
[top, mid],
[mid, horizon],
[top, horizon],
].forEach(([a, b]) => {
for (let k = 0; k <= 100; k += 1) {
l.push(
luminance(
rgbHex(a.map((v, i) => Math.round(v + ((b[i] - v) * k) / 100)) as unknown as Srgb)
)
);
}
});
return { darkest: Math.min(...l), lightest: Math.max(...l) };
const b = BUDGET[resolveSkyState(state)];
return { darkest: b.floor, lightest: b.ceiling };
}
/**

View file

@ -80,6 +80,9 @@ function sync(): void {
timer = setTimeout(tick, TICK_MS);
}
/** The moment the sky is drawn for: the real time, or the test clock. */
export const skyNow = (): Date => clock();
/** Replace the clock (tests); no argument restores the real one. */
export function setSkyClock(now?: () => Date): void {
clock = now ?? (() => new Date());
@ -123,16 +126,17 @@ export interface SkyBackdrop {
}
// The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from
// the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). Held as constants so
// the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). The ridge sky is the physical sky over Vienna, its
// display fitted to the state's budget (ridge/toneMap.ts `BUDGET`): the budget is its extreme, whatever the sun's elevation. Held as constants so
// this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering
// tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts
// over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts).
// Night's lightest also covers a sky the page URL steers (shaped to the band ceiling, skyBand.ts).
// Bounds what the 8-bit screen shows too (one step each way for rounding and dither), rounded outward to six places.
const BACKDROPS: Record<FixedSkyState, SkyBackdrop> = {
day: { darkest: 0.461063, lightest: 0.901108 },
dusk: { darkest: 0.460352, lightest: 0.968744 },
night: { darkest: 0.039159, lightest: 0.081642 },
day: { darkest: 0.321, lightest: 1 },
dusk: { darkest: 0.460352, lightest: 1 },
night: { darkest: 0, lightest: 0.081642 },
};
export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)];