scene: shape each sky state's drawn sky into a luminance band its text ink clears #54
22 changed files with 914 additions and 377 deletions
|
|
@ -1,8 +1,9 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { atStandardTime } from './standardTime.testkit';
|
||||
import { baked } from '../../../sky/baked';
|
||||
import type { Scene } from './Scene';
|
||||
import { startBackgroundScene } from './index';
|
||||
import { setSkyState } from './skyState';
|
||||
import { setSkyClock, setSkyState } from './skyState';
|
||||
|
||||
const { createRidgeScene, createScene } = vi.hoisted(() => ({
|
||||
createRidgeScene: vi.fn(),
|
||||
|
|
@ -23,21 +24,35 @@ const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2
|
|||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined });
|
||||
// Local midnight: 'auto' resolves to night.
|
||||
setSkyClock(() => atStandardTime(0, 0));
|
||||
setSkyState('auto');
|
||||
createRidgeScene.mockReset();
|
||||
createScene.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
setSkyClock();
|
||||
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 () => {
|
||||
it('hands the ridge scene the sky auto resolves to (night at midnight)', async () => {
|
||||
createRidgeScene.mockReturnValue(fakeScene());
|
||||
const scene = await startBackgroundScene(config, caps, { canvas });
|
||||
expect(createRidgeScene).toHaveBeenCalledOnce();
|
||||
expect(createRidgeScene.mock.calls[0][0].sky).toBeUndefined();
|
||||
expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]);
|
||||
scene?.dispose();
|
||||
});
|
||||
|
||||
it('rebuilds when auto is replaced by the explicit state it already resolves to (a URL may steer only auto)', async () => {
|
||||
const [a, b] = [fakeScene(), fakeScene()];
|
||||
createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b);
|
||||
const scene = await startBackgroundScene(config, caps, { canvas });
|
||||
setSkyState('night');
|
||||
await flush();
|
||||
expect(a.dispose).toHaveBeenCalledOnce();
|
||||
expect(createRidgeScene).toHaveBeenCalledTimes(2);
|
||||
scene?.dispose();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Scene, SceneOptions } from './Scene';
|
||||
import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier';
|
||||
import { getSkyState, subscribe } from './skyState';
|
||||
import { getResolvedSkyState, getSkyState, subscribe } from './skyState';
|
||||
import type { SkyState } from './skyState';
|
||||
|
||||
export type { Scene, SceneStats } from './Scene';
|
||||
|
|
@ -52,7 +52,7 @@ async function create(
|
|||
* "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
|
||||
* The ridge and terrain scenes bake their sky once, so they follow the sky (`skyState.ts`: the choice, resolved, so 'auto' follows the clock) 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.
|
||||
|
|
@ -66,18 +66,23 @@ export async function startBackgroundScene(
|
|||
if (tier === 'off') return null;
|
||||
const build = (state: SkyState) => create(config, { ...options, tier }, state);
|
||||
const state = getSkyState();
|
||||
const key0 = `${state}:${getResolvedSkyState()}`;
|
||||
const first = await build(state);
|
||||
if (!first || config?.style === 'raymarch') return first;
|
||||
|
||||
let inner: Scene | null = first;
|
||||
let built = state;
|
||||
let built = key0;
|
||||
let loggedIn = false;
|
||||
let disposed = false;
|
||||
let generation = 0;
|
||||
const rebuild = async (next: SkyState) => {
|
||||
// A rebuild is for a new choice or a new resolved sky ('auto' crossing a boundary), not for a repeat of either.
|
||||
const rebuild = async () => {
|
||||
const next = getSkyState();
|
||||
const key = `${next}:${getResolvedSkyState()}`;
|
||||
if (key === built) return;
|
||||
generation += 1;
|
||||
const mine = generation;
|
||||
built = next;
|
||||
built = key;
|
||||
inner?.dispose();
|
||||
inner = null;
|
||||
const scene = await build(next);
|
||||
|
|
@ -94,7 +99,7 @@ export async function startBackgroundScene(
|
|||
};
|
||||
const off = subscribe(rebuild);
|
||||
// A change that landed while the first build was awaited was missed by the subscription.
|
||||
if (getSkyState() !== built) rebuild(getSkyState());
|
||||
rebuild();
|
||||
return {
|
||||
login() {
|
||||
loggedIn = true;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
// The one place the sky's colours come from. Void's `src/sky/sun.js` (`light({ time, lat, lon,
|
||||
// cloud_cover, precipitation })`, PR #20) is the intended source; until it lands this returns a
|
||||
// fixed dusk drawn from GameCult's nebula inputs (see ../palette.ts). Replace the body, keep the
|
||||
// signature: the scene calls it once, at startup, and bakes the result.
|
||||
// fixed twilight: lavender zenith, rose mid-sky, orange horizon. Its darkest blend stays above the dusk
|
||||
// floor of ../skyBand.ts (the dark ink of Noonday Sky needs a backdrop of at least 0.2983 luminance), which
|
||||
// skyForState.test.ts sweeps. Replace the body, keep the signature: the scene calls it once, at startup,
|
||||
// and bakes the result.
|
||||
|
||||
export type Srgb = readonly [number, number, number];
|
||||
|
||||
|
|
@ -20,9 +22,9 @@ export interface SkyColours {
|
|||
|
||||
export function skyColours(_now: Date = new Date()): SkyColours {
|
||||
return {
|
||||
top: [24, 30, 78],
|
||||
mid: [78, 66, 150],
|
||||
horizon: [226, 130, 74],
|
||||
top: [170, 145, 220],
|
||||
mid: [225, 130, 165],
|
||||
horizon: [240, 150, 80],
|
||||
ground: [7, 17, 26],
|
||||
sunAzimuth: 0.3,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -41,9 +41,10 @@ describe('star placement', () => {
|
|||
expect(makeStars(skyColours(), 390, 844)).toEqual(makeStars(skyColours(), 390, 844));
|
||||
});
|
||||
|
||||
it('draws none in a day sky, and the dusk default is dark enough for some', () => {
|
||||
it('draws none in a day sky or the lavender dusk, and some in a night one', () => {
|
||||
expect(makeStars(DAY, 390, 844)).toHaveLength(0);
|
||||
expect(makeStars(skyColours(), 390, 844).length).toBeGreaterThan(50);
|
||||
expect(makeStars(skyColours(), 390, 844)).toHaveLength(0);
|
||||
expect(makeStars(NIGHT, 390, 844).length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('is a few hundred at most: bounded by MAX_STARS and by the viewport', () => {
|
||||
|
|
|
|||
83
src/app/features/scene/skyBand.test.ts
Normal file
83
src/app/features/scene/skyBand.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { displayedLuminance, hexToLinear, luma, shapeToBand, SKY_BAND } from './skyBand';
|
||||
import type { Linear } from './skyBand';
|
||||
import { GRID_FRAG, SHAPE_SKY_GLSL, SKY_FRAG } from './terrain/terrainShaders';
|
||||
|
||||
const near = (a: number, b: number) => Math.abs(a - b) < 1e-9;
|
||||
|
||||
describe('shapeToBand', () => {
|
||||
const dark: Linear = [0.02, 0.05, 0.2]; // a deep blue
|
||||
const { day } = SKY_BAND;
|
||||
|
||||
it('lifts a sky below the floor onto the floor, mixing toward the tint so the hue is kept', () => {
|
||||
const c = shapeToBand(dark, day);
|
||||
expect(near(luma(c), day.floor)).toBe(true);
|
||||
// Between the sky and the tint on every channel: a mix, not a grey clamp.
|
||||
[0, 1, 2].forEach((i) => {
|
||||
expect(c[i]).toBeGreaterThanOrEqual(Math.min(dark[i], day.lift[i]));
|
||||
expect(c[i]).toBeLessThanOrEqual(Math.max(dark[i], day.lift[i]));
|
||||
});
|
||||
expect(c[2]).toBeGreaterThan(c[0]); // still blue
|
||||
});
|
||||
|
||||
it('lifts black to the tint scaled in, not to grey', () => {
|
||||
const c = shapeToBand([0, 0, 0], SKY_BAND.dusk);
|
||||
expect(near(luma(c), SKY_BAND.dusk.floor)).toBe(true);
|
||||
expect(c[0]).toBeGreaterThan(c[2]); // the dusk tint is rose
|
||||
});
|
||||
|
||||
it('leaves a sky already in the band as it is, and is continuous at the floor', () => {
|
||||
const ok: Linear = [0.5, 0.6, 0.7];
|
||||
expect(shapeToBand(ok, day)).toEqual(ok);
|
||||
const lo = shapeToBand([0.31, 0.31, 0.31], day);
|
||||
const below = shapeToBand([0.3099, 0.3099, 0.3099], day);
|
||||
expect(Math.abs(lo[0] - below[0])).toBeLessThan(1e-3);
|
||||
});
|
||||
|
||||
it('scales a sky above the ceiling down to it, keeping its hue', () => {
|
||||
const c = shapeToBand([0.9, 0.8, 1], SKY_BAND.night);
|
||||
expect(near(luma(c), SKY_BAND.night.ceiling)).toBe(true);
|
||||
expect(near(c[0] / c[2], 0.9)).toBe(true);
|
||||
});
|
||||
|
||||
it('clamps to 0..1 as the screen does, and never leaves the band when shaped twice', () => {
|
||||
expect(shapeToBand([4, 4, 4], day)).toEqual([1, 1, 1]);
|
||||
const once = shapeToBand(dark, day);
|
||||
shapeToBand(once, day).forEach((v, i) => expect(v).toBeCloseTo(once[i], 12));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the bands', () => {
|
||||
it('have a tint above the floor to lift toward', () => {
|
||||
Object.values(SKY_BAND).forEach((b) => expect(luma(b.lift)).toBeGreaterThan(b.floor));
|
||||
});
|
||||
|
||||
it('keep the floor above the 0.2983 text bound by what gamma 2.2, 8-bit rounding and dither can take', () => {
|
||||
const grey = shapeToBand([0, 0, 0], SKY_BAND.day);
|
||||
const shown = displayedLuminance(grey, 'low');
|
||||
expect(shown).toBeGreaterThanOrEqual(0.2983);
|
||||
expect(shown).toBeLessThan(SKY_BAND.day.floor);
|
||||
});
|
||||
|
||||
it('decode hex as linear light', () => {
|
||||
expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the GLSL twin', () => {
|
||||
it('shapes with the same arithmetic: clamp, luma weights, mix to the floor, scale to the ceiling', () => {
|
||||
expect(SHAPE_SKY_GLSL).toContain('c = clamp(c, 0.0, 1.0);');
|
||||
expect(SHAPE_SKY_GLSL).toContain('const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);');
|
||||
expect(SHAPE_SKY_GLSL).toContain(
|
||||
'if (l < uBand.x) return mix(c, uLift, (uBand.x - l) / (dot(uLift, LUMA) - l));'
|
||||
);
|
||||
expect(SHAPE_SKY_GLSL).toContain('if (l > uBand.y) return c * (uBand.y / l);');
|
||||
});
|
||||
|
||||
it('is applied to the sky colour in both shaders that draw it, the sky pass and the haze that fades into it', () => {
|
||||
[SKY_FRAG, GRID_FRAG].forEach((src) => {
|
||||
expect(src).toContain(SHAPE_SKY_GLSL);
|
||||
expect(src).toMatch(/return shapeSky\(1\.0 - exp\(-45\.0 \* skyScatter\(d\)\)/);
|
||||
});
|
||||
});
|
||||
});
|
||||
83
src/app/features/scene/skyBand.ts
Normal file
83
src/app/features/scene/skyBand.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// The luminance band each fixed sky state's drawn sky is shaped into, so the state's theme ink
|
||||
// clears 4.5:1 on bare sky (WCAG relative luminance, the `luminance` of src/sky/skyTokens.js).
|
||||
//
|
||||
// day, dusk Noonday Sky's dark text ink #342d25 (L 0.0274) needs a backdrop of at least 0.2983.
|
||||
// The floor is that text bound (0.30 as drawn); there is no ceiling, dark ink on light is fine.
|
||||
// night Midnight Sky's light text ink #aeccff (L 0.594) needs a backdrop of at most 0.0931.
|
||||
// The baked night sky (0.058 to 0.077) is kept; the ceiling is the bound.
|
||||
//
|
||||
// Link ink is not part of the band: see skyTheme.ts.
|
||||
//
|
||||
// The terrain sky is a physical scatter function, so it is shaped downstream, on its final colour:
|
||||
// `shapeToBand` here, and its GLSL twin (`SHAPE_SKY_GLSL`) in terrain/terrainShaders.ts, built from the
|
||||
// same constants. The ridge sky is a three-stop gradient whose stops are chosen inside the band
|
||||
// (ridge/skyColour.ts, skyForState.ts), and its test sweeps the blends.
|
||||
export type FixedSkyState = 'day' | 'dusk' | 'night';
|
||||
export type Linear = readonly [number, number, number];
|
||||
|
||||
/** WCAG luma weights, applied to linear RGB. */
|
||||
export const LUMA: Linear = [0.2126, 0.7152, 0.0722];
|
||||
export const luma = (c: Linear): number => LUMA[0] * c[0] + LUMA[1] * c[1] + LUMA[2] * c[2];
|
||||
|
||||
/** sRGB hex `#rrggbb` to linear RGB 0..1. */
|
||||
export function hexToLinear(hex: string): Linear {
|
||||
const ch = (at: number) => {
|
||||
const s = parseInt(hex.slice(at, at + 2), 16) / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return [ch(1), ch(3), ch(5)];
|
||||
}
|
||||
|
||||
/** How a displayed colour is read: as encoded, or moved one 8-bit step down or up in every channel ('low', 'high'). */
|
||||
export type Reading = 'exact' | 'low' | 'high';
|
||||
|
||||
/**
|
||||
* Relative luminance of what the terrain shaders put on screen for a linear colour: they encode with
|
||||
* pow(1/2.2), not the sRGB curve, so the screen colour is read back through the sRGB curve. 'low' and 'high' move every
|
||||
* encoded channel one 8-bit step down or up first: rounding to 8 bits (half a step) plus the sky pass's dither (half a
|
||||
* step) cannot move a channel further, so they bound what the screen shows. A smooth bound, so a search can climb it.
|
||||
*/
|
||||
export function displayedLuminance(c: Linear, reading: Reading = 'exact'): number {
|
||||
const shift = { exact: 0, low: -1 / 255, high: 1 / 255 }[reading];
|
||||
const ch = (v: number) => {
|
||||
const e = Math.min(1, Math.max(0, Math.min(1, Math.max(0, v)) ** (1 / 2.2) + shift));
|
||||
return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return luma([ch(c[0]), ch(c[1]), ch(c[2])]);
|
||||
}
|
||||
|
||||
export interface SkyBand {
|
||||
/** Linear luma the shaped colour is at least (0: no floor). */
|
||||
floor: number;
|
||||
/** Linear luma the shaped colour is at most (1: no ceiling). */
|
||||
ceiling: number;
|
||||
/** The colour a too-dark sky is mixed toward: the state's own tint, so the hue survives the lift. Its luma is above `floor`. */
|
||||
lift: Linear;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shaping targets, in linear luma. The floor sits 0.019 above the text bound (0.2983): the screen encoding is
|
||||
* gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level, and 8-bit rounding with the sky
|
||||
* pass's dither at its worst (one step in every channel) takes about 0.007 more.
|
||||
*/
|
||||
export const SKY_BAND: Record<FixedSkyState, SkyBand> = {
|
||||
day: { floor: 0.317, ceiling: 1, lift: hexToLinear('#e2f4ff') },
|
||||
dusk: { floor: 0.317, ceiling: 1, lift: hexToLinear('#eba0b9') },
|
||||
night: { floor: 0, ceiling: 0.085, lift: hexToLinear('#5a6a9a') },
|
||||
};
|
||||
|
||||
/**
|
||||
* Shape a linear sky colour into the band. Below the floor it mixes toward `lift` by exactly the amount
|
||||
* that lands on the floor (continuous at the floor, hue kept); above the ceiling it scales down to the
|
||||
* ceiling. The colour is clamped to 0..1 first, as the screen does.
|
||||
*/
|
||||
export function shapeToBand(c: Linear, band: SkyBand): Linear {
|
||||
const k = [0, 1, 2].map((i) => Math.min(1, Math.max(0, c[i]))) as unknown as Linear;
|
||||
const l = luma(k);
|
||||
if (l < band.floor) {
|
||||
const t = (band.floor - l) / (luma(band.lift) - l);
|
||||
return [0, 1, 2].map((i) => k[i] + (band.lift[i] - k[i]) * t) as unknown as Linear;
|
||||
}
|
||||
if (l > band.ceiling) return [0, 1, 2].map((i) => (k[i] * band.ceiling) / l) as unknown as Linear;
|
||||
return k;
|
||||
}
|
||||
|
|
@ -1,151 +1,249 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { atStandardTime } from './standardTime.testkit';
|
||||
import { baked } from '../../../sky/baked';
|
||||
import { luminance } from '../../../sky/skyTokens.js';
|
||||
import { AA, luminance } from '../../../sky/skyTokens.js';
|
||||
import { skyColours } from './ridge/skyColour';
|
||||
import { chooseSky, skyColour } from './terrain/terrainSky';
|
||||
import {
|
||||
DUSK_SUN_ELEVATION,
|
||||
TERRAIN_REACH_DEG,
|
||||
ridgeDrawn,
|
||||
ridgeSkyFor,
|
||||
terrainDrawn,
|
||||
terrainSkyFor,
|
||||
} from './skyForState';
|
||||
import { skyBackdrop } from './skyState';
|
||||
import { displayedLuminance, SKY_BAND } from './skyBand';
|
||||
import type { FixedSkyState } from './skyBand';
|
||||
import { setSkyClock, skyBackdrop } from './skyState';
|
||||
import { view } from './terrain/terrainWorld';
|
||||
import { skyThemeId } from './skyTheme';
|
||||
|
||||
const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
||||
const EPS = 1e-9;
|
||||
const STATES = ['day', 'dusk', 'night'] as const;
|
||||
const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const;
|
||||
|
||||
/**
|
||||
* The band each state's text ink needs, from the ink itself: dark ink needs a backdrop of at least
|
||||
* AA * (L + 0.05) - 0.05, light ink at most (L + 0.05) / AA - 0.05. Day 0.2983, dusk 0.2983, night 0.0931.
|
||||
*/
|
||||
const needs = (state: FixedSkyState) => {
|
||||
const l = luminance(SKY[skyThemeId(state)].tokens.Background.OnContainer);
|
||||
return state === 'night'
|
||||
? { floor: 0, ceiling: (l + 0.05) / AA - 0.05 }
|
||||
: { floor: AA * (l + 0.05) - 0.05, ceiling: 1 };
|
||||
};
|
||||
|
||||
let search = '';
|
||||
beforeEach(() => {
|
||||
search = '';
|
||||
vi.stubGlobal('window', {
|
||||
get location() {
|
||||
return { search };
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
setSkyClock();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('the bands the text ink needs', () => {
|
||||
it('are the numbers the shaping was set from', () => {
|
||||
expect(needs('day').floor).toBeCloseTo(0.2983, 3);
|
||||
expect(needs('dusk').floor).toBeCloseTo(0.2983, 3);
|
||||
expect(needs('night').ceiling).toBeCloseTo(0.0931, 3);
|
||||
});
|
||||
});
|
||||
|
||||
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 gradient of the sky auto resolves to, never nothing', () => {
|
||||
setSkyClock(() => atStandardTime(12, 0));
|
||||
expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day'));
|
||||
setSkyClock(() => atStandardTime(0, 0));
|
||||
expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('night'));
|
||||
setSkyClock(() => atStandardTime(18, 10));
|
||||
expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('dusk'));
|
||||
});
|
||||
|
||||
it('draws the current ridge dusk for dusk', () => {
|
||||
expect(ridgeSkyFor('dusk')).toEqual(skyColours());
|
||||
it('draws a lavender, rose and orange twilight for dusk', () => {
|
||||
const d = ridgeSkyFor('dusk');
|
||||
expect(d).toEqual(skyColours());
|
||||
const [t, m, h] = [d.top, d.mid, d.horizon];
|
||||
expect(t[2]).toBeGreaterThan(t[1]); // lavender: blue over green
|
||||
expect(t[0]).toBeGreaterThan(t[1]);
|
||||
expect(m[0]).toBeGreaterThan(m[2]); // rose: red over blue
|
||||
expect(m[2]).toBeGreaterThan(m[1]);
|
||||
expect(h[0]).toBeGreaterThan(h[1]); // orange: red over green over blue
|
||||
expect(h[1]).toBeGreaterThan(h[2]);
|
||||
});
|
||||
|
||||
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]);
|
||||
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);
|
||||
});
|
||||
it.each(STATES)('%s: every stop and blend lies in the band its text ink needs', (state) => {
|
||||
const { floor, ceiling } = needs(state);
|
||||
const r = ridgeDrawn(state);
|
||||
expect(r.darkest).toBeGreaterThanOrEqual(floor);
|
||||
expect(r.lightest).toBeLessThanOrEqual(ceiling);
|
||||
});
|
||||
});
|
||||
|
||||
// Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns)
|
||||
// plus a 0.05 degree grid across the sun, five times finer than the generator's.
|
||||
const dirAt = (el: number, az: number): [number, number, number] => {
|
||||
const rad = Math.PI / 180;
|
||||
return [
|
||||
Math.cos(el * rad) * Math.sin(az * rad),
|
||||
Math.sin(el * rad),
|
||||
-Math.cos(el * rad) * Math.cos(az * rad),
|
||||
];
|
||||
};
|
||||
/** Over the sweep: the darkest the 8-bit screen can show (dither at its worst), the lightest, and the darkest as encoded. */
|
||||
const sweepOf = (sky: ReturnType<typeof chooseSky>, N = 4000, maxEl = TERRAIN_REACH_DEG) => {
|
||||
const rad = Math.PI / 180;
|
||||
const out = { low: Infinity, high: -Infinity, exactMin: Infinity };
|
||||
const see = (el: number, az: number) => {
|
||||
const c = skyColour(sky, dirAt(el, az));
|
||||
out.low = Math.min(out.low, displayedLuminance(c, 'low'));
|
||||
out.high = Math.max(out.high, displayedLuminance(c, 'high'));
|
||||
out.exactMin = Math.min(out.exactMin, displayedLuminance(c));
|
||||
};
|
||||
for (let i = 0; i < N; i += 1) {
|
||||
const y = ((i + 0.5) / N) * Math.sin(maxEl * rad); // sin(elevation): uniform from the horizon to maxEl
|
||||
see(Math.asin(y) / rad, (i * 137.50776405) % 360);
|
||||
}
|
||||
for (let dEl = -3; dEl <= 3; dEl += 0.05) {
|
||||
for (let dAz = -3; dAz <= 3; dAz += 0.05) {
|
||||
see(Math.min(maxEl, Math.max(0, sky.elevation + dEl)), sky.azimuth + dAz);
|
||||
}
|
||||
);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('the terrain camera reach', () => {
|
||||
it('covers the highest ray the frozen camera sees, at any aspect, with a small margin', () => {
|
||||
const tanHalf = Math.tan((55 * Math.PI) / 360);
|
||||
const tops = [0.4, 0.5, 1, 16 / 9, 2.4].flatMap((aspect) =>
|
||||
[0, 0.125, 0.25, 0.5, 0.75].flatMap((turns) => {
|
||||
const v = view(aspect, turns);
|
||||
return [-1, 0, 1].map((nx) => {
|
||||
const d = [0, 1, 2].map(
|
||||
(i) => v.forward[i] + nx * tanHalf * aspect * v.right[i] + tanHalf * v.up[i]
|
||||
);
|
||||
return (Math.asin(d[1] / Math.hypot(d[0], d[1], d[2])) * 180) / Math.PI;
|
||||
});
|
||||
})
|
||||
);
|
||||
const top = Math.max(...tops);
|
||||
expect(top).toBeCloseTo(14.5, 1);
|
||||
expect(TERRAIN_REACH_DEG).toBeGreaterThanOrEqual(top);
|
||||
expect(TERRAIN_REACH_DEG - top).toBeLessThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('terrainSkyFor', () => {
|
||||
it('leaves auto to the clock and the URL', () => {
|
||||
expect(terrainSkyFor('auto')).toEqual({});
|
||||
it('draws the sky auto resolves to, with that state band', () => {
|
||||
setSkyClock(() => atStandardTime(12, 0));
|
||||
expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('day'));
|
||||
setSkyClock(() => atStandardTime(0, 0));
|
||||
expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night'));
|
||||
});
|
||||
|
||||
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('lets the page URL steer auto, shaped into the band of the state the clock resolves to', () => {
|
||||
setSkyClock(() => atStandardTime(0, 0)); // night
|
||||
search = '?sun=30&az=120';
|
||||
expect(terrainSkyFor('auto')).toEqual({
|
||||
sun: { elevation: 30, azimuth: 120 },
|
||||
band: SKY_BAND.night,
|
||||
});
|
||||
search = '?sky=noonday';
|
||||
expect(terrainSkyFor('auto')).toEqual({ sky: 'noonday', band: SKY_BAND.night });
|
||||
search = '?sky=garbage';
|
||||
expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night'));
|
||||
setSkyClock(() => atStandardTime(18, 10)); // dusk, URL azimuth honoured
|
||||
search = '?sun=2&az=250';
|
||||
expect(terrainSkyFor('auto').sun).toEqual({ elevation: 2, azimuth: 250 });
|
||||
});
|
||||
|
||||
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);
|
||||
it('keeps a URL-chosen sun inside the band of the resolved state', () => {
|
||||
setSkyClock(() => atStandardTime(12, 0)); // day: floor
|
||||
search = '?sun=-30';
|
||||
const day = sweepOf(chooseSky(terrainSkyFor('auto')));
|
||||
expect(day.low).toBeGreaterThanOrEqual(needs('day').floor);
|
||||
setSkyClock(() => atStandardTime(0, 0)); // night: ceiling
|
||||
search = '?sun=60';
|
||||
const night = sweepOf(chooseSky(terrainSkyFor('auto')));
|
||||
expect(night.high).toBeLessThanOrEqual(needs('night').ceiling);
|
||||
});
|
||||
|
||||
it('lets an explicit state beat the URL, as before auto had one', () => {
|
||||
search = '?sun=30&az=120&sky=midnight';
|
||||
expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day });
|
||||
expect(terrainSkyFor('dusk').sun).toEqual({ elevation: DUSK_SUN_ELEVATION });
|
||||
});
|
||||
|
||||
it('names the baked sky for day and night, and a low sun for dusk, each with its band', () => {
|
||||
expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day });
|
||||
expect(terrainSkyFor('night')).toEqual({ sky: 'midnight', band: SKY_BAND.night });
|
||||
expect(terrainSkyFor('dusk')).toEqual({
|
||||
sun: { elevation: DUSK_SUN_ELEVATION },
|
||||
band: SKY_BAND.dusk,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the terrain sky lies inside skyBackdrop', () => {
|
||||
// Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns)
|
||||
// plus a 0.05 degree grid across the sun, five times finer than the generator's.
|
||||
const luma = (c: readonly number[]) => {
|
||||
const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v)));
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const sweep = (state: 'day' | 'dusk' | 'night') => {
|
||||
const sky = chooseSky(terrainSkyFor(state));
|
||||
const rad = Math.PI / 180;
|
||||
const dirAt = (el: number, az: number): [number, number, number] => [
|
||||
Math.cos(el * rad) * Math.sin(az * rad),
|
||||
Math.sin(el * rad),
|
||||
-Math.cos(el * rad) * Math.cos(az * rad),
|
||||
];
|
||||
const out: number[] = [];
|
||||
const N = 20000;
|
||||
for (let i = 0; i < N; i += 1) {
|
||||
const y = (i + 0.5) / N; // sin(elevation): uniform over the upper hemisphere
|
||||
out.push(luma(skyColour(sky, dirAt(Math.asin(y) / rad, (i * 137.50776405) % 360))));
|
||||
}
|
||||
for (let dEl = -3; dEl <= 3; dEl += 0.05) {
|
||||
for (let dAz = -3; dAz <= 3; dAz += 0.05) {
|
||||
out.push(luma(skyColour(sky, dirAt(Math.max(0, sky.elevation + dEl), sky.azimuth + dAz))));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
describe('the terrain sky lies in the band its text ink needs', () => {
|
||||
const sweep = (state: FixedSkyState) => sweepOf(chooseSky(terrainSkyFor(state)));
|
||||
|
||||
it.each(['day', 'dusk', 'night'] as const)('for %s, and no looser than 5e-4 from it', (state) => {
|
||||
const { darkest, lightest } = skyBackdrop(state);
|
||||
it('dusk needs its lift: the scatter sky alone dips below the text bound within the reach', () => {
|
||||
const { sun } = terrainSkyFor('dusk');
|
||||
expect(sweepOf(chooseSky({ sun })).exactMin).toBeLessThan(needs('dusk').floor - 0.02);
|
||||
expect(sweep('dusk').low).toBeGreaterThanOrEqual(needs('dusk').floor);
|
||||
});
|
||||
|
||||
it.each(STATES)('%s', (state) => {
|
||||
const { floor, ceiling } = needs(state);
|
||||
const l = sweep(state);
|
||||
expect(Math.min(...l)).toBeGreaterThanOrEqual(darkest - EPS);
|
||||
expect(Math.max(...l)).toBeLessThanOrEqual(lightest + EPS);
|
||||
const top = Math.max(Math.max(...l), ridgeDrawn(state)?.lightest ?? 0);
|
||||
expect(lightest - top).toBeLessThan(5e-4);
|
||||
const bottom = Math.min(Math.min(...l), ridgeDrawn(state)?.darkest ?? 1);
|
||||
expect(bottom - darkest).toBeLessThan(5e-4);
|
||||
expect(l.low).toBeGreaterThanOrEqual(floor);
|
||||
expect(l.high).toBeLessThanOrEqual(ceiling);
|
||||
});
|
||||
|
||||
it('and the terrain dusk reaches the sun glow the ridge dusk does not', () => {
|
||||
it.each(STATES)('%s holds for a sun at any elevation, as ?sun= can put it', (state) => {
|
||||
const { floor, ceiling } = needs(state);
|
||||
for (let e = -90; e <= 90; e += 10) {
|
||||
const l = sweepOf(chooseSky({ sun: { elevation: e }, band: SKY_BAND[state] }), 4000, 90);
|
||||
expect(l.low).toBeGreaterThanOrEqual(floor);
|
||||
expect(l.high).toBeLessThanOrEqual(ceiling);
|
||||
}
|
||||
});
|
||||
|
||||
it('and the terrain dusk still 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('skyBackdrop holds the union of what the scenes draw', () => {
|
||||
// An independent extremum: not terrainDrawn's grid and compass search but alternating golden-section
|
||||
// line searches in elevation and azimuth from 60 starts (a golden-angle spiral and scatter about the sun),
|
||||
// the bracket shrinking to 1e-3 degrees. A plateau (the shader clamps) cannot stall it: each line search
|
||||
// always moves to the better end of its bracket.
|
||||
const extremes = (state: 'day' | 'dusk' | 'night') => {
|
||||
// An independent extremum of the screen reading ('low' for the darkest, 'high' for the lightest: one 8-bit step each way) over the terrain camera's reach: not terrainDrawn's grid and compass search but alternating golden-section
|
||||
// line searches in elevation and azimuth from 60 starts (a golden-angle spiral and scatter about the sun), the bracket
|
||||
// shrinking to 1e-3 degrees. A plateau (the shader clamps) cannot stall it: each line search always
|
||||
// moves to the better end of its bracket.
|
||||
const extremes = (state: FixedSkyState) => {
|
||||
const sky = chooseSky(terrainSkyFor(state));
|
||||
const rad = Math.PI / 180;
|
||||
const f = (el: number, az: number) => {
|
||||
const e = Math.min(90, Math.max(0, el)) * rad;
|
||||
const f = (el: number, az: number, reading: 'low' | 'high') => {
|
||||
const e = Math.min(TERRAIN_REACH_DEG, Math.max(0, el)) * rad;
|
||||
const a = az * rad;
|
||||
const [r, g, b] = skyColour(sky, [
|
||||
Math.cos(e) * Math.sin(a),
|
||||
Math.sin(e),
|
||||
-Math.cos(e) * Math.cos(a),
|
||||
]).map((v) => Math.min(1, Math.max(0, v)));
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
return displayedLuminance(
|
||||
skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]),
|
||||
reading
|
||||
);
|
||||
};
|
||||
const P = 0.6180339887;
|
||||
const line = (g: (x: number) => number, mid: number, w: number) => {
|
||||
|
|
@ -159,45 +257,42 @@ describe('skyBackdrop holds the union of what the scenes draw', () => {
|
|||
}
|
||||
return (a + b) / 2;
|
||||
};
|
||||
const search = (sign: 1 | -1) => {
|
||||
const hunt = (sign: 1 | -1) => {
|
||||
const reading = sign === 1 ? 'high' : 'low';
|
||||
let best = -Infinity;
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
let el =
|
||||
i % 2
|
||||
? sky.elevation + (((i * 7919) % 1600) - 800) / 100
|
||||
: Math.asin((i + 0.5) / 60) / rad;
|
||||
: Math.asin(((i + 0.5) / 60) * Math.sin(TERRAIN_REACH_DEG * rad)) / rad;
|
||||
let az =
|
||||
i % 2 ? sky.azimuth + (((i * 104729) % 1600) - 800) / 100 : (i * 137.50776405) % 360;
|
||||
for (let w = 8; w > 1e-3; w /= 2) {
|
||||
const [el0, az0] = [el, az];
|
||||
el = line((x) => sign * f(Math.max(0, x), az0), el0, w);
|
||||
el = line((x) => sign * f(Math.max(0, x), az0, reading), el0, w);
|
||||
const el1 = el;
|
||||
az = line((x) => sign * f(el1, x), az0, w);
|
||||
best = Math.max(best, sign * f(el, az));
|
||||
az = line((x) => sign * f(el1, x, reading), az0, w);
|
||||
best = Math.max(best, sign * f(el, az, reading));
|
||||
}
|
||||
}
|
||||
return sign * best;
|
||||
};
|
||||
return { darkest: search(-1), lightest: search(1) };
|
||||
return { darkest: hunt(-1), lightest: hunt(1) };
|
||||
};
|
||||
|
||||
const computed = (state: 'day' | 'dusk' | 'night') => {
|
||||
const r = [ridgeDrawn(state), terrainDrawn(state)].filter((x) => x !== undefined) as {
|
||||
darkest: number;
|
||||
lightest: number;
|
||||
}[];
|
||||
const computed = (state: FixedSkyState) => {
|
||||
const r = [ridgeDrawn(state), terrainDrawn(state)];
|
||||
const ind = extremes(state);
|
||||
const ridge = ridgeDrawn(state);
|
||||
return {
|
||||
darkest: Math.min(...r.map((x) => x.darkest), ind.darkest, ridge?.darkest ?? 1),
|
||||
lightest: Math.max(...r.map((x) => x.lightest), ind.lightest, ridge?.lightest ?? 0),
|
||||
darkest: Math.min(...r.map((x) => x.darkest), ind.darkest),
|
||||
lightest: Math.max(...r.map((x) => x.lightest), ind.lightest),
|
||||
};
|
||||
};
|
||||
|
||||
// The constants are the swept extremes rounded outward to six places, so each lies in [extreme, extreme + 1e-6):
|
||||
// a constant 1e-6 low is under the extreme, one 2e-5 high is over the bound. SEARCH is the searches' own error.
|
||||
const SEARCH = 1e-8;
|
||||
it.each(['day', 'dusk', 'night'] as const)(
|
||||
it.each(STATES)(
|
||||
'%s: the constant is the swept extreme rounded outward, within 1e-6 above it',
|
||||
(state) => {
|
||||
const c = computed(state);
|
||||
|
|
@ -209,11 +304,14 @@ describe('skyBackdrop holds the union of what the scenes draw', () => {
|
|||
}
|
||||
);
|
||||
|
||||
it('auto is the union of the three fixed states', () => {
|
||||
const all = (['day', 'dusk', 'night'] as const).map(skyBackdrop);
|
||||
expect(skyBackdrop('auto')).toEqual({
|
||||
darkest: Math.min(...all.map((x) => x.darkest)),
|
||||
lightest: Math.max(...all.map((x) => x.lightest)),
|
||||
});
|
||||
it.each(STATES)('%s: an independent sweep, no looser than 5e-4 from the constant', (state) => {
|
||||
// The sweep is not the generator's grid (see above), so it cannot share its blind spots.
|
||||
const l = sweepOf(chooseSky(terrainSkyFor(state)), 20000);
|
||||
const b = skyBackdrop(state);
|
||||
const r = ridgeDrawn(state);
|
||||
expect(l.low).toBeGreaterThanOrEqual(b.darkest - EPS);
|
||||
expect(l.high).toBeLessThanOrEqual(b.lightest + EPS);
|
||||
expect(b.lightest - Math.max(l.high, r.lightest)).toBeLessThan(5e-4);
|
||||
expect(Math.min(l.low, r.darkest) - b.darkest).toBeLessThan(5e-4);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
// 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.
|
||||
// What each scene is told for a sky state. 'auto' is a choice, not a sky: both functions draw the sky it
|
||||
// resolves to (`resolveSkyState`, the clock), so every scene paints one of day, dusk or night. Each is drawn
|
||||
// inside that state's luminance band (skyBand.ts): the ridge sky from gradients whose stops and blends lie in
|
||||
// it, the terrain sky shaped into it downstream of the scatter function. `skyBackdrop` bounds what comes out,
|
||||
// and the login text was checked against that bound.
|
||||
import { baked } from '../../../sky/baked';
|
||||
import { chooseSky, skyColour } from './terrain/terrainSky';
|
||||
import type { SkyName, Vec3 } from './terrain/terrainSky';
|
||||
import { chooseSky, skyColour, urlSky } from './terrain/terrainSky';
|
||||
import type { SkyName } from './terrain/terrainSky';
|
||||
import type { SkyColours, Srgb } from './ridge/skyColour';
|
||||
import { skyColours } from './ridge/skyColour';
|
||||
import { resolveSkyState } from './skyState';
|
||||
import type { SkyState } from './skyState';
|
||||
import { displayedLuminance, SKY_BAND } from './skyBand';
|
||||
import type { SkyBand } from './skyBand';
|
||||
import { luminance } from '../../../sky/skyTokens.js';
|
||||
import { CAMERA } from './terrain/terrainWorld';
|
||||
|
||||
const rgb = (hex: string): Srgb =>
|
||||
[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb;
|
||||
|
|
@ -24,32 +28,42 @@ const fromGradient = (g: readonly string[], ground: Srgb): SkyColours => ({
|
|||
/** 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 {
|
||||
/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). */
|
||||
export function ridgeSkyFor(state: SkyState): SkyColours {
|
||||
const { ground } = skyColours();
|
||||
switch (state) {
|
||||
switch (resolveSkyState(state)) {
|
||||
case 'dusk':
|
||||
return skyColours();
|
||||
case 'day':
|
||||
return fromGradient(baked.noonday.gradient, ground);
|
||||
case 'night':
|
||||
return fromGradient(baked.midnight.gradient, ground);
|
||||
default:
|
||||
return undefined;
|
||||
return fromGradient(baked.midnight.gradient, ground);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
/**
|
||||
* The terrain scene's `sky`, `sun` and `band` options. An explicit state draws its own sky and ignores the URL. 'auto'
|
||||
* draws the sky it resolves to, unless the page URL steers (`?sun=` `?az=` `?sky=`, see `urlSky`): then the URL's sky,
|
||||
* shaped into the band of the state the clock resolves to, which keeps any sun the URL picks inside that band.
|
||||
*/
|
||||
export function terrainSkyFor(state: SkyState): {
|
||||
sky?: SkyName;
|
||||
sun?: { elevation: number; azimuth?: number };
|
||||
band: SkyBand;
|
||||
} {
|
||||
const fixed = resolveSkyState(state);
|
||||
const band = SKY_BAND[fixed];
|
||||
if (state === 'auto') {
|
||||
const url = urlSky();
|
||||
if (url) return { ...url, band };
|
||||
}
|
||||
switch (fixed) {
|
||||
case 'day':
|
||||
return { sky: 'noonday' };
|
||||
return { sky: 'noonday', band };
|
||||
case 'night':
|
||||
return { sky: 'midnight' };
|
||||
case 'dusk':
|
||||
return { sun: { elevation: DUSK_SUN_ELEVATION } };
|
||||
return { sky: 'midnight', band };
|
||||
default:
|
||||
return {};
|
||||
return { sun: { elevation: DUSK_SUN_ELEVATION }, band };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,84 +75,104 @@ export interface Range {
|
|||
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)));
|
||||
/**
|
||||
* The ridge sky's range: its top, middle and horizon stops and the blends between them (the canvas interpolates
|
||||
* encoded sRGB, and luminance is convex in it, so a blend can be darker than both ends: each stop pair is sampled).
|
||||
*/
|
||||
export function ridgeDrawn(state: SkyState): Range {
|
||||
const { top, mid, horizon } = ridgeSkyFor(state);
|
||||
const l: number[] = [];
|
||||
[
|
||||
[top, mid],
|
||||
[mid, horizon],
|
||||
[top, horizon],
|
||||
].forEach(([a, b]) => {
|
||||
for (let k = 0; k <= 100; k += 1) {
|
||||
l.push(
|
||||
luminance(
|
||||
rgbHex(a.map((v, i) => Math.round(v + ((b[i] - v) * k) / 100)) as unknown as Srgb)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
return { darkest: Math.min(...l), lightest: Math.max(...l) };
|
||||
}
|
||||
|
||||
/**
|
||||
* How high the terrain camera can see, in degrees of elevation: it is frozen at a downward pitch and only turns
|
||||
* (no pull-back), so the top edge of its view, at the centre column, is the pitch down subtracted from half the
|
||||
* field of view (14.5 degrees; off-centre columns look lower). Plus a 1.5 degree margin. The terrain's range is
|
||||
* over 0 to this, all azimuths, not the whole upper sphere; below the horizon the sky is the horizon's colour.
|
||||
*/
|
||||
export const TERRAIN_REACH_DEG = Math.ceil((CAMERA.fov / 2 - CAMERA.pitch) * (180 / Math.PI) + 1.5);
|
||||
|
||||
const SAMPLE_DEG = 3;
|
||||
const SUN_DEG = 0.25;
|
||||
const SUN_DEG = 0.1;
|
||||
const SUN_SPAN_DEG = 8;
|
||||
const HORIZON_DEG = 0.5;
|
||||
const REFINE_FROM = 6;
|
||||
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. A 3 degree grid, a 0.5 degree ring of the horizon, the
|
||||
* zenith and a 0.25 degree patch around the sun find where the extremes are; each of the best few is then refined by a
|
||||
* compass search whose step halves from 1 degree to under 0.001, because the sun's glow peaks (and the sky's darkest
|
||||
* patch sits) between any fixed grid's samples.
|
||||
* The terrain sky's range as the 8-bit screen shows it (`displayedLuminance`, read 'low' for the darkest and 'high' for the
|
||||
* lightest: one 8-bit step each way, rounding and dither at their worst): `skyColour` (the CPU twin of its sky shader) over the part of the sky the camera reaches
|
||||
* (0 to TERRAIN_REACH_DEG, every azimuth: it turns), for the sky the scene picks for this state. A 3 degree grid, a 0.5 degree
|
||||
* ring of the horizon, the top of the reach and a 0.25 degree patch around the sun find where the extremes are; each of the
|
||||
* best few is then refined by a compass search whose step halves from 1 degree to under 5e-7, because the sun's glow peaks
|
||||
* (and the sky's darkest patch sits) between any fixed grid's samples.
|
||||
*/
|
||||
export function terrainDrawn(state: SkyState): Range {
|
||||
const sky = chooseSky(terrainSkyFor(state));
|
||||
const rad = Math.PI / 180;
|
||||
const lum = (el: number, az: number) => {
|
||||
const e = Math.min(90, Math.max(0, el)) * rad;
|
||||
const a = az * rad;
|
||||
return linearLuminance(
|
||||
skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)])
|
||||
);
|
||||
};
|
||||
const seen: { el: number; az: number; l: number }[] = [];
|
||||
const at = (el: number, az: number) => seen.push({ el, az, l: lum(el, az) });
|
||||
for (let el = 0; el <= 90; el += SAMPLE_DEG) {
|
||||
for (let az = 0; az < 360; az += SAMPLE_DEG) at(el, az);
|
||||
}
|
||||
for (let az = 0; az < 360; az += HORIZON_DEG) at(0, az);
|
||||
at(90, 0);
|
||||
for (let dEl = -SUN_SPAN_DEG; dEl <= SUN_SPAN_DEG; dEl += SUN_DEG) {
|
||||
for (let dAz = -SUN_SPAN_DEG; dAz <= SUN_SPAN_DEG; dAz += SUN_DEG) {
|
||||
at(sky.elevation + dEl, sky.azimuth + dAz);
|
||||
type Point = { el: number; az: number; read: number; score: number };
|
||||
const extreme = (reading: 'low' | 'high', sign: 1 | -1): number => {
|
||||
const point = (el: number, az: number): Point => {
|
||||
const e = Math.min(TERRAIN_REACH_DEG, Math.max(0, el)) * rad;
|
||||
const a = az * rad;
|
||||
const c = skyColour(sky, [
|
||||
Math.cos(e) * Math.sin(a),
|
||||
Math.sin(e),
|
||||
-Math.cos(e) * Math.cos(a),
|
||||
]);
|
||||
const read = displayedLuminance(c, reading);
|
||||
return { el, az, read, score: read };
|
||||
};
|
||||
const seen: Point[] = [];
|
||||
const at = (el: number, az: number) => seen.push(point(el, az));
|
||||
for (let el = 0; el <= TERRAIN_REACH_DEG; el += SAMPLE_DEG) {
|
||||
for (let az = 0; az < 360; az += SAMPLE_DEG) at(el, az);
|
||||
}
|
||||
}
|
||||
type Point = { el: number; az: number; l: number };
|
||||
const around = (p: Point, step: number): Point[] =>
|
||||
[
|
||||
[step, 0],
|
||||
[-step, 0],
|
||||
[0, step],
|
||||
[0, -step],
|
||||
].map(([dEl, dAz]) => {
|
||||
const el = Math.min(90, Math.max(0, p.el + dEl));
|
||||
return { el, az: p.az + dAz, l: lum(el, p.az + dAz) };
|
||||
});
|
||||
const refine = (from: Point, sign: 1 | -1) => {
|
||||
let best = from;
|
||||
for (let step = 1; step > 5e-7; step /= 2) {
|
||||
let improved = true;
|
||||
while (improved) {
|
||||
const cur = best;
|
||||
best = around(cur, step).reduce((b, c) => (sign * c.l > sign * b.l ? c : b), cur);
|
||||
improved = best !== cur;
|
||||
for (let az = 0; az < 360; az += HORIZON_DEG) at(0, az);
|
||||
at(TERRAIN_REACH_DEG, 0);
|
||||
for (let dEl = -SUN_SPAN_DEG; dEl <= SUN_SPAN_DEG; dEl += SUN_DEG) {
|
||||
for (let dAz = -SUN_SPAN_DEG; dAz <= SUN_SPAN_DEG; dAz += SUN_DEG) {
|
||||
at(sky.elevation + dEl, sky.azimuth + dAz);
|
||||
}
|
||||
}
|
||||
return best.l;
|
||||
const around = (p: Point, step: number): Point[] =>
|
||||
[
|
||||
[step, 0],
|
||||
[-step, 0],
|
||||
[0, step],
|
||||
[0, -step],
|
||||
].map(([dEl, dAz]) =>
|
||||
point(Math.min(TERRAIN_REACH_DEG, Math.max(0, p.el + dEl)), p.az + dAz)
|
||||
);
|
||||
const refine = (from: Point) => {
|
||||
let best = from;
|
||||
for (let step = 1; step > 5e-7; step /= 2) {
|
||||
let improved = true;
|
||||
while (improved) {
|
||||
const cur = best;
|
||||
best = around(cur, step).reduce((b, c) => (sign * c.score > sign * b.score ? c : b), cur);
|
||||
improved = best !== cur;
|
||||
}
|
||||
}
|
||||
return best.read;
|
||||
};
|
||||
const ranked = [...seen].sort((p, q) => sign * (q.score - p.score));
|
||||
return sign === 1
|
||||
? Math.max(ranked[0].read, ...ranked.slice(0, REFINE_FROM).map(refine))
|
||||
: Math.min(ranked[0].read, ...ranked.slice(0, REFINE_FROM).map(refine));
|
||||
};
|
||||
const byLight = [...seen].sort((p, q) => q.l - p.l);
|
||||
const lightest = Math.max(
|
||||
byLight[0].l,
|
||||
...byLight.slice(0, REFINE_FROM).map((p) => refine(p, 1))
|
||||
);
|
||||
const darkest = Math.min(
|
||||
byLight[byLight.length - 1].l,
|
||||
...byLight.slice(-REFINE_FROM).map((p) => refine(p, -1))
|
||||
);
|
||||
return { darkest, lightest };
|
||||
return { darkest: extreme('low', -1), lightest: extreme('high', 1) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { atStandardTime } from './standardTime.testkit';
|
||||
import { baked } from '../../../sky/baked';
|
||||
import { AA, luminance } from '../../../sky/skyTokens.js';
|
||||
import { luminance } from '../../../sky/skyTokens.js';
|
||||
import {
|
||||
SKY_STATES,
|
||||
SKY_STATE_KEY,
|
||||
resolveSkyState,
|
||||
setSkyClock,
|
||||
skyBackdrop,
|
||||
type SkyBackdrop,
|
||||
type SkyState,
|
||||
} from './skyState';
|
||||
|
||||
|
|
@ -105,10 +107,10 @@ describe('sky state', () => {
|
|||
});
|
||||
|
||||
describe('skyBackdrop', () => {
|
||||
it('puts night below day and dusk, which spans from dark to the sun glow', () => {
|
||||
it('puts night below day and dusk, and dusk reaches the sun glow', () => {
|
||||
expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('day').darkest);
|
||||
expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest);
|
||||
expect(skyBackdrop('dusk').darkest).toBeLessThan(skyBackdrop('day').darkest);
|
||||
expect(skyBackdrop('dusk').lightest).toBeGreaterThan(skyBackdrop('day').lightest);
|
||||
});
|
||||
|
||||
it('holds the baked gradients for day and night', () => {
|
||||
|
|
@ -121,12 +123,13 @@ describe('skyBackdrop', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('bounds every fixed sky when auto', () => {
|
||||
const a = skyBackdrop('auto');
|
||||
(['day', 'dusk', 'night'] as const).forEach((s) => {
|
||||
expect(a.lightest).toBeGreaterThanOrEqual(skyBackdrop(s).lightest);
|
||||
expect(a.darkest).toBeLessThanOrEqual(skyBackdrop(s).darkest);
|
||||
});
|
||||
it('is the band of the sky auto resolves to, not the union of all three', () => {
|
||||
const at = (h: number) => setSkyClock(() => atStandardTime(h, 0));
|
||||
at(12);
|
||||
expect(skyBackdrop('auto')).toEqual(skyBackdrop('day'));
|
||||
at(0);
|
||||
expect(skyBackdrop('auto')).toEqual(skyBackdrop('night'));
|
||||
setSkyClock();
|
||||
});
|
||||
|
||||
it('never has darkest above lightest', () => {
|
||||
|
|
@ -136,57 +139,79 @@ describe('skyBackdrop', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Login text and link, per theme, from the card variables in pages/auth/styles.css.ts and the sky
|
||||
// themes' baked bubble text and link. The Light/Silver/Dark/Butter row uses the ink those themes
|
||||
// actually render: under data-scene the auth card text is var(--sky-bubble-text, #b7c7d9), not each
|
||||
// theme's own OnContainer.
|
||||
const inks = [
|
||||
{ theme: 'Light/Silver/Dark/Butter', text: '#b7c7d9', link: '#99c7ff' },
|
||||
{ theme: 'Midnight Sky', text: baked.midnight.bubbles.OtherText, link: baked.midnight.link },
|
||||
{ theme: 'Noonday Sky', text: baked.noonday.bubbles.OtherText, link: baked.noonday.link },
|
||||
];
|
||||
// WCAG contrast of an ink against the sky it is worst on. The sky can show any luminance between
|
||||
// its darkest and lightest, and contrast is lowest where the backdrop is closest to the ink: the
|
||||
// ink's own luminance clamped into that range. An ink inside the range can land on a backdrop of
|
||||
// exactly its own luminance (1.00); an ink outside it is worst on the nearer end.
|
||||
const against = (ink: string, bg: number) => {
|
||||
const li = luminance(ink);
|
||||
const [hi, lo] = li > bg ? [li, bg] : [bg, li];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const ratio = (ink: string, b: SkyBackdrop) =>
|
||||
against(ink, Math.min(b.lightest, Math.max(b.darkest, luminance(ink))));
|
||||
|
||||
describe('login ink against the sky backdrop', () => {
|
||||
const rows = SKY_STATES.flatMap((state) =>
|
||||
inks.flatMap(({ theme, text, link }) =>
|
||||
(
|
||||
[
|
||||
['text', text],
|
||||
['link', link],
|
||||
] as const
|
||||
).map(([role, ink]) => ({ state, theme, role, ratio: ratio(ink, skyBackdrop(state)) }))
|
||||
)
|
||||
);
|
||||
|
||||
it('prints the measured ratios', () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.table(rows.map((r) => ({ ...r, ratio: r.ratio.toFixed(2), ok: r.ratio >= AA })));
|
||||
expect(rows).toHaveLength(SKY_STATES.length * inks.length * 2);
|
||||
describe('auto follows the clock', () => {
|
||||
const local = (h: number, m = 0) => atStandardTime(h, m); // local solar time ~ local clock
|
||||
let storage: ReturnType<typeof fakeStorage>;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
storage = fakeStorage();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// 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, 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([
|
||||
'night|Light/Silver/Dark/Butter|text',
|
||||
'night|Light/Silver/Dark/Butter|link',
|
||||
'night|Midnight Sky|text',
|
||||
]);
|
||||
describe.each(rows)('$theme $role in $state', (r) => {
|
||||
const holds = clears.has(`${r.state}|${r.theme}|${r.role}`);
|
||||
(holds ? it : it.fails)(holds ? 'is at least AA' : 'is below AA (known)', () => {
|
||||
expect(r.ratio).toBeGreaterThanOrEqual(AA);
|
||||
});
|
||||
it('resolves noon to day, midnight to night and the twilight after sunset to dusk', () => {
|
||||
expect(resolveSkyState('auto', local(12))).toBe('day');
|
||||
expect(resolveSkyState('auto', local(0))).toBe('night');
|
||||
expect(resolveSkyState('auto', local(18, 10))).toBe('dusk');
|
||||
expect(resolveSkyState('auto', local(5, 50))).toBe('dusk');
|
||||
expect(resolveSkyState('day', local(0))).toBe('day');
|
||||
});
|
||||
|
||||
it('tells listeners the resolved sky when the clock crosses a boundary, and only then', async () => {
|
||||
const m = await fresh();
|
||||
let now = local(17, 0);
|
||||
m.setSkyClock(() => now);
|
||||
const cb = vi.fn();
|
||||
const off = m.subscribe(cb);
|
||||
vi.advanceTimersByTime(5 * 60_000);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
now = local(18, 10);
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(cb.mock.calls).toEqual([['dusk']]);
|
||||
vi.advanceTimersByTime(5 * 60_000);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
off();
|
||||
});
|
||||
|
||||
it('still tells everyone when a listener comes or goes inside the window before the clock crosses', async () => {
|
||||
const m = await fresh();
|
||||
let now = local(17, 0);
|
||||
m.setSkyClock(() => now);
|
||||
const [a, b, late] = [vi.fn(), vi.fn(), vi.fn()];
|
||||
m.subscribe(a);
|
||||
const offB = m.subscribe(b);
|
||||
vi.advanceTimersByTime(30_000);
|
||||
now = local(18, 10); // the clock has crossed; no tick has seen it yet
|
||||
const offLate = m.subscribe(late);
|
||||
offB();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(a.mock.calls).toEqual([['dusk']]);
|
||||
expect(b).not.toHaveBeenCalled();
|
||||
expect(late.mock.calls).toEqual([['dusk']]);
|
||||
offLate();
|
||||
});
|
||||
|
||||
it('keeps no timer without listeners or when a fixed state is chosen', async () => {
|
||||
const m = await fresh();
|
||||
const off = m.subscribe(() => undefined);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
m.setSkyState('night');
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
m.setSkyState('auto');
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
off();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('notifies a change of choice with the resolved sky', async () => {
|
||||
const m = await fresh();
|
||||
m.setSkyClock(() => local(12));
|
||||
const cb = vi.fn();
|
||||
m.subscribe(cb);
|
||||
m.setSkyState('night');
|
||||
m.setSkyState('auto');
|
||||
expect(cb.mock.calls).toEqual([['night'], ['day']]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
// Which sky the signed-out page is drawn against. The state is one value, kept in one
|
||||
// Which sky the signed-out page is drawn against. The choice is one value, kept in one
|
||||
// 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.
|
||||
// text colours can be checked against it. 'auto' is a choice, not a sky: it resolves from the
|
||||
// clock to day, dusk or night (`resolveSkyState`), and every sky drawn is one of those three.
|
||||
import { sunElevation } from '../../../sky/sun.js';
|
||||
import type { FixedSkyState } from './skyBand';
|
||||
|
||||
export type SkyState = 'auto' | 'day' | 'dusk' | 'night';
|
||||
|
||||
export const SKY_STATES: readonly SkyState[] = ['auto', 'day', 'dusk', 'night'];
|
||||
|
|
@ -18,14 +22,70 @@ function load(): SkyState {
|
|||
}
|
||||
}
|
||||
|
||||
let current: SkyState | undefined;
|
||||
const listeners = new Set<(s: SkyState) => void>();
|
||||
export type { FixedSkyState };
|
||||
|
||||
/** Where the clock puts the sun: the latitude the skies are baked for (src/sky/skyTokens.js MOMENTS), the longitude of this timezone. */
|
||||
const LAT = 45;
|
||||
/** Sun elevation (degrees) from which it is day, and below the negative of which it is night; between, dusk (dawn included). */
|
||||
export const DAY_FROM = 6;
|
||||
|
||||
let clock: () => Date = () => new Date();
|
||||
/** Minutes west of UTC of this timezone's standard time (the larger of its January and July offsets), so daylight saving does not move the sun. */
|
||||
export const standardOffsetMinutes = (now: Date): number =>
|
||||
Math.max(
|
||||
new Date(now.getFullYear(), 0, 1).getTimezoneOffset(),
|
||||
new Date(now.getFullYear(), 6, 1).getTimezoneOffset()
|
||||
);
|
||||
|
||||
/** The fixed sky a choice draws now: itself, or for 'auto' the sky the sun's elevation here calls for. */
|
||||
export function resolveSkyState(s: SkyState, now: Date = clock()): FixedSkyState {
|
||||
if (s !== 'auto') return s;
|
||||
const lon = -standardOffsetMinutes(now) / 4;
|
||||
const el = sunElevation(now, LAT, lon) as number;
|
||||
if (el >= DAY_FROM) return 'day';
|
||||
return el <= -DAY_FROM ? 'night' : 'dusk';
|
||||
}
|
||||
|
||||
let current: SkyState | undefined;
|
||||
const listeners = new Set<(s: FixedSkyState) => void>();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let shown: FixedSkyState | undefined;
|
||||
const TICK_MS = 60_000;
|
||||
|
||||
/** The choice (what the control shows). */
|
||||
export const getSkyState = (): SkyState => {
|
||||
current ??= load();
|
||||
return current;
|
||||
};
|
||||
|
||||
/** The sky being drawn: the choice, resolved. */
|
||||
export const getResolvedSkyState = (): FixedSkyState => resolveSkyState(getSkyState());
|
||||
|
||||
const emit = () => {
|
||||
shown = getResolvedSkyState();
|
||||
[...listeners].forEach((cb) => cb(shown as FixedSkyState));
|
||||
};
|
||||
|
||||
// While anyone listens and the choice is 'auto', one timer checks the clock each minute and tells
|
||||
// listeners when the resolved sky changes from the one last told (`shown`, set when telling and when the
|
||||
// first listener arrives, never by a later arrival or departure). Otherwise there is no timer.
|
||||
function sync(): void {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
if (listeners.size === 0 || getSkyState() !== 'auto') return;
|
||||
const tick = () => {
|
||||
if (getResolvedSkyState() !== shown) emit();
|
||||
timer = setTimeout(tick, TICK_MS);
|
||||
};
|
||||
timer = setTimeout(tick, TICK_MS);
|
||||
}
|
||||
|
||||
/** Replace the clock (tests); no argument restores the real one. */
|
||||
export function setSkyClock(now?: () => Date): void {
|
||||
clock = now ?? (() => new Date());
|
||||
sync();
|
||||
}
|
||||
|
||||
export function setSkyState(s: SkyState): void {
|
||||
if (!isSkyState(s)) return;
|
||||
const changed = s !== getSkyState();
|
||||
|
|
@ -35,20 +95,27 @@ export function setSkyState(s: SkyState): void {
|
|||
} catch {
|
||||
// Storage is refused (private mode, quota): the choice holds for this page load only.
|
||||
}
|
||||
if (changed) [...listeners].forEach((cb) => cb(s));
|
||||
if (changed) {
|
||||
sync();
|
||||
emit();
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribe(cb: (s: SkyState) => void): () => void {
|
||||
/** Listeners hear the resolved sky: on a change of choice, and while 'auto', when the clock crosses a boundary. */
|
||||
export function subscribe(cb: (s: FixedSkyState) => void): () => void {
|
||||
if (listeners.size === 0) shown = getResolvedSkyState();
|
||||
listeners.add(cb);
|
||||
sync();
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
sync();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative luminance (WCAG, 0..1) of the lightest and darkest sky the state can show behind the
|
||||
* page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' follows the
|
||||
* clock, so it can show any of the three fixed skies and bounds all of them.
|
||||
* page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' is the band of
|
||||
* the sky it resolves to now.
|
||||
*/
|
||||
export interface SkyBackdrop {
|
||||
readonly darkest: number;
|
||||
|
|
@ -58,14 +125,13 @@ export interface SkyBackdrop {
|
|||
// 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 (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). Held as constants so
|
||||
// this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering
|
||||
// tables with it; skyForState.test.ts fails when they drift from the computed union.
|
||||
// Rounded outward to six places. 'auto' follows the clock (and a URL), so it
|
||||
// bounds all three fixed states.
|
||||
const BACKDROPS: Record<SkyState, SkyBackdrop> = {
|
||||
auto: { darkest: 0.016727, lightest: 0.967912 },
|
||||
day: { darkest: 0.237309, lightest: 0.928052 },
|
||||
dusk: { darkest: 0.016727, lightest: 0.967912 },
|
||||
night: { darkest: 0.058099, lightest: 0.077467 },
|
||||
// tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts
|
||||
// over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts).
|
||||
// Bounds what the 8-bit screen shows too (one step each way for rounding and dither), rounded outward to six places.
|
||||
const BACKDROPS: Record<FixedSkyState, SkyBackdrop> = {
|
||||
day: { darkest: 0.461063, lightest: 0.901108 },
|
||||
dusk: { darkest: 0.30619, lightest: 0.968744 },
|
||||
night: { darkest: 0.05987, lightest: 0.081705 },
|
||||
};
|
||||
|
||||
export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[state];
|
||||
export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { atStandardTime } from './standardTime.testkit';
|
||||
import { baked } from '../../../sky/baked';
|
||||
import { AA, contrast, luminance } from '../../../sky/skyTokens.js';
|
||||
import { Editor } from '../../components/editor/Editor.css';
|
||||
|
|
@ -8,7 +9,9 @@ import { MembersDrawer } from '../room/MembersDrawer.css';
|
|||
import { RoomViewTyping } from '../room/RoomViewTyping.css';
|
||||
import { SKY_PLATE_FALLBACK } from './skyPlate';
|
||||
import { SkyPlate, plateRegions } from './skyPlate.css';
|
||||
import { SKY_STATES, skyBackdrop, type SkyBackdrop, type SkyState } from './skyState';
|
||||
import { getResolvedSkyState, setSkyClock, skyBackdrop } from './skyState';
|
||||
import type { SkyBackdrop } from './skyState';
|
||||
import type { FixedSkyState } from './skyBand';
|
||||
import { skyThemeId } from './skyTheme';
|
||||
|
||||
const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const;
|
||||
|
|
@ -30,44 +33,76 @@ const ratio = (ink: string, b: SkyBackdrop) =>
|
|||
const r2 = (n: number) => n.toFixed(2);
|
||||
|
||||
describe('skyThemeId', () => {
|
||||
it('leaves the theme alone in auto and picks one for every fixed state', () => {
|
||||
expect(skyThemeId('auto')).toBeUndefined();
|
||||
expect(SKY_STATES.filter((s) => s !== 'auto').map((s) => skyThemeId(s))).not.toContain(
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('lights day and dusk with dark ink and night with light ink', () => {
|
||||
it('picks a theme for each explicit state', () => {
|
||||
expect(skyThemeId('day')).toBe('noonday-sky');
|
||||
expect(skyThemeId('dusk')).toBe('noonday-sky');
|
||||
expect(skyThemeId('night')).toBe('midnight-sky');
|
||||
});
|
||||
|
||||
it('picks none for auto, at any hour: auto never overrides a signed-in theme', () => {
|
||||
[0, 6, 12, 18, 23].forEach((h) => {
|
||||
setSkyClock(() => atStandardTime(h, 10));
|
||||
expect(skyThemeId('auto')).toBeUndefined();
|
||||
});
|
||||
setSkyClock();
|
||||
});
|
||||
|
||||
it('is what a wiring that follows the sky reads: the resolved state names a theme', () => {
|
||||
setSkyClock(() => atStandardTime(12));
|
||||
expect(skyThemeId(getResolvedSkyState())).toBe('noonday-sky');
|
||||
setSkyClock(() => atStandardTime(0));
|
||||
expect(skyThemeId(getResolvedSkyState())).toBe('midnight-sky');
|
||||
setSkyClock();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ink of the theme a state picks, on bare sky', () => {
|
||||
// Per state: the picked theme, its ink and link, and their ratios on bare sky, over the union
|
||||
// of the skies the scenes draw (skyBackdrop). Only night text passes (5.05); day's sky reaches
|
||||
// 0.919 and dusk's 0.017 to 0.965, so no theme's ink holds there. No link passes anywhere
|
||||
// (night 2.36): nothing rests on bare sky, everything sits on the plate below.
|
||||
const cells: [SkyState, string, string, string, string, string][] = [
|
||||
['day', 'noonday-sky', '#342d25', '3.71', '#2a4f27', '2.56'],
|
||||
['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27', '1.00'],
|
||||
['night', 'midnight-sky', '#aeccff', '5.05', '#609385', '2.36'],
|
||||
// Per state: the picked theme, its text ink and link, and their WCAG ratios on the sky the scenes
|
||||
// draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link clears
|
||||
// on day and not on dusk or night: the links are derived in src/sky/skyTokens.js, outside the band, and these
|
||||
// numbers are recorded as they are measured (Night's #609385 would clear 4.5 at a luminance of about 0.53 or more).
|
||||
// 'auto' resolves from the clock to one of these three and has the same numbers.
|
||||
const cells: [FixedSkyState, string, string, string, string, string][] = [
|
||||
['day', 'noonday-sky', '#342d25', '6.60', '#2a4f27', '4.55'],
|
||||
['dusk', 'noonday-sky', '#342d25', '4.60', '#2a4f27', '3.17'],
|
||||
['night', 'midnight-sky', '#aeccff', '4.89', '#609385', '2.28'],
|
||||
];
|
||||
|
||||
it.each(cells)('%s with %s: ink %s is %s, link %s is %s', (state, id, ink, want, link, wantLink) => {
|
||||
const sky = SKY[id as keyof typeof SKY];
|
||||
expect(sky.tokens.Background.OnContainer).toBe(ink);
|
||||
expect(sky.link).toBe(link);
|
||||
expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want);
|
||||
expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink);
|
||||
it.each(cells)(
|
||||
'%s with %s: text %s is %s, link %s is %s',
|
||||
(state, id, ink, want, link, wantLink) => {
|
||||
const sky = SKY[id as keyof typeof SKY];
|
||||
expect(sky.tokens.Background.OnContainer).toBe(ink);
|
||||
expect(sky.link).toBe(link);
|
||||
expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want);
|
||||
expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(cells)('%s: text clears AA', (state, _id, ink) => {
|
||||
expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA);
|
||||
});
|
||||
|
||||
it('night is the only state whose text clears AA on bare sky', () => {
|
||||
const passing = fixed.filter(
|
||||
([s, id]) => ratio(SKY[id].tokens.Background.OnContainer, skyBackdrop(s)) >= AA
|
||||
);
|
||||
expect(passing.map(([s]) => s)).toEqual(['night']);
|
||||
it('link clears AA on day (the terrain camera sees only the lighter sky) and falls short on dusk and night', () => {
|
||||
const link = (s: FixedSkyState, id: keyof typeof SKY) => ratio(SKY[id].link, skyBackdrop(s));
|
||||
expect(link('day', 'noonday-sky')).toBeGreaterThanOrEqual(AA);
|
||||
expect(link('dusk', 'noonday-sky')).toBeLessThan(AA);
|
||||
expect(link('night', 'midnight-sky')).toBeLessThan(AA);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[12, 'day'],
|
||||
[18, 'dusk'],
|
||||
[0, 'night'],
|
||||
] as const)('auto at %s:00 has the numbers of %s', (hour, state) => {
|
||||
setSkyClock(() => atStandardTime(hour, hour === 18 ? 10 : 0));
|
||||
const id = skyThemeId(getResolvedSkyState()) as keyof typeof SKY;
|
||||
expect(id).toBe(skyThemeId(state));
|
||||
const { tokens, link } = SKY[id];
|
||||
const text = tokens.Background.OnContainer;
|
||||
expect(r2(ratio(text, skyBackdrop('auto')))).toBe(r2(ratio(text, skyBackdrop(state))));
|
||||
expect(r2(ratio(link, skyBackdrop('auto')))).toBe(r2(ratio(link, skyBackdrop(state))));
|
||||
setSkyClock();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
// Which theme a sky state lights the page with. 'auto' picks none: the page keeps the theme it
|
||||
// would have had (the user's choice when signed in, Midnight Sky when signed out).
|
||||
// Which theme a sky state lights the page with. 'auto' picks none: it never overrides a person's theme
|
||||
// (the stored one when signed in, Midnight Sky when signed out). A wiring that follows the sky when no theme
|
||||
// is stored reads `getResolvedSkyState` (skyState.ts) and passes that fixed state here.
|
||||
//
|
||||
// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. On bare sky
|
||||
// only Night's text clears 4.5:1 (5.05); Day's sky reaches 0.919 luminance and Dusk's spans
|
||||
// 0.017 to 0.965, so no ink holds there. Text therefore never rests on bare sky: every region
|
||||
// that would is a plate (skyPlate.css.ts), and skyTheme.test.ts pins the numbers.
|
||||
import type { SkyState } from './skyState';
|
||||
// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Each state's
|
||||
// drawn sky is shaped into a luminance band (skyBand.ts) in which that ink's TEXT clears 4.5:1 on
|
||||
// bare sky: Day and Dusk at least 0.2983 (dark ink), Night at most 0.0931 (light ink). skyTheme.test.ts
|
||||
// pins the measured text and link ratios. Link ink is not in the band: the Night link (#609385,
|
||||
// 2.36:1) and the dark links on Day and Dusk fall short, because the themes' link colours are
|
||||
// derived outside this code (src/sky/skyTokens.js); lightening the Night link to a luminance of
|
||||
// about 0.53 or more would clear it. The plate (skyPlate.css.ts) is still in the shell, pending
|
||||
// its removal.
|
||||
import type { FixedSkyState, SkyState } from './skyState';
|
||||
|
||||
export type SkyThemeId = 'midnight-sky' | 'noonday-sky';
|
||||
|
||||
const BY_STATE: Record<Exclude<SkyState, 'auto'>, SkyThemeId> = {
|
||||
const BY_STATE: Record<FixedSkyState, SkyThemeId> = {
|
||||
day: 'noonday-sky',
|
||||
dusk: 'noonday-sky',
|
||||
night: 'midnight-sky',
|
||||
|
|
|
|||
7
src/app/features/scene/standardTime.testkit.ts
Normal file
7
src/app/features/scene/standardTime.testkit.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { standardOffsetMinutes } from './skyState';
|
||||
|
||||
/** 22 September 2026 at h:m of local solar time as the sky clock reads it (standard time, whatever daylight saving says). */
|
||||
export const atStandardTime = (h: number, m = 0): Date => {
|
||||
const probe = new Date(2026, 8, 22);
|
||||
return new Date(Date.UTC(2026, 8, 22, h, m) + standardOffsetMinutes(probe) * 60000);
|
||||
};
|
||||
|
|
@ -5,6 +5,8 @@ import { GRID, triangleCount } from './terrainWorld';
|
|||
import { groundLight, SKY, skyAtSun } from './terrainSky';
|
||||
import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter';
|
||||
import { SKY_FRAG } from './terrainShaders';
|
||||
import { SKY_BAND } from '../skyBand';
|
||||
import { terrainSkyFor } from '../skyForState';
|
||||
import { startBackgroundScene } from '../index';
|
||||
|
||||
/** A GL context that accepts every call, remembers them, reports success for compile, link and framebuffer. */
|
||||
|
|
@ -158,6 +160,32 @@ describe('createTerrainScene', () => {
|
|||
expect(has(uploaded({}), nightA)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['day', 'dusk', 'night'] as const)(
|
||||
'uploads the %s band as uBand (floor, ceiling) and uLift, in the sky pass and the grid pass',
|
||||
(state) => {
|
||||
const { gl, calls } = fakeGl();
|
||||
const opts = terrainSkyFor(state);
|
||||
createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose();
|
||||
const { floor, ceiling, lift } = SKY_BAND[state];
|
||||
const pairs = calls.uniform2fv.filter(
|
||||
(a) =>
|
||||
Math.abs((a[1] as number[])[0] - floor) < 1e-6 &&
|
||||
Math.abs((a[1] as number[])[1] - ceiling) < 1e-6
|
||||
);
|
||||
expect(pairs).toHaveLength(2);
|
||||
const lifts = calls.uniform3fv.filter((a) =>
|
||||
Array.from(a[1] as ArrayLike<number>).every((x, i) => Math.abs(x - lift[i]) < 1e-6)
|
||||
);
|
||||
expect(lifts).toHaveLength(2);
|
||||
}
|
||||
);
|
||||
|
||||
it('uploads no band (0, 1) and a zero lift without one', () => {
|
||||
const { gl, calls } = fakeGl();
|
||||
createTerrainScene({ canvas: canvasWith(gl), tier: 'still' })!.dispose();
|
||||
expect(calls.uniform2fv.filter((a) => (a[1] as number[])[1] === 1)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('a sun elevation (option or ?sun=&az=) lights between the bakes, and wins over ?sky=', () => {
|
||||
const ambients = (opts: Record<string, unknown>) => {
|
||||
const { gl, calls } = fakeGl();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Scene, SceneOptions } from '../Scene';
|
||||
import type { SkyBand } from '../skyBand';
|
||||
import { FrameSampler, FrameStats } from '../ridge/probe';
|
||||
import { albedoPalette } from './terrainAlbedo';
|
||||
import { sunIndex } from '../../../../sky/scatter.js';
|
||||
|
|
@ -27,10 +28,12 @@ export type TerrainOptions = Pick<
|
|||
SceneOptions,
|
||||
'canvas' | 'tier' | 'onCompileFail' | 'onContextLost'
|
||||
> & {
|
||||
/** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else the night notion of `skyColours()`. */
|
||||
/** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else midnight. */
|
||||
sky?: SkyName;
|
||||
/** Put the sun at this elevation (degrees) and azimuth (clockwise from north; default: noonday's) instead of at the baked sky's: `?sun=` and `?az=` in the page URL. Lights between the two bakes, for dusk. */
|
||||
sun?: { elevation: number; azimuth?: number };
|
||||
/** The luminance band the sky is shaped into (skyBand.ts), in the sky pass and in the haze that fades into it. Default: none. */
|
||||
band?: SkyBand;
|
||||
/** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */
|
||||
seed?: number;
|
||||
/** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */
|
||||
|
|
@ -212,6 +215,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null {
|
|||
const ambient = [...ground.ambient];
|
||||
const heading = START_TURNS;
|
||||
const glow = [...glowColour(sky)];
|
||||
const band: [number, number] = sky.band ? [sky.band.floor, sky.band.ceiling] : [0, 1];
|
||||
const lift = [...(sky.band?.lift ?? [0, 0, 0])];
|
||||
const sunTexel = sunIndex(sky.elevation);
|
||||
const night = nightness(sky.elevation);
|
||||
const moonTexel = sunIndex(Math.abs(sky.elevation));
|
||||
|
|
@ -247,6 +252,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null {
|
|||
gl.uniform3fv(us('uSun'), sun);
|
||||
gl.uniform3fv(us('uSunColour'), sunLight);
|
||||
gl.uniform3fv(us('uGlow'), glow);
|
||||
gl.uniform2fv(us('uBand'), band);
|
||||
gl.uniform3fv(us('uLift'), lift);
|
||||
gl.uniform1f(us('uSunIndex'), sunTexel);
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_3D, skyTex);
|
||||
|
|
@ -271,6 +278,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null {
|
|||
gl.uniform3fv(u('uLight'), ground.colour);
|
||||
gl.uniform3fv(u('uAmbient'), ambient);
|
||||
gl.uniform3fv(u('uGlow'), glow);
|
||||
gl.uniform2fv(u('uBand'), band);
|
||||
gl.uniform3fv(u('uLift'), lift);
|
||||
gl.uniform1f(u('uSunIndex'), sunTexel);
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_3D, skyTex);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createTerrainSkyScene, SKY_WIDTH } from './TerrainSky2D';
|
||||
import { startBackgroundScene } from '../index';
|
||||
import { terrainSkyFor } from '../skyForState';
|
||||
|
||||
let search: string;
|
||||
let arcs: number;
|
||||
|
|
@ -218,3 +219,32 @@ describe('startBackgroundScene, terrain style', () => {
|
|||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the 2D sky lies in the band of its state', () => {
|
||||
// The painted pixels, read back through the sRGB curve as a screen shows them.
|
||||
const lumOf = (rgb: number[]) => {
|
||||
const [r, g, b] = rgb.map((v) => {
|
||||
const e = v / 255;
|
||||
return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const painted = (state: 'day' | 'dusk' | 'night') => {
|
||||
const c = canvasWith();
|
||||
createTerrainSkyScene({ canvas: c.canvas, tier: 'low', ...terrainSkyFor(state) })!.dispose();
|
||||
const img = c.painted[0];
|
||||
const out: number[] = [];
|
||||
for (let y = 0; y < img.height; y += 1) {
|
||||
for (let x = 0; x < img.width; x += 1) out.push(lumOf(pixel(img, x, y)));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
it.each([['day'], ['dusk']] as const)('%s: no pixel below the text bound 0.2983', (state) => {
|
||||
expect(Math.min(...painted(state))).toBeGreaterThanOrEqual(0.2983);
|
||||
});
|
||||
|
||||
it('night: no pixel above the text bound 0.0931', () => {
|
||||
expect(Math.max(...painted('night'))).toBeLessThanOrEqual(0.0931);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { CAMERA, view } from './terrainWorld';
|
|||
export const SKY_WIDTH = 96;
|
||||
|
||||
/** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */
|
||||
export type TerrainSkyOptions = Pick<TerrainOptions, 'canvas' | 'tier' | 'sky' | 'sun'>;
|
||||
export type TerrainSkyOptions = Pick<TerrainOptions, 'canvas' | 'tier' | 'sky' | 'sun' | 'band'>;
|
||||
|
||||
/**
|
||||
* Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import cultmath from '../shaders/CultMath.glsl?raw';
|
||||
import phacelle from '../shaders/CultMath.Phacelle.glsl?raw';
|
||||
import { trimGlsl } from '../cultmathTrim';
|
||||
import { LUMA } from '../skyBand';
|
||||
import { ALBEDO, CAVITY } from './terrainAlbedo';
|
||||
import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter';
|
||||
import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, SKY_FADE, STAR_FLOOR } from './terrainSky';
|
||||
|
|
@ -140,14 +141,28 @@ const ALBEDO_DEFINES = `
|
|||
#define CAV_DARK ${lit(CAVITY.dark)}
|
||||
#define CAV_LIGHT ${lit(CAVITY.light)}
|
||||
`;
|
||||
// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The
|
||||
// same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is.
|
||||
const SKY_COLOUR_GLSL = `${SCATTER_GLSL}
|
||||
// The GLSL twin of skyBand.shapeToBand: clamp to 0..1, mix toward uLift to land on the floor uBand.x, or scale down to the ceiling uBand.y.
|
||||
// No band is uBand = (0, 1).
|
||||
export const SHAPE_SKY_GLSL = `
|
||||
uniform vec2 uBand;
|
||||
uniform vec3 uLift;
|
||||
const vec3 LUMA = vec3(${LUMA.map(lit).join(', ')});
|
||||
vec3 shapeSky(vec3 c) {
|
||||
c = clamp(c, 0.0, 1.0);
|
||||
float l = dot(c, LUMA);
|
||||
if (l < uBand.x) return mix(c, uLift, (uBand.x - l) / (dot(uLift, LUMA) - l));
|
||||
if (l > uBand.y) return c * (uBand.y / l);
|
||||
return c;
|
||||
}`;
|
||||
// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow, shaped into
|
||||
// the state's luminance band. The same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already
|
||||
// scaled by how dark the sun is.
|
||||
const SKY_COLOUR_GLSL = `${SCATTER_GLSL}${SHAPE_SKY_GLSL}
|
||||
uniform vec3 uGlow;
|
||||
vec3 skyColour(vec3 d) {
|
||||
return 1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit(
|
||||
return shapeSky(1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit(
|
||||
GLOW_ZENITH
|
||||
)}, sqrt(clamp(d.y, 0.0, 1.0)));
|
||||
)}, sqrt(clamp(d.y, 0.0, 1.0))));
|
||||
}`;
|
||||
|
||||
export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL}${AERIAL_GLSL}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
skyAtSun,
|
||||
skyData,
|
||||
skyColour,
|
||||
skyNameFor,
|
||||
sunDirection,
|
||||
aerialPerspective,
|
||||
SKY_FADE,
|
||||
|
|
@ -25,7 +24,6 @@ import {
|
|||
START_TURNS,
|
||||
Vec3,
|
||||
} from './terrainSky';
|
||||
import { skyColours } from '../ridge/skyColour';
|
||||
|
||||
const UP = [0, 1, 0] as const;
|
||||
const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
||||
|
|
@ -68,11 +66,6 @@ describe('the baked sky is read, not copied', () => {
|
|||
expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]);
|
||||
expect(hexToLinear('#808080')[0]).toBeCloseTo(0.2158, 3);
|
||||
});
|
||||
|
||||
it('the default sky from skyColours() is a night one, as the stars see it', () => {
|
||||
expect(skyNameFor(skyColours())).toBe('midnight');
|
||||
expect(skyNameFor({ ...skyColours(), top: [150, 198, 255] })).toBe('noonday');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a sun at any elevation (skyAtSun)', () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
// Lighting, haze and sky for the terrain scene, as pure functions of one sky's baked numbers (src/sky/baked.ts, Void's
|
||||
// bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms.
|
||||
import { baked } from '../../../../sky/baked';
|
||||
import { isNight } from '../ridge/stars';
|
||||
import { skyColours } from '../ridge/skyColour';
|
||||
import type { SkyColours } from '../ridge/skyColour';
|
||||
import { hexToLinear, shapeToBand } from '../skyBand';
|
||||
import type { SkyBand } from '../skyBand';
|
||||
import {
|
||||
AP_DISTANCE_SCALE,
|
||||
aerialAt,
|
||||
|
|
@ -26,6 +25,8 @@ export interface SkyData {
|
|||
ambient: Vec3;
|
||||
/** The horizon, linear RGB: the baked `horizon_hex`. */
|
||||
horizon: Vec3;
|
||||
/** The luminance band `skyColour` shapes the sky into (skyBand.ts); absent: the physical sky as it is. */
|
||||
band?: SkyBand;
|
||||
}
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
|
|
@ -37,19 +38,9 @@ const smooth = (a: number, b: number, x: number) => {
|
|||
const mixVec = (a: Vec3, b: Vec3, t: number): Vec3 =>
|
||||
[0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * t) as unknown as Vec3;
|
||||
|
||||
/** Which baked sky a ridge-style `SkyColours` means: the night notion the stars use (`isNight`). */
|
||||
export const skyNameFor = (sky: SkyColours): SkyName => (isNight(sky) ? 'midnight' : 'noonday');
|
||||
|
||||
export const isSkyName = (s: unknown): s is SkyName => s === 'midnight' || s === 'noonday';
|
||||
|
||||
/** sRGB hex `#rrggbb` to linear RGB 0..1. */
|
||||
export function hexToLinear(hex: string): Vec3 {
|
||||
const ch = (at: number) => {
|
||||
const s = parseInt(hex.slice(at, at + 2), 16) / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return [ch(1), ch(3), ch(5)];
|
||||
}
|
||||
export { hexToLinear };
|
||||
|
||||
const fromBaked = (b: typeof baked.midnight | typeof baked.noonday): SkyData => ({
|
||||
elevation: b.sun.elevation_deg,
|
||||
|
|
@ -115,7 +106,8 @@ export const glowColour = (d: SkyData): Vec3 => {
|
|||
/**
|
||||
* Linear colour of the sky along a unit view direction (x east, y up, z south): the baked single-scattering
|
||||
* radiance for this sun, lifted by SKY_EXPOSURE and tone-mapped `1 - exp(-x)`, plus the night glow, which sits
|
||||
* brightest at the horizon; at and below the horizon, the horizon's colour. The fragment shader does the same.
|
||||
* brightest at the horizon; at and below the horizon, the horizon's colour; then shaped into `d.band` when it has one.
|
||||
* The fragment shader does the same.
|
||||
*/
|
||||
export function skyColour(d: SkyData, dir: Vec3): Vec3 {
|
||||
const el = Math.max(HORIZON_FLOOR, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD);
|
||||
|
|
@ -129,9 +121,10 @@ export function skyColour(d: SkyData, dir: Vec3): Vec3 {
|
|||
const r = scatterRadiance(d.elevation, el, az);
|
||||
const glow = glowColour(d);
|
||||
const shape = 1 + (GLOW_ZENITH - 1) * Math.sqrt(clamp01(dir[1]));
|
||||
return [0, 1, 2].map(
|
||||
const c = [0, 1, 2].map(
|
||||
(i) => 1 - Math.exp(-SKY_EXPOSURE * r[i]) + glow[i] * shape
|
||||
) as unknown as Vec3;
|
||||
return d.band ? shapeToBand(c, d.band) : c;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -228,24 +221,33 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number,
|
|||
}) as unknown as Vec3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
/** What the page URL asks of the sky, if anything: `?sun=` (with `?az=`) wins over `?sky=midnight|noonday`; a bad value asks nothing. */
|
||||
export function urlSky():
|
||||
| { sky?: SkyName; sun?: { elevation: number; azimuth?: number } }
|
||||
| undefined {
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN);
|
||||
const elevation = number('sun');
|
||||
if (Number.isFinite(elevation)) {
|
||||
const azimuth = number('az');
|
||||
return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined);
|
||||
return { sun: { elevation, azimuth: Number.isFinite(azimuth) ? azimuth : undefined } };
|
||||
}
|
||||
const name = q.get('sky');
|
||||
return skyData(isSkyName(name) ? name : skyNameFor(skyColours()));
|
||||
return isSkyName(name) ? { sky: name } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* (`urlSky`), else midnight. A band, when given, shapes whichever sky that is.
|
||||
*/
|
||||
export function chooseSky(opts: {
|
||||
sky?: SkyName;
|
||||
sun?: { elevation: number; azimuth?: number };
|
||||
band?: SkyBand;
|
||||
}): SkyData {
|
||||
const banded = (d: SkyData): SkyData => (opts.band ? { ...d, band: opts.band } : d);
|
||||
const want = opts.sun || opts.sky ? opts : urlSky() ?? { sky: 'midnight' as const };
|
||||
if (want.sun) return banded(skyAtSun(want.sun.elevation, want.sun.azimuth));
|
||||
return banded(skyData(want.sky ?? 'midnight'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useSyncExternalStore } from 'react';
|
||||
import { getSkyState, subscribe, type SkyState } from '../features/scene/skyState';
|
||||
|
||||
/** The choice, as the control shows it ('auto' stays 'auto'). The sky being drawn is `getResolvedSkyState`. */
|
||||
export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState);
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export const useSystemThemeKind = (): ThemeKind => {
|
|||
return themeKind;
|
||||
};
|
||||
|
||||
/** The theme the sky state asks for, or undefined while it is 'auto'. */
|
||||
/** The theme the sky state asks for, or undefined while it is 'auto': auto never overrides a person's theme. */
|
||||
export const useSkyTheme = (): Theme | undefined => {
|
||||
const id = skyThemeId(useSkyState());
|
||||
return id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme;
|
||||
|
|
|
|||
Loading…
Reference in a new issue