scene: keep the terrain sky and baked tables out of the main chunk (stacked on #47) #48

Merged
void merged 4 commits from nibu/sky-bundle into dev 2026-11-09 23:35:25 +00:00 AGit
4 changed files with 184 additions and 49 deletions

View file

@ -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<Scene | null> {
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');

View file

@ -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)),
});
});
});

View file

@ -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 };
}

View file

@ -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<SkyState, SkyBackdrop> = {
auto: { darkest: 0.016727, lightest: 0.967912 },
day: { darkest: 0.237309, lightest: 0.928052 },
dusk: { darkest: 0.016727, lightest: 0.967912 },
night: { darkest: 0.058099, lightest: 0.077467 },
};
const FIXED_STATES = ['day', 'dusk', 'night'] as const;
const cache = new Map<SkyState, SkyBackdrop>();
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];