sky: bake an atmospheric-scattering table (half float) for a WebGL2 sky shader
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:
parent
7216456a27
commit
0386065f2c
6 changed files with 530 additions and 4 deletions
|
|
@ -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
83
src/sky/README.md
Normal 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
249
src/sky/scatter.js
Normal 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
160
src/sky/scatter.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue