Merge pull request 'scene: cone prepass for pass 1, opt-in steps/pixel readout' (#12) from nibu/scene-trace-prepass into ratatoskr/estate-navigator
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled

This commit is contained in:
Void 2026-11-04 22:56:29 +00:00
commit 7e632e8605
11 changed files with 780 additions and 133 deletions

View file

@ -8,6 +8,8 @@ Chromium. Not shipped, not run by CI.
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> trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms
node drive.cjs <outdir> diff <pngdir> # one frozen frame with and without the prepass, pixel diff and both PNGs
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

View file

@ -1,9 +1,10 @@
// 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)
// usage: node bundle.cjs <outdir> [demo] (esbuild from NODE_PATH). `demo` bundles the standalone page.
const esbuild = require('esbuild');
const fs = require('fs');
const path = require('path');
const out = process.argv[2];
const demo = process.argv[3] === 'demo';
fs.mkdirSync(out, { recursive: true });
const rawPlugin = {
name: 'raw',
@ -20,7 +21,7 @@ const rawPlugin = {
};
esbuild
.build({
entryPoints: [path.join(__dirname, 'entry.ts')],
entryPoints: [path.join(__dirname, demo ? 'demo.ts' : 'entry.ts')],
bundle: true,
outfile: path.join(out, 'bundle.js'),
plugins: [rawPlugin],
@ -30,6 +31,10 @@ esbuild
.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>'
'<!doctype html><meta charset=utf-8><title>Ratatoskr scene: trace prepass</title><body style="margin:0;background:#000"><canvas id=c style="width:100vw;height:100vh;display:block"></canvas>' +
(demo
? '<div style="position:fixed;left:8px;top:8px;padding:6px 8px;background:#000a;color:#ddd;font:12px monospace"><label><input id=prepass type=checkbox checked> cone prepass</label> <label><input id=half type=checkbox> half-float start</label> <label>pull-back <input id=pull type=range min=0 max=1 step=0.05 value=0.6></label><pre id=out style="margin:4px 0 0;white-space:pre-wrap"></pre><details id=dev><summary>device</summary><pre id=info style="margin:4px 0 0;white-space:pre-wrap;max-height:50vh;overflow:auto"></pre></details></div>'
: '') +
'<script src=bundle.js></script>'
);
});

102
harness/scene/demo.ts Normal file
View file

@ -0,0 +1,102 @@
// Standalone page: the scene full-window with a readout of pass ms and trace steps per pixel, and
// a switch for the cone prepass. Not shipped.
import {
createScene,
Scene,
SceneStats,
SceneUnavailable,
TraceStats,
} from '../../src/app/features/scene/Scene';
import { LEVELS } from '../../src/app/features/scene/budget';
let canvas = document.getElementById('c') as HTMLCanvasElement;
const out = document.getElementById('out') as HTMLPreElement;
const box = document.getElementById('prepass') as HTMLInputElement;
const pull = document.getElementById('pull') as HTMLInputElement;
const half = document.getElementById('half') as HTMLInputElement;
const dev = document.getElementById('dev') as HTMLDetailsElement;
const info = document.getElementById('info') as HTMLPreElement;
const timerless = (() => {
const g = document.createElement('canvas').getContext('webgl2');
return !g?.getExtension('EXT_disjoint_timer_query_webgl2');
})();
const WHY: Record<SceneUnavailable, string> = {
'no-webgl2': 'this browser gave no WebGL2 context',
'no-float-or-half-float-colour-buffer':
'WebGL2 is there, but neither EXT_color_buffer_float nor EXT_color_buffer_half_float is',
};
// What the browser says about itself, read from a scratch context so it shows even when the scene
// could not start. A canvas that never got a WebGL2 context is asked for WebGL1 to name the GPU.
function describeDevice(): string {
const c = document.createElement('canvas');
const gl2 = c.getContext('webgl2');
const gl = gl2 ?? (document.createElement('canvas').getContext('webgl') as WebGLRenderingContext | null);
const lines = [`webgl2 context: ${gl2 ? 'yes' : 'NO'}`];
if (!gl) return [...lines, 'webgl1 context: NO'].join('\n');
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
lines.push(
`renderer: ${gl.getParameter(dbg ? dbg.UNMASKED_RENDERER_WEBGL : gl.RENDERER)}`,
`vendor: ${gl.getParameter(dbg ? dbg.UNMASKED_VENDOR_WEBGL : gl.VENDOR)}`,
`version: ${gl.getParameter(gl.VERSION)}`,
`user agent: ${navigator.userAgent}`
);
const ext = gl.getSupportedExtensions() ?? [];
for (const n of ['EXT_color_buffer_float', 'EXT_color_buffer_half_float', 'OES_texture_float_linear', 'EXT_disjoint_timer_query_webgl2'])
lines.push(`${n}: ${ext.includes(n) ? 'yes' : 'NO'}`);
lines.push(`extensions (${ext.length}): ${ext.join(' ')}`);
return lines.join('\n');
}
info.textContent = describeDevice();
let scene: Scene | null = null;
let stats: SceneStats | undefined;
let trace: TraceStats | undefined;
function start() {
scene?.dispose();
stats = trace = undefined;
// A fresh canvas: a context cannot change its attributes, and dispose leaves the old one bound.
const fresh = canvas.cloneNode() as HTMLCanvasElement;
canvas.replaceWith(fresh);
canvas = fresh;
scene = createScene({
canvas: fresh,
unthrottled: true,
fixedLevel: LEVELS[0],
fixedPull: +pull.value,
prepass: box.checked,
halfStart: half.checked,
onUnavailable: (r) => (out.textContent = `createScene returned null: ${WHY[r]}`),
onCompileFail: (e) => (out.textContent = `createScene returned null: ${e.message}`),
onStats: (s) => (stats = s),
onTraceStats: (t) => (trace = t),
});
dev.open = !scene;
}
setInterval(() => {
if (!scene) return;
const lines = [`prepass ${box.checked ? 'on' : 'off'} start target ${scene.startFormat} pull ${pull.value}`];
if (stats) {
const ms = stats.passMs;
const total = Object.values(ms).reduce((a, b) => a + b, 0);
lines.push(
`gpu ms prepass ${ms.prepass.toFixed(2)} terrain ${ms.terrain.toFixed(2)} atmosphere ${ms.atmosphere.toFixed(2)} resolve ${ms.resolve.toFixed(2)} composite ${ms.composite.toFixed(2)} total ${total.toFixed(2)}`,
`solid ${stats.solidSize.join('x')}`
);
} else if (timerless) {
lines.push('gpu ms: this browser has no EXT_disjoint_timer_query_webgl2');
}
if (trace)
lines.push(
`steps/pixel ${trace.stepsPerPixel.toFixed(2)} (pass 1 ${trace.traceSteps.toFixed(2)}, prepass ${trace.prepassSteps.toFixed(1)} per tile)`
);
out.textContent = lines.join('\n');
}, 500);
box.addEventListener('change', start);
half.addEventListener('change', start);
pull.addEventListener('input', start);
start();

View file

@ -3,6 +3,7 @@
// Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report.
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');
const [, , dir, mode, outdir = '.'] = process.argv;
(async () => {
@ -15,6 +16,7 @@ const [, , dir, mode, outdir = '.'] = process.argv;
],
});
const page = await b.newPage({ viewport: { width: 960, height: 540 } });
page.on('pageerror', (e) => process.stderr.write(`[pageerror] ${e}\n`));
page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`));
await page.goto('file://' + path.resolve(dir, 'index.html'));
const report = { mode };
@ -156,6 +158,149 @@ const [, , dir, mode, outdir = '.'] = process.argv;
}
}
if (mode === 'trace') {
// Steps per pixel and pass ms at fixed camera poses, one scene per run. PREPASS=0,1 picks the arms.
const arms = (process.env.PREPASS || '1').split(',').map((x) => x === '1');
report.runs = [];
for (const [w, h] of process.env.PORTRAIT === '0'
? [[960, 540]]
: [
[960, 540],
[540, 1200],
]) {
for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) {
for (const prepass of arms) {
await page.setViewportSize({ width: w, height: h });
const r = await page.evaluate(
({ pull, prepass, FRAMES }) =>
new Promise((res) => {
const rows = [];
const trace = [];
let done = false;
const finish = () => {
if (done || rows.length < FRAMES || trace.length < 4) return;
done = true;
s.dispose();
const keep = rows.slice(FRAMES >> 2);
const med = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1];
const tk = trace.slice(1);
const mean = (k) => tk.reduce((a, t) => a + t[k], 0) / tk.length;
res({
solid: keep[0].solidSize,
readbacks: tk.length,
traceSteps: +mean('traceSteps').toFixed(2),
prepassSteps: +mean('prepassSteps').toFixed(2),
stepsPerPixel: +mean('stepsPerPixel').toFixed(2),
medianMs: Object.fromEntries(
Object.keys(keep[0].passMs).map((k) => [k, +med(keep.map((x) => x.passMs[k])).toFixed(2)])
),
});
};
const s = window.__scene.createScene({
canvas: document.getElementById('c'),
unthrottled: true,
fixedLevel: window.__scene.LEVELS[0],
fixedPull: pull,
prepass,
onTraceStats: (t) => {
trace.push(t);
finish();
},
onStats: (st) => {
rows.push(st);
finish();
},
});
if (!s) res({ error: 'createScene returned null' });
}),
{ pull, prepass, FRAMES: +(process.env.FRAMES || 64) }
);
report.runs.push({ viewport: [w, h], pull, prepass, ...r });
}
}
}
}
if (mode === 'diff') {
// Same frozen frame (reduced motion: time 0, 32 frames) with and without the prepass, compared
// pixel by pixel. The prepass is meant to change cost, not the picture.
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.setViewportSize({ width: 640, height: 360 });
const shoot = async (pull, prepass, steps) => {
await page.evaluate(
({ pull, prepass, steps }) => {
// Reduced motion bounds the scene to 32 frames; each asks for the next with rAF, so the
// count stops moving when it is done. Timer-query callbacks lag too far to count on.
window.__raf = 0;
if (!window.__rafPatched) {
window.__rafPatched = true;
const raf = window.requestAnimationFrame.bind(window);
window.requestAnimationFrame = (cb) => (window.__raf++, raf(cb));
}
window.__d = window.__scene.createScene({
canvas: document.getElementById('c'),
fixedLevel: { ...window.__scene.LEVELS[0], traceSteps: steps },
fixedPull: pull,
prepass,
});
},
{ pull, prepass, steps }
);
for (let last = -1, still = 0; still < 3; await page.waitForTimeout(1000)) {
const n = await page.evaluate(() => window.__raf);
still = n === last ? still + 1 : 0;
last = n;
}
const png = await page.screenshot();
await page.evaluate(() => window.__d.dispose());
return png.toString('base64');
};
// The step cap makes a grazing ray give up and count as a hit, so the baseline is compared
// with a 512-step render of the same frame as well: whichever is closer to it is the more
// faithful one.
const decode = (x) =>
page.evaluate(
(x) =>
new Promise((r) => {
const i = new Image();
i.onload = () => {
const c = document.createElement('canvas');
c.width = i.width;
c.height = i.height;
const g = c.getContext('2d');
g.drawImage(i, 0, 0);
r(Array.from(g.getImageData(0, 0, i.width, i.height).data));
};
i.src = 'data:image/png;base64,' + x;
}),
x
);
const compare = (da, db) => {
let max = 0, over = 0, sum = 0;
const px = da.length / 4;
for (let i = 0; i < da.length; i += 4) {
let m = 0;
for (let k = 0; k < 3; k++) m = Math.max(m, Math.abs(da[i + k] - db[i + k]));
sum += m;
if (m > max) max = m;
if (m > 2) over++;
}
return { maxChannelDiff: max, pixelsOver2of255: over, ofPixels: px, meanMaxChannelDiff: +(sum / px).toFixed(3) };
};
report.diffs = [];
for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) {
const png = {
off: await shoot(pull, false, 96),
on: await shoot(pull, true, 96),
ref: await shoot(pull, false, 512),
off2: await shoot(pull, false, 96),
};
for (const k of Object.keys(png)) fs.writeFileSync(path.join(outdir, `diff-${k}-${pull}.png`), Buffer.from(png[k], 'base64'));
const [off, on, ref, off2] = [await decode(png.off), await decode(png.on), await decode(png.ref), await decode(png.off2)];
report.diffs.push({ pull, offVsOffAgain: compare(off, off2), offVsOn: compare(off, on), offVsRef512: compare(off, ref), onVsRef512: compare(on, ref) });
}
}
console.log(JSON.stringify(report, null, 1));
await b.close();
})().catch((e) => {

View file

@ -17,8 +17,9 @@ callbacks and the creature sizing rule. Nothing has drawn a creature.
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
`{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 is missing, neither
`EXT_color_buffer_float` nor `EXT_color_buffer_half_float` exists (`onUnavailable` says which; with
only the half extension the prepass start target is RGBA16F, written rounded down), 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
@ -31,11 +32,16 @@ callbacks and the creature sizing rule. Nothing has drawn a creature.
- `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),
- `shaders/` `sdf.glsl` (the distance bound pass 0 and pass 1 share), pass 0 `prepass.frag.glsl`, 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
0. **Cone prepass** (`prepass.frag.glsl`, one texel per 8x8 tile of pass 1): marches one ray per
tile, shrinking each step by the widest any ray of the tile strays from it, so every step is
free for the whole tile. It stores the distance no ray of the tile can have hit before, or
`W_FAR` when the whole tile climbs out above the highest geometry. Pass 1 starts its rays
there. `createScene({ prepass: false })` skips it (the measurement baseline).
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
@ -47,6 +53,10 @@ callbacks and the creature sizing rule. Nothing has drawn a creature.
4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES
tonemap, dither.
`createScene({ onTraceStats })` measures the trace: pass 1 gets a step-count output and an extra
render target, read back every 8th frame through a pixel-pack buffer and a fence (never waited
on), and reports mean steps per pixel. Unset, neither the output nor the target exists.
## 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,

View file

@ -1,21 +1,28 @@
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { createScene, ShaderCompileError } from './Scene';
import { createScene, ShaderCompileError, HALF_START_BIAS, HALF_START_SCALE } from './Scene';
import { WORLD } from './world';
import { LEVELS } from './budget';
import { fragmentSource } from './shaderSource';
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) {
function fakeGl(failFrom = Infinity, missing: string[] = []) {
let compiles = 0;
const calls: Record<string, Fn> = {};
const spy = (name: string, impl: () => unknown = () => ({})): Fn => {
const spy = (name: string, impl: (...a: any[]) => unknown = () => ({})): Fn => {
if (!calls[name]) calls[name] = vi.fn(impl);
return calls[name];
};
const gl = new Proxy({} as Record<string, unknown>, {
get(_, prop: string) {
// the two start-target formats must be told apart; every other constant is 1
if (prop === 'RGBA32F') return 32;
if (prop === 'RGBA16F') return 16;
if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 1;
if (prop === 'getExtension')
return spy(prop, (n: string) => (missing.includes(n) ? null : {}));
if (prop === 'compileShader') {
return spy(prop, () => {
compiles += 1;
@ -94,7 +101,7 @@ describe('createScene tiers', () => {
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(calls.drawArrays).toHaveBeenCalledTimes(5); // prepass and four passes, one frame
expect(rafQueue).toHaveLength(0);
});
@ -132,6 +139,137 @@ describe('createScene tiers', () => {
});
});
describe('cone prepass', () => {
const programsFor = (prepass?: boolean) => {
rafQueue.length = 0;
const { gl, calls } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass });
return calls.createProgram.mock.calls.length;
};
it('is one more program than the baseline, and off means off', () => {
expect(programsFor(true)).toBe(programsFor(false) + 1);
expect(programsFor(undefined)).toBe(programsFor(true));
});
it('draws before pass 1 and only when on', () => {
const drawsFor = (prepass: boolean) => {
rafQueue.length = 0;
const { gl, calls } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass });
runFrame(1000);
return calls.drawArrays.mock.calls.length;
};
expect(drawsFor(true)).toBe(drawsFor(false) + 1);
});
it('the trace-step output exists in pass 1 only when the host asked to measure', () => {
expect(fragmentSource('terrain')).not.toContain('TRACE_STATS 1');
expect(fragmentSource('terrain', '#define TRACE_STATS 1\n')).toContain('#define TRACE_STATS 1');
});
it('reads the step bytes tightly packed, whatever the width', () => {
rafQueue.length = 0;
const { gl, calls } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)), onTraceStats: () => {} });
runFrame(1000);
// fake constants are all 1: PACK_ALIGNMENT is 1, so a restore to 4 is the only other call
const packs = calls.pixelStorei.mock.calls.map((c) => c[1]);
expect(packs[0]).toBe(1);
expect(packs).toContain(4);
expect(calls.readPixels.mock.invocationCallOrder[0]).toBeGreaterThan(
calls.pixelStorei.mock.invocationCallOrder[0]
);
});
it('shares one distance bound between pass 1 and the prepass', () => {
const bound = (stage: 'terrain' | 'prepass') =>
fragmentSource(stage).match(/float sceneDistance\([^)]*\)\s*\{[^}]*\}/)?.[0];
expect(bound('prepass')).toBeDefined();
expect(bound('prepass')).toBe(bound('terrain'));
expect(fragmentSource('atmosphere')).not.toContain('sceneDistance');
});
});
describe('device without EXT_color_buffer_float', () => {
const FLOAT = 'EXT_color_buffer_float';
const HALF = 'EXT_color_buffer_half_float';
const build = (missing: string[], extra: Partial<Parameters<typeof createScene>[0]> = {}) => {
rafQueue.length = 0;
const { gl, calls } = fakeGl(Infinity, missing);
const onUnavailable = vi.fn();
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onUnavailable, ...extra });
if (scene) runFrame(1000); // targets are allocated on the first frame
const formats = calls.texStorage2D?.mock.calls.map((c) => c[2]) ?? [];
const sources = (calls.shaderSource?.mock.calls ?? []).map((c) => c[1] as string);
return {
scene,
onUnavailable,
formats,
prepassSource: sources.find((x) => x.includes('o_start')),
};
};
it('names why the scene is unavailable: no WebGL2, or neither float nor half-float colour buffers', () => {
const none = vi.fn();
createScene({ canvas: asCanvas(fakeCanvas(null)), onUnavailable: none });
expect(none).toHaveBeenCalledTimes(1);
expect(none).toHaveBeenCalledWith('no-webgl2');
const r = build([FLOAT, HALF]);
expect(r.scene).toBeNull();
expect(r.onUnavailable).toHaveBeenCalledTimes(1);
expect(r.onUnavailable).toHaveBeenCalledWith('no-float-or-half-float-colour-buffer');
expect(build([]).onUnavailable).not.toHaveBeenCalled();
});
it('with only half-float colour buffers, starts the prepass in an RGBA16F target with the rounding-down write', () => {
const r = build([FLOAT]);
expect(r.scene?.startFormat).toBe('RGBA16F');
expect(r.formats).not.toContain(32);
expect(r.prepassSource).toContain('#define START_HALF 1');
expect(r.prepassSource).toContain(`#define START_HALF_SCALE ${HALF_START_SCALE}`);
});
it('keeps RGBA32F and the plain write where it is renderable, unless asked for half', () => {
const wide = build([HALF]);
expect(wide.scene?.startFormat).toBe('RGBA32F');
expect(wide.formats).toContain(32);
expect(wide.prepassSource).not.toContain('#define START_HALF');
expect(build([], { halfStart: true }).scene?.startFormat).toBe('RGBA16F');
});
// The shader writes max(0, t*SCALE - BIAS) into a half, which rounds to nearest (even); this is
// that rounding. The GPU's rounding of a half write is the unknown a phone must confirm.
const toHalf = (x: number) => {
if (x === 0) return 0;
let e = Math.max(Math.floor(Math.log2(x)), -14);
if (2 ** e > x) e -= 1;
const ulp = 2 ** (e - 10);
const q = x / ulp;
let r = Math.floor(q);
const d = q - r;
if (d > 0.5 || (d === 0.5 && r % 2 === 1)) r += 1;
return r * ulp;
};
const stored = (t: number) => toHalf(Math.max(0, t * HALF_START_SCALE - HALF_START_BIAS));
it('stores a start distance never past the true one, and not much short of it', () => {
let seed = 7;
const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const ts = [0, 1e-6, 1e-4, 6.1e-5, 0.01, 1, 1023.9, 1024, 2047.9];
for (let i = 0; i < 20000; i++) ts.push(rand() * WORLD.far, rand() * rand() * 4);
for (let t = 0; t <= WORLD.far; t += 0.37) ts.push(t);
for (const t of ts) {
expect(stored(t)).toBeLessThanOrEqual(t);
expect(t - stored(t)).toBeLessThanOrEqual(t * 0.0021 + 6e-5);
}
});
it('holds the miss sentinel W_FAR exactly, so pass 1 still skips a tile the prepass proved empty', () => {
expect(toHalf(WORLD.far)).toBe(WORLD.far);
});
});
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
@ -265,7 +403,7 @@ describe('startBackgroundScene', () => {
);
expect(scene).not.toBeNull();
runFrame(1000);
expect(calls.drawArrays).toHaveBeenCalledTimes(4);
expect(calls.drawArrays).toHaveBeenCalledTimes(5);
expect(rafQueue).toHaveLength(0);
});

View file

@ -10,8 +10,10 @@ 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 type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite' | 'prepass';
const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite', 'prepass'];
/** Pass 1 pixels per prepass tile edge. */
const TILE = 8;
export interface SceneStats {
/** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */
@ -21,6 +23,16 @@ export interface SceneStats {
atmosphereSize: [number, number];
}
/** Trace cost over one finished readback: mean distance evaluations per pass-1 pixel. */
export interface TraceStats {
/** Mean pass-1 steps per pixel. */
traceSteps: number;
/** Mean prepass steps per tile; 0 without the prepass. */
prepassSteps: number;
/** traceSteps plus the prepass's steps spread over the pixels of each tile. */
stepsPerPixel: 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;
@ -30,6 +42,23 @@ export class ShaderCompileError extends Error {
}
}
/**
* Why createScene returned null for want of a device feature. Rendering to half floats needs
* EXT_color_buffer_float or EXT_color_buffer_half_float; the prepass start target is RGBA32F with
* the first and RGBA16F with only the second.
*/
export type SceneUnavailable = 'no-webgl2' | 'no-float-or-half-float-colour-buffer';
/**
* The RGBA16F prepass start is written as `t * HALF_START_SCALE - HALF_START_BIAS`. A half rounds
* to nearest with relative error at most 2^-11, so shaving 2^-10 of t leaves the stored value
* below t, and the bias covers the absolute error where halves go subnormal. The shader reads both
* through #defines, so this is the one copy.
*/
export const HALF_START_SCALE = 1 - 1 / 1024;
export const HALF_START_BIAS = 1 / 32768;
const HALF_START_DEFINES = `#define START_HALF 1\n#define START_HALF_SCALE ${HALF_START_SCALE}\n#define START_HALF_BIAS ${HALF_START_BIAS}\n`;
/** 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. */
@ -42,22 +71,37 @@ export interface SceneOptions {
* the adaptive ladder low. 'full' starts it at the top. Default 'full'.
*/
tier?: SceneTier;
/** createScene is about to return null because the device lacks a feature, and which one. */
onUnavailable?: (reason: SceneUnavailable) => void;
/** Force the RGBA16F prepass start target even where RGBA32F is renderable (measurement, tests). */
halfStart?: boolean;
/** 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;
/**
* Measurement: mean sphere-trace steps per pass-1 pixel, read back every few frames. Setting it
* compiles pass 1 with a step-count output and an extra render target; unset, neither exists.
*/
onTraceStats?: (s: TraceStats) => void;
/** Weather knob, 0 clear .. 1 overcast. */
coverage?: number;
/** Fixed level instead of the adaptive ladder (measurement). */
fixedLevel?: Level;
/** Cone prepass that starts pass-1 rays past empty space. Default true; false is the measurement baseline. */
prepass?: boolean;
/** Hold the camera at this pull-back progress, 0..1, instead of following login (measurement). */
fixedPull?: number;
/** Ignore the 30 fps cap and the visibility pause (measurement). */
unthrottled?: boolean;
patchSource?: SourcePatch;
}
export interface Scene {
/** Texture format of the prepass start target. */
readonly startFormat: 'RGBA32F' | 'RGBA16F';
/** Start the camera pull-back from the tree. */
login(): void;
dispose(): void;
@ -83,13 +127,14 @@ function compile(
export function linkStage(
gl: WebGL2RenderingContext,
stage: Stage,
patch?: SourcePatch
patch?: SourcePatch,
defines?: string
): WebGLProgram {
const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex');
const fs = compile(
gl,
gl.FRAGMENT_SHADER,
patch ? patch(stage, fragmentSource(stage)) : fragmentSource(stage),
patch ? patch(stage, fragmentSource(stage, defines)) : fragmentSource(stage, defines),
stage
);
const prog = gl.createProgram()!;
@ -124,7 +169,7 @@ function halton(i: number, base: number): number {
return r;
}
/** Null when WebGL2 or float colour buffers are missing; the host keeps its CSS ground. */
/** Null when WebGL2 or a float colour buffer is missing (`onUnavailable` says which); the host keeps its CSS ground. */
export function createScene(opts: SceneOptions): Scene | null {
const { canvas } = opts;
const tier = opts.tier ?? 'full';
@ -136,20 +181,38 @@ export function createScene(opts: SceneOptions): Scene | null {
stencil: false,
powerPreference: 'low-power',
});
if (!gl || !gl.getExtension('EXT_color_buffer_float')) return null;
if (!gl) {
opts.onUnavailable?.('no-webgl2');
return null;
}
const wide = !!gl.getExtension('EXT_color_buffer_float');
if (!wide && !gl.getExtension('EXT_color_buffer_half_float')) {
opts.onUnavailable?.('no-float-or-half-float-colour-buffer');
return null;
}
// Every other target is a half; only the prepass start can ask for 32 bits.
const halfStart = opts.halfStart || !wide;
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 usePrepass = opts.prepass ?? true;
const passes = usePrepass ? PASSES : PASSES.filter((p) => p !== 'prepass');
const programs = {} as Record<PassName, WebGLProgram>;
try {
PASSES.forEach((p) => {
programs[p] = linkStage(gl, p, opts.patchSource);
passes.forEach((p) => {
const defines =
p === 'terrain' && opts.onTraceStats
? '#define TRACE_STATS 1\n'
: p === 'prepass' && halfStart
? HALF_START_DEFINES
: undefined;
programs[p] = linkStage(gl, p, opts.patchSource, defines);
});
} catch (e) {
PASSES.forEach((p) => {
passes.forEach((p) => {
if (programs[p]) gl.deleteProgram(programs[p]);
});
if (!(e instanceof ShaderCompileError)) throw e;
@ -167,6 +230,8 @@ export function createScene(opts: SceneOptions): Scene | null {
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const startFormat = halfStart ? gl.RGBA16F : gl.RGBA32F;
const makeTarget = (w: number, h: number, format: number, filter: number): Target => {
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
@ -180,15 +245,18 @@ export function createScene(opts: SceneOptions): Scene | null {
const fbos = {
solid: gl.createFramebuffer()!,
pre: gl.createFramebuffer()!,
atm: gl.createFramebuffer()!,
hist: [gl.createFramebuffer()!, gl.createFramebuffer()!],
};
let solid: Target;
let depth: Target;
let steps: Target | undefined;
let startTex: Target;
let atm: Target;
let hist: [Target, Target];
const freeTargets = () => {
[solid, depth, atm, hist?.[0], hist?.[1]].forEach((t) => {
[solid, depth, steps, startTex, atm, hist?.[0], hist?.[1]].forEach((t) => {
if (t) gl.deleteTexture(t.tex);
});
};
@ -205,6 +273,73 @@ export function createScene(opts: SceneOptions): Scene | null {
const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0);
const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current);
// Trace-step readback: one pixel-pack read in flight at a time, polled by fence, never waited on.
// Pass 1's step bytes and the prepass's RGBA floats (g: steps) come back together.
const READBACK_EVERY = 8;
interface Readback {
fence: WebGLSync;
pixels: number;
tiles: number;
}
let readback: Readback | undefined;
const packSteps = opts.onTraceStats ? gl.createBuffer() : null;
const packStart = opts.onTraceStats && usePrepass ? gl.createBuffer() : null;
const discardReadback = () => {
if (readback) gl.deleteSync(readback.fence);
readback = undefined;
};
const startReadback = () => {
if (!steps || readback) return;
gl.pixelStorei(gl.PACK_ALIGNMENT, 1); // R8 rows are not 4-byte multiples at every width
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps);
gl.bufferData(gl.PIXEL_PACK_BUFFER, steps.w * steps.h, gl.STREAM_READ);
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid);
gl.readBuffer(gl.COLOR_ATTACHMENT0 + 2);
gl.readPixels(0, 0, steps.w, steps.h, gl.RED, gl.UNSIGNED_BYTE, 0);
let tiles = 0;
if (packStart) {
tiles = startTex.w * startTex.h;
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart);
gl.bufferData(gl.PIXEL_PACK_BUFFER, tiles * 16, gl.STREAM_READ);
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre);
gl.readPixels(0, 0, startTex.w, startTex.h, gl.RGBA, gl.FLOAT, 0);
}
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
gl.pixelStorei(gl.PACK_ALIGNMENT, 4);
readback = {
fence: gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0)!,
pixels: steps.w * steps.h,
tiles,
};
};
const pollReadback = () => {
if (!readback) return;
const status = gl.clientWaitSync(readback.fence, 0, 0);
if (status !== gl.ALREADY_SIGNALED && status !== gl.CONDITION_SATISFIED) return;
const { pixels, tiles } = readback;
const bytes = new Uint8Array(pixels);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, bytes);
let preSum = 0;
if (packStart) {
const floats = new Float32Array(tiles * 4);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, floats);
for (let i = 1; i < floats.length; i += 4) preSum += floats[i];
}
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
let sum = 0;
for (let i = 0; i < bytes.length; i += 1) sum += bytes[i];
discardReadback();
const traceSteps = sum / pixels;
const prepassSteps = tiles ? preSum / tiles : 0;
opts.onTraceStats?.({
traceSteps,
prepassSteps,
stepsPerPixel: traceSteps + (prepassSteps * tiles) / pixels,
});
};
let canvasW = 0;
let canvasH = 0;
let sizedFor: Level | undefined;
@ -228,9 +363,16 @@ export function createScene(opts: SceneOptions): Scene | null {
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);
// Without the prepass pass 1 reads a 1x1 zero start: every ray from the camera, as before.
startTex = usePrepass
? makeTarget(Math.ceil(sw / TILE), Math.ceil(sh / TILE), startFormat, gl.NEAREST)
: makeTarget(1, 1, startFormat, gl.NEAREST);
steps = opts.onTraceStats ? makeTarget(sw, sh, gl.R8, gl.NEAREST) : undefined;
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.solid, steps ? [solid, depth, steps] : [solid, depth]);
discardReadback();
attach(fbos.pre, [startTex]);
attach(fbos.atm, [atm]);
attach(fbos.hist[0], [hist[0]]);
attach(fbos.hist[1], [hist[1]]);
@ -274,10 +416,10 @@ export function createScene(opts: SceneOptions): Scene | null {
const row = finished.get(f) ?? {};
if (!disjoint) row[pass] = ns / 1e6;
finished.set(f, row);
if (Object.keys(row).length === PASSES.length) {
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);
const passMs = { prepass: 0, ...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,
@ -318,12 +460,26 @@ export function createScene(opts: SceneOptions): Scene | null {
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 pose = cameraPose(opts.fixedPull ?? 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 0: cone march at one ray per 8x8 tile of pass 1, the start distance for its rays.
if (usePrepass) {
timed('prepass', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre);
gl.viewport(0, 0, startTex.w, startTex.h);
const p = programs.prepass;
gl.useProgram(p);
setCommon(p, pose, time);
gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps);
gl.uniform2f(loc(p, 'u_tileHalf'), 0.5 / startTex.w, 0.5 / startTex.h);
draw();
});
}
// Pass 1: solid surfaces, writes colour and depth.
timed('terrain', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid);
@ -336,8 +492,10 @@ export function createScene(opts: SceneOptions): Scene | null {
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);
bindTex(0, startTex, p, 'u_start');
draw();
});
if (f % READBACK_EVERY === 0) startReadback(); // outside the timed span: not pass 1's cost
// Pass 2: atmosphere up to pass 1's depth, low-res.
timed('atmosphere', f, () => {
@ -393,6 +551,7 @@ export function createScene(opts: SceneOptions): Scene | null {
prev = pose;
resetHistory = false;
collect();
pollReadback();
};
const tick = (now: number) => {
@ -458,6 +617,7 @@ export function createScene(opts: SceneOptions): Scene | null {
wake();
return {
startFormat: halfStart ? 'RGBA16F' : 'RGBA32F',
login() {
if (still || disposed) return; // a still has no pull-back
loginAt = performance.now();
@ -468,9 +628,11 @@ export function createScene(opts: SceneOptions): Scene | null {
if (disposed) return;
stop();
freeTargets();
PASSES.forEach((p) => gl.deleteProgram(programs[p]));
passes.forEach((p) => gl.deleteProgram(programs[p]));
discardReadback();
[packSteps, packStart].forEach((b) => b && gl.deleteBuffer(b));
gl.deleteVertexArray(vao);
[fbos.solid, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb));
[fbos.solid, fbos.pre, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb));
},
};
}

View file

@ -2,6 +2,8 @@ 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 sdf from './shaders/sdf.glsl?raw';
import prepassFrag from './shaders/prepass.frag.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';
@ -9,7 +11,7 @@ import compositeFrag from './shaders/composite.frag.glsl?raw';
import { WORLD, worldDefines } from './world';
import { trimGlsl } from './cultmathTrim';
export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite';
export type Stage = 'prepass' | 'terrain' | 'atmosphere' | 'resolve' | 'composite';
const HEADER =
'#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n';
@ -17,6 +19,7 @@ const HEADER =
export const vertexSource = HEADER + fullscreenVert;
const stages: Record<Stage, string> = {
prepass: prepassFrag,
terrain: terrainFrag,
atmosphere: atmosphereFrag,
resolve: resolveFrag,
@ -25,10 +28,14 @@ const stages: Record<Stage, string> = {
/**
* 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.
* shared block, the distance bound (stages that trace), the stage. `defines` is whole `#define`
* lines (measurement flags) ahead of the world's. 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]}`;
export function fragmentSource(stage: Stage, defines = ''): string {
const solid = stage === 'terrain' || stage === 'prepass';
const erosion = solid && WORLD.phacelleGradBound > 0 ? phacelle : '';
const world = solid ? sdf : '';
const math = trimGlsl(cultmath, [erosion, common, world, stages[stage]]);
return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${stages[stage]}`;
}

View file

@ -0,0 +1,57 @@
// Pass 0: a cone march at one ray per tile of pass-1 pixels. Writes the distance along the tile's
// centre ray that no ray in the tile can have hit before, so pass 1 starts there instead of at the
// camera. Every step is safe for every ray of the tile: the cone's radius at t is the widest any
// ray strays from the centre ray, and the centre's distance bound minus that radius is a free ball
// around each ray's own point.
uniform int u_maxSteps;
uniform vec2 u_tileHalf; // half a tile in uv: the footprint of this texel, as pass 1 samples it
in vec2 v_uv;
layout(location = 0) out vec4 o_start; // r: start distance, W_FAR when the tile misses; g: steps spent (a half holds both)
void main()
{
vec3 ro = u_camPos;
vec3 rd = camRay(v_uv);
// Widest stray of any ray in the tile from the centre ray, per unit t. The rays are the unit
// directions through a rectangle on the view plane, whose sublevel sets about rd are convex, so
// the maximum sits at a corner. The margin is for float rounding.
float spread = 0.0;
for (int c = 0; c < 4; c++)
{
vec2 corner = vec2(c & 1, c >> 1) * 2.0 - 1.0;
spread = max(spread, length(camRay(v_uv + corner * u_tileHalf) - rd));
}
spread *= 1.05;
// Past tEnd every ray of the tile is above W_MAX_HEIGHT and still rising (or beyond W_FAR):
// none can hit anything.
float rise = rd.y - spread;
float tEnd = W_FAR;
if (rise > 0.0)
tEnd = min(W_FAR, max(0.0, (W_MAX_HEIGHT - ro.y) / rise));
float t = 0.0;
float leafness;
bool onTree;
int evals = 0;
for (int i = 0; i < u_maxSteps && t < tEnd; i++)
{
evals++;
// Pass 1's hit tolerance, 0.0006 t + 0.01, is kept clear at the far end of the step too, so
// no ray of the tile starts inside its own hit radius.
float s = (sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01)) / 1.0006;
if (s <= 0.0)
break;
t += s;
}
float start = t;
#ifdef START_HALF
// The target is RGBA16F: the write rounds to a half, and the round may go up. Any start past
// the true one is a ray that could skip a surface, so shave more than half a ulp off first
// (START_HALF_SCALE, START_HALF_BIAS: Scene.ts). W_FAR stays exact, an integer a half holds.
start = max(0.0, t * START_HALF_SCALE - START_HALF_BIAS);
#endif
o_start = vec4(t >= tEnd ? W_FAR : start, float(evals), 0.0, 0.0);
}

View file

@ -0,0 +1,95 @@
// The solid world as a distance bound: terrain graph plus tree. Shared by the pass-1 trace and the
// cone prepass, so both step by the same bound.
// ---- 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);
}

View file

@ -5,128 +5,49 @@ uniform vec3 u_rock;
uniform vec3 u_bark;
uniform vec3 u_leaf;
uniform int u_maxSteps;
uniform sampler2D u_start; // prepass: r = distance no ray of this pixel's tile hits before; W_FAR = tile misses
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);
}
#ifdef TRACE_STATS
layout(location = 2) out float o_steps; // distance evaluations this ray spent, over 255 (measurement only)
#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.
float t = texture(u_start, v_uv).r;
// Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. A tile the
// prepass proved empty arrives as W_FAR.
bool missAtOnce = t >= W_FAR;
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;
missAtOnce = missAtOnce || rd.y >= 0.0;
t = max(t, (W_MAX_HEIGHT - ro.y) / rd.y);
}
if (missAtOnce)
{
o_color = vec4(0.0);
o_depth = W_FAR;
#ifdef TRACE_STATS
o_steps = 0.0;
#endif
return;
}
bool hit = false;
bool onTree = false;
float leafness = 0.0;
int i = 0;
int evals = 0;
for (; i < u_maxSteps; i++)
{
evals++;
vec3 p = ro + rd * t;
float d = sceneDistance(p, leafness, onTree);
if (d < 0.0006 * t + 0.01)
@ -145,6 +66,9 @@ void main()
hit = true;
sceneDistance(ro + rd * t, leafness, onTree);
}
#ifdef TRACE_STATS
o_steps = float(evals) / 255.0;
#endif
if (!hit)
{