sky: sun.js, sun position and light colour for a place and moment
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-06 18:20:49 +00:00
commit 7c89bd0289
2 changed files with 161 additions and 0 deletions

99
src/sky/sun.js Normal file
View file

@ -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 };
}

62
src/sky/sun.node-test.js Normal file
View file

@ -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}`);
}
}
});