From 87c9ca60301b5f75eba6198b35db031b3421a4bf Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 18:26:23 +0000 Subject: [PATCH 1/6] scene: cone prepass starts pass-1 rays past empty space; opt-in steps/pixel readout Pass 0 marches one ray per 8x8 tile of pass 1, each step shrunk by the widest any ray of the tile strays from it, and stores the distance no ray of the tile can have hit before (W_FAR when the whole tile climbs out above the highest geometry). Pass 1 starts from it. The distance bound moves to sdf.glsl so both passes step by the same function. onTraceStats adds a step-count render target to pass 1 and reads it back every 8th frame through a pixel-pack buffer and fence, outside the timed span. Unset, neither the output nor the target exists. prepass:false is the baseline. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 11 +- src/app/features/scene/Scene.test.ts | 43 ++++- src/app/features/scene/Scene.ts | 153 ++++++++++++++++-- src/app/features/scene/shaderSource.ts | 19 ++- .../features/scene/shaders/prepass.frag.glsl | 49 ++++++ src/app/features/scene/shaders/sdf.glsl | 95 +++++++++++ .../features/scene/shaders/terrain.frag.glsl | 122 +++----------- 7 files changed, 369 insertions(+), 123 deletions(-) create mode 100644 src/app/features/scene/shaders/prepass.frag.glsl create mode 100644 src/app/features/scene/shaders/sdf.glsl diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index be117a0a..a189b803 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -31,11 +31,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 +52,10 @@ callbacks and the creature sizing rule. Nothing has drawn a creature. 4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES tonemap, dither. +`createScene({ onTraceStats })` measures the trace: pass 1 gets a step-count output and an extra +render target, read back every 8th frame through a pixel-pack buffer and a fence (never waited +on), and reports mean steps per pixel. Unset, neither the output nor the target exists. + ## The step bound, and what is not proven The trace steps by `(y - h(x, z)) / sqrt(1 + G^2)`, `G` the Lipschitz bound of the height field, diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index 25b7f08d..c90d1bba 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { createScene, ShaderCompileError } from './Scene'; import { LEVELS } from './budget'; +import { fragmentSource } from './shaderSource'; import { startBackgroundScene } from './index'; type Fn = Mock; @@ -94,7 +95,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 +133,44 @@ 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('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('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 +304,7 @@ describe('startBackgroundScene', () => { ); expect(scene).not.toBeNull(); runFrame(1000); - expect(calls.drawArrays).toHaveBeenCalledTimes(4); + expect(calls.drawArrays).toHaveBeenCalledTimes(5); expect(rafQueue).toHaveLength(0); }); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 40ce300d..9707a56b 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -10,8 +10,10 @@ const FRAME_INTERVAL_MS = 1000 / 30; const REDUCED_MOTION_FRAMES = 32; const MAX_DPR = 1.5; -export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; -const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite']; +export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite' | 'prepass'; +const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite', 'prepass']; +/** Pass 1 pixels per prepass tile edge. */ +const TILE = 8; export interface SceneStats { /** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */ @@ -21,6 +23,16 @@ export interface SceneStats { atmosphereSize: [number, number]; } +/** Trace cost over one finished readback: mean distance evaluations per pass-1 pixel. */ +export interface TraceStats { + /** Mean pass-1 steps per pixel. */ + traceSteps: number; + /** Mean prepass steps per tile; 0 without the prepass. */ + prepassSteps: number; + /** traceSteps plus the prepass's steps spread over the pixels of each tile. */ + stepsPerPixel: number; +} + /** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ export type SourcePatch = (stage: Stage, source: string) => string; @@ -48,10 +60,19 @@ export interface SceneOptions { 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; @@ -83,13 +104,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()!; @@ -143,13 +165,20 @@ export function createScene(opts: SceneOptions): Scene | null { GPU_DISJOINT_EXT: number; } | null; + const usePrepass = opts.prepass ?? true; + const passes = usePrepass ? PASSES : PASSES.filter((p) => p !== 'prepass'); const programs = {} as Record; try { - PASSES.forEach((p) => { - programs[p] = linkStage(gl, p, opts.patchSource); + passes.forEach((p) => { + programs[p] = linkStage( + gl, + p, + opts.patchSource, + p === 'terrain' && opts.onTraceStats ? '#define TRACE_STATS 1\n' : undefined + ); }); } catch (e) { - PASSES.forEach((p) => { + passes.forEach((p) => { if (programs[p]) gl.deleteProgram(programs[p]); }); if (!(e instanceof ShaderCompileError)) throw e; @@ -180,15 +209,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 +237,71 @@ 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.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); + 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 +325,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), gl.RGBA32F, gl.NEAREST) + : makeTarget(1, 1, gl.RGBA32F, 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 +378,10 @@ export function createScene(opts: SceneOptions): Scene | null { const row = finished.get(f) ?? {}; if (!disjoint) row[pass] = ns / 1e6; finished.set(f, row); - if (Object.keys(row).length === PASSES.length) { + if (Object.keys(row).length === passes.length) { finished.delete(f); - const passMs = row as Record; - const total = PASSES.reduce((s, p) => s + passMs[p], 0); + const passMs = { prepass: 0, ...row } as Record; + const total = passes.reduce((s, p) => s + passMs[p], 0); if (!opts.fixedLevel && ladder.push(total)) sizedFor = undefined; opts.onStats?.({ passMs, @@ -318,12 +422,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 +454,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 +513,7 @@ export function createScene(opts: SceneOptions): Scene | null { prev = pose; resetHistory = false; collect(); + pollReadback(); }; const tick = (now: number) => { @@ -468,9 +589,11 @@ export function createScene(opts: SceneOptions): Scene | null { if (disposed) return; stop(); freeTargets(); - PASSES.forEach((p) => gl.deleteProgram(programs[p])); + passes.forEach((p) => gl.deleteProgram(programs[p])); + discardReadback(); + [packSteps, packStart].forEach((b) => b && gl.deleteBuffer(b)); gl.deleteVertexArray(vao); - [fbos.solid, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb)); + [fbos.solid, fbos.pre, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb)); }, }; } diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts index c7a2a03f..42631d57 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -2,6 +2,8 @@ import cultmath from './shaders/CultMath.glsl?raw'; import phacelle from './shaders/CultMath.Phacelle.glsl?raw'; import common from './shaders/common.glsl?raw'; import fullscreenVert from './shaders/fullscreen.vert.glsl?raw'; +import sdf from './shaders/sdf.glsl?raw'; +import prepassFrag from './shaders/prepass.frag.glsl?raw'; import terrainFrag from './shaders/terrain.frag.glsl?raw'; import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw'; import resolveFrag from './shaders/resolve.frag.glsl?raw'; @@ -9,7 +11,7 @@ import compositeFrag from './shaders/composite.frag.glsl?raw'; import { WORLD, worldDefines } from './world'; import { trimGlsl } from './cultmathTrim'; -export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; +export type Stage = 'prepass' | 'terrain' | 'atmosphere' | 'resolve' | 'composite'; const HEADER = '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; @@ -17,6 +19,7 @@ const HEADER = export const vertexSource = HEADER + fullscreenVert; const stages: Record = { + prepass: prepassFrag, terrain: terrainFrag, atmosphere: atmosphereFrag, resolve: resolveFrag, @@ -25,10 +28,14 @@ const stages: Record = { /** * Fragment source for a stage: header, world defines, the part of CultMath the stage reaches, the - * shared block, the stage. Phacelle stays a separate file, added only where it is called. + * shared block, the distance bound (stages that trace), the stage. `defines` is whole `#define` + * lines (measurement flags) ahead of the world's. Phacelle stays a separate file, added only where + * it is called. */ -export function fragmentSource(stage: Stage): string { - const erosion = stage === 'terrain' && WORLD.phacelleGradBound > 0 ? phacelle : ''; - const math = trimGlsl(cultmath, [erosion, common, stages[stage]]); - return `${HEADER}${worldDefines()}\n${math}\n${erosion}\n${common}\n${stages[stage]}`; +export function fragmentSource(stage: Stage, defines = ''): string { + const solid = stage === 'terrain' || stage === 'prepass'; + const erosion = solid && WORLD.phacelleGradBound > 0 ? phacelle : ''; + const world = solid ? sdf : ''; + const math = trimGlsl(cultmath, [erosion, common, world, stages[stage]]); + return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${stages[stage]}`; } diff --git a/src/app/features/scene/shaders/prepass.frag.glsl b/src/app/features/scene/shaders/prepass.frag.glsl new file mode 100644 index 00000000..3fe840fc --- /dev/null +++ b/src/app/features/scene/shaders/prepass.frag.glsl @@ -0,0 +1,49 @@ +// 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 + +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 is kept clear, so a ray never starts inside its own hit radius. + float s = sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01); + if (s <= 0.0) + break; + t += s; + } + o_start = vec4(t >= tEnd ? W_FAR : t, float(evals), 0.0, 0.0); +} diff --git a/src/app/features/scene/shaders/sdf.glsl b/src/app/features/scene/shaders/sdf.glsl new file mode 100644 index 00000000..95e96bc2 --- /dev/null +++ b/src/app/features/scene/shaders/sdf.glsl @@ -0,0 +1,95 @@ +// The solid world as a distance bound: terrain graph plus tree. Shared by the pass-1 trace and the +// cone prepass, so both step by the same bound. + +// ---- terrain ------------------------------------------------------------------------------- + +float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); } + +// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap. +float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; } + +#if W_ERODE_ENABLED +CultPhasor terrainPhacelle(vec2 xz) +{ + // A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion. + return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM); +} +#endif + +// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the +// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated. +float terrainDistance(vec3 p) +{ + float m = terrainMask(p.xz); + // Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most + // of their steps here. + if (m <= 0.0) + return p.y * W_SLOPE_ENV_INV; + float hb = m * W_AMP * terrainFbm(p.xz); + float above = p.y - (hb + m * W_ERODE_AMP); + if (above > 0.0) + return above * W_SLOPE_ENV_INV; +#if W_ERODE_ENABLED + hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w; +#endif + return (p.y - hb) * W_SLOPE_FULL_INV; +} + +vec3 terrainNormal(vec2 xz) +{ + float r = length(xz); + float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r); + float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0); + float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0; + vec2 dir = r > 0.0 ? xz / r : vec2(0.0); + vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); + float f = fg.w; + vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; +#if W_ERODE_ENABLED + CultPhasor ph = terrainPhacelle(xz); + g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; +#endif + return normalize(vec3(-g.x, 1.0, -g.y)); +} + +// ---- world tree ---------------------------------------------------------------------------- + +float smin(float a, float b, float k) +{ + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * k * 0.25; +} + +// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz. +float treeDistance(vec3 p, out float leafness) +{ + vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0); + float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y); + leafness = 0.0; + if (bound > 8.0) + return bound; + + float ang = atan(p.z, p.x); + float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0); + float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15)); + float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7; + + vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R; + float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0)); + float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp; + canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L) + + float d = smin(trunk, canopy, 6.0); + leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0); + return d; +} + +// ---- trace --------------------------------------------------------------------------------- + +float sceneDistance(vec3 p, out float leafness, out bool onTree) +{ + float dt = terrainDistance(p); + float dr = treeDistance(p, leafness); + onTree = dr < dt; + return min(dt, dr); +} diff --git a/src/app/features/scene/shaders/terrain.frag.glsl b/src/app/features/scene/shaders/terrain.frag.glsl index b8f2f718..5946cc4a 100644 --- a/src/app/features/scene/shaders/terrain.frag.glsl +++ b/src/app/features/scene/shaders/terrain.frag.glsl @@ -5,128 +5,49 @@ uniform vec3 u_rock; uniform vec3 u_bark; uniform vec3 u_leaf; uniform int u_maxSteps; +uniform sampler2D u_start; // prepass: r = distance no ray of this pixel's tile hits before; W_FAR = tile misses in vec2 v_uv; layout(location = 0) out vec4 o_color; // rgb lit, a coverage layout(location = 1) out float o_depth; // hit distance along the ray, W_FAR on a miss - -// ---- terrain ------------------------------------------------------------------------------- - -float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); } - -// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap. -float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; } - -#if W_ERODE_ENABLED -CultPhasor terrainPhacelle(vec2 xz) -{ - // A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion. - return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM); -} +#ifdef TRACE_STATS +layout(location = 2) out float o_steps; // distance evaluations this ray spent, over 255 (measurement only) #endif -// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the -// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated. -float terrainDistance(vec3 p) -{ - float m = terrainMask(p.xz); - // Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most - // of their steps here. - if (m <= 0.0) - return p.y * W_SLOPE_ENV_INV; - float hb = m * W_AMP * terrainFbm(p.xz); - float above = p.y - (hb + m * W_ERODE_AMP); - if (above > 0.0) - return above * W_SLOPE_ENV_INV; -#if W_ERODE_ENABLED - hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w; -#endif - return (p.y - hb) * W_SLOPE_FULL_INV; -} - -vec3 terrainNormal(vec2 xz) -{ - float r = length(xz); - float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r); - float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0); - float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0; - vec2 dir = r > 0.0 ? xz / r : vec2(0.0); - vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); - float f = fg.w; - vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; -#if W_ERODE_ENABLED - CultPhasor ph = terrainPhacelle(xz); - g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; -#endif - return normalize(vec3(-g.x, 1.0, -g.y)); -} - -// ---- world tree ---------------------------------------------------------------------------- - -float smin(float a, float b, float k) -{ - float h = max(k - abs(a - b), 0.0) / k; - return min(a, b) - h * h * k * 0.25; -} - -// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz. -float treeDistance(vec3 p, out float leafness) -{ - vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0); - float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y); - leafness = 0.0; - if (bound > 8.0) - return bound; - - float ang = atan(p.z, p.x); - float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0); - float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15)); - float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7; - - vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R; - float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0)); - float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp; - canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L) - - float d = smin(trunk, canopy, 6.0); - leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0); - return d; -} - // ---- trace --------------------------------------------------------------------------------- -float sceneDistance(vec3 p, out float leafness, out bool onTree) -{ - float dt = terrainDistance(p); - float dr = treeDistance(p, leafness); - onTree = dr < dt; - return min(dt, dr); -} - void main() { vec3 ro = u_camPos; vec3 rd = camRay(v_uv); - float t = 0.0; - // Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. + float t = texture(u_start, v_uv).r; + // Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. A tile the + // prepass proved empty arrives as W_FAR. + bool missAtOnce = t >= W_FAR; if (ro.y > W_MAX_HEIGHT) { - if (rd.y >= 0.0) - { - o_color = vec4(0.0); - o_depth = W_FAR; - return; - } - t = (W_MAX_HEIGHT - ro.y) / rd.y; + missAtOnce = missAtOnce || rd.y >= 0.0; + t = max(t, (W_MAX_HEIGHT - ro.y) / rd.y); + } + if (missAtOnce) + { + o_color = vec4(0.0); + o_depth = W_FAR; +#ifdef TRACE_STATS + o_steps = 0.0; +#endif + return; } bool hit = false; bool onTree = false; float leafness = 0.0; int i = 0; + int evals = 0; for (; i < u_maxSteps; i++) { + evals++; vec3 p = ro + rd * t; float d = sceneDistance(p, leafness, onTree); if (d < 0.0006 * t + 0.01) @@ -145,6 +66,9 @@ void main() hit = true; sceneDistance(ro + rd * t, leafness, onTree); } +#ifdef TRACE_STATS + o_steps = float(evals) / 255.0; +#endif if (!hit) { -- 2.47.3 From b055b2a5af2080a64b62a8642d4b0482f54d3714 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 18:26:23 +0000 Subject: [PATCH 2/6] harness: trace and diff modes, standalone demo page with steps/pixel readout trace: steps per pixel and pass ms at three fixed camera poses, PREPASS=0,1 arms. diff: one frozen frame with and without the prepass, against a 512-step render. bundle.cjs demo: full-window page with a prepass switch and the readout. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/README.md | 2 + harness/scene/bundle.cjs | 11 ++- harness/scene/demo.ts | 59 ++++++++++++++++ harness/scene/drive.cjs | 145 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 harness/scene/demo.ts diff --git a/harness/scene/README.md b/harness/scene/README.md index 382819ec..ef735d2e 100644 --- a/harness/scene/README.md +++ b/harness/scene/README.md @@ -8,6 +8,8 @@ Chromium. Not shipped, not run by CI. node drive.cjs shots # rest, mid pull-back and pulled-back screenshots node drive.cjs measure # GPU time per pass by timer query, two viewports, two levels node drive.cjs variants # the same with source patches (no erosion, no tree, value-only fbm) + node drive.cjs trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms + node drive.cjs diff # one frozen frame with and without the prepass, pixel diff and both PNGs node drive.cjs phacelle # the Phacelle gradient probe behind world.ts `phacelleGradBound` Needs `esbuild` and `playwright@1.48.2` on `NODE_PATH` and a Chromium. The first run was done on a diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs index 62a47078..36e2307a 100644 --- a/harness/scene/bundle.cjs +++ b/harness/scene/bundle.cjs @@ -1,9 +1,10 @@ // Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html. -// usage: node bundle.cjs (esbuild from NODE_PATH) +// usage: node bundle.cjs [demo] (esbuild from NODE_PATH). `demo` bundles the standalone page. const esbuild = require('esbuild'); const fs = require('fs'); const path = require('path'); const out = process.argv[2]; +const demo = process.argv[3] === 'demo'; fs.mkdirSync(out, { recursive: true }); const rawPlugin = { name: 'raw', @@ -20,7 +21,7 @@ const rawPlugin = { }; esbuild .build({ - entryPoints: [path.join(__dirname, 'entry.ts')], + entryPoints: [path.join(__dirname, demo ? 'demo.ts' : 'entry.ts')], bundle: true, outfile: path.join(out, 'bundle.js'), plugins: [rawPlugin], @@ -30,6 +31,10 @@ esbuild .then(() => { fs.writeFileSync( path.join(out, 'index.html'), - '' + 'Ratatoskr scene: trace prepass' + + (demo + ? '
' + : '') + + '' ); }); diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts new file mode 100644 index 00000000..507692d7 --- /dev/null +++ b/harness/scene/demo.ts @@ -0,0 +1,59 @@ +// 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 { LEVELS } from '../../src/app/features/scene/budget'; + +const 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 timerless = (() => { + const g = document.createElement('canvas').getContext('webgl2'); + return !g?.getExtension('EXT_disjoint_timer_query_webgl2'); +})(); + +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); + scene = createScene({ + canvas: fresh, + unthrottled: true, + fixedLevel: LEVELS[0], + fixedPull: +pull.value, + prepass: box.checked, + onStats: (s) => (stats = s), + onTraceStats: (t) => (trace = t), + }); + if (!scene) out.textContent = 'createScene returned null: no WebGL2 or float colour buffers'; +} + +setInterval(() => { + if (!scene) return; + const lines = [`prepass ${box.checked ? 'on' : 'off'} 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); +pull.addEventListener('input', start); +start(); diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs index 74c4a8b0..0f34191f 100644 --- a/harness/scene/drive.cjs +++ b/harness/scene/drive.cjs @@ -3,6 +3,7 @@ // Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report. const { chromium } = require('playwright'); const path = require('path'); +const fs = require('fs'); const [, , dir, mode, outdir = '.'] = process.argv; (async () => { @@ -15,6 +16,7 @@ const [, , dir, mode, outdir = '.'] = process.argv; ], }); const page = await b.newPage({ viewport: { width: 960, height: 540 } }); + page.on('pageerror', (e) => process.stderr.write(`[pageerror] ${e}\n`)); page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`)); await page.goto('file://' + path.resolve(dir, 'index.html')); const report = { mode }; @@ -156,6 +158,149 @@ const [, , dir, mode, outdir = '.'] = process.argv; } } + if (mode === 'trace') { + // Steps per pixel and pass ms at fixed camera poses, one scene per run. PREPASS=0,1 picks the arms. + const arms = (process.env.PREPASS || '1').split(',').map((x) => x === '1'); + report.runs = []; + for (const [w, h] of process.env.PORTRAIT === '0' + ? [[960, 540]] + : [ + [960, 540], + [540, 1200], + ]) { + for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { + for (const prepass of arms) { + await page.setViewportSize({ width: w, height: h }); + const r = await page.evaluate( + ({ pull, prepass, FRAMES }) => + new Promise((res) => { + const rows = []; + const trace = []; + let done = false; + const finish = () => { + if (done || rows.length < FRAMES || trace.length < 4) return; + done = true; + s.dispose(); + const keep = rows.slice(FRAMES >> 2); + const med = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1]; + const tk = trace.slice(1); + const mean = (k) => tk.reduce((a, t) => a + t[k], 0) / tk.length; + res({ + solid: keep[0].solidSize, + readbacks: tk.length, + traceSteps: +mean('traceSteps').toFixed(2), + prepassSteps: +mean('prepassSteps').toFixed(2), + stepsPerPixel: +mean('stepsPerPixel').toFixed(2), + medianMs: Object.fromEntries( + Object.keys(keep[0].passMs).map((k) => [k, +med(keep.map((x) => x.passMs[k])).toFixed(2)]) + ), + }); + }; + const s = window.__scene.createScene({ + canvas: document.getElementById('c'), + unthrottled: true, + fixedLevel: window.__scene.LEVELS[0], + fixedPull: pull, + prepass, + onTraceStats: (t) => { + trace.push(t); + finish(); + }, + onStats: (st) => { + rows.push(st); + finish(); + }, + }); + if (!s) res({ error: 'createScene returned null' }); + }), + { pull, prepass, FRAMES: +(process.env.FRAMES || 64) } + ); + report.runs.push({ viewport: [w, h], pull, prepass, ...r }); + } + } + } + } + + if (mode === 'diff') { + // Same frozen frame (reduced motion: time 0, 32 frames) with and without the prepass, compared + // pixel by pixel. The prepass is meant to change cost, not the picture. + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 640, height: 360 }); + const shoot = async (pull, prepass, steps) => { + await page.evaluate( + ({ pull, prepass, steps }) => { + // Reduced motion bounds the scene to 32 frames; each asks for the next with rAF, so the + // count stops moving when it is done. Timer-query callbacks lag too far to count on. + window.__raf = 0; + if (!window.__rafPatched) { + window.__rafPatched = true; + const raf = window.requestAnimationFrame.bind(window); + window.requestAnimationFrame = (cb) => (window.__raf++, raf(cb)); + } + window.__d = window.__scene.createScene({ + canvas: document.getElementById('c'), + fixedLevel: { ...window.__scene.LEVELS[0], traceSteps: steps }, + fixedPull: pull, + prepass, + }); + }, + { pull, prepass, steps } + ); + for (let last = -1, still = 0; still < 3; await page.waitForTimeout(1000)) { + const n = await page.evaluate(() => window.__raf); + still = n === last ? still + 1 : 0; + last = n; + } + const png = await page.screenshot(); + await page.evaluate(() => window.__d.dispose()); + return png.toString('base64'); + }; + // The step cap makes a grazing ray give up and count as a hit, so the baseline is compared + // with a 512-step render of the same frame as well: whichever is closer to it is the more + // faithful one. + const decode = (x) => + page.evaluate( + (x) => + new Promise((r) => { + const i = new Image(); + i.onload = () => { + const c = document.createElement('canvas'); + c.width = i.width; + c.height = i.height; + const g = c.getContext('2d'); + g.drawImage(i, 0, 0); + r(Array.from(g.getImageData(0, 0, i.width, i.height).data)); + }; + i.src = 'data:image/png;base64,' + x; + }), + x + ); + const compare = (da, db) => { + let max = 0, over = 0, sum = 0; + const px = da.length / 4; + for (let i = 0; i < da.length; i += 4) { + let m = 0; + for (let k = 0; k < 3; k++) m = Math.max(m, Math.abs(da[i + k] - db[i + k])); + sum += m; + if (m > max) max = m; + if (m > 2) over++; + } + return { maxChannelDiff: max, pixelsOver2of255: over, ofPixels: px, meanMaxChannelDiff: +(sum / px).toFixed(3) }; + }; + report.diffs = []; + for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { + const png = { + off: await shoot(pull, false, 96), + on: await shoot(pull, true, 96), + ref: await shoot(pull, false, 512), + off2: await shoot(pull, false, 96), + }; + for (const k of Object.keys(png)) fs.writeFileSync(path.join(outdir, `diff-${k}-${pull}.png`), Buffer.from(png[k], 'base64')); + const [off, on, ref, off2] = [await decode(png.off), await decode(png.on), await decode(png.ref), await decode(png.off2)]; + report.diffs.push({ pull, offVsOffAgain: compare(off, off2), offVsOn: compare(off, on), offVsRef512: compare(off, ref), onVsRef512: compare(on, ref) }); + } + } + console.log(JSON.stringify(report, null, 1)); await b.close(); })().catch((e) => { -- 2.47.3 From 12469671c9870f801f04ada621768b6207aa6fb0 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 18:35:11 +0000 Subject: [PATCH 3/6] scene: pack step bytes tightly; keep the hit tolerance clear at the far end of a prepass step Soul pass on 87c9ca60: the R8 steps read used the default PACK_ALIGNMENT of 4, so at widths not divisible by 4 the buffer was too small (readPixels fails) and padding would be summed. Portrait runs read 0 steps/pixel. The prepass step now divides by 1.0006 so pass 1's tolerance, which grows with t, stays clear at the end of the step. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/Scene.test.ts | 14 ++++++++++++++ src/app/features/scene/Scene.ts | 2 ++ src/app/features/scene/shaders/prepass.frag.glsl | 5 +++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index c90d1bba..c403018a 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -162,6 +162,20 @@ describe('cone prepass', () => { 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]; diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 9707a56b..740c5c0c 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -254,6 +254,7 @@ export function createScene(opts: SceneOptions): Scene | null { }; 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); @@ -268,6 +269,7 @@ export function createScene(opts: SceneOptions): Scene | null { 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, diff --git a/src/app/features/scene/shaders/prepass.frag.glsl b/src/app/features/scene/shaders/prepass.frag.glsl index 3fe840fc..691cfba9 100644 --- a/src/app/features/scene/shaders/prepass.frag.glsl +++ b/src/app/features/scene/shaders/prepass.frag.glsl @@ -39,8 +39,9 @@ void main() for (int i = 0; i < u_maxSteps && t < tEnd; i++) { evals++; - // Pass 1's hit tolerance is kept clear, so a ray never starts inside its own hit radius. - float s = sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01); + // 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; -- 2.47.3 From 50ffd905602fcbbeae564caf31978e6a4912c208 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 18:47:12 +0000 Subject: [PATCH 4/6] harness: demo page keeps hold of the canvas it swaps in, so the prepass switch works twice Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/demo.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts index 507692d7..ba88e507 100644 --- a/harness/scene/demo.ts +++ b/harness/scene/demo.ts @@ -3,7 +3,7 @@ import { createScene, Scene, SceneStats, TraceStats } from '../../src/app/features/scene/Scene'; import { LEVELS } from '../../src/app/features/scene/budget'; -const canvas = document.getElementById('c') as HTMLCanvasElement; +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; @@ -22,6 +22,7 @@ function start() { // 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, -- 2.47.3 From 7e0586643757bd798091bdfdac5a419293b2566d Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 20:39:22 +0000 Subject: [PATCH 5/6] 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 --- harness/scene/bundle.cjs | 2 +- harness/scene/demo.ts | 48 +++++++++- src/app/features/scene/Scene.test.ts | 91 ++++++++++++++++++- src/app/features/scene/Scene.ts | 57 ++++++++++-- .../features/scene/shaders/prepass.frag.glsl | 11 ++- 5 files changed, 190 insertions(+), 19 deletions(-) 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 - ? '
' + ? '
device
' : '') + '' ); 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); } -- 2.47.3 From 293885089a5af6790a3e1a69e8daf2d9181e17ef Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 20:39:58 +0000 Subject: [PATCH 6/6] scene README: createScene needs either float colour-buffer extension Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index a189b803..ad4c13b1 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -17,8 +17,9 @@ callbacks and the creature sizing rule. Nothing has drawn a creature. demand, so it stays out of the main chunk; if that chunk fails to load the error goes to `onCompileFail` and the door returns null, it does not throw. - `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns - `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 or - `EXT_color_buffer_float` is missing, or a shader fails to compile (`onCompileFail` gets the + `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 is missing, neither + `EXT_color_buffer_float` nor `EXT_color_buffer_half_float` exists (`onUnavailable` says which; with + only the half extension the prepass start target is RGBA16F, written rounded down), or a shader fails to compile (`onCompileFail` gets the `ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to -- 2.47.3