scene: voxel terrain as painter's-order strips, the default simple background #13

Open
nibu wants to merge 7 commits from nibu/voxel-scene into main
30 changed files with 1787 additions and 234 deletions

View file

@ -9,6 +9,8 @@ Chromium. Not shipped, not run by CI.
node drive.cjs <outdir> measure # GPU time per pass by timer query, two viewports, two levels
node drive.cjs <outdir> variants # the same with source patches (no erosion, no tree, value-only fbm)
node drive.cjs <outdir> trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms
node drive.cjs <outdir> 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 <outdir> voxelshots <pngdir> # voxel scene screenshots at the same SIZES and POSES
node drive.cjs <outdir> diff <pngdir> # one frozen frame with and without the prepass, pixel diff and both PNGs
node drive.cjs <outdir> phacelle # the Phacelle gradient probe behind world.ts `phacelleGradBound`

View file

@ -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 <outdir> [demo] (esbuild from NODE_PATH). `demo` bundles the standalone page.
// usage: node bundle.cjs <outdir> [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'),
'<!doctype html><meta charset=utf-8><title>Ratatoskr scene: trace prepass</title><body style="margin:0;background:#000"><canvas id=c style="width:100vw;height:100vh;display:block"></canvas>' +
(demo
'<!doctype html><meta charset=utf-8><title>Ratatoskr scene</title><body style="margin:0;background:#000"><canvas id=c style="width:100vw;height:100vh;display:block"></canvas>' +
(voxel
? '<div style="position:fixed;left:8px;top:8px;padding:6px 8px;background:#000a;color:#ddd;font:12px monospace"><label>pull-back <input id=pull type=range min=0 max=1 step=0.05 value=0></label> <label>scale <input id=scale type=range min=0 max=4 step=1 value=0> <span id=scaleName>auto</span></label> <label><input id=measure type=checkbox checked> count overdraw</label><pre id=out style="margin:4px 0 0;white-space:pre-wrap"></pre><details id=dev><summary>device</summary><pre id=info style="margin:4px 0 0;white-space:pre-wrap;max-height:50vh;overflow:auto"></pre></details></div>'
: demo
? '<div style="position:fixed;left:8px;top:8px;padding:6px 8px;background:#000a;color:#ddd;font:12px monospace"><label><input id=prepass type=checkbox checked> cone prepass</label> <label><input id=half type=checkbox> half-float start</label> <label>pull-back <input id=pull type=range min=0 max=1 step=0.05 value=0.6></label><pre id=out style="margin:4px 0 0;white-space:pre-wrap"></pre><details id=dev><summary>device</summary><pre id=info style="margin:4px 0 0;white-space:pre-wrap;max-height:50vh;overflow:auto"></pre></details></div>'
: '') +
'<script src=bundle.js></script>'

View file

@ -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<SceneUnavailable, string> = {
'no-webgl2': 'this browser gave no WebGL2 context',
'no-float-or-half-float-colour-buffer':
'WebGL2 is there, but neither EXT_color_buffer_float nor EXT_color_buffer_half_float is',
};
// What the browser says about itself, read from a scratch context so it shows even when the scene
// could not start. A canvas that never got a WebGL2 context is asked for WebGL1 to name the GPU.
function describeDevice(): string {
const c = document.createElement('canvas');
const gl2 = c.getContext('webgl2');
const gl = gl2 ?? (document.createElement('canvas').getContext('webgl') as WebGLRenderingContext | null);
const lines = [`webgl2 context: ${gl2 ? 'yes' : 'NO'}`];
if (!gl) return [...lines, 'webgl1 context: NO'].join('\n');
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
lines.push(
`renderer: ${gl.getParameter(dbg ? dbg.UNMASKED_RENDERER_WEBGL : gl.RENDERER)}`,
`vendor: ${gl.getParameter(dbg ? dbg.UNMASKED_VENDOR_WEBGL : gl.VENDOR)}`,
`version: ${gl.getParameter(gl.VERSION)}`,
`user agent: ${navigator.userAgent}`
);
const ext = gl.getSupportedExtensions() ?? [];
for (const n of ['EXT_color_buffer_float', 'EXT_color_buffer_half_float', 'OES_texture_float_linear', 'EXT_disjoint_timer_query_webgl2'])
lines.push(`${n}: ${ext.includes(n) ? 'yes' : 'NO'}`);
lines.push(`extensions (${ext.length}): ${ext.join(' ')}`);
return lines.join('\n');
}
info.textContent = describeDevice();
let scene: Scene | null = null;

31
harness/scene/device.ts Normal file
View file

@ -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<SceneUnavailable, string> = {
'no-webgl2': 'this browser gave no WebGL2 context',
'no-float-or-half-float-colour-buffer':
'WebGL2 is there, but neither EXT_color_buffer_float nor EXT_color_buffer_half_float is',
};
// What the browser says about itself, read from a scratch context so it shows even when the scene
// could not start. A canvas that never got a WebGL2 context is asked for WebGL1 to name the GPU.
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');
}

View file

@ -1,5 +1,5 @@
// Drives the scene in headless Chromium (software GL unless the host has a GPU).
// usage: node drive.cjs <bundledir> <mode> [outdir] modes: compile | shots | measure
// usage: node drive.cjs <bundledir> <mode> [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-<w>x<h>-<pull>-<t>.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.

View file

@ -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<string, unknown>).__scene = {
createScene,
createVoxelScene,
linkStage,
ShaderCompileError,
LEVELS,

View file

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

View file

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

View file

@ -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<string, Fn> = {};
const spy = (name: string, impl: (...a: any[]) => unknown = () => ({})): Fn => {
if (!calls[name]) calls[name] = vi.fn(impl);
return calls[name];
};
const gl = new Proxy({} as Record<string, unknown>, {
get(_, prop: string) {
// the two start-target formats must be told apart; every other constant is 1
if (prop === 'RGBA32F') return 32;
if (prop === 'RGBA16F') return 16;
if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 1;
if (prop === 'getExtension')
return spy(prop, (n: string) => (missing.includes(n) ? null : {}));
if (prop === 'compileShader') {
return spy(prop, () => {
compiles += 1;
});
}
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();

View file

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

View file

@ -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<L = Level> {
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;
}

View file

@ -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<string, Fn> = {};
const spy = (name: string, impl: (...a: any[]) => unknown = () => ({})): Fn => {
if (!calls[name]) calls[name] = vi.fn(impl);
return calls[name];
};
const gl = new Proxy({} as Record<string, unknown>, {
get(_, prop: string) {
// 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;
}

View file

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

View file

@ -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<SceneOptions, 'tier' | 'patchSource' | 'onCompileFail'> & {
/** 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<SceneOptions, 'tier' | 'patchSource' | 'onCompil
/**
* The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no
* canvas context is requested; otherwise the scene code loads on demand, so it stays out of the
* main chunk. Null means "keep the CSS ground", including when that chunk fails to load (offline,
* stale deploy): the failure goes to onCompileFail and nothing throws.
* main chunk. `config.style` picks the scene: the voxel terrain unless it says 'raymarch'. Null
* means "keep the CSS ground", including when that chunk fails to load (offline, stale deploy):
* the failure goes to onCompileFail and nothing throws.
*/
export async function startBackgroundScene(
config: BackgroundSceneConfig | undefined,
@ -22,9 +23,12 @@ export async function startBackgroundScene(
): Promise<Scene | null> {
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;

View file

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

View file

@ -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<Stage, string> = {
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<Stage, string> = {
* 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]
}`;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<typeof fakeCanvas>) => c as unknown as HTMLCanvasElement;
/** A voxel scene on a fake GL whose camera follows `time.t`, seconds. */
function build(extra: Partial<Parameters<typeof createVoxelScene>[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);
});
});

View file

@ -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<Name, WebGLProgram>;
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<WebGLProgram, Map<string, WebGLUniformLocation | null>>();
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<ScaleLevel>(
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);
},
};
}

View file

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

View file

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

View file

@ -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<typeof ringRects>, 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<string>();
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<number>();
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 }]);
});
});

View file

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

View file

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

View file

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