scene: keep the terrain sky and baked tables out of the main chunk (stacked on #47) #48
4 changed files with 184 additions and 49 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in a new issue