diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs index 31accf37..9d3079cd 100644 --- a/harness/scene/bundle.cjs +++ b/harness/scene/bundle.cjs @@ -29,6 +29,7 @@ esbuild plugins: [rawPlugin], format: 'iife', target: 'es2020', + define: { 'import.meta.env.BASE_URL': '"/"' }, }) .then(() => { if (ridge) { diff --git a/scripts/bake-horizon.py b/scripts/bake-horizon.py new file mode 100644 index 00000000..372e70e8 --- /dev/null +++ b/scripts/bake-horizon.py @@ -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 [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() diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 957f1a42..45fec5cd 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -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 diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx index b664dc07..30efaa1c 100644 --- a/src/app/features/scene/SkyStateControl.tsx +++ b/src/app/features/scene/SkyStateControl.tsx @@ -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' && ( + + + Data + + + Datenquelle: GeoSphere Austria -{' '} + + {WEATHER_CREDIT_URL} + + . {WEATHER_DATASET}. + + + {HORIZON_CREDIT} + + + )} diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index b6fcf2a7..5574d60c 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -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(); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 4e1db231..36010b40 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -16,6 +16,9 @@ export type StartOptions = Omit 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 { 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 { 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; diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index 864491f1..0234e558 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -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(); + }); +}); diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index 72985d4c..8590a833 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -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['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 => { + 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()) { diff --git a/src/app/features/scene/ridge/clouds.ts b/src/app/features/scene/ridge/clouds.ts new file mode 100644 index 00000000..1b4e8757 --- /dev/null +++ b/src/app/features/scene/ridge/clouds.ts @@ -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; diff --git a/src/app/features/scene/ridge/credits.test.ts b/src/app/features/scene/ridge/credits.test.ts new file mode 100644 index 00000000..633a1b33 --- /dev/null +++ b/src/app/features/scene/ridge/credits.test.ts @@ -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/); + }); +}); diff --git a/src/app/features/scene/ridge/credits.ts b/src/app/features/scene/ridge/credits.ts new file mode 100644 index 00000000..1a0327ea --- /dev/null +++ b/src/app/features/scene/ridge/credits.ts @@ -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.'; diff --git a/src/app/features/scene/ridge/earthAtmosphere.test.ts b/src/app/features/scene/ridge/earthAtmosphere.test.ts new file mode 100644 index 00000000..cc5d4c43 --- /dev/null +++ b/src/app/features/scene/ridge/earthAtmosphere.test.ts @@ -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, 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); + }); +}); diff --git a/src/app/features/scene/ridge/earthAtmosphere.ts b/src/app/features/scene/ridge/earthAtmosphere.ts new file mode 100644 index 00000000..f5350244 --- /dev/null +++ b/src/app/features/scene/ridge/earthAtmosphere.ts @@ -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; +} diff --git a/src/app/features/scene/ridge/horizon.test.ts b/src/app/features/scene/ridge/horizon.test.ts new file mode 100644 index 00000000..092d5835 --- /dev/null +++ b/src/app/features/scene/ridge/horizon.test.ts @@ -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); + }); +}); diff --git a/src/app/features/scene/ridge/horizon.ts b/src/app/features/scene/ridge/horizon.ts new file mode 100644 index 00000000..82265a77 --- /dev/null +++ b/src/app/features/scene/ridge/horizon.ts @@ -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; diff --git a/src/app/features/scene/ridge/horizonLayer.test.ts b/src/app/features/scene/ridge/horizonLayer.test.ts new file mode 100644 index 00000000..8ed27e4c --- /dev/null +++ b/src/app/features/scene/ridge/horizonLayer.test.ts @@ -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); + }); +}); diff --git a/src/app/features/scene/ridge/physicalSky.test.ts b/src/app/features/scene/ridge/physicalSky.test.ts new file mode 100644 index 00000000..bb8c75a2 --- /dev/null +++ b/src/app/features/scene/ridge/physicalSky.test.ts @@ -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]); + }); +}); diff --git a/src/app/features/scene/ridge/physicalSky.ts b/src/app/features/scene/ridge/physicalSky.ts new file mode 100644 index 00000000..48a10a16 --- /dev/null +++ b/src/app/features/scene/ridge/physicalSky.ts @@ -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 }; +} diff --git a/src/app/features/scene/ridge/rain.test.ts b/src/app/features/scene/ridge/rain.test.ts new file mode 100644 index 00000000..6575ea20 --- /dev/null +++ b/src/app/features/scene/ridge/rain.test.ts @@ -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, { + 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); + }); +}); diff --git a/src/app/features/scene/ridge/rain.ts b/src/app/features/scene/ridge/rain.ts new file mode 100644 index 00000000..699130cf --- /dev/null +++ b/src/app/features/scene/ridge/rain.ts @@ -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(); + }) + ); + } +} diff --git a/src/app/features/scene/ridge/ridgeBake.ts b/src/app/features/scene/ridge/ridgeBake.ts index 770c30e0..72736d85 100644 --- a/src/app/features/scene/ridge/ridgeBake.ts +++ b/src/app/features/scene/ridge/ridgeBake.ts @@ -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); diff --git a/src/app/features/scene/ridge/ridgeWorld.ts b/src/app/features/scene/ridge/ridgeWorld.ts index 569b43bd..6bd8e3b1 100644 --- a/src/app/features/scene/ridge/ridgeWorld.ts +++ b/src/app/features/scene/ridge/ridgeWorld.ts @@ -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); } diff --git a/src/app/features/scene/ridge/skyColour.ts b/src/app/features/scene/ridge/skyColour.ts index 1fa7e6fe..e2898a0b 100644 --- a/src/app/features/scene/ridge/skyColour.ts +++ b/src/app/features/scene/ridge/skyColour.ts @@ -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 { diff --git a/src/app/features/scene/ridge/skyDome.ts b/src/app/features/scene/ridge/skyDome.ts new file mode 100644 index 00000000..41da66b8 --- /dev/null +++ b/src/app/features/scene/ridge/skyDome.ts @@ -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 }; +} diff --git a/src/app/features/scene/ridge/toneMap.ts b/src/app/features/scene/ridge/toneMap.ts new file mode 100644 index 00000000..b91a4d6d --- /dev/null +++ b/src/app/features/scene/ridge/toneMap.ts @@ -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 => 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 = { + 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; +} diff --git a/src/app/features/scene/ridge/vienna.test.ts b/src/app/features/scene/ridge/vienna.test.ts new file mode 100644 index 00000000..e0d4a4c7 --- /dev/null +++ b/src/app/features/scene/ridge/vienna.test.ts @@ -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 + }); +}); diff --git a/src/app/features/scene/ridge/vienna.ts b/src/app/features/scene/ridge/vienna.ts new file mode 100644 index 00000000..56188226 --- /dev/null +++ b/src/app/features/scene/ridge/vienna.ts @@ -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 = { + 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)); +} diff --git a/src/app/features/scene/ridge/viennaHorizon.json b/src/app/features/scene/ridge/viennaHorizon.json new file mode 100644 index 00000000..62a4a126 --- /dev/null +++ b/src/app/features/scene/ridge/viennaHorizon.json @@ -0,0 +1 @@ +{"viewpoint":{"lat":48.1929826,"lon":16.3534237,"groundM":183.3},"stepDeg":0.5,"radiusKm":60.0,"minKm":1.0,"curvature":"drop d^2/2R*(1-0.13), R=6371000 m","source":{"dataset":"Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid","tiles":["N47_00_E015_00","N47_00_E016_00","N47_00_E017_00","N48_00_E015_00","N48_00_E016_00","N48_00_E017_00","N49_00_E015_00","N49_00_E016_00","N49_00_E017_00"],"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 \u00a9 DLR e.V. 2010-2014 and \u00a9 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":[1.608,1.522,1.467,1.452,1.433,1.426,1.497,1.6,1.597,1.564,1.532,1.495,1.467,1.544,1.62,1.655,1.707,1.775,1.803,1.683,1.532,1.394,1.263,1.297,1.358,1.412,1.433,1.339,1.264,1.269,1.195,1.142,1.13,1.22,1.401,1.368,1.326,1.294,1.378,1.46,1.488,1.516,1.549,1.598,1.647,1.698,1.769,1.816,1.801,1.783,1.695,1.619,1.586,1.547,1.442,1.335,1.282,1.286,1.335,1.421,1.449,1.437,1.313,1.268,1.164,1.14,1.098,1.06,0.984,0.938,0.91,0.877,0.884,0.872,0.943,0.974,0.919,0.873,0.861,0.839,0.731,0.673,0.56,0.494,0.527,0.601,0.566,0.584,0.579,0.643,0.666,0.741,0.851,0.711,0.703,0.573,0.54,0.488,0.433,0.452,0.467,0.483,0.475,0.517,0.419,0.428,0.457,0.489,0.522,0.482,0.5,0.527,0.528,0.471,0.458,0.487,0.521,0.578,0.553,0.419,0.301,0.324,0.425,0.468,0.512,0.558,0.575,0.521,0.477,0.444,0.372,0.297,0.365,0.446,0.486,0.523,0.532,0.516,0.516,0.531,0.52,0.546,0.565,0.535,0.527,0.508,0.516,0.37,0.359,0.33,0.375,0.454,0.546,0.496,0.449,0.425,0.447,0.398,0.388,0.439,0.476,0.496,0.517,0.541,0.494,0.439,0.383,0.411,0.502,0.621,0.578,0.548,0.568,0.642,0.713,0.708,0.654,0.704,0.741,0.709,0.677,0.661,0.66,0.666,0.662,0.651,0.699,0.727,0.746,0.809,0.788,0.799,0.793,0.702,0.734,0.79,0.754,0.704,0.616,0.598,0.708,0.719,0.706,0.704,0.726,0.826,0.849,0.85,0.813,0.853,0.926,0.971,0.918,1.003,1.0,0.927,0.985,1.031,1.043,1.067,1.13,1.022,1.02,1.043,1.101,1.144,1.099,1.082,1.064,1.2,1.238,1.16,1.084,1.202,1.326,1.39,1.429,1.405,1.332,1.358,1.361,1.357,1.354,1.335,1.319,1.376,1.309,1.289,1.257,1.283,1.237,1.296,1.353,1.259,1.314,1.236,1.307,1.344,1.36,1.251,1.292,1.308,1.26,1.288,1.365,1.391,1.414,1.32,1.299,1.35,1.288,1.361,1.303,1.268,1.29,1.305,1.326,1.344,1.328,1.32,1.358,1.375,1.443,1.477,1.492,1.525,1.513,1.422,1.314,1.294,1.337,1.414,1.343,1.397,1.431,1.409,1.378,1.423,1.438,1.343,1.257,1.208,1.209,1.233,1.251,1.267,1.274,1.248,1.254,1.297,1.34,1.359,1.34,1.199,1.162,1.255,1.292,1.29,1.24,1.222,1.24,1.271,1.264,1.317,1.256,1.26,1.321,1.301,1.266,1.268,1.308,1.357,1.474,1.419,1.438,1.393,1.354,1.343,1.354,1.333,1.321,1.359,1.437,1.47,1.395,1.413,1.446,1.466,1.434,1.446,1.458,1.419,1.522,1.502,1.506,1.512,1.528,1.526,1.485,1.427,1.508,1.5,1.48,1.534,1.514,1.449,1.449,1.386,1.368,1.388,1.404,1.374,1.374,1.352,1.3,1.347,1.332,1.265,1.235,1.39,1.285,1.226,1.233,1.235,1.211,1.164,1.183,1.217,1.143,1.128,1.117,1.112,1.086,1.071,1.118,1.103,1.002,0.99,0.995,1.025,0.998,1.024,0.971,1.045,1.09,1.11,1.108,1.081,1.171,1.308,1.446,1.482,1.484,1.494,1.469,1.472,1.479,1.493,1.467,1.388,1.293,1.203,1.104,1.145,1.12,1.028,1.004,0.982,0.968,0.933,0.97,0.986,1.035,1.12,1.227,1.281,1.316,1.312,1.273,1.187,1.205,1.197,1.199,1.176,1.177,1.142,1.19,1.377,1.369,1.322,1.295,1.385,1.493,1.576,1.679,1.769,1.622,1.711,1.641,1.517,1.582,1.525,1.39,1.389,1.343,1.331,1.369,1.343,1.257,1.212,1.134,1.039,1.03,1.075,1.027,1.059,1.112,1.132,1.14,1.16,1.167,1.178,1.179,1.192,1.214,1.245,1.289,1.286,1.279,1.259,1.256,1.202,1.138,1.159,1.116,1.12,1.102,0.999,1.042,1.051,1.09,1.143,1.244,1.269,1.296,1.353,1.412,1.579,1.686,1.814,1.857,1.773,1.731,1.748,1.742,1.668,1.594,1.628,1.692,1.76,1.822,1.863,1.904,1.895,1.849,1.786,1.778,1.731,1.666,1.633,1.561,1.526,1.662,1.747,1.75,1.704,1.631,1.563,1.473,1.501,1.488,1.464,1.441,1.386,1.31,1.298,1.293,1.306,1.371,1.42,1.416,1.417,1.418,1.409,1.399,1.408,1.431,1.434,1.437,1.509,1.544,1.58,1.674,1.528,1.534,1.517,1.497,1.525,1.546,1.562,1.58,1.55,1.634,1.614,1.66,1.643,1.663,1.68,1.829,1.99,2.058,2.014,1.911,2.025,2.023,2.036,2.031,2.009,2.012,2.02,2.109,2.107,2.12,2.171,2.179,2.125,2.084,2.09,2.129,2.183,2.16,2.172,2.239,2.237,2.218,2.217,2.137,2.169,2.203,2.233,2.262,2.272,2.21,2.133,1.955,1.909,1.97,2.138,2.187,2.229,2.278,2.191,2.167,2.213,2.327,2.409,2.31,2.242,2.205,2.152,2.154,2.258,2.217,2.126,2.054,1.97,1.942,2.022,1.894,1.886,1.921,1.897,1.936,1.964,1.978,1.961,1.927,1.879,1.904,1.96,1.9,1.922,2.023,2.061,2.115,1.99,1.805,1.736,1.776,1.841,1.863,1.934,1.942,1.962,2.043,2.082,2.082,2.092,2.062,1.957,1.794,1.821,1.913,2.009,2.066,2.105,2.036,1.964,1.984,2.005,2.036,2.072,2.094,2.022,2.153,2.177,2.146,2.125,2.061,2.004,1.955,1.902,1.84,1.767,1.7,1.696,1.718,1.725,1.709,1.745,1.805,1.836,1.939,2.053,2.14,2.139,2.142,2.035,1.902,1.779,1.667,1.564,1.57,1.581,1.628,1.693,1.739,1.738,1.738,1.675],"distanceKm":[1.0,1.0,1.06,1.06,1.06,1.06,1.06,1.03,1.0,1.0,1.0,1.0,1.09,1.09,1.0,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.12,1.12,1.12,1.12,1.12,1.12,1.09,1.09,1.09,1.09,1.0,1.0,1.0,1.0,1.03,1.06,1.06,1.03,1.03,1.03,1.03,1.03,1.03,1.0,1.0,1.0,1.0,1.0,1.03,1.03,1.03,1.03,1.03,1.0,1.0,1.06,1.06,1.06,1.06,1.06,1.12,1.0,1.0,1.24,1.24,1.21,1.21,1.3,1.3,1.27,1.3,1.33,1.33,1.33,1.33,1.27,1.24,1.27,1.27,1.27,1.03,1.03,1.42,1.84,1.84,1.81,1.03,1.72,1.63,1.63,1.63,1.33,1.33,1.06,1.06,1.72,1.72,1.69,1.69,1.69,1.69,1.69,1.81,1.81,1.75,1.51,1.75,1.75,1.75,1.75,1.75,1.72,1.72,1.72,1.72,1.72,1.72,1.72,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.42,1.42,1.24,1.3,1.3,1.3,1.3,1.3,1.3,1.3,1.27,1.03,1.36,1.36,1.36,1.36,1.36,1.45,1.45,1.09,1.12,1.12,1.54,1.57,1.48,1.48,1.48,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.18,1.18,1.21,1.21,1.21,1.12,1.12,1.12,1.12,1.12,1.15,1.15,1.15,1.15,1.18,1.15,1.15,1.24,1.24,1.27,1.27,1.24,1.24,1.24,1.27,1.27,1.27,1.9,1.9,1.93,1.93,1.93,1.99,1.99,1.99,1.99,1.9,2.02,2.02,2.02,2.05,2.02,1.99,1.99,1.99,1.48,1.21,1.21,1.57,1.69,1.69,1.66,1.39,1.39,1.39,1.54,1.6,1.54,1.54,1.54,1.48,1.45,1.21,1.21,1.21,1.27,1.39,1.39,1.36,1.39,1.39,1.39,1.33,1.33,1.33,1.33,1.33,1.48,1.45,1.39,1.39,1.18,1.18,1.18,1.48,1.48,1.27,1.24,1.27,1.36,1.36,1.39,1.33,1.3,1.3,1.3,1.3,1.3,1.3,1.33,1.0,1.42,1.42,1.27,1.27,1.27,1.27,1.03,1.03,1.03,1.03,1.03,1.03,1.03,1.03,1.06,1.06,1.03,1.03,1.03,1.36,1.06,1.06,1.06,1.06,1.09,1.09,1.18,1.21,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.15,1.15,1.36,1.36,1.36,1.36,1.33,1.33,3.13,1.21,1.21,3.34,3.31,2.83,2.65,2.65,1.3,2.29,1.3,1.15,1.15,1.15,2.29,2.68,2.41,2.23,2.23,2.2,1.12,2.23,2.17,2.17,2.17,1.84,2.74,1.87,1.87,2.95,2.2,2.17,2.17,2.35,2.35,2.23,2.23,2.11,2.14,2.08,2.2,2.2,2.2,2.2,2.2,2.2,2.41,2.41,2.38,2.41,2.41,2.44,2.44,2.44,1.99,2.08,2.08,2.08,2.08,2.05,2.05,2.5,2.38,2.47,2.29,2.23,2.17,1.69,1.69,2.38,2.47,2.38,2.17,2.71,2.71,2.2,2.47,2.41,2.41,2.32,2.38,2.38,2.08,2.5,2.11,2.11,1.75,1.75,1.48,1.81,1.81,1.78,1.78,1.87,17.2,17.2,17.26,17.32,17.44,18.1,17.56,17.5,17.38,17.5,17.62,17.68,17.74,17.8,14.86,14.92,14.98,2.98,26.38,26.38,26.38,26.62,26.14,26.02,26.02,26.38,26.26,26.38,26.38,26.26,26.26,26.26,14.98,14.98,14.98,14.44,13.12,13.18,13.66,13.72,13.72,13.84,13.84,13.84,13.72,12.52,12.58,12.58,12.28,12.22,12.28,16.54,14.02,15.04,16.18,16.24,17.08,17.26,17.44,17.5,17.44,17.32,17.44,17.02,13.96,14.14,9.22,9.16,9.1,9.22,9.28,9.28,9.22,9.16,9.16,9.34,8.68,8.68,8.62,8.74,8.86,8.92,8.86,8.92,8.92,11.74,14.08,11.98,12.22,12.04,34.3,10.36,10.06,9.64,9.58,9.58,9.7,9.7,10.18,10.18,10.18,10.24,10.06,10.48,9.94,9.82,9.76,9.7,8.02,8.14,8.14,8.14,8.08,8.56,8.38,8.38,8.32,8.38,8.02,8.02,8.02,8.14,8.2,7.9,7.9,7.84,7.78,7.66,7.72,7.72,7.72,1.0,1.0,1.0,1.0,1.0,1.45,1.21,1.21,1.3,1.15,1.15,1.15,1.15,1.15,1.15,1.15,1.33,1.33,1.33,1.48,1.3,1.3,1.3,1.42,1.09,2.02,1.81,2.59,2.59,2.53,1.21,1.75,1.69,1.66,2.38,1.57,1.57,1.57,7.18,7.18,7.24,7.24,7.18,7.12,1.0,1.0,1.0,1.0,1.0,7.36,7.3,7.3,7.18,7.24,7.06,7.06,7.06,7.06,7.0,7.12,7.12,7.12,7.0,7.0,7.0,7.0,7.24,7.24,7.06,7.18,7.0,7.06,7.0,7.0,6.94,7.0,7.24,1.03,1.03,1.03,1.0,1.0,1.0,1.06,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.03,1.03,1.03,1.03,1.03,1.03,1.27,1.27,1.27,1.24,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.09,1.09,1.06,1.06,1.06,1.06,1.06,1.09,1.09,9.64,9.58,9.64,9.64,9.7,9.7,9.7,9.7,9.7,9.64,9.64,9.64,9.7,1.03,1.03,1.03,1.03,1.03,1.03,9.52,1.0,1.0,9.4,8.68,8.68,8.68,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,9.52,9.46,9.46,9.46,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"peakHeightM":[211.4,209.9,210.5,210.2,209.9,209.7,211.0,212.1,211.2,210.6,210.1,209.4,211.3,212.7,211.6,214.8,215.8,217.1,217.7,215.4,212.5,209.9,207.4,208.7,209.9,211.0,211.4,209.5,208.1,207.5,206.1,205.1,204.8,204.6,207.8,207.2,206.5,206.6,208.8,210.4,210.1,210.6,211.2,212.1,213.0,213.9,214.2,215.0,214.8,214.5,212.9,212.5,211.9,211.2,209.3,207.3,205.7,205.8,208.1,209.6,210.2,209.9,207.6,208.2,203.7,203.2,207.2,206.3,204.2,203.2,204.0,203.3,203.0,203.2,205.3,206.0,204.7,203.7,202.5,201.5,199.6,198.3,195.8,192.2,192.8,198.3,201.7,202.3,201.8,194.9,203.5,204.5,207.7,203.7,199.7,196.7,193.3,192.4,196.5,197.1,197.2,197.7,197.5,198.7,195.8,197.0,197.9,198.4,197.2,198.2,198.7,199.6,199.6,197.9,197.2,198.1,199.1,200.8,200.1,196.0,192.5,189.0,190.8,191.5,192.3,193.1,193.4,192.4,191.7,191.1,189.8,190.8,192.5,193.0,194.4,195.2,195.5,195.1,195.1,195.4,195.2,195.5,193.5,196.1,195.9,195.5,195.6,192.2,192.5,191.8,190.5,192.2,194.0,196.8,195.7,194.4,195.0,193.7,190.1,191.0,191.7,192.0,192.4,192.8,192.0,191.0,190.0,191.8,193.7,196.5,195.6,194.9,194.5,195.9,197.3,197.2,196.1,197.5,198.2,197.6,196.9,197.0,196.6,196.7,197.7,197.5,198.9,199.5,199.5,200.9,200.4,201.1,201.0,198.9,207.9,209.7,208.9,207.2,204.3,204.3,208.1,208.5,208.1,206.9,209.1,212.7,213.5,214.0,212.2,213.2,215.7,217.3,207.1,204.6,204.5,208.8,212.5,213.9,213.7,209.3,210.8,208.2,210.9,212.6,213.0,214.2,213.0,211.4,210.3,208.7,209.5,207.9,207.4,212.6,215.6,216.4,218.1,217.5,215.7,214.9,215.0,214.9,214.8,214.4,217.5,218.3,215.2,214.7,209.3,209.8,208.9,216.9,218.4,211.3,211.8,210.8,214.4,215.3,216.4,212.4,212.7,213.1,212.0,212.6,214.4,215.0,216.2,206.4,215.6,216.9,211.9,213.6,212.3,211.5,206.5,206.8,207.2,207.5,207.2,207.1,207.8,208.1,210.0,210.7,210.2,210.8,210.5,217.2,207.7,207.3,208.1,209.5,208.9,209.9,212.8,213.1,209.6,210.4,210.7,208.9,207.3,206.3,206.4,206.8,207.2,207.5,208.9,208.4,213.2,214.2,215.2,215.7,214.5,211.2,247.4,209.9,210.7,259.2,255.7,244.2,241.1,242.6,212.1,236.3,211.9,208.7,209.9,209.5,234.2,243.1,238.7,236.5,241.0,238.1,211.5,237.8,234.9,234.5,234.9,226.3,246.9,227.9,230.4,259.6,237.2,237.1,238.4,243.8,242.5,239.9,240.4,235.8,240.4,238.1,241.4,241.7,242.3,242.2,240.6,238.4,247.1,246.8,245.2,248.2,247.4,245.4,245.4,242.7,231.1,234.0,234.5,233.4,233.5,231.9,230.1,242.5,239.0,238.2,233.0,237.7,232.3,219.6,219.9,235.0,235.9,232.0,228.4,241.4,237.8,226.9,231.8,230.5,229.4,227.0,230.1,229.5,219.9,226.9,220.2,221.3,214.0,214.8,208.5,216.5,217.9,218.0,217.9,218.8,554.9,596.3,639.3,651.8,655.9,677.7,654.8,653.7,652.5,660.3,655.7,632.9,605.1,578.6,484.8,496.7,491.5,237.4,693.3,682.9,676.3,665.1,672.6,677.4,699.5,746.5,793.0,820.8,836.9,831.7,813.8,774.3,513.7,511.5,512.0,493.9,464.6,457.9,479.7,526.0,524.0,515.7,509.3,531.0,553.8,538.4,562.8,582.7,541.4,558.5,545.3,640.0,583.8,599.0,593.8,595.2,603.6,604.7,620.9,614.5,586.6,570.3,549.1,511.8,447.5,462.2,354.3,358.3,365.5,371.3,373.8,377.0,376.8,377.3,377.6,383.6,372.4,377.1,382.3,384.6,386.4,384.8,382.9,375.9,365.9,430.3,471.0,427.2,428.5,403.2,887.7,380.7,381.5,382.0,397.5,401.8,409.1,418.8,441.2,470.9,490.1,514.8,516.4,515.3,490.4,489.5,486.6,472.2,410.8,419.1,428.3,437.9,444.8,466.6,466.6,465.3,456.6,449.4,436.6,430.0,421.0,419.8,411.4,398.0,416.8,426.6,425.1,415.1,407.2,398.0,385.9,209.6,209.3,208.9,208.5,207.5,216.6,210.8,210.7,213.0,210.9,211.9,211.8,211.8,211.8,211.6,211.4,216.1,216.6,216.7,220.6,217.6,218.4,219.2,224.9,212.4,237.7,231.4,251.4,252.7,252.0,216.4,231.7,229.2,230.8,250.7,228.9,228.5,229.0,397.4,416.0,438.4,447.0,439.3,424.3,218.7,218.7,218.9,218.8,218.4,445.6,444.4,455.8,451.0,454.8,454.3,455.3,448.6,443.6,442.1,451.4,458.2,455.3,452.1,460.3,460.0,457.7,467.1,457.0,454.1,463.0,459.5,465.5,464.3,456.8,445.0,425.5,428.2,218.8,221.8,222.7,222.3,223.1,221.6,223.5,222.0,224.0,225.4,223.7,222.5,221.8,220.9,222.1,224.0,223.2,221.6,220.3,218.8,226.4,228.2,225.4,224.2,216.9,216.5,217.1,217.6,217.9,217.6,217.0,216.1,216.6,220.6,219.5,218.9,220.8,221.5,222.5,220.2,217.7,216.4,488.4,497.5,503.2,515.2,518.5,522.0,535.7,542.3,542.3,541.8,536.7,518.9,493.5,216.1,217.8,219.5,220.5,221.2,220.0,516.0,218.0,218.4,523.4,502.5,505.8,494.9,220.9,221.3,220.8,220.4,219.3,218.3,217.5,216.5,215.5,214.2,213.0,212.9,213.3,213.5,473.5,477.6,487.5,492.6,217.2,219.2,220.7,220.7,220.7,218.9,216.5,214.4,212.5,210.6,210.7,210.9,211.8,212.9,213.7,213.7,213.7,212.6],"slopeEast":[0.002,-0.001,0.056,0.043,0.022,0.002,0.007,-0.005,0.014,0.012,0.002,-0.013,0.046,0.027,-0.083,-0.005,-0.017,-0.01,-0.007,-0.03,-0.051,-0.056,-0.058,-0.088,-0.109,-0.099,-0.085,-0.079,-0.077,0.008,0.005,0.006,-0.01,0.062,0.068,0.071,0.074,0.084,0.019,-0.001,0.023,0.02,0.021,0.024,0.025,0.031,0.054,0.031,0.003,-0.022,-0.034,-0.054,-0.055,-0.049,-0.047,-0.045,-0.038,-0.041,-0.051,-0.054,-0.073,-0.086,-0.104,-0.05,-0.085,-0.083,0.125,0.082,0.049,0.018,0.064,0.034,-0.013,-0.004,-0.06,-0.087,-0.128,-0.166,-0.129,-0.038,-0.154,-0.158,-0.163,-0.116,-0.12,0.088,0.055,0.048,0.095,0.036,0.154,0.016,-0.009,-0.018,-0.079,-0.121,-0.161,-0.174,0.003,0.037,0.108,0.103,0.1,0.106,0.101,-0.064,-0.045,0.013,0.149,0.083,0.078,0.071,0.039,-0.062,-0.065,-0.118,-0.105,-0.072,-0.045,-0.025,-0.001,-0.008,-0.007,-0.02,-0.037,-0.058,-0.078,-0.075,-0.069,-0.063,-0.054,-0.008,0.003,0.123,0.036,0.015,-0.005,0.017,0.049,0.086,0.068,0.013,0.032,-0.024,-0.01,0.005,0.009,-0.005,-0.004,0.021,0.062,-0.012,-0.014,-0.013,-0.031,0.02,-0.004,-0.021,-0.005,-0.018,-0.026,-0.032,-0.038,-0.046,-0.055,-0.063,-0.067,-0.007,0.009,-0.048,-0.051,-0.051,-0.004,-0.015,-0.025,-0.018,0.002,-0.002,0.005,0.013,0.02,0.049,0.013,0.002,-0.023,-0.009,-0.075,-0.065,0.018,0.02,0.024,-0.023,-0.022,-0.049,0.01,0.056,-0.003,-0.001,0.008,-0.048,-0.05,-0.03,-0.015,0.002,-0.045,-0.077,-0.052,-0.113,-0.035,0.052,0.029,-0.006,-0.008,0.119,0.111,0.049,0.019,-0.001,-0.008,0.003,0.025,0.028,0.004,-0.059,0.008,0.005,-0.0,0.055,0.03,-0.044,-0.024,-0.002,-0.029,-0.015,-0.028,-0.04,-0.079,-0.084,-0.073,0.118,0.104,0.095,0.098,0.122,-0.042,0.014,0.108,0.117,-0.077,-0.066,-0.052,0.006,0.018,-0.026,-0.008,0.016,-0.019,-0.02,0.006,0.033,0.056,0.035,0.017,0.002,0.011,0.009,-0.015,0.039,-0.016,-0.019,0.073,0.06,0.028,0.012,-0.052,-0.037,-0.027,-0.026,-0.025,-0.027,-0.034,-0.033,-0.112,-0.098,-0.018,-0.005,0.014,-0.002,-0.012,-0.021,-0.041,-0.06,-0.073,-0.068,-0.007,0.023,-0.002,0.014,0.031,0.042,0.045,0.05,0.06,0.075,0.066,0.054,0.029,0.03,-0.066,-0.045,-0.033,-0.035,0.1,0.072,0.022,-0.017,0.002,0.006,0.105,0.007,-0.015,0.014,-0.019,-0.045,0.021,-0.027,-0.032,-0.043,0.023,-0.048,0.051,-0.072,-0.099,-0.013,0.016,0.062,0.05,0.066,0.041,0.022,-0.031,-0.028,-0.003,0.047,-0.019,-0.007,0.014,-0.047,0.019,-0.023,-0.023,0.008,-0.064,0.049,-0.035,-0.026,0.01,0.043,0.057,0.094,-0.076,-0.07,0.043,0.029,0.051,0.001,0.036,0.056,-0.016,-0.042,-0.015,-0.0,0.037,0.052,0.089,0.032,0.021,0.06,-0.018,0.018,0.002,0.036,0.058,-0.043,0.005,-0.0,-0.004,-0.084,-0.088,0.055,-0.004,0.02,0.027,0.018,-0.003,-0.013,-0.01,-0.005,0.032,0.037,-0.085,-0.114,0.016,-0.076,-0.081,0.043,0.029,0.014,-0.247,-0.334,-0.183,-0.03,-0.023,0.027,0.067,0.018,-0.17,0.04,0.076,0.16,0.142,0.214,-0.239,-0.036,0.236,0.006,-0.075,-0.015,-0.134,0.191,-0.098,-0.235,-0.094,-0.348,-0.156,-0.126,0.06,-0.008,0.039,0.184,0.048,0.037,0.035,-0.057,-0.06,0.062,-0.295,-0.071,-0.175,0.046,-0.045,-0.173,-0.173,-0.161,-0.148,0.042,-0.034,-0.145,0.247,0.002,-0.209,0.028,-0.129,-0.037,0.154,-0.069,-0.023,0.149,0.119,-0.146,0.175,0.112,-0.005,-0.029,-0.11,-0.064,-0.06,-0.018,-0.04,0.03,-0.087,-0.043,-0.015,-0.031,-0.13,-0.068,-0.06,0.001,-0.021,-0.0,-0.013,0.032,0.012,-0.025,0.013,0.081,0.008,-0.101,0.006,-0.063,-0.072,-0.034,-0.109,-0.03,-0.058,-0.068,-0.174,-0.1,-0.092,-0.088,-0.053,0.072,0.007,-0.043,-0.029,-0.004,-0.033,-0.07,-0.071,-0.07,-0.077,-0.07,-0.021,-0.02,-0.061,-0.004,-0.114,-0.02,-0.023,0.032,0.05,-0.037,-0.005,-0.02,-0.109,-0.063,0.014,-0.015,-0.037,0.007,0.019,0.035,0.051,0.064,-0.018,-0.034,-0.025,0.038,-0.074,-0.071,-0.068,-0.065,-0.061,-0.056,-0.05,-0.038,-0.032,-0.02,0.035,-0.023,-0.034,-0.042,-0.009,-0.053,-0.061,-0.048,0.032,0.055,-0.052,0.019,0.04,-0.026,0.007,-0.033,-0.04,-0.044,-0.047,0.134,-0.002,0.095,0.061,-0.082,-0.127,0.039,0.057,0.077,0.092,0.081,0.007,-0.041,0.057,-0.038,0.02,0.004,-0.126,-0.118,-0.042,-0.061,-0.009,-0.034,-0.028,-0.016,0.066,-0.008,-0.033,-0.062,-0.168,0.054,0.058,-0.007,0.007,-0.054,-0.042,-0.257,-0.152,0.009,0.029,0.032,0.036,-0.033,-0.046,-0.05,0.018,-0.059,-0.065,-0.078,-0.093,-0.109,-0.126,-0.136,-0.072,-0.057,-0.05,-0.039,-0.032,-0.029,-0.024,-0.055,-0.05,-0.057,0.019,0.031,0.057,0.078,0.091,0.084,0.071,0.05,0.033,0.066,0.024,-0.039,-0.043,-0.062,-0.086,-0.078,-0.035,-0.041,0.114,-0.004,0.08,-0.043,0.008,0.035,0.117,0.075,0.091,-0.088,-0.074,-0.157,-0.187,0.023,0.023,0.021,0.021,0.017,0.002,-0.01,0.054,0.037,0.016,0.058,-0.096,-0.292,0.009,0.007,-0.003,-0.011,-0.009,-0.005,-0.017,-0.031,-0.04,-0.029,-0.019,0.007,0.036,0.064,0.06,0.091,0.084,0.021,0.044,0.024,0.007,0.007,0.006,-0.001,-0.004,-0.011,-0.026,-0.037,-0.048,-0.06,-0.057,-0.054,-0.051,-0.031,-0.014,-0.007],"slopeNorth":[-0.005,0.005,-0.003,0.002,-0.006,-0.016,-0.044,-0.027,-0.024,-0.026,-0.026,-0.026,-0.05,-0.054,0.02,-0.042,-0.033,-0.022,-0.009,0.008,0.025,0.032,0.037,0.003,0.01,0.01,0.013,0.017,0.026,0.059,0.018,-0.02,-0.039,0.024,0.024,0.008,-0.005,-0.003,-0.06,-0.088,-0.024,-0.033,-0.037,-0.039,-0.046,-0.052,-0.026,-0.019,-0.023,-0.025,-0.027,-0.106,-0.113,-0.118,-0.111,-0.103,-0.016,-0.016,-0.129,-0.122,-0.096,-0.075,-0.046,-0.071,0.056,0.061,-0.108,-0.077,0.028,0.067,-0.029,-0.009,0.048,-0.009,-0.029,-0.018,0.006,0.025,0.144,0.19,0.17,0.167,0.149,-0.078,-0.083,-0.0,-0.081,-0.04,-0.001,-0.033,-0.082,-0.031,0.061,0.14,0.078,0.132,0.104,0.127,-0.011,-0.01,0.071,0.129,0.113,0.103,0.13,-0.038,-0.037,0.044,0.052,0.016,0.01,-0.01,-0.02,-0.014,0.06,0.086,0.082,0.067,0.049,0.051,0.058,-0.08,-0.095,-0.099,-0.098,-0.093,-0.064,-0.024,0.007,0.03,0.041,-0.085,-0.064,-0.021,-0.069,-0.063,-0.054,-0.027,0.013,0.058,0.082,0.091,0.065,0.04,0.085,0.117,0.139,0.134,0.059,0.047,0.008,0.002,0.005,0.01,0.034,0.039,0.068,0.077,-0.041,-0.017,-0.011,-0.017,-0.021,-0.021,-0.003,0.016,0.034,-0.057,-0.046,-0.067,-0.062,-0.059,-0.032,-0.028,-0.023,-0.01,-0.01,-0.033,-0.028,-0.004,0.019,-0.01,0.056,0.067,-0.061,-0.049,-0.108,-0.079,-0.0,0.018,0.024,0.053,0.08,0.085,-0.008,0.015,0.016,0.027,0.048,-0.05,-0.038,-0.058,-0.086,-0.001,-0.061,-0.038,-0.014,0.027,0.062,-0.036,-0.007,0.046,-0.023,-0.001,0.01,-0.073,0.053,0.073,-0.02,0.004,-0.001,-0.014,-0.069,-0.016,-0.041,0.001,0.021,-0.017,-0.019,-0.115,-0.11,-0.083,-0.119,-0.097,-0.076,-0.101,-0.069,-0.054,-0.022,-0.045,-0.028,-0.016,-0.008,0.019,-0.037,0.005,0.007,0.01,-0.092,-0.096,-0.079,0.074,0.07,-0.076,-0.025,-0.075,-0.039,0.003,-0.006,0.013,-0.03,-0.064,-0.079,-0.076,-0.056,-0.026,0.042,-0.084,0.038,0.055,0.002,0.033,0.01,-0.003,0.001,-0.02,-0.041,-0.057,-0.056,-0.053,-0.045,-0.037,0.043,0.037,-0.023,-0.013,-0.012,0.063,0.029,0.024,0.012,-0.007,0.01,0.006,-0.057,0.007,-0.017,0.02,0.061,0.058,0.039,0.021,0.001,-0.02,-0.025,-0.024,0.039,0.052,0.03,0.03,0.028,0.035,0.003,0.005,0.027,-0.048,-0.059,0.005,-0.037,-0.029,-0.058,-0.066,-0.009,-0.02,0.014,-0.009,-0.015,-0.01,-0.045,-0.036,-0.085,-0.017,0.011,-0.052,0.048,-0.025,-0.024,-0.035,-0.031,-0.05,-0.058,-0.013,-0.05,-0.053,-0.058,-0.033,-0.017,0.001,-0.025,-0.066,-0.023,-0.014,-0.011,-0.059,-0.019,-0.009,0.005,-0.007,-0.034,-0.051,0.002,0.036,0.0,0.007,-0.022,-0.028,-0.046,-0.051,-0.018,-0.021,-0.013,0.012,-0.005,-0.035,-0.036,0.011,-0.065,0.025,0.006,0.018,-0.103,0.005,-0.007,-0.025,-0.061,-0.044,-0.049,-0.008,0.004,-0.043,0.022,-0.062,-0.019,-0.055,0.001,-0.01,-0.057,-0.005,-0.051,-0.064,-0.023,0.029,-0.044,0.079,0.066,-0.049,-0.043,0.043,0.037,0.009,-0.04,-0.175,-0.046,-0.025,-0.055,-0.045,0.01,-0.078,-0.036,-0.127,-0.126,-0.145,0.102,0.094,-0.123,0.055,-0.109,0.133,-0.142,-0.009,-0.085,0.067,0.024,0.056,-0.06,0.156,0.025,-0.068,0.018,-0.106,0.077,0.019,-0.02,-0.043,0.021,-0.021,0.053,-0.023,-0.091,-0.044,0.016,0.174,0.024,0.172,0.213,-0.05,-0.021,-0.051,-0.255,0.041,0.076,-0.168,0.031,-0.153,-0.058,-0.022,0.063,-0.084,-0.122,-0.153,-0.157,-0.171,0.014,0.054,0.036,0.084,0.009,0.019,0.018,-0.012,0.013,0.008,0.011,0.061,0.061,0.057,-0.013,-0.036,-0.024,-0.045,-0.046,-0.101,-0.156,-0.066,-0.239,-0.1,-0.077,-0.243,-0.08,0.073,0.039,0.179,0.106,0.005,0.086,0.044,0.321,0.232,0.195,0.145,-0.074,-0.069,-0.057,0.021,-0.1,-0.165,0.063,0.139,0.12,0.091,0.134,0.082,0.064,-0.056,-0.118,-0.146,-0.004,-0.123,-0.142,-0.092,-0.121,0.172,0.209,0.083,-0.15,-0.088,-0.128,-0.152,-0.196,0.004,-0.017,-0.027,-0.037,-0.042,0.021,0.014,0.009,0.003,0.082,0.074,0.061,0.048,0.031,0.013,-0.006,0.081,0.074,0.055,-0.036,0.041,0.024,0.006,-0.068,-0.022,0.073,0.021,0.055,0.009,0.022,0.079,-0.019,0.052,0.08,0.02,0.073,0.04,0.01,0.224,0.291,0.218,-0.011,-0.137,-0.234,0.091,0.094,0.095,0.093,0.062,0.02,0.061,0.003,0.044,-0.063,0.145,-0.012,-0.046,-0.011,0.075,0.117,0.046,-0.033,0.108,0.084,-0.011,-0.094,-0.024,-0.13,0.075,0.1,0.124,-0.018,-0.058,-0.136,-0.149,-0.227,-0.022,0.105,0.119,0.113,0.09,0.076,0.045,-0.045,0.032,0.029,0.034,0.051,0.055,0.044,0.028,-0.072,-0.095,-0.088,-0.06,-0.032,-0.005,0.003,-0.016,-0.008,0.039,0.085,0.076,0.061,0.046,0.035,0.031,0.022,0.018,0.014,0.006,0.014,0.009,-0.002,-0.002,0.003,-0.003,-0.024,-0.018,0.068,0.212,0.049,0.187,0.06,0.024,0.003,-0.084,-0.173,0.03,-0.138,-0.17,-0.157,0.006,-0.007,-0.026,-0.034,-0.038,-0.045,0.001,-0.075,-0.067,0.237,0.215,0.109,0.055,-0.068,-0.066,-0.059,-0.054,-0.059,-0.062,-0.065,-0.07,-0.072,-0.064,-0.054,-0.045,-0.035,-0.022,0.059,0.161,0.071,0.021,-0.021,-0.027,-0.033,-0.039,-0.044,-0.038,-0.029,-0.034,-0.057,-0.078,-0.075,-0.071,-0.076,-0.085,-0.083,-0.057,-0.03,-0.018]} diff --git a/src/app/features/scene/ridge/weather.ts b/src/app/features/scene/ridge/weather.ts new file mode 100644 index 00000000..68ea88ea --- /dev/null +++ b/src/app/features/scene/ridge/weather.ts @@ -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; +} diff --git a/src/app/features/scene/ridge/weatherFeed.test.ts b/src/app/features/scene/ridge/weatherFeed.test.ts new file mode 100644 index 00000000..e12bbefc --- /dev/null +++ b/src/app/features/scene/ridge/weatherFeed.test.ts @@ -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 => ({ + 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()); + }); +}); diff --git a/src/app/features/scene/ridge/weatherFeed.ts b/src/app/features/scene/ridge/weatherFeed.ts new file mode 100644 index 00000000..39379fd5 --- /dev/null +++ b/src/app/features/scene/ridge/weatherFeed.ts @@ -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 | 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 { + try { + const res = await fetcher(url, { cache: 'no-store' }); + return res.ok ? parseWeatherFile(await res.json()) : null; + } catch { + return null; + } +} diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 37e170cb..7c8e14b1 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -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) => [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); }); }); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index a9551f65..8a5f9768 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -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 { + 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 }; } /** diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index ae4d9e83..d0a902a2 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -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', () => { diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index 0b60d99b..52b0cdc8 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -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 = { - 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)];