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 <noreply@anthropic.com>
This commit is contained in:
parent
14d9fa6246
commit
7c89bd0289
2 changed files with 161 additions and 0 deletions
99
src/sky/sun.js
Normal file
99
src/sky/sun.js
Normal 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
62
src/sky/sun.node-test.js
Normal 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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue