scene: voxel terrain as painter's-order strips, the default simple background #13
30 changed files with 1787 additions and 234 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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>'
|
||||
|
|
|
|||
|
|
@ -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
31
harness/scene/device.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
73
harness/scene/voxelDemo.ts
Normal file
73
harness/scene/voxelDemo.ts
Normal 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();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
52
src/app/features/scene/fakeGl.ts
Normal file
52
src/app/features/scene/fakeGl.ts
Normal 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;
|
||||
}
|
||||
111
src/app/features/scene/frameLoop.ts
Normal file
111
src/app/features/scene/frameLoop.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
55
src/app/features/scene/link.ts
Normal file
55
src/app/features/scene/link.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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]
|
||||
}`;
|
||||
}
|
||||
|
|
|
|||
17
src/app/features/scene/shaders/voxel.frag.glsl
Normal file
17
src/app/features/scene/shaders/voxel.frag.glsl
Normal 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
|
||||
}
|
||||
22
src/app/features/scene/shaders/voxelBackdrop.frag.glsl
Normal file
22
src/app/features/scene/shaders/voxelBackdrop.frag.glsl
Normal 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);
|
||||
}
|
||||
22
src/app/features/scene/shaders/voxelHeight.frag.glsl
Normal file
22
src/app/features/scene/shaders/voxelHeight.frag.glsl
Normal 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);
|
||||
}
|
||||
25
src/app/features/scene/shaders/voxelSky.frag.glsl
Normal file
25
src/app/features/scene/shaders/voxelSky.frag.glsl
Normal 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);
|
||||
}
|
||||
99
src/app/features/scene/shaders/voxelStrips.vert.glsl
Normal file
99
src/app/features/scene/shaders/voxelStrips.vert.glsl
Normal 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));
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
253
src/app/features/scene/voxel/Voxel.test.ts
Normal file
253
src/app/features/scene/voxel/Voxel.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
334
src/app/features/scene/voxel/Voxel.ts
Normal file
334
src/app/features/scene/voxel/Voxel.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
43
src/app/features/scene/voxel/rails.test.ts
Normal file
43
src/app/features/scene/voxel/rails.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
38
src/app/features/scene/voxel/rails.ts
Normal file
38
src/app/features/scene/voxel/rails.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
94
src/app/features/scene/voxel/ring.test.ts
Normal file
94
src/app/features/scene/voxel/ring.test.ts
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
66
src/app/features/scene/voxel/ring.ts
Normal file
66
src/app/features/scene/voxel/ring.ts
Normal 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;
|
||||
}
|
||||
46
src/app/features/scene/voxel/voxelWorld.test.ts
Normal file
46
src/app/features/scene/voxel/voxelWorld.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
108
src/app/features/scene/voxel/voxelWorld.ts
Normal file
108
src/app/features/scene/voxel/voxelWorld.ts
Normal 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');
|
||||
}
|
||||
Loading…
Reference in a new issue