sky: bake sun azimuth and colour, ambient with a night floor, horizon colour
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Failing after 1s
Build pull request / Build pull request (pull_request) Failing after 3m18s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-07 18:46:39 +00:00
commit 7216456a27
4 changed files with 75 additions and 8 deletions

View file

@ -8,6 +8,13 @@ export const baked = {
lon: 0,
},
elevation: -44.579021600011224,
sun: {
elevation_deg: -44.579021600011224,
azimuth_deg: 2.4988773054640205,
color_linear: [0, 0, 0],
},
ambient_linear: [0.018, 0.024, 0.04],
horizon_hex: '#484e64',
gradient: ['#37446a', '#38456a', '#3b4669', '#3d4868', '#414a67', '#444c66', '#484e64'],
tokens: {
Background: {
@ -117,6 +124,13 @@ export const baked = {
lon: 0,
},
elevation: 45.16994813502928,
sun: {
elevation_deg: 45.16994813502928,
azimuth_deg: 182.58737070473103,
color_linear: [0.9189659216820631, 0.8444983652129638, 0.6933684802959983],
},
ambient_linear: [0.07, 0.132, 0.27],
horizon_hex: '#e2f4ff',
gradient: ['#95c6ff', '#9ac9ff', '#a5cfff', '#b3d6ff', '#c2dfff', '#d2e9ff', '#e2f4ff'],
tokens: {
Background: {

View file

@ -1,7 +1,8 @@
// Midnight Sky and Noonday Sky: theme tokens as albedo x irradiance.
// Dependency-free apart from sun.js, so scripts/bake-sky.mjs and the tests run it under plain node.
//
// bakeSky('midnight' | 'noonday') -> { moment, elevation, gradient: [hex x GRADIENT_STOPS], tokens }
// bakeSky('midnight' | 'noonday') -> { moment, elevation, sun, ambient_linear, horizon_hex,
// gradient: [hex x GRADIENT_STOPS], tokens, ... }
//
// The sky is lit by sun.js at a fixed moment (clear weather: a build-time bake has no feed).
// A surface is `albedo * irradiance`, where irradiance is the sun and sky on an upward-facing
@ -24,6 +25,11 @@ export const MOMENTS = {
midnight: { time: '2026-09-22T00:00:00Z', lat: 45, lon: 0 },
};
// Lowest ambient a baked sky reports, linear RGB. sun.js's night sky is ~0.004, which is
// starlight-true but too dark to see by on a screen; this lifts it to a bluish moonlit-skyglow
// level. Daylight ambient is far above it, so the floor only ever touches the night.
export const AMBIENT_FLOOR = [0.018, 0.024, 0.04];
// Linear-RGB albedos of the things Yggdrasil is made of. EYEBALLED around measured ranges
// (dark bark 0.05-0.1, birch bark ~0.6, leaves ~0.05 red/0.15 green/0.03 blue, wet soil ~0.1).
export const ALBEDO = {
@ -193,6 +199,11 @@ export function bakeSky(name) {
return {
moment,
elevation: l.elevation,
// Sun for consumers that light a scene themselves: direction in degrees (azimuth clockwise
// from north) and the direct light's colour at normal incidence (black while it is below the horizon).
sun: { elevation_deg: l.elevation, azimuth_deg: l.azimuth, color_linear: l.direct },
ambient_linear: l.diffuse.map((v, i) => Math.max(v, AMBIENT_FLOOR[i])),
horizon_hex: gradient[GRADIENT_STOPS - 1], // last stop: the horizon end of the gradient
gradient,
tokens,
bubbles,

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { AA, bakeSky, contrast } from './skyTokens.js';
import { AA, AMBIENT_FLOOR, bakeSky, contrast } from './skyTokens.js';
import { light } from './sun.js';
import { baked } from './baked';
const names = ['midnight', 'noonday'] as const;
@ -18,6 +19,37 @@ describe.each(names)('%s sky', (name) => {
expect(name === 'noonday' ? sky.elevation : -sky.elevation).toBeGreaterThan(40);
});
it('carries the sun, an ambient light and a horizon colour for consumers that light a scene', () => {
const { sun } = sky;
expect(Object.keys(sun).sort()).toEqual(['azimuth_deg', 'color_linear', 'elevation_deg']);
expect(sun.elevation_deg).toBe(sky.elevation);
expect(sun.color_linear).toHaveLength(3);
sun.color_linear.forEach((v: number) => {
expect(Number.isFinite(v)).toBe(true);
expect(v).toBeGreaterThanOrEqual(0);
});
expect(sky.ambient_linear).toHaveLength(3);
expect(sky.horizon_hex).toMatch(/^#[0-9a-f]{6}$/);
expect(sky.horizon_hex).toBe(sky.gradient[sky.gradient.length - 1]);
});
it('puts the sun where Nibu expects it, azimuth clockwise from north', () => {
const azimuth: number = sky.sun.azimuth_deg;
expect(azimuth).toBeGreaterThanOrEqual(0);
expect(azimuth).toBeLessThan(360);
expect(Math.abs(azimuth - (name === 'noonday' ? 182.6 : 2.5))).toBeLessThan(1);
});
it('keeps ambient above the night floor and leaves daylight alone', () => {
sky.ambient_linear.forEach((v: number, i: number) =>
expect(v).toBeGreaterThanOrEqual(AMBIENT_FLOOR[i])
);
if (name === 'noonday') {
sky.ambient_linear.forEach((v: number) => expect(v).toBeGreaterThan(0.05));
expect(sky.ambient_linear).toEqual(light(sky.moment).diffuse);
}
});
it('clears WCAG AA for text on every panel state', () => {
['Background', 'Surface', 'SurfaceVariant'].forEach((p) =>
states.forEach((s) =>

View file

@ -2,13 +2,14 @@
// 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 }
// -> { elevation, azimuth, 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
// azimuth degrees clockwise from north (0 N, 90 E, 180 S), 0..360, 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
@ -28,8 +29,8 @@ 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) {
/** Sun elevation and azimuth in degrees at a UTC moment and place. Azimuth runs clockwise from north. */
export function sunPosition(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
@ -41,9 +42,18 @@ export function sunElevation(time, lat, lon) {
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;
// Hour angle is westward-positive, so atan2 gives azimuth from south, westward; +180 turns it into
// clockwise from north.
const south = Math.atan2(Math.sin(hourAngle), Math.cos(hourAngle) * Math.sin(phi) - Math.tan(dec) * Math.cos(phi));
return {
elevation: Math.asin(clamp(sinEl, -1, 1)) / RAD,
azimuth: (((south / RAD + 180) % 360) + 360) % 360,
};
}
/** Sun elevation in degrees at a UTC moment and place. */
export const sunElevation = (time, lat, lon) => sunPosition(time, lat, lon).elevation;
// 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).
@ -62,7 +72,7 @@ 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 { elevation, azimuth } = sunPosition(time, lat, lon);
const cloud = clamp(cloud_cover, 0, 1);
const rain = Math.max(0, precipitation);
@ -95,5 +105,5 @@ export function light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) {
// 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 };
return { elevation, azimuth, direct, diffuse, wetness };
}