Ridge scene: physical sky over Vienna, real horizon, live weather (Nibu) #110

Merged
void merged 6 commits from void/ridge-scene into dev 2026-11-12 02:48:05 +00:00
35 changed files with 2457 additions and 121 deletions

View file

@ -29,6 +29,7 @@ esbuild
plugins: [rawPlugin],
format: 'iife',
target: 'es2020',
define: { 'import.meta.env.BASE_URL': '"/"' },
})
.then(() => {
if (ridge) {

123
scripts/bake-horizon.py Normal file
View file

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Bake the 360 degree horizon seen from the ridge viewpoint (src/app/features/scene/ridge/vienna.ts: VIENNA) into
src/app/features/scene/ridge/viennaHorizon.json: per azimuth, the highest elevation angle the terrain subtends, the distance and
height of the point that sets it, and the surface slope there (the normal). The source is Copernicus DEM GLO-30, a DSM (it
includes buildings and trees, which is what a horizon is made of). Source, licence and attribution go beside the bake, in its
`source` block, so the file cannot travel without them.
python3 scripts/bake-horizon.py <dir with the nine Copernicus_DSM_COG_10_N{47..49}_00_E{015..017}_00_DEM.tif> [out.json]
Needs numpy, tifffile, imagecodecs (pip, in a venv: this is a build-time tool, the bake is committed).
"""
import json
import math
import sys
from pathlib import Path
import numpy as np
import tifffile
LAT, LON = 48.1929826, 16.3534237 # the server location Meta gave, 2026-11-11; keep equal to VIENNA in vienna.ts
STEP_DEG = 0.5
RADIUS_KM = 60.0
EARTH_R = 6_371_000.0
REFRACTION_K = 0.13 # standard terrestrial refraction: effective radius R / (1 - k)
EYE_ABOVE_GROUND = 0.0 # the viewpoint is the DSM surface at the point (a roof, in a city); the page lifts it with VIENNA.altitudeKm
MIN_KM = 1.0 # the DSM at 30 m cannot resolve neighbouring buildings as a horizon; nearer than this is not ridge
class Mosaic:
def __init__(self, folder: Path):
self.tiles = {}
for p in sorted(folder.glob("Copernicus_DSM_COG_10_N*_E*_DEM.tif")):
lat0 = int(p.name.split("_N")[1][:2])
lon0 = int(p.name.split("_E")[1][:3])
with tifffile.TiffFile(p) as t:
self.tiles[(lat0, lon0)] = t.pages[0].asarray()
def height(self, lat: float, lon: float) -> float:
key = (math.floor(lat), math.floor(lon))
a = self.tiles.get(key)
if a is None:
return float("nan")
n = a.shape[0]
# Tile origin is the north-west corner; pixels are 1/n degree, pixel-is-area (centre at +0.5).
y = (key[0] + 1 - lat) * n - 0.5
x = (lon - key[1]) * n - 0.5
y0, x0 = int(math.floor(y)), int(math.floor(x))
y0 = min(max(y0, 0), n - 2)
x0 = min(max(x0, 0), n - 2)
fy, fx = min(max(y - y0, 0), 1), min(max(x - x0, 0), 1)
top = a[y0, x0] * (1 - fx) + a[y0, x0 + 1] * fx
bot = a[y0 + 1, x0] * (1 - fx) + a[y0 + 1, x0 + 1] * fx
return float(top * (1 - fy) + bot * fy)
def offset(lat: float, lon: float, az_deg: float, d_m: float):
az = math.radians(az_deg)
dlat = d_m * math.cos(az) / 111_320.0
dlon = d_m * math.sin(az) / (111_320.0 * math.cos(math.radians(lat)))
return lat + dlat, lon + dlon
def main() -> None:
folder = Path(sys.argv[1])
out = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("src/app/features/scene/ridge/viennaHorizon.json")
m = Mosaic(folder)
h0 = m.height(LAT, LON) + EYE_ABOVE_GROUND
az_count = int(360 / STEP_DEG)
elev, dist, peak, east, north = [], [], [], [], []
for i in range(az_count):
az = i * STEP_DEG
best, best_d, best_h, best_pt = -90.0, 0.0, h0, (LAT, LON)
d = MIN_KM * 1000
while d <= RADIUS_KM * 1000:
lat, lon = offset(LAT, LON, az, d)
h = m.height(lat, lon)
if not math.isnan(h):
drop = d * d / (2 * EARTH_R) * (1 - REFRACTION_K)
ang = math.degrees(math.atan2(h - h0 - drop, d))
if ang > best:
best, best_d, best_h, best_pt = ang, d, h, (lat, lon)
d += 30.0 if d < 5000 else 60.0 if d < 20000 else 120.0
# Surface slope at the peak (rise per metre, east and north), central difference over 60 m.
la, lo = best_pt
dl, dn = 60.0 / 111_320.0, 60.0 / (111_320.0 * math.cos(math.radians(la)))
ge = (m.height(la, lo + dn) - m.height(la, lo - dn)) / 120.0
gn = (m.height(la + dl, lo) - m.height(la - dl, lo)) / 120.0
elev.append(round(best, 3))
dist.append(round(best_d / 1000, 2))
peak.append(round(best_h, 1))
east.append(round(ge, 3))
north.append(round(gn, 3))
doc = {
"viewpoint": {"lat": LAT, "lon": LON, "groundM": round(h0, 1)},
"stepDeg": STEP_DEG,
"radiusKm": RADIUS_KM,
"minKm": MIN_KM,
"curvature": f"drop d^2/2R*(1-{REFRACTION_K}), R={int(EARTH_R)} m",
"source": {
"dataset": "Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid",
"tiles": sorted(f"N{a:02d}_00_E{b:03d}_00" for a, b in m.tiles),
"tileBytesEach": "about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed",
"url": "https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)",
"licence": "Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty "
"(https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)",
"attribution": "Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 "
"provided under COPERNICUS by the European Union and ESA; all rights reserved.",
"bakedBy": "scripts/bake-horizon.py",
},
"elevationDeg": elev,
"distanceKm": dist,
"peakHeightM": peak,
"slopeEast": east,
"slopeNorth": north,
}
out.write_text(json.dumps(doc, separators=(",", ":")) + "\n")
top = int(np.argmax(elev))
print(f"ground {h0:.1f} m; {az_count} azimuths; highest {elev[top]:.2f} deg at az {top * STEP_DEG}, {dist[top]} km, {peak[top]} m")
print(f"wrote {out} ({out.stat().st_size} bytes)")
if __name__ == "__main__":
main()

View file

@ -78,6 +78,22 @@ holds a GL context gives no 2D one, so a GL failure after context creation (comp
x `AMBIENT_GAIN` 0.25 x `NIGHT_LIFT` up to 4; now `ambient_linear` as baked, so midnight's fill is about 2.5 to 3.5 times darker than
the lifted one). `HAZE_K` (0.0004 per metre) is unchanged.
## Ridge scene: Vienna, the real horizon and the weather
The ridge sky is the physical atmosphere (`ridge/earthAtmosphere.ts`) lit by the real sun over the viewpoint, 48.1929826 N, 16.3534237 E
(`ridge/vienna.ts`). Its farthest layer is the real horizon of that viewpoint, standing still under the sun: `ridge/viennaHorizon.json`,
baked by `scripts/bake-horizon.py` from Copernicus DEM GLO-30 (nine 1 degree tiles, about 380 MB, kept out of the repo). The file carries its
own `source` block with the dataset, tiles, licence and the attribution the licence requires. Its elevation range is 2.1 degrees, so the
layer draws the shape of the ring stretched to a fixed relief, not its scale.
The weather is one number (`ridge/weather.ts`), from the page URL's `?weather=`, else `weather.json` beside `config.json`, else clear.
The browser never calls a weather service. `weather.json` is written by Eureka's refresher (gamecult-ops `docs/weather-feed.md`, a 5-minute timer; the only writer) from GeoSphere Austria's TAWES station 11035 (Wien Hohe Warte), credit
"Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at". Shape: `{fetched_at, observed_at, station, precip_mm_10min, humidity_pct,
glow_wm2, sunshine_s_10min, attribution}`, null for a missing measurement. A file fetched more than 30 minutes ago, or holding an observation more than 40 minutes old, or from another station, is a clear
sky. Rain is the gauge; cloud is an estimate from the global radiation against Haurwitz's clear-sky value, cross-checked by the sunshine
duration, with the humidity as the fallback when the sun is low (`ridge/weatherFeed.ts`). The weather draws a cloud deck in the sky
(`ridge/clouds.ts`), thickens the layers' valley mist, and lays tiled rain streaks over everything (`ridge/rain.ts`, compositor-driven, none in a still).
## Entry points
- `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's

View file

@ -15,6 +15,7 @@ import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../../utils/keyboard';
import { useSkyState } from '../../hooks/useSkyState';
import { SKY_STATES, setSkyState, type SkyState } from './skyState';
import { HORIZON_CREDIT, WEATHER_CREDIT, WEATHER_CREDIT_URL, WEATHER_DATASET } from './ridge/credits';
import { useClientConfig } from '../../hooks/useClientConfig';
import {
getSceneStyleOverride,
@ -116,6 +117,23 @@ export function SkyStateControl() {
))}
</>
)}
{backgroundScene && sceneStyle === 'ridge' && (
<Box direction="Column" gap="100" style={{ padding: `${config.space.S200}`, maxWidth: '20rem' }}>
<Text size="L400" priority="300">
Data
</Text>
<Text size="T200" priority="300">
Datenquelle: GeoSphere Austria -{' '}
<a href={WEATHER_CREDIT_URL} target="_blank" rel="noreferrer noopener">
{WEATHER_CREDIT_URL}
</a>
. {WEATHER_DATASET}.
</Text>
<Text size="T200" priority="300">
{HORIZON_CREDIT}
</Text>
</Box>
)}
</Box>
</Menu>
</FocusTrap>

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';
@ -48,7 +47,7 @@ describe('startBackgroundScene follows the sky state', () => {
createRidgeScene.mockReturnValue(fakeScene());
const scene = await startBackgroundScene(config, caps, { canvas });
expect(createRidgeScene).toHaveBeenCalledOnce();
expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]);
expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(hex(ridgeSkyFor('night').top));
scene?.dispose();
});
@ -67,7 +66,7 @@ describe('startBackgroundScene follows the sky state', () => {
setSkyState('night');
createRidgeScene.mockReturnValue(fakeScene());
const scene = await startBackgroundScene(config, caps, { canvas });
expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]);
expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(hex(ridgeSkyFor('night').top));
scene?.dispose();
});
@ -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

@ -16,6 +16,9 @@ export type StartOptions = Omit<SceneOptions, 'tier' | 'patchSource' | 'onCompil
onActive?: () => void;
};
/** The observation file the ridge sky reads: the page's own, beside config.json. The browser calls no weather service. */
const weatherUrl = (): string => `${import.meta.env.BASE_URL.replace(/\/$/, '')}/weather.json`;
async function create(
config: BackgroundSceneConfig | undefined,
options: StartOptions & { tier: SceneTier; debug?: boolean },
@ -23,7 +26,7 @@ async function create(
): Promise<Scene | null> {
try {
// Loaded here, with the scenes, so the main chunk does not carry the terrain sky or the baked tables.
const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState');
const { loadWeather, ridgeSkyFor, terrainSkyFor } = await import('./skyForState');
if (config?.style === 'raymarch') return (await import('./Scene')).createScene(options);
if (config?.style === 'terrain') {
const { createTerrainScene } = await import('./terrain/Terrain');
@ -35,6 +38,7 @@ async function create(
return createTerrainSkyScene({ ...options, ...sky });
}
const { createRidgeScene } = await import('./ridge/Ridge');
await loadWeather(weatherUrl());
return createRidgeScene({ ...options, sky: ridgeSkyFor(state) });
} catch (e) {
options.onCompileFail?.(e instanceof Error ? e : new Error(String(e)));
@ -52,8 +56,10 @@ 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 { loadWeather, ridgeSkyKey, terrainSkyFor } = await import('./skyForState');
if (config?.style === 'terrain') return JSON.stringify(terrainSkyFor(state));
await loadWeather(weatherUrl());
return ridgeSkyKey(state);
} catch {
// The chunk failed to load: no key, so every change rebuilds (and `create` reports the failure).
return null;

View file

@ -17,6 +17,7 @@ import {
} from './ridgeWorld';
import { skyColours, SkyColours } from './skyColour';
import { makeStars } from './stars';
import { makePhysicalSky } from './physicalSky';
import { startBackgroundScene } from '../index';
describe('noise', () => {
@ -208,6 +209,7 @@ function fakeEl(tag = 'div') {
this.attrs[k] = v;
},
getContext: vi.fn(() => ctx()),
toDataURL: () => 'data:,',
remove() {
if (this.parentElement)
this.parentElement.children = this.parentElement.children.filter((c: any) => c !== this);
@ -273,6 +275,8 @@ function ctx(withFilter = false) {
putImageData: (img: any) => puts.push(img),
beginPath() {},
moveTo() {},
stroke() {},
createRadialGradient: () => ({ addColorStop: vi.fn() }),
lineTo() {},
closePath() {},
// Stars: arc+fill is one draw. A context that has cleared a rect is the twinkle overlay's.
@ -378,9 +382,9 @@ describe('createRidgeScene', () => {
expect(s).not.toBeNull();
const tracks = tracksOf(parent);
expect(tracks).toHaveLength(LAYERS.length);
tracks.forEach((t: any) => {
tracks.forEach((t: any, i: number) => {
expect(t.children).toHaveLength(3);
expect(t.animate).toHaveBeenCalledTimes(1);
expect(t.animate).toHaveBeenCalledTimes(Number.isFinite(LAYERS[i].seconds) ? 1 : 0);
t.children.forEach((c: any) => {
expect(Number.isInteger(c.width) && Number.isInteger(c.height)).toBe(true);
expect(c.width).toBe(layerSize(400, 800, 2).chunk);
@ -428,7 +432,7 @@ describe('createRidgeScene', () => {
const { canvas } = host();
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
const anims = animsOf(canvas.parentElement);
expect(anims.length).toBe(LAYERS.length + 1); // one drift per layer and the sway
expect(anims.length).toBe(LAYERS.filter((l) => Number.isFinite(l.seconds)).length + 1); // one drift per drifting layer (the horizon does not) and the sway
expect(anims.every((a: any) => a.pause.mock.calls.length > 0)).toBe(true);
anims.forEach((a: any) => expect(a.play).not.toHaveBeenCalled());
expect(rafQueue).toHaveLength(0);
@ -759,7 +763,7 @@ describe('endless drift: chunks baked on demand and recycled', () => {
it('when the drift animation ends, the chunks move one slot left and the track restarts', async () => {
const { parent, s, records } = start();
await until(() => records.length === 3 * LAYERS.length);
const track = tracksOf(parent)[0];
const track = tracksOf(parent)[1]; // the first layer that drifts: the horizon, [0], stands still
const [e0, e1, e2] = track.children;
const first = track.anims[0];
// One chunk of drift, not one viewport: the track slides by the width of a chunk.
@ -777,8 +781,8 @@ describe('endless drift: chunks baked on demand and recycled', () => {
it('the canvas that left is baked with the next stretch of world, and each rotation after it, without end', async () => {
const { parent, s, records } = start();
await until(() => records.length === 3 * LAYERS.length);
const track = tracksOf(parent)[0];
const done = () => records.filter((r) => r.layer === 0).map((r) => r.chunk);
const track = tracksOf(parent)[1];
const done = () => records.filter((r) => r.layer === 1).map((r) => r.chunk);
for (let m = 3; m <= 5; m += 1) {
track.anims[track.anims.length - 1].finish();
await until(() => done().includes(m));
@ -786,9 +790,9 @@ describe('endless drift: chunks baked on demand and recycled', () => {
expect(done()).toEqual([0, 1, 2, 3, 4, 5]);
// The pixels of chunks 3 to 5 are the world's, not a repeat of an earlier one.
const imgs = baked();
const layer0 = imgs.filter((_, i) => records[i].layer === 0);
[3, 4, 5].forEach((m, k) => expect(layer0[3 + k].equals(expected(0, m))).toBe(true));
expect(layer0[3].equals(layer0[0])).toBe(false);
const layer1 = imgs.filter((_, i) => records[i].layer === 1);
[3, 4, 5].forEach((m, k) => expect(layer1[3 + k].equals(expected(1, m))).toBe(true));
expect(layer1[3].equals(layer1[0])).toBe(false);
s.dispose();
});
@ -812,3 +816,43 @@ describe('endless drift: chunks baked on demand and recycled', () => {
s.dispose();
});
});
describe('rain in the scene', () => {
const rainy = (w: number): SkyColours => ({ ...DAY, physical: makePhysicalSky('day', new Date('2026-06-21T10:40:00Z'), w) });
const rainEls = (parent: any) => parent.children.filter((c: any) => c.tag === 'div' && c !== rigOf(parent));
it('lays one falling tile per rain layer over the ridges when it rains, each slid by its own height on the compositor', () => {
const { parent, canvas } = host();
const s = createRidgeScene({ canvas, tier: 'full', sky: rainy(1) })!;
const els = rainEls(parent);
expect(els).toHaveLength(2);
els.forEach((e: any) => {
expect(e.anims).toHaveLength(1);
expect(e.anims[0].options.iterations).toBe(Infinity);
});
s.dispose();
expect(rainEls(parent)).toHaveLength(0);
els.forEach((e: any) => expect(e.anims[0].cancel).toHaveBeenCalled());
});
it('draws no rain in dry weather, and none in a still', () => {
const dry = host();
createRidgeScene({ canvas: dry.canvas, tier: 'full', sky: rainy(0.3) })!.dispose();
const a = host();
const sa = createRidgeScene({ canvas: a.canvas, tier: 'full', sky: rainy(0.3) })!;
expect(rainEls(a.parent)).toHaveLength(0);
sa.dispose();
const still = host();
const ss = createRidgeScene({ canvas: still.canvas, tier: 'still', sky: rainy(1) })!;
expect(rainEls(still.parent)).toHaveLength(0);
ss.dispose();
});
it('pauses with the rest when motion is reduced', () => {
motion.matches = true;
const { parent, canvas } = host();
const s = createRidgeScene({ canvas, tier: 'full', sky: rainy(1) })!;
rainEls(parent).forEach((e: any) => expect(e.anims[0].play).not.toHaveBeenCalled());
s.dispose();
});
});

View file

@ -3,9 +3,13 @@ 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';
import { LAYERS, LayerSpec, edgeBlur, HORIZON_TRACK_SHIFT, layerSize, scatterAlpha, transmittance } from './ridgeWorld';
import { hfovDeg } from './horizon';
import { viewFor, VFOV_DEG } from './skyDome';
import { drawRainTile, RAIN_TILES } from './rain';
/** One chunk's bake: `ms` is the summed task time, `maxTaskMs` the longest single task; `first` marks the bake of the first view. */
export interface BakeRecord {
@ -34,9 +38,55 @@ function context2d(c: HTMLCanvasElement): CanvasRenderingContext2D | null {
return c.getContext('2d');
}
function paintSky(canvas: HTMLCanvasElement, sky: SkyColours): boolean {
/** The sun: a bright core of its true angular size (at least 2.5 px) and a short soft falloff, in the colour the air leaves it. */
function drawSun(
ctx: CanvasRenderingContext2D,
d: NonNullable<ReturnType<typeof paintPhysicalSky>['sunDisc']>,
w: number,
h: number
): void {
const r = Math.max(2.5, d.radius * h);
const x = d.x * w;
const y = d.y * h;
const g = ctx.createRadialGradient(x, y, 0, x, y, r * 3.5);
const [cr, cg, cb] = d.rgb;
g.addColorStop(0, `rgba(${cr},${cg},${cb},${d.alpha})`);
g.addColorStop(1 / 3.5, `rgba(${cr},${cg},${cb},${d.alpha})`);
g.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
ctx.fillStyle = g;
ctx.fillRect(x - r * 3.5, y - r * 3.5, r * 7, r * 7);
}
/**
* 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);
if (px.sunDisc) drawSun(ctx, px.sunDisc, 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));
@ -111,6 +161,13 @@ function chunkJob(
const params: BakeParams = {
spec,
sky,
horizonView:
spec.horizon && sky.physical
? {
azimuthDeg: viewFor(sky.physical.sun, size.period / viewH).azimuthDeg,
hfovDeg: hfovDeg(size.period / viewH, VFOV_DEG),
}
: undefined,
period: size.period,
scale,
viewH,
@ -237,6 +294,8 @@ interface Built {
makeCanvas: () => HTMLCanvasElement;
/** The twinkling stars' own canvas, between the sky and the ridges; absent in a day sky and in 'still'. */
twinkle?: HTMLCanvasElement;
/** The rain's tiles and their falling, present only when the weather rains (`rain.ts`). */
rain?: { els: HTMLElement[]; anims: (Animation | undefined)[] };
}
/** Yaw and pitch: the camera's slow sway, a loop of this many seconds over the whole rig. */
@ -291,6 +350,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
built?.layers.forEach((l) => l.anim?.cancel());
built?.rig.remove();
built?.twinkle?.remove();
built?.rain?.anims.forEach((a) => a?.cancel());
built?.rain?.els.forEach((e) => e.remove());
cancelAnimationFrame(twRaf);
twRaf = 0;
built = undefined;
@ -355,7 +416,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
/** One chunk of drift as one animation, holding its end until `rotate` hands the track the next chunk. */
const startDrift = (l: LayerState, onEnd: (l: LayerState) => void) => {
if (!built) return;
if (!built || !Number.isFinite(l.spec.seconds)) return; // the horizon layer does not drift
const { size, scale } = built;
l.anim = l.track.animate?.(
[
@ -406,6 +467,47 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
);
};
/**
* The rain over everything: per tile, a div of the tile's texture (drawn once) taller than the viewport by one tile, slid down one
* tile height per loop on the compositor. 'still' draws none (a settled still has no animation to carry it).
*/
const startRain = (rain: number, colour: Srgb, w: number, h: number, scale: number): NonNullable<Built['rain']> => {
const els: HTMLElement[] = [];
const anims: (Animation | undefined)[] = [];
RAIN_TILES.forEach((t, k) => {
const tile = document.createElement('canvas');
tile.width = t.size;
tile.height = t.size;
const tctx = context2d(tile);
if (!tctx) return;
drawRainTile(tctx, t, rain, colour, scale, k * 41);
const px = t.size / scale;
const el = document.createElement('div');
el.setAttribute('aria-hidden', 'true');
Object.assign(el.style, {
position: 'absolute',
left: '0',
top: `${-px}px`,
width: '100%',
height: `${h + px}px`,
backgroundImage: `url(${tile.toDataURL()})`,
backgroundSize: `${px}px ${px}px`,
pointerEvents: 'none',
willChange: 'transform',
});
parent.appendChild(el);
els.push(el);
anims.push(
el.animate?.([{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(0,${px}px,0)` }], {
duration: t.seconds * 1000,
iterations: Infinity,
easing: 'linear',
})
);
});
return { els, anims };
};
/** Bake the sky, then the layers. Returns false when a 2D context is missing or there is no area yet. */
const build = (): boolean => {
teardownLayers();
@ -415,7 +517,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);
@ -454,7 +556,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
const track = document.createElement('div');
Object.assign(track.style, {
position: 'absolute',
left: '0',
left: spec.horizon ? `${(-HORIZON_TRACK_SHIFT * size.period) / scale}px` : '0',
bottom: '0',
width: `${size.period / scale}px`,
height: `${size.height / scale}px`,
@ -504,6 +606,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
parent.appendChild(rig);
layers.forEach((l) => startDrift(l, rotate));
built.sway = startSway(rig, w, h);
if (tier !== 'still' && sky.physical && sky.physical.weather.rain > 0) built.rain = startRain(sky.physical.weather.rain, sky.horizon, w, h, scale);
pump();
syncPlayState();
return true;
@ -537,7 +640,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
};
function syncPlayState() {
const anims = built ? [built.sway, ...built.layers.map((l) => l.anim)] : [];
const anims = built ? [built.sway, ...built.layers.map((l) => l.anim), ...(built.rain?.anims ?? [])] : [];
anims.forEach((a) => (moving() ? a?.play() : a?.pause()));
if (!built?.twinkle) return;
if (moving()) {

View file

@ -0,0 +1,74 @@
/* eslint-disable no-param-reassign -- `cloudRadiance` fills the caller's array */
// A cloud deck over the sky dome: per texel, the view ray hits a plane at cloud height, a three-octave value noise there is the
// deck's thickness, `cover` says how much of it is cloud, and a cloudy texel is the cloud's own radiance (lit by the sun through the
// air and by the sky, thinned for the thickness of the deck) in place of the sky behind it. One noise sample per octave per texel on
// the dome's small grid: no march, no texture, nothing per frame.
import { hash2 } from './noise';
import type { Weather } from './weather';
const smooth = (t: number): number => t * t * (3 - 2 * t);
const smoothstep = (a: number, b: number, v: number): number => {
const t = Math.min(1, Math.max(0, (v - a) / (b - a)));
return smooth(t);
};
/** 2D value noise in [0, 1). */
export function noise2(x: number, y: number): number {
const cx = Math.floor(x);
const cy = Math.floor(y);
const u = smooth(x - cx);
const v = smooth(y - cy);
const a = hash2(cx, cy);
const b = hash2(cx + 1, cy);
const c = hash2(cx, cy + 1);
const d = hash2(cx + 1, cy + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
/** Plane-projected texels per unit of ray slope, and the octave gain. */
const SCALE = 1.6;
/** Below this ray elevation (sine) the deck fades into the horizon haze. */
const HORIZON_FADE: readonly [number, number] = [0.02, 0.22];
/**
* Cloud amount 0..1 along a ray with horizontal direction (`dx`, `dy`) and vertical sine `vz`. At cover 0 there is none and at 1
* the sky is solid overcast; between, the noise thresholds into puffs with soft edges. `drift` offsets the pattern (the weather's
* wind, in plane units).
*/
export function cloudAmount(dx: number, dy: number, vz: number, cover: number, drift = 0): number {
if (cover <= 0 || vz <= 0) return 0;
const px = (dx / (vz + 0.1)) * SCALE + drift;
const py = (dy / (vz + 0.1)) * SCALE;
let n = 0;
let amp = 0.55;
let f = 1;
let norm = 0;
for (let k = 0; k < 3; k += 1) {
n += amp * noise2(px * f + k * 17.3, py * f + k * 5.1);
norm += amp;
amp *= 0.5;
f *= 2.1;
}
n /= norm;
// cover 1 puts the threshold below every value: solid; cover 0 above every value: none.
const thr = 1.05 - cover * 0.95;
return smoothstep(thr - 0.12, thr + 0.12, n) * smoothstep(HORIZON_FADE[0], HORIZON_FADE[1], vz);
}
/**
* The radiance of cloud, per unit of sun irradiance, for a sun at sine `sunSin` seen through `sunTrans` (the air's transmittance to
* the sun) over a sky whose zenith radiance is `zenith`. A white scatterer (albedo 0.8): the sun's direct light, which thick cover
* blocks (`cover` 1 passes a fifth), and the sky's, which it does not. Written into `out`.
*/
export function cloudRadiance(
sunSin: number,
sunTrans: readonly number[],
zenith: readonly number[],
cover: number,
out: number[]
): void {
const direct = (Math.max(sunSin, 0) / Math.PI) * (1 - 0.8 * cover);
for (let i = 0; i < 3; i += 1) out[i] = 0.8 * (sunTrans[i] * direct + zenith[i]);
}
export const hasClouds = (w: Weather): boolean => w.cover > 0.02;

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { HORIZON_CREDIT, WEATHER_CREDIT, WEATHER_CREDIT_URL, WEATHER_DATASET } from './credits';
import { HORIZON_ATTRIBUTION } from './horizon';
import { WEATHER_ATTRIBUTION } from './weatherFeed';
describe('the credits the Sky menu shows', () => {
it('are the sources\' own', () => {
expect(HORIZON_CREDIT).toBe(HORIZON_ATTRIBUTION);
expect(WEATHER_CREDIT).toBe(WEATHER_ATTRIBUTION);
});
it('credit GeoSphere with its requested line, and state a licence only with the dataset named', () => {
expect(WEATHER_CREDIT).toBe(`Datenquelle: GeoSphere Austria - ${WEATHER_CREDIT_URL}`);
expect(WEATHER_CREDIT).not.toMatch(/CC BY/);
expect(WEATHER_DATASET).toMatch(/TAWES.*10-minute.*CC BY 4\.0/);
});
});

View file

@ -0,0 +1,11 @@
// What the Ridge background owes its data sources, shown in the Sky menu (SkyStateControl). Plain strings in their own module so the
// main chunk carries these and not the baked horizon table or the weather parser; credits.test.ts holds them equal to the sources'.
/** GeoSphere's own requested credit line. It names no licence: that is the dataset's (below). */
export const WEATHER_CREDIT = 'Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at';
export const WEATHER_CREDIT_URL = 'https://data.hub.geosphere.at';
/** The dataset the weather is read from, and its licence as the dataset page names it (data.hub.geosphere.at, tawes-v1-10min). */
export const WEATHER_DATASET = 'TAWES weather stations, 10-minute data (station 11035), CC BY 4.0';
/** The terrain silhouette's credit, as the Copernicus DEM licence asks (equal to `HORIZON_ATTRIBUTION`). */
export const HORIZON_CREDIT =
'Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.';

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,27 @@
import { describe, expect, it } from 'vitest';
import bake from './viennaHorizon.json';
import { VIENNA } from './vienna';
import { horizonDistanceKm, horizonElevation, horizonNormal } from './horizon';
describe('the baked horizon', () => {
it('was baked from the viewpoint the sun is computed for', () => {
expect(bake.viewpoint.lat).toBe(VIENNA.lat);
expect(bake.viewpoint.lon).toBe(VIENNA.lon);
expect(bake.viewpoint.groundM / 1000).toBeCloseTo(VIENNA.altitudeKm, 2);
});
it('carries its source and licence beside the data', () => {
expect(bake.source.licence).toMatch(/attribution required/);
expect(bake.source.attribution).toMatch(/COPERNICUS/);
});
it('is a closed ring of plausible angles: the Wienerwald NW, open Marchfeld E', () => {
expect(bake.elevationDeg).toHaveLength(720);
expect(horizonElevation(0)).toBeCloseTo(horizonElevation(360), 6);
expect(horizonElevation(305)).toBeGreaterThan(horizonElevation(70));
expect(Math.max(...bake.elevationDeg)).toBeLessThan(5);
expect(horizonDistanceKm(305)).toBeGreaterThan(1);
});
it('interpolates between samples and returns unit normals', () => {
expect(horizonElevation(100.25)).toBeCloseTo((bake.elevationDeg[200] + bake.elevationDeg[201]) / 2, 6);
expect(Math.hypot(...horizonNormal(250))).toBeCloseTo(1, 6);
});
});

View file

@ -0,0 +1,54 @@
// The terrain horizon seen from the viewpoint, baked from open DEM by `scripts/bake-horizon.py` (source, licence and attribution are
// in the file's `source` block and in `HORIZON_ATTRIBUTION`).
import bake from './viennaHorizon.json';
export const HORIZON_ATTRIBUTION = bake.source.attribution;
const N = bake.elevationDeg.length;
/** Index pair and weight of an azimuth (degrees clockwise from north) on the baked ring. */
function at(azimuth: number): [number, number, number] {
const f = ((((azimuth / bake.stepDeg) % N) + N) % N);
const i = Math.floor(f);
return [i, (i + 1) % N, f - i];
}
const lerp = (arr: readonly number[], az: number): number => {
const [a, b, t] = at(az);
return arr[a] * (1 - t) + arr[b] * t;
};
/** Elevation of the horizon, degrees above the astronomical horizon, at an azimuth. */
export const horizonElevation = (azimuth: number): number => lerp(bake.elevationDeg, azimuth);
/** Distance of the terrain that sets the horizon, km. */
export const horizonDistanceKm = (azimuth: number): number => lerp(bake.distanceKm, azimuth);
/** Unit surface normal of the terrain at the horizon point: x east, y up, z north. */
export function horizonNormal(azimuth: number): [number, number, number] {
const e = lerp(bake.slopeEast, azimuth);
const n = lerp(bake.slopeNorth, azimuth);
const l = Math.hypot(e, 1, n);
return [-e / l, 1 / l, -n / l];
}
/** The elevations blurred over +-SMOOTH_DEG (a box, around the ring): at 30 m a city's roofs are the horizon's teeth, and a silhouette of them reads as a fence. */
const SMOOTH_DEG = 1.5;
const SMOOTH = Array.from({ length: N }, (_, i) => {
const k = Math.round(SMOOTH_DEG / bake.stepDeg);
let sum = 0;
for (let d = -k; d <= k; d += 1) sum += bake.elevationDeg[(i + d + N) % N];
return sum / (2 * k + 1);
});
const MIN_E = Math.min(...SMOOTH);
const RANGE_E = Math.max(...SMOOTH) - MIN_E;
/** The horizon at an azimuth as 0..1 of the ring's own range (the lowest point of the horizon 0, the highest 1): the silhouette's shape, not its scale. */
export const horizonShape = (azimuth: number): number => (lerp(SMOOTH, azimuth) - MIN_E) / RANGE_E;
/** Real range of the smoothed horizon, degrees: what `horizonShape` stretches to the layer's relief. */
export const HORIZON_RANGE_DEG = RANGE_E;
/** Horizontal field of view, degrees, of a camera with the sky's vertical field (`skyDome.ts` VFOV_DEG) at an aspect ratio. */
export const hfovDeg = (aspect: number, vfovDeg: number): number =>
(2 * Math.atan(aspect * Math.tan((vfovDeg * Math.PI) / 360)) * 180) / Math.PI;

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { hfovDeg, horizonShape } from './horizon';
import { HORIZON_TRACK_SHIFT, LAYERS, layerProfile, profile } from './ridgeWorld';
import { layerPaint } from './ridgeBake';
import { weatherOf } from './weather';
import { skyColours } from './skyColour';
import { makePhysicalSky } from './physicalSky';
import { VFOV_DEG } from './skyDome';
describe('the horizon layer', () => {
const far = LAYERS[0];
const view = { azimuthDeg: 200, hfovDeg: hfovDeg(16 / 9, VFOV_DEG) };
it('is the farthest layer, real and standing still', () => {
expect(far.horizon).toBe(true);
expect(Number.isFinite(far.seconds)).toBe(false);
expect(LAYERS.slice(1).every((l) => !l.horizon && Number.isFinite(l.seconds))).toBe(true);
});
it('reads world-x as azimuth across the viewport: the left edge is half the field to the left of the view, the right edge half to the right', () => {
const left = layerProfile(far, HORIZON_TRACK_SHIFT, view);
const right = layerProfile(far, HORIZON_TRACK_SHIFT + 1, view);
expect(left).toBeCloseTo(horizonShape(200 - view.hfovDeg / 2), 9);
expect(right).toBeCloseTo(horizonShape(200 + view.hfovDeg / 2), 9);
});
it('stays in 0..1 and covers a real range of shape, not a line', () => {
const v = Array.from({ length: 200 }, (_, i) => layerProfile(far, -0.06 + (i / 200) * 1.2, { azimuthDeg: 305, hfovDeg: view.hfovDeg }));
expect(Math.min(...v)).toBeGreaterThanOrEqual(0);
expect(Math.max(...v)).toBeLessThanOrEqual(1);
expect(Math.max(...v) - Math.min(...v)).toBeGreaterThan(0.2);
});
it('is the noise profile for the other layers, and for the horizon layer when no view is known', () => {
expect(layerProfile(LAYERS[2], 0.4, view)).toBe(profile(LAYERS[2], 0.4));
expect(layerProfile(far, 0.4)).toBe(profile(far, 0.4));
});
it('the horizontal field of a 1:1 view is the vertical one, and grows with the aspect', () => {
expect(hfovDeg(1, VFOV_DEG)).toBeCloseTo(VFOV_DEG, 9);
expect(hfovDeg(16 / 9, VFOV_DEG)).toBeGreaterThan(VFOV_DEG);
});
});
describe('weather in the layers', () => {
it('mist multiplies the layers\' own, and clear air leaves it alone', () => {
const base = skyColours();
const day = new Date('2026-06-21T10:40:00Z');
const clear = layerPaint(LAYERS[1], { ...base, physical: makePhysicalSky('day', day, 0) });
const misty = layerPaint(LAYERS[1], { ...base, physical: makePhysicalSky('day', day, 0.8) });
expect(clear.mistK).toBe(1);
expect(misty.mistK).toBeGreaterThan(2);
expect(layerPaint(LAYERS[1], base).mistK).toBe(1);
});
});
describe('the weather number', () => {
it('a number that is not finite is a clear sky, not an error', () => {
expect(weatherOf(Number.NaN).value).toBe(0);
expect(weatherOf(Infinity).value).toBe(0);
expect(weatherOf(0.5).value).toBe(0.5);
});
});

View file

@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import { makePhysicalSky, paintSky } from './physicalSky';
import { skyBackdrop } from '../skyState';
import type { FixedSkyState } from '../skyBand';
// The Aqua method on tonemapped pixels: paint the sky the way the host canvas does and measure the WCAG luminance of every 8-bit
// texel, against the bounds the page text was checked against (`skyBackdrop`, 4.5:1). Over the weather, the day of the year, and
// viewports of different shape (the fit is made on one 16:9 grid and the paint on another).
const lin = (v: number): number => {
const c = v / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const luma = (d: Uint8ClampedArray, i: number): number => 0.2126 * lin(d[i]) + 0.7152 * lin(d[i + 1]) + 0.0722 * lin(d[i + 2]);
const DAYS = ['2026-03-20', '2026-06-21', '2026-09-23', '2026-12-21'];
const WEATHER = [0, 0.3, 0.55, 0.75, 1];
const VIEWPORTS: [number, number][] = [
[1920, 1080],
[1024, 768],
[2560, 1080],
[390, 844],
];
describe.each(['day', 'dusk', 'night'] as FixedSkyState[])('the %s sky stays inside its backdrop bounds', (state) => {
it('over weather, season and viewport shape', () => {
const b = skyBackdrop(state);
let lo = 2;
let hi = -1;
for (const day of DAYS) {
for (const w of WEATHER) {
const sky = makePhysicalSky(state, new Date(`${day}T10:40:00Z`), w);
for (const [vw, vh] of VIEWPORTS) {
const px = paintSky(sky, vw, vh);
for (let i = 0; i < px.w * px.h * 4; i += 4) {
const l = luma(px.data, i);
lo = Math.min(lo, l);
hi = Math.max(hi, l);
}
}
}
}
expect(lo).toBeGreaterThanOrEqual(b.darkest);
expect(hi).toBeLessThanOrEqual(b.lightest);
});
});
describe('weather in the sky', () => {
it('overcast is cloud: less blue and less contrast than clear air at the same sun', () => {
const at = new Date('2026-06-21T10:40:00Z');
const clear = paintSky(makePhysicalSky('day', at, 0), 640, 360);
const grey = paintSky(makePhysicalSky('day', at, 0.7), 640, 360);
const spread = (p: typeof clear) => {
let lo = 2;
let hi = -1;
for (let i = 0; i < p.w * p.h * 4; i += 4) {
const l = luma(p.data, i);
lo = Math.min(lo, l);
hi = Math.max(hi, l);
}
return hi - lo;
};
expect(spread(grey)).toBeLessThan(spread(clear));
const mid = (p: typeof clear) => {
const i = (Math.floor(p.h * 0.3) * p.w + Math.floor(p.w / 2)) * 4;
return p.data[i + 2] - p.data[i];
};
expect(mid(grey)).toBeLessThan(mid(clear));
});
});
describe('the sun disc', () => {
it('keeps the display floor the dark link was fitted to, at every hour it is drawn', () => {
const lin = (c: number) => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4);
let drawn = 0;
for (const state of ['day', 'dusk'] as FixedSkyState[]) {
for (let m = 0; m < 24 * 60; m += 10) {
const d = paintSky(makePhysicalSky(state, new Date(Date.UTC(2026, 10, 11, 0, m)), 0), 1280, 720).sunDisc;
if (!d) continue;
drawn += 1;
expect(0.2126 * lin(d.rgb[0]) + 0.7152 * lin(d.rgb[1]) + 0.0722 * lin(d.rgb[2])).toBeGreaterThanOrEqual(0.4744);
}
}
expect(drawn).toBeGreaterThan(0);
});
const noon = new Date('2026-11-11T10:40:00Z'); // 21 degrees: in June the noon sun is above the top of the image
it('is drawn by the host, in a clear sky by day, white with the sun high', () => {
const d = paintSky(makePhysicalSky('day', noon, 0), 1280, 720).sunDisc;
expect(d).toBeDefined();
expect(d!.x).toBeGreaterThan(0);
expect(d!.x).toBeLessThan(1);
expect(d!.y).toBeGreaterThan(0);
expect(d!.y).toBeLessThan(0.78); // above the horizon line
expect(d!.alpha).toBe(1);
expect(Math.min(...d!.rgb)).toBeGreaterThan(150); // a warm white at 21 degrees
});
it('is hidden by overcast and absent at night', () => {
expect(paintSky(makePhysicalSky('day', noon, 0.7), 1280, 720).sunDisc).toBeUndefined();
expect(paintSky(makePhysicalSky('night', noon, 0), 1280, 720).sunDisc).toBeUndefined();
});
it('is orange-tinted low in the sky', () => {
const d = paintSky(makePhysicalSky('dusk', noon, 0), 1280, 720).sunDisc;
expect(d).toBeDefined();
expect(d!.rgb[0]).toBeGreaterThan(d!.rgb[2]);
});
});

View file

@ -0,0 +1,124 @@
// 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, SUN_RADIUS, sunOnScreen, viewFor, VFOV_DEG } from './skyDome';
import type { Dome, DomeView } from './skyDome';
import type { Srgb } from './skyColour';
import { display8, fitDisplay, holdBudget, MARGIN } 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, weather.cover);
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 sun's disc for the host to draw on the upscaled canvas (it is smaller than a texel): place as fractions of the image, colour, radius in fractions of the height, opacity after the cloud. */
sunDisc?: { x: number; y: number; rgb: Srgb; radius: number; alpha: number };
}
/** 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, p.weather.cover);
const data = new Uint8ClampedArray(w * h * 4);
const out = [0, 0, 0];
const display = holdBudget(p.state, dome.hdr, p.fit.display);
for (let i = 0; i < w * h; i += 1) {
display8(dome.hdr[i * 3], dome.hdr[i * 3 + 1], dome.hdr[i * 3 + 2], 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, sunDisc: sunDisc(p, view) };
}
/** The luma the disc must keep: the day/dusk floor of toneMap's budget. */
const DISC_FLOOR = 0.4744 + MARGIN;
const smooth01 = (a: number, b: number, v: number): number => {
const t = Math.min(1, Math.max(0, (v - a) / (b - a)));
return t * t * (3 - 2 * t);
};
/**
* The sun's disc: always brighter than the display's white, so what is left of it is its colour, the air's transmittance to the sun
* normalised to its strongest channel (white high up, orange near the horizon). Thick cloud hides it.
*/
function sunDisc(p: PhysicalSky, view: DomeView): SkyPixels['sunDisc'] {
const at = sunOnScreen(p.sun, view);
const alpha = 1 - smooth01(0.1, 0.6, p.weather.cover);
if (!at || alpha <= 0) return undefined;
const trans = [0, 0, 0];
p.atm.transmittanceToSpace(Math.max(Math.sin((p.sun.elevation * Math.PI) / 180), 0), trans);
const top = Math.max(...trans, 1e-9);
const rgb = trans.map((t) => Math.round(255 * (t / top) ** 0.6));
// The disc sits on the sky, so it keeps the display floor the dark link was fitted to: mix toward white until it does.
const lin = (c: number): number => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4);
const luma = (c: number[]): number => 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]);
for (let m = 0; luma(rgb) < DISC_FLOOR && m < 40; m += 1) for (let i = 0; i < 3; i += 1) rgb[i] = Math.round(rgb[i] + (255 - rgb[i]) * 0.1);
return { ...at, rgb: rgb as unknown as Srgb, radius: (SUN_RADIUS * 180) / Math.PI / VFOV_DEG, alpha };
}

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { drawRainTile, RAIN_TILES, streakCount } from './rain';
const recorder = () => {
const calls: string[] = [];
const ctx = new Proxy({} as Record<string, unknown>, {
get: (t, k: string) => (k in t ? t[k] : (...a: unknown[]) => calls.push(`${k}(${a.join(',')})`)),
set: (t, k: string, v) => {
t[k] = v;
return true;
},
}) as unknown as CanvasRenderingContext2D;
return { ctx, calls };
};
describe('rain', () => {
it('draws no streaks without rain and more with more', () => {
expect(streakCount(RAIN_TILES[0], 0)).toBe(0);
expect(streakCount(RAIN_TILES[0], 0.3)).toBeLessThan(streakCount(RAIN_TILES[0], 1));
const { ctx, calls } = recorder();
drawRainTile(ctx, RAIN_TILES[0], 0, [200, 210, 220], 1);
expect(calls.filter((c) => c.startsWith('stroke'))).toHaveLength(0);
});
it('is the same tile every time, and every streak is drawn in the nine wraps so the tile repeats without a seam', () => {
const a = recorder();
const b = recorder();
drawRainTile(a.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1);
drawRainTile(b.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1);
expect(a.calls).toEqual(b.calls);
expect(a.calls.filter((c) => c.startsWith('stroke'))).toHaveLength(streakCount(RAIN_TILES[1], 1) * 9);
});
});

View file

@ -0,0 +1,54 @@
// Rain as one tiled texture of slanted streaks, drawn once and slid down by the compositor (a CSS animation on a transform: no per-frame
// script, no per-frame paint). Two tiles of different size and speed read as depth. The streaks wear the sky's own horizon colour, so
// whatever they lighten stays inside the state's luminance bounds (a streak is a blend of two colours the sky already has).
import { hash2 } from './noise';
import type { Srgb } from './skyColour';
export interface RainTile {
/** Tile edge, canvas px. */
size: number;
/** Seconds for a tile to fall its own height. */
seconds: number;
/** Streaks per tile at rain 1. */
streaks: number;
/** Streak length, tile fractions. */
length: number;
/** Peak alpha. */
alpha: number;
}
/** Far, fine and slow; near, long and quick. */
export const RAIN_TILES: readonly RainTile[] = [
{ size: 192, seconds: 0.9, streaks: 46, length: 0.16, alpha: 0.32 },
{ size: 320, seconds: 0.6, streaks: 22, length: 0.24, alpha: 0.42 },
];
/** Slant of a streak: pixels left per pixel down. */
export const RAIN_SLANT = 0.18;
/** Streaks to draw at a rain amount 0..1 (none at 0). */
export const streakCount = (t: RainTile, rain: number): number => (rain <= 0 ? 0 : Math.max(4, Math.round(t.streaks * rain)));
/** Draw one tile. The streaks wrap across the tile's edges so the tile repeats without a seam. */
export function drawRainTile(ctx: CanvasRenderingContext2D, t: RainTile, rain: number, colour: Srgb, scale: number, seed = 0): void {
const n = streakCount(t, rain);
ctx.clearRect(0, 0, t.size, t.size);
ctx.lineCap = 'round';
ctx.lineWidth = Math.max(1, scale);
for (let i = 0; i < n; i += 1) {
const x = hash2(i, seed + 3) * t.size;
const y = hash2(i, seed + 7) * t.size;
const len = t.length * t.size * (0.7 + 0.6 * hash2(i, seed + 11));
const a = t.alpha * (0.5 + 0.5 * hash2(i, seed + 13)) * (0.5 + 0.5 * rain);
ctx.strokeStyle = `rgba(${colour[0]},${colour[1]},${colour[2]},${a.toFixed(3)})`;
// Draw each streak at its place and one tile over in each direction it can cross an edge.
[-t.size, 0, t.size].forEach((ox) =>
[-t.size, 0, t.size].forEach((oy) => {
ctx.beginPath();
ctx.moveTo(x + ox, y + oy);
ctx.lineTo(x + ox - len * RAIN_SLANT, y + oy + len);
ctx.stroke();
})
);
}
}

View file

@ -3,7 +3,7 @@
// slope of the heightfield's crest line against the sun; the face under it takes a light-to-haze
// gradient, erosion streaks and a lit rim on the crest. The depth treatment (colour, haze, contrast
// by distance) is ridgeWorld's `layerColour`, `hazeColour` and `transmittance`, used here and not redone.
import { gullyAt, LayerSpec, profile, hazeColour, layerColour, transmittance } from './ridgeWorld';
import { gullyAt, HorizonView, LayerSpec, layerProfile, hazeColour, layerColour, transmittance } from './ridgeWorld';
import type { SkyBand } from '../skyBand';
import { LUMA, SKY_BAND } from '../skyBand';
import type { SkyColours, Srgb } from './skyColour';
@ -45,6 +45,8 @@ export interface BakeParams {
rows: number;
/** Row count of the visible band; the crest is measured from the top of it. */
bandH: number;
/** Where world-x is a compass azimuth, for the horizon layer (`LayerSpec.horizon`); absent: every layer is noise. */
horizonView?: HorizonView;
/** World pixel of the buffer's column 0 (may be fractional-free negative: a chunk's left minus its padding). */
px0: number;
/** Columns of the buffer. */
@ -67,6 +69,8 @@ export interface LayerPaint {
warm: Srgb;
contrast: number;
sun: Vec3;
/** How much the weather's mist multiplies the layer's own (`weather.ts`): 1 in clear air. */
mistK: number;
}
const decode = (v: number): number => {
@ -123,6 +127,7 @@ export function layerPaint(spec: LayerSpec, sky: SkyColours): LayerPaint {
// Distance flattens relief: the same transmittance that thins the colour.
contrast: 0.35 + 0.65 * transmittance(spec),
sun: sunDirection(sky),
mistK: 1 + 2.5 * (sky.physical?.weather.mist ?? 0),
};
}
@ -132,7 +137,7 @@ function crestRows(p: BakeParams, c0: number, c1: number): Float64Array {
const amp = p.spec.relief * p.viewH;
const lift = p.spec.base * p.viewH;
for (let c = c0 - 1; c <= c1; c += 1) {
const top = Math.min(p.bandH, lift + amp * profile(p.spec, (p.px0 + c) / p.period));
const top = Math.min(p.bandH, lift + amp * layerProfile(p.spec, (p.px0 + c) / p.period, p.horizonView));
out[c - c0 + 1] = p.bandH - top;
}
return out;
@ -155,7 +160,7 @@ export function bakeSlice(
const faceH = 0.45 * p.viewH;
const rim = 2.2 * p.scale;
const glow = 9 * p.scale;
const { base, haze, warm, contrast, sun, shift } = paint;
const { base, haze, warm, contrast, sun, shift, mistK } = paint;
for (let c = c0; c < c1; c += 1) {
const cy = crest[c - c0 + 1];
const slope = (crest[c - c0 + 2] - crest[c - c0]) * -0.5; // rows up per column
@ -182,7 +187,7 @@ export function bakeSlice(
(clamp01(1 - d / rim) * 0.4 + (d < glow * 5 ? Math.exp(-d / glow) * 0.08 : 0)) *
contrast *
(0.3 + 0.7 * lit);
const mist = spec.mist * smoothstep(0, 1, dn);
const mist = Math.min(0.9, spec.mist * mistK) * smoothstep(0, 1, dn);
const o = (y * p.cols + c) * 4;
for (let i = 0; i < 3; i += 1) {
let v = base[i] * shade + warm[i] * (warmK + rimK);

View file

@ -1,6 +1,7 @@
// Every number of the ridge scene, and the pure functions that turn them into silhouettes. No DOM.
// A ridge is a function of world-x (t, in viewport widths): ridged multifractal noise, domain-warped,
// one function for every layer with the parameters below. Nothing in it repeats.
import { horizonShape } from './horizon';
import { valueNoise } from './noise';
import type { Srgb, SkyColours } from './skyColour';
@ -29,23 +30,40 @@ export interface LayerSpec {
gullyPx: number;
/** 0..1 how far the foot of the face dissolves into the valley mist. */
mist: number;
/**
* The layer is the real horizon of the viewpoint (`horizon.ts`), not noise: world-x is compass azimuth across the viewport
* (`HorizonView`) and it does not drift, so it stays under the sun it is lit by. Its `relief` is the ring's own range.
*/
horizon?: boolean;
}
/** Where the camera looks and how wide it sees, to read world-x as an azimuth: the sky's own view (`skyDome.ts viewFor`). */
export interface HorizonView {
/** Compass azimuth of the viewport's centre, degrees clockwise from north. */
azimuthDeg: number;
hfovDeg: number;
}
/** The static horizon layer's track sits this many viewport widths left of the screen, so the camera's sway never shows its edge. */
export const HORIZON_TRACK_SHIFT = 0.06;
/** Four layers: one function, four parameter rows. */
export const LAYERS: readonly LayerSpec[] = [
{
index: 0,
baseCells: 2.2,
relief: 0.24,
relief: 0.16,
crag: 0.85,
base: 0.34,
base: 0.3,
depth: 0.12,
seconds: 420,
seconds: Infinity,
octaves: 5,
warp: 0.6,
gully: 0.3,
gullyPx: 16,
mist: 0.35,
horizon: true,
},
{
index: 1,
@ -128,6 +146,15 @@ export function profile(spec: LayerSpec, t: number): number {
return Math.min(1, 0.85 * (sum / norm) * PEAK_NORM + 0.15 * swell);
}
/**
* Silhouette height in [0, 1] of a layer at world-x `t` (viewport widths). The horizon layer reads the baked horizon at the azimuth `t`
* is under `view`; every other layer, or a horizon layer with no view, is the noise `profile`.
*/
export function layerProfile(spec: LayerSpec, t: number, view?: HorizonView): number {
if (!spec.horizon || !view) return profile(spec, t);
return horizonShape(view.azimuthDeg + (t - HORIZON_TRACK_SHIFT - 0.5) * view.hfovDeg);
}
/** Erosion streak strength in [0, 1] at world-x `wxCss` (CSS px): a folded noise at gully width. */
export function gullyAt(spec: LayerSpec, wxCss: number): number {
const g = 1 - Math.abs(2 * valueNoise(wxCss / spec.gullyPx, spec.index * 131 + 5) - 1);
@ -198,7 +225,7 @@ export function layerSize(cssW: number, cssH: number, scale: number) {
}
/** Ridge-top y (canvas pixels, from the top of the layer's band) at world-x `t` (viewport widths). */
export function ridgeY(spec: LayerSpec, t: number, bandH: number, viewH: number): number {
const topFromBottom = spec.base * viewH + spec.relief * viewH * profile(spec, t);
export function ridgeY(spec: LayerSpec, t: number, bandH: number, viewH: number, view?: HorizonView): number {
const topFromBottom = spec.base * viewH + spec.relief * viewH * layerProfile(spec, t, view);
return bandH - Math.min(bandH, topFromBottom);
}

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,122 @@
// 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`), 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';
import { cloudAmount, cloudRadiance } from './clouds';
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). The sun's disc is not here: it is smaller than a texel, so the host draws it on the upscaled canvas (`sunOnScreen`). `cover` (0..1, `weather.ts`) lays the cloud deck (`clouds.ts`) over it.
*/
export function renderDome(
atm: Atmosphere,
sun: SunSample,
view: DomeView,
w: number,
h: number,
cover = 0
): 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 sz = Math.sin(sunEl);
const trans = [0, 0, 0];
atm.transmittanceToSpace(Math.max(sz, 0), trans); // the cloud's sunlight
const rgb = [0, 0, 0];
const cloud = [0, 0, 0];
if (cover > 0) {
const zenith = [0, 0, 0];
atm.radiance(1, 1, zenith);
cloudRadiance(sz, trans, zenith, cover, cloud);
}
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];
let lum = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
if (cover > 0) {
const c = cloudAmount(dx / n, dy / n, vz, cover);
lum += (0.2126 * cloud[0] + 0.7152 * cloud[1] + 0.0722 * cloud[2] - lum) * c;
r += (cloud[0] - r) * c;
g += (cloud[1] - g) * c;
b += (cloud[2] - b) * c;
}
const o = (j * w + i) * 3;
hdr[o] = r;
hdr[o + 1] = g;
hdr[o + 2] = b;
logSum += Math.log(Math.max(1e-9, lum));
}
}
return { w, h, hdr, meanLuma: Math.exp(logSum / (w * h)) };
}
/** Where the sun is on the image, as fractions of its width and height (y down), or null when it is behind the camera or below the horizon. */
export function sunOnScreen(sun: SunSample, view: DomeView): { x: number; y: number } | null {
if (sun.elevation < -0.3) return null;
const tanHalf = Math.tan((VFOV_DEG * RAD) / 2);
const fa = view.azimuthDeg * RAD;
const az = sun.azimuth * RAD;
const el = sun.elevation * RAD;
const sx = Math.cos(el) * Math.sin(az);
const sy = Math.cos(el) * Math.cos(az);
const along = sx * Math.sin(fa) + sy * Math.cos(fa);
if (along <= 0.05) return null;
const u = (sx * Math.cos(fa) - sy * Math.sin(fa)) / along;
const v = Math.sin(el) / along;
const x = u / (view.aspect * 2 * tanHalf) + 0.5;
const y = HORIZON_AT - v / (2 * tanHalf);
return x < -0.05 || x > 1.05 || y < -0.05 || y > 1.05 ? null : { x, y };
}

View file

@ -0,0 +1,194 @@
// 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 the same dark ink, so one floor: its bound plus margin, 0.4744, which is what the dark link needs (about 0.4554 for 4.5:1, skyTheme.test.ts); a lower day floor (0.317 was tried) leaves the link at 3.3:1. 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.4744 + 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 };
}
/**
* The display, moved only as far as the budget needs, for a grid other than the one it was fitted on (another viewport's shape,
* its own texels): the fit is made once on a canonical grid so that resizing repaints the same sky, and this keeps a texel the canonical
* grid did not have inside the state's bounds. A display that already holds them is returned as it is.
*/
export function holdBudget(state: FixedSkyState, hdr: Float32Array, d: Display): Display {
const b = BUDGET[state];
const n = hdr.length / 3;
const tmp = [0, 0, 0];
const range = (x: Display) => {
let lo = 2;
let hi = -1;
for (let i = 0; i < n; i += 1) {
const l = displayLumaOf(hdr[i * 3], hdr[i * 3 + 1], hdr[i * 3 + 2], x, tmp);
if (l < lo) lo = l;
if (l > hi) hi = l;
}
return { lo, hi };
};
let cur = d;
let r = range(cur);
if (r.hi > b.ceiling) {
let lo = 0;
let hiE = cur.exposure;
for (let k = 0; k < 20; k += 1) {
const mid = (lo + hiE) / 2;
if (range({ exposure: mid, lift: cur.lift }).hi > b.ceiling) hiE = mid;
else lo = mid;
}
cur = { exposure: lo, lift: cur.lift };
r = range(cur);
}
for (let k = 0; k < 8 && r.lo < b.floor; k += 1) {
cur = { exposure: cur.exposure, lift: cur.lift + ((b.floor - r.lo) / (1 - r.lo)) * 1.05 + 1e-4 };
r = range(cur);
}
return cur;
}

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.35 E falls near 10:40 UTC in mid November', () => {
// 16.3534 E is 65.4 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 the server's viewpoint in Vienna (48.1929826 N, 16.3534237 E, 183 m: Meta's location, the Copernicus DSM height there) 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.1929826, lon: 16.3534237, altitudeKm: 0.183 } 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));
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,51 @@
// One weather number, 0..1, and what it does to the sky. It is the page URL's `?weather=` (for a still), else the number the
// observation file gives (`weatherFeed.ts`, set by `setFedWeather`), else a clear sky. 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`).
let fed: number | undefined;
/** The weather number read from `weather.json`, or undefined to forget it. */
export const setFedWeather = (v: number | undefined): void => {
fed = v;
};
/** The weather the ridge sky is drawn for: the URL's, else the file's, else clear. */
export const ridgeWeather = (): number => urlWeather() ?? fed ?? 0;
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 : 0);
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

@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest';
import { viennaSun } from './vienna';
import {
CLEAR_SKY,
currentWeather,
fetchWeatherFile,
parseWeatherFile,
OBSERVATION_STALE_MS,
STALE_MS,
WEATHER_ATTRIBUTION,
weatherFromFile,
clearSkyGhi,
cloudEstimate,
cloudFromGlow,
WeatherFile,
} from './weatherFeed';
const file = (over: Partial<WeatherFile> = {}): WeatherFile => ({
fetched_at: '2026-11-11T10:45:00Z',
observed_at: '2026-11-11T10:40:00Z',
station: 11035,
precip_mm_10min: 0,
humidity_pct: 60,
glow_wm2: null,
sunshine_s_10min: null,
attribution: WEATHER_ATTRIBUTION,
...over,
});
const NOW = new Date('2026-11-11T10:50:00Z');
describe('weather from the file', () => {
it('credits GeoSphere as its legal page asks, and claims no licence', () => {
expect(WEATHER_ATTRIBUTION).toBe('Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at');
expect(WEATHER_ATTRIBUTION).not.toMatch(/CC/);
});
it('rain is 0.8..1, by rate', () => {
expect(weatherFromFile(file({ precip_mm_10min: 0.1 }), 20)).toBeCloseTo(0.813, 2);
expect(weatherFromFile(file({ precip_mm_10min: 3 }), -20)).toBe(1);
});
it('the clear-sky radiation is Haurwitz: about 335 W/m2 at 21 degrees, none below the horizon', () => {
expect(clearSkyGhi(21)).toBeCloseTo(335, -1);
expect(clearSkyGhi(-5)).toBe(0);
});
it('noon clear: radiation at the clear-sky value is no cloud', () => {
expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 600, humidity_pct: 95 }), 21)).toBe(0);
expect(cloudFromGlow(300, 21)).toBeCloseTo(0.56, 1); // k = 0.9 is still a few oktas
});
it('noon overcast: a fifth of the clear-sky radiation is nearly full cloud, and with saturated air, mist', () => {
expect(cloudFromGlow(0.2 * clearSkyGhi(21), 21)).toBeGreaterThan(0.9);
expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 80 }), 21)).toBeGreaterThan(0.6);
expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 100 }), 21)).toBe(0.75);
});
it('the radiation, not the humidity, decides by day: humid air under a bright sky is clear', () => {
expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(30), sunshine_s_10min: 600, humidity_pct: 98 }), 30)).toBe(0);
});
it('dusk: with the sun at 3 degrees the humidity ramp speaks, cloud 0 at 60% and 1 at 95%', () => {
expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 60 }), 3)).toBe(0);
expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 77.5 }), 3)).toBeCloseTo(0.35, 2);
expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 95 }), 3)).toBeCloseTo(0.7, 2);
});
it('night: the radiation and sunshine are 0.0 and the humidity speaks; saturated air is mist, 0.75', () => {
expect(weatherFromFile(file({ glow_wm2: 0, humidity_pct: 40 }), -20)).toBe(0);
expect(weatherFromFile(file({ glow_wm2: 0, humidity_pct: 100 }), -20)).toBe(0.75);
});
it('the 2026-11-10 midday fixture, GLOW 56-89 W/m2 under an overcast sky, is full cloud; the clear midday of 2026-10-15, 546-569, is none', () => {
const nov = viennaSun(new Date('2026-11-10T10:40:00Z')).elevation;
const oct = viennaSun(new Date('2026-10-15T10:50:00Z')).elevation;
for (const g of [56, 72, 89]) expect(cloudFromGlow(g, nov)).toBeGreaterThan(0.95);
for (const g of [546, 569]) expect(cloudFromGlow(g, oct)).toBe(0);
expect(weatherFromFile(file({ glow_wm2: 72, sunshine_s_10min: 0 }), nov)).toBeGreaterThan(0.65);
expect(weatherFromFile(file({ glow_wm2: 560, sunshine_s_10min: 600, humidity_pct: 70 }), oct)).toBe(0);
});
it('the sunshine cross-checks the radiation: agreement keeps the radiation, a half-sky disagreement splits it', () => {
expect(cloudEstimate(file({ glow_wm2: 0.2 * clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBeGreaterThan(0.9);
// Radiation says clear, the sunshine says none: each is half trusted.
expect(cloudEstimate(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBe(0.5);
// No radiation reading by day: the sunshine speaks alone.
expect(cloudEstimate(file({ glow_wm2: null, sunshine_s_10min: 300 }), 21)).toBe(0.5);
expect(cloudEstimate(file({ glow_wm2: 0, sunshine_s_10min: 600 }), 21)).toBe(0);
});
it('with the sun below the instruments\' reach there is no estimate: the humidity speaks', () => {
expect(cloudEstimate(file({ glow_wm2: 3, sunshine_s_10min: 0 }), 3)).toBeNull();
});
it('null: unknown measurements are not zero, and nothing known is a clear sky', () => {
expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: null, precip_mm_10min: null, sunshine_s_10min: null }), 21)).toBe(CLEAR_SKY);
expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: 95 }), 21)).toBeCloseTo(0.7, 2);
});
});
describe('the weather now', () => {
it('uses the file while it is fresh', () => {
expect(currentWeather(file({ glow_wm2: 0, humidity_pct: 95 }), NOW)).toBeCloseTo(0.7, 2);
});
it('falls back to a clear sky when the file was fetched more than 30 minutes ago', () => {
const f = file({ humidity_pct: 100, fetched_at: '2026-11-11T10:19:00Z' });
expect(NOW.getTime() - Date.parse(f.fetched_at)).toBeGreaterThan(STALE_MS);
expect(currentWeather(f, NOW)).toBe(CLEAR_SKY);
});
it('the 30 minute mark itself is still fresh', () => {
expect(currentWeather(file({ glow_wm2: 0, humidity_pct: 100, fetched_at: '2026-11-11T10:20:00Z' }), NOW)).toBe(0.75);
});
it('falls back to a clear sky when the observation is more than 40 minutes old, though the file is fresh', () => {
const f = file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:09:00Z' });
expect(NOW.getTime() - Date.parse(f.observed_at)).toBeGreaterThan(OBSERVATION_STALE_MS);
expect(currentWeather(f, NOW)).toBe(CLEAR_SKY);
expect(currentWeather(file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:11:00Z' }), NOW)).toBeGreaterThan(0.8);
});
it('falls back to a clear sky with no file, and with a fetch time from the future', () => {
expect(currentWeather(null, NOW)).toBe(CLEAR_SKY);
expect(currentWeather(file({ precip_mm_10min: 2, fetched_at: '2026-11-12T10:40:00Z' }), NOW)).toBe(CLEAR_SKY);
});
});
describe('bad instrument values', () => {
it('a negative sunshine is no reading, not full cloud', () => {
expect(cloudEstimate(file({ glow_wm2: null, sunshine_s_10min: -5 }), 21)).toBeNull();
});
it('the radiation is set against the sun of the ten minutes it averaged, not the sun now', () => {
// Sun rising: 07:45Z observation (mean of 07:35-07:45), read 35 minutes later at the stale limit.
const f = file({ observed_at: '2026-11-11T07:45:00Z', fetched_at: '2026-11-11T07:46:00Z', glow_wm2: 100, sunshine_s_10min: null, humidity_pct: 50 });
const at = (m: number) => currentWeather(f, new Date(Date.parse(f.observed_at) + m * 60_000));
expect(at(0)).toBe(at(29));
});
});
describe('reading weather.json', () => {
it('reads Eureka\'s file as the host writes it: Z times, a whole-station gap as nulls, 0.0 at night', () => {
const night = JSON.parse(
'{"fetched_at":"2026-11-11T22:48:22Z","observed_at":"2026-11-11T22:40:00Z","station":11035,"precip_mm_10min":0.1,"humidity_pct":100.0,"sunshine_s_10min":0.0,"glow_wm2":0.0,"attribution":"Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at"}'
);
expect(parseWeatherFile(night)).toMatchObject({ precip_mm_10min: 0.1, humidity_pct: 100, glow_wm2: 0, sunshine_s_10min: 0 });
const gap = parseWeatherFile({ ...night, precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null });
expect(gap).toMatchObject({ precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null });
expect(currentWeather(gap, new Date('2026-11-11T22:50:00Z'))).toBe(CLEAR_SKY);
});
it('is not another station\'s file', () => {
expect(parseWeatherFile({ ...file(), station: 11036 })).toBeNull();
});
it('parses the agreed shape, keeping nulls and the sunshine it never reads', () => {
const raw = { ...file(), precip_mm_10min: null, glow_wm2: 12, sunshine_s_10min: 0 };
expect(parseWeatherFile(raw)).toEqual(raw);
});
it('rejects a body without usable times, and anything not an object', () => {
expect(parseWeatherFile({ ...file(), observed_at: 'yesterday' })).toBeNull();
expect(parseWeatherFile({ ...file(), fetched_at: undefined })).toBeNull();
expect(parseWeatherFile(null)).toBeNull();
expect(parseWeatherFile('x')).toBeNull();
});
it('rejects a time with no zone: it would be read in the client\'s local time', () => {
expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T10:40:00' })).toBeNull();
expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T11:40:00+01:00' })).not.toBeNull();
});
it('is no observation when the file is missing, fails, or is not JSON', async () => {
expect(await fetchWeatherFile('/weather.json', (async () => ({ ok: false })) as unknown as typeof fetch)).toBeNull();
expect(await fetchWeatherFile('/weather.json', (async () => { throw new Error('offline'); }) as unknown as typeof fetch)).toBeNull();
expect(
await fetchWeatherFile('/weather.json', (async () => ({ ok: true, json: async () => { throw new Error('bad'); } })) as unknown as typeof fetch)
).toBeNull();
});
it('reads a good file', async () => {
const f = (async () => ({ ok: true, json: async () => file() })) as unknown as typeof fetch;
expect(await fetchWeatherFile('/weather.json', f)).toEqual(file());
});
});

View file

@ -0,0 +1,122 @@
// The weather over the viewpoint as one `weather.ts` number, read from a static `weather.json` next to the page (config.json's
// neighbour). The browser never calls a weather service: Eureka's refresher (gamecult-ops, docs/weather-feed.md; the only writer)
// writes the file from GeoSphere Austria's TAWES 10-minute observations (station 11035, Wien Hohe
// Warte). Rain is the gauge. CLOUD IS AN ESTIMATE: TAWES has no cloud cover. By day it is the measured global radiation (GLOW) against
// what a clear sky gives at the sun's position now (`clearSkyGhi`, Haurwitz), cross-checked by the sunshine duration (`SO`, seconds of
// 600 the direct beam was strong enough to burn the card); with the sun low or down it is a ramp of the humidity. A file that is
// missing, unreadable, malformed or stale (below) is no observation, and no observation is a clear sky.
import { WEATHER_CREDIT } from './credits';
import { viennaSun } from './vienna';
/** Reader rule of docs/weather-feed.md: the file is stale 30 minutes after it was fetched or 40 after the observation it holds (the station reports every 10). */
export const STALE_MS = 30 * 60_000;
export const OBSERVATION_STALE_MS = 40 * 60_000;
export const STATION = 11035;
const SKEW_MS = 5 * 60_000;
export const CLEAR_SKY = 0;
/** The credit GeoSphere's legal page asks for. */
export const WEATHER_ATTRIBUTION = WEATHER_CREDIT;
/** The file's shape; every measurement is null when the station did not report it. Times are ISO 8601. */
export interface WeatherFile {
fetched_at: string;
observed_at: string;
station: number;
precip_mm_10min: number | null;
humidity_pct: number | null;
/** Global radiation, W/m2, last 10 minutes. */
glow_wm2: number | null;
sunshine_s_10min: number | null;
attribution: string;
}
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null);
/** An ISO time that names its zone (`Z` or an offset); `Date.parse` would read a bare one in the client's local time. */
const isZonedTime = (v: unknown): v is string => typeof v === 'string' && /(Z|[+-]\d\d:?\d\d)$/.test(v) && !Number.isNaN(Date.parse(v));
/** A `WeatherFile` from parsed JSON, or null when it does not have the shape (times missing or unparseable). */
export function parseWeatherFile(json: unknown): WeatherFile | null {
const j = json as Record<string, unknown> | null;
if (!j || typeof j !== 'object') return null;
if (!isZonedTime(j.observed_at) || !isZonedTime(j.fetched_at)) return null;
if (j.station !== STATION) return null;
return {
fetched_at: j.fetched_at,
observed_at: j.observed_at,
station: STATION,
precip_mm_10min: num(j.precip_mm_10min),
humidity_pct: num(j.humidity_pct),
glow_wm2: num(j.glow_wm2),
sunshine_s_10min: num(j.sunshine_s_10min),
attribution: typeof j.attribution === 'string' ? j.attribution : WEATHER_ATTRIBUTION,
};
}
/** Global horizontal radiation of a clear sky, W/m2, sun at `el` degrees: Haurwitz (1945), 1098 cos(z) exp(-0.057 / cos(z)). */
export function clearSkyGhi(el: number): number {
const c = Math.sin((el * Math.PI) / 180);
return c <= 0 ? 0 : 1098 * c * Math.exp(-0.057 / c);
}
/** Below this sun elevation the radiation says too little (clear-sky ~100 W/m2 at 8 degrees) and the humidity is read instead. */
export const GLOW_FROM_DEG = 8;
/**
* Cloud 0..1 from measured global radiation against the clear-sky value: the clearness ratio k inverted through Kasten and
* Czeplak (1980), k = 1 - 0.75 (N/8)^3.4, N the oktas. Null when the sun is too low or the reading is missing or 0.
*/
export function cloudFromGlow(glow: number | null, sunEl: number): number | null {
if (glow === null || glow <= 0 || sunEl < GLOW_FROM_DEG) return null;
const k = Math.min(1, glow / clearSkyGhi(sunEl));
return clamp01(((1 - k) / 0.75) ** (1 / 3.4));
}
/** The cloud the sunshine duration implies: the share of the ten minutes the sun did not shine. Null when the sun is too low or it is unreported. */
export function cloudFromSunshine(so: number | null, sunEl: number): number | null {
if (so === null || so < 0 || sunEl < GLOW_FROM_DEG) return null;
return 1 - clamp01(so / 600);
}
/** The cloud estimate by day, or null when the sun is too low for either instrument (the caller reads the humidity). */
export function cloudEstimate(f: WeatherFile, sunEl: number): number | null {
const g = cloudFromGlow(f.glow_wm2, sunEl);
const s = cloudFromSunshine(f.sunshine_s_10min, sunEl);
if (g !== null && s !== null) return Math.abs(g - s) > 0.5 ? (g + s) / 2 : g; // instruments that disagree by half a sky are each half trusted
return g ?? s;
}
/**
* The weather number of an observation with the sun at `sunEl` degrees. Rain sets 0.8..1 by rate (0.1 mm in ten minutes is the
* gauge's step: drizzle). Without rain, cloud (above) puts the number up to 0.7 (overcast); where the instruments cannot give it
* (the sun low or down), cloud is a ramp of the humidity, 0 at 60% to 1 at 95%. Saturated air (92% to 100%) sets a mist floor of up to 0.75 when the sky is
* more than half cloud.
*/
export function weatherFromFile(f: WeatherFile, sunEl: number): number {
const rain = f.precip_mm_10min ?? 0;
if (rain > 0) return clamp01(0.8 + 0.2 * Math.min(1, rain / 1.5));
const cloud = cloudEstimate(f, sunEl) ?? (f.humidity_pct === null ? 0 : clamp01((f.humidity_pct - 60) / 35));
const mist = f.humidity_pct === null || cloud < 0.5 ? 0 : clamp01((f.humidity_pct - 92) / 8) * 0.75;
return Math.max(0.7 * cloud, mist);
}
/** The weather number now: the file's, or a clear sky when there is none or it is stale (see STALE_MS) or from the future. */
export function currentWeather(f: WeatherFile | null, now: Date = new Date()): number {
if (!f) return CLEAR_SKY;
const fetched = now.getTime() - Date.parse(f.fetched_at);
const observed = now.getTime() - Date.parse(f.observed_at);
if (!(fetched <= STALE_MS && fetched >= -SKEW_MS && observed <= OBSERVATION_STALE_MS && observed >= -SKEW_MS)) return CLEAR_SKY;
// GLOW and SO are means of the ten minutes ending at observed_at: the sun of their midpoint is the one to set them against.
return weatherFromFile(f, viennaSun(new Date(Date.parse(f.observed_at) - 5 * 60_000)).elevation);
}
/** Fetch and parse `weather.json`; null on any failure. */
export async function fetchWeatherFile(url: string, fetcher: typeof fetch = fetch): Promise<WeatherFile | null> {
try {
const res = await fetcher(url, { cache: 'no-store' });
return res.ok ? parseWeatherFile(await res.json()) : null;
} catch {
return null;
}
}

View file

@ -9,12 +9,13 @@ import {
TERRAIN_REACH_DEG,
ridgeDrawn,
ridgeSkyFor,
ridgeSkyKey,
terrainDrawn,
terrainSkyFor,
} from './skyForState';
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';
@ -77,47 +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(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 gradients for day and night', () => {
const day = ridgeSkyFor('day');
const night = ridgeSkyFor('night');
expect(hex(day.top)).toBe(baked.noonday.gradient[0]);
expect(hex(day.horizon)).toBe(baked.noonday.gradient[6]);
expect(hex(night.top)).toBe(baked.midnight.gradient[0]);
expect(hex(night.horizon)).toBe(baked.midnight.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.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,56 +8,61 @@ 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 { ridgeWeather, setFedWeather } from './ridge/weather';
import { currentWeather, fetchWeatherFile } from './ridge/weatherFeed';
import { resolveSkyState, skyNow } from './skyState';
import type { SkyState } from './skyState';
import { displayedLuminance, hexToLinear, 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 = ridgeWeather(),
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;
const WEATHER_REFRESH_MS = 10 * 60_000;
let weatherAt = -Infinity;
/**
* Read `weather.json` (the page's own, next to config.json) into the weather the ridge sky is drawn for; at most once in ten minutes.
* Never throws: a missing, stale or broken file is a clear sky (`currentWeather`).
*/
export async function loadWeather(url: string, now: Date = skyNow()): Promise<void> {
if (now.getTime() - weatherAt < WEATHER_REFRESH_MS) return;
weatherAt = now.getTime();
setFedWeather(currentWeather(await fetchWeatherFile(url), now));
}
/** 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 = ridgeWeather(),
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:
return fromGradient(baked.midnight.gradient, ground, SKY_BAND.night);
}
const fixed = resolveSkyState(state, now);
const physical = makePhysicalSky(fixed, now, weather);
return {
...physical.stops,
ground,
band: SKY_BAND[fixed],
physical,
};
}
/**
@ -93,26 +98,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

@ -107,10 +107,10 @@ describe('sky state', () => {
});
describe('skyBackdrop', () => {
it('puts night below day and dusk, and dusk reaches the sun glow', () => {
it('puts night below day and dusk', () => {
expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('day').darkest);
expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest);
expect(skyBackdrop('dusk').lightest).toBeGreaterThan(skyBackdrop('day').lightest);
expect(skyBackdrop('dusk').lightest).toBeGreaterThanOrEqual(skyBackdrop('day').lightest);
});
it('holds the baked gradients for day and night', () => {

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.05987, lightest: 0.081642 },
day: { darkest: 0.461063, lightest: 1 },
dusk: { darkest: 0.460352, lightest: 1 },
night: { darkest: 0, lightest: 0.081642 },
};
export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)];