diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 684e0c67..6c3d034a 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -62,6 +62,20 @@ describe('startBackgroundScene follows the sky state', () => { expect(second.dispose).toHaveBeenCalledOnce(); }); + it('rebuilds for a change that landed while the first build was awaited', async () => { + const [a, b] = [fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b); + const started = startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + const scene = await started; + await flush(); + expect(createRidgeScene).toHaveBeenCalledTimes(2); + expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]); + expect(a.dispose).toHaveBeenCalledOnce(); + scene?.dispose(); + expect(b.dispose).toHaveBeenCalledOnce(); + }); + it('replays a login onto the rebuilt scene, but only if one happened', async () => { const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 2319b328..3cc42486 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -64,30 +64,36 @@ export async function startBackgroundScene( const tier = resolveTier(config, capabilities); if (tier === 'off') return null; const build = (state: SkyState) => create(config, { ...options, tier }, state); - const first = await build(getSkyState()); + const state = getSkyState(); + const first = await build(state); if (!first || config?.style === 'raymarch') return first; let inner: Scene | null = first; + let built = state; let loggedIn = false; let disposed = false; let generation = 0; - const off = subscribe(async (state) => { + const rebuild = async (next: SkyState) => { generation += 1; const mine = generation; + built = next; inner?.dispose(); inner = null; - const next = await build(state); + const scene = await build(next); if (disposed || mine !== generation) { - next?.dispose(); + scene?.dispose(); return; } - inner = next; - if (!next) options.onInactive?.(); + inner = scene; + if (!scene) options.onInactive?.(); else { options.onActive?.(); - if (loggedIn) next.login(); + if (loggedIn) scene.login(); } - }); + }; + const off = subscribe(rebuild); + // A change that landed while the first build was awaited was missed by the subscription. + if (getSkyState() !== built) rebuild(getSkyState()); return { login() { loggedIn = true; diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 443e8fdb..1e09021d 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -2,7 +2,14 @@ import { describe, expect, it } from 'vitest'; import { baked } from '../../../sky/baked'; import { luminance } from '../../../sky/skyTokens.js'; import { skyColours } from './ridge/skyColour'; -import { DUSK_SUN_ELEVATION, ridgeSkyFor, terrainSkyFor } from './skyForState'; +import { chooseSky, skyColour } from './terrain/terrainSky'; +import { + DUSK_SUN_ELEVATION, + ridgeDrawn, + ridgeSkyFor, + terrainDrawn, + terrainSkyFor, +} from './skyForState'; import { skyBackdrop } from './skyState'; const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; @@ -63,3 +70,49 @@ describe('terrainSkyFor', () => { }); }); }); + +describe('the terrain sky lies inside skyBackdrop', () => { + // A grid offset from the one skyBackdrop samples, so a miss between its samples shows. + const sweep = (state: 'day' | 'dusk' | 'night') => { + const sky = chooseSky(terrainSkyFor(state)); + const out: number[] = []; + for (let el = 1.3; el <= 90; el += 4.1) { + for (let az = 0.7; az < 360; az += 4.3) { + const e = (el * Math.PI) / 180; + const a = (az * Math.PI) / 180; + const c = skyColour(sky, [ + Math.cos(e) * Math.sin(a), + Math.sin(e), + -Math.cos(e) * Math.cos(a), + ]); + const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); + out.push(0.2126 * r + 0.7152 * g + 0.0722 * b); + } + } + return out; + }; + + it.each(['day', 'dusk', 'night'] as const)('for %s', (state) => { + const { darkest, lightest } = skyBackdrop(state); + const l = sweep(state); + expect(Math.min(...l)).toBeGreaterThanOrEqual(darkest - EPS); + expect(Math.max(...l)).toBeLessThanOrEqual(lightest + EPS); + }); + + it('and the terrain dusk 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); + }); + }); +}); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index 64869440..aa3a7803 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -4,10 +4,12 @@ // `skyBackdrop` bounds, so the colours a scene paints lie inside the luminance range the login // text was checked against. import { baked } from '../../../sky/baked'; -import type { SkyName } from './terrain/terrainSky'; +import { chooseSky, skyColour } from './terrain/terrainSky'; +import type { SkyName, Vec3 } from './terrain/terrainSky'; import type { SkyColours, Srgb } from './ridge/skyColour'; import { skyColours } from './ridge/skyColour'; import type { SkyState } from './skyState'; +import { luminance } from '../../../sky/skyTokens.js'; const rgb = (hex: string): Srgb => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb; @@ -50,3 +52,47 @@ export function terrainSkyFor(state: SkyState): { sky?: SkyName; sun?: { elevati return {}; } } + +const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; + +/** Relative luminance (WCAG) of the darkest and lightest colour a scene can draw. */ +export interface Range { + readonly darkest: number; + 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))); + return { darkest: Math.min(...l), lightest: Math.max(...l) }; +} + +const SAMPLE_DEG = 3; +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. Sampled every 3 degrees. + */ +export function terrainDrawn(state: SkyState): Range { + const sky = chooseSky(terrainSkyFor(state)); + let darkest = Infinity; + let lightest = -Infinity; + for (let el = 0; el <= 90; el += SAMPLE_DEG) { + for (let az = 0; az < 360; az += SAMPLE_DEG) { + const e = (el * Math.PI) / 180; + const a = (az * Math.PI) / 180; + const l = linearLuminance( + skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) + ); + darkest = Math.min(darkest, l); + lightest = Math.max(lightest, l); + } + } + return { darkest, lightest }; +} diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index 4885abcd..7b33a1ba 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -105,15 +105,20 @@ describe('sky state', () => { }); describe('skyBackdrop', () => { - it('orders the fixed skies night < dusk < day by their lightest sky', () => { + it('puts night below day and dusk, which spans from dark to the sun glow', () => { + expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('day').darkest); expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest); - expect(skyBackdrop('dusk').lightest).toBeLessThan(skyBackdrop('day').lightest); + expect(skyBackdrop('dusk').darkest).toBeLessThan(skyBackdrop('day').darkest); }); - it('is the luminance of the baked gradients for day and night', () => { + it('holds the baked gradients for day and night', () => { const lum = (g: readonly string[]) => g.map(luminance); - expect(skyBackdrop('day').lightest).toBe(Math.max(...lum(baked.noonday.gradient))); - expect(skyBackdrop('night').darkest).toBe(Math.min(...lum(baked.midnight.gradient))); + expect(skyBackdrop('day').lightest).toBeGreaterThanOrEqual( + Math.max(...lum(baked.noonday.gradient)) - 1e-9 + ); + expect(skyBackdrop('night').darkest).toBeLessThanOrEqual( + Math.min(...lum(baked.midnight.gradient)) + 1e-9 + ); }); it('bounds every fixed sky when auto', () => { @@ -171,11 +176,9 @@ describe('login ink against the sky backdrop', () => { }); // 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; every other + // (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([ - 'day|Noonday Sky|text', - 'day|Noonday Sky|link', 'night|Light/Silver/Dark/Butter|text', 'night|Light/Silver/Dark/Butter|link', 'night|Midnight Sky|text', diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index 60e0471f..bc164d7a 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -2,9 +2,7 @@ // 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. -import { baked } from '../../../sky/baked'; -import { luminance } from '../../../sky/skyTokens.js'; -import { skyColours } from './ridge/skyColour'; +import { ridgeDrawn, terrainDrawn } from './skyForState'; export type SkyState = 'auto' | 'day' | 'dusk' | 'night'; @@ -59,26 +57,28 @@ export interface SkyBackdrop { readonly lightest: number; } -const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; -const bound = (colours: readonly string[]): SkyBackdrop => { - const l = colours.map(luminance); - return { darkest: Math.min(...l), lightest: Math.max(...l) }; +// 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. 'auto' follows the clock (and a URL), so it bounds all three fixed +// states. Computed on first use: the terrain range samples its sky. +const union = (ranges: readonly (SkyBackdrop | undefined)[]): SkyBackdrop => { + const r = ranges.filter((x): x is SkyBackdrop => !!x); + return { + darkest: Math.min(...r.map((x) => x.darkest)), + lightest: Math.max(...r.map((x) => x.lightest)), + }; }; -const FIXED: Record, SkyBackdrop> = (() => { - const dusk = skyColours(); - return { - day: bound(baked.noonday.gradient), - dusk: bound([dusk.top, dusk.mid, dusk.horizon].map(hex)), - night: bound(baked.midnight.gradient), - }; -})(); +const FIXED_STATES = ['day', 'dusk', 'night'] as const; +const cache = new Map(); export function skyBackdrop(state: SkyState): SkyBackdrop { - if (state !== 'auto') return FIXED[state]; - const all = Object.values(FIXED); - return { - darkest: Math.min(...all.map((b) => b.darkest)), - lightest: Math.max(...all.map((b) => b.lightest)), - }; + let b = cache.get(state); + if (!b) { + b = + state === 'auto' + ? union(FIXED_STATES.map(skyBackdrop)) + : union([ridgeDrawn(state), terrainDrawn(state)]); + cache.set(state, b); + } + return b; }