diff --git a/src/app/features/scene/host.test.ts b/src/app/features/scene/host.test.ts index 272f502f..b987d0cc 100644 --- a/src/app/features/scene/host.test.ts +++ b/src/app/features/scene/host.test.ts @@ -108,6 +108,20 @@ describe('mountBackgroundScene', () => { expect(scene.login).toHaveBeenCalledOnce(); }); + it('hands the door its onInactive and onActive, for a sky-state rebuild that makes no scene', async () => { + startBackgroundScene.mockResolvedValue(fakeScene()); + const onInactive = vi.fn(); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive, onActive }); + await flush(); + const given = startBackgroundScene.mock.calls[0][2]; + onActive.mockClear(); + given.onInactive(); + given.onActive(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(onActive).toHaveBeenCalledOnce(); + }); + describe('a lost WebGL context', () => { const lose = () => startBackgroundScene.mock.calls[ diff --git a/src/app/features/scene/host.ts b/src/app/features/scene/host.ts index e771f696..9a31c812 100644 --- a/src/app/features/scene/host.ts +++ b/src/app/features/scene/host.ts @@ -40,6 +40,8 @@ export function mountBackgroundScene( startBackgroundScene(config, capabilities, { canvas, onCompileFail: onFail, + onInactive, + onActive, onContextLost: () => { if (stale()) return; generation += 1; diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts new file mode 100644 index 00000000..6c3d034a --- /dev/null +++ b/src/app/features/scene/index.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { baked } from '../../../sky/baked'; +import type { Scene } from './Scene'; +import { startBackgroundScene } from './index'; +import { setSkyState } from './skyState'; + +const { createRidgeScene, createScene } = vi.hoisted(() => ({ + createRidgeScene: vi.fn(), + createScene: vi.fn(), +})); +vi.mock('./ridge/Ridge', () => ({ createRidgeScene })); +vi.mock('./Scene', () => ({ createScene })); + +const canvas = {} as HTMLCanvasElement; +const config = { tier: 'low' as const }; +const caps = { hardwareConcurrency: 8 }; +const flush = () => + new Promise((r) => { + setTimeout(r, 0); + }); +const fakeScene = () => ({ login: vi.fn(), dispose: vi.fn() }); +const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; + +beforeEach(() => { + vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }); + setSkyState('auto'); + createRidgeScene.mockReset(); + createScene.mockReset(); +}); +afterEach(() => { + 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 () => { + createRidgeScene.mockReturnValue(fakeScene()); + const scene = await startBackgroundScene(config, caps, { canvas }); + expect(createRidgeScene).toHaveBeenCalledOnce(); + expect(createRidgeScene.mock.calls[0][0].sky).toBeUndefined(); + scene?.dispose(); + }); + + it('starts on the stored state', async () => { + setSkyState('night'); + createRidgeScene.mockReturnValue(fakeScene()); + const scene = await startBackgroundScene(config, caps, { canvas }); + expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]); + scene?.dispose(); + }); + + it('rebuilds on a change: the old scene is disposed, the new one draws the new sky', async () => { + const first = fakeScene(); + const second = fakeScene(); + createRidgeScene.mockReturnValueOnce(first).mockReturnValueOnce(second); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + await flush(); + expect(first.dispose).toHaveBeenCalledOnce(); + expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]); + scene?.dispose(); + 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); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + await flush(); + expect(b.login).not.toHaveBeenCalled(); + scene?.login(); + expect(b.login).toHaveBeenCalledOnce(); + setSkyState('dusk'); + await flush(); + expect(c.login).toHaveBeenCalledOnce(); + scene?.dispose(); + }); + + it('puts the ground back when a rebuild makes no scene, and lets it show when one returns', async () => { + const [a, c] = [fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(null).mockReturnValueOnce(c); + const onInactive = vi.fn(); + const onActive = vi.fn(); + const scene = await startBackgroundScene(config, caps, { canvas, onInactive, onActive }); + expect(onInactive).not.toHaveBeenCalled(); + setSkyState('day'); + await flush(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(onActive).not.toHaveBeenCalled(); + setSkyState('dusk'); + await flush(); + expect(onActive).toHaveBeenCalledOnce(); + expect(onInactive).toHaveBeenCalledOnce(); + scene?.dispose(); + }); + + it('does not report a dropped rebuild as inactive', async () => { + createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(null); + const onInactive = vi.fn(); + const scene = await startBackgroundScene(config, caps, { canvas, onInactive }); + setSkyState('day'); + scene?.dispose(); + await flush(); + expect(onInactive).not.toHaveBeenCalled(); + }); + + it('removes the subscription on dispose', async () => { + const first = fakeScene(); + createRidgeScene.mockReturnValue(first); + const scene = await startBackgroundScene(config, caps, { canvas }); + scene?.dispose(); + scene?.dispose(); + setSkyState('day'); + await flush(); + expect(createRidgeScene).toHaveBeenCalledOnce(); + expect(first.dispose).toHaveBeenCalledOnce(); + }); + + it('drops a scene whose rebuild finishes after dispose', async () => { + const second = fakeScene(); + createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(second); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + scene?.dispose(); + await flush(); + expect(second.dispose).toHaveBeenCalledOnce(); + }); + + it('keeps only the latest of two quick changes', async () => { + const scenes = [fakeScene(), fakeScene(), fakeScene()]; + scenes.forEach((s) => createRidgeScene.mockReturnValueOnce(s)); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + setSkyState('night'); + await flush(); + expect(scenes[1].dispose).toHaveBeenCalledOnce(); + expect(scenes[2].dispose).not.toHaveBeenCalled(); + scene?.dispose(); + }); + + it('leaves the ray-march scene alone: it has one fixed dusk', async () => { + const inner = fakeScene() as unknown as Scene; + createScene.mockReturnValue(inner); + const scene = await startBackgroundScene({ ...config, style: 'raymarch' }, caps, { canvas }); + expect(scene).toBe(inner); + setSkyState('day'); + await flush(); + expect(createScene).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 794ddc11..3cc42486 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -1,5 +1,8 @@ import type { Scene, SceneOptions } from './Scene'; -import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier'; +import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; +import { getSkyState, subscribe } from './skyState'; +import type { SkyState } from './skyState'; +import { ridgeSkyFor, terrainSkyFor } from './skyForState'; export type { Scene, SceneStats } from './Scene'; export type { SceneStyle, SceneTier } from './tier'; @@ -7,8 +10,39 @@ export type { SceneStyle, SceneTier } from './tier'; export type StartOptions = Omit & { /** A shader failed to compile, or the scene chunk failed to load. The host keeps its ground. */ onCompileFail?: (e: Error) => void; + /** A rebuild for a new sky state made no scene: the host puts the CSS ground back (as on a lost context). */ + onInactive?: () => void; + /** A rebuild made a scene again: the host lets it show. */ + onActive?: () => void; }; +async function create( + config: BackgroundSceneConfig | undefined, + options: StartOptions & { tier: SceneTier }, + state: SkyState +): Promise { + let createScene: (o: SceneOptions) => Scene | null; + try { + if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; + else if (config?.style === 'terrain') { + const { createTerrainScene } = await import('./terrain/Terrain'); + const sky = terrainSkyFor(state); + const scene = createTerrainScene({ ...options, ...sky }); + // No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground. + if (scene) return scene; + const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D'); + return createTerrainSkyScene({ ...options, ...sky }); + } else { + const { createRidgeScene } = await import('./ridge/Ridge'); + return createRidgeScene({ ...options, sky: ridgeSkyFor(state) }); + } + } catch (e) { + options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); + return null; + } + return createScene(options); +} + /** * The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no * canvas context is requested; otherwise the scene code loads on demand, so it stays out of the @@ -16,6 +50,11 @@ export type StartOptions = Omit { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - let createScene: (o: SceneOptions) => Scene | null; - try { - if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; - else if (config?.style === 'terrain') { - const { createTerrainScene } = await import('./terrain/Terrain'); - const scene = createTerrainScene({ ...options, tier }); - // No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground. - if (scene) return scene; - const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D'); - return createTerrainSkyScene({ ...options, tier }); - } else createScene = (await import('./ridge/Ridge')).createRidgeScene; - } catch (e) { - options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); - return null; - } - return createScene({ ...options, tier }); + const build = (state: SkyState) => create(config, { ...options, tier }, state); + 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 rebuild = async (next: SkyState) => { + generation += 1; + const mine = generation; + built = next; + inner?.dispose(); + inner = null; + const scene = await build(next); + if (disposed || mine !== generation) { + scene?.dispose(); + return; + } + inner = scene; + if (!scene) options.onInactive?.(); + else { + options.onActive?.(); + 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; + inner?.login(); + }, + dispose() { + if (disposed) return; + disposed = true; + off(); + inner?.dispose(); + inner = null; + }, + }; } diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts new file mode 100644 index 00000000..1e09021d --- /dev/null +++ b/src/app/features/scene/skyForState.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../sky/baked'; +import { luminance } from '../../../sky/skyTokens.js'; +import { skyColours } from './ridge/skyColour'; +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('')}`; +const EPS = 1e-9; + +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 current ridge dusk for dusk', () => { + expect(ridgeSkyFor('dusk')).toEqual(skyColours()); + }); + + 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]); + }); + + 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); + }); + } + ); +}); + +describe('terrainSkyFor', () => { + it('leaves auto to the clock and the URL', () => { + expect(terrainSkyFor('auto')).toEqual({}); + }); + + 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.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); + }); + }); +}); + +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 new file mode 100644 index 00000000..aa3a7803 --- /dev/null +++ b/src/app/features/scene/skyForState.ts @@ -0,0 +1,98 @@ +// 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. +import { baked } from '../../../sky/baked'; +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; + +const fromGradient = (g: readonly string[], ground: Srgb): SkyColours => ({ + top: rgb(g[0]), + mid: rgb(g[Math.floor(g.length / 2)]), + horizon: rgb(g[g.length - 1]), + ground, +}); + +/** 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 { + const { ground } = skyColours(); + switch (state) { + case 'dusk': + return skyColours(); + case 'day': + return fromGradient(baked.noonday.gradient, ground); + case 'night': + return fromGradient(baked.midnight.gradient, ground); + default: + return undefined; + } +} + +/** 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) { + case 'day': + return { sky: 'noonday' }; + case 'night': + return { sky: 'midnight' }; + case 'dusk': + return { sun: { elevation: DUSK_SUN_ELEVATION } }; + default: + 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; } diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index 016ee1ce..a6dc7a7e 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -1,8 +1,9 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ import { AP_DISTANCE_SCALE } from './terrainScatter'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { baked } from '../../../../sky/baked'; import { + chooseSky, coldness, glowColour, hazeColour, @@ -11,6 +12,7 @@ import { shade, SKY, skyAtSun, + skyData, skyColour, skyNameFor, sunDirection, @@ -298,3 +300,30 @@ describe('ground light and haze', () => { expect(Math.abs(along)).toBeLessThan(0.4); }); }); + +describe('chooseSky: an explicit sky wins over the page URL', () => { + const at = (search: string) => vi.stubGlobal('window', { location: { search } }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('lets the URL steer when no option is given', () => { + at('?sun=40&az=90'); + expect(chooseSky({})).toEqual(skyAtSun(40, 90)); + at('?sky=noonday'); + expect(chooseSky({})).toEqual(skyData('noonday')); + }); + + it('prefers an explicit baked sky to ?sun= and ?sky=', () => { + at('?sun=40&az=90&sky=noonday'); + expect(chooseSky({ sky: 'midnight' })).toEqual(skyData('midnight')); + at('?sky=noonday'); + expect(chooseSky({ sky: 'midnight' })).toEqual(skyData('midnight')); + }); + + it('prefers an explicit sun to ?sun=, and takes no azimuth from ?az=', () => { + at('?sun=40&az=90&sky=noonday'); + expect(chooseSky({ sun: { elevation: 2 } })).toEqual(skyAtSun(2, undefined)); + expect(chooseSky({ sun: { elevation: 2, azimuth: 10 } })).toEqual(skyAtSun(2, 10)); + }); +}); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index 4034fd93..f6647407 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -228,19 +228,24 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, }) as unknown as Vec3; } -/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ +/** + * 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); const q = new URLSearchParams(window.location.search); const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); - const elevation = opts.sun?.elevation ?? number('sun'); + const elevation = number('sun'); if (Number.isFinite(elevation)) { - const azimuth = opts.sun?.azimuth ?? number('az'); + const azimuth = number('az'); return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); } - if (opts.sky) return skyData(opts.sky); const name = q.get('sky'); return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); }