scene: shape each sky state's drawn sky into a luminance band its text ink clears #54

Merged
void merged 4 commits from nibu/sky-contrast into dev 2026-11-10 00:28:59 +00:00 AGit
22 changed files with 914 additions and 377 deletions

View file

@ -1,8 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { atStandardTime } from './standardTime.testkit';
import { baked } from '../../../sky/baked';
import type { Scene } from './Scene';
import { startBackgroundScene } from './index';
import { setSkyState } from './skyState';
import { setSkyClock, setSkyState } from './skyState';
const { createRidgeScene, createScene } = vi.hoisted(() => ({
createRidgeScene: vi.fn(),
@ -23,21 +24,35 @@ const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2
beforeEach(() => {
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined });
// Local midnight: 'auto' resolves to night.
setSkyClock(() => atStandardTime(0, 0));
setSkyState('auto');
createRidgeScene.mockReset();
createScene.mockReset();
});
afterEach(() => {
setSkyClock();
setSkyState('auto');
vi.unstubAllGlobals();
});
describe('startBackgroundScene follows the sky state', () => {
it('hands the ridge scene no sky for auto, as before there was a state', async () => {
it('hands the ridge scene the sky auto resolves to (night at midnight)', async () => {
createRidgeScene.mockReturnValue(fakeScene());
const scene = await startBackgroundScene(config, caps, { canvas });
expect(createRidgeScene).toHaveBeenCalledOnce();
expect(createRidgeScene.mock.calls[0][0].sky).toBeUndefined();
expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]);
scene?.dispose();
});
it('rebuilds when auto is replaced by the explicit state it already resolves to (a URL may steer only auto)', async () => {
const [a, b] = [fakeScene(), fakeScene()];
createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b);
const scene = await startBackgroundScene(config, caps, { canvas });
setSkyState('night');
await flush();
expect(a.dispose).toHaveBeenCalledOnce();
expect(createRidgeScene).toHaveBeenCalledTimes(2);
scene?.dispose();
});

View file

@ -1,6 +1,6 @@
import type { Scene, SceneOptions } from './Scene';
import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier';
import { getSkyState, subscribe } from './skyState';
import { getResolvedSkyState, getSkyState, subscribe } from './skyState';
import type { SkyState } from './skyState';
export type { Scene, SceneStats } from './Scene';
@ -52,7 +52,7 @@ async function create(
* "keep the CSS ground", including when that chunk fails to load (offline, stale deploy): the
* failure goes to onCompileFail and nothing throws.
*
* The ridge and terrain scenes bake their sky once, so they follow the sky state (`skyState.ts`) by
* The ridge and terrain scenes bake their sky once, so they follow the sky (`skyState.ts`: the choice, resolved, so 'auto' follows the clock) by
* being rebuilt on the same canvas when it changes: the door subscribes on start and the returned
* scene's `dispose` unsubscribes. The pull-back restarts from the tree; a later `login()` is
* replayed on the new scene. The ray-march scene has one fixed dusk and ignores the state.
@ -66,18 +66,23 @@ export async function startBackgroundScene(
if (tier === 'off') return null;
const build = (state: SkyState) => create(config, { ...options, tier }, state);
const state = getSkyState();
const key0 = `${state}:${getResolvedSkyState()}`;
const first = await build(state);
if (!first || config?.style === 'raymarch') return first;
let inner: Scene | null = first;
let built = state;
let built = key0;
let loggedIn = false;
let disposed = false;
let generation = 0;
const rebuild = async (next: SkyState) => {
// A rebuild is for a new choice or a new resolved sky ('auto' crossing a boundary), not for a repeat of either.
const rebuild = async () => {
const next = getSkyState();
const key = `${next}:${getResolvedSkyState()}`;
if (key === built) return;
generation += 1;
const mine = generation;
built = next;
built = key;
inner?.dispose();
inner = null;
const scene = await build(next);
@ -94,7 +99,7 @@ export async function startBackgroundScene(
};
const off = subscribe(rebuild);
// A change that landed while the first build was awaited was missed by the subscription.
if (getSkyState() !== built) rebuild(getSkyState());
rebuild();
return {
login() {
loggedIn = true;

View file

@ -1,7 +1,9 @@
// The one place the sky's colours come from. Void's `src/sky/sun.js` (`light({ time, lat, lon,
// cloud_cover, precipitation })`, PR #20) is the intended source; until it lands this returns a
// fixed dusk drawn from GameCult's nebula inputs (see ../palette.ts). Replace the body, keep the
// signature: the scene calls it once, at startup, and bakes the result.
// fixed twilight: lavender zenith, rose mid-sky, orange horizon. Its darkest blend stays above the dusk
// floor of ../skyBand.ts (the dark ink of Noonday Sky needs a backdrop of at least 0.2983 luminance), which
// skyForState.test.ts sweeps. Replace the body, keep the signature: the scene calls it once, at startup,
// and bakes the result.
export type Srgb = readonly [number, number, number];
@ -20,9 +22,9 @@ export interface SkyColours {
export function skyColours(_now: Date = new Date()): SkyColours {
return {
top: [24, 30, 78],
mid: [78, 66, 150],
horizon: [226, 130, 74],
top: [170, 145, 220],
mid: [225, 130, 165],
horizon: [240, 150, 80],
ground: [7, 17, 26],
sunAzimuth: 0.3,
};

View file

@ -41,9 +41,10 @@ describe('star placement', () => {
expect(makeStars(skyColours(), 390, 844)).toEqual(makeStars(skyColours(), 390, 844));
});
it('draws none in a day sky, and the dusk default is dark enough for some', () => {
it('draws none in a day sky or the lavender dusk, and some in a night one', () => {
expect(makeStars(DAY, 390, 844)).toHaveLength(0);
expect(makeStars(skyColours(), 390, 844).length).toBeGreaterThan(50);
expect(makeStars(skyColours(), 390, 844)).toHaveLength(0);
expect(makeStars(NIGHT, 390, 844).length).toBeGreaterThan(50);
});
it('is a few hundred at most: bounded by MAX_STARS and by the viewport', () => {

View file

@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { displayedLuminance, hexToLinear, luma, shapeToBand, SKY_BAND } from './skyBand';
import type { Linear } from './skyBand';
import { GRID_FRAG, SHAPE_SKY_GLSL, SKY_FRAG } from './terrain/terrainShaders';
const near = (a: number, b: number) => Math.abs(a - b) < 1e-9;
describe('shapeToBand', () => {
const dark: Linear = [0.02, 0.05, 0.2]; // a deep blue
const { day } = SKY_BAND;
it('lifts a sky below the floor onto the floor, mixing toward the tint so the hue is kept', () => {
const c = shapeToBand(dark, day);
expect(near(luma(c), day.floor)).toBe(true);
// Between the sky and the tint on every channel: a mix, not a grey clamp.
[0, 1, 2].forEach((i) => {
expect(c[i]).toBeGreaterThanOrEqual(Math.min(dark[i], day.lift[i]));
expect(c[i]).toBeLessThanOrEqual(Math.max(dark[i], day.lift[i]));
});
expect(c[2]).toBeGreaterThan(c[0]); // still blue
});
it('lifts black to the tint scaled in, not to grey', () => {
const c = shapeToBand([0, 0, 0], SKY_BAND.dusk);
expect(near(luma(c), SKY_BAND.dusk.floor)).toBe(true);
expect(c[0]).toBeGreaterThan(c[2]); // the dusk tint is rose
});
it('leaves a sky already in the band as it is, and is continuous at the floor', () => {
const ok: Linear = [0.5, 0.6, 0.7];
expect(shapeToBand(ok, day)).toEqual(ok);
const lo = shapeToBand([0.31, 0.31, 0.31], day);
const below = shapeToBand([0.3099, 0.3099, 0.3099], day);
expect(Math.abs(lo[0] - below[0])).toBeLessThan(1e-3);
});
it('scales a sky above the ceiling down to it, keeping its hue', () => {
const c = shapeToBand([0.9, 0.8, 1], SKY_BAND.night);
expect(near(luma(c), SKY_BAND.night.ceiling)).toBe(true);
expect(near(c[0] / c[2], 0.9)).toBe(true);
});
it('clamps to 0..1 as the screen does, and never leaves the band when shaped twice', () => {
expect(shapeToBand([4, 4, 4], day)).toEqual([1, 1, 1]);
const once = shapeToBand(dark, day);
shapeToBand(once, day).forEach((v, i) => expect(v).toBeCloseTo(once[i], 12));
});
});
describe('the bands', () => {
it('have a tint above the floor to lift toward', () => {
Object.values(SKY_BAND).forEach((b) => expect(luma(b.lift)).toBeGreaterThan(b.floor));
});
it('keep the floor above the 0.2983 text bound by what gamma 2.2, 8-bit rounding and dither can take', () => {
const grey = shapeToBand([0, 0, 0], SKY_BAND.day);
const shown = displayedLuminance(grey, 'low');
expect(shown).toBeGreaterThanOrEqual(0.2983);
expect(shown).toBeLessThan(SKY_BAND.day.floor);
});
it('decode hex as linear light', () => {
expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]);
});
});
describe('the GLSL twin', () => {
it('shapes with the same arithmetic: clamp, luma weights, mix to the floor, scale to the ceiling', () => {
expect(SHAPE_SKY_GLSL).toContain('c = clamp(c, 0.0, 1.0);');
expect(SHAPE_SKY_GLSL).toContain('const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);');
expect(SHAPE_SKY_GLSL).toContain(
'if (l < uBand.x) return mix(c, uLift, (uBand.x - l) / (dot(uLift, LUMA) - l));'
);
expect(SHAPE_SKY_GLSL).toContain('if (l > uBand.y) return c * (uBand.y / l);');
});
it('is applied to the sky colour in both shaders that draw it, the sky pass and the haze that fades into it', () => {
[SKY_FRAG, GRID_FRAG].forEach((src) => {
expect(src).toContain(SHAPE_SKY_GLSL);
expect(src).toMatch(/return shapeSky\(1\.0 - exp\(-45\.0 \* skyScatter\(d\)\)/);
});
});
});

View file

@ -0,0 +1,83 @@
// The luminance band each fixed sky state's drawn sky is shaped into, so the state's theme ink
// clears 4.5:1 on bare sky (WCAG relative luminance, the `luminance` of src/sky/skyTokens.js).
//
// day, dusk Noonday Sky's dark text ink #342d25 (L 0.0274) needs a backdrop of at least 0.2983.
// The floor is that text bound (0.30 as drawn); there is no ceiling, dark ink on light is fine.
// night Midnight Sky's light text ink #aeccff (L 0.594) needs a backdrop of at most 0.0931.
// The baked night sky (0.058 to 0.077) is kept; the ceiling is the bound.
//
// Link ink is not part of the band: see skyTheme.ts.
//
// The terrain sky is a physical scatter function, so it is shaped downstream, on its final colour:
// `shapeToBand` here, and its GLSL twin (`SHAPE_SKY_GLSL`) in terrain/terrainShaders.ts, built from the
// same constants. The ridge sky is a three-stop gradient whose stops are chosen inside the band
// (ridge/skyColour.ts, skyForState.ts), and its test sweeps the blends.
export type FixedSkyState = 'day' | 'dusk' | 'night';
export type Linear = readonly [number, number, number];
/** WCAG luma weights, applied to linear RGB. */
export const LUMA: Linear = [0.2126, 0.7152, 0.0722];
export const luma = (c: Linear): number => LUMA[0] * c[0] + LUMA[1] * c[1] + LUMA[2] * c[2];
/** sRGB hex `#rrggbb` to linear RGB 0..1. */
export function hexToLinear(hex: string): Linear {
const ch = (at: number) => {
const s = parseInt(hex.slice(at, at + 2), 16) / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return [ch(1), ch(3), ch(5)];
}
/** How a displayed colour is read: as encoded, or moved one 8-bit step down or up in every channel ('low', 'high'). */
export type Reading = 'exact' | 'low' | 'high';
/**
* Relative luminance of what the terrain shaders put on screen for a linear colour: they encode with
* pow(1/2.2), not the sRGB curve, so the screen colour is read back through the sRGB curve. 'low' and 'high' move every
* encoded channel one 8-bit step down or up first: rounding to 8 bits (half a step) plus the sky pass's dither (half a
* step) cannot move a channel further, so they bound what the screen shows. A smooth bound, so a search can climb it.
*/
export function displayedLuminance(c: Linear, reading: Reading = 'exact'): number {
const shift = { exact: 0, low: -1 / 255, high: 1 / 255 }[reading];
const ch = (v: number) => {
const e = Math.min(1, Math.max(0, Math.min(1, Math.max(0, v)) ** (1 / 2.2) + shift));
return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4;
};
return luma([ch(c[0]), ch(c[1]), ch(c[2])]);
}
export interface SkyBand {
/** Linear luma the shaped colour is at least (0: no floor). */
floor: number;
/** Linear luma the shaped colour is at most (1: no ceiling). */
ceiling: number;
/** The colour a too-dark sky is mixed toward: the state's own tint, so the hue survives the lift. Its luma is above `floor`. */
lift: Linear;
}
/**
* The shaping targets, in linear luma. The floor sits 0.019 above the text bound (0.2983): the screen encoding is
* gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level, and 8-bit rounding with the sky
* pass's dither at its worst (one step in every channel) takes about 0.007 more.
*/
export const SKY_BAND: Record<FixedSkyState, SkyBand> = {
day: { floor: 0.317, ceiling: 1, lift: hexToLinear('#e2f4ff') },
dusk: { floor: 0.317, ceiling: 1, lift: hexToLinear('#eba0b9') },
night: { floor: 0, ceiling: 0.085, lift: hexToLinear('#5a6a9a') },
};
/**
* Shape a linear sky colour into the band. Below the floor it mixes toward `lift` by exactly the amount
* that lands on the floor (continuous at the floor, hue kept); above the ceiling it scales down to the
* ceiling. The colour is clamped to 0..1 first, as the screen does.
*/
export function shapeToBand(c: Linear, band: SkyBand): Linear {
const k = [0, 1, 2].map((i) => Math.min(1, Math.max(0, c[i]))) as unknown as Linear;
const l = luma(k);
if (l < band.floor) {
const t = (band.floor - l) / (luma(band.lift) - l);
return [0, 1, 2].map((i) => k[i] + (band.lift[i] - k[i]) * t) as unknown as Linear;
}
if (l > band.ceiling) return [0, 1, 2].map((i) => (k[i] * band.ceiling) / l) as unknown as Linear;
return k;
}

View file

@ -1,151 +1,249 @@
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { atStandardTime } from './standardTime.testkit';
import { baked } from '../../../sky/baked';
import { luminance } from '../../../sky/skyTokens.js';
import { AA, luminance } from '../../../sky/skyTokens.js';
import { skyColours } from './ridge/skyColour';
import { chooseSky, skyColour } from './terrain/terrainSky';
import {
DUSK_SUN_ELEVATION,
TERRAIN_REACH_DEG,
ridgeDrawn,
ridgeSkyFor,
terrainDrawn,
terrainSkyFor,
} from './skyForState';
import { skyBackdrop } from './skyState';
import { displayedLuminance, SKY_BAND } from './skyBand';
import type { FixedSkyState } from './skyBand';
import { setSkyClock, skyBackdrop } from './skyState';
import { view } from './terrain/terrainWorld';
import { skyThemeId } from './skyTheme';
const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
const EPS = 1e-9;
const STATES = ['day', 'dusk', 'night'] as const;
const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const;
/**
* The band each state's text ink needs, from the ink itself: dark ink needs a backdrop of at least
* AA * (L + 0.05) - 0.05, light ink at most (L + 0.05) / AA - 0.05. Day 0.2983, dusk 0.2983, night 0.0931.
*/
const needs = (state: FixedSkyState) => {
const l = luminance(SKY[skyThemeId(state)].tokens.Background.OnContainer);
return state === 'night'
? { floor: 0, ceiling: (l + 0.05) / AA - 0.05 }
: { floor: AA * (l + 0.05) - 0.05, ceiling: 1 };
};
let search = '';
beforeEach(() => {
search = '';
vi.stubGlobal('window', {
get location() {
return { search };
},
});
});
afterEach(() => {
setSkyClock();
vi.unstubAllGlobals();
});
describe('the bands the text ink needs', () => {
it('are the numbers the shaping was set from', () => {
expect(needs('day').floor).toBeCloseTo(0.2983, 3);
expect(needs('dusk').floor).toBeCloseTo(0.2983, 3);
expect(needs('night').ceiling).toBeCloseTo(0.0931, 3);
});
});
describe('ridgeSkyFor', () => {
it('leaves auto to the scene, so it draws what it drew before there was a state', () => {
expect(ridgeSkyFor('auto')).toBeUndefined();
it('draws the gradient of the sky auto resolves to, never nothing', () => {
setSkyClock(() => atStandardTime(12, 0));
expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day'));
setSkyClock(() => atStandardTime(0, 0));
expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('night'));
setSkyClock(() => atStandardTime(18, 10));
expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('dusk'));
});
it('draws the current ridge dusk for dusk', () => {
expect(ridgeSkyFor('dusk')).toEqual(skyColours());
it('draws a lavender, rose and orange twilight for dusk', () => {
const d = ridgeSkyFor('dusk');
expect(d).toEqual(skyColours());
const [t, m, h] = [d.top, d.mid, d.horizon];
expect(t[2]).toBeGreaterThan(t[1]); // lavender: blue over green
expect(t[0]).toBeGreaterThan(t[1]);
expect(m[0]).toBeGreaterThan(m[2]); // rose: red over blue
expect(m[2]).toBeGreaterThan(m[1]);
expect(h[0]).toBeGreaterThan(h[1]); // orange: red over green over blue
expect(h[1]).toBeGreaterThan(h[2]);
});
it('draws the baked gradients for day and night', () => {
const day = ridgeSkyFor('day');
const night = ridgeSkyFor('night');
expect(hex(day?.top ?? [])).toBe(baked.noonday.gradient[0]);
expect(hex(day?.horizon ?? [])).toBe(baked.noonday.gradient[6]);
expect(hex(night?.top ?? [])).toBe(baked.midnight.gradient[0]);
expect(hex(night?.horizon ?? [])).toBe(baked.midnight.gradient[6]);
expect(hex(day.top)).toBe(baked.noonday.gradient[0]);
expect(hex(day.horizon)).toBe(baked.noonday.gradient[6]);
expect(hex(night.top)).toBe(baked.midnight.gradient[0]);
expect(hex(night.horizon)).toBe(baked.midnight.gradient[6]);
});
it.each(['day', 'dusk', 'night'] as const)(
'paints a %s sky whose colours lie inside skyBackdrop',
(state) => {
const sky = ridgeSkyFor(state);
const { darkest, lightest } = skyBackdrop(state);
[sky?.top, sky?.mid, sky?.horizon].forEach((c) => {
const l = luminance(hex(c ?? []));
expect(l).toBeGreaterThanOrEqual(darkest - EPS);
expect(l).toBeLessThanOrEqual(lightest + EPS);
});
it.each(STATES)('%s: every stop and blend lies in the band its text ink needs', (state) => {
const { floor, ceiling } = needs(state);
const r = ridgeDrawn(state);
expect(r.darkest).toBeGreaterThanOrEqual(floor);
expect(r.lightest).toBeLessThanOrEqual(ceiling);
});
});
// Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns)
// plus a 0.05 degree grid across the sun, five times finer than the generator's.
const dirAt = (el: number, az: number): [number, number, number] => {
const rad = Math.PI / 180;
return [
Math.cos(el * rad) * Math.sin(az * rad),
Math.sin(el * rad),
-Math.cos(el * rad) * Math.cos(az * rad),
];
};
/** Over the sweep: the darkest the 8-bit screen can show (dither at its worst), the lightest, and the darkest as encoded. */
const sweepOf = (sky: ReturnType<typeof chooseSky>, N = 4000, maxEl = TERRAIN_REACH_DEG) => {
const rad = Math.PI / 180;
const out = { low: Infinity, high: -Infinity, exactMin: Infinity };
const see = (el: number, az: number) => {
const c = skyColour(sky, dirAt(el, az));
out.low = Math.min(out.low, displayedLuminance(c, 'low'));
out.high = Math.max(out.high, displayedLuminance(c, 'high'));
out.exactMin = Math.min(out.exactMin, displayedLuminance(c));
};
for (let i = 0; i < N; i += 1) {
const y = ((i + 0.5) / N) * Math.sin(maxEl * rad); // sin(elevation): uniform from the horizon to maxEl
see(Math.asin(y) / rad, (i * 137.50776405) % 360);
}
for (let dEl = -3; dEl <= 3; dEl += 0.05) {
for (let dAz = -3; dAz <= 3; dAz += 0.05) {
see(Math.min(maxEl, Math.max(0, sky.elevation + dEl)), sky.azimuth + dAz);
}
);
}
return out;
};
describe('the terrain camera reach', () => {
it('covers the highest ray the frozen camera sees, at any aspect, with a small margin', () => {
const tanHalf = Math.tan((55 * Math.PI) / 360);
const tops = [0.4, 0.5, 1, 16 / 9, 2.4].flatMap((aspect) =>
[0, 0.125, 0.25, 0.5, 0.75].flatMap((turns) => {
const v = view(aspect, turns);
return [-1, 0, 1].map((nx) => {
const d = [0, 1, 2].map(
(i) => v.forward[i] + nx * tanHalf * aspect * v.right[i] + tanHalf * v.up[i]
);
return (Math.asin(d[1] / Math.hypot(d[0], d[1], d[2])) * 180) / Math.PI;
});
})
);
const top = Math.max(...tops);
expect(top).toBeCloseTo(14.5, 1);
expect(TERRAIN_REACH_DEG).toBeGreaterThanOrEqual(top);
expect(TERRAIN_REACH_DEG - top).toBeLessThan(2);
});
});
describe('terrainSkyFor', () => {
it('leaves auto to the clock and the URL', () => {
expect(terrainSkyFor('auto')).toEqual({});
it('draws the sky auto resolves to, with that state band', () => {
setSkyClock(() => atStandardTime(12, 0));
expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('day'));
setSkyClock(() => atStandardTime(0, 0));
expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night'));
});
it('names the baked sky for day and night, and a low sun for dusk', () => {
expect(terrainSkyFor('day')).toEqual({ sky: 'noonday' });
expect(terrainSkyFor('night')).toEqual({ sky: 'midnight' });
expect(terrainSkyFor('dusk')).toEqual({ sun: { elevation: DUSK_SUN_ELEVATION } });
it('lets the page URL steer auto, shaped into the band of the state the clock resolves to', () => {
setSkyClock(() => atStandardTime(0, 0)); // night
search = '?sun=30&az=120';
expect(terrainSkyFor('auto')).toEqual({
sun: { elevation: 30, azimuth: 120 },
band: SKY_BAND.night,
});
search = '?sky=noonday';
expect(terrainSkyFor('auto')).toEqual({ sky: 'noonday', band: SKY_BAND.night });
search = '?sky=garbage';
expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night'));
setSkyClock(() => atStandardTime(18, 10)); // dusk, URL azimuth honoured
search = '?sun=2&az=250';
expect(terrainSkyFor('auto').sun).toEqual({ elevation: 2, azimuth: 250 });
});
it.each([
['day', 'noonday'],
['night', 'midnight'],
] as const)('lights %s from a gradient inside skyBackdrop', (state, name) => {
const { darkest, lightest } = skyBackdrop(state);
expect(terrainSkyFor(state).sky).toBe(name);
baked[name].gradient.forEach((c) => {
expect(luminance(c)).toBeGreaterThanOrEqual(darkest - EPS);
expect(luminance(c)).toBeLessThanOrEqual(lightest + EPS);
it('keeps a URL-chosen sun inside the band of the resolved state', () => {
setSkyClock(() => atStandardTime(12, 0)); // day: floor
search = '?sun=-30';
const day = sweepOf(chooseSky(terrainSkyFor('auto')));
expect(day.low).toBeGreaterThanOrEqual(needs('day').floor);
setSkyClock(() => atStandardTime(0, 0)); // night: ceiling
search = '?sun=60';
const night = sweepOf(chooseSky(terrainSkyFor('auto')));
expect(night.high).toBeLessThanOrEqual(needs('night').ceiling);
});
it('lets an explicit state beat the URL, as before auto had one', () => {
search = '?sun=30&az=120&sky=midnight';
expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day });
expect(terrainSkyFor('dusk').sun).toEqual({ elevation: DUSK_SUN_ELEVATION });
});
it('names the baked sky for day and night, and a low sun for dusk, each with its band', () => {
expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day });
expect(terrainSkyFor('night')).toEqual({ sky: 'midnight', band: SKY_BAND.night });
expect(terrainSkyFor('dusk')).toEqual({
sun: { elevation: DUSK_SUN_ELEVATION },
band: SKY_BAND.dusk,
});
});
});
describe('the terrain sky lies inside skyBackdrop', () => {
// Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns)
// plus a 0.05 degree grid across the sun, five times finer than the generator's.
const luma = (c: readonly number[]) => {
const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v)));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const sweep = (state: 'day' | 'dusk' | 'night') => {
const sky = chooseSky(terrainSkyFor(state));
const rad = Math.PI / 180;
const dirAt = (el: number, az: number): [number, number, number] => [
Math.cos(el * rad) * Math.sin(az * rad),
Math.sin(el * rad),
-Math.cos(el * rad) * Math.cos(az * rad),
];
const out: number[] = [];
const N = 20000;
for (let i = 0; i < N; i += 1) {
const y = (i + 0.5) / N; // sin(elevation): uniform over the upper hemisphere
out.push(luma(skyColour(sky, dirAt(Math.asin(y) / rad, (i * 137.50776405) % 360))));
}
for (let dEl = -3; dEl <= 3; dEl += 0.05) {
for (let dAz = -3; dAz <= 3; dAz += 0.05) {
out.push(luma(skyColour(sky, dirAt(Math.max(0, sky.elevation + dEl), sky.azimuth + dAz))));
}
}
return out;
};
describe('the terrain sky lies in the band its text ink needs', () => {
const sweep = (state: FixedSkyState) => sweepOf(chooseSky(terrainSkyFor(state)));
it.each(['day', 'dusk', 'night'] as const)('for %s, and no looser than 5e-4 from it', (state) => {
const { darkest, lightest } = skyBackdrop(state);
it('dusk needs its lift: the scatter sky alone dips below the text bound within the reach', () => {
const { sun } = terrainSkyFor('dusk');
expect(sweepOf(chooseSky({ sun })).exactMin).toBeLessThan(needs('dusk').floor - 0.02);
expect(sweep('dusk').low).toBeGreaterThanOrEqual(needs('dusk').floor);
});
it.each(STATES)('%s', (state) => {
const { floor, ceiling } = needs(state);
const l = sweep(state);
expect(Math.min(...l)).toBeGreaterThanOrEqual(darkest - EPS);
expect(Math.max(...l)).toBeLessThanOrEqual(lightest + EPS);
const top = Math.max(Math.max(...l), ridgeDrawn(state)?.lightest ?? 0);
expect(lightest - top).toBeLessThan(5e-4);
const bottom = Math.min(Math.min(...l), ridgeDrawn(state)?.darkest ?? 1);
expect(bottom - darkest).toBeLessThan(5e-4);
expect(l.low).toBeGreaterThanOrEqual(floor);
expect(l.high).toBeLessThanOrEqual(ceiling);
});
it('and the terrain dusk reaches the sun glow the ridge dusk does not', () => {
it.each(STATES)('%s holds for a sun at any elevation, as ?sun= can put it', (state) => {
const { floor, ceiling } = needs(state);
for (let e = -90; e <= 90; e += 10) {
const l = sweepOf(chooseSky({ sun: { elevation: e }, band: SKY_BAND[state] }), 4000, 90);
expect(l.low).toBeGreaterThanOrEqual(floor);
expect(l.high).toBeLessThanOrEqual(ceiling);
}
});
it('and the terrain dusk still reaches the sun glow the ridge dusk does not', () => {
expect(skyBackdrop('dusk').lightest).toBeGreaterThan(0.9);
expect(skyBackdrop('dusk').lightest).toBeGreaterThan(Math.max(...sweep('night')));
});
});
describe('both scenes of a state lie inside skyBackdrop', () => {
it.each(['day', 'dusk', 'night'] as const)('%s', (state) => {
const b = skyBackdrop(state);
const r = ridgeDrawn(state);
const t = terrainDrawn(state);
[r, t].forEach((x) => {
expect(x?.darkest).toBeGreaterThanOrEqual(b.darkest - EPS);
expect(x?.lightest).toBeLessThanOrEqual(b.lightest + EPS);
});
});
});
describe('skyBackdrop holds the union of what the scenes draw', () => {
// An independent extremum: not terrainDrawn's grid and compass search but alternating golden-section
// line searches in elevation and azimuth from 60 starts (a golden-angle spiral and scatter about the sun),
// the bracket shrinking to 1e-3 degrees. A plateau (the shader clamps) cannot stall it: each line search
// always moves to the better end of its bracket.
const extremes = (state: 'day' | 'dusk' | 'night') => {
// An independent extremum of the screen reading ('low' for the darkest, 'high' for the lightest: one 8-bit step each way) over the terrain camera's reach: not terrainDrawn's grid and compass search but alternating golden-section
// line searches in elevation and azimuth from 60 starts (a golden-angle spiral and scatter about the sun), the bracket
// shrinking to 1e-3 degrees. A plateau (the shader clamps) cannot stall it: each line search always
// moves to the better end of its bracket.
const extremes = (state: FixedSkyState) => {
const sky = chooseSky(terrainSkyFor(state));
const rad = Math.PI / 180;
const f = (el: number, az: number) => {
const e = Math.min(90, Math.max(0, el)) * rad;
const f = (el: number, az: number, reading: 'low' | 'high') => {
const e = Math.min(TERRAIN_REACH_DEG, Math.max(0, el)) * rad;
const a = az * rad;
const [r, g, b] = skyColour(sky, [
Math.cos(e) * Math.sin(a),
Math.sin(e),
-Math.cos(e) * Math.cos(a),
]).map((v) => Math.min(1, Math.max(0, v)));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
return displayedLuminance(
skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]),
reading
);
};
const P = 0.6180339887;
const line = (g: (x: number) => number, mid: number, w: number) => {
@ -159,45 +257,42 @@ describe('skyBackdrop holds the union of what the scenes draw', () => {
}
return (a + b) / 2;
};
const search = (sign: 1 | -1) => {
const hunt = (sign: 1 | -1) => {
const reading = sign === 1 ? 'high' : 'low';
let best = -Infinity;
for (let i = 0; i < 60; i += 1) {
let el =
i % 2
? sky.elevation + (((i * 7919) % 1600) - 800) / 100
: Math.asin((i + 0.5) / 60) / rad;
: Math.asin(((i + 0.5) / 60) * Math.sin(TERRAIN_REACH_DEG * rad)) / rad;
let az =
i % 2 ? sky.azimuth + (((i * 104729) % 1600) - 800) / 100 : (i * 137.50776405) % 360;
for (let w = 8; w > 1e-3; w /= 2) {
const [el0, az0] = [el, az];
el = line((x) => sign * f(Math.max(0, x), az0), el0, w);
el = line((x) => sign * f(Math.max(0, x), az0, reading), el0, w);
const el1 = el;
az = line((x) => sign * f(el1, x), az0, w);
best = Math.max(best, sign * f(el, az));
az = line((x) => sign * f(el1, x, reading), az0, w);
best = Math.max(best, sign * f(el, az, reading));
}
}
return sign * best;
};
return { darkest: search(-1), lightest: search(1) };
return { darkest: hunt(-1), lightest: hunt(1) };
};
const computed = (state: 'day' | 'dusk' | 'night') => {
const r = [ridgeDrawn(state), terrainDrawn(state)].filter((x) => x !== undefined) as {
darkest: number;
lightest: number;
}[];
const computed = (state: FixedSkyState) => {
const r = [ridgeDrawn(state), terrainDrawn(state)];
const ind = extremes(state);
const ridge = ridgeDrawn(state);
return {
darkest: Math.min(...r.map((x) => x.darkest), ind.darkest, ridge?.darkest ?? 1),
lightest: Math.max(...r.map((x) => x.lightest), ind.lightest, ridge?.lightest ?? 0),
darkest: Math.min(...r.map((x) => x.darkest), ind.darkest),
lightest: Math.max(...r.map((x) => x.lightest), ind.lightest),
};
};
// The constants are the swept extremes rounded outward to six places, so each lies in [extreme, extreme + 1e-6):
// a constant 1e-6 low is under the extreme, one 2e-5 high is over the bound. SEARCH is the searches' own error.
const SEARCH = 1e-8;
it.each(['day', 'dusk', 'night'] as const)(
it.each(STATES)(
'%s: the constant is the swept extreme rounded outward, within 1e-6 above it',
(state) => {
const c = computed(state);
@ -209,11 +304,14 @@ describe('skyBackdrop holds the union of what the scenes draw', () => {
}
);
it('auto is the union of the three fixed states', () => {
const all = (['day', 'dusk', 'night'] as const).map(skyBackdrop);
expect(skyBackdrop('auto')).toEqual({
darkest: Math.min(...all.map((x) => x.darkest)),
lightest: Math.max(...all.map((x) => x.lightest)),
});
it.each(STATES)('%s: an independent sweep, no looser than 5e-4 from the constant', (state) => {
// The sweep is not the generator's grid (see above), so it cannot share its blind spots.
const l = sweepOf(chooseSky(terrainSkyFor(state)), 20000);
const b = skyBackdrop(state);
const r = ridgeDrawn(state);
expect(l.low).toBeGreaterThanOrEqual(b.darkest - EPS);
expect(l.high).toBeLessThanOrEqual(b.lightest + EPS);
expect(b.lightest - Math.max(l.high, r.lightest)).toBeLessThan(5e-4);
expect(Math.min(l.low, r.darkest) - b.darkest).toBeLessThan(5e-4);
});
});

View file

@ -1,15 +1,19 @@
// What each scene is told for a sky state. 'auto' returns nothing: the scene chooses for itself
// (the ridges' fixed dusk, the terrain's clock with `?sky=` `?sun=` `?az=`), exactly as before
// there was a state. The other states name a sky explicitly, drawn from the same gradients
// `skyBackdrop` bounds, so the colours a scene paints lie inside the luminance range the login
// text was checked against.
// What each scene is told for a sky state. 'auto' is a choice, not a sky: both functions draw the sky it
// resolves to (`resolveSkyState`, the clock), so every scene paints one of day, dusk or night. Each is drawn
// inside that state's luminance band (skyBand.ts): the ridge sky from gradients whose stops and blends lie in
// it, the terrain sky shaped into it downstream of the scatter function. `skyBackdrop` bounds what comes out,
// and the login text was checked against that bound.
import { baked } from '../../../sky/baked';
import { chooseSky, skyColour } from './terrain/terrainSky';
import type { SkyName, Vec3 } from './terrain/terrainSky';
import { chooseSky, skyColour, urlSky } from './terrain/terrainSky';
import type { SkyName } from './terrain/terrainSky';
import type { SkyColours, Srgb } from './ridge/skyColour';
import { skyColours } from './ridge/skyColour';
import { resolveSkyState } from './skyState';
import type { SkyState } from './skyState';
import { displayedLuminance, SKY_BAND } from './skyBand';
import type { SkyBand } from './skyBand';
import { luminance } from '../../../sky/skyTokens.js';
import { CAMERA } from './terrain/terrainWorld';
const rgb = (hex: string): Srgb =>
[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb;
@ -24,32 +28,42 @@ const fromGradient = (g: readonly string[], ground: Srgb): SkyColours => ({
/** Sun elevation in degrees for the terrain's dusk: just above the horizon, between the two bakes (as `?sun=` puts it). */
export const DUSK_SUN_ELEVATION = 2;
/** The ridge scene's `sky` option; undefined leaves the scene on `skyColours()`. */
export function ridgeSkyFor(state: SkyState): SkyColours | undefined {
/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). */
export function ridgeSkyFor(state: SkyState): SkyColours {
const { ground } = skyColours();
switch (state) {
switch (resolveSkyState(state)) {
case 'dusk':
return skyColours();
case 'day':
return fromGradient(baked.noonday.gradient, ground);
case 'night':
return fromGradient(baked.midnight.gradient, ground);
default:
return undefined;
return fromGradient(baked.midnight.gradient, ground);
}
}
/** The terrain scene's `sky` and `sun` options; `{}` leaves the scene on its clock and URL choice. */
export function terrainSkyFor(state: SkyState): { sky?: SkyName; sun?: { elevation: number } } {
switch (state) {
/**
* The terrain scene's `sky`, `sun` and `band` options. An explicit state draws its own sky and ignores the URL. 'auto'
* draws the sky it resolves to, unless the page URL steers (`?sun=` `?az=` `?sky=`, see `urlSky`): then the URL's sky,
* shaped into the band of the state the clock resolves to, which keeps any sun the URL picks inside that band.
*/
export function terrainSkyFor(state: SkyState): {
sky?: SkyName;
sun?: { elevation: number; azimuth?: number };
band: SkyBand;
} {
const fixed = resolveSkyState(state);
const band = SKY_BAND[fixed];
if (state === 'auto') {
const url = urlSky();
if (url) return { ...url, band };
}
switch (fixed) {
case 'day':
return { sky: 'noonday' };
return { sky: 'noonday', band };
case 'night':
return { sky: 'midnight' };
case 'dusk':
return { sun: { elevation: DUSK_SUN_ELEVATION } };
return { sky: 'midnight', band };
default:
return {};
return { sun: { elevation: DUSK_SUN_ELEVATION }, band };
}
}
@ -61,84 +75,104 @@ export interface Range {
readonly lightest: number;
}
/** The ridge sky's range: its top, middle and horizon stops (the canvas blends between them). */
export function ridgeDrawn(state: SkyState): Range | undefined {
const sky = ridgeSkyFor(state === 'auto' ? 'dusk' : state);
if (!sky) return undefined;
const l = [sky.top, sky.mid, sky.horizon].map((c) => luminance(rgbHex(c)));
/**
* The ridge sky's range: its top, middle and horizon stops and the blends between them (the canvas interpolates
* encoded sRGB, and luminance is convex in it, so a blend can be darker than both ends: each stop pair is sampled).
*/
export function ridgeDrawn(state: SkyState): Range {
const { top, mid, horizon } = ridgeSkyFor(state);
const l: number[] = [];
[
[top, mid],
[mid, horizon],
[top, horizon],
].forEach(([a, b]) => {
for (let k = 0; k <= 100; k += 1) {
l.push(
luminance(
rgbHex(a.map((v, i) => Math.round(v + ((b[i] - v) * k) / 100)) as unknown as Srgb)
)
);
}
});
return { darkest: Math.min(...l), lightest: Math.max(...l) };
}
/**
* How high the terrain camera can see, in degrees of elevation: it is frozen at a downward pitch and only turns
* (no pull-back), so the top edge of its view, at the centre column, is the pitch down subtracted from half the
* field of view (14.5 degrees; off-centre columns look lower). Plus a 1.5 degree margin. The terrain's range is
* over 0 to this, all azimuths, not the whole upper sphere; below the horizon the sky is the horizon's colour.
*/
export const TERRAIN_REACH_DEG = Math.ceil((CAMERA.fov / 2 - CAMERA.pitch) * (180 / Math.PI) + 1.5);
const SAMPLE_DEG = 3;
const SUN_DEG = 0.25;
const SUN_DEG = 0.1;
const SUN_SPAN_DEG = 8;
const HORIZON_DEG = 0.5;
const REFINE_FROM = 6;
const linearLuminance = (c: Vec3) => {
const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v)));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
/**
* The terrain sky's range: `skyColour` (the CPU twin of its sky shader) over the whole upper sphere, which the camera
* sweeps as it turns, for the sky the scene picks for this state. A 3 degree grid, a 0.5 degree ring of the horizon, the
* zenith and a 0.25 degree patch around the sun find where the extremes are; each of the best few is then refined by a
* compass search whose step halves from 1 degree to under 0.001, because the sun's glow peaks (and the sky's darkest
* patch sits) between any fixed grid's samples.
* The terrain sky's range as the 8-bit screen shows it (`displayedLuminance`, read 'low' for the darkest and 'high' for the
* lightest: one 8-bit step each way, rounding and dither at their worst): `skyColour` (the CPU twin of its sky shader) over the part of the sky the camera reaches
* (0 to TERRAIN_REACH_DEG, every azimuth: it turns), for the sky the scene picks for this state. A 3 degree grid, a 0.5 degree
* ring of the horizon, the top of the reach and a 0.25 degree patch around the sun find where the extremes are; each of the
* best few is then refined by a compass search whose step halves from 1 degree to under 5e-7, because the sun's glow peaks
* (and the sky's darkest patch sits) between any fixed grid's samples.
*/
export function terrainDrawn(state: SkyState): Range {
const sky = chooseSky(terrainSkyFor(state));
const rad = Math.PI / 180;
const lum = (el: number, az: number) => {
const e = Math.min(90, Math.max(0, el)) * rad;
const a = az * rad;
return linearLuminance(
skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)])
);
};
const seen: { el: number; az: number; l: number }[] = [];
const at = (el: number, az: number) => seen.push({ el, az, l: lum(el, az) });
for (let el = 0; el <= 90; el += SAMPLE_DEG) {
for (let az = 0; az < 360; az += SAMPLE_DEG) at(el, az);
}
for (let az = 0; az < 360; az += HORIZON_DEG) at(0, az);
at(90, 0);
for (let dEl = -SUN_SPAN_DEG; dEl <= SUN_SPAN_DEG; dEl += SUN_DEG) {
for (let dAz = -SUN_SPAN_DEG; dAz <= SUN_SPAN_DEG; dAz += SUN_DEG) {
at(sky.elevation + dEl, sky.azimuth + dAz);
type Point = { el: number; az: number; read: number; score: number };
const extreme = (reading: 'low' | 'high', sign: 1 | -1): number => {
const point = (el: number, az: number): Point => {
const e = Math.min(TERRAIN_REACH_DEG, Math.max(0, el)) * rad;
const a = az * rad;
const c = skyColour(sky, [
Math.cos(e) * Math.sin(a),
Math.sin(e),
-Math.cos(e) * Math.cos(a),
]);
const read = displayedLuminance(c, reading);
return { el, az, read, score: read };
};
const seen: Point[] = [];
const at = (el: number, az: number) => seen.push(point(el, az));
for (let el = 0; el <= TERRAIN_REACH_DEG; el += SAMPLE_DEG) {
for (let az = 0; az < 360; az += SAMPLE_DEG) at(el, az);
}
}
type Point = { el: number; az: number; l: number };
const around = (p: Point, step: number): Point[] =>
[
[step, 0],
[-step, 0],
[0, step],
[0, -step],
].map(([dEl, dAz]) => {
const el = Math.min(90, Math.max(0, p.el + dEl));
return { el, az: p.az + dAz, l: lum(el, p.az + dAz) };
});
const refine = (from: Point, sign: 1 | -1) => {
let best = from;
for (let step = 1; step > 5e-7; step /= 2) {
let improved = true;
while (improved) {
const cur = best;
best = around(cur, step).reduce((b, c) => (sign * c.l > sign * b.l ? c : b), cur);
improved = best !== cur;
for (let az = 0; az < 360; az += HORIZON_DEG) at(0, az);
at(TERRAIN_REACH_DEG, 0);
for (let dEl = -SUN_SPAN_DEG; dEl <= SUN_SPAN_DEG; dEl += SUN_DEG) {
for (let dAz = -SUN_SPAN_DEG; dAz <= SUN_SPAN_DEG; dAz += SUN_DEG) {
at(sky.elevation + dEl, sky.azimuth + dAz);
}
}
return best.l;
const around = (p: Point, step: number): Point[] =>
[
[step, 0],
[-step, 0],
[0, step],
[0, -step],
].map(([dEl, dAz]) =>
point(Math.min(TERRAIN_REACH_DEG, Math.max(0, p.el + dEl)), p.az + dAz)
);
const refine = (from: Point) => {
let best = from;
for (let step = 1; step > 5e-7; step /= 2) {
let improved = true;
while (improved) {
const cur = best;
best = around(cur, step).reduce((b, c) => (sign * c.score > sign * b.score ? c : b), cur);
improved = best !== cur;
}
}
return best.read;
};
const ranked = [...seen].sort((p, q) => sign * (q.score - p.score));
return sign === 1
? Math.max(ranked[0].read, ...ranked.slice(0, REFINE_FROM).map(refine))
: Math.min(ranked[0].read, ...ranked.slice(0, REFINE_FROM).map(refine));
};
const byLight = [...seen].sort((p, q) => q.l - p.l);
const lightest = Math.max(
byLight[0].l,
...byLight.slice(0, REFINE_FROM).map((p) => refine(p, 1))
);
const darkest = Math.min(
byLight[byLight.length - 1].l,
...byLight.slice(-REFINE_FROM).map((p) => refine(p, -1))
);
return { darkest, lightest };
return { darkest: extreme('low', -1), lightest: extreme('high', 1) };
}

View file

@ -1,11 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { atStandardTime } from './standardTime.testkit';
import { baked } from '../../../sky/baked';
import { AA, luminance } from '../../../sky/skyTokens.js';
import { luminance } from '../../../sky/skyTokens.js';
import {
SKY_STATES,
SKY_STATE_KEY,
resolveSkyState,
setSkyClock,
skyBackdrop,
type SkyBackdrop,
type SkyState,
} from './skyState';
@ -105,10 +107,10 @@ describe('sky state', () => {
});
describe('skyBackdrop', () => {
it('puts night below day and dusk, which spans from dark to the sun glow', () => {
it('puts night below day and dusk, and dusk reaches the sun glow', () => {
expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('day').darkest);
expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest);
expect(skyBackdrop('dusk').darkest).toBeLessThan(skyBackdrop('day').darkest);
expect(skyBackdrop('dusk').lightest).toBeGreaterThan(skyBackdrop('day').lightest);
});
it('holds the baked gradients for day and night', () => {
@ -121,12 +123,13 @@ describe('skyBackdrop', () => {
);
});
it('bounds every fixed sky when auto', () => {
const a = skyBackdrop('auto');
(['day', 'dusk', 'night'] as const).forEach((s) => {
expect(a.lightest).toBeGreaterThanOrEqual(skyBackdrop(s).lightest);
expect(a.darkest).toBeLessThanOrEqual(skyBackdrop(s).darkest);
});
it('is the band of the sky auto resolves to, not the union of all three', () => {
const at = (h: number) => setSkyClock(() => atStandardTime(h, 0));
at(12);
expect(skyBackdrop('auto')).toEqual(skyBackdrop('day'));
at(0);
expect(skyBackdrop('auto')).toEqual(skyBackdrop('night'));
setSkyClock();
});
it('never has darkest above lightest', () => {
@ -136,57 +139,79 @@ describe('skyBackdrop', () => {
});
});
// Login text and link, per theme, from the card variables in pages/auth/styles.css.ts and the sky
// themes' baked bubble text and link. The Light/Silver/Dark/Butter row uses the ink those themes
// actually render: under data-scene the auth card text is var(--sky-bubble-text, #b7c7d9), not each
// theme's own OnContainer.
const inks = [
{ theme: 'Light/Silver/Dark/Butter', text: '#b7c7d9', link: '#99c7ff' },
{ theme: 'Midnight Sky', text: baked.midnight.bubbles.OtherText, link: baked.midnight.link },
{ theme: 'Noonday Sky', text: baked.noonday.bubbles.OtherText, link: baked.noonday.link },
];
// WCAG contrast of an ink against the sky it is worst on. The sky can show any luminance between
// its darkest and lightest, and contrast is lowest where the backdrop is closest to the ink: the
// ink's own luminance clamped into that range. An ink inside the range can land on a backdrop of
// exactly its own luminance (1.00); an ink outside it is worst on the nearer end.
const against = (ink: string, bg: number) => {
const li = luminance(ink);
const [hi, lo] = li > bg ? [li, bg] : [bg, li];
return (hi + 0.05) / (lo + 0.05);
};
const ratio = (ink: string, b: SkyBackdrop) =>
against(ink, Math.min(b.lightest, Math.max(b.darkest, luminance(ink))));
describe('login ink against the sky backdrop', () => {
const rows = SKY_STATES.flatMap((state) =>
inks.flatMap(({ theme, text, link }) =>
(
[
['text', text],
['link', link],
] as const
).map(([role, ink]) => ({ state, theme, role, ratio: ratio(ink, skyBackdrop(state)) }))
)
);
it('prints the measured ratios', () => {
// eslint-disable-next-line no-console
console.table(rows.map((r) => ({ ...r, ratio: r.ratio.toFixed(2), ok: r.ratio >= AA })));
expect(rows).toHaveLength(SKY_STATES.length * inks.length * 2);
describe('auto follows the clock', () => {
const local = (h: number, m = 0) => atStandardTime(h, m); // local solar time ~ local clock
let storage: ReturnType<typeof fakeStorage>;
beforeEach(() => {
vi.useFakeTimers();
storage = fakeStorage();
vi.stubGlobal('localStorage', storage);
});
afterEach(() => {
vi.useRealTimers();
});
// Bare sky does not hold either ink in most states: the card behind the login form stays opaque
// (pages/auth/styles.css.ts) for that reason. These are the cells that clear AA, with the sky range the scenes can draw; every other
// cell is asserted to fail, so a sky or theme change that fixes one turns it red until listed.
const clears = new Set([
'night|Light/Silver/Dark/Butter|text',
'night|Light/Silver/Dark/Butter|link',
'night|Midnight Sky|text',
]);
describe.each(rows)('$theme $role in $state', (r) => {
const holds = clears.has(`${r.state}|${r.theme}|${r.role}`);
(holds ? it : it.fails)(holds ? 'is at least AA' : 'is below AA (known)', () => {
expect(r.ratio).toBeGreaterThanOrEqual(AA);
});
it('resolves noon to day, midnight to night and the twilight after sunset to dusk', () => {
expect(resolveSkyState('auto', local(12))).toBe('day');
expect(resolveSkyState('auto', local(0))).toBe('night');
expect(resolveSkyState('auto', local(18, 10))).toBe('dusk');
expect(resolveSkyState('auto', local(5, 50))).toBe('dusk');
expect(resolveSkyState('day', local(0))).toBe('day');
});
it('tells listeners the resolved sky when the clock crosses a boundary, and only then', async () => {
const m = await fresh();
let now = local(17, 0);
m.setSkyClock(() => now);
const cb = vi.fn();
const off = m.subscribe(cb);
vi.advanceTimersByTime(5 * 60_000);
expect(cb).not.toHaveBeenCalled();
now = local(18, 10);
vi.advanceTimersByTime(60_000);
expect(cb.mock.calls).toEqual([['dusk']]);
vi.advanceTimersByTime(5 * 60_000);
expect(cb).toHaveBeenCalledTimes(1);
off();
});
it('still tells everyone when a listener comes or goes inside the window before the clock crosses', async () => {
const m = await fresh();
let now = local(17, 0);
m.setSkyClock(() => now);
const [a, b, late] = [vi.fn(), vi.fn(), vi.fn()];
m.subscribe(a);
const offB = m.subscribe(b);
vi.advanceTimersByTime(30_000);
now = local(18, 10); // the clock has crossed; no tick has seen it yet
const offLate = m.subscribe(late);
offB();
vi.advanceTimersByTime(60_000);
expect(a.mock.calls).toEqual([['dusk']]);
expect(b).not.toHaveBeenCalled();
expect(late.mock.calls).toEqual([['dusk']]);
offLate();
});
it('keeps no timer without listeners or when a fixed state is chosen', async () => {
const m = await fresh();
const off = m.subscribe(() => undefined);
expect(vi.getTimerCount()).toBe(1);
m.setSkyState('night');
expect(vi.getTimerCount()).toBe(0);
m.setSkyState('auto');
expect(vi.getTimerCount()).toBe(1);
off();
expect(vi.getTimerCount()).toBe(0);
});
it('notifies a change of choice with the resolved sky', async () => {
const m = await fresh();
m.setSkyClock(() => local(12));
const cb = vi.fn();
m.subscribe(cb);
m.setSkyState('night');
m.setSkyState('auto');
expect(cb.mock.calls).toEqual([['night'], ['day']]);
});
});

View file

@ -1,7 +1,11 @@
// Which sky the signed-out page is drawn against. The state is one value, kept in one
// Which sky the signed-out page is drawn against. The choice is one value, kept in one
// localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and
// `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the
// text colours can be checked against it.
// text colours can be checked against it. 'auto' is a choice, not a sky: it resolves from the
// clock to day, dusk or night (`resolveSkyState`), and every sky drawn is one of those three.
import { sunElevation } from '../../../sky/sun.js';
import type { FixedSkyState } from './skyBand';
export type SkyState = 'auto' | 'day' | 'dusk' | 'night';
export const SKY_STATES: readonly SkyState[] = ['auto', 'day', 'dusk', 'night'];
@ -18,14 +22,70 @@ function load(): SkyState {
}
}
let current: SkyState | undefined;
const listeners = new Set<(s: SkyState) => void>();
export type { FixedSkyState };
/** Where the clock puts the sun: the latitude the skies are baked for (src/sky/skyTokens.js MOMENTS), the longitude of this timezone. */
const LAT = 45;
/** Sun elevation (degrees) from which it is day, and below the negative of which it is night; between, dusk (dawn included). */
export const DAY_FROM = 6;
let clock: () => Date = () => new Date();
/** Minutes west of UTC of this timezone's standard time (the larger of its January and July offsets), so daylight saving does not move the sun. */
export const standardOffsetMinutes = (now: Date): number =>
Math.max(
new Date(now.getFullYear(), 0, 1).getTimezoneOffset(),
new Date(now.getFullYear(), 6, 1).getTimezoneOffset()
);
/** The fixed sky a choice draws now: itself, or for 'auto' the sky the sun's elevation here calls for. */
export function resolveSkyState(s: SkyState, now: Date = clock()): FixedSkyState {
if (s !== 'auto') return s;
const lon = -standardOffsetMinutes(now) / 4;
const el = sunElevation(now, LAT, lon) as number;
if (el >= DAY_FROM) return 'day';
return el <= -DAY_FROM ? 'night' : 'dusk';
}
let current: SkyState | undefined;
const listeners = new Set<(s: FixedSkyState) => void>();
let timer: ReturnType<typeof setTimeout> | undefined;
let shown: FixedSkyState | undefined;
const TICK_MS = 60_000;
/** The choice (what the control shows). */
export const getSkyState = (): SkyState => {
current ??= load();
return current;
};
/** The sky being drawn: the choice, resolved. */
export const getResolvedSkyState = (): FixedSkyState => resolveSkyState(getSkyState());
const emit = () => {
shown = getResolvedSkyState();
[...listeners].forEach((cb) => cb(shown as FixedSkyState));
};
// While anyone listens and the choice is 'auto', one timer checks the clock each minute and tells
// listeners when the resolved sky changes from the one last told (`shown`, set when telling and when the
// first listener arrives, never by a later arrival or departure). Otherwise there is no timer.
function sync(): void {
clearTimeout(timer);
timer = undefined;
if (listeners.size === 0 || getSkyState() !== 'auto') return;
const tick = () => {
if (getResolvedSkyState() !== shown) emit();
timer = setTimeout(tick, TICK_MS);
};
timer = setTimeout(tick, TICK_MS);
}
/** Replace the clock (tests); no argument restores the real one. */
export function setSkyClock(now?: () => Date): void {
clock = now ?? (() => new Date());
sync();
}
export function setSkyState(s: SkyState): void {
if (!isSkyState(s)) return;
const changed = s !== getSkyState();
@ -35,20 +95,27 @@ export function setSkyState(s: SkyState): void {
} catch {
// Storage is refused (private mode, quota): the choice holds for this page load only.
}
if (changed) [...listeners].forEach((cb) => cb(s));
if (changed) {
sync();
emit();
}
}
export function subscribe(cb: (s: SkyState) => void): () => void {
/** Listeners hear the resolved sky: on a change of choice, and while 'auto', when the clock crosses a boundary. */
export function subscribe(cb: (s: FixedSkyState) => void): () => void {
if (listeners.size === 0) shown = getResolvedSkyState();
listeners.add(cb);
sync();
return () => {
listeners.delete(cb);
sync();
};
}
/**
* Relative luminance (WCAG, 0..1) of the lightest and darkest sky the state can show behind the
* page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' follows the
* clock, so it can show any of the three fixed skies and bounds all of them.
* page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' is the band of
* the sky it resolves to now.
*/
export interface SkyBackdrop {
readonly darkest: number;
@ -58,14 +125,13 @@ export interface SkyBackdrop {
// The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from
// the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). Held as constants so
// this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering
// tables with it; skyForState.test.ts fails when they drift from the computed union.
// Rounded outward to six places. 'auto' follows the clock (and a URL), so it
// bounds all three fixed states.
const BACKDROPS: Record<SkyState, SkyBackdrop> = {
auto: { darkest: 0.016727, lightest: 0.967912 },
day: { darkest: 0.237309, lightest: 0.928052 },
dusk: { darkest: 0.016727, lightest: 0.967912 },
night: { darkest: 0.058099, lightest: 0.077467 },
// tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts
// over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts).
// Bounds what the 8-bit screen shows too (one step each way for rounding and dither), rounded outward to six places.
const BACKDROPS: Record<FixedSkyState, SkyBackdrop> = {
day: { darkest: 0.461063, lightest: 0.901108 },
dusk: { darkest: 0.30619, lightest: 0.968744 },
night: { darkest: 0.05987, lightest: 0.081705 },
};
export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[state];
export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)];

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { atStandardTime } from './standardTime.testkit';
import { baked } from '../../../sky/baked';
import { AA, contrast, luminance } from '../../../sky/skyTokens.js';
import { Editor } from '../../components/editor/Editor.css';
@ -8,7 +9,9 @@ import { MembersDrawer } from '../room/MembersDrawer.css';
import { RoomViewTyping } from '../room/RoomViewTyping.css';
import { SKY_PLATE_FALLBACK } from './skyPlate';
import { SkyPlate, plateRegions } from './skyPlate.css';
import { SKY_STATES, skyBackdrop, type SkyBackdrop, type SkyState } from './skyState';
import { getResolvedSkyState, setSkyClock, skyBackdrop } from './skyState';
import type { SkyBackdrop } from './skyState';
import type { FixedSkyState } from './skyBand';
import { skyThemeId } from './skyTheme';
const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const;
@ -30,44 +33,76 @@ const ratio = (ink: string, b: SkyBackdrop) =>
const r2 = (n: number) => n.toFixed(2);
describe('skyThemeId', () => {
it('leaves the theme alone in auto and picks one for every fixed state', () => {
expect(skyThemeId('auto')).toBeUndefined();
expect(SKY_STATES.filter((s) => s !== 'auto').map((s) => skyThemeId(s))).not.toContain(
undefined
);
});
it('lights day and dusk with dark ink and night with light ink', () => {
it('picks a theme for each explicit state', () => {
expect(skyThemeId('day')).toBe('noonday-sky');
expect(skyThemeId('dusk')).toBe('noonday-sky');
expect(skyThemeId('night')).toBe('midnight-sky');
});
it('picks none for auto, at any hour: auto never overrides a signed-in theme', () => {
[0, 6, 12, 18, 23].forEach((h) => {
setSkyClock(() => atStandardTime(h, 10));
expect(skyThemeId('auto')).toBeUndefined();
});
setSkyClock();
});
it('is what a wiring that follows the sky reads: the resolved state names a theme', () => {
setSkyClock(() => atStandardTime(12));
expect(skyThemeId(getResolvedSkyState())).toBe('noonday-sky');
setSkyClock(() => atStandardTime(0));
expect(skyThemeId(getResolvedSkyState())).toBe('midnight-sky');
setSkyClock();
});
});
describe('ink of the theme a state picks, on bare sky', () => {
// Per state: the picked theme, its ink and link, and their ratios on bare sky, over the union
// of the skies the scenes draw (skyBackdrop). Only night text passes (5.05); day's sky reaches
// 0.919 and dusk's 0.017 to 0.965, so no theme's ink holds there. No link passes anywhere
// (night 2.36): nothing rests on bare sky, everything sits on the plate below.
const cells: [SkyState, string, string, string, string, string][] = [
['day', 'noonday-sky', '#342d25', '3.71', '#2a4f27', '2.56'],
['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27', '1.00'],
['night', 'midnight-sky', '#aeccff', '5.05', '#609385', '2.36'],
// Per state: the picked theme, its text ink and link, and their WCAG ratios on the sky the scenes
// draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link clears
// on day and not on dusk or night: the links are derived in src/sky/skyTokens.js, outside the band, and these
// numbers are recorded as they are measured (Night's #609385 would clear 4.5 at a luminance of about 0.53 or more).
// 'auto' resolves from the clock to one of these three and has the same numbers.
const cells: [FixedSkyState, string, string, string, string, string][] = [
['day', 'noonday-sky', '#342d25', '6.60', '#2a4f27', '4.55'],
['dusk', 'noonday-sky', '#342d25', '4.60', '#2a4f27', '3.17'],
['night', 'midnight-sky', '#aeccff', '4.89', '#609385', '2.28'],
];
it.each(cells)('%s with %s: ink %s is %s, link %s is %s', (state, id, ink, want, link, wantLink) => {
const sky = SKY[id as keyof typeof SKY];
expect(sky.tokens.Background.OnContainer).toBe(ink);
expect(sky.link).toBe(link);
expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want);
expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink);
it.each(cells)(
'%s with %s: text %s is %s, link %s is %s',
(state, id, ink, want, link, wantLink) => {
const sky = SKY[id as keyof typeof SKY];
expect(sky.tokens.Background.OnContainer).toBe(ink);
expect(sky.link).toBe(link);
expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want);
expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink);
}
);
it.each(cells)('%s: text clears AA', (state, _id, ink) => {
expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA);
});
it('night is the only state whose text clears AA on bare sky', () => {
const passing = fixed.filter(
([s, id]) => ratio(SKY[id].tokens.Background.OnContainer, skyBackdrop(s)) >= AA
);
expect(passing.map(([s]) => s)).toEqual(['night']);
it('link clears AA on day (the terrain camera sees only the lighter sky) and falls short on dusk and night', () => {
const link = (s: FixedSkyState, id: keyof typeof SKY) => ratio(SKY[id].link, skyBackdrop(s));
expect(link('day', 'noonday-sky')).toBeGreaterThanOrEqual(AA);
expect(link('dusk', 'noonday-sky')).toBeLessThan(AA);
expect(link('night', 'midnight-sky')).toBeLessThan(AA);
});
it.each([
[12, 'day'],
[18, 'dusk'],
[0, 'night'],
] as const)('auto at %s:00 has the numbers of %s', (hour, state) => {
setSkyClock(() => atStandardTime(hour, hour === 18 ? 10 : 0));
const id = skyThemeId(getResolvedSkyState()) as keyof typeof SKY;
expect(id).toBe(skyThemeId(state));
const { tokens, link } = SKY[id];
const text = tokens.Background.OnContainer;
expect(r2(ratio(text, skyBackdrop('auto')))).toBe(r2(ratio(text, skyBackdrop(state))));
expect(r2(ratio(link, skyBackdrop('auto')))).toBe(r2(ratio(link, skyBackdrop(state))));
setSkyClock();
});
});

View file

@ -1,15 +1,20 @@
// Which theme a sky state lights the page with. 'auto' picks none: the page keeps the theme it
// would have had (the user's choice when signed in, Midnight Sky when signed out).
// Which theme a sky state lights the page with. 'auto' picks none: it never overrides a person's theme
// (the stored one when signed in, Midnight Sky when signed out). A wiring that follows the sky when no theme
// is stored reads `getResolvedSkyState` (skyState.ts) and passes that fixed state here.
//
// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. On bare sky
// only Night's text clears 4.5:1 (5.05); Day's sky reaches 0.919 luminance and Dusk's spans
// 0.017 to 0.965, so no ink holds there. Text therefore never rests on bare sky: every region
// that would is a plate (skyPlate.css.ts), and skyTheme.test.ts pins the numbers.
import type { SkyState } from './skyState';
// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Each state's
// drawn sky is shaped into a luminance band (skyBand.ts) in which that ink's TEXT clears 4.5:1 on
// bare sky: Day and Dusk at least 0.2983 (dark ink), Night at most 0.0931 (light ink). skyTheme.test.ts
// pins the measured text and link ratios. Link ink is not in the band: the Night link (#609385,
// 2.36:1) and the dark links on Day and Dusk fall short, because the themes' link colours are
// derived outside this code (src/sky/skyTokens.js); lightening the Night link to a luminance of
// about 0.53 or more would clear it. The plate (skyPlate.css.ts) is still in the shell, pending
// its removal.
import type { FixedSkyState, SkyState } from './skyState';
export type SkyThemeId = 'midnight-sky' | 'noonday-sky';
const BY_STATE: Record<Exclude<SkyState, 'auto'>, SkyThemeId> = {
const BY_STATE: Record<FixedSkyState, SkyThemeId> = {
day: 'noonday-sky',
dusk: 'noonday-sky',
night: 'midnight-sky',

View file

@ -0,0 +1,7 @@
import { standardOffsetMinutes } from './skyState';
/** 22 September 2026 at h:m of local solar time as the sky clock reads it (standard time, whatever daylight saving says). */
export const atStandardTime = (h: number, m = 0): Date => {
const probe = new Date(2026, 8, 22);
return new Date(Date.UTC(2026, 8, 22, h, m) + standardOffsetMinutes(probe) * 60000);
};

View file

@ -5,6 +5,8 @@ import { GRID, triangleCount } from './terrainWorld';
import { groundLight, SKY, skyAtSun } from './terrainSky';
import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter';
import { SKY_FRAG } from './terrainShaders';
import { SKY_BAND } from '../skyBand';
import { terrainSkyFor } from '../skyForState';
import { startBackgroundScene } from '../index';
/** A GL context that accepts every call, remembers them, reports success for compile, link and framebuffer. */
@ -158,6 +160,32 @@ describe('createTerrainScene', () => {
expect(has(uploaded({}), nightA)).toBe(true);
});
it.each(['day', 'dusk', 'night'] as const)(
'uploads the %s band as uBand (floor, ceiling) and uLift, in the sky pass and the grid pass',
(state) => {
const { gl, calls } = fakeGl();
const opts = terrainSkyFor(state);
createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose();
const { floor, ceiling, lift } = SKY_BAND[state];
const pairs = calls.uniform2fv.filter(
(a) =>
Math.abs((a[1] as number[])[0] - floor) < 1e-6 &&
Math.abs((a[1] as number[])[1] - ceiling) < 1e-6
);
expect(pairs).toHaveLength(2);
const lifts = calls.uniform3fv.filter((a) =>
Array.from(a[1] as ArrayLike<number>).every((x, i) => Math.abs(x - lift[i]) < 1e-6)
);
expect(lifts).toHaveLength(2);
}
);
it('uploads no band (0, 1) and a zero lift without one', () => {
const { gl, calls } = fakeGl();
createTerrainScene({ canvas: canvasWith(gl), tier: 'still' })!.dispose();
expect(calls.uniform2fv.filter((a) => (a[1] as number[])[1] === 1)).toHaveLength(2);
});
it('a sun elevation (option or ?sun=&az=) lights between the bakes, and wins over ?sky=', () => {
const ambients = (opts: Record<string, unknown>) => {
const { gl, calls } = fakeGl();

View file

@ -1,4 +1,5 @@
import type { Scene, SceneOptions } from '../Scene';
import type { SkyBand } from '../skyBand';
import { FrameSampler, FrameStats } from '../ridge/probe';
import { albedoPalette } from './terrainAlbedo';
import { sunIndex } from '../../../../sky/scatter.js';
@ -27,10 +28,12 @@ export type TerrainOptions = Pick<
SceneOptions,
'canvas' | 'tier' | 'onCompileFail' | 'onContextLost'
> & {
/** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else the night notion of `skyColours()`. */
/** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else midnight. */
sky?: SkyName;
/** Put the sun at this elevation (degrees) and azimuth (clockwise from north; default: noonday's) instead of at the baked sky's: `?sun=` and `?az=` in the page URL. Lights between the two bakes, for dusk. */
sun?: { elevation: number; azimuth?: number };
/** The luminance band the sky is shaped into (skyBand.ts), in the sky pass and in the haze that fades into it. Default: none. */
band?: SkyBand;
/** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */
seed?: number;
/** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */
@ -212,6 +215,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null {
const ambient = [...ground.ambient];
const heading = START_TURNS;
const glow = [...glowColour(sky)];
const band: [number, number] = sky.band ? [sky.band.floor, sky.band.ceiling] : [0, 1];
const lift = [...(sky.band?.lift ?? [0, 0, 0])];
const sunTexel = sunIndex(sky.elevation);
const night = nightness(sky.elevation);
const moonTexel = sunIndex(Math.abs(sky.elevation));
@ -247,6 +252,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null {
gl.uniform3fv(us('uSun'), sun);
gl.uniform3fv(us('uSunColour'), sunLight);
gl.uniform3fv(us('uGlow'), glow);
gl.uniform2fv(us('uBand'), band);
gl.uniform3fv(us('uLift'), lift);
gl.uniform1f(us('uSunIndex'), sunTexel);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_3D, skyTex);
@ -271,6 +278,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null {
gl.uniform3fv(u('uLight'), ground.colour);
gl.uniform3fv(u('uAmbient'), ambient);
gl.uniform3fv(u('uGlow'), glow);
gl.uniform2fv(u('uBand'), band);
gl.uniform3fv(u('uLift'), lift);
gl.uniform1f(u('uSunIndex'), sunTexel);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_3D, skyTex);

View file

@ -2,6 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createTerrainSkyScene, SKY_WIDTH } from './TerrainSky2D';
import { startBackgroundScene } from '../index';
import { terrainSkyFor } from '../skyForState';
let search: string;
let arcs: number;
@ -218,3 +219,32 @@ describe('startBackgroundScene, terrain style', () => {
).toBeNull();
});
});
describe('the 2D sky lies in the band of its state', () => {
// The painted pixels, read back through the sRGB curve as a screen shows them.
const lumOf = (rgb: number[]) => {
const [r, g, b] = rgb.map((v) => {
const e = v / 255;
return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const painted = (state: 'day' | 'dusk' | 'night') => {
const c = canvasWith();
createTerrainSkyScene({ canvas: c.canvas, tier: 'low', ...terrainSkyFor(state) })!.dispose();
const img = c.painted[0];
const out: number[] = [];
for (let y = 0; y < img.height; y += 1) {
for (let x = 0; x < img.width; x += 1) out.push(lumOf(pixel(img, x, y)));
}
return out;
};
it.each([['day'], ['dusk']] as const)('%s: no pixel below the text bound 0.2983', (state) => {
expect(Math.min(...painted(state))).toBeGreaterThanOrEqual(0.2983);
});
it('night: no pixel above the text bound 0.0931', () => {
expect(Math.max(...painted('night'))).toBeLessThanOrEqual(0.0931);
});
});

View file

@ -9,7 +9,7 @@ import { CAMERA, view } from './terrainWorld';
export const SKY_WIDTH = 96;
/** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */
export type TerrainSkyOptions = Pick<TerrainOptions, 'canvas' | 'tier' | 'sky' | 'sun'>;
export type TerrainSkyOptions = Pick<TerrainOptions, 'canvas' | 'tier' | 'sky' | 'sun' | 'band'>;
/**
* Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the

View file

@ -7,6 +7,7 @@
import cultmath from '../shaders/CultMath.glsl?raw';
import phacelle from '../shaders/CultMath.Phacelle.glsl?raw';
import { trimGlsl } from '../cultmathTrim';
import { LUMA } from '../skyBand';
import { ALBEDO, CAVITY } from './terrainAlbedo';
import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter';
import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, SKY_FADE, STAR_FLOOR } from './terrainSky';
@ -140,14 +141,28 @@ const ALBEDO_DEFINES = `
#define CAV_DARK ${lit(CAVITY.dark)}
#define CAV_LIGHT ${lit(CAVITY.light)}
`;
// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The
// same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is.
const SKY_COLOUR_GLSL = `${SCATTER_GLSL}
// The GLSL twin of skyBand.shapeToBand: clamp to 0..1, mix toward uLift to land on the floor uBand.x, or scale down to the ceiling uBand.y.
// No band is uBand = (0, 1).
export const SHAPE_SKY_GLSL = `
uniform vec2 uBand;
uniform vec3 uLift;
const vec3 LUMA = vec3(${LUMA.map(lit).join(', ')});
vec3 shapeSky(vec3 c) {
c = clamp(c, 0.0, 1.0);
float l = dot(c, LUMA);
if (l < uBand.x) return mix(c, uLift, (uBand.x - l) / (dot(uLift, LUMA) - l));
if (l > uBand.y) return c * (uBand.y / l);
return c;
}`;
// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow, shaped into
// the state's luminance band. The same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already
// scaled by how dark the sun is.
const SKY_COLOUR_GLSL = `${SCATTER_GLSL}${SHAPE_SKY_GLSL}
uniform vec3 uGlow;
vec3 skyColour(vec3 d) {
return 1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit(
return shapeSky(1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit(
GLOW_ZENITH
)}, sqrt(clamp(d.y, 0.0, 1.0)));
)}, sqrt(clamp(d.y, 0.0, 1.0))));
}`;
export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL}${AERIAL_GLSL}

View file

@ -14,7 +14,6 @@ import {
skyAtSun,
skyData,
skyColour,
skyNameFor,
sunDirection,
aerialPerspective,
SKY_FADE,
@ -25,7 +24,6 @@ import {
START_TURNS,
Vec3,
} from './terrainSky';
import { skyColours } from '../ridge/skyColour';
const UP = [0, 1, 0] as const;
const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
@ -68,11 +66,6 @@ describe('the baked sky is read, not copied', () => {
expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]);
expect(hexToLinear('#808080')[0]).toBeCloseTo(0.2158, 3);
});
it('the default sky from skyColours() is a night one, as the stars see it', () => {
expect(skyNameFor(skyColours())).toBe('midnight');
expect(skyNameFor({ ...skyColours(), top: [150, 198, 255] })).toBe('noonday');
});
});
describe('a sun at any elevation (skyAtSun)', () => {

View file

@ -1,9 +1,8 @@
// Lighting, haze and sky for the terrain scene, as pure functions of one sky's baked numbers (src/sky/baked.ts, Void's
// bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms.
import { baked } from '../../../../sky/baked';
import { isNight } from '../ridge/stars';
import { skyColours } from '../ridge/skyColour';
import type { SkyColours } from '../ridge/skyColour';
import { hexToLinear, shapeToBand } from '../skyBand';
import type { SkyBand } from '../skyBand';
import {
AP_DISTANCE_SCALE,
aerialAt,
@ -26,6 +25,8 @@ export interface SkyData {
ambient: Vec3;
/** The horizon, linear RGB: the baked `horizon_hex`. */
horizon: Vec3;
/** The luminance band `skyColour` shapes the sky into (skyBand.ts); absent: the physical sky as it is. */
band?: SkyBand;
}
const RAD = Math.PI / 180;
@ -37,19 +38,9 @@ const smooth = (a: number, b: number, x: number) => {
const mixVec = (a: Vec3, b: Vec3, t: number): Vec3 =>
[0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * t) as unknown as Vec3;
/** Which baked sky a ridge-style `SkyColours` means: the night notion the stars use (`isNight`). */
export const skyNameFor = (sky: SkyColours): SkyName => (isNight(sky) ? 'midnight' : 'noonday');
export const isSkyName = (s: unknown): s is SkyName => s === 'midnight' || s === 'noonday';
/** sRGB hex `#rrggbb` to linear RGB 0..1. */
export function hexToLinear(hex: string): Vec3 {
const ch = (at: number) => {
const s = parseInt(hex.slice(at, at + 2), 16) / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return [ch(1), ch(3), ch(5)];
}
export { hexToLinear };
const fromBaked = (b: typeof baked.midnight | typeof baked.noonday): SkyData => ({
elevation: b.sun.elevation_deg,
@ -115,7 +106,8 @@ export const glowColour = (d: SkyData): Vec3 => {
/**
* Linear colour of the sky along a unit view direction (x east, y up, z south): the baked single-scattering
* radiance for this sun, lifted by SKY_EXPOSURE and tone-mapped `1 - exp(-x)`, plus the night glow, which sits
* brightest at the horizon; at and below the horizon, the horizon's colour. The fragment shader does the same.
* brightest at the horizon; at and below the horizon, the horizon's colour; then shaped into `d.band` when it has one.
* The fragment shader does the same.
*/
export function skyColour(d: SkyData, dir: Vec3): Vec3 {
const el = Math.max(HORIZON_FLOOR, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD);
@ -129,9 +121,10 @@ export function skyColour(d: SkyData, dir: Vec3): Vec3 {
const r = scatterRadiance(d.elevation, el, az);
const glow = glowColour(d);
const shape = 1 + (GLOW_ZENITH - 1) * Math.sqrt(clamp01(dir[1]));
return [0, 1, 2].map(
const c = [0, 1, 2].map(
(i) => 1 - Math.exp(-SKY_EXPOSURE * r[i]) + glow[i] * shape
) as unknown as Vec3;
return d.band ? shapeToBand(c, d.band) : c;
}
/**
@ -228,24 +221,33 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number,
}) as unknown as Vec3;
}
/**
* The sky to use. An explicit option wins over the page URL, so a person who picked a sky sees it: a sun
* elevation (with its azimuth, default noonday's), else a baked sky. Only without either does the URL steer:
* `?sun=` (with `?az=`), then `?sky=midnight|noonday`, then the night notion of `skyColours()`.
*/
export function chooseSky(opts: {
sky?: SkyName;
sun?: { elevation: number; azimuth?: number };
}): SkyData {
if (opts.sun) return skyAtSun(opts.sun.elevation, opts.sun.azimuth);
if (opts.sky) return skyData(opts.sky);
/** What the page URL asks of the sky, if anything: `?sun=` (with `?az=`) wins over `?sky=midnight|noonday`; a bad value asks nothing. */
export function urlSky():
| { sky?: SkyName; sun?: { elevation: number; azimuth?: number } }
| undefined {
const q = new URLSearchParams(window.location.search);
const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN);
const elevation = number('sun');
if (Number.isFinite(elevation)) {
const azimuth = number('az');
return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined);
return { sun: { elevation, azimuth: Number.isFinite(azimuth) ? azimuth : undefined } };
}
const name = q.get('sky');
return skyData(isSkyName(name) ? name : skyNameFor(skyColours()));
return isSkyName(name) ? { sky: name } : undefined;
}
/**
* The sky to use. An explicit option wins over the page URL, so a person who picked a sky sees it: a sun
* elevation (with its azimuth, default noonday's), else a baked sky. Only without either does the URL steer
* (`urlSky`), else midnight. A band, when given, shapes whichever sky that is.
*/
export function chooseSky(opts: {
sky?: SkyName;
sun?: { elevation: number; azimuth?: number };
band?: SkyBand;
}): SkyData {
const banded = (d: SkyData): SkyData => (opts.band ? { ...d, band: opts.band } : d);
const want = opts.sun || opts.sky ? opts : urlSky() ?? { sky: 'midnight' as const };
if (want.sun) return banded(skyAtSun(want.sun.elevation, want.sun.azimuth));
return banded(skyData(want.sky ?? 'midnight'));
}

View file

@ -1,4 +1,5 @@
import { useSyncExternalStore } from 'react';
import { getSkyState, subscribe, type SkyState } from '../features/scene/skyState';
/** The choice, as the control shows it ('auto' stays 'auto'). The sky being drawn is `getResolvedSkyState`. */
export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState);

View file

@ -102,7 +102,7 @@ export const useSystemThemeKind = (): ThemeKind => {
return themeKind;
};
/** The theme the sky state asks for, or undefined while it is 'auto'. */
/** The theme the sky state asks for, or undefined while it is 'auto': auto never overrides a person's theme. */
export const useSkyTheme = (): Theme | undefined => {
const id = skyThemeId(useSkyState());
return id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme;