diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 6c3d034a..7971508b 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -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(); }); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 55b00e86..73ab8d5a 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -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; diff --git a/src/app/features/scene/ridge/skyColour.ts b/src/app/features/scene/ridge/skyColour.ts index 326fe66d..3a5147a4 100644 --- a/src/app/features/scene/ridge/skyColour.ts +++ b/src/app/features/scene/ridge/skyColour.ts @@ -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, }; diff --git a/src/app/features/scene/ridge/stars.test.ts b/src/app/features/scene/ridge/stars.test.ts index 38b4e7e8..e14f2a7c 100644 --- a/src/app/features/scene/ridge/stars.test.ts +++ b/src/app/features/scene/ridge/stars.test.ts @@ -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', () => { diff --git a/src/app/features/scene/skyBand.test.ts b/src/app/features/scene/skyBand.test.ts new file mode 100644 index 00000000..5963fb79 --- /dev/null +++ b/src/app/features/scene/skyBand.test.ts @@ -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\)\)/); + }); + }); +}); diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts new file mode 100644 index 00000000..b6b39315 --- /dev/null +++ b/src/app/features/scene/skyBand.ts @@ -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 = { + 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; +} diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 20bc023a..2ad3b5f6 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -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, 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); }); }); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index aedc6861..7da17476 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -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) }; } diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index 7b33a1ba..ae4d9e83 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -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; + 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']]); }); }); diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index dc2b6199..666e83d6 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -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 | 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 = { - 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 = { + 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)]; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 3fd06875..f6873466 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -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(); }); }); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 8854fcd4..52aff750 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -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, SkyThemeId> = { +const BY_STATE: Record = { day: 'noonday-sky', dusk: 'noonday-sky', night: 'midnight-sky', diff --git a/src/app/features/scene/standardTime.testkit.ts b/src/app/features/scene/standardTime.testkit.ts new file mode 100644 index 00000000..a6966238 --- /dev/null +++ b/src/app/features/scene/standardTime.testkit.ts @@ -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); +}; diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index 01f2df11..ef1623c4 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -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).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) => { const { gl, calls } = fakeGl(); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index fdc53926..b697927b 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -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); diff --git a/src/app/features/scene/terrain/TerrainSky2D.test.ts b/src/app/features/scene/terrain/TerrainSky2D.test.ts index c3eb1e3d..c3a95d69 100644 --- a/src/app/features/scene/terrain/TerrainSky2D.test.ts +++ b/src/app/features/scene/terrain/TerrainSky2D.test.ts @@ -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); + }); +}); diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts index 0c19ec97..71bc7a78 100644 --- a/src/app/features/scene/terrain/TerrainSky2D.ts +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -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; +export type TerrainSkyOptions = Pick; /** * Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 8e270d83..b79e2873 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -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} diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index a6dc7a7e..f409471a 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -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)', () => { diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index f6647407..92a2ab22 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -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')); } diff --git a/src/app/hooks/useSkyState.ts b/src/app/hooks/useSkyState.ts index 985e2da2..d49bf267 100644 --- a/src/app/hooks/useSkyState.ts +++ b/src/app/hooks/useSkyState.ts @@ -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); diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index bf556dd0..00ac1ee9 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -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;