sky: bake an atmospheric-scattering table (half float) for a WebGL2 sky shader
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m38s
CLA Assistant / CLAssistant (pull_request_target) Failing after 1s

scatter.js ray-marches a single-scattering Rayleigh+Mie atmosphere whose vertical optical depth is
sun.js's TAU, and bakes two tables into baked.scatter: transmittance to space by view elevation
(32x1) and sky radiance by azimuth from the sun x view elevation x sun elevation (7x32x12), RGB
half floats, little-endian base64, value x 64. Layout, exact decode and a GLSL sampler are in
src/sky/README.md. Tests pin layout, encoding, checksums and physical anchors (zenith transmittance
equals exp(-TAU); blue noon, red low sun toward the sun, black below the planet-shadow limit).

The per-sky sun {elevation_deg, azimuth_deg, color_linear}, ambient_linear and horizon_hex were
already baked in 7216456a; sun.js now exports TAU.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-07 20:57:22 +00:00
commit 0386065f2c
6 changed files with 530 additions and 4 deletions

View file

@ -1,10 +1,16 @@
// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts.
// Run `npm run bake:sky` after changing sun.js, skyTokens.js or a moment.
// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts, with the
// atmospheric-scattering tables (src/sky/scatter.js) as `scatter`.
// Run `npm run bake:sky` after changing sun.js, skyTokens.js, scatter.js or a moment.
import { writeFileSync } from 'node:fs';
import { bakeSky } from '../src/sky/skyTokens.js';
import { bakeScatter } from '../src/sky/scatter.js';
const out = new URL('../src/sky/baked.ts', import.meta.url);
const baked = { midnight: bakeSky('midnight'), noonday: bakeSky('noonday') };
const baked = {
midnight: bakeSky('midnight'),
noonday: bakeSky('noonday'),
scatter: bakeScatter(),
};
writeFileSync(
out,
`// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit.\n` +

83
src/sky/README.md Normal file
View file

@ -0,0 +1,83 @@
# Baked sky
`npm run bake:sky` (`scripts/bake-sky.mjs`) lights two fixed moments with `sun.js`, the single source of
the sun and the air, and writes `baked.ts`. `baked.midnight`, `baked.noonday` and `baked.scatter` are its
three keys. Everything here is linear RGB unless a name says `hex`.
## Per sky: `baked.midnight`, `baked.noonday`
| field | meaning |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `moment` | `{ time, lat, lon }` fed to `sun.js` (equinox, 45N, prime meridian; 00:00 and 12:00 UTC) |
| `elevation` | sun elevation in degrees at that moment: -44.6 midnight, +45.2 noonday |
| `sun.elevation_deg` | the same, for scene code; fade stars by this (and see below for twilight) |
| `sun.azimuth_deg` | degrees clockwise from north, 0..360 |
| `sun.color_linear` | direct sun colour at normal incidence, clear zenith ~ 1; multiply by `max(0, dot(n, toSun))` |
| `ambient_linear` | sky ambient on an upward surface, with a night floor of `AMBIENT_FLOOR` (a moonlit skyglow, not black); daylight is `sun.js`'s own `diffuse` |
| `horizon_hex` | colour of the last gradient stop, the horizon |
| `gradient` | 7 sRGB hex stops, zenith to horizon |
| `tokens`, `bubbles`, ... | theme colours (see `skyTokens.js`) |
The two skies are single moments. To fade stars through twilight, take the elevation from `sun.js` at the
viewer's real time (`sunPosition(time, lat, lon).elevation`) rather than from `baked.<sky>.sun`, which only
ever says -44.6 or +45.2. The scattering table below goes dark at -12 degrees and below (the planet's shadow
covers every sunlit air path), so a star fade between about -6 and -12 degrees follows it.
## `baked.scatter`: atmospheric scattering for a WebGL2 shader
A Hillaire-style (2020) single-scattering atmosphere, reduced to two small tables. The air is `sun.js`'s:
`TAU` (its zenith optical depth per channel) is the vertical optical depth here, split into a grey aerosol
part (0.02) and a Rayleigh part, so the transmittance table equals `exp(-TAU)` at the zenith. Planet radius
6360 km, atmosphere 100 km, scale heights 8 km (Rayleigh) and 1.2 km (Mie, g = 0.8, albedo 0.9), observer
100 m above the ground. Single scattering only: no ground bounce, no multiple scattering, no sun disc
(the consumer draws it from `sun.color_linear`). That makes dusk a little darker than the real thing.
Constants: `planet_radius_km`, `atmosphere_height_km`, `observer_altitude_km`, `extinction_per_km` (RGB at the
observer, for aerial perspective: `exp(-k * distance_km)`).
### Encoding (both tables)
IEEE 754 half floats (16 bit), little-endian, base64 of the raw bytes, no header. The stored value is
`value * store_scale` (`store_scale` = 64; it lifts the dark end of twilight clear of the half-float
subnormals). Exact decode: for a 16-bit word `h`, `e = (h >> 10) & 31`, `m = h & 1023`,
`half = e == 0 ? m * 2^-24 : (1 + m / 1024) * 2^(e - 15)` (no infinities or NaNs are stored), and
`value = half / store_scale`. In WebGL2 upload as `RGB16F` / `HALF_FLOAT` from a `Uint16Array` over the
decoded bytes, and the GPU does the decode: sample, then divide by `store_scale`. Precision is 11 bits
(about 0.05%): the horizon glow does not step.
### `transmittance`: `width` 32, `height` 1, 3 channels
Transmittance from the observer to space along a view elevation, per channel. Texel `i` is the elevation
`-4 + 94 * (i / 31)^2` degrees (`viewElevation(i)` in `scatter.js`; the inverse is `viewIndex(deg)`):
finest at the horizon. Elevations that look at the ground (-4 to about -0.3) are 0. Sample it for the sun's
direct colour: at the sun's elevation, `sun.color_linear`-like attenuation, redder as it sets.
### `sky_view`: `width` 7, `height` 32, `depth` 12, 3 channels
Sky radiance per steradian for a sun of unit irradiance (multiply by your own sun colour and exposure).
One 3D texture, `x` fastest:
- `x`: azimuth of the view away from the sun, 0, 30, ... 180 degrees (`azimuth_step_deg`). The sky is
symmetric about the sun's vertical plane, so use `abs` of the relative azimuth.
- `y`: view elevation, same axis as the transmittance table (`viewElevation(i)`).
- `z`: sun elevation, `sun_elevations_deg` = -18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90. Between them
interpolate linearly; `sunIndex(deg)` in `scatter.js` gives the fractional index.
Element `((z * 32 + y) * 7 + x) * 3 + c` of the decoded array. Texel centres, for linear filtering:
```glsl
uniform highp sampler3D uSky; // RGB16F, 7 x 32 x 12, LINEAR, CLAMP_TO_EDGE
uniform float uSunIndex; // sunIndex(sunElevationDeg), 0..11, from the CPU
vec3 skyRadiance(float viewElDeg, float azFromSunDeg) {
float y = sqrt(clamp((viewElDeg + 4.0) / 94.0, 0.0, 1.0)) * 31.0;
float x = clamp(abs(azFromSunDeg) / 180.0, 0.0, 1.0) * 6.0;
return texture(uSky, vec3((x + 0.5) / 7.0, (y + 0.5) / 32.0, (uSunIndex + 0.5) / 12.0)).rgb / 64.0;
}
```
Row width is 21 bytes, so set `gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)` before `texImage3D`.
Size: 16,128 bytes for `sky_view` (21.5 KB as base64) and 192 bytes for `transmittance`; about
twice the "few KB" of an 8-bit table, the price of not quantising the glow. `scatter.test.ts` pins the
layout, the encoding, the checksums and a set of physical anchors (blue noon sky, red sunset toward the sun,
black below the planet's shadow limit).

File diff suppressed because one or more lines are too long

249
src/sky/scatter.js Normal file
View file

@ -0,0 +1,249 @@
// Atmospheric scattering tables, baked for a WebGL2 shader (layout: src/sky/README.md).
// Dependency-free apart from sun.js, so scripts/bake-sky.mjs and the tests run it under plain node.
//
// bakeScatter() -> { transmittance, sky_view, ... } with base64 RGB16F tables (half floats).
//
// A Hillaire-style single-scattering model (Rayleigh + Mie, spherical planet, observer 100 m up),
// reduced to two small tables: the transmittance of the whole atmosphere along a view direction, and
// the sky's radiance by sun elevation, view elevation and azimuth from the sun. sun.js stays the
// single source of the air: its zenith optical depths (TAU, per channel) are the vertical optical
// depth of this atmosphere, split into a grey aerosol part and a Rayleigh part. So the direct sun
// sun.js returns and the transmittance here agree at the zenith; only the air-mass law differs
// (Kasten-Young there, the spherical integral here).
import { TAU } from './sun.js';
const RAD = Math.PI / 180;
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
// Geometry (km) and scale heights.
export const PLANET_RADIUS_KM = 6360;
export const ATMOSPHERE_HEIGHT_KM = 100;
export const OBSERVER_ALTITUDE_KM = 0.1;
const H_RAYLEIGH = 8;
const H_MIE = 1.2;
const MIE_ALBEDO = 0.9; // single-scattering albedo of the aerosol (EYEBALLED)
const MIE_G = 0.8; // Henyey-Greenstein asymmetry (Hillaire 2020)
const MIE_TAU = 0.02; // grey aerosol vertical optical depth (EYEBALLED); the rest of TAU is Rayleigh
// Vertical optical depth of an exponential layer of scale height H over the atmosphere.
const column = (H) => H * (1 - Math.exp(-ATMOSPHERE_HEIGHT_KM / H));
const BETA_RAYLEIGH = TAU.map((tau) => (tau - MIE_TAU) / column(H_RAYLEIGH)); // per km at sea level
const BETA_MIE_EXT = MIE_TAU / column(H_MIE);
const BETA_MIE_SCA = BETA_MIE_EXT * MIE_ALBEDO;
// Table axes.
export const SUN_ELEVATIONS_DEG = [-18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90];
export const VIEW_SAMPLES = 32;
export const AZIMUTH_SAMPLES = 7; // 0, 30, ... 180 degrees from the sun
export const AZIMUTH_STEP_DEG = 30;
export const VIEW_MIN_DEG = -4;
export const VIEW_MAX_DEG = 90;
// Tables hold IEEE 754 half floats, little-endian, holding value * STORE_SCALE. The scale lifts the
// dark end (twilight sky radiance is ~1e-4) clear of the half-float subnormals; the brightest
// stored value, a Mie peak near 0.3 x 64, is far below the format's 65504.
export const STORE_SCALE = 64;
/** View elevation in degrees of sample index i of VIEW_SAMPLES: finest at the horizon. */
export const viewElevation = (i) =>
VIEW_MIN_DEG + (VIEW_MAX_DEG - VIEW_MIN_DEG) * (i / (VIEW_SAMPLES - 1)) ** 2;
/** Fractional index into SUN_ELEVATIONS_DEG of a sun elevation (clamped to the table): the z texel coordinate. */
export const sunIndex = (elevationDeg) => {
const e = clamp(
elevationDeg,
SUN_ELEVATIONS_DEG[0],
SUN_ELEVATIONS_DEG[SUN_ELEVATIONS_DEG.length - 1]
);
const k = Math.min(SUN_ELEVATIONS_DEG.findIndex((x) => x > e) - 1, SUN_ELEVATIONS_DEG.length - 2);
const i = k < 0 ? SUN_ELEVATIONS_DEG.length - 2 : k;
return i + (e - SUN_ELEVATIONS_DEG[i]) / (SUN_ELEVATIONS_DEG[i + 1] - SUN_ELEVATIONS_DEG[i]);
};
/** Inverse of viewElevation: the fractional sample index of an elevation. */
export const viewIndex = (elevationDeg) =>
Math.sqrt(clamp((elevationDeg - VIEW_MIN_DEG) / (VIEW_MAX_DEG - VIEW_MIN_DEG), 0, 1)) *
(VIEW_SAMPLES - 1);
/** Round a non-negative number to the nearest IEEE 754 half float (ties to even); returns its 16 bits. */
export function toHalf(x) {
if (!(x > 0)) return 0;
const u = new Uint32Array(new Float32Array([x]).buffer)[0];
const exp = ((u >> 23) & 0xff) - 127 + 15;
const mant = u & 0x7fffff;
if (exp >= 31) return 0x7bff;
let half;
let rem;
let halfway;
if (exp <= 0) {
if (exp < -10) return 0;
const m = mant | 0x800000;
const shift = 14 - exp;
half = m >> shift;
rem = m & ((1 << shift) - 1);
halfway = 1 << (shift - 1);
} else {
half = (exp << 10) | (mant >> 13);
rem = mant & 0x1fff;
halfway = 0x1000;
}
return rem > halfway || (rem === halfway && half & 1) ? half + 1 : half;
}
/** The exact value of 16 half-float bits (finite values only). */
export const fromHalf = (h) => {
const e = (h >> 10) & 31;
const m = h & 1023;
return e === 0 ? m * 2 ** -24 : (1 + m / 1024) * 2 ** (e - 15);
};
/** Stored 16 bits of a value, and the value back out of them. */
export const encode = (v) => toHalf(v * STORE_SCALE);
export const decode = (h) => fromHalf(h) / STORE_SCALE;
const extinction = (h) => {
const r = Math.exp(-h / H_RAYLEIGH);
const m = Math.exp(-h / H_MIE);
return BETA_RAYLEIGH.map((b) => b * r + BETA_MIE_EXT * m);
};
// Distance along ray (origin o, unit direction d) to the sphere of radius r centred on the origin
// of the planet: the far root, or the near root if `near`. Infinity when it misses or is behind.
function sphereHit(o, d, r, near) {
const b = o[0] * d[0] + o[1] * d[1] + o[2] * d[2];
const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - r * r;
const disc = b * b - c;
if (disc < 0) return Infinity;
const sq = Math.sqrt(disc);
const t = near ? -b - sq : -b + sq;
return t > 0 ? t : Infinity;
}
// Optical depth per channel from p along d to the top of the atmosphere; Infinity if the planet is in the way.
function opticalDepthToSpace(p, d, steps) {
if (sphereHit(p, d, PLANET_RADIUS_KM, true) !== Infinity) return null;
const top = sphereHit(p, d, PLANET_RADIUS_KM + ATMOSPHERE_HEIGHT_KM, false);
const dt = top / steps;
const depth = [0, 0, 0];
for (let k = 0; k < steps; k += 1) {
const t = (k + 0.5) * dt;
const q = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t];
const e = extinction(Math.hypot(...q) - PLANET_RADIUS_KM);
for (let c = 0; c < 3; c += 1) depth[c] += e[c] * dt;
}
return depth;
}
const observer = [0, PLANET_RADIUS_KM + OBSERVER_ALTITUDE_KM, 0];
const viewDirection = (elevationDeg, azimuthDeg) => {
const e = elevationDeg * RAD;
const a = azimuthDeg * RAD;
return [Math.cos(e) * Math.cos(a), Math.sin(e), Math.cos(e) * Math.sin(a)];
};
const sunDirection = (elevationDeg) => [
Math.cos(elevationDeg * RAD),
Math.sin(elevationDeg * RAD),
0,
];
/** Transmittance per channel from the observer along an elevation to space; zeros where the ground is in the way. */
export function transmittance(elevationDeg) {
const depth = opticalDepthToSpace(observer, viewDirection(elevationDeg, 0), 64);
return depth ? depth.map((x) => Math.exp(-x)) : [0, 0, 0];
}
const rayleighPhase = (c) => (3 / (16 * Math.PI)) * (1 + c * c);
const miePhase = (c) =>
(1 - MIE_G * MIE_G) / (4 * Math.PI * (1 + MIE_G * MIE_G - 2 * MIE_G * c) ** 1.5);
/**
* Sky radiance per channel, per steradian, for a sun of unit irradiance (so the consumer multiplies by
* its own sun colour), in a view direction at an elevation and an azimuth measured from the sun.
* Single scattering, no ground bounce, no sun disc.
*/
export function skyRadiance(sunElevationDeg, viewElevationDeg, azimuthDeg) {
const v = viewDirection(viewElevationDeg, azimuthDeg);
const s = sunDirection(sunElevationDeg);
const cosTheta = v[0] * s[0] + v[1] * s[1] + v[2] * s[2];
const pr = rayleighPhase(cosTheta);
const pm = miePhase(cosTheta);
const ground = sphereHit(observer, v, PLANET_RADIUS_KM, true);
const top = sphereHit(observer, v, PLANET_RADIUS_KM + ATMOSPHERE_HEIGHT_KM, false);
const end = Math.min(ground, top);
const steps = 24;
const dt = end / steps;
const along = [0, 0, 0];
const radiance = [0, 0, 0];
for (let k = 0; k < steps; k += 1) {
const t = (k + 0.5) * dt;
const q = [observer[0] + v[0] * t, observer[1] + v[1] * t, observer[2] + v[2] * t];
const h = Math.hypot(...q) - PLANET_RADIUS_KM;
const e = extinction(h);
for (let c = 0; c < 3; c += 1) along[c] += e[c] * dt;
const toSun = opticalDepthToSpace(q, s, 12);
if (toSun) {
const r = Math.exp(-h / H_RAYLEIGH);
const m = Math.exp(-h / H_MIE);
for (let c = 0; c < 3; c += 1) {
const scatter = BETA_RAYLEIGH[c] * r * pr + BETA_MIE_SCA * m * pm;
radiance[c] += Math.exp(-along[c] - toSun[c]) * scatter * dt;
}
}
}
return radiance;
}
const base64 = (halves) => {
const bytes = new Uint8Array(halves.length * 2);
halves.forEach((h, i) => {
bytes[i * 2] = h & 0xff; // little-endian
bytes[i * 2 + 1] = h >> 8;
});
let s = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(s);
};
export function bakeScatter() {
const view = Array.from({ length: VIEW_SAMPLES }, (_, i) => viewElevation(i));
const t = new Uint16Array(VIEW_SAMPLES * 3);
view.forEach((e, i) => transmittance(e).forEach((v, c) => (t[i * 3 + c] = encode(v))));
// x = azimuth, y = view elevation, z = sun elevation, three half floats per texel: a 3D texture.
const sky = new Uint16Array(SUN_ELEVATIONS_DEG.length * VIEW_SAMPLES * AZIMUTH_SAMPLES * 3);
let at = 0;
SUN_ELEVATIONS_DEG.forEach((sun) =>
view.forEach((e) => {
for (let a = 0; a < AZIMUTH_SAMPLES; a += 1) {
skyRadiance(sun, e, a * AZIMUTH_STEP_DEG).forEach((v) => {
sky[at] = encode(v);
at += 1;
});
}
})
);
return {
planet_radius_km: PLANET_RADIUS_KM,
atmosphere_height_km: ATMOSPHERE_HEIGHT_KM,
observer_altitude_km: OBSERVER_ALTITUDE_KM,
// Extinction per km at the observer, per channel, for aerial perspective: exp(-k * distance_km).
extinction_per_km: extinction(OBSERVER_ALTITUDE_KM),
format: 'half-float',
store_scale: STORE_SCALE,
view_elevation_deg: { min: VIEW_MIN_DEG, max: VIEW_MAX_DEG, samples: VIEW_SAMPLES },
transmittance: { width: VIEW_SAMPLES, height: 1, channels: 3, data: base64(t) },
sky_view: {
sun_elevations_deg: SUN_ELEVATIONS_DEG,
azimuth_step_deg: AZIMUTH_STEP_DEG,
width: AZIMUTH_SAMPLES,
height: VIEW_SAMPLES,
depth: SUN_ELEVATIONS_DEG.length,
channels: 3,
data: base64(sky),
},
};
}

160
src/sky/scatter.test.ts Normal file
View file

@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest';
import {
AZIMUTH_SAMPLES,
AZIMUTH_STEP_DEG,
STORE_SCALE,
SUN_ELEVATIONS_DEG,
VIEW_SAMPLES,
bakeScatter,
decode,
fromHalf,
skyRadiance,
sunIndex,
toHalf,
transmittance,
viewElevation,
viewIndex,
} from './scatter.js';
import { TAU } from './sun.js';
import { baked } from './baked';
const table = (b64: string): Uint16Array => {
const bin = atob(b64);
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
return new Uint16Array(bytes.buffer);
};
// Checksums of the baked halves, so a change to the model or the encoding is a deliberate re-pin.
const SKY_CHECKSUM = 622927107;
const TRANS_CHECKSUM = 1498461058;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const scatter = baked.scatter as any;
const sky = table(scatter.sky_view.data);
const trans = table(scatter.transmittance.data);
const sunIndex_ = (deg: number) => SUN_ELEVATIONS_DEG.indexOf(deg);
// Texel of the sky table: x = azimuth, y = view elevation, z = sun elevation; value = three halves.
const texel = (s: number, v: number, a: number) =>
[0, 1, 2].map((c) => decode(sky[((s * VIEW_SAMPLES + v) * AZIMUTH_SAMPLES + a) * 3 + c]));
describe('half floats', () => {
it('round-trip within half precision across the table range, ties to even', () => {
[6.1e-5, 1e-4, 0.0123, 0.3, 1, 20, 1000].forEach((x) =>
expect(Math.abs(fromHalf(toHalf(x)) - x) / x).toBeLessThan(5.1e-4)
);
expect(toHalf(0)).toBe(0);
expect(toHalf(1)).toBe(0x3c00);
expect(toHalf(2 ** -24)).toBe(1); // smallest subnormal
expect(toHalf(1 + 2 ** -11)).toBe(0x3c00); // exact tie goes to the even neighbour
expect(toHalf(1 + 3 * 2 ** -11)).toBe(0x3c02);
expect(fromHalf(0x7bff)).toBe(65504);
});
it('keeps twilight radiance, ~1e-4, within 0.1% thanks to the store scale', () => {
const x = 1.234e-4;
expect(Math.abs(decode(toHalf(x * STORE_SCALE)) - x) / x).toBeLessThan(1e-3);
});
});
describe('sunIndex', () => {
it('is the fractional position of an elevation on the sun axis, clamped to the table', () => {
expect(sunIndex(-18)).toBe(0);
expect(sunIndex(90)).toBe(SUN_ELEVATIONS_DEG.length - 1);
expect(sunIndex(-100)).toBe(0);
expect(sunIndex(100)).toBe(SUN_ELEVATIONS_DEG.length - 1);
expect(sunIndex(0)).toBe(sunIndex_(0));
expect(sunIndex(1.5)).toBeCloseTo(sunIndex_(0) + 0.5, 12); // halfway between 0 and 3
expect(sunIndex(35)).toBeCloseTo(sunIndex_(25) + 0.5, 12); // halfway between 25 and 45
});
});
describe('scattering tables', () => {
it('are what scripts/bake-sky.mjs would write now (run npm run bake:sky if not)', () => {
expect(JSON.parse(JSON.stringify(bakeScatter()))).toEqual(JSON.parse(JSON.stringify(scatter)));
});
it('have the documented layout and size', () => {
expect(scatter.format).toBe('half-float');
expect(scatter.sky_view).toMatchObject({
width: AZIMUTH_SAMPLES,
height: VIEW_SAMPLES,
depth: SUN_ELEVATIONS_DEG.length,
channels: 3,
azimuth_step_deg: AZIMUTH_STEP_DEG,
});
expect(sky.length).toBe(AZIMUTH_SAMPLES * VIEW_SAMPLES * SUN_ELEVATIONS_DEG.length * 3);
expect(trans.length).toBe(VIEW_SAMPLES * 3);
expect(sky.length * 2).toBe(16128); // bytes: the budget the README states
});
it('pin the view-elevation axis and its inverse', () => {
expect(viewElevation(0)).toBe(-4);
expect(viewElevation(VIEW_SAMPLES - 1)).toBe(90);
expect(viewElevation(6)).toBeCloseTo(-4 + 94 * (6 / 31) ** 2, 10);
[0, 5, 6, 17, 31].forEach((i) => expect(viewIndex(viewElevation(i))).toBeCloseTo(i, 9));
});
it('transmittance to space agrees with sun.js at the zenith and falls toward the horizon', () => {
const top = VIEW_SAMPLES - 1;
[0, 1, 2].forEach((c) =>
expect(Math.abs(decode(trans[top * 3 + c]) / Math.exp(-TAU[c]) - 1)).toBeLessThan(0.01)
);
expect(transmittance(90).map((v, c) => v / Math.exp(-TAU[c]))).toSatisfy((r: number[]) =>
r.every((x) => Math.abs(x - 1) < 0.01)
);
// blue goes first: lower view, lower blue, and redder light at the horizon
let prev = Infinity;
for (let i = top; i >= 8; i -= 1) {
const blue = decode(trans[i * 3 + 2]);
expect(blue).toBeLessThanOrEqual(prev);
prev = blue;
}
expect(decode(trans[8 * 3]) / decode(trans[8 * 3 + 2])).toBeGreaterThan(10); // ~2 degrees up: sunset red
expect(decode(trans[0])).toBe(0); // -4 degrees looks at the ground
});
it('colour the daytime sky blue, and a low sun redder toward it than away from it', () => {
const noon = texel(sunIndex_(45), VIEW_SAMPLES - 1, 0);
expect(noon[2]).toBeGreaterThan(noon[1]);
expect(noon[1]).toBeGreaterThan(noon[0]);
const horizon = Math.round(viewIndex(5));
const toward = texel(sunIndex_(3), horizon, 0);
const away = texel(sunIndex_(3), horizon, AZIMUTH_SAMPLES - 1);
expect(toward[0] / toward[2]).toBeGreaterThan(5);
expect(toward[0] / toward[2]).toBeGreaterThan(2 * (away[0] / away[2]));
expect(toward[0]).toBeGreaterThan(5 * away[0]); // the glow is around the sun
});
it('fade through twilight to black below the planet-shadow limit', () => {
const mid = Math.round(viewIndex(30));
const lum = (s: number) => texel(sunIndex_(s), mid, 3).reduce((x, y) => x + y, 0);
expect(lum(8)).toBeGreaterThan(lum(3));
expect(lum(3)).toBeGreaterThan(lum(-2));
expect(lum(-2)).toBeGreaterThan(lum(-5));
expect(lum(-5)).toBeGreaterThan(0);
expect(lum(-12)).toBe(0);
expect(lum(-18)).toBe(0);
});
it('hold the model to half-float precision, with no quantised steps along the horizon glow', () => {
// Every table texel off a handful of sampled ones is the model's own value to < 0.2%.
[
[sunIndex_(3), 6, 0],
[sunIndex_(3), 9, 2],
[sunIndex_(0), 8, 0],
[sunIndex_(45), 20, 3],
[sunIndex_(-2), 12, 1],
].forEach(([s, v, a]) => {
const direct = skyRadiance(SUN_ELEVATIONS_DEG[s], viewElevation(v), a * AZIMUTH_STEP_DEG);
texel(s, v, a).forEach((got, c) => expect(Math.abs(got / direct[c] - 1)).toBeLessThan(2e-3));
});
// Moving up from the horizon toward the sun, red drops smoothly: distinct, strictly decreasing.
const s = sunIndex_(3);
const reds = Array.from({ length: 8 }, (_, k) => texel(s, 7 + k, 0)[0]);
reds.slice(1).forEach((r, k) => expect(r).toBeLessThan(reds[k]));
expect(new Set(reds).size).toBe(reds.length);
});
it('are the same bytes as when pinned', () => {
const sum = (a: Uint16Array) => a.reduce((x, y) => (x * 31 + y) % 2147483647, 7);
expect([sum(sky), sum(trans)]).toEqual([SKY_CHECKSUM, TRANS_CHECKSUM]);
});
});

View file

@ -57,7 +57,7 @@ export const sunElevation = (time, lat, lon) => sunPosition(time, lat, lon).elev
// Optical depth of the clear atmosphere at the zenith, per channel (R, G, B).
// Rayleigh at 650/550/450 nm plus a little ozone and haze (EYEBALLED to keep
// low sun orange rather than brown).
const TAU = [0.06, 0.12, 0.26];
export const TAU = [0.06, 0.12, 0.26];
// Relative air mass, Kasten & Young 1989. Elevation in degrees, >= 0.
const airMass = (el) => 1 / (Math.sin(el * RAD) + 0.50572 * Math.pow(el + 6.07995, -1.6364));