From 0d26b7186210e38208fc4bcbc79c0df97e8099fa Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:37:06 +0000 Subject: [PATCH 1/3] scene: keep the terrain sky and baked tables out of the main chunk skyBackdrop reads constants (the union of the ridge and terrain skies, rounded outward) instead of computing it, and the door imports skyForState lazily with the scenes. A test fails when the constants drift from the computed union. Main entry chunk vs dev: +0.4 KB gzip, down from +51.5 KB. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.ts | 3 +- src/app/features/scene/skyForState.test.ts | 33 ++++++++++++++++++++ src/app/features/scene/skyState.ts | 35 +++++++--------------- 3 files changed, 46 insertions(+), 25 deletions(-) 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..aff09b1e 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -116,3 +116,36 @@ describe('both scenes of a state lie inside skyBackdrop', () => { }); }); }); + +describe('skyBackdrop holds the union of what the scenes draw', () => { + const computed = (state: 'day' | 'dusk' | 'night') => { + const r = [ridgeDrawn(state), terrainDrawn(state)].filter((x) => x !== undefined) as { + darkest: number; + lightest: number; + }[]; + return { + darkest: Math.min(...r.map((x) => x.darkest)), + lightest: Math.max(...r.map((x) => x.lightest)), + }; + }; + + it.each(['day', 'dusk', 'night'] as const)( + '%s: the constant bounds the computed range and sits within 1e-5 of 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-5); + expect(b.lightest - c.lightest).toBeLessThan(1e-5); + } + ); + + 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/skyState.ts b/src/app/features/scene/skyState.ts index bc164d7a..a6d1ce8e 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.964723 }, + day: { darkest: 0.237514, lightest: 0.919226 }, + dusk: { darkest: 0.016727, lightest: 0.964723 }, + 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]; -- 2.47.3 From 8a05668d8483e83bbf1e83036864533e967c76f2 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:41:27 +0000 Subject: [PATCH 2/3] scene: terrain backdrop range found by refinement, not a grid terrainDrawn finds the sun glow peak and the darkest patch with a compass search from the best coarse samples; constants regenerated (Day 0.237309-0.92805, Dusk 0.016727-0.967912). The drift test sweeps its own grid (golden-angle spiral plus a 0.05 degree patch around the sun). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyForState.test.ts | 38 +++++++----- src/app/features/scene/skyForState.ts | 68 ++++++++++++++++++---- src/app/features/scene/skyState.ts | 6 +- 3 files changed, 85 insertions(+), 27 deletions(-) diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index aff09b1e..44ca5899 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', () => { diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index aa3a7803..a6d78e4a 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 > 0.0005; 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 a6d1ce8e..e06e9845 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -62,9 +62,9 @@ export interface SkyBackdrop { // 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.964723 }, - day: { darkest: 0.237514, lightest: 0.919226 }, - dusk: { darkest: 0.016727, lightest: 0.964723 }, + auto: { darkest: 0.016727, lightest: 0.967912 }, + day: { darkest: 0.237309, lightest: 0.92805 }, + dusk: { darkest: 0.016727, lightest: 0.967912 }, night: { darkest: 0.058099, lightest: 0.077467 }, }; -- 2.47.3 From eafa3eda798f4dcfb2aa0d49c686b763902c9aed Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 23:28:55 +0000 Subject: [PATCH 3/3] scene: Day sky bound was 1.2e-6 low; refine to 1e-6 degrees and check both directions Day lightest 0.92805 -> 0.928052 (true terrain peak 0.92805117...). The compass refinement now halves to 5e-7 degrees; the drift test checks the constant against the refined computation and an independent golden-section sweep, exact to [extreme, extreme + 1e-6). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyForState.test.ts | 66 ++++++++++++++++++++-- src/app/features/scene/skyForState.ts | 2 +- src/app/features/scene/skyState.ts | 2 +- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 44ca5899..20bc023a 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -130,26 +130,82 @@ 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)), - lightest: Math.max(...r.map((x) => x.lightest)), + 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 bounds the computed range and sits within 1e-5 of it', + '%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-5); - expect(b.lightest - c.lightest).toBeLessThan(1e-5); + expect(c.darkest - b.darkest).toBeLessThan(1e-6 + SEARCH); + expect(b.lightest - c.lightest).toBeLessThan(1e-6 + SEARCH); } ); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index a6d78e4a..aedc6861 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -121,7 +121,7 @@ export function terrainDrawn(state: SkyState): Range { }); const refine = (from: Point, sign: 1 | -1) => { let best = from; - for (let step = 1; step > 0.0005; step /= 2) { + for (let step = 1; step > 5e-7; step /= 2) { let improved = true; while (improved) { const cur = best; diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index e06e9845..dc2b6199 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -63,7 +63,7 @@ export interface SkyBackdrop { // bounds all three fixed states. const BACKDROPS: Record = { auto: { darkest: 0.016727, lightest: 0.967912 }, - day: { darkest: 0.237309, lightest: 0.92805 }, + day: { darkest: 0.237309, lightest: 0.928052 }, dusk: { darkest: 0.016727, lightest: 0.967912 }, night: { darkest: 0.058099, lightest: 0.077467 }, }; -- 2.47.3