sky: Midnight Sky and Noonday Sky baked from sun.js; bubbles and buttons as albedo x irradiance (do not merge) #22
15 changed files with 1173 additions and 138 deletions
|
|
@ -19,6 +19,7 @@
|
|||
"prepare": "husky install",
|
||||
"commit": "git-cz",
|
||||
"bump": "node scripts/update-version.js",
|
||||
"bake:sky": "node scripts/bake-sky.mjs && prettier --write src/sky/baked.ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
|
|
|||
19
scripts/bake-sky.mjs
Normal file
19
scripts/bake-sky.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts, with the
|
||||
// atmospheric-scattering tables (src/sky/scatter.js) as `scatter`.
|
||||
// Run `npm run bake:sky` after changing sun.js, skyTokens.js, scatter.js or a moment.
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { bakeSky } from '../src/sky/skyTokens.js';
|
||||
import { bakeScatter } from '../src/sky/scatter.js';
|
||||
|
||||
const out = new URL('../src/sky/baked.ts', import.meta.url);
|
||||
const baked = {
|
||||
midnight: bakeSky('midnight'),
|
||||
noonday: bakeSky('noonday'),
|
||||
scatter: bakeScatter(),
|
||||
};
|
||||
writeFileSync(
|
||||
out,
|
||||
`// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit.\n` +
|
||||
`/* eslint-disable */\n` +
|
||||
`export const baked = ${JSON.stringify(baked, null, 2)} as const;\n`
|
||||
);
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
import { lightTheme } from 'folds';
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
||||
import { butterTheme, darkTheme, gamecultTheme, silverTheme } from '../../colors.css';
|
||||
import {
|
||||
butterTheme,
|
||||
darkTheme,
|
||||
midnightSkyTheme,
|
||||
noondaySkyTheme,
|
||||
silverTheme,
|
||||
} from '../../colors.css';
|
||||
import '../../gamecult.css';
|
||||
import '../../sky.css';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
|
||||
|
|
@ -39,14 +46,23 @@ export const ButterTheme: Theme = {
|
|||
classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'],
|
||||
};
|
||||
|
||||
export const GameCultTheme: Theme = {
|
||||
id: 'gamecult-theme',
|
||||
export const MidnightSkyTheme: Theme = {
|
||||
id: 'midnight-sky',
|
||||
kind: ThemeKind.Dark,
|
||||
classNames: ['gamecult-theme', gamecultTheme, onDarkFontWeight, 'prism-dark'],
|
||||
classNames: ['gamecult-theme', 'midnight-sky', midnightSkyTheme, onDarkFontWeight, 'prism-dark'],
|
||||
};
|
||||
|
||||
export const NoondaySkyTheme: Theme = {
|
||||
id: 'noonday-sky',
|
||||
kind: ThemeKind.Light,
|
||||
classNames: ['gamecult-theme', 'noonday-sky', noondaySkyTheme, onLightFontWeight, 'prism-light'],
|
||||
};
|
||||
|
||||
export const useThemes = (): Theme[] => {
|
||||
const themes: Theme[] = useMemo(() => [GameCultTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme], []);
|
||||
const themes: Theme[] = useMemo(
|
||||
() => [MidnightSkyTheme, NoondaySkyTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme],
|
||||
[]
|
||||
);
|
||||
|
||||
return themes;
|
||||
};
|
||||
|
|
@ -54,7 +70,8 @@ export const useThemes = (): Theme[] => {
|
|||
export const useThemeNames = (): Record<string, string> =>
|
||||
useMemo(
|
||||
() => ({
|
||||
[GameCultTheme.id]: 'GameCult',
|
||||
[MidnightSkyTheme.id]: 'Midnight Sky',
|
||||
[NoondaySkyTheme.id]: 'Noonday Sky',
|
||||
[LightTheme.id]: 'Light',
|
||||
[SilverTheme.id]: 'Silver',
|
||||
[DarkTheme.id]: 'Dark',
|
||||
|
|
@ -92,15 +109,15 @@ export const useActiveTheme = (): Theme => {
|
|||
const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
||||
|
||||
if (!systemTheme) {
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? GameCultTheme;
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? MidnightSkyTheme;
|
||||
|
||||
return selectedTheme;
|
||||
}
|
||||
|
||||
const selectedTheme =
|
||||
systemThemeKind === ThemeKind.Dark
|
||||
? themes.find((theme) => theme.id === darkThemeId) ?? GameCultTheme
|
||||
: themes.find((theme) => theme.id === lightThemeId) ?? GameCultTheme;
|
||||
? themes.find((theme) => theme.id === darkThemeId) ?? MidnightSkyTheme
|
||||
: themes.find((theme) => theme.id === lightThemeId) ?? NoondaySkyTheme;
|
||||
|
||||
return selectedTheme;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { configClass, varsClass } from 'folds';
|
||||
import {
|
||||
GameCultTheme,
|
||||
MidnightSkyTheme,
|
||||
ThemeContextProvider,
|
||||
useActiveTheme,
|
||||
} from '../hooks/useTheme';
|
||||
|
|
@ -12,8 +12,8 @@ export function UnAuthRouteThemeManager() {
|
|||
useEffect(() => {
|
||||
document.body.className = '';
|
||||
document.body.classList.add(configClass, varsClass);
|
||||
// The GameCult identity is single-theme: signed-out pages ignore the system scheme.
|
||||
document.body.classList.add(...GameCultTheme.classNames);
|
||||
// Signed-out pages are always Midnight Sky and ignore the system scheme.
|
||||
document.body.classList.add(...MidnightSkyTheme.classNames);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export interface Settings {
|
|||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
themeId: 'gamecult-theme',
|
||||
themeId: 'midnight-sky',
|
||||
useSystemTheme: false,
|
||||
lightThemeId: undefined,
|
||||
darkThemeId: undefined,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createTheme } from '@vanilla-extract/css';
|
||||
import { color } from 'folds';
|
||||
import { baked } from './sky/baked';
|
||||
|
||||
export const silverTheme = createTheme(color, {
|
||||
Background: {
|
||||
|
|
@ -240,91 +241,22 @@ export const butterTheme = createTheme(color, {
|
|||
// GameCult identity (gamecult-site quartz.config.ts): single dark theme on #07111a,
|
||||
// orange #ff8a2a accent, sky #59b7ff links. The brand defines no status colours, so
|
||||
// Success/Warning/Critical are invented here and kept distinct from the orange accent.
|
||||
export const gamecultTheme = createTheme(color, {
|
||||
Background: {
|
||||
Container: '#07111a',
|
||||
ContainerHover: '#0d1a26',
|
||||
ContainerActive: '#122130',
|
||||
ContainerLine: '#16212c',
|
||||
OnContainer: '#b7c7d9',
|
||||
},
|
||||
Surface: {
|
||||
Container: '#09141f',
|
||||
ContainerHover: '#0f1c29',
|
||||
ContainerActive: '#152433',
|
||||
ContainerLine: '#16212c',
|
||||
OnContainer: '#b7c7d9',
|
||||
},
|
||||
SurfaceVariant: {
|
||||
Container: '#0d1a26',
|
||||
ContainerHover: '#122130',
|
||||
ContainerActive: '#16273a',
|
||||
ContainerLine: '#1b2a38',
|
||||
OnContainer: '#b7c7d9',
|
||||
},
|
||||
Primary: {
|
||||
Main: '#ff8a2a',
|
||||
MainHover: '#ff9640',
|
||||
MainActive: '#ffa155',
|
||||
MainLine: '#ffab66',
|
||||
OnMain: '#2a1300',
|
||||
Container: '#2b1a0e',
|
||||
ContainerHover: '#35200f',
|
||||
ContainerActive: '#402611',
|
||||
ContainerLine: '#4d2d13',
|
||||
OnContainer: '#ffd3ab',
|
||||
},
|
||||
Secondary: {
|
||||
Main: '#eef5ff',
|
||||
MainHover: '#dbe7f6',
|
||||
MainActive: '#cddced',
|
||||
MainLine: '#bfd0e4',
|
||||
OnMain: '#07111a',
|
||||
Container: '#16212c',
|
||||
ContainerHover: '#1c2a38',
|
||||
ContainerActive: '#233342',
|
||||
ContainerLine: '#2a3b4c',
|
||||
OnContainer: '#eef5ff',
|
||||
},
|
||||
Success: {
|
||||
Main: '#5fd0a4',
|
||||
MainHover: '#72d8b0',
|
||||
MainActive: '#84deba',
|
||||
MainLine: '#93e3c2',
|
||||
OnMain: '#042014',
|
||||
Container: '#0c2a21',
|
||||
ContainerHover: '#10342a',
|
||||
ContainerActive: '#143e32',
|
||||
ContainerLine: '#19493a',
|
||||
OnContainer: '#c4f1de',
|
||||
},
|
||||
Warning: {
|
||||
Main: '#f2d04b',
|
||||
MainHover: '#f5d963',
|
||||
MainActive: '#f7e078',
|
||||
MainLine: '#f9e68b',
|
||||
OnMain: '#2a2100',
|
||||
Container: '#2a2410',
|
||||
ContainerHover: '#352d13',
|
||||
ContainerActive: '#403616',
|
||||
ContainerLine: '#4d411a',
|
||||
OnContainer: '#f8ebb0',
|
||||
},
|
||||
Critical: {
|
||||
Main: '#ff7a90',
|
||||
MainHover: '#ff8fa1',
|
||||
MainActive: '#ffa0af',
|
||||
MainLine: '#ffb0bc',
|
||||
OnMain: '#3a0710',
|
||||
Container: '#2e1118',
|
||||
ContainerHover: '#391520',
|
||||
ContainerActive: '#441a27',
|
||||
ContainerLine: '#51202e',
|
||||
OnContainer: '#ffd0d8',
|
||||
},
|
||||
// Midnight Sky and Noonday Sky: every colour is albedo x irradiance from src/sky/sun.js,
|
||||
// baked by scripts/bake-sky.mjs.
|
||||
export const midnightSkyTheme = createTheme(color, {
|
||||
...baked.midnight.tokens,
|
||||
Other: {
|
||||
FocusRing: 'rgba(89, 183, 255, 0.55)',
|
||||
FocusRing: 'rgba(132, 200, 181, 0.6)',
|
||||
Shadow: 'rgba(0, 0, 0, 0.6)',
|
||||
Overlay: 'rgba(3, 7, 13, 0.8)',
|
||||
Overlay: 'rgba(3, 4, 10, 0.8)',
|
||||
},
|
||||
});
|
||||
|
||||
export const noondaySkyTheme = createTheme(color, {
|
||||
...baked.noonday.tokens,
|
||||
Other: {
|
||||
FocusRing: 'rgba(43, 80, 40, 0.6)',
|
||||
Shadow: 'rgba(40, 30, 20, 0.25)',
|
||||
Overlay: 'rgba(20, 24, 30, 0.5)',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -136,44 +136,11 @@ li p::before {
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
/* GameCult identity: Ubuntu 300 prose, Montserrat thin titles, IBM Plex Mono labels. */
|
||||
/* GameCult identity (type only; colour is sky.css.ts): Ubuntu 300 prose, Montserrat thin titles, IBM Plex Mono labels. */
|
||||
.gamecult-theme {
|
||||
--tc-link: #59b7ff;
|
||||
|
||||
--mx-uc-1: hsl(208, 100%, 72%);
|
||||
--mx-uc-2: hsl(280, 100%, 78%);
|
||||
--mx-uc-3: hsl(163, 70%, 65%);
|
||||
--mx-uc-4: hsl(343, 100%, 76%);
|
||||
--mx-uc-5: hsl(28, 100%, 66%);
|
||||
--mx-uc-6: hsl(181, 70%, 62%);
|
||||
--mx-uc-7: hsl(243, 100%, 80%);
|
||||
--mx-uc-8: hsl(94, 70%, 70%);
|
||||
|
||||
--font-secondary: 'Ubuntu', var(--font-emoji), sans-serif;
|
||||
--font-title: 'Montserrat', 'Ubuntu', sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', monospace;
|
||||
--gamecult-rule: rgba(148, 163, 184, 0.22);
|
||||
--gamecult-wash: 0.5;
|
||||
|
||||
background-color: #07111a;
|
||||
background-image: radial-gradient(
|
||||
circle at 78% 14%,
|
||||
rgb(255 138 42 / calc(0.18 * var(--gamecult-wash))),
|
||||
transparent 18%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 18% 10%,
|
||||
rgb(109 96 255 / calc(0.16 * var(--gamecult-wash))),
|
||||
transparent 24%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 52% 0%,
|
||||
rgb(89 183 255 / calc(0.14 * var(--gamecult-wash))),
|
||||
transparent 28%
|
||||
),
|
||||
linear-gradient(180deg, #03070d 0%, #07111a 44%, #09141f 100%);
|
||||
background-attachment: fixed;
|
||||
color-scheme: dark;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
|
|
|
|||
29
src/sky.css.ts
Normal file
29
src/sky.css.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { globalStyle } from '@vanilla-extract/css';
|
||||
import { baked } from './sky/baked';
|
||||
|
||||
// Fallback background and the colours folds has no token for. Everything is read from
|
||||
// the bake (albedo x irradiance via sun.js); nothing here chooses a colour.
|
||||
const skies = [
|
||||
{ scope: 'body.midnight-sky', sky: baked.midnight, scheme: 'dark' },
|
||||
{ scope: 'body.noonday-sky', sky: baked.noonday, scheme: 'light' },
|
||||
] as const;
|
||||
|
||||
skies.forEach(({ scope, sky, scheme }) => {
|
||||
const stops = sky.gradient.map(
|
||||
(c, i) => `${c} ${Math.round((i / (sky.gradient.length - 1)) * 100)}%`
|
||||
);
|
||||
globalStyle(scope, {
|
||||
colorScheme: scheme,
|
||||
backgroundColor: sky.gradient[0],
|
||||
backgroundImage: `linear-gradient(180deg, ${stops.join(', ')})`,
|
||||
backgroundAttachment: 'fixed',
|
||||
vars: {
|
||||
'--tc-link': sky.link,
|
||||
...Object.fromEntries(sky.userColors.map((c, i) => [`--mx-uc-${i + 1}`, c])),
|
||||
'--sky-bubble': sky.bubbles.Other,
|
||||
'--sky-bubble-text': sky.bubbles.OtherText,
|
||||
'--sky-bubble-own': sky.bubbles.Own,
|
||||
'--sky-bubble-own-text': sky.bubbles.OwnText,
|
||||
},
|
||||
});
|
||||
});
|
||||
83
src/sky/README.md
Normal file
83
src/sky/README.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Baked sky
|
||||
|
||||
`npm run bake:sky` (`scripts/bake-sky.mjs`) lights two fixed moments with `sun.js`, the single source of
|
||||
the sun and the air, and writes `baked.ts`. `baked.midnight`, `baked.noonday` and `baked.scatter` are its
|
||||
three keys. Everything here is linear RGB unless a name says `hex`.
|
||||
|
||||
## Per sky: `baked.midnight`, `baked.noonday`
|
||||
|
||||
| field | meaning |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `moment` | `{ time, lat, lon }` fed to `sun.js` (equinox, 45N, prime meridian; 00:00 and 12:00 UTC) |
|
||||
| `elevation` | sun elevation in degrees at that moment: -44.6 midnight, +45.2 noonday |
|
||||
| `sun.elevation_deg` | the same, for scene code; fade stars by this (and see below for twilight) |
|
||||
| `sun.azimuth_deg` | degrees clockwise from north, 0..360 |
|
||||
| `sun.color_linear` | direct sun colour at normal incidence, clear zenith ~ 1; multiply by `max(0, dot(n, toSun))` |
|
||||
| `ambient_linear` | sky ambient on an upward surface, with a night floor of `AMBIENT_FLOOR` (a moonlit skyglow, not black); daylight is `sun.js`'s own `diffuse` |
|
||||
| `horizon_hex` | colour of the last gradient stop, the horizon |
|
||||
| `gradient` | 7 sRGB hex stops, zenith to horizon |
|
||||
| `tokens`, `bubbles`, ... | theme colours (see `skyTokens.js`) |
|
||||
|
||||
The two skies are single moments. To fade stars through twilight, take the elevation from `sun.js` at the
|
||||
viewer's real time (`sunPosition(time, lat, lon).elevation`) rather than from `baked.<sky>.sun`, which only
|
||||
ever says -44.6 or +45.2. The scattering table below goes dark at -12 degrees and below (the planet's shadow
|
||||
covers every sunlit air path), so a star fade between about -6 and -12 degrees follows it.
|
||||
|
||||
## `baked.scatter`: atmospheric scattering for a WebGL2 shader
|
||||
|
||||
A Hillaire-style (2020) single-scattering atmosphere, reduced to two small tables. The air is `sun.js`'s:
|
||||
`TAU` (its zenith optical depth per channel) is the vertical optical depth here, split into a grey aerosol
|
||||
part (0.02) and a Rayleigh part, so the transmittance table equals `exp(-TAU)` at the zenith. Planet radius
|
||||
6360 km, atmosphere 100 km, scale heights 8 km (Rayleigh) and 1.2 km (Mie, g = 0.8, albedo 0.9), observer
|
||||
100 m above the ground. Single scattering only: no ground bounce, no multiple scattering, no sun disc
|
||||
(the consumer draws it from `sun.color_linear`). That makes dusk a little darker than the real thing.
|
||||
|
||||
Constants: `planet_radius_km`, `atmosphere_height_km`, `observer_altitude_km`, `extinction_per_km` (RGB at the
|
||||
observer, for aerial perspective: `exp(-k * distance_km)`).
|
||||
|
||||
### Encoding (both tables)
|
||||
|
||||
IEEE 754 half floats (16 bit), little-endian, base64 of the raw bytes, no header. The stored value is
|
||||
`value * store_scale` (`store_scale` = 64; it lifts the dark end of twilight clear of the half-float
|
||||
subnormals). Exact decode: for a 16-bit word `h`, `e = (h >> 10) & 31`, `m = h & 1023`,
|
||||
`half = e == 0 ? m * 2^-24 : (1 + m / 1024) * 2^(e - 15)` (no infinities or NaNs are stored), and
|
||||
`value = half / store_scale`. In WebGL2 upload as `RGB16F` / `HALF_FLOAT` from a `Uint16Array` over the
|
||||
decoded bytes, and the GPU does the decode: sample, then divide by `store_scale`. Precision is 11 bits
|
||||
(about 0.05%): the horizon glow does not step.
|
||||
|
||||
### `transmittance`: `width` 32, `height` 1, 3 channels
|
||||
|
||||
Transmittance from the observer to space along a view elevation, per channel. Texel `i` is the elevation
|
||||
`-4 + 94 * (i / 31)^2` degrees (`viewElevation(i)` in `scatter.js`; the inverse is `viewIndex(deg)`):
|
||||
finest at the horizon. Elevations that look at the ground (-4 to about -0.3) are 0. Sample it for the sun's
|
||||
direct colour: at the sun's elevation, `sun.color_linear`-like attenuation, redder as it sets.
|
||||
|
||||
### `sky_view`: `width` 7, `height` 32, `depth` 12, 3 channels
|
||||
|
||||
Sky radiance per steradian for a sun of unit irradiance (multiply by your own sun colour and exposure).
|
||||
One 3D texture, `x` fastest:
|
||||
|
||||
- `x`: azimuth of the view away from the sun, 0, 30, ... 180 degrees (`azimuth_step_deg`). The sky is
|
||||
symmetric about the sun's vertical plane, so use `abs` of the relative azimuth.
|
||||
- `y`: view elevation, same axis as the transmittance table (`viewElevation(i)`).
|
||||
- `z`: sun elevation, `sun_elevations_deg` = -18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90. Between them
|
||||
interpolate linearly; `sunIndex(deg)` in `scatter.js` gives the fractional index.
|
||||
|
||||
Element `((z * 32 + y) * 7 + x) * 3 + c` of the decoded array. Texel centres, for linear filtering:
|
||||
|
||||
```glsl
|
||||
uniform highp sampler3D uSky; // RGB16F, 7 x 32 x 12, LINEAR, CLAMP_TO_EDGE
|
||||
uniform float uSunIndex; // sunIndex(sunElevationDeg), 0..11, from the CPU
|
||||
vec3 skyRadiance(float viewElDeg, float azFromSunDeg) {
|
||||
float y = sqrt(clamp((viewElDeg + 4.0) / 94.0, 0.0, 1.0)) * 31.0;
|
||||
float x = clamp(abs(azFromSunDeg) / 180.0, 0.0, 1.0) * 6.0;
|
||||
return texture(uSky, vec3((x + 0.5) / 7.0, (y + 0.5) / 32.0, (uSunIndex + 0.5) / 12.0)).rgb / 64.0;
|
||||
}
|
||||
```
|
||||
|
||||
Row width is 21 bytes, so set `gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)` before `texImage3D`.
|
||||
|
||||
Size: 16,128 bytes for `sky_view` (21.5 KB as base64) and 192 bytes for `transmittance`; about
|
||||
twice the "few KB" of an 8-bit table, the price of not quantising the glow. `scatter.test.ts` pins the
|
||||
layout, the encoding, the checksums and a set of physical anchors (blue noon sky, red sunset toward the sun,
|
||||
black below the planet's shadow limit).
|
||||
264
src/sky/baked.ts
Normal file
264
src/sky/baked.ts
Normal file
File diff suppressed because one or more lines are too long
249
src/sky/scatter.js
Normal file
249
src/sky/scatter.js
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
// Atmospheric scattering tables, baked for a WebGL2 shader (layout: src/sky/README.md).
|
||||
// Dependency-free apart from sun.js, so scripts/bake-sky.mjs and the tests run it under plain node.
|
||||
//
|
||||
// bakeScatter() -> { transmittance, sky_view, ... } with base64 RGB16F tables (half floats).
|
||||
//
|
||||
// A Hillaire-style single-scattering model (Rayleigh + Mie, spherical planet, observer 100 m up),
|
||||
// reduced to two small tables: the transmittance of the whole atmosphere along a view direction, and
|
||||
// the sky's radiance by sun elevation, view elevation and azimuth from the sun. sun.js stays the
|
||||
// single source of the air: its zenith optical depths (TAU, per channel) are the vertical optical
|
||||
// depth of this atmosphere, split into a grey aerosol part and a Rayleigh part. So the direct sun
|
||||
// sun.js returns and the transmittance here agree at the zenith; only the air-mass law differs
|
||||
// (Kasten-Young there, the spherical integral here).
|
||||
|
||||
import { TAU } from './sun.js';
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
|
||||
|
||||
// Geometry (km) and scale heights.
|
||||
export const PLANET_RADIUS_KM = 6360;
|
||||
export const ATMOSPHERE_HEIGHT_KM = 100;
|
||||
export const OBSERVER_ALTITUDE_KM = 0.1;
|
||||
const H_RAYLEIGH = 8;
|
||||
const H_MIE = 1.2;
|
||||
const MIE_ALBEDO = 0.9; // single-scattering albedo of the aerosol (EYEBALLED)
|
||||
const MIE_G = 0.8; // Henyey-Greenstein asymmetry (Hillaire 2020)
|
||||
const MIE_TAU = 0.02; // grey aerosol vertical optical depth (EYEBALLED); the rest of TAU is Rayleigh
|
||||
|
||||
// Vertical optical depth of an exponential layer of scale height H over the atmosphere.
|
||||
const column = (H) => H * (1 - Math.exp(-ATMOSPHERE_HEIGHT_KM / H));
|
||||
const BETA_RAYLEIGH = TAU.map((tau) => (tau - MIE_TAU) / column(H_RAYLEIGH)); // per km at sea level
|
||||
const BETA_MIE_EXT = MIE_TAU / column(H_MIE);
|
||||
const BETA_MIE_SCA = BETA_MIE_EXT * MIE_ALBEDO;
|
||||
|
||||
// Table axes.
|
||||
export const SUN_ELEVATIONS_DEG = [-18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90];
|
||||
export const VIEW_SAMPLES = 32;
|
||||
export const AZIMUTH_SAMPLES = 7; // 0, 30, ... 180 degrees from the sun
|
||||
export const AZIMUTH_STEP_DEG = 30;
|
||||
export const VIEW_MIN_DEG = -4;
|
||||
export const VIEW_MAX_DEG = 90;
|
||||
// Tables hold IEEE 754 half floats, little-endian, holding value * STORE_SCALE. The scale lifts the
|
||||
// dark end (twilight sky radiance is ~1e-4) clear of the half-float subnormals; the brightest
|
||||
// stored value, a Mie peak near 0.3 x 64, is far below the format's 65504.
|
||||
export const STORE_SCALE = 64;
|
||||
|
||||
/** View elevation in degrees of sample index i of VIEW_SAMPLES: finest at the horizon. */
|
||||
export const viewElevation = (i) =>
|
||||
VIEW_MIN_DEG + (VIEW_MAX_DEG - VIEW_MIN_DEG) * (i / (VIEW_SAMPLES - 1)) ** 2;
|
||||
|
||||
/** Fractional index into SUN_ELEVATIONS_DEG of a sun elevation (clamped to the table): the z texel coordinate. */
|
||||
export const sunIndex = (elevationDeg) => {
|
||||
const e = clamp(
|
||||
elevationDeg,
|
||||
SUN_ELEVATIONS_DEG[0],
|
||||
SUN_ELEVATIONS_DEG[SUN_ELEVATIONS_DEG.length - 1]
|
||||
);
|
||||
const k = Math.min(SUN_ELEVATIONS_DEG.findIndex((x) => x > e) - 1, SUN_ELEVATIONS_DEG.length - 2);
|
||||
const i = k < 0 ? SUN_ELEVATIONS_DEG.length - 2 : k;
|
||||
return i + (e - SUN_ELEVATIONS_DEG[i]) / (SUN_ELEVATIONS_DEG[i + 1] - SUN_ELEVATIONS_DEG[i]);
|
||||
};
|
||||
|
||||
/** Inverse of viewElevation: the fractional sample index of an elevation. */
|
||||
export const viewIndex = (elevationDeg) =>
|
||||
Math.sqrt(clamp((elevationDeg - VIEW_MIN_DEG) / (VIEW_MAX_DEG - VIEW_MIN_DEG), 0, 1)) *
|
||||
(VIEW_SAMPLES - 1);
|
||||
|
||||
/** Round a non-negative number to the nearest IEEE 754 half float (ties to even); returns its 16 bits. */
|
||||
export function toHalf(x) {
|
||||
if (!(x > 0)) return 0;
|
||||
const u = new Uint32Array(new Float32Array([x]).buffer)[0];
|
||||
const exp = ((u >> 23) & 0xff) - 127 + 15;
|
||||
const mant = u & 0x7fffff;
|
||||
if (exp >= 31) return 0x7bff;
|
||||
let half;
|
||||
let rem;
|
||||
let halfway;
|
||||
if (exp <= 0) {
|
||||
if (exp < -10) return 0;
|
||||
const m = mant | 0x800000;
|
||||
const shift = 14 - exp;
|
||||
half = m >> shift;
|
||||
rem = m & ((1 << shift) - 1);
|
||||
halfway = 1 << (shift - 1);
|
||||
} else {
|
||||
half = (exp << 10) | (mant >> 13);
|
||||
rem = mant & 0x1fff;
|
||||
halfway = 0x1000;
|
||||
}
|
||||
return rem > halfway || (rem === halfway && half & 1) ? half + 1 : half;
|
||||
}
|
||||
|
||||
/** The exact value of 16 half-float bits (finite values only). */
|
||||
export const fromHalf = (h) => {
|
||||
const e = (h >> 10) & 31;
|
||||
const m = h & 1023;
|
||||
return e === 0 ? m * 2 ** -24 : (1 + m / 1024) * 2 ** (e - 15);
|
||||
};
|
||||
|
||||
/** Stored 16 bits of a value, and the value back out of them. */
|
||||
export const encode = (v) => toHalf(v * STORE_SCALE);
|
||||
export const decode = (h) => fromHalf(h) / STORE_SCALE;
|
||||
|
||||
const extinction = (h) => {
|
||||
const r = Math.exp(-h / H_RAYLEIGH);
|
||||
const m = Math.exp(-h / H_MIE);
|
||||
return BETA_RAYLEIGH.map((b) => b * r + BETA_MIE_EXT * m);
|
||||
};
|
||||
|
||||
// Distance along ray (origin o, unit direction d) to the sphere of radius r centred on the origin
|
||||
// of the planet: the far root, or the near root if `near`. Infinity when it misses or is behind.
|
||||
function sphereHit(o, d, r, near) {
|
||||
const b = o[0] * d[0] + o[1] * d[1] + o[2] * d[2];
|
||||
const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - r * r;
|
||||
const disc = b * b - c;
|
||||
if (disc < 0) return Infinity;
|
||||
const sq = Math.sqrt(disc);
|
||||
const t = near ? -b - sq : -b + sq;
|
||||
return t > 0 ? t : Infinity;
|
||||
}
|
||||
|
||||
// Optical depth per channel from p along d to the top of the atmosphere; Infinity if the planet is in the way.
|
||||
function opticalDepthToSpace(p, d, steps) {
|
||||
if (sphereHit(p, d, PLANET_RADIUS_KM, true) !== Infinity) return null;
|
||||
const top = sphereHit(p, d, PLANET_RADIUS_KM + ATMOSPHERE_HEIGHT_KM, false);
|
||||
const dt = top / steps;
|
||||
const depth = [0, 0, 0];
|
||||
for (let k = 0; k < steps; k += 1) {
|
||||
const t = (k + 0.5) * dt;
|
||||
const q = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t];
|
||||
const e = extinction(Math.hypot(...q) - PLANET_RADIUS_KM);
|
||||
for (let c = 0; c < 3; c += 1) depth[c] += e[c] * dt;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
const observer = [0, PLANET_RADIUS_KM + OBSERVER_ALTITUDE_KM, 0];
|
||||
const viewDirection = (elevationDeg, azimuthDeg) => {
|
||||
const e = elevationDeg * RAD;
|
||||
const a = azimuthDeg * RAD;
|
||||
return [Math.cos(e) * Math.cos(a), Math.sin(e), Math.cos(e) * Math.sin(a)];
|
||||
};
|
||||
const sunDirection = (elevationDeg) => [
|
||||
Math.cos(elevationDeg * RAD),
|
||||
Math.sin(elevationDeg * RAD),
|
||||
0,
|
||||
];
|
||||
|
||||
/** Transmittance per channel from the observer along an elevation to space; zeros where the ground is in the way. */
|
||||
export function transmittance(elevationDeg) {
|
||||
const depth = opticalDepthToSpace(observer, viewDirection(elevationDeg, 0), 64);
|
||||
return depth ? depth.map((x) => Math.exp(-x)) : [0, 0, 0];
|
||||
}
|
||||
|
||||
const rayleighPhase = (c) => (3 / (16 * Math.PI)) * (1 + c * c);
|
||||
const miePhase = (c) =>
|
||||
(1 - MIE_G * MIE_G) / (4 * Math.PI * (1 + MIE_G * MIE_G - 2 * MIE_G * c) ** 1.5);
|
||||
|
||||
/**
|
||||
* Sky radiance per channel, per steradian, for a sun of unit irradiance (so the consumer multiplies by
|
||||
* its own sun colour), in a view direction at an elevation and an azimuth measured from the sun.
|
||||
* Single scattering, no ground bounce, no sun disc.
|
||||
*/
|
||||
export function skyRadiance(sunElevationDeg, viewElevationDeg, azimuthDeg) {
|
||||
const v = viewDirection(viewElevationDeg, azimuthDeg);
|
||||
const s = sunDirection(sunElevationDeg);
|
||||
const cosTheta = v[0] * s[0] + v[1] * s[1] + v[2] * s[2];
|
||||
const pr = rayleighPhase(cosTheta);
|
||||
const pm = miePhase(cosTheta);
|
||||
const ground = sphereHit(observer, v, PLANET_RADIUS_KM, true);
|
||||
const top = sphereHit(observer, v, PLANET_RADIUS_KM + ATMOSPHERE_HEIGHT_KM, false);
|
||||
const end = Math.min(ground, top);
|
||||
const steps = 24;
|
||||
const dt = end / steps;
|
||||
const along = [0, 0, 0];
|
||||
const radiance = [0, 0, 0];
|
||||
for (let k = 0; k < steps; k += 1) {
|
||||
const t = (k + 0.5) * dt;
|
||||
const q = [observer[0] + v[0] * t, observer[1] + v[1] * t, observer[2] + v[2] * t];
|
||||
const h = Math.hypot(...q) - PLANET_RADIUS_KM;
|
||||
const e = extinction(h);
|
||||
for (let c = 0; c < 3; c += 1) along[c] += e[c] * dt;
|
||||
const toSun = opticalDepthToSpace(q, s, 12);
|
||||
if (toSun) {
|
||||
const r = Math.exp(-h / H_RAYLEIGH);
|
||||
const m = Math.exp(-h / H_MIE);
|
||||
for (let c = 0; c < 3; c += 1) {
|
||||
const scatter = BETA_RAYLEIGH[c] * r * pr + BETA_MIE_SCA * m * pm;
|
||||
radiance[c] += Math.exp(-along[c] - toSun[c]) * scatter * dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
return radiance;
|
||||
}
|
||||
|
||||
const base64 = (halves) => {
|
||||
const bytes = new Uint8Array(halves.length * 2);
|
||||
halves.forEach((h, i) => {
|
||||
bytes[i * 2] = h & 0xff; // little-endian
|
||||
bytes[i * 2 + 1] = h >> 8;
|
||||
});
|
||||
let s = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(s);
|
||||
};
|
||||
|
||||
export function bakeScatter() {
|
||||
const view = Array.from({ length: VIEW_SAMPLES }, (_, i) => viewElevation(i));
|
||||
|
||||
const t = new Uint16Array(VIEW_SAMPLES * 3);
|
||||
view.forEach((e, i) => transmittance(e).forEach((v, c) => (t[i * 3 + c] = encode(v))));
|
||||
|
||||
// x = azimuth, y = view elevation, z = sun elevation, three half floats per texel: a 3D texture.
|
||||
const sky = new Uint16Array(SUN_ELEVATIONS_DEG.length * VIEW_SAMPLES * AZIMUTH_SAMPLES * 3);
|
||||
let at = 0;
|
||||
SUN_ELEVATIONS_DEG.forEach((sun) =>
|
||||
view.forEach((e) => {
|
||||
for (let a = 0; a < AZIMUTH_SAMPLES; a += 1) {
|
||||
skyRadiance(sun, e, a * AZIMUTH_STEP_DEG).forEach((v) => {
|
||||
sky[at] = encode(v);
|
||||
at += 1;
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
planet_radius_km: PLANET_RADIUS_KM,
|
||||
atmosphere_height_km: ATMOSPHERE_HEIGHT_KM,
|
||||
observer_altitude_km: OBSERVER_ALTITUDE_KM,
|
||||
// Extinction per km at the observer, per channel, for aerial perspective: exp(-k * distance_km).
|
||||
extinction_per_km: extinction(OBSERVER_ALTITUDE_KM),
|
||||
format: 'half-float',
|
||||
store_scale: STORE_SCALE,
|
||||
view_elevation_deg: { min: VIEW_MIN_DEG, max: VIEW_MAX_DEG, samples: VIEW_SAMPLES },
|
||||
transmittance: { width: VIEW_SAMPLES, height: 1, channels: 3, data: base64(t) },
|
||||
sky_view: {
|
||||
sun_elevations_deg: SUN_ELEVATIONS_DEG,
|
||||
azimuth_step_deg: AZIMUTH_STEP_DEG,
|
||||
width: AZIMUTH_SAMPLES,
|
||||
height: VIEW_SAMPLES,
|
||||
depth: SUN_ELEVATIONS_DEG.length,
|
||||
channels: 3,
|
||||
data: base64(sky),
|
||||
},
|
||||
};
|
||||
}
|
||||
160
src/sky/scatter.test.ts
Normal file
160
src/sky/scatter.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AZIMUTH_SAMPLES,
|
||||
AZIMUTH_STEP_DEG,
|
||||
STORE_SCALE,
|
||||
SUN_ELEVATIONS_DEG,
|
||||
VIEW_SAMPLES,
|
||||
bakeScatter,
|
||||
decode,
|
||||
fromHalf,
|
||||
skyRadiance,
|
||||
sunIndex,
|
||||
toHalf,
|
||||
transmittance,
|
||||
viewElevation,
|
||||
viewIndex,
|
||||
} from './scatter.js';
|
||||
import { TAU } from './sun.js';
|
||||
import { baked } from './baked';
|
||||
|
||||
const table = (b64: string): Uint16Array => {
|
||||
const bin = atob(b64);
|
||||
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
||||
return new Uint16Array(bytes.buffer);
|
||||
};
|
||||
// Checksums of the baked halves, so a change to the model or the encoding is a deliberate re-pin.
|
||||
const SKY_CHECKSUM = 622927107;
|
||||
const TRANS_CHECKSUM = 1498461058;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const scatter = baked.scatter as any;
|
||||
const sky = table(scatter.sky_view.data);
|
||||
const trans = table(scatter.transmittance.data);
|
||||
const sunIndex_ = (deg: number) => SUN_ELEVATIONS_DEG.indexOf(deg);
|
||||
// Texel of the sky table: x = azimuth, y = view elevation, z = sun elevation; value = three halves.
|
||||
const texel = (s: number, v: number, a: number) =>
|
||||
[0, 1, 2].map((c) => decode(sky[((s * VIEW_SAMPLES + v) * AZIMUTH_SAMPLES + a) * 3 + c]));
|
||||
|
||||
describe('half floats', () => {
|
||||
it('round-trip within half precision across the table range, ties to even', () => {
|
||||
[6.1e-5, 1e-4, 0.0123, 0.3, 1, 20, 1000].forEach((x) =>
|
||||
expect(Math.abs(fromHalf(toHalf(x)) - x) / x).toBeLessThan(5.1e-4)
|
||||
);
|
||||
expect(toHalf(0)).toBe(0);
|
||||
expect(toHalf(1)).toBe(0x3c00);
|
||||
expect(toHalf(2 ** -24)).toBe(1); // smallest subnormal
|
||||
expect(toHalf(1 + 2 ** -11)).toBe(0x3c00); // exact tie goes to the even neighbour
|
||||
expect(toHalf(1 + 3 * 2 ** -11)).toBe(0x3c02);
|
||||
expect(fromHalf(0x7bff)).toBe(65504);
|
||||
});
|
||||
|
||||
it('keeps twilight radiance, ~1e-4, within 0.1% thanks to the store scale', () => {
|
||||
const x = 1.234e-4;
|
||||
expect(Math.abs(decode(toHalf(x * STORE_SCALE)) - x) / x).toBeLessThan(1e-3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sunIndex', () => {
|
||||
it('is the fractional position of an elevation on the sun axis, clamped to the table', () => {
|
||||
expect(sunIndex(-18)).toBe(0);
|
||||
expect(sunIndex(90)).toBe(SUN_ELEVATIONS_DEG.length - 1);
|
||||
expect(sunIndex(-100)).toBe(0);
|
||||
expect(sunIndex(100)).toBe(SUN_ELEVATIONS_DEG.length - 1);
|
||||
expect(sunIndex(0)).toBe(sunIndex_(0));
|
||||
expect(sunIndex(1.5)).toBeCloseTo(sunIndex_(0) + 0.5, 12); // halfway between 0 and 3
|
||||
expect(sunIndex(35)).toBeCloseTo(sunIndex_(25) + 0.5, 12); // halfway between 25 and 45
|
||||
});
|
||||
});
|
||||
|
||||
describe('scattering tables', () => {
|
||||
it('are what scripts/bake-sky.mjs would write now (run npm run bake:sky if not)', () => {
|
||||
expect(JSON.parse(JSON.stringify(bakeScatter()))).toEqual(JSON.parse(JSON.stringify(scatter)));
|
||||
});
|
||||
|
||||
it('have the documented layout and size', () => {
|
||||
expect(scatter.format).toBe('half-float');
|
||||
expect(scatter.sky_view).toMatchObject({
|
||||
width: AZIMUTH_SAMPLES,
|
||||
height: VIEW_SAMPLES,
|
||||
depth: SUN_ELEVATIONS_DEG.length,
|
||||
channels: 3,
|
||||
azimuth_step_deg: AZIMUTH_STEP_DEG,
|
||||
});
|
||||
expect(sky.length).toBe(AZIMUTH_SAMPLES * VIEW_SAMPLES * SUN_ELEVATIONS_DEG.length * 3);
|
||||
expect(trans.length).toBe(VIEW_SAMPLES * 3);
|
||||
expect(sky.length * 2).toBe(16128); // bytes: the budget the README states
|
||||
});
|
||||
|
||||
it('pin the view-elevation axis and its inverse', () => {
|
||||
expect(viewElevation(0)).toBe(-4);
|
||||
expect(viewElevation(VIEW_SAMPLES - 1)).toBe(90);
|
||||
expect(viewElevation(6)).toBeCloseTo(-4 + 94 * (6 / 31) ** 2, 10);
|
||||
[0, 5, 6, 17, 31].forEach((i) => expect(viewIndex(viewElevation(i))).toBeCloseTo(i, 9));
|
||||
});
|
||||
|
||||
it('transmittance to space agrees with sun.js at the zenith and falls toward the horizon', () => {
|
||||
const top = VIEW_SAMPLES - 1;
|
||||
[0, 1, 2].forEach((c) =>
|
||||
expect(Math.abs(decode(trans[top * 3 + c]) / Math.exp(-TAU[c]) - 1)).toBeLessThan(0.01)
|
||||
);
|
||||
expect(transmittance(90).map((v, c) => v / Math.exp(-TAU[c]))).toSatisfy((r: number[]) =>
|
||||
r.every((x) => Math.abs(x - 1) < 0.01)
|
||||
);
|
||||
// blue goes first: lower view, lower blue, and redder light at the horizon
|
||||
let prev = Infinity;
|
||||
for (let i = top; i >= 8; i -= 1) {
|
||||
const blue = decode(trans[i * 3 + 2]);
|
||||
expect(blue).toBeLessThanOrEqual(prev);
|
||||
prev = blue;
|
||||
}
|
||||
expect(decode(trans[8 * 3]) / decode(trans[8 * 3 + 2])).toBeGreaterThan(10); // ~2 degrees up: sunset red
|
||||
expect(decode(trans[0])).toBe(0); // -4 degrees looks at the ground
|
||||
});
|
||||
|
||||
it('colour the daytime sky blue, and a low sun redder toward it than away from it', () => {
|
||||
const noon = texel(sunIndex_(45), VIEW_SAMPLES - 1, 0);
|
||||
expect(noon[2]).toBeGreaterThan(noon[1]);
|
||||
expect(noon[1]).toBeGreaterThan(noon[0]);
|
||||
const horizon = Math.round(viewIndex(5));
|
||||
const toward = texel(sunIndex_(3), horizon, 0);
|
||||
const away = texel(sunIndex_(3), horizon, AZIMUTH_SAMPLES - 1);
|
||||
expect(toward[0] / toward[2]).toBeGreaterThan(5);
|
||||
expect(toward[0] / toward[2]).toBeGreaterThan(2 * (away[0] / away[2]));
|
||||
expect(toward[0]).toBeGreaterThan(5 * away[0]); // the glow is around the sun
|
||||
});
|
||||
|
||||
it('fade through twilight to black below the planet-shadow limit', () => {
|
||||
const mid = Math.round(viewIndex(30));
|
||||
const lum = (s: number) => texel(sunIndex_(s), mid, 3).reduce((x, y) => x + y, 0);
|
||||
expect(lum(8)).toBeGreaterThan(lum(3));
|
||||
expect(lum(3)).toBeGreaterThan(lum(-2));
|
||||
expect(lum(-2)).toBeGreaterThan(lum(-5));
|
||||
expect(lum(-5)).toBeGreaterThan(0);
|
||||
expect(lum(-12)).toBe(0);
|
||||
expect(lum(-18)).toBe(0);
|
||||
});
|
||||
|
||||
it('hold the model to half-float precision, with no quantised steps along the horizon glow', () => {
|
||||
// Every table texel off a handful of sampled ones is the model's own value to < 0.2%.
|
||||
[
|
||||
[sunIndex_(3), 6, 0],
|
||||
[sunIndex_(3), 9, 2],
|
||||
[sunIndex_(0), 8, 0],
|
||||
[sunIndex_(45), 20, 3],
|
||||
[sunIndex_(-2), 12, 1],
|
||||
].forEach(([s, v, a]) => {
|
||||
const direct = skyRadiance(SUN_ELEVATIONS_DEG[s], viewElevation(v), a * AZIMUTH_STEP_DEG);
|
||||
texel(s, v, a).forEach((got, c) => expect(Math.abs(got / direct[c] - 1)).toBeLessThan(2e-3));
|
||||
});
|
||||
// Moving up from the horizon toward the sun, red drops smoothly: distinct, strictly decreasing.
|
||||
const s = sunIndex_(3);
|
||||
const reds = Array.from({ length: 8 }, (_, k) => texel(s, 7 + k, 0)[0]);
|
||||
reds.slice(1).forEach((r, k) => expect(r).toBeLessThan(reds[k]));
|
||||
expect(new Set(reds).size).toBe(reds.length);
|
||||
});
|
||||
|
||||
it('are the same bytes as when pinned', () => {
|
||||
const sum = (a: Uint16Array) => a.reduce((x, y) => (x * 31 + y) % 2147483647, 7);
|
||||
expect([sum(sky), sum(trans)]).toEqual([SKY_CHECKSUM, TRANS_CHECKSUM]);
|
||||
});
|
||||
});
|
||||
213
src/sky/skyTokens.js
Normal file
213
src/sky/skyTokens.js
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
// 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, 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
|
||||
// surface, rescaled to a per-role brightness `key` (the eye adapts to the scene; only the
|
||||
// light's colour survives, not its absolute level). Everything marked EYEBALLED is tuned by
|
||||
// looking and then held to WCAG AA by skyTokens.test.ts.
|
||||
|
||||
import { light } from './sun.js';
|
||||
|
||||
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
|
||||
const mix3 = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
|
||||
const luma = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
||||
|
||||
export const GRADIENT_STOPS = 7;
|
||||
export const AA = 4.5; // WCAG 2.x AA, normal text
|
||||
|
||||
// Where and when the theme is lit. Equinox, 45N on the prime meridian: noon sun at 45 degrees.
|
||||
export const MOMENTS = {
|
||||
noonday: { time: '2026-09-22T12:00:00Z', lat: 45, lon: 0 },
|
||||
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 = {
|
||||
soil: [0.1, 0.065, 0.04],
|
||||
bark: [0.075, 0.048, 0.03],
|
||||
barkPale: [0.6, 0.57, 0.5], // birch and lichen
|
||||
barkChar: [0.035, 0.026, 0.018],
|
||||
leaf: [0.06, 0.2, 0.05],
|
||||
leafPale: [0.3, 0.5, 0.16], // new growth
|
||||
clay: [0.36, 0.1, 0.07],
|
||||
ochre: [0.4, 0.28, 0.06],
|
||||
};
|
||||
|
||||
// Irradiance on an upward surface, unit-luminance: only the light's colour is kept.
|
||||
function chroma({ direct, diffuse, elevation }) {
|
||||
const up = Math.max(0, Math.sin((elevation * Math.PI) / 180));
|
||||
const e = diffuse.map((d, i) => d + direct[i] * up);
|
||||
const y = luma(e);
|
||||
return e.map((v) => v / y);
|
||||
}
|
||||
|
||||
// sRGB transfer, linear <-> encoded.
|
||||
const encode = (v) => (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055);
|
||||
const decode = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
||||
export const toHex = (lin) =>
|
||||
`#${
|
||||
lin
|
||||
.map((v) =>
|
||||
Math.round(clamp(encode(clamp(v, 0, 1)), 0, 1) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
)
|
||||
.join('')}`;
|
||||
export const fromHex = (hex) =>
|
||||
[1, 3, 5].map((i) => decode(parseInt(hex.slice(i, i + 2), 16) / 255));
|
||||
export const luminance = (hex) => luma(fromHex(hex));
|
||||
export const contrast = (a, b) => {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
|
||||
// Per-theme brightness keys (target luminance of an albedo-1 surface). EYEBALLED.
|
||||
const DARK = {
|
||||
skyKey: 0.06, // zenith of the baked gradient
|
||||
main: 0.85, // buttons and accents: lit pale albedos
|
||||
container: 0.06, // tinted panels: lit mid albedos
|
||||
text: 1.1,
|
||||
steps: { Hover: 1.18, Active: 1.36, Line: 1.7 },
|
||||
};
|
||||
const LIGHT = {
|
||||
skyKey: 0.55,
|
||||
main: 0.55,
|
||||
container: 1.0,
|
||||
text: 1.0,
|
||||
steps: { Hover: 0.93, Active: 0.86, Line: 0.76 },
|
||||
};
|
||||
|
||||
// Pigments for other people's names (--mx-uc-1..8): flower and berry albedos. EYEBALLED hues.
|
||||
const PIGMENTS = [
|
||||
[0.1, 0.25, 0.7],
|
||||
[0.45, 0.15, 0.6],
|
||||
[0.1, 0.5, 0.3],
|
||||
[0.7, 0.15, 0.3],
|
||||
[0.8, 0.35, 0.08],
|
||||
[0.1, 0.5, 0.55],
|
||||
[0.3, 0.3, 0.8],
|
||||
[0.45, 0.6, 0.12],
|
||||
];
|
||||
|
||||
export function bakeSky(name) {
|
||||
const dark = name === 'midnight';
|
||||
const T = dark ? DARK : LIGHT;
|
||||
const moment = MOMENTS[name];
|
||||
const l = light(moment);
|
||||
const c = chroma(l);
|
||||
const lit = (albedo, key) => albedo.map((a, i) => a * c[i] * key);
|
||||
const hex = (albedo, key) => toHex(lit(albedo, key));
|
||||
|
||||
// Sky gradient, top to bottom: zenith radiance ~ diffuse, horizon whitened by aerosol and
|
||||
// warmed by whatever direct light is left. EYEBALLED mix.
|
||||
const zenith = l.diffuse;
|
||||
const grey = luma(zenith);
|
||||
const horizon = zenith.map((z, i) => z * 0.6 + grey * 0.7 + l.direct[i] * 0.05);
|
||||
const scale = T.skyKey / luma(zenith);
|
||||
const gradient = Array.from({ length: GRADIENT_STOPS }, (_, k) => {
|
||||
const t = (k / (GRADIENT_STOPS - 1)) ** 1.6; // dwell on the upper sky
|
||||
return toHex(mix3(zenith, horizon, t).map((v) => v * scale));
|
||||
});
|
||||
|
||||
const stepped = (albedo, key) =>
|
||||
Object.fromEntries(Object.entries(T.steps).map(([n, s]) => [n, hex(albedo, key * s)]));
|
||||
const PALE = ALBEDO.barkPale;
|
||||
const tint = (a) => (dark ? a : mix3(a, PALE, 0.8)); // light containers are pale tints
|
||||
const text = hex(dark ? PALE : ALBEDO.barkChar, T.text);
|
||||
|
||||
const panel = (albedo, key) => ({
|
||||
Container: hex(albedo, key),
|
||||
...Object.fromEntries(
|
||||
Object.entries(stepped(albedo, key)).map(([n, v]) => [`Container${ n}`, v])
|
||||
),
|
||||
OnContainer: text,
|
||||
});
|
||||
const accent = (albedo) => {
|
||||
const main = dark ? mix3(albedo, PALE, 0.35) : albedo;
|
||||
// Label: whichever of a charred or a pale lit surface reads better. The button is then
|
||||
// pushed away from it (brighter on dark, darker on light) until the pair clears AA.
|
||||
const labels = [hex(ALBEDO.barkChar, 0.9), hex(PALE, 1.4)];
|
||||
let key = T.main;
|
||||
let mainHex = hex(main, key);
|
||||
const onMain = labels.sort((a, b) => contrast(mainHex, b) - contrast(mainHex, a))[0];
|
||||
for (let n = 0; n < 40 && contrast(mainHex, onMain) < AA; n += 1) {
|
||||
key *= dark ? 1.04 : 0.96;
|
||||
mainHex = hex(main, key);
|
||||
}
|
||||
return {
|
||||
Main: mainHex,
|
||||
...Object.fromEntries(Object.entries(stepped(main, key)).map(([n, v]) => [`Main${ n}`, v])),
|
||||
OnMain: onMain,
|
||||
Container: hex(tint(albedo), T.container),
|
||||
...Object.fromEntries(
|
||||
Object.entries(stepped(tint(albedo), T.container)).map(([n, v]) => [`Container${ n}`, v])
|
||||
),
|
||||
OnContainer: text,
|
||||
};
|
||||
};
|
||||
|
||||
// Depth reads as brightness: Background < Surface < SurfaceVariant on dark, the reverse on light.
|
||||
const depth = dark ? [0.05, 0.12, 0.2] : [0.85, 1.15, 1.0];
|
||||
const tokens = {
|
||||
Background: panel(dark ? ALBEDO.soil : PALE, depth[0]),
|
||||
Surface: panel(dark ? ALBEDO.bark : ALBEDO.barkPale, depth[1]),
|
||||
SurfaceVariant: panel(dark ? ALBEDO.bark : ALBEDO.barkPale, depth[2]),
|
||||
Primary: accent(dark ? ALBEDO.leafPale : ALBEDO.leaf),
|
||||
Secondary: accent(dark ? ALBEDO.barkPale : ALBEDO.bark),
|
||||
Success: accent(dark ? ALBEDO.leafPale : ALBEDO.leaf),
|
||||
Warning: accent(ALBEDO.ochre),
|
||||
Critical: accent(ALBEDO.clay),
|
||||
};
|
||||
|
||||
// Chat bubbles, same lighting: other people's on bark, your own on leaf.
|
||||
const bubbles = {
|
||||
Other: hex(dark ? ALBEDO.bark : mix3(ALBEDO.barkPale, ALBEDO.bark, 0.0), dark ? 0.06 : 0.82),
|
||||
OtherText: text,
|
||||
Own: hex(dark ? ALBEDO.leaf : mix3(ALBEDO.leafPale, PALE, 0.55), dark ? 0.1 : 0.9),
|
||||
OwnText: text,
|
||||
};
|
||||
|
||||
// Names must clear AA on every panel a message can sit on: pushed away from them until they do.
|
||||
const grounds = [
|
||||
tokens.Background.Container,
|
||||
tokens.Surface.Container,
|
||||
tokens.SurfaceVariant.Container,
|
||||
bubbles.Other,
|
||||
bubbles.Own,
|
||||
];
|
||||
const readable = (h) => grounds.every((g) => contrast(h, g) >= AA);
|
||||
const fit = (albedo) => {
|
||||
let key = 0.5;
|
||||
let h = hex(albedo, key);
|
||||
for (let n = 0; n < 80 && !readable(h); n += 1) {
|
||||
key *= dark ? 1.05 : 0.95;
|
||||
h = hex(albedo, key);
|
||||
}
|
||||
return h;
|
||||
};
|
||||
|
||||
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,
|
||||
userColors: PIGMENTS.map(fit),
|
||||
link: fit(dark ? ALBEDO.leafPale : ALBEDO.leaf),
|
||||
};
|
||||
}
|
||||
91
src/sky/skyTokens.test.ts
Normal file
91
src/sky/skyTokens.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { AA, AMBIENT_FLOOR, bakeSky, contrast } from './skyTokens.js';
|
||||
import { light } from './sun.js';
|
||||
import { baked } from './baked';
|
||||
|
||||
const names = ['midnight', 'noonday'] as const;
|
||||
const roles = ['Primary', 'Secondary', 'Success', 'Warning', 'Critical'] as const;
|
||||
const states = ['', 'Hover', 'Active'];
|
||||
|
||||
describe.each(names)('%s sky', (name) => {
|
||||
const sky = baked[name] as any;
|
||||
const t = sky.tokens;
|
||||
|
||||
it('is what scripts/bake-sky.mjs would write now (run npm run bake:sky if not)', () => {
|
||||
expect(JSON.parse(JSON.stringify(bakeSky(name)))).toEqual(JSON.parse(JSON.stringify(sky)));
|
||||
});
|
||||
|
||||
it('is lit by the sun on the side of the horizon its name says', () => {
|
||||
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) =>
|
||||
expect(contrast(t[p][`Container${ s}`], t[p].OnContainer)).toBeGreaterThanOrEqual(AA)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('clears AA for button labels and tinted-container text in every state', () => {
|
||||
roles.forEach((r) =>
|
||||
states.forEach((s) => {
|
||||
expect(contrast(t[r][`Main${ s}`], t[r].OnMain)).toBeGreaterThanOrEqual(AA);
|
||||
expect(contrast(t[r][`Container${ s}`], t[r].OnContainer)).toBeGreaterThanOrEqual(AA);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('clears AA for message text in both bubbles, links and names', () => {
|
||||
expect(contrast(sky.bubbles.Other, sky.bubbles.OtherText)).toBeGreaterThanOrEqual(AA);
|
||||
expect(contrast(sky.bubbles.Own, sky.bubbles.OwnText)).toBeGreaterThanOrEqual(AA);
|
||||
const grounds = [
|
||||
t.Background.Container,
|
||||
t.Surface.Container,
|
||||
t.SurfaceVariant.Container,
|
||||
sky.bubbles.Other,
|
||||
sky.bubbles.Own,
|
||||
];
|
||||
grounds.forEach((g) => {
|
||||
expect(contrast(sky.link, g)).toBeGreaterThanOrEqual(AA);
|
||||
sky.userColors.forEach((c: string) => expect(contrast(c, g)).toBeGreaterThanOrEqual(AA));
|
||||
});
|
||||
});
|
||||
|
||||
it('clears AA for body text straight over the fallback sky gradient', () => {
|
||||
sky.gradient.forEach((g: string) =>
|
||||
expect(contrast(t.Surface.OnContainer, g)).toBeGreaterThanOrEqual(AA)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,13 +42,22 @@ 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).
|
||||
const TAU = [0.06, 0.12, 0.26];
|
||||
export 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));
|
||||
|
|
@ -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