Merge pull request 'scene: cone prepass for pass 1, opt-in steps/pixel readout' (#12) from nibu/scene-trace-prepass into ratatoskr/estate-navigator
This commit is contained in:
commit
7e632e8605
11 changed files with 780 additions and 133 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
102
harness/scene/demo.ts
Normal 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();
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]}`;
|
||||
}
|
||||
|
|
|
|||
57
src/app/features/scene/shaders/prepass.frag.glsl
Normal file
57
src/app/features/scene/shaders/prepass.frag.glsl
Normal 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);
|
||||
}
|
||||
95
src/app/features/scene/shaders/sdf.glsl
Normal file
95
src/app/features/scene/shaders/sdf.glsl
Normal 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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue