diff --git a/harness/scene/README.md b/harness/scene/README.md index ef735d2e..158c0f12 100644 --- a/harness/scene/README.md +++ b/harness/scene/README.md @@ -9,6 +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, 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 027a2006..9fade865 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..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'); @@ -221,6 +221,117 @@ const [, , dir, mode, outdir = '.'] = process.argv; } } + if (mode === 'voxel') { + // 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 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(',')) { + 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), + }); + }; + 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()); + }); + } + } + } + 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..1f97d66d --- /dev/null +++ b/harness/scene/voxelDemo.ts @@ -0,0 +1,73 @@ +// 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'; +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}`, + `lookups/frame ${frames.lookups} vertices/frame ${frames.vertices}` + ); + } + 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); + +pull.addEventListener('input', start); +scaleSel.addEventListener('input', () => { + scaleName.textContent = scaleChoice(); + start(); +}); +measure.addEventListener('change', start); +start(); diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index ad4c13b1..4ed8b0f1 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,51 @@ 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). It draws the terrain painter's order, +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 + 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/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); 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, 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. +- 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 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` (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, the buffer size, the vertices + 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. + `harness/scene` has `drive.cjs voxel`, `voxelshots` 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 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 70580ab8..5d15efaf 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -1,13 +1,13 @@ -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'; 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'; @@ -23,7 +23,22 @@ export interface SceneStats { atmosphereSize: [number, number]; } -/** Trace cost over one finished readback: mean distance evaluations per pass-1 pixel. */ +/** 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]; + /** 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 (the voxel scene: ring lookups per pixel). */ export interface TraceStats { /** Mean pass-1 steps per pixel. */ traceSteps: number; @@ -31,15 +46,8 @@ export interface TraceStats { prepassSteps: number; /** traceSteps plus the prepass's steps spread over the pixels of each tile. */ stepsPerPixel: number; -} - -/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ -export type SourcePatch = (stage: Stage, source: string) => string; - -export class ShaderCompileError extends Error { - constructor(readonly stage: Stage | 'vertex', readonly log: string) { - super(`${stage} shader failed to compile:\n${log}`); - } + /** Voxel strips: mean fragments the strips shade per pixel (the painter's overdraw); the sky's one is not counted. */ + overdraw?: number; } /** @@ -92,6 +100,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). */ @@ -100,57 +112,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; @@ -379,16 +347,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 +419,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 +517,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/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/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, + }; +} diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index c896649e..0dd32b8d 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,8 +12,9 @@ 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/link.ts b/src/app/features/scene/link.ts new file mode 100644 index 00000000..c0e2c238 --- /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, 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; + +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, vertexSourceFor(stage, defines), '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/shaderSource.ts b/src/app/features/scene/shaderSource.ts index 42631d57..de03430d 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -8,22 +8,50 @@ 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 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'; -export type Stage = 'prepass' | 'terrain' | 'atmosphere' | 'resolve' | 'composite'; +export type Stage = + | 'prepass' + | 'terrain' + | 'atmosphere' + | 'resolve' + | 'composite' + | 'voxelHeight' + | 'voxelSky' + | 'voxelBackdrop' + | 'voxel'; const HEADER = '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; 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, atmosphere: atmosphereFrag, resolve: resolveFrag, composite: compositeFrag, + voxelHeight: voxelHeightFrag, + voxelSky: voxelSkyFrag, + voxelBackdrop: voxelBackdropFrag, + voxel: voxelFrag, }; /** @@ -33,9 +61,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..bb1ff20b --- /dev/null +++ b/src/app/features/scene/shaders/voxel.frag.glsl @@ -0,0 +1,17 @@ +// 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; + +flat in vec3 v_color; +layout(location = 0) out vec4 o_color; + +void main() +{ +#ifdef TRACE_STATS + 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/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/shaders/voxelStrips.vert.glsl b/src/app/features/scene/shaders/voxelStrips.vert.glsl new file mode 100644 index 00000000..0c361889 --- /dev/null +++ b/src/app/features/scene/shaders/voxelStrips.vert.glsl @@ -0,0 +1,99 @@ +// 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. +// +// 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. +// +// 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. +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_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 + + 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); + 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); + 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/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 { 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..741c095b --- /dev/null +++ b/src/app/features/scene/voxel/Voxel.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fakeCanvas, fakeGl } from '../fakeGl'; +import heightStage from '../shaders/voxelHeight.frag.glsl?raw'; +import { fragmentSource, vertexSourceFor } from '../shaderSource'; +import { startBackgroundScene } from '../index'; +import { createVoxelScene, SCALE_LEVELS } from './Voxel'; +import { STEPS, 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 backdrop then strips', () => { + const { calls } = build(); + runFrame(1000); + 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]]); + }); + + it('a frame in the same cell generates nothing', () => { + const { calls } = build(); + runFrame(1000); + runFrame(1034); + expect(calls.drawArrays).toHaveBeenCalledTimes(6); // two more: backdrop, strips + 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 draw pass', () => { + 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 [, first, count] = calls.drawArrays.mock.calls.at(-1)!; + expect(first).toBe(0); + 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 + }); + + 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 fragment counts back 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(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 + 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); + 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(widthAt(1)); + for (let i = 0; i < 200; i++) runFrame((now += 10)); + 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(Math.sqrt(VOXEL.pixels * (400 / 300)) * SCALE_LEVELS[1].scale) + ); + }); +}); + +describe('voxel shaders', () => { + 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', () => { + 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 backdrop, then the strips', 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(4); + }); + + 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..97d98cd6 --- /dev/null +++ b/src/app/features/scene/voxel/Voxel.ts @@ -0,0 +1,334 @@ +// The voxel terrain scene: a wrapping ring of cell heights around the camera, drawn painter's order +// 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. +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 { + firstStep, + liveStrips, + LOOKUPS_PER_STRIP, + STEPS, + VERTICES_PER_STRIP, + VOXEL, + voxelDefines, +} from './voxelWorld'; + +const SKY_W = 512; +const SKY_H = 128; +/** 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 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: 1 }, + { scale: 0.8 }, + { scale: 0.64 }, + { scale: 0.5 }, +]; +const LOW_START_LEVEL = 1; + +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 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' | 'voxelBackdrop' | 'voxel', string]> = [ + ['height', 'voxelHeight', ''], + ['sky', 'voxelSky', ''], + ['backdrop', 'voxelBackdrop', ''], + ['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); + const FALLBACK_ASPECT = 16 / 9; + + let canvasW = 0; + let canvasH = 0; + let sizedFor = 0; + const ensureSize = () => { + const s = scale(); + 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; + 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 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 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; + const t0 = timed ? performance.now() : 0; + const bindWorld = (p: WebGLProgram) => { + 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)); + 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); + }; + // 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.drawArrays(gl.TRIANGLES, 0, strips * VERTICES_PER_STRIP); + }; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, canvasW, canvasH); + bindWorld(programs.backdrop); + draw(); + drawStrips(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 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 ?? 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 = 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], + vertices, + lookups, + }); + 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.backdrop); + 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.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 new file mode 100644 index 00000000..344cfcea --- /dev/null +++ b/src/app/features/scene/voxel/voxelWorld.ts @@ -0,0 +1,108 @@ +// 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, + /** First strip depth along the view axis. */ + near: 2, + /** 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. */ + 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; + +/** + * 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 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 = 8; +export const VERTICES_PER_STRIP = 6; + +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_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)}`, + `#define VOX_HB ${f(heightBound())}`, + `#define VOX_FOG ${VOXEL.fog}`, + '', + ].join('\n'); +}