diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 3cc42486..55b00e86 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -2,7 +2,6 @@ import type { Scene, SceneOptions } from './Scene'; import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; import { getSkyState, subscribe } from './skyState'; import type { SkyState } from './skyState'; -import { ridgeSkyFor, terrainSkyFor } from './skyForState'; export type { Scene, SceneStats } from './Scene'; export type { SceneStyle, SceneTier } from './tier'; @@ -23,6 +22,8 @@ async function create( ): Promise { let createScene: (o: SceneOptions) => Scene | null; try { + // Loaded here, with the scenes, so the main chunk does not carry the terrain sky or the baked tables. + const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState'); if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; else if (config?.style === 'terrain') { const { createTerrainScene } = await import('./terrain/Terrain'); diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 1e09021d..20bc023a 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -72,31 +72,43 @@ describe('terrainSkyFor', () => { }); describe('the terrain sky lies inside skyBackdrop', () => { - // A grid offset from the one skyBackdrop samples, so a miss between its samples shows. + // 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[] = []; - for (let el = 1.3; el <= 90; el += 4.1) { - for (let az = 0.7; az < 360; az += 4.3) { - const e = (el * Math.PI) / 180; - const a = (az * Math.PI) / 180; - const c = skyColour(sky, [ - Math.cos(e) * Math.sin(a), - Math.sin(e), - -Math.cos(e) * Math.cos(a), - ]); - const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); - out.push(0.2126 * r + 0.7152 * g + 0.0722 * b); + 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; }; - it.each(['day', 'dusk', 'night'] as const)('for %s', (state) => { + it.each(['day', 'dusk', 'night'] as const)('for %s, and no looser than 5e-4 from it', (state) => { const { darkest, lightest } = skyBackdrop(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); }); it('and the terrain dusk reaches the sun glow the ridge dusk does not', () => { @@ -116,3 +128,92 @@ describe('both scenes of a state lie inside skyBackdrop', () => { }); }); }); + +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') => { + 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 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; + }; + const P = 0.6180339887; + const line = (g: (x: number) => number, mid: number, w: number) => { + let a = mid - w; + let b = mid + w; + for (let k = 0; k < 40; k += 1) { + const c = b - P * (b - a); + const d = a + P * (b - a); + if (g(c) > g(d)) b = d; + else a = c; + } + return (a + b) / 2; + }; + const search = (sign: 1 | -1) => { + 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; + 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); + const el1 = el; + az = line((x) => sign * f(el1, x), az0, w); + best = Math.max(best, sign * f(el, az)); + } + } + return sign * best; + }; + return { darkest: search(-1), lightest: search(1) }; + }; + + const computed = (state: 'day' | 'dusk' | 'night') => { + const r = [ridgeDrawn(state), terrainDrawn(state)].filter((x) => x !== undefined) as { + darkest: number; + lightest: number; + }[]; + 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), + }; + }; + + // 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)( + '%s: the constant is the swept extreme rounded outward, within 1e-6 above it', + (state) => { + const c = computed(state); + const b = skyBackdrop(state); + expect(b.darkest).toBeLessThanOrEqual(c.darkest); + expect(b.lightest).toBeGreaterThanOrEqual(c.lightest); + expect(c.darkest - b.darkest).toBeLessThan(1e-6 + SEARCH); + expect(b.lightest - c.lightest).toBeLessThan(1e-6 + SEARCH); + } + ); + + 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)), + }); + }); +}); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index aa3a7803..aedc6861 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -70,6 +70,10 @@ export function ridgeDrawn(state: SkyState): Range | undefined { } const SAMPLE_DEG = 3; +const SUN_DEG = 0.25; +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; @@ -77,22 +81,64 @@ const linearLuminance = (c: Vec3) => { /** * The terrain sky's range: `skyColour` (the CPU twin of its sky shader) over the whole upper sphere, which the camera - * sweeps as it turns, for the sky the scene picks for this state. Sampled every 3 degrees. + * 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. */ export function terrainDrawn(state: SkyState): Range { const sky = chooseSky(terrainSkyFor(state)); - let darkest = Infinity; - let lightest = -Infinity; + 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) { - const e = (el * Math.PI) / 180; - const a = (az * Math.PI) / 180; - const l = linearLuminance( - skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) - ); - darkest = Math.min(darkest, l); - lightest = Math.max(lightest, l); + 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; 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; + } + } + return best.l; + }; + 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 }; } diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index bc164d7a..dc2b6199 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -2,8 +2,6 @@ // localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and // `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the // text colours can be checked against it. -import { ridgeDrawn, terrainDrawn } from './skyForState'; - export type SkyState = 'auto' | 'day' | 'dusk' | 'night'; export const SKY_STATES: readonly SkyState[] = ['auto', 'day', 'dusk', 'night']; @@ -58,27 +56,16 @@ 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. 'auto' follows the clock (and a URL), so it bounds all three fixed -// states. Computed on first use: the terrain range samples its sky. -const union = (ranges: readonly (SkyBackdrop | undefined)[]): SkyBackdrop => { - const r = ranges.filter((x): x is SkyBackdrop => !!x); - return { - darkest: Math.min(...r.map((x) => x.darkest)), - lightest: Math.max(...r.map((x) => x.lightest)), - }; +// 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 }, }; -const FIXED_STATES = ['day', 'dusk', 'night'] as const; -const cache = new Map(); - -export function skyBackdrop(state: SkyState): SkyBackdrop { - let b = cache.get(state); - if (!b) { - b = - state === 'auto' - ? union(FIXED_STATES.map(skyBackdrop)) - : union([ridgeDrawn(state), terrainDrawn(state)]); - cache.set(state, b); - } - return b; -} +export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[state];