From 61567a16fc101ac21ec891eb6b9094c7e9bfd95a Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 21:36:30 +0000 Subject: [PATCH 1/7] scene: frame cadence moves to frameLoop.ts so a second scene can share it Cap, visibility pause, reduced-motion bound, still, wake on resize and context loss were inline in createScene. Moved verbatim into startFrameLoop; Scene.ts keeps only what it owns (history reset on resume, ladder step on a missed cap). No behaviour change: the scene tests pass unchanged. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/Scene.ts | 92 +++++------------------ src/app/features/scene/frameLoop.ts | 111 ++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 73 deletions(-) create mode 100644 src/app/features/scene/frameLoop.ts diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 70580ab8..daacd608 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -1,13 +1,12 @@ import { fragmentSource, vertexSource, Stage } from './shaderSource'; import { cameraPose, pullProgress, CameraPose } from './camera'; import { BudgetLadder, Level, LEVELS } from './budget'; +import { startFrameLoop } from './frameLoop'; import type { SceneTier } from './tier'; import { PALETTE } from './palette'; /** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */ export const GPU_BUDGET_MS = 4.0; -const FRAME_INTERVAL_MS = 1000 / 30; -const REDUCED_MOTION_FRAMES = 32; const MAX_DPR = 1.5; export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite' | 'prepass'; @@ -379,16 +378,11 @@ export function createScene(opts: SceneOptions): Scene | null { resetHistory = true; }; - const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); const start = performance.now(); let loginAt: number | undefined; let frame = 0; let histIndex = 0; let prev: CameraPose | undefined; - let raf = 0; - let lastRendered = -Infinity; - let framesLeft = still ? 1 : Infinity; // a still frame, or reduced motion, bounds this - let disposed = false; // Timer queries in flight: each holds one pass's query. const pending: Array<{ q: WebGLQuery; pass: PassName; frame: number }> = []; @@ -456,7 +450,7 @@ export function createScene(opts: SceneOptions): Scene | null { const render = (now: number) => { ensureSize(); const lv = level(); - const frozen = reducedMotion?.matches ?? false; + const frozen = loop.frozen; const time = frozen ? 0 : (now - start) / 1000; let elapsed: number | undefined; if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000; @@ -554,79 +548,31 @@ export function createScene(opts: SceneOptions): Scene | null { pollReadback(); }; - const tick = (now: number) => { - raf = 0; - if (disposed) return; - const due = opts.unthrottled || now - lastRendered >= FRAME_INTERVAL_MS - 2; - if (due) { - if ( - !opts.fixedLevel && - !timer && - lastRendered > 0 && - now - lastRendered > FRAME_INTERVAL_MS * 1.5 - ) { - // no timer: a missed cap counts as over budget - if (ladder.push(GPU_BUDGET_MS * 2)) sizedFor = undefined; - } - lastRendered = now; - render(now); - framesLeft -= 1; - } - if (framesLeft > 0 && (opts.unthrottled || document.visibilityState === 'visible')) - raf = requestAnimationFrame(tick); - }; - - const wake = () => { - if (disposed || raf) return; - if (reducedMotion?.matches && framesLeft <= 0) return; - resetHistory = true; // the first frame after a pause has a real elapsed dt - raf = requestAnimationFrame(tick); - }; - const undo: Array<() => void> = []; - const listen = (target: EventTarget, type: string, handler: (e: Event) => void) => { - target.addEventListener(type, handler); - undo.push(() => target.removeEventListener(type, handler)); - }; - const stop = () => { - disposed = true; - if (raf) cancelAnimationFrame(raf); - undo.forEach((f) => f()); - }; - if (!opts.unthrottled) { - listen(document, 'visibilitychange', () => { - if (document.visibilityState === 'visible') wake(); - }); - } - listen(window, 'resize', () => wake()); - // A lost context takes every texture and program with it and nothing here rebuilds them, so the - // scene ends and the host decides what to show. preventDefault: otherwise the browser never - // restores the context, which is what we want, and a restore would find a dead scene. - listen(canvas, 'webglcontextlost', (e) => { - e.preventDefault(); - stop(); - opts.onContextLost?.(); + const loop = startFrameLoop({ + canvas, + still, + unthrottled: opts.unthrottled, + render, + onSlow: () => { + // no timer: a missed cap counts as over budget + if (!opts.fixedLevel && !timer && ladder.push(GPU_BUDGET_MS * 2)) sizedFor = undefined; + }, + onResume: () => { + resetHistory = true; // the first frame after a pause has a real elapsed dt + }, + onContextLost: opts.onContextLost, }); - if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES; - // The preference can flip while the scene lives: freeze to a bounded run, or thaw to a free one. - if (reducedMotion && !still) { - listen(reducedMotion, 'change', () => { - framesLeft = reducedMotion.matches ? REDUCED_MOTION_FRAMES : Infinity; - wake(); - }); - } - wake(); return { startFormat: halfStart ? 'RGBA16F' : 'RGBA32F', login() { - if (still || disposed) return; // a still has no pull-back + if (still || loop.disposed) return; // a still has no pull-back loginAt = performance.now(); - if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; - wake(); + loop.restart(); }, dispose() { - if (disposed) return; - stop(); + if (loop.disposed) return; + loop.stop(); freeTargets(); passes.forEach((p) => gl.deleteProgram(programs[p])); discardReadback(); diff --git a/src/app/features/scene/frameLoop.ts b/src/app/features/scene/frameLoop.ts new file mode 100644 index 00000000..b310f30b --- /dev/null +++ b/src/app/features/scene/frameLoop.ts @@ -0,0 +1,111 @@ +// The frame cadence both scenes share: a 30 fps cap, a pause while the tab is hidden, a bounded run +// under reduced motion, one frame for a still, and an end for good when the context is lost. + +/** Cap: a scene draws at most this often. */ +export const FRAME_INTERVAL_MS = 1000 / 30; +/** Frames a reduced-motion scene draws after it starts or after login(), then stops. */ +const REDUCED_MOTION_FRAMES = 32; + +export interface FrameLoopOptions { + canvas: HTMLCanvasElement; + /** Draw one frame and stop. */ + still: boolean; + /** Ignore the cap and the visibility pause (measurement). */ + unthrottled?: boolean; + render(now: number): void; + /** A frame arrived well after the cap allows: the device is not keeping up. */ + onSlow?(): void; + /** Frames are about to start again after a pause (first start, tab visible, resize, thaw). */ + onResume?(): void; + /** The WebGL context was lost. The loop has already stopped for good. */ + onContextLost?(): void; +} + +export interface FrameLoop { + /** The user prefers reduced motion right now. */ + readonly frozen: boolean; + readonly disposed: boolean; + /** Start frames if none are scheduled. */ + wake(): void; + /** A new bounded run under reduced motion, then wake. A still ignores it. */ + restart(): void; + stop(): void; +} + +export function startFrameLoop(o: FrameLoopOptions): FrameLoop { + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + let raf = 0; + let lastRendered = -Infinity; + let framesLeft = o.still ? 1 : Infinity; // a still frame, or reduced motion, bounds this + let disposed = false; + + const tick = (now: number) => { + raf = 0; + if (disposed) return; + const due = o.unthrottled || now - lastRendered >= FRAME_INTERVAL_MS - 2; + if (due) { + if (lastRendered > 0 && now - lastRendered > FRAME_INTERVAL_MS * 1.5) o.onSlow?.(); + lastRendered = now; + o.render(now); + framesLeft -= 1; + } + if (framesLeft > 0 && (o.unthrottled || document.visibilityState === 'visible')) + raf = requestAnimationFrame(tick); + }; + + const wake = () => { + if (disposed || raf) return; + if (reducedMotion?.matches && framesLeft <= 0) return; + o.onResume?.(); + raf = requestAnimationFrame(tick); + }; + const undo: Array<() => void> = []; + const listen = (target: EventTarget, type: string, handler: (e: Event) => void) => { + target.addEventListener(type, handler); + undo.push(() => target.removeEventListener(type, handler)); + }; + const stop = () => { + disposed = true; + if (raf) cancelAnimationFrame(raf); + undo.forEach((f) => f()); + }; + if (!o.unthrottled) { + listen(document, 'visibilitychange', () => { + if (document.visibilityState === 'visible') wake(); + }); + } + listen(window, 'resize', () => wake()); + // A lost context takes every texture and program with it and nothing here rebuilds them, so the + // scene ends and the host decides what to show. preventDefault: otherwise the browser never + // restores the context, which is what we want, and a restore would find a dead scene. + listen(o.canvas, 'webglcontextlost', (e) => { + e.preventDefault(); + stop(); + o.onContextLost?.(); + }); + if (reducedMotion?.matches && !o.still) framesLeft = REDUCED_MOTION_FRAMES; + // The preference can flip while the scene lives: freeze to a bounded run, or thaw to a free one. + if (reducedMotion && !o.still) { + listen(reducedMotion, 'change', () => { + framesLeft = reducedMotion.matches ? REDUCED_MOTION_FRAMES : Infinity; + wake(); + }); + } + wake(); + + return { + get frozen() { + return reducedMotion?.matches ?? false; + }, + get disposed() { + return disposed; + }, + wake, + restart() { + if (o.still || disposed) return; + if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; + wake(); + }, + stop, + }; +} -- 2.47.3 From f6d48fbac016ab43534f5e2899c1c48de3a13f57 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 21:53:52 +0000 Subject: [PATCH 2/7] scene: shader linking in link.ts, BudgetLadder takes its levels, test GL fake shared, SceneStyle in the config Groundwork for a second scene behind the same createScene contract: the compile/link path and ShaderCompileError leave Scene.ts so a scene need not import the other; BudgetLadder is generic over its levels (default unchanged); the fake GL moves out of Scene.test.ts; backgroundScene.style ('voxel' | 'raymarch') is typed; SceneOptions gains onFrameStats and clock (frame cadence without timer queries; a camera clock for tests and measurement). No behaviour change. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/Scene.test.ts | 66 +++++------------------- src/app/features/scene/Scene.ts | 75 +++++++--------------------- src/app/features/scene/budget.ts | 25 +++++++--- src/app/features/scene/fakeGl.ts | 52 +++++++++++++++++++ src/app/features/scene/link.ts | 55 ++++++++++++++++++++ src/app/features/scene/tier.ts | 4 ++ 6 files changed, 161 insertions(+), 116 deletions(-) create mode 100644 src/app/features/scene/fakeGl.ts create mode 100644 src/app/features/scene/link.ts diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index 6493ad88..12b611fc 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -1,58 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { createScene, ShaderCompileError, HALF_START_BIAS, HALF_START_SCALE } from './Scene'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fakeCanvas, fakeGl } from './fakeGl'; +import { createScene, HALF_START_BIAS, HALF_START_SCALE } from './Scene'; +import { ShaderCompileError } from './link'; import { WORLD } from './world'; import { LEVELS } from './budget'; import { fragmentSource } from './shaderSource'; import { startBackgroundScene } from './index'; -type Fn = Mock; - -/** A GL context where every call is a spy and every constant is 1. Compiles succeed until `failFrom`. */ -function fakeGl(failFrom = Infinity, missing: string[] = []) { - let compiles = 0; - const calls: Record = {}; - 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; - }); - } - if (prop === 'getShaderParameter') return spy(prop, () => compiles < failFrom); - if (prop === 'getShaderInfoLog') return spy(prop, () => 'ERROR: 0:1: bad'); - if (prop === 'getProgramParameter') return spy(prop, () => true); - return spy(prop); - }, - }); - return { gl: gl as unknown as WebGL2RenderingContext, calls }; -} - -function fakeCanvas(gl: WebGL2RenderingContext | null) { - const canvas = new EventTarget() as EventTarget & { - clientWidth: number; - clientHeight: number; - width: number; - height: number; - getContext: Fn; - }; - canvas.clientWidth = 400; - canvas.clientHeight = 300; - canvas.width = 0; - canvas.height = 0; - canvas.getContext = vi.fn(() => gl); - return canvas; -} - let rafQueue: Array<(t: number) => void>; let cancelled: number; let win: EventTarget; @@ -397,7 +351,7 @@ describe('startBackgroundScene', () => { it('applies the device ceiling: saveData runs a still, one frame', async () => { const { gl, calls } = fakeGl(); const scene = await startBackgroundScene( - { tier: 'full' }, + { tier: 'full', style: 'raymarch' }, { saveData: true }, { canvas: asCanvas(fakeCanvas(gl)) } ); @@ -416,13 +370,19 @@ describe('startBackgroundScene', () => { const { startBackgroundScene: start } = await import('./index'); const onCompileFail = vi.fn(); const canvas = fakeCanvas(fakeGl().gl); - const scene = await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas), onCompileFail }); + const scene = await start( + { tier: 'full', style: 'raymarch' }, + {}, + { canvas: asCanvas(canvas), onCompileFail } + ); expect(scene).toBeNull(); expect(onCompileFail).toHaveBeenCalledTimes(1); expect(onCompileFail.mock.calls[0][0]).toBeInstanceOf(Error); expect(canvas.getContext).not.toHaveBeenCalled(); // and with no callback it still does not throw - expect(await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas) })).toBeNull(); + expect( + await start({ tier: 'full', style: 'raymarch' }, {}, { canvas: asCanvas(canvas) }) + ).toBeNull(); } finally { vi.doUnmock('./Scene'); vi.resetModules(); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index daacd608..49eb8ece 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -1,4 +1,5 @@ -import { fragmentSource, vertexSource, Stage } from './shaderSource'; +import { Stage } from './shaderSource'; +import { linkStage, ShaderCompileError, SourcePatch } from './link'; import { cameraPose, pullProgress, CameraPose } from './camera'; import { BudgetLadder, Level, LEVELS } from './budget'; import { startFrameLoop } from './frameLoop'; @@ -22,6 +23,17 @@ export interface SceneStats { atmosphereSize: [number, number]; } +/** The voxel scene's per-frame figures, with no extension needed. */ +export interface FrameStats { + /** Milliseconds since the previous frame: the cadence, 33 ms at the 30 fps cap. */ + frameMs: number; + /** Milliseconds one frame took to draw and read back; only when the host asked for trace stats, which forces a sync. */ + renderMs?: number; + /** Drawing-buffer size as a fraction of the canvas's CSS size times device pixel ratio. */ + scale: number; + size: [number, number]; +} + /** Trace cost over one finished readback: mean distance evaluations per pass-1 pixel. */ export interface TraceStats { /** Mean pass-1 steps per pixel. */ @@ -32,15 +44,6 @@ export interface TraceStats { 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; - -export class ShaderCompileError extends Error { - constructor(readonly stage: Stage | 'vertex', readonly log: string) { - super(`${stage} shader failed to compile:\n${log}`); - } -} - /** * 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 @@ -91,6 +94,10 @@ export interface SceneOptions { fixedLevel?: Level; /** Cone prepass that starts pass-1 rays past empty space. Default true; false is the measurement baseline. */ prepass?: boolean; + /** Voxel scene: per finished frame, the cadence and the drawing-buffer scale. */ + onFrameStats?: (s: FrameStats) => void; + /** Voxel scene: seconds for the camera path, instead of the clock (measurement, tests). */ + clock?: () => number; /** 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). */ @@ -99,57 +106,13 @@ export interface SceneOptions { } export interface Scene { - /** Texture format of the prepass start target. */ - readonly startFormat: 'RGBA32F' | 'RGBA16F'; + /** Texture format of the ray-march scene's prepass start target; the voxel scene has none. */ + readonly startFormat?: 'RGBA32F' | 'RGBA16F'; /** Start the camera pull-back from the tree. */ login(): void; dispose(): void; } -function compile( - gl: WebGL2RenderingContext, - type: number, - source: string, - stage: Stage | 'vertex' -): WebGLShader { - const sh = gl.createShader(type)!; - gl.shaderSource(sh, source); - gl.compileShader(sh); - if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { - const log = gl.getShaderInfoLog(sh) ?? ''; - gl.deleteShader(sh); - throw new ShaderCompileError(stage, log); - } - return sh; -} - -export function linkStage( - gl: WebGL2RenderingContext, - stage: Stage, - 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, defines)) : fragmentSource(stage, defines), - stage - ); - const prog = gl.createProgram()!; - gl.attachShader(prog, vs); - gl.attachShader(prog, fs); - gl.linkProgram(prog); - gl.deleteShader(vs); - gl.deleteShader(fs); - if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { - const log = gl.getProgramInfoLog(prog) ?? ''; - gl.deleteProgram(prog); - throw new ShaderCompileError(stage, log); - } - return prog; -} - interface Target { tex: WebGLTexture; w: number; diff --git a/src/app/features/scene/budget.ts b/src/app/features/scene/budget.ts index 3b373d86..1da204bf 100644 --- a/src/app/features/scene/budget.ts +++ b/src/app/features/scene/budget.ts @@ -19,7 +19,7 @@ export const LEVELS: readonly Level[] = [ ]; /** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */ -export class BudgetLadder { +export class BudgetLadder { private level: number; private recent: number[] = []; @@ -28,15 +28,26 @@ export class BudgetLadder { private readonly window: number; - /** `startLevel` lets a weak device begin below level 0; it is clamped into the ladder. */ - constructor(budgetMs: number, window = 45, startLevel = 0) { + private readonly levels: readonly L[]; + + /** + * `startLevel` lets a weak device begin below level 0; it is clamped into the ladder. `levels` + * defaults to the ray-march ladder; another scene passes its own. + */ + constructor( + budgetMs: number, + window = 45, + startLevel = 0, + levels: readonly L[] = LEVELS as unknown as readonly L[] + ) { this.budgetMs = budgetMs; this.window = window; - this.level = Math.min(Math.max(startLevel, 0), LEVELS.length - 1); + this.levels = levels; + this.level = Math.min(Math.max(startLevel, 0), levels.length - 1); } - get current(): Level { - return LEVELS[this.level]; + get current(): L { + return this.levels[this.level]; } get index(): number { @@ -50,7 +61,7 @@ export class BudgetLadder { const sorted = [...this.recent].sort((a, b) => a - b); const median = sorted[Math.floor(sorted.length / 2)]; this.recent = []; - if (median > this.budgetMs && this.level < LEVELS.length - 1) { + if (median > this.budgetMs && this.level < this.levels.length - 1) { this.level += 1; return true; } diff --git a/src/app/features/scene/fakeGl.ts b/src/app/features/scene/fakeGl.ts new file mode 100644 index 00000000..2989f6c5 --- /dev/null +++ b/src/app/features/scene/fakeGl.ts @@ -0,0 +1,52 @@ +// A GL context for tests: every call a spy, so a scene can be driven without a GPU. +import { Mock, vi } from 'vitest'; + +type Fn = Mock; + +/** A GL context where every call is a spy and every constant is 1. Compiles succeed until `failFrom`. */ +export function fakeGl(failFrom = Infinity, missing: string[] = []) { + let compiles = 0; + const calls: Record = {}; + 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) { + // texture formats must be told apart; every other constant is 1 + if (prop === 'RGBA32F') return 32; + if (prop === 'RGBA16F') return 16; + if (prop === 'RG8') return 208; + if (prop === 'RGBA8') return 209; + 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; + }); + } + if (prop === 'getShaderParameter') return spy(prop, () => compiles < failFrom); + if (prop === 'getShaderInfoLog') return spy(prop, () => 'ERROR: 0:1: bad'); + if (prop === 'getProgramParameter') return spy(prop, () => true); + return spy(prop); + }, + }); + return { gl: gl as unknown as WebGL2RenderingContext, calls }; +} + +export function fakeCanvas(gl: WebGL2RenderingContext | null) { + const canvas = new EventTarget() as EventTarget & { + clientWidth: number; + clientHeight: number; + width: number; + height: number; + getContext: Fn; + }; + canvas.clientWidth = 400; + canvas.clientHeight = 300; + canvas.width = 0; + canvas.height = 0; + canvas.getContext = vi.fn(() => gl); + return canvas; +} diff --git a/src/app/features/scene/link.ts b/src/app/features/scene/link.ts new file mode 100644 index 00000000..2590ec3c --- /dev/null +++ b/src/app/features/scene/link.ts @@ -0,0 +1,55 @@ +// Compiling and linking a stage: the one place a shader failure becomes a ShaderCompileError. +import { fragmentSource, vertexSource, Stage } from './shaderSource'; + +/** 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; + +export class ShaderCompileError extends Error { + constructor(readonly stage: Stage | 'vertex', readonly log: string) { + super(`${stage} shader failed to compile:\n${log}`); + } +} + +function compile( + gl: WebGL2RenderingContext, + type: number, + source: string, + stage: Stage | 'vertex' +): WebGLShader { + const sh = gl.createShader(type)!; + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(sh) ?? ''; + gl.deleteShader(sh); + throw new ShaderCompileError(stage, log); + } + return sh; +} + +export function linkStage( + gl: WebGL2RenderingContext, + stage: Stage, + 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, defines)) : fragmentSource(stage, defines), + stage + ); + const prog = gl.createProgram()!; + gl.attachShader(prog, vs); + gl.attachShader(prog, fs); + gl.linkProgram(prog); + gl.deleteShader(vs); + gl.deleteShader(fs); + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(prog) ?? ''; + gl.deleteProgram(prog); + throw new ShaderCompileError(stage, log); + } + return prog; +} diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts index 1c1668c7..94a59a57 100644 --- a/src/app/features/scene/tier.ts +++ b/src/app/features/scene/tier.ts @@ -15,8 +15,12 @@ export interface Capabilities { hardwareConcurrency?: number; } +/** 'voxel': the simple heightfield terrain (default). 'raymarch': the sphere-traced world with its tree and clouds. */ +export type SceneStyle = 'voxel' | 'raymarch'; + export interface BackgroundSceneConfig { tier?: SceneTier; + style?: SceneStyle; } export function readCapabilities(nav: Navigator): Capabilities { -- 2.47.3 From a40a85b19e3f72f4af9a42bceab87a3774f0665f Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 21:53:52 +0000 Subject: [PATCH 3/7] scene: voxel terrain scene, the default simple background createVoxelScene: same contract as createScene, WebGL2 core only (no extension, no float target). A 256x256 wrapping ring of cell heights (RG8, 16 bit) regenerated one column or row at a time as the camera crosses cells (Phacelle terrain in world space, a pure function of the cell); one fullscreen pass marching each screen column near to far, at most 128 lookups, none for sky; baked sky texture with cloud bands; free-yaw rail camera; drawing-buffer scale ladder on frame cadence. The door's style defaults to voxel; raymarch stays selectable. Harness: drive.cjs voxel mode, standalone page with lookups/pixel, frame ms, device panel. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/README.md | 1 + harness/scene/bundle.cjs | 15 +- harness/scene/demo.ts | 29 +- harness/scene/device.ts | 31 ++ harness/scene/drive.cjs | 59 ++++ harness/scene/entry.ts | 5 +- harness/scene/voxelDemo.ts | 72 +++++ src/app/features/scene/index.ts | 11 +- src/app/features/scene/shaderSource.ts | 22 +- .../features/scene/shaders/voxel.frag.glsl | 94 ++++++ .../scene/shaders/voxelHeight.frag.glsl | 22 ++ .../features/scene/shaders/voxelSky.frag.glsl | 25 ++ src/app/features/scene/voxel/Voxel.test.ts | 210 +++++++++++++ src/app/features/scene/voxel/Voxel.ts | 286 ++++++++++++++++++ src/app/features/scene/voxel/rails.test.ts | 43 +++ src/app/features/scene/voxel/rails.ts | 38 +++ src/app/features/scene/voxel/ring.test.ts | 94 ++++++ src/app/features/scene/voxel/ring.ts | 66 ++++ src/app/features/scene/voxel/voxelWorld.ts | 50 +++ 19 files changed, 1132 insertions(+), 41 deletions(-) create mode 100644 harness/scene/device.ts create mode 100644 harness/scene/voxelDemo.ts create mode 100644 src/app/features/scene/shaders/voxel.frag.glsl create mode 100644 src/app/features/scene/shaders/voxelHeight.frag.glsl create mode 100644 src/app/features/scene/shaders/voxelSky.frag.glsl create mode 100644 src/app/features/scene/voxel/Voxel.test.ts create mode 100644 src/app/features/scene/voxel/Voxel.ts create mode 100644 src/app/features/scene/voxel/rails.test.ts create mode 100644 src/app/features/scene/voxel/rails.ts create mode 100644 src/app/features/scene/voxel/ring.test.ts create mode 100644 src/app/features/scene/voxel/ring.ts create mode 100644 src/app/features/scene/voxel/voxelWorld.ts diff --git a/harness/scene/README.md b/harness/scene/README.md index ef735d2e..f6f5cf40 100644 --- a/harness/scene/README.md +++ b/harness/scene/README.md @@ -9,6 +9,7 @@ Chromium. Not shipped, not run by CI. 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 voxel # voxel scene: lookups per pixel and ms per frame at fixed poses, POSES=pull:seconds SCALES=0.75,0.5 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` diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs index 027a2006..189318ca 100644 --- a/harness/scene/bundle.cjs +++ b/harness/scene/bundle.cjs @@ -1,10 +1,13 @@ // Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html. -// usage: node bundle.cjs [demo] (esbuild from NODE_PATH). `demo` bundles the standalone page. +// usage: node bundle.cjs [demo|voxel] (esbuild from NODE_PATH). `demo` bundles the ray-march +// standalone page, `voxel` the voxel scene's. const esbuild = require('esbuild'); const fs = require('fs'); const path = require('path'); const out = process.argv[2]; -const demo = process.argv[3] === 'demo'; +const kind = process.argv[3]; +const demo = kind === 'demo'; +const voxel = kind === 'voxel'; fs.mkdirSync(out, { recursive: true }); const rawPlugin = { name: 'raw', @@ -21,7 +24,7 @@ const rawPlugin = { }; esbuild .build({ - entryPoints: [path.join(__dirname, demo ? 'demo.ts' : 'entry.ts')], + entryPoints: [path.join(__dirname, demo ? 'demo.ts' : voxel ? 'voxelDemo.ts' : 'entry.ts')], bundle: true, outfile: path.join(out, 'bundle.js'), plugins: [rawPlugin], @@ -31,8 +34,10 @@ esbuild .then(() => { fs.writeFileSync( path.join(out, 'index.html'), - 'Ratatoskr scene: trace prepass' + - (demo + 'Ratatoskr scene' + + (voxel + ? '
device
' + : demo ? '
device
' : '') + '' diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts index 40424ff5..f259bba8 100644 --- a/harness/scene/demo.ts +++ b/harness/scene/demo.ts @@ -4,10 +4,10 @@ import { createScene, Scene, SceneStats, - SceneUnavailable, TraceStats, } from '../../src/app/features/scene/Scene'; import { LEVELS } from '../../src/app/features/scene/budget'; +import { describeDevice, WHY } from './device'; let canvas = document.getElementById('c') as HTMLCanvasElement; const out = document.getElementById('out') as HTMLPreElement; @@ -21,33 +21,6 @@ const timerless = (() => { 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; diff --git a/harness/scene/device.ts b/harness/scene/device.ts new file mode 100644 index 00000000..51f3c0d1 --- /dev/null +++ b/harness/scene/device.ts @@ -0,0 +1,31 @@ +// What a standalone page tells the reader about the browser, so a phone that cannot run the scene +// says why on screen. Not shipped. +import type { SceneUnavailable } from '../../src/app/features/scene/Scene'; + +export 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. +export 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'); +} diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs index 0f34191f..64889b8c 100644 --- a/harness/scene/drive.cjs +++ b/harness/scene/drive.cjs @@ -221,6 +221,65 @@ const [, , dir, mode, outdir = '.'] = process.argv; } } + if (mode === 'voxel') { + // The voxel scene at fixed poses (POSES=pull:seconds,...) and internal scales (SCALES=0.75,0.5): + // lookups per pixel, and the median ms to draw one frame (a one-texel sync read), two viewports. + report.runs = []; + for (const [w, h] of process.env.PORTRAIT === '0' + ? [[960, 540]] + : [ + [960, 540], + [540, 1200], + ]) { + for (const pose of (process.env.POSES || '0:0,0:60,1:60').split(',')) { + for (const scale of (process.env.SCALES || '0.75').split(',').map(Number)) { + await page.setViewportSize({ width: w, height: h }); + const [pull, t] = pose.split(':').map(Number); + const r = await page.evaluate( + ({ pull, t, scale }) => + new Promise((res) => { + const ms = []; + const looks = []; + let size; + const done = () => { + s.dispose(); + const med = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1]; + res({ + size, + readbacks: looks.length, + lookupsPerPixel: +(looks.slice(1).reduce((a, b) => a + b, 0) / (looks.length - 1)).toFixed(2), + medianRenderMs: +med(ms.slice(1)).toFixed(1), + }); + }; + const s = window.__scene.createVoxelScene({ + canvas: document.getElementById('c'), + unthrottled: true, + fixedPull: pull, + clock: () => t, + fixedLevel: { solidScale: scale }, + onTraceStats: (x) => { + looks.push(x.traceSteps); + if (looks.length >= 12) done(); + }, + onFrameStats: (x) => { + size = x.size; + if (x.renderMs !== undefined) ms.push(x.renderMs); + }, + }); + if (!s) res({ error: 'createVoxelScene returned null' }); + }), + { pull, t, scale } + ); + report.runs.push({ viewport: [w, h], pull, t, scale, ...r }); + await page.evaluate(() => { + const c = document.getElementById('c'); + c.replaceWith(c.cloneNode()); + }); + } + } + } + } + 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. diff --git a/harness/scene/entry.ts b/harness/scene/entry.ts index d43a5453..305e8c7d 100644 --- a/harness/scene/entry.ts +++ b/harness/scene/entry.ts @@ -1,11 +1,14 @@ // Browser entry for the harness: exposes the scene's seams to the driver. Not shipped. -import { createScene, linkStage, ShaderCompileError } from '../../src/app/features/scene/Scene'; +import { createScene } from '../../src/app/features/scene/Scene'; +import { createVoxelScene } from '../../src/app/features/scene/voxel/Voxel'; +import { linkStage, ShaderCompileError } from '../../src/app/features/scene/link'; import { LEVELS } from '../../src/app/features/scene/budget'; import { fragmentSource } from '../../src/app/features/scene/shaderSource'; import { WORLD } from '../../src/app/features/scene/world'; (window as unknown as Record).__scene = { createScene, + createVoxelScene, linkStage, ShaderCompileError, LEVELS, diff --git a/harness/scene/voxelDemo.ts b/harness/scene/voxelDemo.ts new file mode 100644 index 00000000..ca046d11 --- /dev/null +++ b/harness/scene/voxelDemo.ts @@ -0,0 +1,72 @@ +// Standalone page: the voxel scene full-window with a readout of lookups per pixel and frame ms, +// the internal scale, and what the browser says about itself. Not shipped. +import { createVoxelScene, SCALE_LEVELS } from '../../src/app/features/scene/voxel/Voxel'; +import type { FrameStats, Scene, TraceStats } from '../../src/app/features/scene/Scene'; +import { LEVELS } from '../../src/app/features/scene/budget'; +import { describeDevice, WHY } from './device'; + +let canvas = document.getElementById('c') as HTMLCanvasElement; +const out = document.getElementById('out') as HTMLPreElement; +const pull = document.getElementById('pull') as HTMLInputElement; +const scaleSel = document.getElementById('scale') as HTMLInputElement; +const scaleName = document.getElementById('scaleName') as HTMLSpanElement; +/** The slider's positions: the ladder's own, or auto. */ +const SCALES = ['auto', ...SCALE_LEVELS.map((l) => String(l.scale))]; +const scaleChoice = () => SCALES[+scaleSel.value]; +const measure = document.getElementById('measure') as HTMLInputElement; +const dev = document.getElementById('dev') as HTMLDetailsElement; +const info = document.getElementById('info') as HTMLPreElement; +info.textContent = describeDevice(); + +let scene: Scene | null = null; +let trace: TraceStats | undefined; +let frames: FrameStats | undefined; +let drawMs: number | undefined; +let cadence = 0; + +function start() { + scene?.dispose(); + trace = frames = drawMs = undefined; + cadence = 0; + // A fresh canvas: dispose leaves the old context bound to the old one. + const fresh = canvas.cloneNode() as HTMLCanvasElement; + canvas.replaceWith(fresh); + canvas = fresh; + out.textContent = ''; + scene = createVoxelScene({ + canvas: fresh, + fixedPull: +pull.value, + fixedLevel: + scaleChoice() === 'auto' ? undefined : { ...LEVELS[0], solidScale: +scaleChoice() }, + onTraceStats: measure.checked ? (t) => (trace = t) : undefined, + onFrameStats: (s) => { + frames = s; + cadence = cadence ? cadence * 0.9 + s.frameMs * 0.1 : s.frameMs; + if (s.renderMs !== undefined) drawMs = s.renderMs; + }, + onUnavailable: (r) => (out.textContent = `createVoxelScene returned null: ${WHY[r]}`), + onCompileFail: (e) => (out.textContent = `createVoxelScene returned null: ${e.message}`), + }); + dev.open = !scene; +} + +setInterval(() => { + if (!scene) return; + const lines = [`voxel pull ${pull.value} scale ${scaleChoice()}`]; + if (frames) { + lines.push( + `frame ms ${cadence.toFixed(1)} (cadence; the cap is 33.3) buffer ${frames.size.join('x')} at scale ${frames.scale}` + ); + } + if (trace) lines.push(`lookups/pixel ${trace.traceSteps.toFixed(2)} draw ms ${drawMs?.toFixed(1)} (one frame in 8, synced)`); + else if (measure.checked) lines.push('lookups/pixel: waiting for the first counted frame'); + out.textContent = lines.join('\n'); +}, 500); + +pull.addEventListener('input', start); +scaleSel.addEventListener('input', () => { + scaleName.textContent = scaleChoice(); + start(); +}); +measure.addEventListener('change', start); +start(); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index c896649e..09803283 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -2,7 +2,7 @@ import type { Scene, SceneOptions } from './Scene'; import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier'; export type { Scene, SceneStats } from './Scene'; -export type { SceneTier } from './tier'; +export type { SceneStyle, SceneTier } from './tier'; export type StartOptions = Omit & { /** A shader failed to compile, or the scene chunk failed to load. The host keeps its ground. */ @@ -12,7 +12,7 @@ export type StartOptions = Omit { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - let createScene: typeof import('./Scene').createScene; + let createScene: (o: SceneOptions) => Scene | null; try { - ({ createScene } = await import('./Scene')); + createScene = + config?.style === 'raymarch' + ? (await import('./Scene')).createScene + : (await import('./voxel/Voxel')).createVoxelScene; } catch (e) { options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); return null; diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts index 42631d57..5e569364 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -8,10 +8,21 @@ import terrainFrag from './shaders/terrain.frag.glsl?raw'; import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw'; import resolveFrag from './shaders/resolve.frag.glsl?raw'; import compositeFrag from './shaders/composite.frag.glsl?raw'; +import voxelHeightFrag from './shaders/voxelHeight.frag.glsl?raw'; +import voxelSkyFrag from './shaders/voxelSky.frag.glsl?raw'; +import voxelFrag from './shaders/voxel.frag.glsl?raw'; import { WORLD, worldDefines } from './world'; import { trimGlsl } from './cultmathTrim'; -export type Stage = 'prepass' | 'terrain' | 'atmosphere' | 'resolve' | 'composite'; +export type Stage = + | 'prepass' + | 'terrain' + | 'atmosphere' + | 'resolve' + | 'composite' + | 'voxelHeight' + | 'voxelSky' + | 'voxel'; const HEADER = '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; @@ -24,6 +35,9 @@ const stages: Record = { atmosphere: atmosphereFrag, resolve: resolveFrag, composite: compositeFrag, + voxelHeight: voxelHeightFrag, + voxelSky: voxelSkyFrag, + voxel: voxelFrag, }; /** @@ -33,9 +47,11 @@ const stages: Record = { * it is called. */ export function fragmentSource(stage: Stage, defines = ''): string { - const solid = stage === 'terrain' || stage === 'prepass'; + const solid = stage === 'terrain' || stage === 'prepass' || stage === 'voxelHeight'; 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]}`; + return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${ + stages[stage] + }`; } diff --git a/src/app/features/scene/shaders/voxel.frag.glsl b/src/app/features/scene/shaders/voxel.frag.glsl new file mode 100644 index 00000000..93c162f7 --- /dev/null +++ b/src/app/features/scene/shaders/voxel.frag.glsl @@ -0,0 +1,94 @@ +// One pass, straight to the canvas: for every pixel, march down its screen column from the camera +// outward, one height lookup per step, until a column of terrain rises past the pixel. +// +// Projection is a shear, as in the classic voxel renderer: a point at depth z along the view axis +// whose terrain top is h projects to ndc y = horizon + (h - camY) / (z * tanHalf.y). A pixel below +// the horizon is covered by the first sample whose top reaches it, which is the same pixel a +// running-horizon column renderer paints, found without carrying the horizon between pixels. The +// camera is above the highest terrain, so nothing is covered above the horizon (no lookups at all), +// and below it no sample shallower than (camY - VOX_HB) / (tanHalf.y * -rel) can reach the pixel, +// so the march starts there. +uniform sampler2D u_height; // RG8, the ring of 16-bit heights +uniform sampler2D u_sky; // baked sky, gamma-encoded +// u_camPos and u_tanHalf (x: tan(fov/2) * aspect, y: tan(fov/2)) come from the shared block. +uniform vec2 u_fwd; // unit forward on the ground plane (x, z) +uniform vec2 u_right; // unit right on the ground plane +uniform float u_horizon; // ndc y of the horizon +uniform vec3 u_grass; +uniform vec3 u_rock; +uniform float u_frame; + +in vec2 v_uv; +layout(location = 0) out vec4 o_color; // rgb gamma-encoded; with TRACE_STATS, a: lookups spent / 255 + +float cellHeight(ivec2 c) +{ + vec2 t = floor(texelFetch(u_height, c & (VOX_RING - 1), 0).rg * 255.0 + 0.5); + return mix(-VOX_HB, VOX_HB, (t.x * 256.0 + t.y) / 65535.0); +} + +vec3 skyAt(float az, float el) +{ + vec3 c = texture(u_sky, vec2(az * 0.15915494 + 0.5, el * 0.63661977)).rgb; + return pow(c, vec3(2.2)); +} + +void main() +{ + vec2 ndc = v_uv * 2.0 - 1.0; + vec2 d = u_fwd + u_right * (ndc.x * u_tanHalf.x); // ground direction per unit of view depth + float dl = length(d); + float rel = ndc.y - u_horizon; // ndc height above the horizon + float az = atan(d.y, d.x); + float camY = u_camPos.y; + float zFar = VOX_FAR / dl; + + int lookups = 0; + float z = 0.0; + float h = 0.0; + ivec2 cell = ivec2(0); + bool hit = false; + if (rel < 0.0) + { + z = max(VOX_NEAR, (camY - VOX_HB) / (-rel * u_tanHalf.y)); + for (int i = 0; i < VOX_MAX_LOOKUPS; i++) + { + if (z >= zFar) + break; + cell = ivec2(floor((u_camPos.xz + d * z) * (1.0 / VOX_CELL))); + h = cellHeight(cell); + lookups++; + if (rel <= (h - camY) / (z * u_tanHalf.y)) + { + hit = true; + break; + } + z += max(VOX_STEP_MIN, z * VOX_STEP_REL); + } + } + + vec3 horizonCol = skyAt(az, 0.0); + vec3 col; + if (hit) + { + float hx = cellHeight(cell + ivec2(1, 0)); + float hz = cellHeight(cell + ivec2(0, 1)); + vec3 n = normalize(vec3((h - hx) / VOX_CELL, 1.0, (h - hz) / VOX_CELL)); + float lit = max(dot(n, u_sunDir), 0.0); + float high = smoothstep(0.1, 0.9, (h + VOX_HB) / (2.0 * VOX_HB)); + vec3 albedo = mix(u_grass, u_rock, clamp((1.0 - n.y) * 4.0 + high * 0.6, 0.0, 1.0)); + col = albedo * (u_skyTop * 5.0 + u_fogColor * 2.0 + u_sunColor * (0.3 * lit)); + col = mix(col, horizonCol * 0.6 + u_fogColor, 1.0 - exp(-z * dl * VOX_FOG)); + } + else + { + float el = rel > 0.0 ? atan(rel * u_tanHalf.y, dl) : 0.0; + col = skyAt(az, el); + } + + float dither = (ign(gl_FragCoord.xy, u_frame) - 0.5) / 255.0; + o_color = vec4(pow(clamp(col, 0.0, 1.0), vec3(1.0 / 2.2)) + dither, 1.0); +#ifdef TRACE_STATS + o_color.a = float(lookups) / 255.0; +#endif +} diff --git a/src/app/features/scene/shaders/voxelHeight.frag.glsl b/src/app/features/scene/shaders/voxelHeight.frag.glsl new file mode 100644 index 00000000..b0295d4f --- /dev/null +++ b/src/app/features/scene/shaders/voxelHeight.frag.glsl @@ -0,0 +1,22 @@ +// Ring generation: writes the height of the world cell each texel of the ring holds. A pure function +// of the cell, so any order of writes gives the same ring. The camera's draw call scissors this to +// the columns and rows it just admitted. +uniform ivec2 u_origin; // lowest world cell of the ring's window + +in vec2 v_uv; +layout(location = 0) out vec4 o_height; // rg: height as 16 bits across [-VOX_HB, VOX_HB], high byte first + +void main() +{ + ivec2 texel = ivec2(gl_FragCoord.xy); + ivec2 cell = u_origin + ((texel - u_origin) & (VOX_RING - 1)); + vec2 xz = (vec2(cell) + 0.5) * VOX_CELL; + // The ray-march scene's terrain, unmasked: fbm envelope plus Phacelle ridges. + float h = W_AMP * terrainFbm(xz); +#if W_ERODE_ENABLED + h += W_ERODE_AMP * terrainPhacelle(xz).cos.w; +#endif + float q = floor(clamp((h + VOX_HB) / (2.0 * VOX_HB), 0.0, 1.0) * 65535.0 + 0.5); + float hi = floor(q / 256.0); + o_height = vec4(hi / 255.0, (q - hi * 256.0) / 255.0, 0.0, 1.0); +} diff --git a/src/app/features/scene/shaders/voxelSky.frag.glsl b/src/app/features/scene/shaders/voxelSky.frag.glsl new file mode 100644 index 00000000..2e0db3cf --- /dev/null +++ b/src/app/features/scene/shaders/voxelSky.frag.glsl @@ -0,0 +1,25 @@ +// Sky bake, run once: azimuth across, elevation up from the horizon to the zenith, with cloud +// bands. Stored gamma-encoded because the target is 8 bits. +in vec2 v_uv; +layout(location = 0) out vec4 o_sky; + +void main() +{ + float az = (v_uv.x - 0.5) * 6.2831853; + float el = v_uv.y * 1.5707963; + vec3 rd = vec3(cos(el) * cos(az), sin(el), cos(el) * sin(az)); + // Dusk: the horizon glow gives way to the night blue within the visible band, not at the zenith. + vec3 c = mix(u_skyHorizon, u_skyTop, smoothstep(0.0, 0.2, rd.y)); + c += u_sunColor * (0.5 * pow(max(dot(rd, u_sunDir), 0.0), 64.0)); + + // Bands: noise stretched along the horizon, layered by elevation. Taken from the direction + // vector so the seam at +-pi is seamless. + vec3 q = vec3(rd.x * 2.2, rd.y * 11.0, rd.z * 2.2); + float n = 0.5 + 0.5 * cultmath_snoise(q) + 0.25 * cultmath_snoise(q * 2.3 + 7.0); + float cover = smoothstep(0.62, 0.95, n) * smoothstep(0.02, 0.2, el) * (1.0 - smoothstep(0.55, 1.1, el)); + float sunSide = pow(max(dot(rd, u_sunDir), 0.0), 3.0); + vec3 cloud = u_skyHorizon * 0.9 + u_skyTop * 4.0 + u_sunColor * (0.07 * sunSide); + c = mix(c, cloud, cover * 0.8); + + o_sky = vec4(pow(clamp(c, 0.0, 1.0), vec3(1.0 / 2.2)), 1.0); +} diff --git a/src/app/features/scene/voxel/Voxel.test.ts b/src/app/features/scene/voxel/Voxel.test.ts new file mode 100644 index 00000000..acadfefc --- /dev/null +++ b/src/app/features/scene/voxel/Voxel.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fakeCanvas, fakeGl } from '../fakeGl'; +import heightStage from '../shaders/voxelHeight.frag.glsl?raw'; +import { fragmentSource } from '../shaderSource'; +import { startBackgroundScene } from '../index'; +import { createVoxelScene, SCALE_LEVELS } from './Voxel'; +import { VOXEL, voxelDefines } from './voxelWorld'; + +const N = VOXEL.ring; +let rafQueue: Array<(t: number) => void>; +let win: EventTarget; +let motion: EventTarget & { matches: boolean }; + +beforeEach(() => { + rafQueue = []; + motion = Object.assign(new EventTarget(), { matches: false }); + win = Object.assign(new EventTarget(), { devicePixelRatio: 1, matchMedia: () => motion }); + vi.stubGlobal('window', win); + vi.stubGlobal('document', Object.assign(new EventTarget(), { visibilityState: 'visible' })); + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); + vi.stubGlobal('cancelAnimationFrame', () => {}); +}); +afterEach(() => vi.unstubAllGlobals()); + +const runFrame = (now: number) => { + const cb = rafQueue.shift(); + if (!cb) throw new Error('no frame was scheduled'); + cb(now); +}; +const asCanvas = (c: ReturnType) => c as unknown as HTMLCanvasElement; + +/** A voxel scene on a fake GL whose camera follows `time.t`, seconds. */ +function build(extra: Partial[0]> = {}) { + const { gl, calls } = fakeGl(); + const canvas = fakeCanvas(gl); + const time = { t: 0 }; + const scene = createVoxelScene({ + canvas: asCanvas(canvas), + clock: () => time.t, + ...extra, + }); + return { scene, calls, canvas, time }; +} + +describe('voxel scene contract', () => { + it("'off' returns null without a context; no WebGL2 says so", () => { + const { gl } = fakeGl(); + const canvas = fakeCanvas(gl); + expect(createVoxelScene({ canvas: asCanvas(canvas), tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + const onUnavailable = vi.fn(); + expect(createVoxelScene({ canvas: asCanvas(fakeCanvas(null)), onUnavailable })).toBeNull(); + expect(onUnavailable).toHaveBeenCalledTimes(1); + expect(onUnavailable).toHaveBeenCalledWith('no-webgl2'); + }); + + it('needs no extension and allocates no float target: only RG8 and RGBA8 textures', () => { + const { calls } = build(); + runFrame(1000); + expect(calls.getExtension).toBeUndefined(); + const formats = calls.texStorage2D.mock.calls.map((c) => c[2]); + expect(formats.sort()).toEqual([208, 209]); // RG8 ring, RGBA8 sky + }); + + it('reports a compile failure through onCompileFail and returns null', () => { + const { gl } = fakeGl(2); + const onCompileFail = vi.fn(); + expect(createVoxelScene({ canvas: asCanvas(fakeCanvas(gl)), onCompileFail })).toBeNull(); + expect(onCompileFail).toHaveBeenCalledTimes(1); + }); + + it("'still' draws one frame and schedules no more; 'full' keeps going", () => { + const still = build({ tier: 'still' }); + runFrame(1000); + expect(rafQueue).toHaveLength(0); + expect(still.scene).not.toBeNull(); + rafQueue.length = 0; + build({ tier: 'full' }); + runFrame(1000); + expect(rafQueue).toHaveLength(1); + }); + + it('login() starts a pull-back and a lost context ends the scene', () => { + const lost = vi.fn(); + const { scene, canvas } = build({ onContextLost: lost }); + runFrame(1000); + scene?.login(); + canvas.dispatchEvent(new Event('webglcontextlost')); + expect(lost).toHaveBeenCalledTimes(1); + rafQueue.length = 0; + scene?.login(); + expect(rafQueue).toHaveLength(0); + }); +}); + +describe('voxel ring generation', () => { + it('the first frame bakes the sky, fills the whole ring once, and draws the terrain', () => { + const { calls } = build(); + runFrame(1000); + expect(calls.drawArrays).toHaveBeenCalledTimes(3); + expect(calls.scissor.mock.calls).toEqual([[0, 0, N, N]]); + }); + + it('a frame in the same cell generates nothing', () => { + const { calls } = build(); + runFrame(1000); + runFrame(1034); + expect(calls.drawArrays).toHaveBeenCalledTimes(4); // one more draw: the terrain + expect(calls.scissor).toHaveBeenCalledTimes(1); + }); + + it('crossing a cell writes strips, never the whole ring again', () => { + const { calls, time } = build(); + runFrame(1000); + time.t = 1; // 14 units along the rail: a cell or two + runFrame(1034); + const strips = calls.scissor.mock.calls.slice(1); + expect(strips.length).toBeGreaterThan(0); + for (const [, , w, h] of strips) { + expect((w === N) !== (h === N)).toBe(true); // one side the whole ring, the other a few cells + expect(Math.min(w, h)).toBeLessThanOrEqual(3); + } + }); + + it('a long pause jumps the camera past the ring and regenerates it whole', () => { + const { calls, time } = build(); + runFrame(1000); + time.t = 3600; // an hour on the rail + runFrame(1034); + expect(calls.scissor.mock.calls.at(-1)).toEqual([0, 0, N, N]); + }); +}); + +describe('voxel measurement and adaptation', () => { + it('reads lookups per pixel back from alpha and times the draw, only when asked', () => { + const plain = build(); + runFrame(1000); + expect(plain.calls.readPixels).toBeUndefined(); + rafQueue.length = 0; + const onTraceStats = vi.fn(); + const onFrameStats = vi.fn(); + const m = build({ onTraceStats, onFrameStats }); + runFrame(1000); + runFrame(1034); + expect(m.calls.readPixels).toHaveBeenCalledTimes(2); // frame 0 of every 8: a one-texel sync, then the counted frame + expect(onTraceStats).toHaveBeenCalledTimes(1); + expect(m.canvas.getContext.mock.calls[0][1].alpha).toBe(false); // the canvas is opaque; the count is drawn off screen + expect(onFrameStats).toHaveBeenCalledTimes(1); // the first frame has no predecessor + expect(onFrameStats.mock.calls[0][0].frameMs).toBe(34); + }); + + it('steps the drawing buffer down when frames keep missing the cap, and never back up', () => { + const { canvas } = build({ unthrottled: true }); + let now = 1000; + runFrame(now); + expect(canvas.width).toBe(Math.round(400 * SCALE_LEVELS[0].scale)); + for (let i = 0; i < 60; i++) runFrame((now += 100)); + expect(canvas.width).toBe(Math.round(400 * SCALE_LEVELS[1].scale)); + for (let i = 0; i < 200; i++) runFrame((now += 10)); + expect(canvas.width).toBe(Math.round(400 * SCALE_LEVELS[1].scale)); + }); + + it("'low' starts below the top of the ladder", () => { + const { canvas } = build({ tier: 'low' }); + runFrame(1000); + expect(canvas.width).toBe(Math.round(400 * SCALE_LEVELS[2].scale)); + }); +}); + +describe('voxel shaders', () => { + it('the march is capped at the lookup budget and the pass uses no extension', () => { + const src = fragmentSource('voxel', voxelDefines()); + expect(src).toContain(`#define VOX_MAX_LOOKUPS ${VOXEL.maxLookups}`); + expect(src).not.toContain('#extension'); + expect(src).toContain('texelFetch(u_height'); + }); + + it('ring generation is a pure function of the cell and uses the shipped terrain', () => { + const src = fragmentSource('voxelHeight', voxelDefines()); + expect(src).toContain('terrainFbm(xz)'); + expect(src).toContain('terrainPhacelle(xz)'); + expect(heightStage).not.toContain('u_time'); // nothing in the height depends on the clock + expect(src).toContain(`#define VOX_RING ${N}`); + }); +}); + +describe('the door picks the style', () => { + it('voxel by default: one frame is sky, ring and terrain, three draws', async () => { + const { gl, calls } = fakeGl(); + const scene = await startBackgroundScene( + { tier: 'still' }, + {}, + { canvas: asCanvas(fakeCanvas(gl)) } + ); + expect(scene).not.toBeNull(); + runFrame(1000); + expect(calls.drawArrays).toHaveBeenCalledTimes(3); + }); + + it("'raymarch' keeps the sphere-traced scene", async () => { + const { gl, calls } = fakeGl(); + await startBackgroundScene( + { tier: 'still', style: 'raymarch' }, + {}, + { canvas: asCanvas(fakeCanvas(gl)) } + ); + runFrame(1000); + expect(calls.drawArrays).toHaveBeenCalledTimes(5); + }); +}); diff --git a/src/app/features/scene/voxel/Voxel.ts b/src/app/features/scene/voxel/Voxel.ts new file mode 100644 index 00000000..bcfc94b7 --- /dev/null +++ b/src/app/features/scene/voxel/Voxel.ts @@ -0,0 +1,286 @@ +// The voxel terrain scene: a wrapping ring of cell heights around the camera, drawn by one fullscreen +// pass. Same createScene contract as the ray-march scene, but WebGL2 core only: no extensions, no +// float targets. Heights are 16 bits in an RG8 ring; the sky is baked once into RGBA8; the drawing +// buffer is the internal resolution, and CSS scales it up. +import type { FrameStats, Scene, SceneOptions } from '../Scene'; +import { BudgetLadder } from '../budget'; +import { pullProgress } from '../camera'; +import { FRAME_INTERVAL_MS, startFrameLoop } from '../frameLoop'; +import { linkStage, ShaderCompileError } from '../link'; +import { PALETTE } from '../palette'; +import { Cell, ringRects, windowOrigin } from './ring'; +import { voxelPose } from './rails'; +import { VOXEL, voxelDefines } from './voxelWorld'; + +const MAX_DPR = 1.5; +const SKY_W = 512; +const SKY_H = 128; +/** Measurement readback of lookups per pixel, every this many frames. */ +const READBACK_EVERY = 8; +/** Median cadence above this, over a window, drops one level: the 30 fps cap with some grace. */ +const SLOW_MS = FRAME_INTERVAL_MS * 1.2; + +export interface ScaleLevel { + /** Drawing-buffer size as a fraction of the canvas's CSS size times device pixel ratio. */ + scale: number; +} + +/** Cheapest last. The ladder only descends. */ +export const SCALE_LEVELS: readonly ScaleLevel[] = [ + { scale: 0.75 }, + { scale: 0.6 }, + { scale: 0.5 }, + { scale: 0.4 }, +]; +const LOW_START_LEVEL = 2; + +export function createVoxelScene(opts: SceneOptions): Scene | null { + const { canvas } = opts; + const tier = opts.tier ?? 'full'; + if (tier === 'off') return null; + const measuring = !!opts.onTraceStats; + const gl = canvas.getContext('webgl2', { + antialias: false, + alpha: false, + premultipliedAlpha: false, + depth: false, + stencil: false, + powerPreference: 'low-power', + }); + if (!gl) { + opts.onUnavailable?.('no-webgl2'); + return null; + } + + // `counted` is the terrain pass with its lookup count written to alpha; it exists only for a + // measuring scene, which draws it to an offscreen target (the canvas has no alpha to spare). + type Name = 'height' | 'sky' | 'voxel' | 'counted'; + const programs = {} as Record; + const sources: Array<[Name, 'voxelHeight' | 'voxelSky' | 'voxel', string]> = [ + ['height', 'voxelHeight', ''], + ['sky', 'voxelSky', ''], + ['voxel', 'voxel', ''], + ]; + if (measuring) sources.push(['counted', 'voxel', '#define TRACE_STATS 1\n']); + try { + sources.forEach(([name, stage, extra]) => { + programs[name] = linkStage(gl, stage, opts.patchSource, voxelDefines() + extra); + }); + } catch (e) { + Object.values(programs).forEach((p) => gl.deleteProgram(p)); + if (!(e instanceof ShaderCompileError)) throw e; + opts.onCompileFail?.(e); + return null; + } + + const uniforms = new Map>(); + const loc = (p: WebGLProgram, name: string) => { + let m = uniforms.get(p); + if (!m) uniforms.set(p, (m = new Map())); + if (!m.has(name)) m.set(name, gl.getUniformLocation(p, name)); + return m.get(name) ?? null; + }; + const vec3 = (p: WebGLProgram, name: string, v: readonly number[]) => + gl.uniform3fv(loc(p, name), v as number[]); + const palette = (p: WebGLProgram) => { + vec3(p, 'u_sunDir', PALETTE.sunDir); + vec3(p, 'u_sunColor', PALETTE.sunColor); + vec3(p, 'u_skyTop', PALETTE.skyTop); + vec3(p, 'u_skyHorizon', PALETTE.skyHorizon); + vec3(p, 'u_fogColor', PALETTE.fogColor); + }; + + const vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + const draw = () => gl.drawArrays(gl.TRIANGLES, 0, 3); + + const makeTexture = (w: number, h: number, format: number, filter: number, wrapS: number) => { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrapS); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + return tex; + }; + const fboFor = (tex: WebGLTexture) => { + const fbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); + return fbo; + }; + + // The sky is baked once: nothing in it moves. + const skyTex = makeTexture(SKY_W, SKY_H, gl.RGBA8, gl.LINEAR, gl.REPEAT); + { + const fbo = fboFor(skyTex); + gl.viewport(0, 0, SKY_W, SKY_H); + gl.useProgram(programs.sky); + palette(programs.sky); + draw(); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.deleteFramebuffer(fbo); + gl.deleteProgram(programs.sky); + } + + const ringTex = makeTexture(VOXEL.ring, VOXEL.ring, gl.RG8, gl.NEAREST, gl.REPEAT); + const ringFbo = fboFor(ringTex); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + let ringCell: Cell | undefined; + + /** Fill the ring for a camera in `cell`: only the columns and rows it just admitted. */ + const generate = (cell: Cell) => { + const rects = ringRects(ringCell, cell); + ringCell = cell; + if (!rects.length) return; + gl.bindFramebuffer(gl.FRAMEBUFFER, ringFbo); + gl.viewport(0, 0, VOXEL.ring, VOXEL.ring); + gl.useProgram(programs.height); + const o = windowOrigin(cell); + gl.uniform2i(loc(programs.height, 'u_origin'), o.x, o.z); + gl.enable(gl.SCISSOR_TEST); + for (const r of rects) { + gl.scissor(r.x, r.y, r.w, r.h); + draw(); + } + gl.disable(gl.SCISSOR_TEST); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + }; + + const still = tier === 'still'; + const ladder = new BudgetLadder( + SLOW_MS, + 45, + tier === 'low' ? LOW_START_LEVEL : 0, + SCALE_LEVELS + ); + const scale = () => (opts.fixedLevel ? opts.fixedLevel.solidScale : ladder.current.scale); + + let canvasW = 0; + let canvasH = 0; + let sizedFor = 0; + const ensureSize = () => { + const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); + const s = scale(); + const w = Math.max(2, Math.round(canvas.clientWidth * dpr * s)); + const h = Math.max(2, Math.round(canvas.clientHeight * dpr * s)); + if (w === canvasW && h === canvasH) return; + canvasW = w; + canvasH = h; + sizedFor = s; + canvas.width = w; + canvas.height = h; + }; + + // The measuring scene's offscreen copy of the terrain pass, sized like the canvas. + const counted: { tex?: WebGLTexture; fbo?: WebGLFramebuffer; w: number; h: number } = { + w: 0, + h: 0, + }; + const resizeCounted = () => { + if (counted.tex) gl.deleteTexture(counted.tex); + if (counted.fbo) gl.deleteFramebuffer(counted.fbo); + counted.tex = makeTexture(canvasW, canvasH, gl.RGBA8, gl.NEAREST, gl.CLAMP_TO_EDGE); + counted.fbo = fboFor(counted.tex); + counted.w = canvasW; + counted.h = canvasH; + }; + + const start = performance.now(); + let loginAt: number | undefined; + let frame = 0; + let prevNow: number | undefined; + const loop = startFrameLoop({ + canvas, + still, + unthrottled: opts.unthrottled, + render, + onContextLost: opts.onContextLost, + }); + + function render(now: number) { + ensureSize(); + const frozen = loop.frozen; + const time = opts.clock ? opts.clock() : frozen ? 0 : (now - start) / 1000; + let elapsed: number | undefined; + if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000; + const pose = voxelPose(opts.fixedPull ?? pullProgress(elapsed), time); + const [px, py, pz] = pose.position; + generate({ x: Math.floor(px / VOXEL.cell), z: Math.floor(pz / VOXEL.cell) }); + + const timed = measuring && frame % READBACK_EVERY === 0; + const t0 = timed ? performance.now() : 0; + const drawTerrain = (p: WebGLProgram) => { + gl.viewport(0, 0, canvasW, canvasH); + gl.useProgram(p); + palette(p); + vec3(p, 'u_grass', PALETTE.grass); + vec3(p, 'u_rock', PALETTE.rock); + gl.uniform3f(loc(p, 'u_camPos'), px, py, pz); + gl.uniform2f(loc(p, 'u_fwd'), Math.cos(pose.yaw), Math.sin(pose.yaw)); + gl.uniform2f(loc(p, 'u_right'), Math.sin(pose.yaw), -Math.cos(pose.yaw)); + const tanY = Math.tan(pose.fov / 2); + gl.uniform2f(loc(p, 'u_tanHalf'), tanY * (canvasW / canvasH), tanY); + // The shear: the horizon sits above the centre by the amount the camera looks down. + gl.uniform1f(loc(p, 'u_horizon'), -Math.tan(pose.pitch) / tanY); + gl.uniform1f(loc(p, 'u_frame'), frame); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, ringTex); + gl.uniform1i(loc(p, 'u_height'), 0); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, skyTex); + gl.uniform1i(loc(p, 'u_sky'), 1); + draw(); + }; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + drawTerrain(programs.voxel); + + let renderMs: number | undefined; + if (timed) { + // One-texel synchronous read: it waits for the draw, so the time is the draw's, which a + // queued GPU (SwiftShader and phones alike) would otherwise not report. + gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4)); + renderMs = performance.now() - t0; + // Then the same frame again with its lookup counts in alpha, off screen, and read back. + if (counted.w !== canvasW || counted.h !== canvasH) resizeCounted(); + gl.bindFramebuffer(gl.FRAMEBUFFER, counted.fbo); + drawTerrain(programs.counted); + const px4 = new Uint8Array(canvasW * canvasH * 4); + gl.readPixels(0, 0, canvasW, canvasH, gl.RGBA, gl.UNSIGNED_BYTE, px4); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + let sum = 0; + for (let i = 3; i < px4.length; i += 4) sum += px4[i]; + const mean = sum / (canvasW * canvasH); + opts.onTraceStats?.({ traceSteps: mean, prepassSteps: 0, stepsPerPixel: mean }); + } + if (prevNow !== undefined) { + const frameMs = now - prevNow; + opts.onFrameStats?.({ frameMs, renderMs, scale: sizedFor, size: [canvasW, canvasH] }); + if (!opts.fixedLevel && ladder.push(frameMs)) canvasW = 0; // resize on the next frame + } + prevNow = now; + frame += 1; + } + + return { + login() { + if (still || loop.disposed) return; // a still has no pull-back + loginAt = performance.now(); + loop.restart(); + }, + dispose() { + if (loop.disposed) return; + loop.stop(); + gl.deleteTexture(ringTex); + gl.deleteTexture(skyTex); + gl.deleteFramebuffer(ringFbo); + if (counted.tex) gl.deleteTexture(counted.tex); + if (counted.fbo) gl.deleteFramebuffer(counted.fbo); + if (measuring) gl.deleteProgram(programs.counted); + gl.deleteProgram(programs.height); + gl.deleteProgram(programs.voxel); + gl.deleteVertexArray(vao); + }, + }; +} diff --git a/src/app/features/scene/voxel/rails.test.ts b/src/app/features/scene/voxel/rails.test.ts new file mode 100644 index 00000000..41932ad8 --- /dev/null +++ b/src/app/features/scene/voxel/rails.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { pullProgress } from '../camera'; +import { RAIL_HEADING, RAIL_SPEED, voxelPose } from './rails'; +import { VOXEL, heightBound } from './voxelWorld'; + +describe('voxel rails camera', () => { + it('stays above the highest terrain at every time and pull-back', () => { + for (let t = 0; t < 7200; t += 13.7) + for (const pull of [0, 0.3, 1]) + expect(voxelPose(pull, t).position[1]).toBeGreaterThanOrEqual( + heightBound() + VOXEL.clearance + ); + }); + + it('slides along the heading at the rail speed, swaying within a bound', () => { + const a = voxelPose(0, 100).position; + const b = voxelPose(0, 200).position; + const along = (b[0] - a[0]) * Math.cos(RAIL_HEADING) + (b[2] - a[2]) * Math.sin(RAIL_HEADING); + expect(along).toBeCloseTo(RAIL_SPEED * 100, 5); + const across = (p: readonly number[]) => + -p[0] * Math.sin(RAIL_HEADING) + p[2] * Math.cos(RAIL_HEADING); + expect(Math.abs(across(a))).toBeLessThanOrEqual(40); + expect(Math.abs(across(b))).toBeLessThanOrEqual(40); + }); + + it('yaws freely either side of the heading, bounded', () => { + const yaws = Array.from({ length: 500 }, (_, i) => voxelPose(0, i * 5).yaw - RAIL_HEADING); + expect(Math.max(...yaws)).toBeGreaterThan(0.3); + expect(Math.min(...yaws)).toBeLessThan(-0.3); + expect(Math.max(...yaws.map(Math.abs))).toBeLessThanOrEqual(0.35 + 1e-9); + }); + + it('login lifts the camera and tips it down, and a time step moves it a little', () => { + const rest = voxelPose(pullProgress(undefined), 50); + const pulled = voxelPose(pullProgress(99), 50); + expect(pulled.position[1]).toBeGreaterThan(rest.position[1]); + expect(pulled.pitch).toBeLessThan(rest.pitch); + const next = voxelPose(0, 50.033); + expect( + Math.hypot(next.position[0] - rest.position[0], next.position[2] - rest.position[2]) + ).toBeLessThan(1); + }); +}); diff --git a/src/app/features/scene/voxel/rails.ts b/src/app/features/scene/voxel/rails.ts new file mode 100644 index 00000000..787c78a7 --- /dev/null +++ b/src/app/features/scene/voxel/rails.ts @@ -0,0 +1,38 @@ +// The voxel scene's camera: a free-yaw rail. The camera slides along a fixed heading while its yaw +// wanders either side of it, always above the highest terrain; login() lifts it and tips it down. +// Pure functions of time, so the path is testable without a GL context. +import { heightBound, VOXEL } from './voxelWorld'; + +export interface VoxelPose { + /** World x, y (up), z. */ + position: readonly [number, number, number]; + /** Radians about +y; 0 looks down +x. */ + yaw: number; + /** Radians, negative looks down. */ + pitch: number; + /** Vertical field of view in radians. */ + fov: number; +} + +/** The rail's heading and the world units it covers per second. */ +export const RAIL_HEADING = 0.6; +export const RAIL_SPEED = 14; +const SWAY = 40; +const YAW_SWING = 0.35; + +/** @param pull 0..1 from pullProgress. @param time seconds since the scene started. */ +export function voxelPose(pull: number, time: number): VoxelPose { + const hx = Math.cos(RAIL_HEADING); + const hz = Math.sin(RAIL_HEADING); + const sway = SWAY * Math.sin(time * 0.05); + return { + position: [ + hx * RAIL_SPEED * time - hz * sway, + heightBound() + VOXEL.clearance + VOXEL.pullClimb * pull, + hz * RAIL_SPEED * time + hx * sway, + ], + yaw: RAIL_HEADING + YAW_SWING * Math.sin(time * 0.07), + pitch: -0.1 - 0.12 * pull, + fov: 1.0 - 0.1 * pull, + }; +} diff --git a/src/app/features/scene/voxel/ring.test.ts b/src/app/features/scene/voxel/ring.test.ts new file mode 100644 index 00000000..15ba0604 --- /dev/null +++ b/src/app/features/scene/voxel/ring.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { Cell, cellAt, ringRects, windowOrigin } from './ring'; +import { HALF_RING, VOXEL } from './voxelWorld'; + +const N = VOXEL.ring; + +/** What each ring texel holds after applying rewrites: the world cell it was last written as, x then z. */ +type Ring = Int32Array; +const UNWRITTEN = -2147483648; + +function applyRects(ring: Ring, rects: ReturnType, to: Cell) { + const o = windowOrigin(to); + for (const r of rects) + for (let y = r.y; y < r.y + r.h; y++) + for (let x = r.x; x < r.x + r.w; x++) { + ring[(y * N + x) * 2] = cellAt(o.x, x); + ring[(y * N + x) * 2 + 1] = cellAt(o.z, y); + } +} + +/** Index of the first texel where the rings differ, or -1. (toEqual on 131k numbers is far too slow.) */ +const firstDifference = (a: Ring, b: Ring) => a.findIndex((v, i) => v !== b[i]); + +const fill = (cell: Cell) => { + const ring: Ring = new Int32Array(N * N * 2).fill(UNWRITTEN); + applyRects(ring, ringRects(undefined, cell), cell); + return ring; +}; + +describe('height ring bookkeeping', () => { + it('the first window is the whole ring, and standing still rewrites nothing', () => { + expect(ringRects(undefined, { x: 5, z: -3 })).toEqual([{ x: 0, y: 0, w: N, h: N }]); + expect(ringRects({ x: 5, z: -3 }, { x: 5, z: -3 })).toEqual([]); + }); + + it('the window is the N x N block centred on the camera cell, one world cell per texel', () => { + const cam = { x: 1000, z: -777 }; + const o = windowOrigin(cam); + expect(o).toEqual({ x: cam.x - HALF_RING, z: cam.z - HALF_RING }); + const seen = new Set(); + for (let i = 0; i < N; i++) { + const cx = cellAt(o.x, i); + expect(cx).toBeGreaterThanOrEqual(o.x); + expect(cx).toBeLessThan(o.x + N); + expect(((cx % N) + N) % N).toBe(i); // the cell lives at its own residue + seen.add(String(cx)); + } + expect(seen.size).toBe(N); + }); + + it('after any walk of strip rewrites, every texel holds the cell a full fill would put there', () => { + let seed = 11; + const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647; + let cam: Cell = { x: -3, z: 250 }; // straddles the wrap on both axes + const ring = fill(cam); + for (let step = 0; step < 150; step++) { + // mostly single-cell moves, some multi-cell jumps, some diagonals, some standing still + const jump = rand() < 0.1 ? 40 : 2; + const next = { + x: cam.x + Math.round((rand() - 0.5) * jump), + z: cam.z + Math.round((rand() - 0.5) * jump), + }; + applyRects(ring, ringRects(cam, next), next); + cam = next; + expect(firstDifference(ring, fill(cam))).toBe(-1); + } + }); + + it('writes only what it must: the cells the move admitted, not one texel more', () => { + for (const [dx, dz] of [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + [1, 1], + [-2, 3], + ]) { + const prev = { x: N - 1, z: 0 }; // wraps on x for positive dx + const rects = ringRects(prev, { x: prev.x + dx, z: prev.z + dz }); + const touched = new Set(); + for (const r of rects) + for (let y = r.y; y < r.y + r.h; y++) + for (let x = r.x; x < r.x + r.w; x++) touched.add(y * N + x); + const ax = Math.abs(dx); + const az = Math.abs(dz); + expect(touched.size).toBe(N * N - (N - ax) * (N - az)); + } + }); + + it('a move of a whole ring or more is a full rewrite', () => { + expect(ringRects({ x: 0, z: 0 }, { x: N, z: 0 })).toEqual([{ x: 0, y: 0, w: N, h: N }]); + expect(ringRects({ x: 0, z: 0 }, { x: 3, z: -N - 1 })).toEqual([{ x: 0, y: 0, w: N, h: N }]); + }); +}); diff --git a/src/app/features/scene/voxel/ring.ts b/src/app/features/scene/voxel/ring.ts new file mode 100644 index 00000000..bd61cfe5 --- /dev/null +++ b/src/app/features/scene/voxel/ring.ts @@ -0,0 +1,66 @@ +// The height ring's bookkeeping, as pure functions: which texels must be rewritten when the camera +// crosses into another cell. The ring is a torus: world cell (x, z) lives at texel (x mod N, z mod N), +// and the window of cells the camera can see is the N x N block centred on its cell. Moving one +// cell retires one column or row of cells and admits one, and the admitted cells reuse exactly the +// texels the retired ones held. +import { HALF_RING, VOXEL } from './voxelWorld'; + +const N = VOXEL.ring; + +export interface Cell { + x: number; + z: number; +} + +/** Texels to rewrite, in ring coordinates. */ +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +/** The window's lowest cell for a camera in `camera`. */ +export const windowOrigin = (camera: Cell): Cell => ({ + x: camera.x - HALF_RING, + z: camera.z - HALF_RING, +}); + +export const mod = (a: number, n: number) => ((a % n) + n) % n; + +/** The world cell texel index `i` holds, along one axis, for a window starting at `origin`. */ +export const cellAt = (origin: number, i: number) => origin + mod(i - origin, N); + +/** `count` consecutive cells from `first`, as runs of ring texels (two when the run wraps). */ +function runs(first: number, count: number): Array<[number, number]> { + const start = mod(first, N); + if (start + count <= N) return [[start, count]]; + return [ + [start, N - start], + [0, start + count - N], + ]; +} + +/** + * The rewrites that take a ring filled for `prev` to one filled for `next`: the whole ring when + * there is no previous window or the move is a full ring or more, else the newly admitted columns + * and rows, and nothing when the cell has not changed. + */ +export function ringRects(prev: Cell | undefined, next: Cell): Rect[] { + if (!prev) return [{ x: 0, y: 0, w: N, h: N }]; + const dx = next.x - prev.x; + const dz = next.z - prev.z; + if (Math.abs(dx) >= N || Math.abs(dz) >= N) return [{ x: 0, y: 0, w: N, h: N }]; + const o0 = windowOrigin(prev); + const o1 = windowOrigin(next); + const out: Rect[] = []; + if (dx !== 0) { + const first = dx > 0 ? o0.x + N : o1.x; + for (const [x, w] of runs(first, Math.abs(dx))) out.push({ x, y: 0, w, h: N }); + } + if (dz !== 0) { + const first = dz > 0 ? o0.z + N : o1.z; + for (const [y, h] of runs(first, Math.abs(dz))) out.push({ x: 0, y, w: N, h }); + } + return out; +} diff --git a/src/app/features/scene/voxel/voxelWorld.ts b/src/app/features/scene/voxel/voxelWorld.ts new file mode 100644 index 00000000..4fbbd226 --- /dev/null +++ b/src/app/features/scene/voxel/voxelWorld.ts @@ -0,0 +1,50 @@ +// The voxel scene's constants. One owner: the shader defines below are generated from this object. +// The terrain itself is the ray-march scene's (../world.ts: fbm plus Phacelle erosion) with the +// plateau mask left out, sampled once per cell into a wrapping ring of heights. +import { terrainBounds } from '../world'; + +export const VOXEL = { + /** Texels per side of the wrapping height ring: world cell (x, z) lives at texel (x mod ring, z mod ring). */ + ring: 256, + /** World units per cell. */ + cell: 8, + /** Furthest sample, in cells from the camera. The ring holds 128 each way; the margin keeps a ray from reading cells the camera has not generated. */ + farCells: 120, + /** Height lookups one column march may spend. */ + maxLookups: 128, + /** First sample depth along the view axis. */ + near: 2, + /** A march step is max(stepMin, depth * stepRel): about a cell up close, coarser far away. */ + stepMin: 4.8, + stepRel: 0.02, + /** Fog: 1 - exp(-distance * fog) of the way to the horizon colour. */ + fog: 0.0016, + /** Camera altitude above the highest possible terrain, so the camera is never inside a column. */ + clearance: 30, + /** Extra altitude the login pull-back climbs. */ + pullClimb: 45, +} as const; + +/** Terrain heights lie in [-heightBound, heightBound] and are stored as 16 bits across that range. */ +export const heightBound = (): number => terrainBounds().maxHeight; + +/** Half the ring, in cells: the camera cell sits at the centre of the window. */ +export const HALF_RING = VOXEL.ring / 2; + +const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`); + +/** `#define` lines every voxel stage sees. */ +export function voxelDefines(): string { + return [ + `#define VOX_RING ${VOXEL.ring}`, + `#define VOX_CELL ${f(VOXEL.cell)}`, + `#define VOX_FAR ${f(VOXEL.farCells * VOXEL.cell)}`, + `#define VOX_MAX_LOOKUPS ${VOXEL.maxLookups}`, + `#define VOX_NEAR ${f(VOXEL.near)}`, + `#define VOX_STEP_MIN ${f(VOXEL.stepMin)}`, + `#define VOX_STEP_REL ${f(VOXEL.stepRel)}`, + `#define VOX_HB ${f(heightBound())}`, + `#define VOX_FOG ${VOXEL.fog}`, + '', + ].join('\n'); +} -- 2.47.3 From 2470a79f451733a451581b62a8b6f4c0aaa5321b Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 21:53:52 +0000 Subject: [PATCH 4/7] scene README: the voxel scene Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 35 +++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index ad4c13b1..07d8028e 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -14,7 +14,8 @@ callbacks and the creature sizing rule. Nothing has drawn a creature. door. The client config's `backgroundScene.tier` (`off` default, `still`, `low`, `full`) is the feature flag and the ceiling; `tier.ts` lowers it for `saveData`, low memory or few cores. At `off` nothing is imported and no canvas context is requested; otherwise `Scene.ts` loads on - demand, so it stays out of the main chunk; if that chunk fails to load the error goes to + demand, so it stays out of the main chunk; `backgroundScene.style` picks which scene loads: + `voxel` (default, below) or `raymarch` (the rest of this file); 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 is missing, neither @@ -73,6 +74,38 @@ not evaluated. - The low-resolution atmosphere samples pass-1 depth nearest, so silhouettes of near objects carry a thin fog halo. +## The voxel scene (default style) + +`voxel/Voxel.ts` `createVoxelScene` takes the same options and returns the same `{ login, dispose }` +as `createScene`, so the door swaps one for the other. It is the cheap background: WebGL2 core +only (no extension is requested, no float target is allocated), one fullscreen pass. + +- A wrapping ring of 256 x 256 world cells (8 units each) around the camera, in an RG8 texture + holding a 16-bit height per cell. Cell (x, z) lives at texel (x mod 256, z mod 256). When the + camera crosses into another cell, `ring.ts` `ringRects` names the columns and rows it admitted + and `shaders/voxelHeight.frag.glsl` rewrites exactly those, scissored: one `terrainFbm` plus one + Phacelle per texel, the ray-march scene's terrain without its plateau mask. A cell's height is a + pure function of the cell, so the order of writes cannot change the ring. A move of a whole ring + or more, or the first frame, rewrites all of it. +- `shaders/voxel.frag.glsl`: per pixel below the horizon, march the screen column from the camera + outward, one lookup per step (steps grow with depth, at most 128, none past 120 cells), and stop + at the first column whose top reaches the pixel. The camera flies above the highest terrain, so + sky pixels spend no lookups and the march starts where a pixel first could be covered. That is + the image a running-horizon column renderer paints, found per pixel instead of carrying the + horizon between pixels, which one fullscreen pass cannot do. +- The sky is baked once into RGBA8 (`voxelSky.frag.glsl`, gradient plus cloud bands) and sampled + by azimuth and elevation. The CSS ground stays the fallback: `createVoxelScene` returns null + where `createScene` would, and says why through `onUnavailable`. +- Resolution adapts through the drawing-buffer size, not extra targets: `BudgetLadder` over + `SCALE_LEVELS` steps down when the median frame time misses the 30 fps cap, using only + `requestAnimationFrame` cadence, since timer queries are an extension. +- `rails.ts` is the camera: a straight rail with the yaw wandering either side and sway, constant + altitude above the terrain; `login()` lifts and tips it down. +- Measurement without extensions: `onFrameStats` gives the cadence and the buffer size; setting + `onTraceStats` makes one frame in eight draw a second, off-screen copy with lookup counts in + alpha and read it back (lookups per pixel), after timing the first draw with a one-texel + synchronous read. `harness/scene` has `drive.cjs voxel` and a standalone page. + ## Measured and not measured See the pull request or `harness/scene/README.md` for the numbers and how they were taken. They -- 2.47.3 From 50b5155b0e65a3673b046ab639d2151f1a7fe9ef Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 23:26:44 +0000 Subject: [PATCH 5/7] scene: voxel terrain drawn as painter's-order instanced strips The fullscreen fragment march is replaced by one instance per screen column and depth step, drawn far to near with no depth test. The vertex shader fetches the strip's height and its nearer neighbour's from the RG8 ring (vertex texture fetch, WebGL2 core) and emits a quad from the projected top down to the nearer top, or the screen bottom for the first strip; the fragment is a flat colour by height, slope and fog. Strips nearer than any terrain could reach the screen are not drawn, and strips past a column's far plane are clipped. The drawing buffer is a fixed pixel budget in the canvas's aspect, 320x180 by default, with the ladder below it. The ring, its generation pass, the baked sky, the createScene contract and the flag are unchanged; the sky is now a small backdrop pass. FrameStats gains vertices and ring lookups per frame; the measuring pass counts fragments per pixel (overdraw) instead of march lookups. Harness: drive.cjs voxel takes SIZES, a voxelshots mode takes screenshots. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/README.md | 3 +- harness/scene/bundle.cjs | 2 +- harness/scene/drive.cjs | 154 ++++++++++++------ harness/scene/voxelDemo.ts | 9 +- src/app/features/scene/README.md | 38 +++-- src/app/features/scene/Scene.ts | 8 +- src/app/features/scene/link.ts | 4 +- src/app/features/scene/shaderSource.ts | 14 ++ .../features/scene/shaders/voxel.frag.glsl | 95 +---------- .../scene/shaders/voxelBackdrop.frag.glsl | 22 +++ .../scene/shaders/voxelStrips.vert.glsl | 95 +++++++++++ src/app/features/scene/voxel/Voxel.test.ts | 80 +++++++-- src/app/features/scene/voxel/Voxel.ts | 115 +++++++++---- .../features/scene/voxel/voxelWorld.test.ts | 46 ++++++ src/app/features/scene/voxel/voxelWorld.ts | 71 +++++++- 15 files changed, 539 insertions(+), 217 deletions(-) create mode 100644 src/app/features/scene/shaders/voxelBackdrop.frag.glsl create mode 100644 src/app/features/scene/shaders/voxelStrips.vert.glsl create mode 100644 src/app/features/scene/voxel/voxelWorld.test.ts diff --git a/harness/scene/README.md b/harness/scene/README.md index f6f5cf40..158c0f12 100644 --- a/harness/scene/README.md +++ b/harness/scene/README.md @@ -9,7 +9,8 @@ Chromium. Not shipped, not run by CI. 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 voxel # voxel scene: lookups per pixel and ms per frame at fixed poses, POSES=pull:seconds SCALES=0.75,0.5 + node drive.cjs voxel # voxel scene: lookups, vertices, overdraw, ms per frame at fixed poses and buffer sizes, POSES=pull:seconds SIZES=320x180,160x90 (BUFFER=canvas for a scene whose buffer is the canvas) + node drive.cjs voxelshots # voxel scene screenshots at the same SIZES and POSES 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` diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs index 189318ca..9fade865 100644 --- a/harness/scene/bundle.cjs +++ b/harness/scene/bundle.cjs @@ -36,7 +36,7 @@ esbuild path.join(out, 'index.html'), 'Ratatoskr scene' + (voxel - ? '
device
' + ? '
device
' : demo ? '
device
' : '') + diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs index 64889b8c..eb781250 100644 --- a/harness/scene/drive.cjs +++ b/harness/scene/drive.cjs @@ -1,5 +1,5 @@ // Drives the scene in headless Chromium (software GL unless the host has a GPU). -// usage: node drive.cjs [outdir] modes: compile | shots | measure +// usage: node drive.cjs [outdir] modes: compile | shots | measure | voxel | voxelshots // Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report. const { chromium } = require('playwright'); const path = require('path'); @@ -222,60 +222,112 @@ const [, , dir, mode, outdir = '.'] = process.argv; } if (mode === 'voxel') { - // The voxel scene at fixed poses (POSES=pull:seconds,...) and internal scales (SCALES=0.75,0.5): - // lookups per pixel, and the median ms to draw one frame (a one-texel sync read), two viewports. + // The voxel scene at fixed poses (POSES=pull:seconds,...) and internal buffer sizes + // (SIZES=320x180,160x90): the viewport is set to the size, and the scale to the fraction of the + // 320x180 pixel budget that makes the drawing buffer that size (BUFFER=canvas: scale 1, for a scene + // whose buffer is the canvas), so a scene whose buffer follows the + // canvas (the march) and one whose buffer follows the budget (the strips) draw the same pixels. + // Per run: lookups per pixel and per frame, vertices, fragment overdraw where the scene reports + // it, and the median ms to draw one frame (a one-texel sync read). + const BUDGET = 320 * 180; report.runs = []; - for (const [w, h] of process.env.PORTRAIT === '0' - ? [[960, 540]] - : [ - [960, 540], - [540, 1200], - ]) { + for (const size of (process.env.SIZES || '320x180,256x144,160x90,162x360').split(',')) { + const [w, h] = size.split('x').map(Number); + const scale = process.env.BUFFER === 'canvas' ? 1 : +Math.sqrt((w * h) / BUDGET).toFixed(6); for (const pose of (process.env.POSES || '0:0,0:60,1:60').split(',')) { - for (const scale of (process.env.SCALES || '0.75').split(',').map(Number)) { - await page.setViewportSize({ width: w, height: h }); - const [pull, t] = pose.split(':').map(Number); - const r = await page.evaluate( - ({ pull, t, scale }) => - new Promise((res) => { - const ms = []; - const looks = []; - let size; - const done = () => { - s.dispose(); - const med = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1]; - res({ - size, - readbacks: looks.length, - lookupsPerPixel: +(looks.slice(1).reduce((a, b) => a + b, 0) / (looks.length - 1)).toFixed(2), - medianRenderMs: +med(ms.slice(1)).toFixed(1), - }); - }; - const s = window.__scene.createVoxelScene({ - canvas: document.getElementById('c'), - unthrottled: true, - fixedPull: pull, - clock: () => t, - fixedLevel: { solidScale: scale }, - onTraceStats: (x) => { - looks.push(x.traceSteps); - if (looks.length >= 12) done(); - }, - onFrameStats: (x) => { - size = x.size; - if (x.renderMs !== undefined) ms.push(x.renderMs); - }, + await page.setViewportSize({ width: w, height: h }); + const [pull, t] = pose.split(':').map(Number); + const r = await page.evaluate( + ({ pull, t, scale, READS }) => + new Promise((res) => { + const ms = []; + const looks = []; + const over = []; + let frame; + const done = () => { + s.dispose(); + const med = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1]; + const mean = (xs) => +(xs.reduce((a, b) => a + b, 0) / xs.length).toFixed(2); + res({ + size: frame.size, + readbacks: looks.length, + lookupsPerPixel: mean(looks.slice(1)), + overdraw: over.length ? mean(over.slice(1)) : undefined, + vertices: frame.vertices, + lookupsPerFrame: frame.lookups, + medianRenderMs: +med(ms.slice(1)).toFixed(1), }); - if (!s) res({ error: 'createVoxelScene returned null' }); - }), - { pull, t, scale } - ); - report.runs.push({ viewport: [w, h], pull, t, scale, ...r }); - await page.evaluate(() => { - const c = document.getElementById('c'); - c.replaceWith(c.cloneNode()); - }); + }; + const s = window.__scene.createVoxelScene({ + canvas: document.getElementById('c'), + unthrottled: true, + fixedPull: pull, + clock: () => t, + fixedLevel: { solidScale: scale }, + onTraceStats: (x) => { + looks.push(x.traceSteps); + if (x.overdraw !== undefined) over.push(x.overdraw); + if (looks.length >= READS) done(); + }, + onFrameStats: (x) => { + frame = x; + if (x.renderMs !== undefined) ms.push(x.renderMs); + }, + }); + if (!s) res({ error: 'createVoxelScene returned null' }); + }), + { pull, t, scale, READS: +(process.env.READS || 16) } + ); + report.runs.push({ viewport: [w, h], pull, t, scale, ...r }); + await page.evaluate(() => { + const c = document.getElementById('c'); + c.replaceWith(c.cloneNode()); + }); + } + } + } + + if (mode === 'voxelshots') { + // Screenshots of the voxel scene at SIZES and POSES, the viewport equal to the drawing buffer + // (see the voxel mode), one PNG each: vox-x--.png in outdir. + const BUDGET = 320 * 180; + report.shots = []; + for (const size of (process.env.SIZES || '320x180').split(',')) { + const [w, h] = size.split('x').map(Number); + const scale = process.env.BUFFER === 'canvas' ? 1 : +Math.sqrt((w * h) / BUDGET).toFixed(6); + for (const pose of (process.env.POSES || '0:0,0:60,1:60').split(',')) { + await page.setViewportSize({ width: w, height: h }); + const [pull, t] = pose.split(':').map(Number); + const ok = await page.evaluate( + ({ pull, t, scale }) => + new Promise((res) => { + let n = 0; + const s = window.__scene.createVoxelScene({ + canvas: document.getElementById('c'), + unthrottled: true, + fixedPull: pull, + clock: () => t, + fixedLevel: { solidScale: scale }, + onFrameStats: () => { + if (++n === 3) res(true); + }, + }); + window.__v = s; + if (!s) res(false); + }), + { pull, t, scale } + ); + const file = `vox-${size}-${pull}-${t}.png`; + if (ok) { + await page.waitForTimeout(100); + await page.screenshot({ path: path.join(outdir, file) }); + report.shots.push(file); } + await page.evaluate(() => { + window.__v?.dispose(); + const c = document.getElementById('c'); + c.replaceWith(c.cloneNode()); + }); } } } diff --git a/harness/scene/voxelDemo.ts b/harness/scene/voxelDemo.ts index ca046d11..1f97d66d 100644 --- a/harness/scene/voxelDemo.ts +++ b/harness/scene/voxelDemo.ts @@ -1,5 +1,5 @@ -// Standalone page: the voxel scene full-window with a readout of lookups per pixel and frame ms, -// the internal scale, and what the browser says about itself. Not shipped. +// Standalone page: the voxel scene full-window with a readout of ring lookups per frame, vertices, +// fragment overdraw and frame ms, the internal scale, and what the browser says about itself. Not shipped. import { createVoxelScene, SCALE_LEVELS } from '../../src/app/features/scene/voxel/Voxel'; import type { FrameStats, Scene, TraceStats } from '../../src/app/features/scene/Scene'; import { LEVELS } from '../../src/app/features/scene/budget'; @@ -55,10 +55,11 @@ setInterval(() => { const lines = [`voxel pull ${pull.value} scale ${scaleChoice()}`]; if (frames) { lines.push( - `frame ms ${cadence.toFixed(1)} (cadence; the cap is 33.3) buffer ${frames.size.join('x')} at scale ${frames.scale}` + `frame ms ${cadence.toFixed(1)} (cadence; the cap is 33.3) buffer ${frames.size.join('x')} at scale ${frames.scale}`, + `lookups/frame ${frames.lookups} vertices/frame ${frames.vertices}` ); } - if (trace) lines.push(`lookups/pixel ${trace.traceSteps.toFixed(2)} draw ms ${drawMs?.toFixed(1)} (one frame in 8, synced)`); + if (trace) lines.push(`lookups/pixel ${trace.traceSteps.toFixed(2)} overdraw ${trace.overdraw?.toFixed(2)} fragments/pixel draw ms ${drawMs?.toFixed(1)} (one frame in 8, synced)`); else if (measure.checked) lines.push('lookups/pixel: waiting for the first counted frame'); out.textContent = lines.join('\n'); }, 500); diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 07d8028e..b37b76f0 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -78,7 +78,8 @@ not evaluated. `voxel/Voxel.ts` `createVoxelScene` takes the same options and returns the same `{ login, dispose }` as `createScene`, so the door swaps one for the other. It is the cheap background: WebGL2 core -only (no extension is requested, no float target is allocated), one fullscreen pass. +only (no extension is requested, no float target is allocated). It draws the terrain painter's order, +as instanced vertical strips, instead of marching a ray per pixel. - A wrapping ring of 256 x 256 world cells (8 units each) around the camera, in an RG8 texture holding a 16-bit height per cell. Cell (x, z) lives at texel (x mod 256, z mod 256). When the @@ -87,24 +88,35 @@ only (no extension is requested, no float target is allocated), one fullscreen p Phacelle per texel, the ray-march scene's terrain without its plateau mask. A cell's height is a pure function of the cell, so the order of writes cannot change the ring. A move of a whole ring or more, or the first frame, rewrites all of it. -- `shaders/voxel.frag.glsl`: per pixel below the horizon, march the screen column from the camera - outward, one lookup per step (steps grow with depth, at most 128, none past 120 cells), and stop - at the first column whose top reaches the pixel. The camera flies above the highest terrain, so - sky pixels spend no lookups and the march starts where a pixel first could be covered. That is - the image a running-horizon column renderer paints, found per pixel instead of carrying the - horizon between pixels, which one fullscreen pass cannot do. +- `shaders/voxelStrips.vert.glsl` draws the terrain: one instance is a screen column (one buffer + pixel wide) at one depth step, and the host draws the steps far to near with no depth buffer + and no blending, so a nearer strip overwrites what it hides. A strip's vertices fetch its height + and its nearer neighbour's from the ring (vertex texture fetch, core WebGL2) and project them + with the shear `y = horizon + (h - camY) / (z * tanHalf.y)`; the quad runs from its own top + down to the nearer neighbour's top (the screen bottom for the first strip drawn), and collapses + when the neighbour is higher. Colour is chosen once per strip, by height, slope along the view + ray and fog, and leaves as a flat varying (the last vertex of each strip triangle is a top + vertex); `shaders/voxel.frag.glsl` writes it with a dither. Depths follow `voxelWorld.ts` + `stepDepth` (about a cell up close, 2% of the depth far away; the same closed form in GLSL and + TypeScript), strips nearer than any terrain could reach the screen are not drawn, and strips + past a column's far plane (120 cells) are clipped in the vertex shader. +- `shaders/voxelBackdrop.frag.glsl` is the sky behind the strips, one fullscreen triangle. +- The drawing buffer is a fixed pixel budget in the canvas's aspect, 320 x 180 at the top of the + ladder; CSS scales it up. One column per buffer pixel, so the strip count follows the width. - The sky is baked once into RGBA8 (`voxelSky.frag.glsl`, gradient plus cloud bands) and sampled - by azimuth and elevation. The CSS ground stays the fallback: `createVoxelScene` returns null + by azimuth and elevation (the strips' vertex shader samples the horizon row for fog). The CSS ground stays the fallback: `createVoxelScene` returns null where `createScene` would, and says why through `onUnavailable`. - Resolution adapts through the drawing-buffer size, not extra targets: `BudgetLadder` over - `SCALE_LEVELS` steps down when the median frame time misses the 30 fps cap, using only + `SCALE_LEVELS` (1, 0.8, 0.64, 0.5 of the side of the 320 x 180 budget) steps down when the median frame time misses the 30 fps cap, using only `requestAnimationFrame` cadence, since timer queries are an extension. - `rails.ts` is the camera: a straight rail with the yaw wandering either side and sway, constant altitude above the terrain; `login()` lifts and tips it down. -- Measurement without extensions: `onFrameStats` gives the cadence and the buffer size; setting - `onTraceStats` makes one frame in eight draw a second, off-screen copy with lookup counts in - alpha and read it back (lookups per pixel), after timing the first draw with a one-texel - synchronous read. `harness/scene` has `drive.cjs voxel` and a standalone page. +- Measurement without extensions: `onFrameStats` gives the cadence, the buffer size, the vertices + submitted and the ring lookups the frame costs (two per shaded strip vertex, counted on the host + from the same schedule the shader runs). Setting `onTraceStats` makes one frame in eight time + the draw with a one-texel synchronous read, then draw the strips again off screen, each fragment + adding one count, and read that back: lookups per pixel, and fragment overdraw per pixel. + `harness/scene` has `drive.cjs voxel`, `voxelshots` and a standalone page. ## Measured and not measured diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 49eb8ece..5d15efaf 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -32,9 +32,13 @@ export interface FrameStats { /** Drawing-buffer size as a fraction of the canvas's CSS size times device pixel ratio. */ scale: number; size: [number, number]; + /** Vertices submitted this frame (the strips, 4 each, plus the sky triangle). */ + vertices: number; + /** Height-ring lookups this frame: the vertex shader's, two per shaded strip vertex. */ + lookups: number; } -/** Trace cost over one finished readback: mean distance evaluations per pass-1 pixel. */ +/** Trace cost over one finished readback: mean distance evaluations per pass-1 pixel (the voxel scene: ring lookups per pixel). */ export interface TraceStats { /** Mean pass-1 steps per pixel. */ traceSteps: number; @@ -42,6 +46,8 @@ export interface TraceStats { prepassSteps: number; /** traceSteps plus the prepass's steps spread over the pixels of each tile. */ stepsPerPixel: number; + /** Voxel strips: mean fragments the strips shade per pixel (the painter's overdraw); the sky's one is not counted. */ + overdraw?: number; } /** diff --git a/src/app/features/scene/link.ts b/src/app/features/scene/link.ts index 2590ec3c..c0e2c238 100644 --- a/src/app/features/scene/link.ts +++ b/src/app/features/scene/link.ts @@ -1,5 +1,5 @@ // Compiling and linking a stage: the one place a shader failure becomes a ShaderCompileError. -import { fragmentSource, vertexSource, Stage } from './shaderSource'; +import { fragmentSource, vertexSourceFor, Stage } from './shaderSource'; /** 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; @@ -33,7 +33,7 @@ export function linkStage( patch?: SourcePatch, defines?: string ): WebGLProgram { - const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex'); + const vs = compile(gl, gl.VERTEX_SHADER, vertexSourceFor(stage, defines), 'vertex'); const fs = compile( gl, gl.FRAGMENT_SHADER, diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts index 5e569364..de03430d 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -10,6 +10,8 @@ import resolveFrag from './shaders/resolve.frag.glsl?raw'; import compositeFrag from './shaders/composite.frag.glsl?raw'; import voxelHeightFrag from './shaders/voxelHeight.frag.glsl?raw'; import voxelSkyFrag from './shaders/voxelSky.frag.glsl?raw'; +import voxelBackdropFrag from './shaders/voxelBackdrop.frag.glsl?raw'; +import voxelStripsVert from './shaders/voxelStrips.vert.glsl?raw'; import voxelFrag from './shaders/voxel.frag.glsl?raw'; import { WORLD, worldDefines } from './world'; import { trimGlsl } from './cultmathTrim'; @@ -22,6 +24,7 @@ export type Stage = | 'composite' | 'voxelHeight' | 'voxelSky' + | 'voxelBackdrop' | 'voxel'; const HEADER = @@ -29,6 +32,16 @@ const HEADER = export const vertexSource = HEADER + fullscreenVert; +/** + * Vertex source for a stage: the fullscreen triangle, except the voxel strips, which have a vertex + * shader of their own (it reads the height ring and the shared block). + */ +export function vertexSourceFor(stage: Stage, defines = ''): string { + return stage === 'voxel' + ? `${HEADER}${defines}\n${common}\n${voxelStripsVert}` + : vertexSource; +} + const stages: Record = { prepass: prepassFrag, terrain: terrainFrag, @@ -37,6 +50,7 @@ const stages: Record = { composite: compositeFrag, voxelHeight: voxelHeightFrag, voxelSky: voxelSkyFrag, + voxelBackdrop: voxelBackdropFrag, voxel: voxelFrag, }; diff --git a/src/app/features/scene/shaders/voxel.frag.glsl b/src/app/features/scene/shaders/voxel.frag.glsl index 93c162f7..bb1ff20b 100644 --- a/src/app/features/scene/shaders/voxel.frag.glsl +++ b/src/app/features/scene/shaders/voxel.frag.glsl @@ -1,94 +1,17 @@ -// One pass, straight to the canvas: for every pixel, march down its screen column from the camera -// outward, one height lookup per step, until a column of terrain rises past the pixel. -// -// Projection is a shear, as in the classic voxel renderer: a point at depth z along the view axis -// whose terrain top is h projects to ndc y = horizon + (h - camY) / (z * tanHalf.y). A pixel below -// the horizon is covered by the first sample whose top reaches it, which is the same pixel a -// running-horizon column renderer paints, found without carrying the horizon between pixels. The -// camera is above the highest terrain, so nothing is covered above the horizon (no lookups at all), -// and below it no sample shallower than (camY - VOX_HB) / (tanHalf.y * -rel) can reach the pixel, -// so the march starts there. -uniform sampler2D u_height; // RG8, the ring of 16-bit heights -uniform sampler2D u_sky; // baked sky, gamma-encoded -// u_camPos and u_tanHalf (x: tan(fov/2) * aspect, y: tan(fov/2)) come from the shared block. -uniform vec2 u_fwd; // unit forward on the ground plane (x, z) -uniform vec2 u_right; // unit right on the ground plane -uniform float u_horizon; // ndc y of the horizon -uniform vec3 u_grass; -uniform vec3 u_rock; +// A strip's fragment: the flat colour its vertex shader chose, dithered against banding. With +// TRACE_STATS, each fragment instead adds one count to red (the host blends additively), so the +// target reads back as fragments per pixel. uniform float u_frame; -in vec2 v_uv; -layout(location = 0) out vec4 o_color; // rgb gamma-encoded; with TRACE_STATS, a: lookups spent / 255 - -float cellHeight(ivec2 c) -{ - vec2 t = floor(texelFetch(u_height, c & (VOX_RING - 1), 0).rg * 255.0 + 0.5); - return mix(-VOX_HB, VOX_HB, (t.x * 256.0 + t.y) / 65535.0); -} - -vec3 skyAt(float az, float el) -{ - vec3 c = texture(u_sky, vec2(az * 0.15915494 + 0.5, el * 0.63661977)).rgb; - return pow(c, vec3(2.2)); -} +flat in vec3 v_color; +layout(location = 0) out vec4 o_color; void main() { - vec2 ndc = v_uv * 2.0 - 1.0; - vec2 d = u_fwd + u_right * (ndc.x * u_tanHalf.x); // ground direction per unit of view depth - float dl = length(d); - float rel = ndc.y - u_horizon; // ndc height above the horizon - float az = atan(d.y, d.x); - float camY = u_camPos.y; - float zFar = VOX_FAR / dl; - - int lookups = 0; - float z = 0.0; - float h = 0.0; - ivec2 cell = ivec2(0); - bool hit = false; - if (rel < 0.0) - { - z = max(VOX_NEAR, (camY - VOX_HB) / (-rel * u_tanHalf.y)); - for (int i = 0; i < VOX_MAX_LOOKUPS; i++) - { - if (z >= zFar) - break; - cell = ivec2(floor((u_camPos.xz + d * z) * (1.0 / VOX_CELL))); - h = cellHeight(cell); - lookups++; - if (rel <= (h - camY) / (z * u_tanHalf.y)) - { - hit = true; - break; - } - z += max(VOX_STEP_MIN, z * VOX_STEP_REL); - } - } - - vec3 horizonCol = skyAt(az, 0.0); - vec3 col; - if (hit) - { - float hx = cellHeight(cell + ivec2(1, 0)); - float hz = cellHeight(cell + ivec2(0, 1)); - vec3 n = normalize(vec3((h - hx) / VOX_CELL, 1.0, (h - hz) / VOX_CELL)); - float lit = max(dot(n, u_sunDir), 0.0); - float high = smoothstep(0.1, 0.9, (h + VOX_HB) / (2.0 * VOX_HB)); - vec3 albedo = mix(u_grass, u_rock, clamp((1.0 - n.y) * 4.0 + high * 0.6, 0.0, 1.0)); - col = albedo * (u_skyTop * 5.0 + u_fogColor * 2.0 + u_sunColor * (0.3 * lit)); - col = mix(col, horizonCol * 0.6 + u_fogColor, 1.0 - exp(-z * dl * VOX_FOG)); - } - else - { - float el = rel > 0.0 ? atan(rel * u_tanHalf.y, dl) : 0.0; - col = skyAt(az, el); - } - - float dither = (ign(gl_FragCoord.xy, u_frame) - 0.5) / 255.0; - o_color = vec4(pow(clamp(col, 0.0, 1.0), vec3(1.0 / 2.2)) + dither, 1.0); #ifdef TRACE_STATS - o_color.a = float(lookups) / 255.0; + o_color = vec4(1.0 / 255.0, 0.0, 0.0, 1.0); +#else + float dither = (ign(gl_FragCoord.xy, u_frame) - 0.5) / 255.0; + o_color = vec4(v_color + dither, 1.0); #endif } diff --git a/src/app/features/scene/shaders/voxelBackdrop.frag.glsl b/src/app/features/scene/shaders/voxelBackdrop.frag.glsl new file mode 100644 index 00000000..3b9d0d29 --- /dev/null +++ b/src/app/features/scene/shaders/voxelBackdrop.frag.glsl @@ -0,0 +1,22 @@ +// The sky behind the strips, one fullscreen triangle: the baked sky by azimuth and elevation above +// the horizon. Below the horizon it is the horizon colour, which is what shows past the last strip. +uniform sampler2D u_sky; // baked sky, gamma-encoded +// u_tanHalf comes from the shared block. +uniform vec2 u_fwd; // unit forward on the ground plane (x, z) +uniform vec2 u_right; // unit right on the ground plane +uniform float u_horizon; // ndc y of the horizon +uniform float u_frame; + +in vec2 v_uv; +layout(location = 0) out vec4 o_color; + +void main() +{ + vec2 ndc = v_uv * 2.0 - 1.0; + vec2 d = u_fwd + u_right * (ndc.x * u_tanHalf.x); + float rel = ndc.y - u_horizon; + float el = rel > 0.0 ? atan(rel * u_tanHalf.y, length(d)) : 0.0; + vec3 c = texture(u_sky, vec2(atan(d.y, d.x) * 0.15915494 + 0.5, el * 0.63661977)).rgb; + float dither = (ign(gl_FragCoord.xy, u_frame) - 0.5) / 255.0; + o_color = vec4(c + dither, 1.0); +} diff --git a/src/app/features/scene/shaders/voxelStrips.vert.glsl b/src/app/features/scene/shaders/voxelStrips.vert.glsl new file mode 100644 index 00000000..dade8d92 --- /dev/null +++ b/src/app/features/scene/shaders/voxelStrips.vert.glsl @@ -0,0 +1,95 @@ +// The terrain, painter's order. One instance is one strip: a screen column (one pixel wide) at one +// depth step. The host draws steps far to near with no depth test, so a nearer strip overwrites +// what it hides, which is the classic voxel renderer's occlusion without a running horizon. +// +// Projection is a shear: a point at depth z along the view axis whose terrain top is h projects to +// ndc y = horizon + (h - camY) / (z * tanHalf.y). A strip runs from its own top down to the top of +// the next nearer strip, or to the screen bottom for the first. Where the nearer top is higher the +// quad is upside down, but it then lies below that nearer top, and the nearer strips drawn after +// it cover everything below their tops, so no test for "hidden" is needed and no vertex has to +// read both heights to place itself. No pixel below a strip's top is left unpainted either: +// the nearer strips cover the rest. +// +// Four vertices (a triangle strip): 0,1 are the bottom edge, 2,3 the top edge, 0,2 the left side. +// A bottom vertex reads the nearer neighbour's height; a top vertex its own. The colour is +// computed on the top vertices (which also read the neighbour's, for the slope) and leaves as a +// flat varying; the provoking vertex of a GL ES triangle is its last, which for both triangles of +// the strip is a top vertex. +uniform sampler2D u_height; // RG8, the ring of 16-bit heights +uniform sampler2D u_sky; // baked sky, gamma-encoded +// u_camPos, u_tanHalf (x: tan(fov/2) * aspect, y: tan(fov/2)) and the palette come from the shared block. +uniform vec2 u_fwd; // unit forward on the ground plane (x, z) +uniform vec2 u_right; // unit right on the ground plane +uniform float u_horizon; // ndc y of the horizon +uniform vec3 u_grass; +uniform vec3 u_rock; +uniform int u_columns; // screen columns, one per drawing-buffer pixel +uniform int u_firstStep; // nearest step drawn; its strips run to the screen bottom + +flat out vec3 v_color; // gamma-encoded + +float cellHeight(ivec2 c) +{ + vec2 t = floor(texelFetch(u_height, c & (VOX_RING - 1), 0).rg * 255.0 + 0.5); + return mix(-VOX_HB, VOX_HB, (t.x * 256.0 + t.y) / 65535.0); +} + +// The closed form of z += max(VOX_STEP_MIN, z * VOX_STEP_REL) from VOX_NEAR; voxelWorld.ts has the same. +float stepDepth(int k) +{ + return k <= VOX_LINEAR + ? VOX_NEAR + float(k) * VOX_STEP_MIN + : VOX_GROW_FROM * pow(1.0 + VOX_STEP_REL, float(k - VOX_LINEAR)); +} + +void main() +{ + int strip = gl_InstanceID; + bool top = gl_VertexID >= 2; + float side = float(gl_VertexID & 1) * 2.0 - 1.0; + int column = strip % u_columns; + int k = VOX_STEPS - 1 - strip / u_columns; // far to near + + float cx = (float(column) + 0.5) / float(u_columns) * 2.0 - 1.0; + vec2 d = u_fwd + u_right * (cx * u_tanHalf.x); // ground direction per unit of view depth + float dl = length(d); + float z = stepDepth(k); + v_color = vec3(0.0); + gl_Position = vec4(cx + side / float(u_columns), -1.0, 0.0, 1.0); + if (z * dl >= VOX_FAR) + { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); // outside the column's far plane: clipped + return; + } + + int kp = max(k - 1, 0); + // The nearer neighbour's depth, without a second pow: one growth step back, or one linear step. + float zp = k > VOX_LINEAR + 1 ? z * (1.0 / (1.0 + VOX_STEP_REL)) : VOX_NEAR + float(kp) * VOX_STEP_MIN; + vec2 cam = u_camPos.xz; + vec2 base = cam * (1.0 / VOX_CELL); + vec2 dcell = d * (1.0 / VOX_CELL); + if (!top) + { + // Bottom edge: the nearer strip's top. If that is higher than this strip's own top the quad is + // upside down, but it then lies below the nearer top, where nearer strips are drawn over it. + float hp = cellHeight(ivec2(floor(base + dcell * zp))); + float yNear = k == u_firstStep ? -1.0 : u_horizon + (hp - u_camPos.y) / (zp * u_tanHalf.y); + gl_Position.y = clamp(yNear, -1.0, 1.0); + return; + } + float h = cellHeight(ivec2(floor(base + dcell * z))); + gl_Position.y = clamp(u_horizon + (h - u_camPos.y) / (z * u_tanHalf.y), -1.0, 1.0); + // Colour by height and slope along the view ray, then fog toward the sky's horizon colour. + float hp = cellHeight(ivec2(floor(base + dcell * zp))); + float slope = (h - hp) / max((z - zp) * dl, 1e-3); + vec2 dn = d / dl; + vec3 n = normalize(vec3(-slope * dn.x, 1.0, -slope * dn.y)); + float lit = max(dot(n, u_sunDir), 0.0); + float high = smoothstep(0.1, 0.9, (h + VOX_HB) / (2.0 * VOX_HB)); + vec3 albedo = mix(u_grass, u_rock, clamp((1.0 - n.y) * 4.0 + high * 0.6, 0.0, 1.0)); + vec3 col = albedo * (u_skyTop * 5.0 + u_fogColor * 2.0 + u_sunColor * (0.3 * lit)); + float az = atan(d.y, d.x); + vec3 horizonCol = pow(textureLod(u_sky, vec2(az * 0.15915494 + 0.5, 0.0), 0.0).rgb, vec3(2.2)); + col = mix(col, horizonCol * 0.6 + u_fogColor, 1.0 - exp(-z * dl * VOX_FOG)); + v_color = pow(clamp(col, 0.0, 1.0), vec3(1.0 / 2.2)); +} diff --git a/src/app/features/scene/voxel/Voxel.test.ts b/src/app/features/scene/voxel/Voxel.test.ts index acadfefc..4077e2b0 100644 --- a/src/app/features/scene/voxel/Voxel.test.ts +++ b/src/app/features/scene/voxel/Voxel.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { fakeCanvas, fakeGl } from '../fakeGl'; import heightStage from '../shaders/voxelHeight.frag.glsl?raw'; -import { fragmentSource } from '../shaderSource'; +import { fragmentSource, vertexSourceFor } from '../shaderSource'; import { startBackgroundScene } from '../index'; import { createVoxelScene, SCALE_LEVELS } from './Voxel'; -import { VOXEL, voxelDefines } from './voxelWorld'; +import { STEPS, VOXEL, voxelDefines } from './voxelWorld'; const N = VOXEL.ring; let rafQueue: Array<(t: number) => void>; @@ -94,10 +94,11 @@ describe('voxel scene contract', () => { }); describe('voxel ring generation', () => { - it('the first frame bakes the sky, fills the whole ring once, and draws the terrain', () => { + it('the first frame bakes the sky, fills the whole ring once, and draws backdrop then strips', () => { const { calls } = build(); runFrame(1000); - expect(calls.drawArrays).toHaveBeenCalledTimes(3); + expect(calls.drawArrays).toHaveBeenCalledTimes(3); // sky bake, ring fill, backdrop + expect(calls.drawArraysInstanced).toHaveBeenCalledTimes(1); expect(calls.scissor.mock.calls).toEqual([[0, 0, N, N]]); }); @@ -105,7 +106,8 @@ describe('voxel ring generation', () => { const { calls } = build(); runFrame(1000); runFrame(1034); - expect(calls.drawArrays).toHaveBeenCalledTimes(4); // one more draw: the terrain + expect(calls.drawArrays).toHaveBeenCalledTimes(4); // one more backdrop + expect(calls.drawArraysInstanced).toHaveBeenCalledTimes(2); expect(calls.scissor).toHaveBeenCalledTimes(1); }); @@ -131,8 +133,39 @@ describe('voxel ring generation', () => { }); }); +describe('voxel draw pass', () => { + it('draws one instance per column and step, four vertices each, with no depth buffer', () => { + const { calls, canvas } = build(); + runFrame(1000); + const [mode, first, count, instances] = calls.drawArraysInstanced.mock.calls[0]; + expect(count).toBe(4); + expect(first).toBe(0); + expect(mode).toBeDefined(); + expect(instances % canvas.width).toBe(0); // whole columns + expect(instances / canvas.width).toBeLessThanOrEqual(STEPS); + expect(instances / canvas.width).toBeGreaterThan(STEPS / 2); // the near steps below the screen are skipped, not most of them + expect(canvas.getContext.mock.calls[0][1].depth).toBe(false); // no depth buffer: order is the draw order + }); + + it('the drawing buffer is the pixel budget in the canvas aspect, 320x180 at 16:9', () => { + const wide = build(); + wide.canvas.clientWidth = 1280; + wide.canvas.clientHeight = 720; + runFrame(1000); + expect([wide.canvas.width, wide.canvas.height]).toEqual([320, 180]); + rafQueue.length = 0; + const tall = build(); + tall.canvas.clientWidth = 360; + tall.canvas.clientHeight = 800; + runFrame(1034); + expect(tall.canvas.width * tall.canvas.height).toBeGreaterThan(VOXEL.pixels * 0.98); + expect(tall.canvas.width * tall.canvas.height).toBeLessThan(VOXEL.pixels * 1.02); + expect(tall.canvas.height / tall.canvas.width).toBeCloseTo(800 / 360, 1); + }); +}); + describe('voxel measurement and adaptation', () => { - it('reads lookups per pixel back from alpha and times the draw, only when asked', () => { + it('reads fragment counts back and times the draw, only when asked', () => { const plain = build(); runFrame(1000); expect(plain.calls.readPixels).toBeUndefined(); @@ -144,35 +177,47 @@ describe('voxel measurement and adaptation', () => { runFrame(1034); expect(m.calls.readPixels).toHaveBeenCalledTimes(2); // frame 0 of every 8: a one-texel sync, then the counted frame expect(onTraceStats).toHaveBeenCalledTimes(1); + expect(onTraceStats.mock.calls[0][0].overdraw).toBe(0); // the fake GL shaded nothing expect(m.canvas.getContext.mock.calls[0][1].alpha).toBe(false); // the canvas is opaque; the count is drawn off screen expect(onFrameStats).toHaveBeenCalledTimes(1); // the first frame has no predecessor - expect(onFrameStats.mock.calls[0][0].frameMs).toBe(34); + const stats = onFrameStats.mock.calls[0][0]; + expect(stats.frameMs).toBe(34); + expect(stats.vertices).toBeGreaterThan(0); + expect(stats.lookups).toBeGreaterThan(0); + expect(stats.lookups).toBeLessThanOrEqual(stats.vertices * 2); }); it('steps the drawing buffer down when frames keep missing the cap, and never back up', () => { const { canvas } = build({ unthrottled: true }); let now = 1000; runFrame(now); - expect(canvas.width).toBe(Math.round(400 * SCALE_LEVELS[0].scale)); + const widthAt = (level: number) => + Math.round(Math.sqrt(VOXEL.pixels * (400 / 300)) * SCALE_LEVELS[level].scale); + expect(canvas.width).toBe(widthAt(0)); for (let i = 0; i < 60; i++) runFrame((now += 100)); - expect(canvas.width).toBe(Math.round(400 * SCALE_LEVELS[1].scale)); + expect(canvas.width).toBe(widthAt(1)); for (let i = 0; i < 200; i++) runFrame((now += 10)); - expect(canvas.width).toBe(Math.round(400 * SCALE_LEVELS[1].scale)); + expect(canvas.width).toBe(widthAt(1)); }); it("'low' starts below the top of the ladder", () => { const { canvas } = build({ tier: 'low' }); runFrame(1000); - expect(canvas.width).toBe(Math.round(400 * SCALE_LEVELS[2].scale)); + expect(canvas.width).toBe( + Math.round(Math.sqrt(VOXEL.pixels * (400 / 300)) * SCALE_LEVELS[1].scale) + ); }); }); describe('voxel shaders', () => { - it('the march is capped at the lookup budget and the pass uses no extension', () => { - const src = fragmentSource('voxel', voxelDefines()); - expect(src).toContain(`#define VOX_MAX_LOOKUPS ${VOXEL.maxLookups}`); - expect(src).not.toContain('#extension'); - expect(src).toContain('texelFetch(u_height'); + it('the strips read the ring in the vertex shader and use no extension', () => { + const vs = vertexSourceFor('voxel', voxelDefines()); + expect(vs).toContain('texelFetch(u_height'); + expect(vs).toContain(`#define VOX_STEPS ${STEPS}`); + expect(vs).not.toContain('#extension'); + const fs = fragmentSource('voxel', voxelDefines()); + expect(fs).not.toContain('texelFetch'); // the fragment is a flat colour + expect(fs).not.toContain('#extension'); }); it('ring generation is a pure function of the cell and uses the shipped terrain', () => { @@ -185,7 +230,7 @@ describe('voxel shaders', () => { }); describe('the door picks the style', () => { - it('voxel by default: one frame is sky, ring and terrain, three draws', async () => { + it('voxel by default: one frame is sky, ring and backdrop, then the strips', async () => { const { gl, calls } = fakeGl(); const scene = await startBackgroundScene( { tier: 'still' }, @@ -195,6 +240,7 @@ describe('the door picks the style', () => { expect(scene).not.toBeNull(); runFrame(1000); expect(calls.drawArrays).toHaveBeenCalledTimes(3); + expect(calls.drawArraysInstanced).toHaveBeenCalledTimes(1); }); it("'raymarch' keeps the sphere-traced scene", async () => { diff --git a/src/app/features/scene/voxel/Voxel.ts b/src/app/features/scene/voxel/Voxel.ts index bcfc94b7..bf265a9a 100644 --- a/src/app/features/scene/voxel/Voxel.ts +++ b/src/app/features/scene/voxel/Voxel.ts @@ -1,7 +1,8 @@ -// The voxel terrain scene: a wrapping ring of cell heights around the camera, drawn by one fullscreen -// pass. Same createScene contract as the ray-march scene, but WebGL2 core only: no extensions, no -// float targets. Heights are 16 bits in an RG8 ring; the sky is baked once into RGBA8; the drawing -// buffer is the internal resolution, and CSS scales it up. +// The voxel terrain scene: a wrapping ring of cell heights around the camera, drawn painter's order +// as instanced vertical strips over a baked sky. Same createScene contract as the ray-march scene, +// but WebGL2 core only: no extensions, no float targets. Heights are 16 bits in an RG8 ring that the +// strips' vertex shader reads; the sky is baked once into RGBA8; the drawing buffer is a fixed pixel +// budget (320x180 at the top of the ladder) in the canvas's aspect, and CSS scales it up. import type { FrameStats, Scene, SceneOptions } from '../Scene'; import { BudgetLadder } from '../budget'; import { pullProgress } from '../camera'; @@ -10,29 +11,36 @@ import { linkStage, ShaderCompileError } from '../link'; import { PALETTE } from '../palette'; import { Cell, ringRects, windowOrigin } from './ring'; import { voxelPose } from './rails'; -import { VOXEL, voxelDefines } from './voxelWorld'; +import { + firstStep, + liveStrips, + LOOKUPS_PER_STRIP, + STEPS, + VERTICES_PER_STRIP, + VOXEL, + voxelDefines, +} from './voxelWorld'; -const MAX_DPR = 1.5; const SKY_W = 512; const SKY_H = 128; -/** Measurement readback of lookups per pixel, every this many frames. */ +/** Measurement readback of fragment counts, every this many frames. */ const READBACK_EVERY = 8; /** Median cadence above this, over a window, drops one level: the 30 fps cap with some grace. */ const SLOW_MS = FRAME_INTERVAL_MS * 1.2; export interface ScaleLevel { - /** Drawing-buffer size as a fraction of the canvas's CSS size times device pixel ratio. */ + /** Drawing-buffer side as a fraction of the default's: pixels = VOXEL.pixels * scale^2, in the canvas's aspect. */ scale: number; } /** Cheapest last. The ladder only descends. */ export const SCALE_LEVELS: readonly ScaleLevel[] = [ - { scale: 0.75 }, - { scale: 0.6 }, + { scale: 1 }, + { scale: 0.8 }, + { scale: 0.64 }, { scale: 0.5 }, - { scale: 0.4 }, ]; -const LOW_START_LEVEL = 2; +const LOW_START_LEVEL = 1; export function createVoxelScene(opts: SceneOptions): Scene | null { const { canvas } = opts; @@ -52,13 +60,14 @@ export function createVoxelScene(opts: SceneOptions): Scene | null { return null; } - // `counted` is the terrain pass with its lookup count written to alpha; it exists only for a - // measuring scene, which draws it to an offscreen target (the canvas has no alpha to spare). - type Name = 'height' | 'sky' | 'voxel' | 'counted'; + // `counted` is the strip pass with each fragment writing one count; it exists only for a + // measuring scene, which draws it blended to an offscreen target. + type Name = 'height' | 'sky' | 'backdrop' | 'voxel' | 'counted'; const programs = {} as Record; - const sources: Array<[Name, 'voxelHeight' | 'voxelSky' | 'voxel', string]> = [ + const sources: Array<[Name, 'voxelHeight' | 'voxelSky' | 'voxelBackdrop' | 'voxel', string]> = [ ['height', 'voxelHeight', ''], ['sky', 'voxelSky', ''], + ['backdrop', 'voxelBackdrop', ''], ['voxel', 'voxel', ''], ]; if (measuring) sources.push(['counted', 'voxel', '#define TRACE_STATS 1\n']); @@ -156,15 +165,19 @@ export function createVoxelScene(opts: SceneOptions): Scene | null { SCALE_LEVELS ); const scale = () => (opts.fixedLevel ? opts.fixedLevel.solidScale : ladder.current.scale); + const FALLBACK_ASPECT = 16 / 9; let canvasW = 0; let canvasH = 0; let sizedFor = 0; const ensureSize = () => { - const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); const s = scale(); - const w = Math.max(2, Math.round(canvas.clientWidth * dpr * s)); - const h = Math.max(2, Math.round(canvas.clientHeight * dpr * s)); + const aspect = + canvas.clientWidth > 0 && canvas.clientHeight > 0 + ? canvas.clientWidth / canvas.clientHeight + : FALLBACK_ASPECT; + const w = Math.max(2, Math.round(Math.sqrt(VOXEL.pixels * aspect) * s)); + const h = Math.max(2, Math.round(Math.sqrt(VOXEL.pixels / aspect) * s)); if (w === canvasW && h === canvasH) return; canvasW = w; canvasH = h; @@ -209,10 +222,18 @@ export function createVoxelScene(opts: SceneOptions): Scene | null { const [px, py, pz] = pose.position; generate({ x: Math.floor(px / VOXEL.cell), z: Math.floor(pz / VOXEL.cell) }); + const tanY = Math.tan(pose.fov / 2); + const tanX = tanY * (canvasW / canvasH); + // The shear: the horizon sits above the centre by the amount the camera looks down. + const horizon = -Math.tan(pose.pitch) / tanY; + const first = firstStep(py, horizon, tanY); + const instances = canvasW * (STEPS - first); + const vertices = instances * VERTICES_PER_STRIP + 3; + const lookups = liveStrips(canvasW, first, tanX) * LOOKUPS_PER_STRIP; + const timed = measuring && frame % READBACK_EVERY === 0; const t0 = timed ? performance.now() : 0; - const drawTerrain = (p: WebGLProgram) => { - gl.viewport(0, 0, canvasW, canvasH); + const bindWorld = (p: WebGLProgram) => { gl.useProgram(p); palette(p); vec3(p, 'u_grass', PALETTE.grass); @@ -220,21 +241,28 @@ export function createVoxelScene(opts: SceneOptions): Scene | null { gl.uniform3f(loc(p, 'u_camPos'), px, py, pz); gl.uniform2f(loc(p, 'u_fwd'), Math.cos(pose.yaw), Math.sin(pose.yaw)); gl.uniform2f(loc(p, 'u_right'), Math.sin(pose.yaw), -Math.cos(pose.yaw)); - const tanY = Math.tan(pose.fov / 2); - gl.uniform2f(loc(p, 'u_tanHalf'), tanY * (canvasW / canvasH), tanY); - // The shear: the horizon sits above the centre by the amount the camera looks down. - gl.uniform1f(loc(p, 'u_horizon'), -Math.tan(pose.pitch) / tanY); + gl.uniform2f(loc(p, 'u_tanHalf'), tanX, tanY); + gl.uniform1f(loc(p, 'u_horizon'), horizon); gl.uniform1f(loc(p, 'u_frame'), frame); + gl.uniform1i(loc(p, 'u_columns'), canvasW); + gl.uniform1i(loc(p, 'u_firstStep'), first); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, ringTex); gl.uniform1i(loc(p, 'u_height'), 0); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, skyTex); gl.uniform1i(loc(p, 'u_sky'), 1); - draw(); + }; + // Far to near, no depth test, no blending: the later strip is the nearer one and overwrites. + const drawStrips = (p: WebGLProgram) => { + bindWorld(p); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, VERTICES_PER_STRIP, instances); }; gl.bindFramebuffer(gl.FRAMEBUFFER, null); - drawTerrain(programs.voxel); + gl.viewport(0, 0, canvasW, canvasH); + bindWorld(programs.backdrop); + draw(); + drawStrips(programs.voxel); let renderMs: number | undefined; if (timed) { @@ -242,21 +270,39 @@ export function createVoxelScene(opts: SceneOptions): Scene | null { // queued GPU (SwiftShader and phones alike) would otherwise not report. gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4)); renderMs = performance.now() - t0; - // Then the same frame again with its lookup counts in alpha, off screen, and read back. + // Then the strips again, off screen, each fragment adding one to red, and read back. if (counted.w !== canvasW || counted.h !== canvasH) resizeCounted(); - gl.bindFramebuffer(gl.FRAMEBUFFER, counted.fbo); - drawTerrain(programs.counted); + gl.bindFramebuffer(gl.FRAMEBUFFER, counted.fbo ?? null); + gl.viewport(0, 0, canvasW, canvasH); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE); + drawStrips(programs.counted); + gl.disable(gl.BLEND); const px4 = new Uint8Array(canvasW * canvasH * 4); gl.readPixels(0, 0, canvasW, canvasH, gl.RGBA, gl.UNSIGNED_BYTE, px4); gl.bindFramebuffer(gl.FRAMEBUFFER, null); let sum = 0; - for (let i = 3; i < px4.length; i += 4) sum += px4[i]; - const mean = sum / (canvasW * canvasH); - opts.onTraceStats?.({ traceSteps: mean, prepassSteps: 0, stepsPerPixel: mean }); + for (let i = 0; i < px4.length; i += 4) sum += px4[i]; + const perPixel = lookups / (canvasW * canvasH); + opts.onTraceStats?.({ + traceSteps: perPixel, + prepassSteps: 0, + stepsPerPixel: perPixel, + overdraw: sum / (canvasW * canvasH), + }); } if (prevNow !== undefined) { const frameMs = now - prevNow; - opts.onFrameStats?.({ frameMs, renderMs, scale: sizedFor, size: [canvasW, canvasH] }); + opts.onFrameStats?.({ + frameMs, + renderMs, + scale: sizedFor, + size: [canvasW, canvasH], + vertices, + lookups, + }); if (!opts.fixedLevel && ladder.push(frameMs)) canvasW = 0; // resize on the next frame } prevNow = now; @@ -279,6 +325,7 @@ export function createVoxelScene(opts: SceneOptions): Scene | null { if (counted.fbo) gl.deleteFramebuffer(counted.fbo); if (measuring) gl.deleteProgram(programs.counted); gl.deleteProgram(programs.height); + gl.deleteProgram(programs.backdrop); gl.deleteProgram(programs.voxel); gl.deleteVertexArray(vao); }, diff --git a/src/app/features/scene/voxel/voxelWorld.test.ts b/src/app/features/scene/voxel/voxelWorld.test.ts new file mode 100644 index 00000000..5b22b8f3 --- /dev/null +++ b/src/app/features/scene/voxel/voxelWorld.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { firstStep, heightBound, liveStrips, STEPS, stepDepth, VOXEL } from './voxelWorld'; + +describe('the strip schedule', () => { + it('is the closed form of z += max(stepMin, z * stepRel) from near', () => { + let z: number = VOXEL.near; + for (let k = 0; k < STEPS; k++) { + expect(stepDepth(k)).toBeCloseTo(z, 6); + z += Math.max(VOXEL.stepMin, z * VOXEL.stepRel); + } + }); + + it('covers every depth short of the far distance and no further', () => { + const far = VOXEL.farCells * VOXEL.cell; + expect(stepDepth(STEPS - 1)).toBeLessThan(far); + expect(stepDepth(STEPS)).toBeGreaterThanOrEqual(far); + }); +}); + +describe('where the strips begin', () => { + const camY = heightBound() + VOXEL.clearance; + const tanY = Math.tan(0.5); + const topAt = (k: number, h: number, horizon: number) => + horizon + (h - camY) / (stepDepth(k) * tanY); + + it('skips only strips whose every possible top is below the screen', () => { + const horizon = 0.2; + const first = firstStep(camY, horizon, tanY); + expect(first).toBeGreaterThan(0); + expect(topAt(first - 1, heightBound(), horizon)).toBeLessThan(-1); + expect(topAt(first, heightBound(), horizon)).toBeGreaterThanOrEqual(-1 - 1e-9); + }); + + it('starts nearer when the camera is lower or the view steeper', () => { + expect(firstStep(camY + 45, 0.3, tanY)).toBeGreaterThan(firstStep(camY, 0.3, tanY)); + expect(firstStep(camY, 0.5, tanY)).toBeLessThan(firstStep(camY, 0.1, tanY)); + }); +}); + +describe('strips a frame shades', () => { + it('loses the far corners of a wide view to the far plane, and nothing of a narrow one', () => { + const wide = liveStrips(320, 10, 1.0); + expect(wide).toBeLessThan(liveStrips(320, 10, 0.01)); + expect(liveStrips(320, 10, 0.01)).toBeCloseTo(320 * (STEPS - 10), -3); + }); +}); diff --git a/src/app/features/scene/voxel/voxelWorld.ts b/src/app/features/scene/voxel/voxelWorld.ts index 4fbbd226..ab02fcd1 100644 --- a/src/app/features/scene/voxel/voxelWorld.ts +++ b/src/app/features/scene/voxel/voxelWorld.ts @@ -10,13 +10,13 @@ export const VOXEL = { cell: 8, /** Furthest sample, in cells from the camera. The ring holds 128 each way; the margin keeps a ray from reading cells the camera has not generated. */ farCells: 120, - /** Height lookups one column march may spend. */ - maxLookups: 128, - /** First sample depth along the view axis. */ + /** First strip depth along the view axis. */ near: 2, - /** A march step is max(stepMin, depth * stepRel): about a cell up close, coarser far away. */ - stepMin: 4.8, - stepRel: 0.02, + /** A strip step is max(stepMin, depth * stepRel): about a cell up close, coarser far away. */ + stepMin: 8, + stepRel: 0.03, + /** Pixel budget of the drawing buffer at scale 1 (320x180); the buffer takes the canvas's aspect. */ + pixels: 320 * 180, /** Fog: 1 - exp(-distance * fog) of the way to the horizon colour. */ fog: 0.0016, /** Camera altitude above the highest possible terrain, so the camera is never inside a column. */ @@ -31,6 +31,61 @@ export const heightBound = (): number => terrainBounds().maxHeight; /** Half the ring, in cells: the camera cell sits at the centre of the window. */ export const HALF_RING = VOXEL.ring / 2; +/** + * The strip schedule, one copy for the shader and the host. Depth k along the view axis is + * near + k * stepMin while a step is the minimum one, then grows by (1 + stepRel) per strip: the + * closed form of z += max(stepMin, z * stepRel), so a vertex shader can evaluate any k directly. + */ +const LINEAR_STEPS = Math.ceil((VOXEL.stepMin / VOXEL.stepRel - VOXEL.near) / VOXEL.stepMin); +const GROW_FROM = VOXEL.near + LINEAR_STEPS * VOXEL.stepMin; + +export const stepDepth = (k: number): number => + k <= LINEAR_STEPS + ? VOXEL.near + k * VOXEL.stepMin + : GROW_FROM * (1 + VOXEL.stepRel) ** (k - LINEAR_STEPS); + +/** Strips per column: the first k whose depth reaches the far distance on the view axis (a column's own far is nearer). */ +export const STEPS = (() => { + let k = 0; + while (stepDepth(k) < VOXEL.farCells * VOXEL.cell) k += 1; + return k; +})(); + +/** The farthest depth a column with ground direction length `dl` draws to: its far plane is a distance, not a depth. */ +const columnReach = (dl: number) => (VOXEL.farCells * VOXEL.cell) / dl; + +/** + * Where the strips begin for a camera at `camY` with the horizon at `horizon` (ndc). Terrain tops + * at most heightBound high, so a strip nearer than this projects entirely below the screen; the + * first strip drawn runs down to the screen bottom instead of to its nearer neighbour's top. + */ +export function firstStep(camY: number, horizon: number, tanY: number): number { + const reach = (camY - heightBound()) / ((1 + horizon) * tanY); + let k = 0; + while (k < STEPS - 1 && stepDepth(k) < reach) k += 1; + return k; +} + +/** How many strips a frame shades: those from `first` out, per column, that lie inside the column's far plane. */ +export function liveStrips(columns: number, first: number, tanX: number): number { + let n = 0; + for (let c = 0; c < columns; c++) { + const x = ((c + 0.5) / columns) * 2 - 1; + const reach = columnReach(Math.hypot(1, x * tanX)); + let k = first; + while (k < STEPS && stepDepth(k) < reach) k += 1; + n += k - first; + } + return n; +} + +/** + * Ring lookups per live strip: each bottom vertex reads the nearer neighbour's height, each top + * vertex its own, and the two top vertices that carry the strip's colour read the neighbour's too. + */ +export const LOOKUPS_PER_STRIP = 6; +export const VERTICES_PER_STRIP = 4; + const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`); /** `#define` lines every voxel stage sees. */ @@ -39,7 +94,9 @@ export function voxelDefines(): string { `#define VOX_RING ${VOXEL.ring}`, `#define VOX_CELL ${f(VOXEL.cell)}`, `#define VOX_FAR ${f(VOXEL.farCells * VOXEL.cell)}`, - `#define VOX_MAX_LOOKUPS ${VOXEL.maxLookups}`, + `#define VOX_STEPS ${STEPS}`, + `#define VOX_LINEAR ${LINEAR_STEPS}`, + `#define VOX_GROW_FROM ${f(GROW_FROM)}`, `#define VOX_NEAR ${f(VOXEL.near)}`, `#define VOX_STEP_MIN ${f(VOXEL.stepMin)}`, `#define VOX_STEP_REL ${f(VOXEL.stepRel)}`, -- 2.47.3 From 8732cd549d2e8a6a0fe3e04e08642a5e26d52a5d Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 23:27:21 +0000 Subject: [PATCH 6/7] scene: strips in one plain draw, not instanced On SwiftShader the instanced draw (one instance per strip, four vertices) costs about 1.9 s per frame at 320x180 against 11.6 ms for the old march, nearly all of it per-instance overhead: the same strips as six vertices decoded from gl_VertexID in one drawArrays take about 11 ms. The vertex shader now reads strip and corner from gl_VertexID; no buffers, no instancing, same quads, same painter's order. VERTICES_PER_STRIP and LOOKUPS_PER_STRIP follow the new corner layout. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 17 +++++++------- .../scene/shaders/voxelStrips.vert.glsl | 22 ++++++++++-------- src/app/features/scene/voxel/Voxel.test.ts | 23 ++++++++----------- src/app/features/scene/voxel/Voxel.ts | 11 +++++---- src/app/features/scene/voxel/voxelWorld.ts | 9 ++++---- 5 files changed, 43 insertions(+), 39 deletions(-) diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index b37b76f0..4ed8b0f1 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -79,7 +79,7 @@ not evaluated. `voxel/Voxel.ts` `createVoxelScene` takes the same options and returns the same `{ login, dispose }` as `createScene`, so the door swaps one for the other. It is the cheap background: WebGL2 core only (no extension is requested, no float target is allocated). It draws the terrain painter's order, -as instanced vertical strips, instead of marching a ray per pixel. +as vertical strips, instead of marching a ray per pixel. - A wrapping ring of 256 x 256 world cells (8 units each) around the camera, in an RG8 texture holding a 16-bit height per cell. Cell (x, z) lives at texel (x mod 256, z mod 256). When the @@ -88,16 +88,17 @@ as instanced vertical strips, instead of marching a ray per pixel. Phacelle per texel, the ray-march scene's terrain without its plateau mask. A cell's height is a pure function of the cell, so the order of writes cannot change the ring. A move of a whole ring or more, or the first frame, rewrites all of it. -- `shaders/voxelStrips.vert.glsl` draws the terrain: one instance is a screen column (one buffer - pixel wide) at one depth step, and the host draws the steps far to near with no depth buffer - and no blending, so a nearer strip overwrites what it hides. A strip's vertices fetch its height +- `shaders/voxelStrips.vert.glsl` draws the terrain: a strip is a screen column (one buffer + pixel wide) at one depth step, six vertices decoded from `gl_VertexID` (no buffers) in one + `drawArrays`. The host draws the steps far to near with no depth buffer and no blending, so a nearer strip overwrites what it hides. A strip's vertices fetch its height and its nearer neighbour's from the ring (vertex texture fetch, core WebGL2) and project them with the shear `y = horizon + (h - camY) / (z * tanHalf.y)`; the quad runs from its own top - down to the nearer neighbour's top (the screen bottom for the first strip drawn), and collapses - when the neighbour is higher. Colour is chosen once per strip, by height, slope along the view + down to the nearer neighbour's top (the screen bottom for the first strip drawn); where the + neighbour is higher the quad is upside down but lies under that nearer top, which the nearer + strips drawn after it cover. Colour is chosen once per strip, by height, slope along the view ray and fog, and leaves as a flat varying (the last vertex of each strip triangle is a top vertex); `shaders/voxel.frag.glsl` writes it with a dither. Depths follow `voxelWorld.ts` - `stepDepth` (about a cell up close, 2% of the depth far away; the same closed form in GLSL and + `stepDepth` (about a cell up close, 3% of the depth far away; the same closed form in GLSL and TypeScript), strips nearer than any terrain could reach the screen are not drawn, and strips past a column's far plane (120 cells) are clipped in the vertex shader. - `shaders/voxelBackdrop.frag.glsl` is the sky behind the strips, one fullscreen triangle. @@ -112,7 +113,7 @@ as instanced vertical strips, instead of marching a ray per pixel. - `rails.ts` is the camera: a straight rail with the yaw wandering either side and sway, constant altitude above the terrain; `login()` lifts and tips it down. - Measurement without extensions: `onFrameStats` gives the cadence, the buffer size, the vertices - submitted and the ring lookups the frame costs (two per shaded strip vertex, counted on the host + submitted and the ring lookups the frame costs (eight per shaded strip, counted on the host from the same schedule the shader runs). Setting `onTraceStats` makes one frame in eight time the draw with a one-texel synchronous read, then draw the strips again off screen, each fragment adding one count, and read that back: lookups per pixel, and fragment overdraw per pixel. diff --git a/src/app/features/scene/shaders/voxelStrips.vert.glsl b/src/app/features/scene/shaders/voxelStrips.vert.glsl index dade8d92..0c361889 100644 --- a/src/app/features/scene/shaders/voxelStrips.vert.glsl +++ b/src/app/features/scene/shaders/voxelStrips.vert.glsl @@ -1,4 +1,4 @@ -// The terrain, painter's order. One instance is one strip: a screen column (one pixel wide) at one +// The terrain, painter's order. Six vertices are one strip: a screen column (one pixel wide) at one // depth step. The host draws steps far to near with no depth test, so a nearer strip overwrites // what it hides, which is the classic voxel renderer's occlusion without a running horizon. // @@ -10,11 +10,11 @@ // read both heights to place itself. No pixel below a strip's top is left unpainted either: // the nearer strips cover the rest. // -// Four vertices (a triangle strip): 0,1 are the bottom edge, 2,3 the top edge, 0,2 the left side. -// A bottom vertex reads the nearer neighbour's height; a top vertex its own. The colour is -// computed on the top vertices (which also read the neighbour's, for the slope) and leaves as a -// flat varying; the provoking vertex of a GL ES triangle is its last, which for both triangles of -// the strip is a top vertex. +// One plain drawArrays of triangles, no vertex buffers, no instancing: gl_VertexID alone says which +// strip and which corner. The six vertices are BL BR TL | TL BR TR. A bottom vertex reads the nearer +// neighbour's height; a top vertex its own. The colour is computed on the two corners that are the +// last vertex of a triangle (TL and TR, which also read the neighbour's height, for the slope) and +// leaves as a flat varying: the provoking vertex of a GL ES triangle is its last. uniform sampler2D u_height; // RG8, the ring of 16-bit heights uniform sampler2D u_sky; // baked sky, gamma-encoded // u_camPos, u_tanHalf (x: tan(fov/2) * aspect, y: tan(fov/2)) and the palette come from the shared block. @@ -44,9 +44,11 @@ float stepDepth(int k) void main() { - int strip = gl_InstanceID; - bool top = gl_VertexID >= 2; - float side = float(gl_VertexID & 1) * 2.0 - 1.0; + int strip = gl_VertexID / 6; + int corner = gl_VertexID - strip * 6; + bool top = corner == 2 || corner == 3 || corner == 5; + bool carriesColour = corner == 2 || corner == 5; + float side = (corner == 1 || corner == 4 || corner == 5) ? 1.0 : -1.0; int column = strip % u_columns; int k = VOX_STEPS - 1 - strip / u_columns; // far to near @@ -79,6 +81,8 @@ void main() } float h = cellHeight(ivec2(floor(base + dcell * z))); gl_Position.y = clamp(u_horizon + (h - u_camPos.y) / (z * u_tanHalf.y), -1.0, 1.0); + if (!carriesColour) + return; // Colour by height and slope along the view ray, then fog toward the sky's horizon colour. float hp = cellHeight(ivec2(floor(base + dcell * zp))); float slope = (h - hp) / max((z - zp) * dl, 1e-3); diff --git a/src/app/features/scene/voxel/Voxel.test.ts b/src/app/features/scene/voxel/Voxel.test.ts index 4077e2b0..741c095b 100644 --- a/src/app/features/scene/voxel/Voxel.test.ts +++ b/src/app/features/scene/voxel/Voxel.test.ts @@ -97,8 +97,8 @@ describe('voxel ring generation', () => { it('the first frame bakes the sky, fills the whole ring once, and draws backdrop then strips', () => { const { calls } = build(); runFrame(1000); - expect(calls.drawArrays).toHaveBeenCalledTimes(3); // sky bake, ring fill, backdrop - expect(calls.drawArraysInstanced).toHaveBeenCalledTimes(1); + expect(calls.drawArrays).toHaveBeenCalledTimes(4); // sky bake, ring fill, backdrop, strips + expect(calls.drawArraysInstanced).toBeUndefined(); // one plain draw expect(calls.scissor.mock.calls).toEqual([[0, 0, N, N]]); }); @@ -106,8 +106,7 @@ describe('voxel ring generation', () => { const { calls } = build(); runFrame(1000); runFrame(1034); - expect(calls.drawArrays).toHaveBeenCalledTimes(4); // one more backdrop - expect(calls.drawArraysInstanced).toHaveBeenCalledTimes(2); + expect(calls.drawArrays).toHaveBeenCalledTimes(6); // two more: backdrop, strips expect(calls.scissor).toHaveBeenCalledTimes(1); }); @@ -134,16 +133,15 @@ describe('voxel ring generation', () => { }); describe('voxel draw pass', () => { - it('draws one instance per column and step, four vertices each, with no depth buffer', () => { + it('draws one strip per column and step, six vertices each, in one plain draw with no depth buffer', () => { const { calls, canvas } = build(); runFrame(1000); - const [mode, first, count, instances] = calls.drawArraysInstanced.mock.calls[0]; - expect(count).toBe(4); + const [, first, count] = calls.drawArrays.mock.calls.at(-1)!; expect(first).toBe(0); - expect(mode).toBeDefined(); - expect(instances % canvas.width).toBe(0); // whole columns - expect(instances / canvas.width).toBeLessThanOrEqual(STEPS); - expect(instances / canvas.width).toBeGreaterThan(STEPS / 2); // the near steps below the screen are skipped, not most of them + const strips = count / 6; + expect(strips % canvas.width).toBe(0); // whole columns + expect(strips / canvas.width).toBeLessThanOrEqual(STEPS); + expect(strips / canvas.width).toBeGreaterThan(STEPS / 2); // the near steps below the screen are skipped, not most of them expect(canvas.getContext.mock.calls[0][1].depth).toBe(false); // no depth buffer: order is the draw order }); @@ -239,8 +237,7 @@ describe('the door picks the style', () => { ); expect(scene).not.toBeNull(); runFrame(1000); - expect(calls.drawArrays).toHaveBeenCalledTimes(3); - expect(calls.drawArraysInstanced).toHaveBeenCalledTimes(1); + expect(calls.drawArrays).toHaveBeenCalledTimes(4); }); it("'raymarch' keeps the sphere-traced scene", async () => { diff --git a/src/app/features/scene/voxel/Voxel.ts b/src/app/features/scene/voxel/Voxel.ts index bf265a9a..97d98cd6 100644 --- a/src/app/features/scene/voxel/Voxel.ts +++ b/src/app/features/scene/voxel/Voxel.ts @@ -1,5 +1,5 @@ // The voxel terrain scene: a wrapping ring of cell heights around the camera, drawn painter's order -// as instanced vertical strips over a baked sky. Same createScene contract as the ray-march scene, +// as vertical strips over a baked sky. Same createScene contract as the ray-march scene, // but WebGL2 core only: no extensions, no float targets. Heights are 16 bits in an RG8 ring that the // strips' vertex shader reads; the sky is baked once into RGBA8; the drawing buffer is a fixed pixel // budget (320x180 at the top of the ladder) in the canvas's aspect, and CSS scales it up. @@ -227,8 +227,8 @@ export function createVoxelScene(opts: SceneOptions): Scene | null { // The shear: the horizon sits above the centre by the amount the camera looks down. const horizon = -Math.tan(pose.pitch) / tanY; const first = firstStep(py, horizon, tanY); - const instances = canvasW * (STEPS - first); - const vertices = instances * VERTICES_PER_STRIP + 3; + const strips = canvasW * (STEPS - first); + const vertices = strips * VERTICES_PER_STRIP + 3; const lookups = liveStrips(canvasW, first, tanX) * LOOKUPS_PER_STRIP; const timed = measuring && frame % READBACK_EVERY === 0; @@ -253,10 +253,11 @@ export function createVoxelScene(opts: SceneOptions): Scene | null { gl.bindTexture(gl.TEXTURE_2D, skyTex); gl.uniform1i(loc(p, 'u_sky'), 1); }; - // Far to near, no depth test, no blending: the later strip is the nearer one and overwrites. + // Far to near, no depth test, no blending: the later strip is the nearer one and overwrites. One + // plain draw: instancing multiplies per-draw cost by the instance count in software GL. const drawStrips = (p: WebGLProgram) => { bindWorld(p); - gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, VERTICES_PER_STRIP, instances); + gl.drawArrays(gl.TRIANGLES, 0, strips * VERTICES_PER_STRIP); }; gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, canvasW, canvasH); diff --git a/src/app/features/scene/voxel/voxelWorld.ts b/src/app/features/scene/voxel/voxelWorld.ts index ab02fcd1..344cfcea 100644 --- a/src/app/features/scene/voxel/voxelWorld.ts +++ b/src/app/features/scene/voxel/voxelWorld.ts @@ -80,11 +80,12 @@ export function liveStrips(columns: number, first: number, tanX: number): number } /** - * Ring lookups per live strip: each bottom vertex reads the nearer neighbour's height, each top - * vertex its own, and the two top vertices that carry the strip's colour read the neighbour's too. + * Ring lookups per live strip: each of the three bottom vertices reads the nearer neighbour's + * height, each of the three top vertices its own, and the two that carry the strip's colour read + * the neighbour's too. */ -export const LOOKUPS_PER_STRIP = 6; -export const VERTICES_PER_STRIP = 4; +export const LOOKUPS_PER_STRIP = 8; +export const VERTICES_PER_STRIP = 6; const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`); -- 2.47.3 From 12736c5e1739e5794d7addb21b689022ebdb3b46 Mon Sep 17 00:00:00 2001 From: Nibu Date: Thu, 5 Nov 2026 00:37:08 +0000 Subject: [PATCH 7/7] scene: rewrap the over-long comment line in index.ts Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 09803283..0dd32b8d 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -12,8 +12,9 @@ export type StartOptions = Omit