Ridge scene: physical sky over Vienna, real horizon, live weather (Nibu) #110
35 changed files with 2457 additions and 121 deletions
|
|
@ -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
123
scripts/bake-horizon.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
74
src/app/features/scene/ridge/clouds.ts
Normal file
74
src/app/features/scene/ridge/clouds.ts
Normal 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;
|
||||
16
src/app/features/scene/ridge/credits.test.ts
Normal file
16
src/app/features/scene/ridge/credits.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
11
src/app/features/scene/ridge/credits.ts
Normal file
11
src/app/features/scene/ridge/credits.ts
Normal 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.';
|
||||
96
src/app/features/scene/ridge/earthAtmosphere.test.ts
Normal file
96
src/app/features/scene/ridge/earthAtmosphere.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
552
src/app/features/scene/ridge/earthAtmosphere.ts
Normal file
552
src/app/features/scene/ridge/earthAtmosphere.ts
Normal 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;
|
||||
}
|
||||
27
src/app/features/scene/ridge/horizon.test.ts
Normal file
27
src/app/features/scene/ridge/horizon.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
src/app/features/scene/ridge/horizon.ts
Normal file
54
src/app/features/scene/ridge/horizon.ts
Normal 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;
|
||||
63
src/app/features/scene/ridge/horizonLayer.test.ts
Normal file
63
src/app/features/scene/ridge/horizonLayer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
105
src/app/features/scene/ridge/physicalSky.test.ts
Normal file
105
src/app/features/scene/ridge/physicalSky.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
124
src/app/features/scene/ridge/physicalSky.ts
Normal file
124
src/app/features/scene/ridge/physicalSky.ts
Normal 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 };
|
||||
}
|
||||
32
src/app/features/scene/ridge/rain.test.ts
Normal file
32
src/app/features/scene/ridge/rain.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
src/app/features/scene/ridge/rain.ts
Normal file
54
src/app/features/scene/ridge/rain.ts
Normal 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();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
122
src/app/features/scene/ridge/skyDome.ts
Normal file
122
src/app/features/scene/ridge/skyDome.ts
Normal 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 };
|
||||
}
|
||||
194
src/app/features/scene/ridge/toneMap.ts
Normal file
194
src/app/features/scene/ridge/toneMap.ts
Normal 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;
|
||||
}
|
||||
56
src/app/features/scene/ridge/vienna.test.ts
Normal file
56
src/app/features/scene/ridge/vienna.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
74
src/app/features/scene/ridge/vienna.ts
Normal file
74
src/app/features/scene/ridge/vienna.ts
Normal 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));
|
||||
}
|
||||
1
src/app/features/scene/ridge/viennaHorizon.json
Normal file
1
src/app/features/scene/ridge/viennaHorizon.json
Normal file
File diff suppressed because one or more lines are too long
51
src/app/features/scene/ridge/weather.ts
Normal file
51
src/app/features/scene/ridge/weather.ts
Normal 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;
|
||||
}
|
||||
164
src/app/features/scene/ridge/weatherFeed.test.ts
Normal file
164
src/app/features/scene/ridge/weatherFeed.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
122
src/app/features/scene/ridge/weatherFeed.ts
Normal file
122
src/app/features/scene/ridge/weatherFeed.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
Loading…
Reference in a new issue