From 0e0cff66ae3752cf521272485543a73c472da021 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:20:49 +0000 Subject: [PATCH 1/3] scene: the ridge and terrain scenes follow the sky state The door (index.ts) reads getSkyState, subscribes on start and rebuilds the scene on the same canvas when it changes; the returned scene's dispose unsubscribes. skyForState.ts maps a state to the scene's sky option: auto leaves it unset (today's behaviour), dusk is the ridge dusk, day and night are the baked gradients skyBackdrop bounds. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 123 +++++++++++++++++++++ src/app/features/scene/index.ts | 87 ++++++++++++--- src/app/features/scene/skyForState.test.ts | 65 +++++++++++ src/app/features/scene/skyForState.ts | 52 +++++++++ 4 files changed, 310 insertions(+), 17 deletions(-) create mode 100644 src/app/features/scene/index.test.ts create mode 100644 src/app/features/scene/skyForState.test.ts create mode 100644 src/app/features/scene/skyForState.ts diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts new file mode 100644 index 00000000..5ea2cdab --- /dev/null +++ b/src/app/features/scene/index.test.ts @@ -0,0 +1,123 @@ +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('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('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..7dc5b4f0 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'; @@ -9,6 +12,33 @@ export type StartOptions = Omit 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 +46,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 first = await build(getSkyState()); + if (!first || config?.style === 'raymarch') return first; + + let inner: Scene | null = first; + let loggedIn = false; + let disposed = false; + let generation = 0; + const off = subscribe(async (state) => { + generation += 1; + const mine = generation; + inner?.dispose(); + inner = null; + const next = await build(state); + if (disposed || mine !== generation) { + next?.dispose(); + return; + } + inner = next; + if (loggedIn) inner?.login(); + }); + 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..443e8fdb --- /dev/null +++ b/src/app/features/scene/skyForState.test.ts @@ -0,0 +1,65 @@ +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 { 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); + }); + }); +}); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts new file mode 100644 index 00000000..64869440 --- /dev/null +++ b/src/app/features/scene/skyForState.ts @@ -0,0 +1,52 @@ +// 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 type { SkyName } from './terrain/terrainSky'; +import type { SkyColours, Srgb } from './ridge/skyColour'; +import { skyColours } from './ridge/skyColour'; +import type { SkyState } from './skyState'; + +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 {}; + } +} -- 2.47.3 From 6c22d4f1dc197e2017c664bf91a917e9abc623a3 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:23:47 +0000 Subject: [PATCH 2/3] scene: a failed sky-state rebuild restores the ground; an explicit sky beats the URL The door tells the host (onInactive/onActive) when a rebuild makes no scene or makes one again. chooseSky takes an explicit sun or sky option before ?sun=, ?az= and ?sky=; only auto, which passes none, is steered by the URL. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/host.test.ts | 14 +++++++++ src/app/features/scene/host.ts | 2 ++ src/app/features/scene/index.test.ts | 28 +++++++++++++++++ src/app/features/scene/index.ts | 10 +++++- .../features/scene/terrain/terrainSky.test.ts | 31 ++++++++++++++++++- src/app/features/scene/terrain/terrainSky.ts | 13 +++++--- 6 files changed, 92 insertions(+), 6 deletions(-) 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 index 5ea2cdab..684e0c67 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -77,6 +77,34 @@ describe('startBackgroundScene follows the sky state', () => { 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); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 7dc5b4f0..2319b328 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -10,6 +10,10 @@ 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( @@ -78,7 +82,11 @@ export async function startBackgroundScene( return; } inner = next; - if (loggedIn) inner?.login(); + if (!next) options.onInactive?.(); + else { + options.onActive?.(); + if (loggedIn) next.login(); + } }); return { login() { 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())); } -- 2.47.3 From edc6a9a8f15691968aa3c2fc9fb4fb659ad0e794 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:31:00 +0000 Subject: [PATCH 3/3] scene: catch a state change during the first build; skyBackdrop is the union of what the scenes draw index.ts compares the current state with the built one after subscribing and rebuilds if they differ. skyBackdrop unions the ridge sky and the terrain sky (skyColour sampled over the sphere, from chooseSky(terrainSkyFor(state))), so terrain dusk's sun glow and terrain day's darker zenith are inside the range. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 14 ++++++ src/app/features/scene/index.ts | 22 +++++---- src/app/features/scene/skyForState.test.ts | 55 +++++++++++++++++++++- src/app/features/scene/skyForState.ts | 48 ++++++++++++++++++- src/app/features/scene/skyState.test.ts | 19 ++++---- src/app/features/scene/skyState.ts | 42 ++++++++--------- 6 files changed, 161 insertions(+), 39 deletions(-) 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; } -- 2.47.3