scene: half-float prepass start target where EXT_color_buffer_float is missing; say why createScene is null
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled

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:
Nibu 2026-11-04 20:39:22 +00:00
commit 7e05866437
5 changed files with 190 additions and 19 deletions

View file

@ -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>'
);

View file

@ -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();

View file

@ -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

View file

@ -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();

View file

@ -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);
}