diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs
index 36e2307a..027a2006 100644
--- a/harness/scene/bundle.cjs
+++ b/harness/scene/bundle.cjs
@@ -33,7 +33,7 @@ esbuild
path.join(out, 'index.html'),
'
Ratatoskr scene: trace prepass' +
(demo
- ? ''
+ ? ''
: '') +
''
);
diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts
index ba88e507..40424ff5 100644
--- a/harness/scene/demo.ts
+++ b/harness/scene/demo.ts
@@ -1,17 +1,55 @@
// 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, TraceStats } from '../../src/app/features/scene/Scene';
+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;
@@ -29,15 +67,18 @@ function start() {
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),
});
- if (!scene) out.textContent = 'createScene returned null: no WebGL2 or float colour buffers';
+ dev.open = !scene;
}
setInterval(() => {
if (!scene) return;
- const lines = [`prepass ${box.checked ? 'on' : 'off'} pull ${pull.value}`];
+ 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);
@@ -56,5 +97,6 @@ setInterval(() => {
}, 500);
box.addEventListener('change', start);
+half.addEventListener('change', start);
pull.addEventListener('input', start);
start();
diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts
index c403018a..6493ad88 100644
--- a/src/app/features/scene/Scene.test.ts
+++ b/src/app/features/scene/Scene.test.ts
@@ -1,5 +1,6 @@
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';
@@ -7,16 +8,21 @@ 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;
@@ -185,6 +191,85 @@ describe('cone prepass', () => {
});
});
+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
diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts
index 740c5c0c..70580ab8 100644
--- a/src/app/features/scene/Scene.ts
+++ b/src/app/features/scene/Scene.ts
@@ -42,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. */
@@ -54,6 +71,10 @@ 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. */
@@ -79,6 +100,8 @@ export interface SceneOptions {
}
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;
@@ -146,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';
@@ -158,7 +181,17 @@ 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;
@@ -170,12 +203,13 @@ export function createScene(opts: SceneOptions): Scene | null {
const programs = {} as Record;
try {
passes.forEach((p) => {
- programs[p] = linkStage(
- gl,
- p,
- opts.patchSource,
- p === 'terrain' && opts.onTraceStats ? '#define TRACE_STATS 1\n' : undefined
- );
+ 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) => {
@@ -196,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);
@@ -329,8 +365,8 @@ export function createScene(opts: SceneOptions): Scene | null {
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), gl.RGBA32F, gl.NEAREST)
- : makeTarget(1, 1, gl.RGBA32F, gl.NEAREST);
+ ? 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)];
@@ -581,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();
diff --git a/src/app/features/scene/shaders/prepass.frag.glsl b/src/app/features/scene/shaders/prepass.frag.glsl
index 691cfba9..4ca21a2c 100644
--- a/src/app/features/scene/shaders/prepass.frag.glsl
+++ b/src/app/features/scene/shaders/prepass.frag.glsl
@@ -7,7 +7,7 @@ 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
+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()
{
@@ -46,5 +46,12 @@ void main()
break;
t += s;
}
- o_start = vec4(t >= tEnd ? W_FAR : t, float(evals), 0.0, 0.0);
+ 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);
}