scene: ridge and terrain scenes follow the sky state #47

Merged
void merged 4 commits from nibu/sky-consumer into dev 2026-11-09 22:35:18 +00:00 AGit
10 changed files with 552 additions and 51 deletions

View file

@ -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[

View file

@ -40,6 +40,8 @@ export function mountBackgroundScene(
startBackgroundScene(config, capabilities, {
canvas,
onCompileFail: onFail,
onInactive,
onActive,
onContextLost: () => {
if (stale()) return;
generation += 1;

View file

@ -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<void>((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();
});
});

View file

@ -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<SceneOptions, 'tier' | 'patchSource' | 'onCompileFail'> & {
/** 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<Scene | 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 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<SceneOptions, 'tier' | 'patchSource' | 'onCompil
* sky alone, `terrain/TerrainSky2D.ts`, where WebGL2 is missing). Null means
* "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
* 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.
*/
export async function startBackgroundScene(
config: BackgroundSceneConfig | undefined,
@ -24,20 +63,48 @@ export async function startBackgroundScene(
): Promise<Scene | null> {
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;
},
};
}

View file

@ -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);
});
});
});

View file

@ -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 };
}

View file

@ -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',

View file

@ -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<Exclude<SkyState, 'auto'>, 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<SkyState, SkyBackdrop>();
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;
}

View file

@ -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));
});
});

View file

@ -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()));
}