From 7c89bd0289e79c6001fcf9abce4834b238520fac Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 6 Nov 2026 18:20:49 +0000 Subject: [PATCH] sky: sun.js, sun position and light colour for a place and moment Dependency-free ES module: light({time,lat,lon,cloud_cover,precipitation}) -> {elevation, direct, diffuse, wetness}, linear RGB. Constants that were tuned by eye are labelled EYEBALLED. Fixed-moment tests in sun.node-test.js (plain node, not collected by vitest). Co-Authored-By: Claude Sonnet 5.5 --- src/sky/sun.js | 99 ++++++++++++++++++++++++++++++++++++++++ src/sky/sun.node-test.js | 62 +++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/sky/sun.js create mode 100644 src/sky/sun.node-test.js diff --git a/src/sky/sun.js b/src/sky/sun.js new file mode 100644 index 00000000..d66f0f9a --- /dev/null +++ b/src/sky/sun.js @@ -0,0 +1,99 @@ +// Sun and sky light for a place and a moment. Dependency-free ES module: no DOM, +// no imports, so a build-time token script and a runtime shader can share it. +// +// light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) +// -> { elevation, direct: [r,g,b], diffuse: [r,g,b], wetness } +// +// time Date, epoch milliseconds, or an ISO string (read as UTC) +// lat, lon degrees, north and east positive +// cloud_cover 0..1 (Open-Meteo reports percent: divide by 100) +// precipitation mm/h +// elevation degrees above the horizon, no refraction +// direct sun colour at normal incidence, linear RGB. Clear zenith sun ~ 1. +// The consumer multiplies by max(0, dot(normal, toSun)). +// diffuse sky ambient on an upward surface, linear RGB +// wetness 0..1, how soaked surfaces are right now; darkens bark and soil +// +// Everything marked EYEBALLED is a tuned constant, not a measurement. Change it +// by looking at the result. Sun position follows the low-precision NOAA/Meeus +// series, good to about a hundredth of a degree for 1900..2100. + +const RAD = Math.PI / 180; +const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x)); +const smooth = (a, b, x) => { + const t = clamp((x - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; +const mix = (a, b, t) => a + (b - a) * t; + +const toMs = (time) => (time instanceof Date ? time.getTime() : typeof time === 'string' ? Date.parse(time) : Number(time)); + +/** Sun elevation in degrees at a UTC moment and place. */ +export function sunElevation(time, lat, lon) { + const d = toMs(time) / 86400000 - 10957.5; // days since J2000.0 (2000-01-01 12:00 UTC) + const L = (280.46 + 0.9856474 * d) * RAD; // mean longitude + const g = (357.528 + 0.9856003 * d) * RAD; // mean anomaly + const lambda = L + (1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * RAD; // ecliptic longitude + const eps = (23.439 - 0.0000004 * d) * RAD; // obliquity + const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)); + const ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)); + const gmst = (280.46061837 + 360.98564736629 * d) * RAD; + const hourAngle = gmst + lon * RAD - ra; + const phi = lat * RAD; + const sinEl = Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(hourAngle); + return Math.asin(clamp(sinEl, -1, 1)) / RAD; +} + +// Optical depth of the clear atmosphere at the zenith, per channel (R, G, B). +// Rayleigh at 650/550/450 nm plus a little ozone and haze (EYEBALLED to keep +// low sun orange rather than brown). +const TAU = [0.06, 0.12, 0.26]; + +// Relative air mass, Kasten & Young 1989. Elevation in degrees, >= 0. +const airMass = (el) => 1 / (Math.sin(el * RAD) + 0.50572 * Math.pow(el + 6.07995, -1.6364)); + +// Zenith sky colour and brightness. EYEBALLED. +const SKY_DAY = [0.22, 0.42, 0.85]; // blue, linear +const SKY_DAY_LEVEL = 0.3; // clear-noon ambient relative to direct = 1 +const SKY_TWILIGHT = [0.5, 0.35, 0.55]; // warm violet at the horizon (weight 0.05 below) +const SKY_NIGHT = [0.004, 0.006, 0.015]; // starlight and airglow floor + +const GREY_TINT = [0.95, 1, 1.08]; // cool overcast. EYEBALLED +const luma = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; + +export function light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) { + const elevation = sunElevation(time, lat, lon); + const cloud = clamp(cloud_cover, 0, 1); + const rain = Math.max(0, precipitation); + + // Rain implies cloud: even a reported 0 % cover goes grey once it is falling. EYEBALLED. + const overcast = Math.max(cloud, smooth(0, 1, rain) * 0.85); + + // Direct sun: attenuated per channel along the air mass, off at the horizon. + // Disc fully gone at -0.83 deg (centre below the upper limb); soft to 1.5 deg. EYEBALLED fade. + const above = smooth(-0.83, 1.5, elevation); + const m = elevation > 0 ? airMass(elevation) : airMass(0); + const clearDirect = TAU.map((tau) => Math.exp(-tau * m) * above); + // Cloud takes the sun away fast: 50 % cover leaves about a third. EYEBALLED. + const directKeep = Math.pow(1 - overcast, 1.6); + const direct = clearDirect.map((c) => c * directKeep); + + // Sky ambient: blue by day, a violet band around sunset, a floor at night. + const dayAmount = smooth(-6, 10, elevation); + const twilight = smooth(-18, -2, elevation) * (1 - smooth(-2, 8, elevation)); + const clearSky = SKY_NIGHT.map((night, i) => night + SKY_DAY[i] * SKY_DAY_LEVEL * dayAmount + SKY_TWILIGHT[i] * 0.05 * twilight); + + // Cloud: part of the sun's lost light is scattered into the sky (EYEBALLED 0.3), + // and the colour drifts to a cool grey (EYEBALLED tint and 0.7 pull). + const lostLuma = luma(clearDirect) * (1 - directKeep); + const scattered = clearSky.map((c) => c + lostLuma * 0.3); + const grey = luma(scattered); + const rainDim = mix(1, 0.8, smooth(0, 4, rain)); // heavy rain darkens the sky. EYEBALLED + const diffuse = scattered.map((c, i) => mix(c, grey * GREY_TINT[i], overcast * 0.7) * rainDim); + + // Surfaces take time to dry, but this is a point sample of the feed: + // 0 dry, ~0.63 at 1 mm/h, ~0.98 at 4 mm/h. EYEBALLED. + const wetness = 1 - Math.exp(-rain); + + return { elevation, direct, diffuse, wetness }; +} diff --git a/src/sky/sun.node-test.js b/src/sky/sun.node-test.js new file mode 100644 index 00000000..20e1624c --- /dev/null +++ b/src/sky/sun.node-test.js @@ -0,0 +1,62 @@ +// Fixed-moment checks for sun.js. Plain node, no framework dependency: +// node --test sun.node-test.js (run from a copy next to sun.js) +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { light, sunElevation } from './sun.js'; + +const near = (a, b, tol, msg) => assert.ok(Math.abs(a - b) <= tol, `${msg}: ${a} vs ${b} (tol ${tol})`); +const luma = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; + +// Greenwich, equator: sun overhead within a degree or two at equinox solar noon. +test('equinox noon on the equator: sun near the zenith, white-ish and bright', () => { + const l = light({ time: '2026-03-20T12:00:00Z', lat: 0, lon: 0 }); + assert.ok(l.elevation > 85, `elevation ${l.elevation}`); + assert.ok(l.direct[0] > 0.7 && l.direct[0] <= 1, `direct ${l.direct}`); + assert.ok(l.direct[0] > l.direct[1] && l.direct[1] > l.direct[2], 'sun is warmer than white'); + assert.ok(l.diffuse[2] > l.diffuse[0], 'sky is blue'); + assert.equal(l.wetness, 0); +}); + +test('midsummer noon at Berlin: Meeus elevation 90 - 52.52 + 23.44 ~= 60.9', () => { + near(sunElevation('2026-06-21T11:07:00Z', 52.52, 13.405), 60.9, 0.5, 'elevation'); +}); + +test('midnight in Berlin in December: sun far below, direct is zero, sky at floor', () => { + const l = light({ time: '2026-12-21T23:30:00Z', lat: 52.52, lon: 13.405 }); + assert.ok(l.elevation < -50, `elevation ${l.elevation}`); + assert.deepEqual(l.direct, [0, 0, 0]); + assert.ok(luma(l.diffuse) < 0.02, `diffuse ${l.diffuse}`); + assert.ok(luma(l.diffuse) > 0, 'night is not pure black'); +}); + +test('equinox sunrise on the equator at lon 0: ~06:00 UTC, sun on the horizon, red-orange and dim', () => { + const l = light({ time: '2026-03-20T06:00:00Z', lat: 0, lon: 0 }); + near(l.elevation, 0, 2, 'elevation'); + const lo = light({ time: '2026-03-20T06:10:00Z', lat: 0, lon: 0 }); + assert.ok(lo.elevation > 0.5, `elevation ${lo.elevation}`); + assert.ok(lo.direct[0] > 5 * lo.direct[2], `reddened ${lo.direct}`); + assert.ok(lo.direct[0] < 0.5, `dim ${lo.direct}`); +}); + +test('cloud shrinks direct, grey-cools diffuse; rain is darker and wet', () => { + const base = { time: '2026-06-21T11:07:00Z', lat: 52.52, lon: 13.405 }; + const clear = light(base); + const cloudy = light({ ...base, cloud_cover: 1 }); + const rainy = light({ ...base, cloud_cover: 1, precipitation: 4 }); + assert.ok(luma(cloudy.direct) < 0.1 * luma(clear.direct)); + assert.ok(luma(cloudy.diffuse) > 0.5 * luma(clear.diffuse)); + const blueness = (c) => c[2] / c[0]; + assert.ok(blueness(cloudy.diffuse) < blueness(clear.diffuse), 'greyer'); + assert.ok(luma(rainy.diffuse) < luma(cloudy.diffuse), 'rain darker'); + assert.ok(rainy.wetness > 0.95 && cloudy.wetness === 0); + assert.ok(light({ ...base, precipitation: 1 }).wetness > 0.6, 'rain with 0 cover still wet'); +}); + +test('every channel is finite and non-negative across a day and bad inputs', () => { + for (let h = 0; h < 24; h += 0.5) { + for (const cc of [0, 0.5, 1, 7, -1]) { + const l = light({ time: Date.UTC(2026, 10, 6, 0, 0) + h * 3600000, lat: 52.5, lon: 13.4, cloud_cover: cc, precipitation: cc > 0 ? 2 : 0 }); + for (const v of [...l.direct, ...l.diffuse, l.wetness]) assert.ok(Number.isFinite(v) && v >= 0, `${h} ${cc} ${v}`); + } + } +}); -- 2.47.3