Merge pull request 'Background scene: two-pass WebGL2 terrain and world tree with login pull-back' (#1) from nibu/background-scene into ratatoskr/estate-navigator

This commit is contained in:
Void 2026-11-02 16:50:13 +00:00
commit 686f72bcf1
33 changed files with 3441 additions and 0 deletions

View file

@ -17,5 +17,9 @@
"hashRouter": {
"enabled": false,
"basename": "/"
},
"backgroundScene": {
"tier": "off"
}
}

19
harness/scene/README.md Normal file
View file

@ -0,0 +1,19 @@
# Scene harness
Compiles, renders and times the background scene (`src/app/features/scene/`) in headless
Chromium. Not shipped, not run by CI.
node bundle.cjs <outdir> # esbuild bundle of entry.ts plus an index.html
node drive.cjs <outdir> compile # compile and link each stage, JSON report
node drive.cjs <outdir> shots <pngdir> # rest, mid pull-back and pulled-back screenshots
node drive.cjs <outdir> measure # GPU time per pass by timer query, two viewports, two levels
node drive.cjs <outdir> variants # the same with source patches (no erosion, no tree, value-only fbm)
node drive.cjs <outdir> phacelle # the Phacelle gradient probe behind world.ts `phacelleGradBound`
Needs `esbuild` and `playwright@1.48.2` on `NODE_PATH` and a Chromium. The first run was done on a
host with no GPU, so `drive.cjs` launches with `--use-angle=swiftshader` (software Vulkan). On a
machine with a GPU, remove those flags to measure the real device. `bundle.cjs` must run from a
directory whose ancestors hold no empty `package.json` (esbuild reads them).
Timings from SwiftShader are CPU time: use them for the split between passes and for before and
after of one change, never as a phone frame time.

35
harness/scene/bundle.cjs Normal file
View file

@ -0,0 +1,35 @@
// Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html.
// usage: node bundle.cjs <outdir> (esbuild from NODE_PATH)
const esbuild = require('esbuild');
const fs = require('fs');
const path = require('path');
const out = process.argv[2];
fs.mkdirSync(out, { recursive: true });
const rawPlugin = {
name: 'raw',
setup(b) {
b.onResolve({ filter: /\?raw$/ }, (a) => ({
path: path.resolve(a.resolveDir, a.path.slice(0, -4)),
namespace: 'raw',
}));
b.onLoad({ filter: /.*/, namespace: 'raw' }, (a) => ({
contents: fs.readFileSync(a.path, 'utf8'),
loader: 'text',
}));
},
};
esbuild
.build({
entryPoints: [path.join(__dirname, 'entry.ts')],
bundle: true,
outfile: path.join(out, 'bundle.js'),
plugins: [rawPlugin],
format: 'iife',
target: 'es2020',
})
.then(() => {
fs.writeFileSync(
path.join(out, 'index.html'),
'<!doctype html><meta charset=utf-8><body style="margin:0"><canvas id=c style="width:100vw;height:100vh;display:block"></canvas><script src=bundle.js></script>'
);
});

164
harness/scene/drive.cjs Normal file
View file

@ -0,0 +1,164 @@
// Drives the scene in headless Chromium (software GL unless the host has a GPU).
// usage: node drive.cjs <bundledir> <mode> [outdir] modes: compile | shots | measure
// Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report.
const { chromium } = require('playwright');
const path = require('path');
const [, , dir, mode, outdir = '.'] = process.argv;
(async () => {
const b = await chromium.launch({
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
],
});
const page = await b.newPage({ viewport: { width: 960, height: 540 } });
page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`));
await page.goto('file://' + path.resolve(dir, 'index.html'));
const report = { mode };
report.renderer = await page.evaluate(() => {
const g = document.createElement('canvas').getContext('webgl2');
const e = g.getExtension('WEBGL_debug_renderer_info');
return g.getParameter(e ? e.UNMASKED_RENDERER_WEBGL : g.RENDERER);
});
if (mode === 'compile') {
report.stages = await page.evaluate(() => {
const gl = document.createElement('canvas').getContext('webgl2');
const S = window.__scene;
return ['terrain', 'atmosphere', 'resolve', 'composite'].map((stage) => {
const t0 = performance.now();
try {
const p = S.linkStage(gl, stage);
return {
stage,
ok: true,
ms: +(performance.now() - t0).toFixed(1),
sourceBytes: S.fragmentSource(stage).length,
log: gl.getProgramInfoLog(p),
};
} catch (e) {
return {
stage,
ok: false,
ms: +(performance.now() - t0).toFixed(1),
log: String(e.log || e),
};
}
});
});
}
if (mode === 'phacelle') {
report.probe = await page.evaluate(() =>
window.__probePhacelle(
window.__scene.WORLD.erodeSide.slice(),
window.__scene.WORLD.erodeNorm,
4
)
);
}
if (mode === 'shots') {
await page.evaluate(() => {
const c = document.getElementById('c');
window.__s = window.__scene.createScene({
canvas: c,
unthrottled: true,
fixedLevel: window.__scene.LEVELS[0],
});
});
const wait = (ms) => page.waitForTimeout(ms);
await wait(4000);
await page.screenshot({ path: path.join(outdir, 'rest.png') });
await page.evaluate(() => window.__s.login());
await wait(3500);
await page.screenshot({ path: path.join(outdir, 'mid-pullback.png') });
await wait(9000);
await page.screenshot({ path: path.join(outdir, 'pulled-back.png') });
report.shots = ['rest.png', 'mid-pullback.png', 'pulled-back.png'];
}
if (mode === 'measure' || mode === 'variants') {
const VARIANTS = {
baseline: null,
// What a public value-only fbm would cost: the same octaves without the gradient work.
valueOnlyFbm: [
'float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }',
'float terrainFbm(vec2 xz) { float a = 1.0, fr = 1.0, v = 0.0; for (int i = 0; i < W_OCTAVES; i++) { v += a * cultmath_snoise(vec3(xz * W_FREQ * fr, 0.0)); fr *= W_LACUNARITY; a *= W_GAIN; } return v; }',
],
noErosion: ['#define W_ERODE_ENABLED 1', '#define W_ERODE_ENABLED 0'],
// Upper bound on what skipping sky rays would save: every upward ray misses at once.
noSkyMarch: [' bool hit = false;\n bool onTree = false;', ' if (rd.y > 0.0) { o_color = vec4(0.0); o_depth = W_FAR; return; }\n bool hit = false;\n bool onTree = false;'],
noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'],
};
const wanted = mode === "variants" ? (process.env.ONLY ? process.env.ONLY.split(",") : Object.keys(VARIANTS)) : ['baseline'];
const sizes = [
[960, 540],
[540, 1200],
];
report.runs = [];
for (const [w, h] of sizes) {
for (const [li, variant] of wanted.flatMap((v) =>
mode === 'variants'
? [[0, v]]
: [
[0, v],
[3, v],
]
)) {
await page.setViewportSize({ width: w, height: h });
const r = await page.evaluate(
({ li, rep }) =>
new Promise((res) => {
const rows = [];
const c = document.getElementById('c');
const s = window.__scene.createScene({
canvas: c,
unthrottled: true,
fixedLevel: window.__scene.LEVELS[li],
patchSource: rep
? (st, src) =>
st === 'terrain'
? rep[0].startsWith('#define')
? src.replace(/#define W_ERODE_ENABLED \d/, rep[1])
: src.split(rep[0]).join(rep[1])
: src
: undefined,
onStats: (st) => {
rows.push(st);
if (rows.length === 40) {
s.dispose();
const keep = rows.slice(10);
const med = (k) =>
keep.map((x) => x.passMs[k]).sort((a, b) => a - b)[keep.length >> 1];
res({
solid: keep[0].solidSize,
atm: keep[0].atmosphereSize,
medianMs: {
terrain: med('terrain'),
atmosphere: med('atmosphere'),
resolve: med('resolve'),
composite: med('composite'),
},
});
}
},
});
if (!s) res({ error: 'createScene returned null' });
}),
{ li, rep: VARIANTS[variant] }
);
report.runs.push({ viewport: [w, h], level: li, variant, ...r });
}
}
}
console.log(JSON.stringify(report, null, 1));
await b.close();
})().catch((e) => {
console.log('ERR', String(e).slice(0, 2000));
process.exit(1);
});

87
harness/scene/entry.ts Normal file
View file

@ -0,0 +1,87 @@
// Browser entry for the harness: exposes the scene's seams to the driver. Not shipped.
import { createScene, linkStage, ShaderCompileError } from '../../src/app/features/scene/Scene';
import { LEVELS } from '../../src/app/features/scene/budget';
import { fragmentSource } from '../../src/app/features/scene/shaderSource';
import { WORLD } from '../../src/app/features/scene/world';
(window as unknown as Record<string, unknown>).__scene = {
createScene,
linkStage,
ShaderCompileError,
LEVELS,
fragmentSource,
WORLD,
};
// ---- Phacelle gradient probe -----------------------------------------------------------------
import cultmath from '../../src/app/features/scene/shaders/CultMath.glsl?raw';
import phacelle from '../../src/app/features/scene/shaders/CultMath.Phacelle.glsl?raw';
import { vertexSource } from '../../src/app/features/scene/shaderSource';
const PROBE_FRAG = `#version 300 es
precision highp float; precision highp int;
${cultmath}
${phacelle}
uniform float u_seed; uniform vec3 u_side; uniform float u_norm; uniform float u_span;
in vec2 v_uv; layout(location=0) out vec4 o;
void main() {
// The scene samples the z = 0 slice: random points in a u_span square of it.
vec3 h = vec3(cultmath_pcg3d(vec3(gl_FragCoord.xy, u_seed))) ;
vec2 xy = vec2(float(uint(int(h.x)) >> 8), float(uint(int(h.y)) >> 8)) * (1.0 / 16777216.0) * u_span;
CultPhasor ph = cultmath_phacelle(vec3(xy, 0.0), u_side, 0.0, u_norm);
o = vec4(length(ph.cos.xyz), length(ph.sin.xyz), ph.cos.w, ph.sin.w);
}`;
/** Max |grad| of the phasor over `draws * 1024^2` random points; min/max of the cos component. */
(window as unknown as Record<string, unknown>).__probePhacelle = (
side: number[],
norm: number,
draws: number
) => {
const c = document.createElement('canvas');
const gl = c.getContext('webgl2')!;
gl.getExtension('EXT_color_buffer_float');
const mk = (t: number, s: string) => {
const x = gl.createShader(t)!;
gl.shaderSource(x, s);
gl.compileShader(x);
if (!gl.getShaderParameter(x, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(x)!);
return x;
};
const p = gl.createProgram()!;
gl.attachShader(p, mk(gl.VERTEX_SHADER, vertexSource));
gl.attachShader(p, mk(gl.FRAGMENT_SHADER, PROBE_FRAG));
gl.linkProgram(p);
gl.useProgram(p);
const N = 1024;
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, N, N);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.viewport(0, 0, N, N);
gl.bindVertexArray(gl.createVertexArray());
gl.uniform3fv(gl.getUniformLocation(p, 'u_side'), side);
gl.uniform1f(gl.getUniformLocation(p, 'u_norm'), norm);
gl.uniform1f(gl.getUniformLocation(p, 'u_span'), 400);
const buf = new Float32Array(N * N * 4);
let gradCos = 0,
gradSin = 0,
vMin = 1e9,
vMax = -1e9,
n = 0;
for (let d = 0; d < draws; d++) {
gl.uniform1f(gl.getUniformLocation(p, 'u_seed'), d + 1);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.readPixels(0, 0, N, N, gl.RGBA, gl.FLOAT, buf);
for (let i = 0; i < buf.length; i += 4) {
if (buf[i] > gradCos) gradCos = buf[i];
if (buf[i + 1] > gradSin) gradSin = buf[i + 1];
if (buf[i + 2] < vMin) vMin = buf[i + 2];
if (buf[i + 2] > vMax) vMax = buf[i + 2];
n++;
}
}
return { samples: n, maxGradCos: gradCos, maxGradSin: gradSin, minCos: vMin, maxCos: vMax };
};

View file

@ -0,0 +1,69 @@
# Background scene
A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath
noise with a world tree at its centre, dusk atmosphere and clouds, and a camera that pulls back
from the tree on login. It is a self-contained module; nothing in the client imports it yet.
Integration (a canvas under the app, `startBackgroundScene`, `login()` when the session starts,
`dispose()`) is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel
are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure
callbacks and the creature sizing rule. Nothing has drawn a creature.
## Entry points
- `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's
door. The client config's `backgroundScene.tier` (`off` default, `still`, `low`, `full`) is the
feature flag and the ceiling; `tier.ts` lowers it for `saveData`, low memory or few cores. At
`off` nothing is imported and no canvas context is requested; otherwise `Scene.ts` loads on
demand, so it stays out of the main chunk; if that chunk fails to load the error goes to
`onCompileFail` and the door returns null, it does not throw.
- `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns
`{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 or
`EXT_color_buffer_float` is missing, or a shader fails to compile (`onCompileFail` gets the
`ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a
baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context
stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to
show. It caps at 30 fps, pauses when
the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion` (a change of the
preference while it runs freezes or thaws it), and steps down a
quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist.
- `creatures.ts` the creature record and `creatureScale`: a neutral creature is one fixed size and
overrides `sizeBucket`. The bucket names and scales are placeholders until the persona cut owns them.
- `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`).
- `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL
`#define`s are generated from it; no number is written twice.
- `shaders/` pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA),
`composite.frag.glsl`, plus CultLib's vendored GLSL (`shaders/README.md`).
## How it renders
1. **Solid pass** (`terrain.frag.glsl`, 0.4-0.75 of canvas resolution): sphere trace of
`min(terrain, tree)`. Terrain height is `mask(r) * (A * fbm + E * phacelle)`, with
`cultmath_fbm_grad(...).w` and `cultmath_phacelle(...).cos.w`. It writes lit colour with
coverage and the hit distance (second render target).
2. **Atmosphere pass** (0.25-0.5 of canvas): closed-form exponential height fog plus a cloud
slab marched with a few jittered steps, both only up to the hit distance from pass 1.
3. **Resolve**: TAA on the atmosphere (Halton jitter, history reprojected by the pass-1 depth,
3x3 neighbourhood clamp, 1/8 blend; reset on resize, level change and wake).
4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES
tonemap, dither.
## The step bound, and what is not proven
The trace steps by `(y - h(x, z)) / sqrt(1 + G^2)`, `G` the Lipschitz bound of the height field,
computed from `SNOISE_LIPSCHITZ` (CultMath's constant for `snoise`) in `world.ts`. Above the
envelope (fbm plus the erosion amplitude) the step uses the envelope's bound alone and Phacelle is
not evaluated.
- The Phacelle gradient bound `phacelleGradBound` (7.0) is Libby's measured sup (6.63 at
|side| = 1.5, n = 0, CultLib PR #1 head 990521b3) plus margin: a measurement, not a proof.
The shader fixes roughness n = 0; a nonzero n needs the bound divided by (1 - n).
- The bound makes grazing rays crawl. A ray that exhausts its steps while still below the highest
terrain is ended as a distant surface (fog hides it). That is a visible approximation, not a
hit proven by the march.
- The low-resolution atmosphere samples pass-1 depth nearest, so silhouettes of near objects
carry a thin fog halo.
## Measured and not measured
See the pull request or `harness/scene/README.md` for the numbers and how they were taken. They
come from a software rasterizer, not a GPU, and nothing has been run on a phone.

View file

@ -0,0 +1,305 @@
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { createScene, ShaderCompileError } from './Scene';
import { LEVELS } from './budget';
import { startBackgroundScene } from './index';
type Fn = Mock;
/** A GL context where every call is a spy and every constant is 1. Compiles succeed until `failFrom`. */
function fakeGl(failFrom = Infinity) {
let compiles = 0;
const calls: Record<string, Fn> = {};
const spy = (name: string, impl: () => unknown = () => ({})): Fn => {
if (!calls[name]) calls[name] = vi.fn(impl);
return calls[name];
};
const gl = new Proxy({} as Record<string, unknown>, {
get(_, prop: string) {
if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 1;
if (prop === 'compileShader') {
return spy(prop, () => {
compiles += 1;
});
}
if (prop === 'getShaderParameter') return spy(prop, () => compiles < failFrom);
if (prop === 'getShaderInfoLog') return spy(prop, () => 'ERROR: 0:1: bad');
if (prop === 'getProgramParameter') return spy(prop, () => true);
return spy(prop);
},
});
return { gl: gl as unknown as WebGL2RenderingContext, calls };
}
function fakeCanvas(gl: WebGL2RenderingContext | null) {
const canvas = new EventTarget() as EventTarget & {
clientWidth: number;
clientHeight: number;
width: number;
height: number;
getContext: Fn;
};
canvas.clientWidth = 400;
canvas.clientHeight = 300;
canvas.width = 0;
canvas.height = 0;
canvas.getContext = vi.fn(() => gl);
return canvas;
}
let rafQueue: Array<(t: number) => void>;
let cancelled: number;
let win: EventTarget;
let motion: EventTarget & { matches: boolean };
let doc: EventTarget & { visibilityState: string };
beforeEach(() => {
rafQueue = [];
cancelled = 0;
motion = Object.assign(new EventTarget(), { matches: false });
win = Object.assign(new EventTarget(), {
devicePixelRatio: 1,
matchMedia: () => motion,
});
doc = Object.assign(new EventTarget(), { visibilityState: 'visible' });
vi.stubGlobal('window', win);
vi.stubGlobal('document', doc);
vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb));
vi.stubGlobal('cancelAnimationFrame', () => {
cancelled += 1;
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
const runFrame = (now: number) => {
const cb = rafQueue.shift();
if (!cb) throw new Error('no frame was scheduled');
cb(now);
};
const asCanvas = (c: ReturnType<typeof fakeCanvas>) => c as unknown as HTMLCanvasElement;
describe('createScene tiers', () => {
it("'off' returns null without asking the canvas for a context", () => {
const { gl } = fakeGl();
const canvas = fakeCanvas(gl);
expect(createScene({ canvas: asCanvas(canvas), tier: 'off' })).toBeNull();
expect(canvas.getContext).not.toHaveBeenCalled();
expect(rafQueue).toHaveLength(0);
});
it("'still' draws one frame and schedules no more", () => {
const { gl, calls } = fakeGl();
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' });
expect(scene).not.toBeNull();
runFrame(1000);
expect(calls.drawArrays).toHaveBeenCalledTimes(4); // four passes, one frame
expect(rafQueue).toHaveLength(0);
});
it("'still' ignores login(): the pull-back is for a moving scene", () => {
const { gl } = fakeGl();
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' });
runFrame(1000);
scene?.login();
expect(rafQueue).toHaveLength(0);
});
it("'full' keeps scheduling frames", () => {
const { gl } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'full' });
runFrame(1000);
expect(rafQueue).toHaveLength(1);
});
it("'low' traces with a cheaper level than 'full'", () => {
const traceSteps = (tier: 'low' | 'full') => {
rafQueue.length = 0; // each call is a fresh scene
const { gl, calls } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)), tier });
runFrame(1000);
// u_maxSteps is the only uniform1i whose value is a trace-step count
return calls.uniform1i.mock.calls.map((c) => c[1]).filter((v) => v >= 40);
};
expect(traceSteps('full')).toContain(LEVELS[0].traceSteps);
expect(traceSteps('low')).toContain(LEVELS[2].traceSteps);
expect(traceSteps('low')).not.toContain(LEVELS[0].traceSteps);
});
it('returns null when WebGL2 is missing, so the host keeps its ground', () => {
expect(createScene({ canvas: asCanvas(fakeCanvas(null)) })).toBeNull();
});
});
describe('shader compile failure', () => {
it('reports through onCompileFail, returns null, and frees the programs already linked', () => {
const { gl, calls } = fakeGl(4); // terrain compiles both shaders; atmosphere's fragment is the 4th and fails
const onCompileFail = vi.fn();
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onCompileFail });
expect(scene).toBeNull();
expect(onCompileFail).toHaveBeenCalledTimes(1);
const err = onCompileFail.mock.calls[0][0] as ShaderCompileError;
expect(err).toBeInstanceOf(ShaderCompileError);
expect(err.stage).toBe('atmosphere');
expect(err.log).toContain('bad');
expect(calls.deleteProgram).toHaveBeenCalled(); // the terrain program that did link
expect(rafQueue).toHaveLength(0);
});
it('does not throw out of createScene when no callback is given', () => {
const { gl } = fakeGl(0);
expect(() => createScene({ canvas: asCanvas(fakeCanvas(gl)) })).not.toThrow();
});
});
describe('context loss', () => {
it('stops the loop, tells the host, and detaches every listener', () => {
const { gl } = fakeGl();
const canvas = fakeCanvas(gl);
const onContextLost = vi.fn();
createScene({ canvas: asCanvas(canvas), onContextLost });
const lost = new Event('webglcontextlost', { cancelable: true });
canvas.dispatchEvent(lost);
expect(lost.defaultPrevented).toBe(true);
expect(onContextLost).toHaveBeenCalledTimes(1);
expect(cancelled).toBe(1);
// listeners are gone: a second loss, a resize and a visibility change do nothing
canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
win.dispatchEvent(new Event('resize'));
doc.dispatchEvent(new Event('visibilitychange'));
expect(onContextLost).toHaveBeenCalledTimes(1);
expect(rafQueue.length).toBe(1); // only the frame queued before the loss, never run
});
it('does not render a queued frame after the loss', () => {
const { gl, calls } = fakeGl();
const canvas = fakeCanvas(gl);
createScene({ canvas: asCanvas(canvas) });
canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
runFrame(1000);
expect(calls.drawArrays).toBeUndefined();
});
});
describe('prefers-reduced-motion', () => {
const runAll = (t0: number) => {
let t = t0;
let n = 0;
while (rafQueue.length && n < 200) {
runFrame(t);
t += 40;
n += 1;
}
return n;
};
it('freezes to a bounded run when the preference turns on mid-flight', () => {
const { gl } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)) });
runFrame(1000);
expect(rafQueue).toHaveLength(1); // moving: keeps going
motion.matches = true;
motion.dispatchEvent(new Event('change'));
expect(runAll(1040)).toBeLessThan(200); // ends
expect(rafQueue).toHaveLength(0);
});
it('thaws a frozen scene when the preference turns off', () => {
motion.matches = true;
const { gl } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)) });
runAll(1000);
expect(rafQueue).toHaveLength(0);
motion.matches = false;
motion.dispatchEvent(new Event('change'));
expect(rafQueue).toHaveLength(1);
runFrame(5000);
expect(rafQueue).toHaveLength(1); // unbounded again
});
it('removes the listener on dispose and on context loss', () => {
const { gl } = fakeGl();
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)) });
scene?.dispose();
rafQueue.length = 0;
motion.dispatchEvent(new Event('change'));
expect(rafQueue).toHaveLength(0);
const lostCanvas = fakeCanvas(fakeGl().gl);
createScene({ canvas: asCanvas(lostCanvas) });
lostCanvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
rafQueue.length = 0;
motion.matches = true;
motion.dispatchEvent(new Event('change'));
expect(rafQueue).toHaveLength(0);
});
it('a still does not listen: one frame stays one frame', () => {
const { gl } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' });
runFrame(1000);
motion.matches = false;
motion.dispatchEvent(new Event('change'));
expect(rafQueue).toHaveLength(0);
});
});
describe('startBackgroundScene', () => {
it("does not request a context when the config leaves the tier 'off'", async () => {
const { gl } = fakeGl();
const canvas = fakeCanvas(gl);
expect(await startBackgroundScene(undefined, {}, { canvas: asCanvas(canvas) })).toBeNull();
expect(
await startBackgroundScene({ tier: 'off' }, {}, { canvas: asCanvas(canvas) })
).toBeNull();
expect(canvas.getContext).not.toHaveBeenCalled();
});
it('applies the device ceiling: saveData runs a still, one frame', async () => {
const { gl, calls } = fakeGl();
const scene = await startBackgroundScene(
{ tier: 'full' },
{ saveData: true },
{ canvas: asCanvas(fakeCanvas(gl)) }
);
expect(scene).not.toBeNull();
runFrame(1000);
expect(calls.drawArrays).toHaveBeenCalledTimes(4);
expect(rafQueue).toHaveLength(0);
});
it('reports a failed scene chunk to onCompileFail and returns null instead of throwing', async () => {
vi.resetModules();
vi.doMock('./Scene', () => {
throw new Error('Failed to fetch dynamically imported module');
});
try {
const { startBackgroundScene: start } = await import('./index');
const onCompileFail = vi.fn();
const canvas = fakeCanvas(fakeGl().gl);
const scene = await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas), onCompileFail });
expect(scene).toBeNull();
expect(onCompileFail).toHaveBeenCalledTimes(1);
expect(onCompileFail.mock.calls[0][0]).toBeInstanceOf(Error);
expect(canvas.getContext).not.toHaveBeenCalled();
// and with no callback it still does not throw
expect(await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas) })).toBeNull();
} finally {
vi.doUnmock('./Scene');
vi.resetModules();
}
});
it('hands compile failure to the host and returns null, the fall to the CSS ground', async () => {
const { gl } = fakeGl(0);
const onCompileFail = vi.fn();
const scene = await startBackgroundScene(
{ tier: 'full' },
{},
{ canvas: asCanvas(fakeCanvas(gl)), onCompileFail }
);
expect(scene).toBeNull();
expect(onCompileFail).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,476 @@
import { fragmentSource, vertexSource, Stage } from './shaderSource';
import { cameraPose, pullProgress, CameraPose } from './camera';
import { BudgetLadder, Level, LEVELS } from './budget';
import type { SceneTier } from './tier';
import { PALETTE } from './palette';
/** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */
export const GPU_BUDGET_MS = 4.0;
const FRAME_INTERVAL_MS = 1000 / 30;
const REDUCED_MOTION_FRAMES = 32;
const MAX_DPR = 1.5;
export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite';
const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite'];
export interface SceneStats {
/** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */
passMs: Record<PassName, number>;
level: number;
solidSize: [number, number];
atmosphereSize: [number, number];
}
/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */
export type SourcePatch = (stage: Stage, source: string) => string;
export class ShaderCompileError extends Error {
constructor(readonly stage: Stage | 'vertex', readonly log: string) {
super(`${stage} shader failed to compile:\n${log}`);
}
}
/** Ladder level a 'low' scene starts at: the cost of a weak device's first 45 frames is never paid at full. */
const LOW_START_LEVEL = 2;
/** The 'still' tier draws one frame at this level and stops. */
const STILL_LEVEL = LEVELS[0];
export interface SceneOptions {
canvas: HTMLCanvasElement;
/**
* 'off' returns null without touching the canvas. 'still' draws one frame and stops. 'low' starts
* the adaptive ladder low. 'full' starts it at the top. Default 'full'.
*/
tier?: SceneTier;
/** A shader failed to compile or link. createScene frees what it built and returns null. */
onCompileFail?: (e: ShaderCompileError) => void;
/** The WebGL context was lost. The scene stops for good; the host keeps its ground. */
onContextLost?: () => void;
/** Called per finished frame when timer queries exist. */
onStats?: (s: SceneStats) => void;
/** Weather knob, 0 clear .. 1 overcast. */
coverage?: number;
/** Fixed level instead of the adaptive ladder (measurement). */
fixedLevel?: Level;
/** Ignore the 30 fps cap and the visibility pause (measurement). */
unthrottled?: boolean;
patchSource?: SourcePatch;
}
export interface Scene {
/** Start the camera pull-back from the tree. */
login(): void;
dispose(): void;
}
function compile(
gl: WebGL2RenderingContext,
type: number,
source: string,
stage: Stage | 'vertex'
): WebGLShader {
const sh = gl.createShader(type)!;
gl.shaderSource(sh, source);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(sh) ?? '';
gl.deleteShader(sh);
throw new ShaderCompileError(stage, log);
}
return sh;
}
export function linkStage(
gl: WebGL2RenderingContext,
stage: Stage,
patch?: SourcePatch
): WebGLProgram {
const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex');
const fs = compile(
gl,
gl.FRAGMENT_SHADER,
patch ? patch(stage, fragmentSource(stage)) : fragmentSource(stage),
stage
);
const prog = gl.createProgram()!;
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(prog) ?? '';
gl.deleteProgram(prog);
throw new ShaderCompileError(stage, log);
}
return prog;
}
interface Target {
tex: WebGLTexture;
w: number;
h: number;
}
function halton(i: number, base: number): number {
let f = 1;
let r = 0;
let n = i;
while (n > 0) {
f /= base;
r += f * (n % base);
n = Math.floor(n / base);
}
return r;
}
/** Null when WebGL2 or float colour buffers are missing; the host keeps its CSS ground. */
export function createScene(opts: SceneOptions): Scene | null {
const { canvas } = opts;
const tier = opts.tier ?? 'full';
if (tier === 'off') return null;
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
depth: false,
stencil: false,
powerPreference: 'low-power',
});
if (!gl || !gl.getExtension('EXT_color_buffer_float')) return null;
gl.getExtension('OES_texture_float_linear');
const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2') as {
TIME_ELAPSED_EXT: number;
GPU_DISJOINT_EXT: number;
} | null;
const programs = {} as Record<PassName, WebGLProgram>;
try {
PASSES.forEach((p) => {
programs[p] = linkStage(gl, p, opts.patchSource);
});
} catch (e) {
PASSES.forEach((p) => {
if (programs[p]) gl.deleteProgram(programs[p]);
});
if (!(e instanceof ShaderCompileError)) throw e;
opts.onCompileFail?.(e);
return null;
}
const uniforms = new Map<WebGLProgram, Map<string, WebGLUniformLocation | null>>();
const loc = (p: WebGLProgram, name: string) => {
let m = uniforms.get(p);
if (!m) uniforms.set(p, (m = new Map()));
if (!m.has(name)) m.set(name, gl.getUniformLocation(p, name));
return m.get(name) ?? null;
};
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const makeTarget = (w: number, h: number, format: number, filter: number): Target => {
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return { tex, w, h };
};
const fbos = {
solid: gl.createFramebuffer()!,
atm: gl.createFramebuffer()!,
hist: [gl.createFramebuffer()!, gl.createFramebuffer()!],
};
let solid: Target;
let depth: Target;
let atm: Target;
let hist: [Target, Target];
const freeTargets = () => {
[solid, depth, atm, hist?.[0], hist?.[1]].forEach((t) => {
if (t) gl.deleteTexture(t.tex);
});
};
const attach = (fbo: WebGLFramebuffer, targets: Target[]) => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
targets.forEach((t, i) =>
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, t.tex, 0)
);
gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i));
};
const still = tier === 'still';
const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0);
const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current);
let canvasW = 0;
let canvasH = 0;
let sizedFor: Level | undefined;
let resetHistory = true;
const ensureSize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
const w = Math.max(2, Math.round(canvas.clientWidth * dpr));
const h = Math.max(2, Math.round(canvas.clientHeight * dpr));
const lv = level();
if (w === canvasW && h === canvasH && lv === sizedFor) return;
canvasW = w;
canvasH = h;
sizedFor = lv;
canvas.width = w;
canvas.height = h;
freeTargets();
const sw = Math.max(2, Math.round(w * lv.solidScale));
const sh = Math.max(2, Math.round(h * lv.solidScale));
const aw = Math.max(2, Math.round(w * lv.atmosphereScale));
const ah = Math.max(2, Math.round(h * lv.atmosphereScale));
solid = makeTarget(sw, sh, gl.RGBA16F, gl.LINEAR);
depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST);
atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR);
hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)];
attach(fbos.solid, [solid, depth]);
attach(fbos.atm, [atm]);
attach(fbos.hist[0], [hist[0]]);
attach(fbos.hist[1], [hist[1]]);
resetHistory = true;
};
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
const start = performance.now();
let loginAt: number | undefined;
let frame = 0;
let histIndex = 0;
let prev: CameraPose | undefined;
let raf = 0;
let lastRendered = -Infinity;
let framesLeft = still ? 1 : Infinity; // a still frame, or reduced motion, bounds this
let disposed = false;
// Timer queries in flight: each holds one pass's query.
const pending: Array<{ q: WebGLQuery; pass: PassName; frame: number }> = [];
const finished = new Map<number, Partial<Record<PassName, number>>>();
const timed = (pass: PassName, f: number, body: () => void) => {
if (!timer || !opts.onStats) {
body();
return;
}
const q = gl.createQuery()!;
gl.beginQuery(timer.TIME_ELAPSED_EXT, q);
body();
gl.endQuery(timer.TIME_ELAPSED_EXT);
pending.push({ q, pass, frame: f });
};
const collect = () => {
if (!timer) return;
while (pending.length && gl.getQueryParameter(pending[0].q, gl.QUERY_RESULT_AVAILABLE)) {
const { q, pass, frame: f } = pending.shift()!;
const disjoint = gl.getParameter(timer.GPU_DISJOINT_EXT);
const ns = gl.getQueryParameter(q, gl.QUERY_RESULT) as number;
gl.deleteQuery(q);
const row = finished.get(f) ?? {};
if (!disjoint) row[pass] = ns / 1e6;
finished.set(f, row);
if (Object.keys(row).length === PASSES.length) {
finished.delete(f);
const passMs = row as Record<PassName, number>;
const total = PASSES.reduce((s, p) => s + passMs[p], 0);
if (!opts.fixedLevel && ladder.push(total)) sizedFor = undefined;
opts.onStats?.({
passMs,
level: ladder.index,
solidSize: [solid.w, solid.h],
atmosphereSize: [atm.w, atm.h],
});
}
}
};
const setCommon = (p: WebGLProgram, pose: CameraPose, time: number) => {
const aspect = canvasW / canvasH;
const th = Math.tan(pose.fov / 2);
gl.uniform3fv(loc(p, 'u_camPos'), pose.position as unknown as number[]);
gl.uniformMatrix3fv(loc(p, 'u_camBasis'), false, pose.basis as unknown as number[]);
gl.uniform2f(loc(p, 'u_tanHalf'), th * aspect, th);
gl.uniform1f(loc(p, 'u_time'), time);
gl.uniform3fv(loc(p, 'u_sunDir'), PALETTE.sunDir as unknown as number[]);
gl.uniform3fv(loc(p, 'u_sunColor'), PALETTE.sunColor as unknown as number[]);
gl.uniform3fv(loc(p, 'u_skyTop'), PALETTE.skyTop as unknown as number[]);
gl.uniform3fv(loc(p, 'u_skyHorizon'), PALETTE.skyHorizon as unknown as number[]);
gl.uniform3fv(loc(p, 'u_fogColor'), PALETTE.fogColor as unknown as number[]);
};
const bindTex = (unit: number, t: Target, p: WebGLProgram, name: string) => {
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, t.tex);
gl.uniform1i(loc(p, name), unit);
};
const draw = () => gl.drawArrays(gl.TRIANGLES, 0, 3);
const render = (now: number) => {
ensureSize();
const lv = level();
const frozen = reducedMotion?.matches ?? false;
const time = frozen ? 0 : (now - start) / 1000;
let elapsed: number | undefined;
if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000;
const pose = cameraPose(pullProgress(elapsed), time);
const f = frame;
frame += 1;
const jx = halton((f % 16) + 1, 2) - 0.5;
const jy = halton((f % 16) + 1, 3) - 0.5;
// Pass 1: solid surfaces, writes colour and depth.
timed('terrain', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid);
gl.viewport(0, 0, solid.w, solid.h);
const p = programs.terrain;
gl.useProgram(p);
setCommon(p, pose, time);
gl.uniform3fv(loc(p, 'u_grass'), PALETTE.grass as unknown as number[]);
gl.uniform3fv(loc(p, 'u_rock'), PALETTE.rock as unknown as number[]);
gl.uniform3fv(loc(p, 'u_bark'), PALETTE.bark as unknown as number[]);
gl.uniform3fv(loc(p, 'u_leaf'), PALETTE.leaf as unknown as number[]);
gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps);
draw();
});
// Pass 2: atmosphere up to pass 1's depth, low-res.
timed('atmosphere', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.atm);
gl.viewport(0, 0, atm.w, atm.h);
const p = programs.atmosphere;
gl.useProgram(p);
setCommon(p, pose, time);
bindTex(0, depth, p, 'u_depth');
gl.uniform1i(loc(p, 'u_steps'), lv.cloudSteps);
gl.uniform1f(loc(p, 'u_frame'), f);
gl.uniform2f(loc(p, 'u_jitter'), jx, jy);
gl.uniform2f(loc(p, 'u_size'), atm.w, atm.h);
gl.uniform1f(loc(p, 'u_coverage'), opts.coverage ?? 0.45);
draw();
});
// TAA resolve into the history that is not being read.
const write = histIndex === 0 ? 1 : 0;
timed('resolve', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]);
gl.viewport(0, 0, atm.w, atm.h);
const p = programs.resolve;
gl.useProgram(p);
setCommon(p, pose, time);
bindTex(0, atm, p, 'u_cur');
bindTex(1, hist[histIndex], p, 'u_history');
bindTex(2, depth, p, 'u_depth');
const pp = prev ?? pose;
const th = Math.tan(pp.fov / 2);
gl.uniform3fv(loc(p, 'u_prevPos'), pp.position as unknown as number[]);
gl.uniformMatrix3fv(loc(p, 'u_prevBasis'), false, pp.basis as unknown as number[]);
gl.uniform2f(loc(p, 'u_prevTanHalf'), th * (canvasW / canvasH), th);
gl.uniform1f(loc(p, 'u_blend'), resetHistory ? 1 : 0.125);
gl.uniform2f(loc(p, 'u_curSize'), atm.w, atm.h);
draw();
});
// Full-resolution composite to the canvas.
timed('composite', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, canvasW, canvasH);
const p = programs.composite;
gl.useProgram(p);
setCommon(p, pose, time);
bindTex(0, solid, p, 'u_solid');
bindTex(1, hist[write], p, 'u_atm');
gl.uniform1f(loc(p, 'u_frame'), f);
draw();
});
histIndex = write;
prev = pose;
resetHistory = false;
collect();
};
const tick = (now: number) => {
raf = 0;
if (disposed) return;
const due = opts.unthrottled || now - lastRendered >= FRAME_INTERVAL_MS - 2;
if (due) {
if (
!opts.fixedLevel &&
!timer &&
lastRendered > 0 &&
now - lastRendered > FRAME_INTERVAL_MS * 1.5
) {
// no timer: a missed cap counts as over budget
if (ladder.push(GPU_BUDGET_MS * 2)) sizedFor = undefined;
}
lastRendered = now;
render(now);
framesLeft -= 1;
}
if (framesLeft > 0 && (opts.unthrottled || document.visibilityState === 'visible'))
raf = requestAnimationFrame(tick);
};
const wake = () => {
if (disposed || raf) return;
if (reducedMotion?.matches && framesLeft <= 0) return;
resetHistory = true; // the first frame after a pause has a real elapsed dt
raf = requestAnimationFrame(tick);
};
const undo: Array<() => void> = [];
const listen = (target: EventTarget, type: string, handler: (e: Event) => void) => {
target.addEventListener(type, handler);
undo.push(() => target.removeEventListener(type, handler));
};
const stop = () => {
disposed = true;
if (raf) cancelAnimationFrame(raf);
undo.forEach((f) => f());
};
if (!opts.unthrottled) {
listen(document, 'visibilitychange', () => {
if (document.visibilityState === 'visible') wake();
});
}
listen(window, 'resize', () => wake());
// A lost context takes every texture and program with it and nothing here rebuilds them, so the
// scene ends and the host decides what to show. preventDefault: otherwise the browser never
// restores the context, which is what we want, and a restore would find a dead scene.
listen(canvas, 'webglcontextlost', (e) => {
e.preventDefault();
stop();
opts.onContextLost?.();
});
if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES;
// The preference can flip while the scene lives: freeze to a bounded run, or thaw to a free one.
if (reducedMotion && !still) {
listen(reducedMotion, 'change', () => {
framesLeft = reducedMotion.matches ? REDUCED_MOTION_FRAMES : Infinity;
wake();
});
}
wake();
return {
login() {
if (still || disposed) return; // a still has no pull-back
loginAt = performance.now();
if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES;
wake();
},
dispose() {
if (disposed) return;
stop();
freeTargets();
PASSES.forEach((p) => gl.deleteProgram(programs[p]));
gl.deleteVertexArray(vao);
[fbos.solid, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb));
},
};
}

View file

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { BudgetLadder, LEVELS } from './budget';
describe('budget ladder', () => {
it('steps down after a whole window over budget and never back up', () => {
const ladder = new BudgetLadder(4, 5);
Array.from({ length: 4 }).forEach(() => expect(ladder.push(9)).toBe(false));
expect(ladder.push(9)).toBe(true);
expect(ladder.index).toBe(1);
Array.from({ length: 100 }).forEach(() => ladder.push(0.1)); // fast frames never raise the level
expect(ladder.index).toBe(1);
});
it('judges the median, so one slow frame does not step down', () => {
const ladder = new BudgetLadder(4, 5);
[1, 1, 50, 1, 1].forEach((ms) => ladder.push(ms));
expect(ladder.index).toBe(0);
});
it('stops at the last level', () => {
const ladder = new BudgetLadder(4, 1);
Array.from({ length: 20 }).forEach(() => ladder.push(99));
expect(ladder.index).toBe(LEVELS.length - 1);
});
it('every level is cheaper than the one before in each cost axis', () => {
LEVELS.slice(1).forEach((lv, i) => {
const before = LEVELS[i];
expect(lv.solidScale).toBeLessThan(before.solidScale);
expect(lv.atmosphereScale).toBeLessThan(before.atmosphereScale);
expect(lv.cloudSteps).toBeLessThan(before.cloudSteps);
expect(lv.traceSteps).toBeLessThan(before.traceSteps);
});
});
it('starts at the level it is given, clamped into the ladder', () => {
expect(new BudgetLadder(4, 5, 2).index).toBe(2);
expect(new BudgetLadder(4, 5, 99).index).toBe(LEVELS.length - 1);
expect(new BudgetLadder(4, 5, -3).index).toBe(0);
});
});

View file

@ -0,0 +1,59 @@
// The adaptive step-down. The ladder only descends within a session; nothing here ever steps up.
export interface Level {
/** Pass 1 resolution as a fraction of the canvas. */
solidScale: number;
/** Pass 2 resolution as a fraction of the canvas. */
atmosphereScale: number;
/** Cloud march steps. */
cloudSteps: number;
/** Sphere-trace step cap. */
traceSteps: number;
}
export const LEVELS: readonly Level[] = [
{ solidScale: 0.75, atmosphereScale: 0.5, cloudSteps: 12, traceSteps: 96 },
{ solidScale: 0.6, atmosphereScale: 0.4, cloudSteps: 8, traceSteps: 80 },
{ solidScale: 0.5, atmosphereScale: 0.33, cloudSteps: 6, traceSteps: 64 },
{ solidScale: 0.4, atmosphereScale: 0.25, cloudSteps: 4, traceSteps: 48 },
];
/** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */
export class BudgetLadder {
private level: number;
private recent: number[] = [];
private readonly budgetMs: number;
private readonly window: number;
/** `startLevel` lets a weak device begin below level 0; it is clamped into the ladder. */
constructor(budgetMs: number, window = 45, startLevel = 0) {
this.budgetMs = budgetMs;
this.window = window;
this.level = Math.min(Math.max(startLevel, 0), LEVELS.length - 1);
}
get current(): Level {
return LEVELS[this.level];
}
get index(): number {
return this.level;
}
/** Feed one frame's GPU (or cadence) time. Returns true when the level changed. */
push(ms: number): boolean {
this.recent.push(ms);
if (this.recent.length < this.window) return false;
const sorted = [...this.recent].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
this.recent = [];
if (median > this.budgetMs && this.level < LEVELS.length - 1) {
this.level += 1;
return true;
}
return false;
}
}

View file

@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { cameraPose, pullProgress, PULL_BACK_SECONDS } from './camera';
import { WORLD } from './world';
const dist = (p: readonly [number, number, number]) => Math.hypot(p[0], p[2]);
describe('login pull-back', () => {
it('is at rest before login and complete after PULL_BACK_SECONDS', () => {
expect(pullProgress(undefined)).toBe(0);
expect(pullProgress(0)).toBe(0);
expect(pullProgress(PULL_BACK_SECONDS)).toBe(1);
expect(pullProgress(PULL_BACK_SECONDS * 10)).toBe(1);
});
it('only moves away from the tree and never reverses', () => {
let last = -1;
for (let s = 0; s <= PULL_BACK_SECONDS; s += 0.1) {
const d = dist(cameraPose(pullProgress(s), 0).position);
expect(d).toBeGreaterThanOrEqual(last);
last = d;
}
});
it('ends over the flat ground so it cannot be inside the terrain, whatever the orbit angle', () => {
for (let t = 0; t < 2000; t += 37) {
expect(dist(cameraPose(1, t).position)).toBeLessThan(WORLD.plateauInner);
}
});
it('looks at the tree: forward points at the axis and the basis is orthonormal', () => {
const pose = cameraPose(0.5, 12);
const [rx, ry, rz, ux, uy, uz, fx, fy, fz] = pose.basis;
const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
expect(dot([rx, ry, rz], [ux, uy, uz])).toBeCloseTo(0, 6);
expect(dot([rx, ry, rz], [fx, fy, fz])).toBeCloseTo(0, 6);
expect(dot([ux, uy, uz], [fx, fy, fz])).toBeCloseTo(0, 6);
expect(Math.hypot(fx, fy, fz)).toBeCloseTo(1, 6);
// Forward points back across the tree axis, not away from it.
const [px, , pz] = pose.position;
expect(fx * px + fz * pz).toBeLessThan(0);
});
});

View file

@ -0,0 +1,56 @@
// The scene's camera: close under the tree at rest, pulled back on login, drifting after.
// Pure functions of time so the pull-back is testable without a GL context.
export type Vec3 = readonly [number, number, number];
export interface CameraPose {
position: Vec3;
/** Column-major right, up, forward: the layout of the shader's mat3 uniform. */
basis: readonly [number, number, number, number, number, number, number, number, number];
/** Vertical field of view in radians. */
fov: number;
}
export const PULL_BACK_SECONDS = 6;
// Keep the end distance inside WORLD.plateauInner so the camera is always over flat ground.
const NEAR = { radius: 20, camY: 5, targetY: 34, fov: 1.15 };
const FAR = { radius: 110, camY: 50, targetY: 36, fov: 0.82 };
const DRIFT_RAD_PER_SEC = 0.025;
const smoother = (x: number) => x * x * x * (x * (x * 6 - 15) + 10);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
/** 0 before login, easing to 1 over PULL_BACK_SECONDS. `elapsed` is seconds since login. */
export function pullProgress(elapsed: number | undefined): number {
if (elapsed === undefined || elapsed <= 0) return 0;
return smoother(Math.min(1, elapsed / PULL_BACK_SECONDS));
}
function norm(v: Vec3): Vec3 {
const l = Math.hypot(v[0], v[1], v[2]) || 1;
return [v[0] / l, v[1] / l, v[2] / l];
}
function cross(a: Vec3, b: Vec3): Vec3 {
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
}
/**
* @param pull 0..1 from pullProgress
* @param time seconds since the scene started, for the slow orbit
*/
export function cameraPose(pull: number, time: number): CameraPose {
const radius = lerp(NEAR.radius, FAR.radius, pull);
const camY = lerp(NEAR.camY, FAR.camY, pull);
const targetY = lerp(NEAR.targetY, FAR.targetY, pull);
const azimuth = 0.9 + time * DRIFT_RAD_PER_SEC;
const position: Vec3 = [radius * Math.cos(azimuth), camY, radius * Math.sin(azimuth)];
const forward = norm([-position[0], targetY - position[1], -position[2]]);
const right = norm(cross([0, 1, 0], forward));
const up = cross(forward, right);
return {
position,
basis: [...right, ...up, ...forward] as unknown as CameraPose['basis'],
fov: lerp(NEAR.fov, FAR.fov, pull),
};
}

View file

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { BUCKET_SCALE, creatureScale, NEUTRAL_SCALE, SizeBucket } from './creatures';
const buckets: SizeBucket[] = ['small', 'medium', 'large'];
describe('creature scale', () => {
it('follows sizeBucket for a creature with an estate', () => {
buckets.forEach((sizeBucket) =>
expect(creatureScale({ sizeBucket, neutral: false })).toBe(BUCKET_SCALE[sizeBucket])
);
});
it('is one fixed size for every neutral creature, whatever its sizeBucket', () => {
buckets.forEach((sizeBucket) =>
expect(creatureScale({ sizeBucket, neutral: true })).toBe(NEUTRAL_SCALE)
);
});
it('is only visible as an override where the bucket would have said something else', () => {
expect(BUCKET_SCALE.small).not.toBe(NEUTRAL_SCALE);
expect(BUCKET_SCALE.large).not.toBe(NEUTRAL_SCALE);
});
});

View file

@ -0,0 +1,37 @@
// Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut.
//
// Props contract: heimdall-body:follow_up:ratatoskr-creature-projection-v2 (against GameCult/Bifrost; supersedes heimdall-body/ratatoskr-creature-projection).
// Nothing here is binding until Bifrost's steward cuts that contract.
//
// Shape: {id, estate, sizeBucket, neutral, activity}.
// Invariants:
// - Exact vote weights stay in Bifrost; Ratatoskr only ever sees a bucket.
// - neutral=true overrides any bucket, and no size is drawn.
// - activity comes from the Heimdall session.
// Consent: humans get a coarse bucket by default plus a typed opt-out; Minds and Personas may show finer buckets.
// Humans have three coarse buckets. The scales below (0.7 / 1 / 1.4) are placeholders, not fixed.
export type SizeBucket = 'small' | 'medium' | 'large';
export interface Creature {
id: string;
estate: string;
sizeBucket: SizeBucket;
/** A neutral creature (no estate allegiance) is drawn at one fixed size whatever its bucket. */
neutral: boolean;
activity: 'idle' | 'active';
}
export const BUCKET_SCALE: Readonly<Record<SizeBucket, number>> = {
small: 0.7,
medium: 1,
large: 1.4,
};
/** Neutral creatures are medium sized, not necessarily their bucket's size. */
export const NEUTRAL_SCALE = BUCKET_SCALE.medium;
/** Neutral overrides sizeBucket. */
export function creatureScale(c: Pick<Creature, 'sizeBucket' | 'neutral'>): number {
return c.neutral ? NEUTRAL_SCALE : BUCKET_SCALE[c.sizeBucket];
}

View file

@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { trimGlsl } from './cultmathTrim';
import { fragmentSource, Stage } from './shaderSource';
import cultmath from './shaders/CultMath.glsl?raw';
const LIB = `// header
#ifndef LIB_GLSL
#define LIB_GLSL
const float LIB_PI = 3.14;
float lib_half(float v) { return v * 0.5; }
vec2 lib_half(vec2 v) { return v * 0.5; }
// uses the constant and an overload set
float lib_pi_half(float v) { return lib_half(v) * LIB_PI; }
struct LibPair { float a; float b; };
LibPair lib_pair(float v) { LibPair p; p.a = v; p.b = lib_half(v); return p; }
float lib_unused(float v) { return v; }
#endif
`;
describe('trimGlsl', () => {
it('keeps what a root names and what that reaches, and drops the rest', () => {
const out = trimGlsl(LIB, ['float x = lib_pi_half(1.0);']);
expect(out).toContain('lib_pi_half');
expect(out).toContain('LIB_PI');
expect(out).toContain('float lib_half');
expect(out).toContain('vec2 lib_half'); // overloads stay together
expect(out).not.toContain('lib_unused');
expect(out).not.toContain('LibPair');
expect(out).not.toContain('lib_pair');
});
it('keeps a struct that a kept function returns', () => {
const out = trimGlsl(LIB, ['LibPair p = lib_pair(1.0);']);
expect(out).toContain('struct LibPair');
expect(out).toContain('lib_half');
expect(out).not.toContain('lib_pi_half');
});
it('keeps the include guard balanced', () => {
const out = trimGlsl(LIB, ['lib_half(1.0)']);
expect(out).toMatch(/^#ifndef LIB_GLSL\n#define LIB_GLSL\n/);
expect(out.trimEnd().endsWith('#endif')).toBe(true);
});
it('keeps nothing but the guard for a root that names nothing', () => {
const out = trimGlsl(LIB, ['void main() {}']);
expect(out).not.toContain('lib_');
expect(out).toContain('#ifndef LIB_GLSL');
});
});
describe('CultMath per shader stage', () => {
const stages: Stage[] = ['terrain', 'atmosphere', 'resolve', 'composite'];
const cultmathNames = (text: string) => new Set(text.match(/\bcultmath_\w+/g) ?? []);
const defined = (src: string, name: string) =>
new RegExp(`^\\s*(?:float|vec[234]|mat3|int|\\w+)\\s+${name}\\s*\\(`, 'm').test(src);
it.each(stages)('%s defines every CultMath function it, or Phacelle, calls', (stage) => {
const src = fragmentSource(stage);
cultmathNames(src).forEach((n) => expect(defined(src, n), n).toBe(true));
});
it.each(stages)('%s is smaller than the whole library', (stage) => {
expect(fragmentSource(stage).length).toBeLessThan(cultmath.length / 2);
});
it('puts the noise a stage calls in that stage and not in the others', () => {
expect(fragmentSource('terrain')).toContain('cultmath_snoise');
expect(fragmentSource('composite')).not.toContain('cultmath_snoise');
});
it('adds Phacelle to terrain alone, as its own file', () => {
expect(fragmentSource('terrain')).toContain('cultmath_phacelle');
stages
.filter((s) => s !== 'terrain')
.forEach((s) => expect(fragmentSource(s)).not.toContain('cultmath_phacelle'));
});
});

View file

@ -0,0 +1,81 @@
/**
* Keeps the part of CultMath's GLSL that a stage can reach. The vendored file is whole and never
* edited; each stage compiles only the declarations its own source (and the source concatenated
* beside it) names, directly or through other kept declarations. Overloads share a name and are
* kept together.
*/
interface Decl {
name: string;
text: string;
}
const GUARD_OPEN = /^#ifndef\s+\w+\s*\n#define\s+\w+\s*\n/m;
const GUARD_CLOSE = /\n#endif\s*$/;
/** Name a top-level declaration defines: function, struct or const. */
function declName(text: string): string | undefined {
const code = text.replace(/\/\/.*$/gm, '');
if (!code.includes('{')) return /\b(\w+)\s*=/.exec(code)?.[1];
return /\bstruct\s+(\w+)/.exec(code)?.[1] ?? /\b(\w+)\s*\(/.exec(code)?.[1];
}
/** Splits at brace depth 0: a declaration ends at the `;` or `}` that returns to depth 0. */
function declarations(body: string): Decl[] {
const out: Decl[] = [];
let depth = 0;
let start = 0;
let i = 0;
while (i < body.length) {
const c = body[i];
if (c === '/' && body[i + 1] === '/') {
const eol = body.indexOf('\n', i);
i = eol < 0 ? body.length : eol;
} else if (c === '{') {
depth += 1;
} else if ((c === '}' || c === ';') && depth - (c === '}' ? 1 : 0) === 0) {
if (c === '}') depth -= 1;
// a struct's closing brace is followed by `;`
const end = c === '}' && /^\s*;/.test(body.slice(i + 1)) ? body.indexOf(';', i) : i;
const text = body.slice(start, end + 1);
const name = declName(text);
if (name) out.push({ name, text });
start = end + 1;
i = end;
} else if (c === '}') {
depth -= 1;
}
i += 1;
}
return out;
}
const idents = (text: string): string[] => text.match(/\b[A-Za-z_]\w*\b/g) ?? [];
/** `library` trimmed to what `roots` reach; the include guard is kept. */
export function trimGlsl(library: string, roots: string[]): string {
const open = GUARD_OPEN.exec(library)?.[0] ?? '';
const close = GUARD_CLOSE.test(library) ? '\n#endif\n' : '';
const body = library
.slice(open ? library.indexOf(open) + open.length : 0)
.replace(GUARD_CLOSE, '');
const decls = declarations(body);
const byName = new Map<string, Decl[]>();
decls.forEach((d) => byName.set(d.name, [...(byName.get(d.name) ?? []), d]));
const kept = new Set<Decl>();
const queue = roots.flatMap(idents);
while (queue.length) {
const group = byName.get(queue.pop() ?? '');
group?.forEach((d) => {
if (kept.has(d)) return;
kept.add(d);
queue.push(...idents(d.text));
});
}
const text = decls
.filter((d) => kept.has(d))
.map((d) => d.text.replace(/^\s*\n/, ''))
.join('\n');
return `${open}${text}${close}`;
}

View file

@ -0,0 +1,33 @@
import type { Scene, SceneOptions } from './Scene';
import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier';
export type { Scene, SceneStats } from './Scene';
export type { SceneTier } from './tier';
export type StartOptions = Omit<SceneOptions, 'tier' | 'patchSource' | 'onCompileFail'> & {
/** A shader failed to compile, or the scene chunk failed to load. The host keeps its ground. */
onCompileFail?: (e: Error) => void;
};
/**
* The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no
* canvas context is requested; otherwise the scene code loads on demand, so it stays out of the
* main chunk. Null means "keep the CSS ground", including when that chunk fails to load (offline,
* stale deploy): the failure goes to onCompileFail and nothing throws.
*/
export async function startBackgroundScene(
config: BackgroundSceneConfig | undefined,
capabilities: Capabilities,
options: StartOptions
): Promise<Scene | null> {
const tier = resolveTier(config, capabilities);
if (tier === 'off') return null;
let createScene: typeof import('./Scene').createScene;
try {
({ createScene } = await import('./Scene'));
} catch (e) {
options.onCompileFail?.(e instanceof Error ? e : new Error(String(e)));
return null;
}
return createScene({ ...options, tier });
}

View file

@ -0,0 +1,27 @@
// The scene's colours, as linear RGB uniforms. The sky tints are GameCult's three nebula inputs
// (orange 255 138 42, violet 109 96 255, sky 89 183 255) over the brand base #07111a, so the
// background reads as the same dusk as gamecult.org. The shaders hold no colour literal.
export type Rgb = readonly [number, number, number];
const srgb = (r: number, g: number, b: number): Rgb => [
(r / 255) ** 2.2,
(g / 255) ** 2.2,
(b / 255) ** 2.2,
];
export const PALETTE = {
sunColor: srgb(255, 150, 70).map((c) => c * 3.2) as unknown as Rgb,
skyTop: srgb(30, 36, 92),
skyHorizon: srgb(255, 138, 42).map((c) => c * 0.55) as unknown as Rgb,
fogColor: srgb(109, 96, 255).map((c) => c * 0.12) as unknown as Rgb,
grass: srgb(72, 112, 86),
rock: srgb(112, 104, 122),
bark: srgb(62, 46, 40),
leaf: srgb(89, 183, 255).map((c) => c * 0.7) as unknown as Rgb,
sunDir: (() => {
const v = [0.55, 0.16, -0.82];
const l = Math.hypot(v[0], v[1], v[2]);
return [v[0] / l, v[1] / l, v[2] / l] as Rgb;
})(),
} as const;

View file

@ -0,0 +1,34 @@
import cultmath from './shaders/CultMath.glsl?raw';
import phacelle from './shaders/CultMath.Phacelle.glsl?raw';
import common from './shaders/common.glsl?raw';
import fullscreenVert from './shaders/fullscreen.vert.glsl?raw';
import terrainFrag from './shaders/terrain.frag.glsl?raw';
import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw';
import resolveFrag from './shaders/resolve.frag.glsl?raw';
import compositeFrag from './shaders/composite.frag.glsl?raw';
import { WORLD, worldDefines } from './world';
import { trimGlsl } from './cultmathTrim';
export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite';
const HEADER =
'#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n';
export const vertexSource = HEADER + fullscreenVert;
const stages: Record<Stage, string> = {
terrain: terrainFrag,
atmosphere: atmosphereFrag,
resolve: resolveFrag,
composite: compositeFrag,
};
/**
* Fragment source for a stage: header, world defines, the part of CultMath the stage reaches, the
* shared block, the stage. Phacelle stays a separate file, added only where it is called.
*/
export function fragmentSource(stage: Stage): string {
const erosion = stage === 'terrain' && WORLD.phacelleGradBound > 0 ? phacelle : '';
const math = trimGlsl(cultmath, [erosion, common, stages[stage]]);
return `${HEADER}${worldDefines()}\n${math}\n${erosion}\n${common}\n${stages[stage]}`;
}

View file

@ -0,0 +1,99 @@
// Generated from shaders/CultMath.Phacelle.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MPL-2.0; requires CultMath.glsl before it.
#ifndef CULTMATH_PHACELLE_GLSL
#define CULTMATH_PHACELLE_GLSL
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
//
// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the
// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke
// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact
// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after
// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md).
// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit
// against CultMath.CultPhasor, same field order.
struct CultPhasor
{
vec4 cos;
vec4 sin;
};
// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave
// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors
// the C# body operation for operation; the C#-only internal overload that switches the prune off
// has no shader twin.
CultPhasor cultmath_phacelle(vec3 p, vec3 side, float offset, float normalization)
{
vec3 cell = floor(p);
vec3 local = p - cell;
float phaseOffset = offset * CULTMATH_TAU;
float sumW = 0.0;
float sumCos = 0.0;
float sumSin = 0.0;
vec3 gradW = vec3(0.0, 0.0, 0.0);
vec3 gradCos = vec3(0.0, 0.0, 0.0);
vec3 gradSin = vec3(0.0, 0.0, 0.0);
for (int dz = -1; dz <= 2; dz++)
{
float gz = max(0.0, abs(local.z - float(dz)) - 0.5);
for (int dy = -1; dy <= 2; dy++)
{
float gy = max(0.0, abs(local.y - float(dy)) - 0.5);
for (int dx = -1; dx <= 2; dx++)
{
float gx = max(0.0, abs(local.x - float(dx)) - 0.5);
if (gx * gx + gy * gy + gz * gz >= 2.25)
continue;
vec3 gridStep = vec3(dx, dy, dz);
ivec3 hash = cultmath_pcg3d(ivec3(cell + gridStep));
vec3 jitter = vec3(
float(uint(hash.x) >> 8) * (1.0 / 16777216.0) - 0.5,
float(uint(hash.y) >> 8) * (1.0 / 16777216.0) - 0.5,
float(uint(hash.z) >> 8) * (1.0 / 16777216.0) - 0.5);
vec3 v = local - gridStep - jitter;
float falloff = exp(-2.0 * dot(v, v));
float w = max(0.0, falloff - 0.01111);
vec3 dw = w > 0.0 ? (-4.0 * falloff) * v : vec3(0.0, 0.0, 0.0);
float phase = dot(v, side) + phaseOffset;
float c = cos(phase);
float s = sin(phase);
sumW += w;
sumCos += w * c;
sumSin += w * s;
gradW += dw;
gradCos += dw * c - (w * s) * side;
gradSin += dw * s + (w * c) * side;
}
}
}
float invW = 1.0 / sumW;
float rawCos = sumCos * invW;
float rawSin = sumSin * invW;
vec3 gradRawCos = (gradCos - rawCos * gradW) * invW;
vec3 gradRawSin = (gradSin - rawSin * gradW) * invW;
float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin);
float floorLength = 1.0 - normalization;
CultPhasor result;
if (rawLength > floorLength)
{
float outCos = rawCos / rawLength;
float outSin = rawSin / rawLength;
vec3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
result.cos = vec4((gradRawCos - outCos * gradLength) / rawLength, outCos);
result.sin = vec4((gradRawSin - outSin * gradLength) / rawLength, outSin);
}
else
{
result.cos = vec4(gradRawCos / floorLength, rawCos / floorLength);
result.sin = vec4(gradRawSin / floorLength, rawSin / floorLength);
}
return result;
}
#endif

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
# Vendored CultMath GLSL
`CultMath.glsl` and `CultMath.Phacelle.glsl` are CultLib's generated WebGL2
lowering of `packages/cultmath/shaders/CultMath.hlsl` and
`CultMath.Phacelle.hlsl`, copied unedited from CultLib branch
`eureka/cultmath-tapes-interval-ops-r5` at `658c09e` (the branch that carries
the interval ops and `CULTMATH_SNOISE2_LIPSCHITZ`; the repo default branch
does not carry the GLSL yet). Never edit them here: regenerate in CultLib and
re-copy. `CultMath.glsl` is MIT; `CultMath.Phacelle.glsl` is MPL-2.0 (Rune
Skovbo Johansen's Phacelle noise, see its header), so that file keeps its
header and any change to it is published under MPL-2.0.
The other `*.glsl` files here are the scene's own stages. They are
concatenated by `../shaderSource.ts` after a prelude; they use no colour
literal (the palette is uniforms) and define no noise.
`../shaderSource.ts` compiles each stage with only the part of `CultMath.glsl` that stage reaches
(`../cultmathTrim.ts`); the vendored file itself stays whole. Phacelle is added to the terrain stage
alone.

View file

@ -0,0 +1,90 @@
// Pass 2: atmosphere, marched only up to pass 1's depth. Height fog is closed-form; the cloud
// slab is marched with a few jittered steps. Output: rgb in-scattered light, a transmittance.
uniform sampler2D u_depth; // pass 1 hit distance, nearest
uniform int u_steps; // cloud march steps
uniform float u_frame;
uniform vec2 u_jitter; // sub-pixel jitter in pixels of this target
uniform vec2 u_size; // this target's size in pixels
uniform float u_coverage; // weather knob: 0 clear .. 1 overcast
in vec2 v_uv;
layout(location = 0) out vec4 o_atm;
float hg(float cosTheta, float g)
{
float g2 = g * g;
return (1.0 - g2) / (12.566370614 * pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5));
}
float cloudDensity(vec3 p)
{
float wind = u_time * 2.5;
float f = cultmath_fbm_grad(vec3(p.x * 0.0042 + wind * 0.0042, p.y * 0.0075, p.z * 0.0042), 3, 2.0, 0.5).w;
float h = (p.y - W_CLOUD_Y0) / (W_CLOUD_Y1 - W_CLOUD_Y0);
float profile = smoothstep(0.0, 0.2, h) * smoothstep(1.0, 0.55, h);
return clamp((f + 0.55 * u_coverage - 0.25) * 2.4, 0.0, 1.0) * profile;
}
void main()
{
vec2 jittered = (gl_FragCoord.xy + u_jitter) / u_size;
vec3 rd = camRay(jittered);
vec3 ro = u_camPos;
float depth = texture(u_depth, v_uv).r;
float tEnd = min(depth, W_ATM_FAR);
// Exponential height fog, closed form: optical depth of density d0 * exp(-k y) along the ray.
float k = W_FOG_FALLOFF;
float dy = rd.y * tEnd;
float tau = W_FOG_DENSITY * exp(-k * ro.y) * (abs(dy) > 1e-3 ? (1.0 - exp(-k * dy)) / (k * rd.y) : tEnd);
float fogT = exp(-max(tau, 0.0));
float cosSun = dot(rd, u_sunDir);
vec3 fogLight = u_fogColor + u_sunColor * hg(cosSun, 0.55) * 0.9;
vec3 inscatter = fogLight * (1.0 - fogT);
float trans = fogT;
// Clouds: only the part of the ray inside the slab and in front of the surface.
float ta = 0.0;
float tb = tEnd;
if (abs(rd.y) > 1e-4)
{
float t0 = (W_CLOUD_Y0 - ro.y) / rd.y;
float t1 = (W_CLOUD_Y1 - ro.y) / rd.y;
ta = max(0.0, min(t0, t1));
tb = min(tEnd, max(t0, t1));
}
else if (ro.y < W_CLOUD_Y0 || ro.y > W_CLOUD_Y1)
tb = 0.0;
if (tb > ta && u_steps > 0)
{
float dt = (tb - ta) / float(u_steps);
float t = ta + dt * ign(gl_FragCoord.xy, u_frame);
float T = 1.0;
vec3 L = vec3(0.0);
float phase = hg(cosSun, 0.6) + 0.35 * hg(cosSun, -0.2);
for (int i = 0; i < u_steps; i++)
{
vec3 p = ro + rd * t;
float d = cloudDensity(p);
if (d > 0.01)
{
// One sample toward the sun stands in for self-shadowing.
float shade = exp(-cloudDensity(p + u_sunDir * 38.0) * 2.2);
vec3 light = u_sunColor * (phase * 6.0) * shade + mix(u_skyHorizon, u_skyTop, 0.6) * 0.35;
float ext = d * dt * 0.035;
float stepT = exp(-ext);
L += T * (1.0 - stepT) * light;
T *= stepT;
if (T < 0.03)
break;
}
t += dt;
}
// Fog in front of the slab is already in `inscatter`; clouds sit behind it.
inscatter += trans * L;
trans *= T;
}
o_atm = vec4(inscatter, trans);
}

View file

@ -0,0 +1,31 @@
// Shared by every fragment stage; concatenated after the prelude, CultMath and the world defines.
uniform vec3 u_camPos;
uniform mat3 u_camBasis; // columns: right, up, forward
uniform vec2 u_tanHalf; // x: tan(fov/2) * aspect, y: tan(fov/2)
uniform float u_time;
uniform vec3 u_sunDir; // unit, towards the sun
uniform vec3 u_sunColor;
uniform vec3 u_skyTop;
uniform vec3 u_skyHorizon;
uniform vec3 u_fogColor;
vec3 camRay(vec2 uv)
{
vec2 ndc = uv * 2.0 - 1.0;
return normalize(u_camBasis * vec3(ndc * u_tanHalf, 1.0));
}
float ign(vec2 p, float frame)
{
p += 5.588238 * mod(frame, 64.0);
return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715))));
}
vec3 skyColor(vec3 rd)
{
float h = clamp(rd.y, 0.0, 1.0);
vec3 c = mix(u_skyHorizon, u_skyTop, pow(h, 0.45));
float s = max(dot(rd, u_sunDir), 0.0);
c += u_sunColor * (1.5 * pow(s, 256.0));
return c;
}

View file

@ -0,0 +1,26 @@
// Full-resolution composite to the canvas: solid over sky, then the accumulated atmosphere.
uniform sampler2D u_solid; // rgb lit, a coverage (bilinear)
uniform sampler2D u_atm; // TAA'd atmosphere (bilinear)
uniform float u_frame;
in vec2 v_uv;
out vec4 o_frag;
vec3 tonemap(vec3 x)
{
// Narkowicz ACES fit.
return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);
}
void main()
{
vec3 rd = camRay(v_uv);
vec4 solid = texture(u_solid, v_uv);
vec4 atm = texture(u_atm, v_uv);
vec3 base = mix(skyColor(rd), solid.rgb, solid.a);
vec3 col = base * atm.a + atm.rgb;
col = tonemap(col);
col = pow(col, vec3(1.0 / 2.2));
col += (ign(gl_FragCoord.xy, u_frame) - 0.5) / 255.0;
o_frag = vec4(col, 1.0);
}

View file

@ -0,0 +1,8 @@
out vec2 v_uv;
void main()
{
// One oversized triangle; uv spans 0..1 over the visible part.
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
v_uv = p;
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}

View file

@ -0,0 +1,41 @@
// TAA resolve for the atmosphere target: reproject history by the pass-1 depth, clamp it to the
// current frame's 3x3 neighbourhood, blend.
uniform sampler2D u_cur;
uniform sampler2D u_history;
uniform sampler2D u_depth;
uniform vec3 u_prevPos;
uniform mat3 u_prevBasis;
uniform vec2 u_prevTanHalf;
uniform float u_blend; // weight of the current frame; 1 resets
uniform vec2 u_curSize;
in vec2 v_uv;
layout(location = 0) out vec4 o_hist;
void main()
{
vec2 px = 1.0 / u_curSize;
vec4 cur = texture(u_cur, v_uv);
vec4 lo = cur;
vec4 hi = cur;
for (int y = -1; y <= 1; y++)
for (int x = -1; x <= 1; x++)
{
vec4 s = texture(u_cur, v_uv + vec2(float(x), float(y)) * px);
lo = min(lo, s);
hi = max(hi, s);
}
// Where this pixel's ray ended, reprojected into last frame's camera.
vec3 rd = camRay(v_uv);
float t = min(texture(u_depth, v_uv).r, 600.0);
vec3 v = u_camPos + rd * t - u_prevPos;
vec3 local = transpose(u_prevBasis) * v;
vec2 prevNdc = local.z > 0.0 ? local.xy / (local.z * u_prevTanHalf) : vec2(2.0);
vec2 prevUv = prevNdc * 0.5 + 0.5;
bool inside = all(greaterThanEqual(prevUv, vec2(0.0))) && all(lessThanEqual(prevUv, vec2(1.0)));
float a = (inside && u_blend < 1.0) ? u_blend : 1.0;
vec4 hist = clamp(texture(u_history, prevUv), lo, hi);
o_hist = mix(hist, cur, a);
}

View file

@ -0,0 +1,184 @@
// Pass 1: solid surfaces (terrain + world tree), sphere-traced. Writes lit colour with coverage
// and, separately, the hit distance that bounds pass 2.
uniform vec3 u_grass;
uniform vec3 u_rock;
uniform vec3 u_bark;
uniform vec3 u_leaf;
uniform int u_maxSteps;
in vec2 v_uv;
layout(location = 0) out vec4 o_color; // rgb lit, a coverage
layout(location = 1) out float o_depth; // hit distance along the ray, W_FAR on a miss
// ---- terrain -------------------------------------------------------------------------------
float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); }
// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap.
float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }
#if W_ERODE_ENABLED
CultPhasor terrainPhacelle(vec2 xz)
{
// A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion.
return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM);
}
#endif
// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the
// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated.
float terrainDistance(vec3 p)
{
float m = terrainMask(p.xz);
// Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most
// of their steps here.
if (m <= 0.0)
return p.y * W_SLOPE_ENV_INV;
float hb = m * W_AMP * terrainFbm(p.xz);
float above = p.y - (hb + m * W_ERODE_AMP);
if (above > 0.0)
return above * W_SLOPE_ENV_INV;
#if W_ERODE_ENABLED
hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w;
#endif
return (p.y - hb) * W_SLOPE_FULL_INV;
}
vec3 terrainNormal(vec2 xz)
{
float r = length(xz);
float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r);
float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0);
float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0;
vec2 dir = r > 0.0 ? xz / r : vec2(0.0);
vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN);
float f = fg.w;
vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir;
#if W_ERODE_ENABLED
CultPhasor ph = terrainPhacelle(xz);
g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir;
#endif
return normalize(vec3(-g.x, 1.0, -g.y));
}
// ---- world tree ----------------------------------------------------------------------------
float smin(float a, float b, float k)
{
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz.
float treeDistance(vec3 p, out float leafness)
{
vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0);
float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y);
leafness = 0.0;
if (bound > 8.0)
return bound;
float ang = atan(p.z, p.x);
float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0);
float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15));
float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7;
vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R;
float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0));
float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp;
canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L)
float d = smin(trunk, canopy, 6.0);
leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0);
return d;
}
// ---- trace ---------------------------------------------------------------------------------
float sceneDistance(vec3 p, out float leafness, out bool onTree)
{
float dt = terrainDistance(p);
float dr = treeDistance(p, leafness);
onTree = dr < dt;
return min(dt, dr);
}
void main()
{
vec3 ro = u_camPos;
vec3 rd = camRay(v_uv);
float t = 0.0;
// Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright.
if (ro.y > W_MAX_HEIGHT)
{
if (rd.y >= 0.0)
{
o_color = vec4(0.0);
o_depth = W_FAR;
return;
}
t = (W_MAX_HEIGHT - ro.y) / rd.y;
}
bool hit = false;
bool onTree = false;
float leafness = 0.0;
int i = 0;
for (; i < u_maxSteps; i++)
{
vec3 p = ro + rd * t;
float d = sceneDistance(p, leafness, onTree);
if (d < 0.0006 * t + 0.01)
{
hit = true;
break;
}
t += d;
if (t > W_FAR || (p.y > W_MAX_HEIGHT && rd.y > 0.0))
break;
}
// The conservative bound makes grazing rays crawl: a ray that ran out of steps while still
// below the highest terrain is a distant surface under thick fog, so it ends there.
if (!hit && i == u_maxSteps && t < W_FAR)
{
hit = true;
sceneDistance(ro + rd * t, leafness, onTree);
}
if (!hit)
{
o_color = vec4(0.0);
o_depth = W_FAR;
return;
}
vec3 p = ro + rd * t;
vec3 n;
vec3 albedo;
if (onTree)
{
float e = 0.35;
float l;
n = normalize(vec3(
treeDistance(p + vec3(e, 0.0, 0.0), l) - treeDistance(p - vec3(e, 0.0, 0.0), l),
treeDistance(p + vec3(0.0, e, 0.0), l) - treeDistance(p - vec3(0.0, e, 0.0), l),
treeDistance(p + vec3(0.0, 0.0, e), l) - treeDistance(p - vec3(0.0, 0.0, e), l)));
float speckle = 0.75 + 0.25 * cultmath_snoise(p * 0.8);
albedo = mix(u_bark, u_leaf * speckle, leafness);
}
else
{
n = terrainNormal(p.xz);
float rockness = smoothstep(0.78, 0.55, n.y);
albedo = mix(u_grass, u_rock, rockness);
}
float ndl = dot(n, u_sunDir);
float wrap = clamp(ndl * 0.6 + 0.4, 0.0, 1.0); // half-lambert: no shadow ray in the cheap pass
vec3 ambient = mix(u_skyHorizon, u_skyTop, 0.5 + 0.5 * n.y) * 0.45;
vec3 col = albedo * (u_sunColor * wrap * wrap + ambient);
o_color = vec4(col, 1.0);
o_depth = t;
}

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { resolveTier } from './tier';
describe('scene tier', () => {
it('is off unless the config turns it on, which is the feature flag', () => {
expect(resolveTier(undefined)).toBe('off');
expect(resolveTier({})).toBe('off');
expect(resolveTier({ tier: 'off' }, { deviceMemory: 16 })).toBe('off');
expect(resolveTier({ tier: 'full' })).toBe('full');
});
it('lets the device lower the configured tier and never raise it', () => {
expect(resolveTier({ tier: 'full' }, { saveData: true })).toBe('still');
expect(resolveTier({ tier: 'full' }, { deviceMemory: 2 })).toBe('low');
expect(resolveTier({ tier: 'full' }, { hardwareConcurrency: 4 })).toBe('low');
expect(resolveTier({ tier: 'low' }, { saveData: true })).toBe('still');
expect(resolveTier({ tier: 'still' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe(
'still'
);
expect(resolveTier({ tier: 'low' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe('low');
});
it('treats unknown capabilities as capable', () => {
expect(resolveTier({ tier: 'full' }, {})).toBe('full');
});
});

View file

@ -0,0 +1,49 @@
// Which quality the background scene runs at. The client config's `backgroundScene.tier` is both
// the feature flag (absent or 'off' means the scene is not even imported) and the ceiling; the
// device can only lower it.
export type SceneTier = 'off' | 'still' | 'low' | 'full';
/** Cheapest first. */
export const TIERS: readonly SceneTier[] = ['off', 'still', 'low', 'full'];
export interface Capabilities {
/** `navigator.connection.saveData`. */
saveData?: boolean;
/** `navigator.deviceMemory` in GiB (Chromium only). */
deviceMemory?: number;
hardwareConcurrency?: number;
}
export interface BackgroundSceneConfig {
tier?: SceneTier;
}
export function readCapabilities(nav: Navigator): Capabilities {
const n = nav as Navigator & {
deviceMemory?: number;
connection?: { saveData?: boolean };
};
return {
saveData: n.connection?.saveData,
deviceMemory: n.deviceMemory,
hardwareConcurrency: n.hardwareConcurrency,
};
}
/** The most the device is allowed to run, before the configured ceiling is applied. */
function deviceCeiling(c: Capabilities): SceneTier {
if (c.saveData) return 'still';
if ((c.deviceMemory ?? Infinity) <= 2 || (c.hardwareConcurrency ?? Infinity) <= 4) return 'low';
return 'full';
}
/** The tier to run: the configured one (default 'off'), lowered by what the device can carry. */
export function resolveTier(
config: BackgroundSceneConfig | undefined,
capabilities: Capabilities = {}
): SceneTier {
const configured = config?.tier ?? 'off';
const ceiling = deviceCeiling(capabilities);
return TIERS.indexOf(configured) <= TIERS.indexOf(ceiling) ? configured : ceiling;
}

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import common from './shaders/common.glsl?raw';
import terrain from './shaders/terrain.frag.glsl?raw';
import atmosphere from './shaders/atmosphere.frag.glsl?raw';
import resolve from './shaders/resolve.frag.glsl?raw';
import composite from './shaders/composite.frag.glsl?raw';
import { terrainBounds, WORLD, worldDefines } from './world';
describe('world defines', () => {
it('define every W_ name any scene shader uses', () => {
const defined = new Set([...worldDefines().matchAll(/#define (W_\w+)/g)].map((m) => m[1]));
[common, terrain, atmosphere, resolve, composite]
.flatMap((src) => [...src.matchAll(/\bW_[A-Z0-9_]+\b/g)])
.forEach((m) => expect(defined, m[0]).toContain(m[0]));
});
it('emit only valid GLSL numbers', () => {
expect(worldDefines()).not.toMatch(/NaN|Infinity|undefined/);
});
});
describe('terrain step bound', () => {
it('is more conservative with erosion than without, and without erosion carries no phacelle term', () => {
const b = terrainBounds();
expect(b.slopeFull).toBeGreaterThan(b.slopeEnvelope);
const off = terrainBounds({ ...WORLD, phacelleGradBound: 0 });
expect(off.slopeFull).toBe(off.slopeEnvelope);
});
it('puts every surface under the height the tracer skips to', () => {
const maxHeight = Number(/#define W_MAX_HEIGHT (\S+)/.exec(worldDefines())![1]);
expect(maxHeight).toBeGreaterThanOrEqual(terrainBounds().maxHeight);
expect(maxHeight).toBeGreaterThanOrEqual(WORLD.tree.canopyY + WORLD.tree.canopyRadius[1]);
});
it('leaves the erosion bound turned on only when a bound exists', () => {
expect(worldDefines()).toContain('#define W_ERODE_ENABLED 1');
expect(worldDefines({ ...WORLD, phacelleGradBound: 0 })).toContain('#define W_ERODE_ENABLED 0');
});
});

View file

@ -0,0 +1,116 @@
// The scene's world constants. One owner: the shader prelude below is generated from this
// object, so a number lives here and nowhere else, and the Lipschitz bounds that make the
// sphere-trace step safe are computed beside the numbers they bound.
// Lipschitz constant of cultmath_snoise(vec3), CULTMATH_SNOISE_LIPSCHITZ in the vendored
// CultMath.glsl (provenance in CultLib math.Interval.cs).
export const SNOISE_LIPSCHITZ = 7.9640074;
export const WORLD = {
far: 1400,
// Terrain: height = mask(r) * (amp * fbm(xz * freq) + erodeAmp * phacelle(xz * erodeFreq)).
amp: 70,
freq: 1 / 520,
octaves: 4,
lacunarity: 2.0,
gain: 0.45,
// Flat ground (height 0) out to plateauInner, full terrain from plateauOuter. The login
// camera stays inside plateauInner, so it can never end up inside the terrain.
plateauInner: 135,
plateauOuter: 380,
erodeAmp: 2.5,
erodeFreq: 1 / 38,
// The stripe wave vector and normalization the terrain feeds cultmath_phacelle.
erodeSide: [1.7, 0, 1] as const,
erodeNorm: 0.5,
// Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a
// sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. The
// shader passes roughness n = 0 as a literal, so the doc envelope max(10, 1.25|side|)/(1-n)
// needs no 1/(1-n) factor here; if n ever becomes nonzero, divide this bound by (1 - n).
// |value| <= 1 holds by construction (the output is a normalized phasor).
// Typed number, not the literal 7, so callers can build variants such as { ...WORLD, phacelleGradBound: 0 }.
phacelleGradBound: 7.0 as number,
// World tree at the origin.
tree: {
trunkHeight: 38,
baseRadius: 3.4,
topRadius: 1.9,
canopyY: 52,
canopyRadius: [30, 17, 30] as const,
},
// Atmosphere.
atmosphereFar: 1400,
cloudBase: 130,
cloudTop: 230,
fogDensity: 0.0028,
fogFalloff: 0.011,
} as const;
const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`);
/** Sum over octaves of (lacunarity * gain)^i: the slope bound's octave factor. */
function slopeSum(octaves: number, lacunarity: number, gain: number): number {
let s = 0;
for (let i = 0; i < octaves; i += 1) s += (lacunarity * gain) ** i;
return s;
}
/** Sum over octaves of gain^i: the fbm value bound. */
function valueSum(octaves: number, gain: number): number {
let s = 0;
for (let i = 0; i < octaves; i += 1) s += gain ** i;
return s;
}
export interface TerrainBounds {
/** Lipschitz bound of the envelope surface (no erosion). */
slopeEnvelope: number;
/** Lipschitz bound of the eroded surface. */
slopeFull: number;
/** Highest point the terrain can reach. */
maxHeight: number;
}
export function terrainBounds(w: typeof WORLD = WORLD): TerrainBounds {
const fbmMax = valueSum(w.octaves, w.gain);
const maskSlope = 1.5 / (w.plateauOuter - w.plateauInner); // smoothstep's max slope
const fbmSlope = w.amp * w.freq * SNOISE_LIPSCHITZ * slopeSum(w.octaves, w.lacunarity, w.gain);
const slopeEnvelope = fbmSlope + (w.amp * fbmMax + w.erodeAmp) * maskSlope;
const slopeFull = slopeEnvelope + w.erodeAmp * w.erodeFreq * w.phacelleGradBound;
return { slopeEnvelope, slopeFull, maxHeight: w.amp * fbmMax + w.erodeAmp };
}
/** The #defines every stage sees. */
export function worldDefines(w: typeof WORLD = WORLD): string {
const b = terrainBounds(w);
const t = w.tree;
const lines: Array<[string, string]> = [
['W_FAR', f(w.far)],
['W_AMP', f(w.amp)],
['W_FREQ', f(w.freq)],
['W_OCTAVES', String(w.octaves)],
['W_LACUNARITY', f(w.lacunarity)],
['W_GAIN', f(w.gain)],
['W_PLATEAU_IN', f(w.plateauInner)],
['W_PLATEAU_OUT', f(w.plateauOuter)],
['W_ERODE_AMP', f(w.erodeAmp)],
['W_ERODE_FREQ', f(w.erodeFreq)],
['W_ERODE_SIDE', `vec3(${w.erodeSide.map(f).join(', ')})`],
['W_ERODE_NORM', f(w.erodeNorm)],
['W_ERODE_ENABLED', w.phacelleGradBound > 0 ? '1' : '0'],
['W_SLOPE_ENV_INV', f(1 / Math.sqrt(1 + b.slopeEnvelope ** 2))],
['W_SLOPE_FULL_INV', f(1 / Math.sqrt(1 + b.slopeFull ** 2))],
['W_MAX_HEIGHT', f(Math.max(b.maxHeight, t.canopyY + t.canopyRadius[1] + 6))],
['W_TRUNK_H', f(t.trunkHeight)],
['W_TRUNK_R0', f(t.baseRadius)],
['W_TRUNK_R1', f(t.topRadius)],
['W_CANOPY_Y', f(t.canopyY)],
['W_CANOPY_R', `vec3(${t.canopyRadius.map(f).join(', ')})`],
['W_ATM_FAR', f(w.atmosphereFar)],
['W_CLOUD_Y0', f(w.cloudBase)],
['W_CLOUD_Y1', f(w.cloudTop)],
['W_FOG_DENSITY', f(w.fogDensity)],
['W_FOG_FALLOFF', f(w.fogFalloff)],
];
return lines.map(([k, v]) => `#define ${k} ${v}`).join('\n');
}

View file

@ -1,4 +1,5 @@
import { createContext, useContext } from 'react';
import type { BackgroundSceneConfig } from '../features/scene/tier';
export type HashRouterConfig = {
enabled?: boolean;
@ -18,6 +19,8 @@ export type ClientConfig = {
};
hashRouter?: HashRouterConfig;
backgroundScene?: BackgroundSceneConfig;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);