diff --git a/harness/scene/README.md b/harness/scene/README.md index 382819ec..ef735d2e 100644 --- a/harness/scene/README.md +++ b/harness/scene/README.md @@ -8,6 +8,8 @@ Chromium. Not shipped, not run by CI. node drive.cjs shots # rest, mid pull-back and pulled-back screenshots node drive.cjs measure # GPU time per pass by timer query, two viewports, two levels node drive.cjs variants # the same with source patches (no erosion, no tree, value-only fbm) + node drive.cjs trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms + node drive.cjs diff # one frozen frame with and without the prepass, pixel diff and both PNGs node drive.cjs 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 diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs index 62a47078..027a2006 100644 --- a/harness/scene/bundle.cjs +++ b/harness/scene/bundle.cjs @@ -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 (esbuild from NODE_PATH) +// usage: node bundle.cjs [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'), - '' + 'Ratatoskr scene: trace prepass' + + (demo + ? '
device
' + : '') + + '' ); }); diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts new file mode 100644 index 00000000..40424ff5 --- /dev/null +++ b/harness/scene/demo.ts @@ -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 = { + '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(); diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs index 74c4a8b0..0f34191f 100644 --- a/harness/scene/drive.cjs +++ b/harness/scene/drive.cjs @@ -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) => { diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index be117a0a..ad4c13b1 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -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, diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index 25b7f08d..6493ad88 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -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 = {}; - 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, { 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[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); }); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 40ce300d..70580ab8 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -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; 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; - const total = PASSES.reduce((s, p) => s + passMs[p], 0); + const passMs = { prepass: 0, ...row } as Record; + 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)); }, }; } diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts index c7a2a03f..42631d57 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -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 = { + prepass: prepassFrag, terrain: terrainFrag, atmosphere: atmosphereFrag, resolve: resolveFrag, @@ -25,10 +28,14 @@ const stages: Record = { /** * 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]}`; } diff --git a/src/app/features/scene/shaders/prepass.frag.glsl b/src/app/features/scene/shaders/prepass.frag.glsl new file mode 100644 index 00000000..4ca21a2c --- /dev/null +++ b/src/app/features/scene/shaders/prepass.frag.glsl @@ -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); +} diff --git a/src/app/features/scene/shaders/sdf.glsl b/src/app/features/scene/shaders/sdf.glsl new file mode 100644 index 00000000..95e96bc2 --- /dev/null +++ b/src/app/features/scene/shaders/sdf.glsl @@ -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); +} diff --git a/src/app/features/scene/shaders/terrain.frag.glsl b/src/app/features/scene/shaders/terrain.frag.glsl index b8f2f718..5946cc4a 100644 --- a/src/app/features/scene/shaders/terrain.frag.glsl +++ b/src/app/features/scene/shaders/terrain.frag.glsl @@ -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) {