scene: ridge and terrain scenes follow the sky state #47
10 changed files with 552 additions and 51 deletions
|
|
@ -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[
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ export function mountBackgroundScene(
|
|||
startBackgroundScene(config, capabilities, {
|
||||
canvas,
|
||||
onCompileFail: onFail,
|
||||
onInactive,
|
||||
onActive,
|
||||
onContextLost: () => {
|
||||
if (stale()) return;
|
||||
generation += 1;
|
||||
|
|
|
|||
165
src/app/features/scene/index.test.ts
Normal file
165
src/app/features/scene/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
118
src/app/features/scene/skyForState.test.ts
Normal file
118
src/app/features/scene/skyForState.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
src/app/features/scene/skyForState.ts
Normal file
98
src/app/features/scene/skyForState.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue