sky: bake sun azimuth and colour, ambient with a night floor, horizon colour
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7b4aa2f184
commit
7216456a27
4 changed files with 75 additions and 8 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue