scene: half-float prepass start target where EXT_color_buffer_float is missing; say why createScene is null
Phone Chrome without EXT_color_buffer_float returned null with one message. Rendering to RGBA16F only needs EXT_color_buffer_half_float, and every other target is already a half, so only the prepass start (RGBA32F) needed a fallback. In half mode the prepass writes t*(1-2^-10) - 2^-15, which stays below t after the half rounds to nearest, so pass 1 still starts at or before the true distance. W_FAR stays exact. onUnavailable names no-webgl2 vs neither float extension; Scene.startFormat and options.halfStart (force, for tests and the harness). The standalone page shows the reason, renderer, vendor, UA and extension list. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
50ffd90560
commit
7e05866437
5 changed files with 190 additions and 19 deletions
|
|
@ -33,7 +33,7 @@ esbuild
|
|||
path.join(out, 'index.html'),
|
||||
'<!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>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"></pre></div>'
|
||||
? '<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>'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
|
|
@ -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<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
|
||||
|
|
|
|||
|
|
@ -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<PassName, WebGLProgram>;
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue