WIP: scene: far heightfield mesh tier with view-depth haze (style 'mesh') #23

Closed
nibu wants to merge 1 commit from nibu/ridge-mesh-haze into nibu/ridge-scene
13 changed files with 1003 additions and 15 deletions

View file

@ -34,7 +34,7 @@ esbuild
if (ridge) {
fs.writeFileSync(
path.join(out, 'index.html'),
'<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Ratatoskr ridge scene</title><body style="margin:0;background:#07111a"><div id=stage style="position:relative;width:100vw;height:100vh;overflow:hidden"><canvas id=c style="width:100%;height:100%;display:block"></canvas></div><script src=bundle.js></script>'
'<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Ratatoskr ridge, mesh scene</title><body style="margin:0;background:#07111a"><div id=stage style="position:relative;width:100vw;height:100vh;overflow:hidden"><canvas id=c style="width:100%;height:100%;display:block"></canvas></div><script src=bundle.js></script>'
);
return;
}

View file

@ -1,12 +1,19 @@
// Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the
// tier, `?probe=1` shows the rAF median/p95 readout. Not shipped.
// Standalone page for the ridge and mesh scenes: full window, no host app. `?tier=still|low|full` picks the
// tier, `?style=mesh` the heightfield-with-haze scene (default: the baked ridges), `?rowhaze=1` adds the
// screen-row haze gradient to the baked ridges, `?probe=1` shows the rAF median/p95 readout. Not shipped.
import { startBackgroundScene } from '../../src/app/features/scene';
import type { SceneTier } from '../../src/app/features/scene';
import type { SceneStyle, SceneTier } from '../../src/app/features/scene';
import { createRidgeScene } from '../../src/app/features/scene/ridge/Ridge';
const q = new URLSearchParams(location.search);
const tier = (q.get('tier') ?? 'full') as SceneTier;
const style = (q.get('style') ?? 'ridge') as SceneStyle;
const canvas = document.getElementById('c') as HTMLCanvasElement;
startBackgroundScene({ tier }, {}, { canvas }).then((scene) => {
const started =
q.get('rowhaze') === '1'
? Promise.resolve(createRidgeScene({ canvas, tier, rowHaze: true }))
: startBackgroundScene({ tier, style }, {}, { canvas });
started.then((scene) => {
(window as unknown as { scene: unknown }).scene = scene;
document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)';
document.title = scene ? `${style}: running` : `${style}: unavailable (CSS ground)`;
});

View file

@ -0,0 +1,29 @@
// Drives the ridge/mesh page (bundle.cjs <out> ridge) in headless Chromium and prints the probe readout.
// usage: node ridgeDrive.cjs <bundledir> <outdir> [width height reps] Needs playwright from NODE_PATH.
// Software GL (SwiftShader) unless the host has a GPU: the numbers are a CPU rasteriser's, not a phone's.
const { chromium } = require('playwright');
const path = require('path');
const [, , dir, outdir, w = '960', h = '540', reps = '1'] = process.argv;
const cases = [
['ridge', 'style=ridge'],
['ridge-rowhaze', 'rowhaze=1'],
['mesh', 'style=mesh'],
['mesh-low', 'style=mesh&tier=low'],
['mesh-still', 'style=mesh&tier=still'],
];
(async () => {
const b = await chromium.launch({
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist'],
});
for (const [name, qs] of cases.flatMap((c) => Array.from({ length: +reps }, () => c))) {
const page = await b.newPage({ viewport: { width: +w, height: +h } });
page.on('pageerror', (e) => process.stderr.write(`[pageerror ${name}] ${e}\n`));
await page.goto(`file://${path.resolve(dir, 'index.html')}?probe=1&${qs}`);
await page.waitForTimeout(3500);
const text = await page.evaluate(() => document.querySelector('#stage > div')?.textContent ?? '(no readout)');
await page.screenshot({ path: path.join(outdir, `${name}.png`) });
console.log(`${name}\t${await page.title()}\t${text}`);
await page.close();
}
await b.close();
})();

View file

@ -2,7 +2,7 @@
**Default: the ridge scene** (`ridge/`), no WebGL. `startBackgroundScene` runs it unless the client
config says `backgroundScene.style: 'raymarch'`; the tier flag still defaults to `off`, and null still
means "keep the CSS ground". Everything below this section describes the opt-in ray-march scene.
means "keep the CSS ground". `style: 'mesh'` selects a third scene (next section); everything after that describes the opt-in ray-march scene.
- `ridge/Ridge.ts` `createRidgeScene({ canvas, tier, probe?, onProbe?, sky? })` returns `{ login, dispose }` or
null (tier `off`, no 2D context). Four ridge layers are generated once on the CPU from CultMath's
@ -25,9 +25,27 @@ means "keep the CSS ground". Everything below this section describes the opt-in
via the 2D `filter`, skipped where unsupported, e.g. Safari); a faint warm in-scatter (at most 0.16 alpha) peaks at the
sun's azimuth. Nothing runs per frame. The in-scatter lobe is painted into each layer, so it drifts with the layer
rather than staying fixed under the sun; that is a known approximation until the sun is a real, fixed light.
- `harness/scene/bundle.cjs <out> ridge` builds a standalone page (`?tier=`, `?probe=1`).
- `harness/scene/bundle.cjs <out> ridge` builds a standalone page (`?tier=`, `?style=mesh`, `?rowhaze=1`, `?probe=1`);
`harness/scene/ridgeDrive.cjs <out> <shots> [w h reps]` drives it in headless Chromium (SwiftShader unless there is a GPU).
- Row haze (`createRidgeScene({ rowHaze: true })`, default off; `ridgeWorld.ts` `layerColourAtRow`, `ROW_DEPTH`): a comparison for the mesh tier.
Inside each baked layer the colour runs from hazier at the crest to the layer's own at the foot, as if the screen row were depth
(extra distance `0.35 (1 - rowFrac)` through the same transmittance). Still no per-frame work.
- Not yet measured on a phone: see the PR description.
## Mesh scene (`style: 'mesh'`, tier flag still defaults off)
`ridge/Mesh.ts` `createMeshScene({ canvas, tier, probe?, onProbe?, sky?, onCompileFail?, onContextLost? })`, same `{ login, dispose }`/null
contract. One WebGL1/2 pass: a full-screen gradient sky, then a low-poly far heightfield (`ridge/meshWorld.ts`: 96 x 20 quads =
3840 triangles, 2037 vertices, 16-bit indices, rows from 6 to 150 world units, built once on the CPU from the same periodic fbm as the
ridges plus depth bands). The camera is fixed; the field drifts one period in 240 s by a uniform, so there is no per-frame upload.
No ray march and no light but the sky: the fragment shader (`ridge/meshShaders.ts`) shades by normal and height from `skyColours()`
ground and mid colours, then hazes by view-space depth d (the projection's w): `T = exp(-3 d / 150)`, colour `T * base + (1 - T) * haze`,
then chroma drops by `0.45 (1 - T)`. The haze colour is `hazeColour(skyColours())`, the same one the baked layers fade into; there
is no second sun and no new colour source. `tier: 'low'` renders at 0.75 of the CSS size; `still` draws once. Cadence guard, pause on
hidden tab, reduced motion and the `?probe=1` readout (which adds the triangle count) match the ridge scene. Context loss stops it
and calls `onContextLost`. `?style=mesh` on the harness page opens it.
## Ray-march scene
A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath

View file

@ -12,7 +12,7 @@ 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. `config.style` picks the scene: the ridges unless it says 'raymarch'. Null means
* main chunk. `config.style` picks the scene: the ridges unless it says 'mesh' or '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.
*/
@ -28,6 +28,8 @@ export async function startBackgroundScene(
createScene =
config?.style === 'raymarch'
? (await import('./Scene')).createScene
: config?.style === 'mesh'
? (await import('./ridge/Mesh')).createMeshScene
: (await import('./ridge/Ridge')).createRidgeScene;
} catch (e) {
options.onCompileFail?.(e instanceof Error ? e : new Error(String(e)));

View file

@ -0,0 +1,160 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createMeshScene } from './Mesh';
import { triangleCount } from './meshWorld';
import { startBackgroundScene } from '../index';
// A GL context that accepts every call, remembers draws, and reports success for compile and link.
function fakeGl() {
const calls: Record<string, unknown[][]> = {};
const gl: any = new Proxy(
{},
{
get(_t, name: string) {
if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true;
if (name === 'getAttribLocation' || name === 'getUniformLocation') return () => 1;
if (/^[A-Z_0-9]+$/.test(name)) return 1;
return (...args: unknown[]) => {
(calls[name] ??= []).push(args);
return {};
};
},
}
);
return { gl, calls };
}
let rafQueue: Array<(t: number) => void>;
let visibility: string;
beforeEach(() => {
rafQueue = [];
visibility = 'visible';
vi.stubGlobal('document', {
...new EventTarget(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
get visibilityState() {
return visibility;
},
createElement: () => ({ style: {}, remove() {} }),
});
vi.stubGlobal('window', {
devicePixelRatio: 1,
innerWidth: 400,
innerHeight: 800,
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
location: { search: '' },
setTimeout,
});
vi.stubGlobal('performance', { now: () => 0 });
vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb));
vi.stubGlobal('cancelAnimationFrame', () => {
rafQueue = [];
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
function canvasWith(gl: unknown) {
return {
width: 0,
height: 0,
clientWidth: 400,
clientHeight: 800,
parentElement: { appendChild: vi.fn() },
getContext: vi.fn(() => gl),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as any;
}
describe('createMeshScene', () => {
it("returns null at tier 'off' and touches nothing", () => {
const canvas = canvasWith(null);
expect(createMeshScene({ canvas, tier: 'off' })).toBeNull();
expect(canvas.getContext).not.toHaveBeenCalled();
});
it('returns null without WebGL, so the host keeps its CSS ground', () => {
expect(createMeshScene({ canvas: canvasWith(null), tier: 'full' })).toBeNull();
});
it('reports a shader that does not compile and returns null', () => {
const { gl } = fakeGl();
const bad = new Proxy(gl, {
get: (t, n: string) => (n === 'getShaderParameter' ? () => false : t[n]),
});
const onCompileFail = vi.fn();
expect(createMeshScene({ canvas: canvasWith(bad), tier: 'full', onCompileFail })).toBeNull();
expect(onCompileFail).toHaveBeenCalledOnce();
});
it('draws the whole mesh in one indexed call per frame and then keeps drifting', () => {
const { gl, calls } = fakeGl();
const scene = createMeshScene({ canvas: canvasWith(gl), tier: 'full' })!;
expect(scene).not.toBeNull();
rafQueue.shift()!(16);
expect(calls.drawElements).toHaveLength(1);
expect(calls.drawElements[0][1]).toBe(triangleCount() * 3);
expect(rafQueue).toHaveLength(1); // the next frame is already queued
scene.dispose();
});
it("'still' draws once and queues no frames", () => {
const { gl, calls } = fakeGl();
const scene = createMeshScene({ canvas: canvasWith(gl), tier: 'still' })!;
expect(calls.drawElements).toHaveLength(1);
expect(rafQueue).toHaveLength(0);
scene.dispose();
});
it('falls back to a still frame when the measured p95 is over 33 ms', () => {
const { gl, calls } = fakeGl();
const onProbe = vi.fn();
const scene = createMeshScene({ canvas: canvasWith(gl), tier: 'full', onProbe })!;
let t = 0;
for (let i = 0; i < 400 && rafQueue.length; i += 1) rafQueue.shift()!((t += 50));
expect(rafQueue).toHaveLength(0);
expect(onProbe).toHaveBeenLastCalledWith(expect.objectContaining({ still: true }));
expect(calls.drawElements.length).toBeLessThan(400);
scene.dispose();
});
it('does not draw while the tab is hidden', () => {
const { gl, calls } = fakeGl();
const scene = createMeshScene({ canvas: canvasWith(gl), tier: 'full' })!;
visibility = 'hidden';
rafQueue.shift()!(16);
expect(calls.drawElements ?? []).toHaveLength(0);
scene.dispose();
});
it('dispose frees its buffers and programs and queues nothing more', () => {
const { gl, calls } = fakeGl();
const scene = createMeshScene({ canvas: canvasWith(gl), tier: 'full' })!;
scene.dispose();
expect(calls.deleteBuffer).toHaveLength(4);
expect(calls.deleteProgram).toHaveLength(2);
expect(rafQueue).toHaveLength(0);
});
});
describe("startBackgroundScene style 'mesh'", () => {
it('is off by default: nothing loads and no context is requested', async () => {
const canvas = canvasWith(null);
expect(await startBackgroundScene({ style: 'mesh' }, {}, { canvas })).toBeNull();
expect(await startBackgroundScene(undefined, {}, { canvas })).toBeNull();
expect(canvas.getContext).not.toHaveBeenCalled();
});
it("asks for WebGL, not 2D, when the tier is on and the style is 'mesh'", async () => {
const { gl } = fakeGl();
const canvas = canvasWith(gl);
const scene = await startBackgroundScene({ tier: 'still', style: 'mesh' }, {}, { canvas });
expect(scene).not.toBeNull();
expect(canvas.getContext).toHaveBeenCalledWith('webgl', expect.anything());
scene!.dispose();
});
});

View file

@ -0,0 +1,317 @@
import type { Scene, SceneOptions } from '../Scene';
import { FrameSampler, FrameStats } from './probe';
import { skyColours, SkyColours, Srgb } from './skyColour';
import { hazeColour } from './ridgeWorld';
import { buildMesh, HAZE, MESH, periodWidth, triangleCount, viewProjection } from './meshWorld';
import { MESH_FRAG, MESH_VERT, SKY_FRAG, SKY_VERT } from './meshShaders';
export type MeshOptions = Pick<
SceneOptions,
'canvas' | 'tier' | 'onCompileFail' | 'onContextLost'
> & {
/** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */
probe?: boolean;
onProbe?: (s: FrameStats & { still: boolean; triangles: number }) => void;
/** The sky's colours; default `skyColours()`, the one function to replace with Void's sun.js. */
sky?: SkyColours;
};
const rgb = (c: Srgb): [number, number, number] => [c[0] / 255, c[1] / 255, c[2] / 255];
/**
* Same shape as Scene.ts's ShaderCompileError, which the host's `onCompileFail` already takes (stage 'vertex' for
* every mesh failure, the log says which shader). Redeclared because importing the class would pull the
* ray-march scene and its GLSL into this chunk.
*/
class ShaderCompileError extends Error {
constructor(readonly stage: 'vertex', readonly log: string) {
super(`${stage} shader failed to compile:\n${log}`);
}
}
function program(gl: WebGLRenderingContext, vs: string, fs: string): WebGLProgram {
const make = (type: number, src: string) => {
const s = gl.createShader(type);
if (!s) throw new Error('createShader failed');
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(s) ?? '';
gl.deleteShader(s);
throw new ShaderCompileError(
'vertex',
`mesh ${type === gl.VERTEX_SHADER ? 'vertex' : 'fragment'} shader: ${log}`
);
}
return s;
};
const v = make(gl.VERTEX_SHADER, vs);
const f = make(gl.FRAGMENT_SHADER, fs);
const p = gl.createProgram();
if (!p) throw new Error('createProgram failed');
gl.attachShader(p, v);
gl.attachShader(p, f);
gl.linkProgram(p);
gl.deleteShader(v);
gl.deleteShader(f);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(p) ?? '';
gl.deleteProgram(p);
throw new ShaderCompileError('vertex', `mesh link: ${log}`);
}
return p;
}
/**
* The mesh scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep
* the CSS ground"). One low-poly heightfield far from the camera ({@link triangleCount} triangles), drawn over
* a gradient sky in one WebGL pass. The fragment shader hazes by view-space depth, exp(-k d), toward the sky
* colour behind that pixel, with a slight desaturation; there is no march and no light besides the sky.
* Cadence guard, pauses and the `?probe=1` readout match the ridge scene.
*/
export function createMeshScene(opts: MeshOptions): Scene | null {
const tier = opts.tier ?? 'full';
if (tier === 'off') return null;
const canvas = opts.canvas;
const sky = opts.sky ?? skyColours();
const probe = opts.probe ?? new URLSearchParams(window.location.search).get('probe') === '1';
const gl = (canvas.getContext('webgl', { antialias: false, alpha: false }) ??
canvas.getContext('experimental-webgl')) as WebGLRenderingContext | null;
if (!gl) return null;
let skyProg: WebGLProgram;
let meshProg: WebGLProgram;
try {
skyProg = program(gl, SKY_VERT, SKY_FRAG);
meshProg = program(gl, MESH_VERT, MESH_FRAG);
} catch (e) {
opts.onCompileFail?.(e as ShaderCompileError);
return null;
}
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
const sampler = new FrameSampler();
let still = tier === 'still';
let disposed = false;
let lost = false;
let raf = 0;
let readout: HTMLElement | undefined;
let resizeTimer = 0;
let turnsAtPause = 0;
let playedFrom: number | undefined;
let width = 1;
let indexCount = 0;
const tri = gl.createBuffer();
const vbuf = gl.createBuffer();
const nbuf = gl.createBuffer();
const ibuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, tri);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const skyUniforms = (p: WebGLProgram) => {
gl.uniform3fv(gl.getUniformLocation(p, 'uTop'), rgb(sky.top));
gl.uniform3fv(gl.getUniformLocation(p, 'uMid'), rgb(sky.mid));
gl.uniform3fv(gl.getUniformLocation(p, 'uHorizon'), rgb(sky.horizon));
};
/** Rebuild the grid and the buffers for the canvas's aspect and size. */
const build = () => {
// low tier renders at 0.75 of the CSS size; full at device pixel ratio capped to 2.
const dpr = Math.min(window.devicePixelRatio || 1, 2) * (tier === 'low' ? 0.75 : 1);
const cssW = canvas.clientWidth || window.innerWidth;
const cssH = canvas.clientHeight || window.innerHeight;
canvas.width = Math.max(1, Math.round(cssW * dpr));
canvas.height = Math.max(1, Math.round(cssH * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
const aspect = canvas.width / canvas.height;
const m = buildMesh(aspect);
width = periodWidth(aspect);
indexCount = m.indices.length;
gl.bindBuffer(gl.ARRAY_BUFFER, vbuf);
gl.bufferData(gl.ARRAY_BUFFER, m.positions, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, nbuf);
gl.bufferData(gl.ARRAY_BUFFER, m.normals, gl.STATIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibuf);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, m.indices, gl.STATIC_DRAW);
};
const draw = (turns: number) => {
if (lost) return;
const aspect = canvas.width / canvas.height;
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.useProgram(skyProg);
skyUniforms(skyProg);
gl.uniform2f(gl.getUniformLocation(skyProg, 'uSize'), canvas.width, canvas.height);
gl.bindBuffer(gl.ARRAY_BUFFER, tri);
const sa = gl.getAttribLocation(skyProg, 'aPos');
gl.enableVertexAttribArray(sa);
gl.vertexAttribPointer(sa, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.disableVertexAttribArray(sa);
gl.enable(gl.DEPTH_TEST);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(meshProg);
const u = (n: string) => gl.getUniformLocation(meshProg, n);
gl.uniformMatrix4fv(u('uViewProj'), false, viewProjection(aspect));
gl.uniform1f(u('uShift'), (turns % 1) * width);
gl.uniform3fv(u('uGround'), rgb(sky.ground));
gl.uniform3fv(u('uHaze'), rgb(hazeColour(sky)));
gl.uniform3fv(u('uMid'), rgb(sky.mid));
gl.uniform1f(u('uFar'), MESH.far);
gl.uniform1f(u('uExtinction'), HAZE.extinction);
gl.uniform1f(u('uDesaturate'), HAZE.desaturate);
gl.uniform1f(u('uRelief'), MESH.relief);
const bind = (buf: WebGLBuffer | null, name: string) => {
const a = gl.getAttribLocation(meshProg, name);
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.enableVertexAttribArray(a);
gl.vertexAttribPointer(a, 3, gl.FLOAT, false, 0, 0);
return a;
};
const a0 = bind(vbuf, 'aPos');
const a1 = bind(nbuf, 'aNormal');
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibuf);
gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0);
gl.disableVertexAttribArray(a0);
gl.disableVertexAttribArray(a1);
};
const moving = () =>
!still &&
!disposed &&
!lost &&
!(reducedMotion?.matches ?? false) &&
document.visibilityState === 'visible';
const turnsNow = (now: number) =>
turnsAtPause + (playedFrom === undefined ? 0 : (now - playedFrom) / (MESH.seconds * 1000));
const report = (stillNow: boolean) => {
const s = { ...sampler.stats(), still: stillNow, triangles: triangleCount() };
opts.onProbe?.(s);
if (readout) {
readout.textContent = `mesh · ${s.triangles} tris · n ${s.n} · median ${s.medianMs.toFixed(
1
)} ms · p95 ${s.p95Ms.toFixed(1)} ms${stillNow ? ' · STILL' : ''}`;
}
};
const settleToStill = () => {
if (playedFrom !== undefined) turnsAtPause = turnsNow(performance.now());
playedFrom = undefined;
still = true;
cancelAnimationFrame(raf);
raf = 0;
draw(turnsAtPause);
report(true);
};
let sinceReport = 0;
// The first window is measured (guard against a slow device); after it the loop only draws.
let measuring = true;
const frame = (now: number) => {
raf = 0;
if (!moving()) return;
draw(turnsNow(now));
if (!measuring) {
raf = requestAnimationFrame(frame);
return;
}
const full = sampler.push(now);
if (full) {
measuring = false;
if (sampler.slow()) {
settleToStill();
return;
}
if (probe) report(false);
} else if (probe && now - sinceReport > 500) {
sinceReport = now;
report(false);
}
raf = requestAnimationFrame(frame);
};
const wake = () => {
if (!moving()) {
if (playedFrom !== undefined) turnsAtPause = turnsNow(performance.now());
playedFrom = undefined;
return;
}
if (raf) return;
playedFrom = performance.now();
if (measuring) sampler.reset();
raf = requestAnimationFrame(frame);
};
const onLost = (e: Event) => {
e.preventDefault();
lost = true;
cancelAnimationFrame(raf);
raf = 0;
opts.onContextLost?.();
};
canvas.addEventListener('webglcontextlost', onLost);
build();
if (probe && canvas.parentElement) {
readout = document.createElement('div');
Object.assign(readout.style, {
position: 'absolute',
left: '8px',
top: '8px',
font: '12px monospace',
color: '#fff',
background: 'rgba(0,0,0,.55)',
padding: '2px 6px',
borderRadius: '3px',
pointerEvents: 'none',
});
canvas.parentElement.appendChild(readout);
}
if (still) settleToStill();
else wake();
if (!still && !moving()) draw(0);
const onVisibility = () => {
sampler.reset();
wake();
};
const onResize = () => {
clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
if (disposed || lost) return;
build();
draw(turnsNow(performance.now()));
sampler.reset();
}, 250);
};
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('resize', onResize);
reducedMotion?.addEventListener?.('change', onVisibility);
return {
login() {
// The camera is fixed; the field is the same before and after.
},
dispose() {
if (disposed) return;
disposed = true;
cancelAnimationFrame(raf);
clearTimeout(resizeTimer);
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('resize', onResize);
reducedMotion?.removeEventListener?.('change', onVisibility);
canvas.removeEventListener('webglcontextlost', onLost);
readout?.remove();
gl.deleteBuffer(tri);
gl.deleteBuffer(vbuf);
gl.deleteBuffer(nbuf);
gl.deleteBuffer(ibuf);
gl.deleteProgram(skyProg);
gl.deleteProgram(meshProg);
},
};
}

View file

@ -7,6 +7,7 @@ import {
edgeBlur,
hazeColour,
layerColour,
layerColourAtRow,
layerSize,
ridgeY,
scatterAlpha,
@ -19,6 +20,8 @@ export type RidgeOptions = Pick<SceneOptions, 'canvas' | 'tier'> & {
onProbe?: (s: FrameStats & { still: boolean }) => void;
/** The sky's colours; default `skyColours()`, the one function to replace with Void's sun.js. */
sky?: SkyColours;
/** Comparison for the mesh tier: haze each layer by screen row (higher = farther) instead of one colour per layer. Default false. */
rowHaze?: boolean;
};
const css = (c: Srgb): string => `rgb(${c[0]},${c[1]},${c[2]})`;
@ -102,7 +105,8 @@ function paintLayer(
sky: SkyColours,
size: { period: number; width: number; height: number },
viewH: number,
scale: number
scale: number,
rowHaze: boolean
): HTMLCanvasElement | null {
const el = document.createElement('canvas');
el.width = size.width;
@ -114,8 +118,13 @@ function paintLayer(
top.push(ridgeY(spec, x, size.period, size.height, viewH));
const c = layerColour(spec, sky);
const g = ctx.createLinearGradient(0, 0, 0, size.height);
g.addColorStop(0, css(c));
g.addColorStop(1, css(mix(c, hazeColour(sky), 0.5 * (1 - spec.depth))));
if (rowHaze) {
// The band's top row is the ridge crest at its highest; its foot is the viewport's bottom edge.
for (let k = 0; k <= 4; k += 1) g.addColorStop(k / 4, css(layerColourAtRow(spec, sky, k / 4)));
} else {
g.addColorStop(0, css(c));
g.addColorStop(1, css(mix(c, hazeColour(sky), 0.5 * (1 - spec.depth))));
}
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(0, size.height);
@ -178,7 +187,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
if (!paintSky(canvas, sky)) return false;
const layers: HTMLCanvasElement[] = [];
for (const spec of LAYERS) {
const el = paintLayer(spec, sky, size, canvas.height, scale);
const el = paintLayer(spec, sky, size, canvas.height, scale, opts.rowHaze ?? false);
if (!el) {
layers.forEach((l) => l.remove());
return false;

View file

@ -0,0 +1,54 @@
// GLSL ES 1.00 so one source runs on WebGL1 and WebGL2. Colours are sRGB 0..1 and mixed as they are, like the
// baked ridge path, so the two tiers fade into the same haze colour (ridgeWorld.hazeColour of the sky). The numbers come in as uniforms from meshWorld.ts.
/** The sky gradient the 2D path paints (stops 0 / 0.5 / 0.78 / 1 from the top), by `v` = 0 top .. 1 bottom. */
const SKY_FN = `
uniform vec3 uTop, uMid, uHorizon;
vec3 skyAt(float v) {
vec3 c = mix(uTop, uMid, smoothstep(0.0, 0.5, v));
return mix(c, uHorizon, smoothstep(0.5, 0.78, v));
}`;
export const SKY_VERT = `
attribute vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.999, 1.0); }`;
export const SKY_FRAG = `
precision mediump float;
uniform vec2 uSize;${SKY_FN}
void main() { gl_FragColor = vec4(skyAt(1.0 - gl_FragCoord.y / uSize.y), 1.0); }`;
export const MESH_VERT = `
attribute vec3 aPos;
attribute vec3 aNormal;
uniform mat4 uViewProj;
uniform float uShift; // drift along x, world units
varying float vDepth;
varying float vUp;
varying float vHeight;
void main() {
vec3 p = aPos - vec3(uShift, 0.0, 0.0);
gl_Position = uViewProj * vec4(p, 1.0);
// View-space depth along the view axis is what the projection puts in w.
vDepth = gl_Position.w;
vUp = aNormal.y;
vHeight = aPos.y;
}`;
export const MESH_FRAG = `
precision mediump float;
uniform vec3 uGround, uHaze, uMid;
uniform float uFar, uExtinction, uDesaturate, uRelief;
varying float vDepth;
varying float vUp;
varying float vHeight;
void main() {
// Unlit by any sun: slopes and crests pick up the sky's own mid colour.
float rim = clamp(0.35 * (1.0 - vUp) * 4.0 + 0.5 * vHeight / uRelief, 0.0, 1.0);
vec3 base = mix(uGround, uMid, 0.10 + 0.45 * rim);
float t = exp(-uExtinction * vDepth / uFar);
vec3 seen = mix(uHaze, base, t);
float luma = dot(seen, vec3(0.2126, 0.7152, 0.0722));
seen = mix(vec3(luma), seen, 1.0 - uDesaturate * (1.0 - t));
gl_FragColor = vec4(seen, 1.0);
}`;

View file

@ -0,0 +1,167 @@
import { describe, expect, it } from 'vitest';
import {
buildMesh,
HAZE,
hazed,
hazeTransmittance,
height,
MESH,
periodWidth,
triangleCount,
vertexCount,
viewDepth,
viewProjection,
} from './meshWorld';
import { hazeColour, layerColour, layerColourAtRow, LAYERS, ROW_DEPTH } from './ridgeWorld';
import { skyColours } from './skyColour';
describe('mesh geometry', () => {
it('is a few thousand triangles and fits 16-bit indices', () => {
expect(triangleCount()).toBeGreaterThanOrEqual(2000);
expect(triangleCount()).toBeLessThanOrEqual(5000);
expect(vertexCount()).toBeLessThan(65536);
});
it('builds exactly the declared triangles with every index in range', () => {
const m = buildMesh(16 / 9);
expect(m.indices.length).toBe(triangleCount() * 3);
expect(m.positions.length).toBe(vertexCount() * 3);
expect(m.normals.length).toBe(m.positions.length);
for (const i of m.indices) expect(i).toBeLessThan(vertexCount());
});
it('is periodic: a column one period over has the same height, so a drift of one period loops', () => {
const m = buildMesh(1);
const cols = 2 * MESH.colsPerPeriod + 1;
for (const r of [0, 7, MESH.rows]) {
for (const c of [0, 5, 30]) {
const a = m.positions[(r * cols + c) * 3 + 1];
const b = m.positions[(r * cols + c + MESH.colsPerPeriod) * 3 + 1];
expect(b).toBeCloseTo(a, 5);
}
}
});
it('keeps heights in [0, relief], lower in front of the camera than at the far edge on average', () => {
const mean = (r: number) => {
let s = 0;
for (let k = 0; k < 48; k += 1) {
const h = height(k / 48, r);
expect(h).toBeGreaterThanOrEqual(0);
expect(h).toBeLessThanOrEqual(MESH.relief);
s += h;
}
return s / 48;
};
expect(mean(MESH.rows)).toBeGreaterThan(mean(0));
});
it('puts every row between near and far and keeps the eye above the nearest rows', () => {
const m = buildMesh(1);
const cols = 2 * MESH.colsPerPeriod + 1;
expect(-m.positions[2]).toBeCloseTo(MESH.near, 4);
expect(-m.positions[MESH.rows * cols * 3 + 2]).toBeCloseTo(MESH.far, 3);
for (let c = 0; c < cols; c += 1) expect(m.positions[c * 3 + 1]).toBeLessThan(MESH.eye);
});
it('has unit, upward-facing normals (the only shading input)', () => {
const m = buildMesh(1);
for (let i = 0; i < m.normals.length; i += 3) {
const len = Math.hypot(m.normals[i], m.normals[i + 1], m.normals[i + 2]);
expect(len).toBeCloseTo(1, 4);
expect(m.normals[i + 1]).toBeGreaterThan(0);
}
});
it('is wide enough at the far row that the view never sees an end, at any aspect', () => {
for (const aspect of [0.5, 1, 2.4]) {
const farHalf = MESH.far * Math.tan(MESH.fov / 2) * Math.max(aspect, aspect * 1);
expect(periodWidth(aspect) / 2).toBeGreaterThan(farHalf * (aspect < 1 ? 0.5 : 0.8));
}
});
});
describe('view-space depth', () => {
it("is the w the projection produces, which is what the shader's haze reads", () => {
const vp = viewProjection(16 / 9);
for (const [y, z] of [
[3, -10],
[20, -90],
[0, -150],
]) {
const w = vp[3] * 0 + vp[7] * y + vp[11] * z + vp[15];
expect(w).toBeCloseTo(viewDepth(y, z), 4);
}
});
});
describe('depth haze', () => {
const sky = skyColours();
const ground = sky.ground;
const haze = hazeColour(sky);
it('is exp(-k d / far): 1 at the eye, strictly falling, never zero', () => {
expect(hazeTransmittance(0)).toBe(1);
let prev = 1;
for (const d of [10, 40, 90, MESH.far]) {
const t = hazeTransmittance(d);
expect(t).toBeLessThan(prev);
expect(t).toBeGreaterThan(0);
prev = t;
}
expect(hazeTransmittance(MESH.far)).toBeCloseTo(Math.exp(-HAZE.extinction), 12);
});
it('leaves the base colour at depth 0 and approaches the haze colour far away', () => {
const near = hazed(ground, haze, 0);
for (let i = 0; i < 3; i += 1) expect(near[i]).toBeCloseTo(ground[i], 9);
const far = hazed(ground, haze, MESH.far * 4);
const dist = (a: readonly number[], b: readonly number[]) =>
Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
// Far away only the haze is left, desaturated by the full HAZE.desaturate.
const luma = 0.2126 * haze[0] + 0.7152 * haze[1] + 0.0722 * haze[2];
for (let i = 0; i < 3; i += 1)
expect(far[i]).toBeCloseTo(luma + (haze[i] - luma) * (1 - HAZE.desaturate), 2);
expect(dist(far, haze)).toBeLessThan(dist(near, haze));
});
it('desaturates slightly: chroma shrinks by desaturate * (1 - T) relative to the plain mix', () => {
const chroma = (c: readonly number[]) => Math.max(...c) - Math.min(...c);
const d = MESH.far * 0.6;
const t = hazeTransmittance(d);
const plain = [0, 1, 2].map((i) => ground[i] * t + haze[i] * (1 - t));
const out = hazed(ground, haze, d);
expect(chroma(out) / chroma(plain)).toBeCloseTo(1 - HAZE.desaturate * (1 - t), 6);
expect(HAZE.desaturate).toBeLessThanOrEqual(0.5); // "slight"
});
it('takes its haze colour from the one sky seam: a different sky, a different far colour', () => {
const other = { ...sky, horizon: [20, 200, 90] as const, mid: [10, 120, 60] as const };
expect(hazed(ground, hazeColour(other), MESH.far)).not.toEqual(hazed(ground, haze, MESH.far));
});
});
describe('baked-path row haze (comparison)', () => {
const sky = skyColours();
const mean = (c: readonly number[]) => (c[0] + c[1] + c[2]) / 3;
const hazeMean = mean(hazeColour(sky));
it('is hazier at the silhouette top than at its foot, for every layer', () => {
for (const spec of LAYERS) {
const top = layerColourAtRow(spec, sky, 0);
const foot = layerColourAtRow(spec, sky, 1);
expect(Math.abs(mean(top) - hazeMean)).toBeLessThanOrEqual(Math.abs(mean(foot) - hazeMean));
}
});
it("at the foot is the layer's own bake colour shifted by nothing but the layer distance", () => {
for (const spec of LAYERS)
expect(layerColourAtRow(spec, sky, 1)).toEqual(layerColour(spec, sky));
});
it('adds ROW_DEPTH of distance at the top', () => {
expect(ROW_DEPTH).toBeGreaterThan(0);
const spec = LAYERS[1];
expect(layerColourAtRow(spec, sky, 0)).not.toEqual(layerColourAtRow(spec, sky, 1));
});
});

View file

@ -0,0 +1,205 @@
// The far heightfield of the mesh scene, as pure functions: grid, heights, camera, haze. No DOM, no GL.
import { fbmPeriodic, FbmShape } from './noise';
import type { Srgb } from './skyColour';
/** A few thousand triangles. Columns span two periods so a drift of one period loops. */
export const MESH = {
/** Quads across one period. */
colsPerPeriod: 48,
/** Quads from the near edge to the far edge. */
rows: 20,
/** Lattice rows of noise per grid row: heights vary smoothly in depth rather than per row. */
rowsPerCell: 2,
/** Distances in world units (view-space depth of the near and far rows with the camera at the origin's height). */
near: 6,
far: 150,
/** Peak height of the terrain. */
relief: 55,
/** Camera height above the terrain's zero, vertical FOV in radians, pitch down in radians. */
eye: 20,
fov: (42 * Math.PI) / 180,
pitch: (1 * Math.PI) / 180,
/** Seconds for the field to drift one period. */
seconds: 240,
};
export const FBM_MESH: FbmShape = { octaves: 4, lacunarity: 2, gain: 0.5 };
/** Haze: transmittance T = exp(-extinction * d / far) at view-space depth d; chroma drops by desaturate * (1 - T). */
export const HAZE = { extinction: 3, desaturate: 0.45 };
export const triangleCount = (): number => 2 * 2 * MESH.colsPerPeriod * MESH.rows;
export const vertexCount = (): number => (2 * MESH.colsPerPeriod + 1) * (MESH.rows + 1);
/** World width of one period: what the view sees at the far row, so the far edge never shows a seam. */
export function periodWidth(aspect: number): number {
return 2 * MESH.far * Math.tan(MESH.fov / 2) * Math.max(aspect, 1) * 1.15;
}
const smooth = (t: number): number => t * t * (3 - 2 * t);
/** Terrain height in [0, relief] at column fraction `t` (0..1 across one period, periodic) and grid row `r`. */
export function height(t: number, r: number): number {
const cell = r / MESH.rowsPerCell;
const r0 = Math.floor(cell);
const u = smooth(cell - r0);
const base = 7; // lattice cells per period for the first octave: integer at every octave
const a = fbmPeriodic(t, 500 + r0 * 17, base, FBM_MESH);
const b = fbmPeriodic(t, 500 + (r0 + 1) * 17, base, FBM_MESH);
// Depth bands: rows of ridgelines one behind another, bent sideways by the noise so no band is straight.
const band = 0.5 + 0.5 * Math.sin(r * 0.8 + 5 * (a + (b - a) * u));
const n = 0.3 * (a + (b - a) * u) + 0.7 * band;
// Ridged fold, so the far field reads as ridgelines rather than lumps.
const crag = 1 - Math.abs(2 * n - 1);
// Low in front of the camera, tall toward the horizon, so the eye is never inside a hill.
const lift = 0.12 + 0.88 * (r / MESH.rows) ** 1.2;
return MESH.relief * lift * (0.55 * n + 0.45 * crag);
}
export interface MeshData {
/** x, y, z per vertex (z is negative depth: camera looks down -z). */
positions: Float32Array;
/** Smooth normal per vertex. */
normals: Float32Array;
indices: Uint16Array;
}
/** Build the grid for a viewport aspect. Rows run from `near` (row 0) to `far`; columns from 0 to two periods. */
export function buildMesh(aspect: number): MeshData {
const cols = 2 * MESH.colsPerPeriod;
const w = periodWidth(aspect);
const x0 = -w * 0.5;
const nv = (cols + 1) * (MESH.rows + 1);
const positions = new Float32Array(nv * 3);
const heights = new Float32Array(nv);
const idx = (c: number, r: number) => r * (cols + 1) + c;
for (let r = 0; r <= MESH.rows; r += 1) {
// Rows bunch toward the camera, where a given depth step is more pixels.
const z = MESH.near + (MESH.far - MESH.near) * (r / MESH.rows) ** 1.5;
for (let c = 0; c <= cols; c += 1) {
const t = (c % MESH.colsPerPeriod) / MESH.colsPerPeriod;
const h = height(t, r);
const i = idx(c, r);
heights[i] = h;
positions.set([x0 + (c / MESH.colsPerPeriod) * w, h, -z], i * 3);
}
}
const normals = new Float32Array(nv * 3);
for (let r = 0; r <= MESH.rows; r += 1) {
for (let c = 0; c <= cols; c += 1) {
const i = idx(c, r);
const l = positions.subarray(
idx(Math.max(c - 1, 0), r) * 3,
idx(Math.max(c - 1, 0), r) * 3 + 3
);
const rt = positions.subarray(
idx(Math.min(c + 1, cols), r) * 3,
idx(Math.min(c + 1, cols), r) * 3 + 3
);
const d = positions.subarray(
idx(c, Math.max(r - 1, 0)) * 3,
idx(c, Math.max(r - 1, 0)) * 3 + 3
);
const u = positions.subarray(
idx(c, Math.min(r + 1, MESH.rows)) * 3,
idx(c, Math.min(r + 1, MESH.rows)) * 3 + 3
);
const dx = [rt[0] - l[0], rt[1] - l[1], rt[2] - l[2]];
const dz = [u[0] - d[0], u[1] - d[1], u[2] - d[2]];
// dz points away from the camera (-z); dx x dz points up.
const n = [
dx[1] * dz[2] - dx[2] * dz[1],
dx[2] * dz[0] - dx[0] * dz[2],
dx[0] * dz[1] - dx[1] * dz[0],
];
const len = Math.hypot(n[0], n[1], n[2]) || 1;
normals.set([n[0] / len, n[1] / len, n[2] / len], i * 3);
}
}
const indices = new Uint16Array(cols * MESH.rows * 6);
let k = 0;
for (let r = 0; r < MESH.rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const a = idx(c, r);
const b = idx(c + 1, r);
const d = idx(c, r + 1);
const e = idx(c + 1, r + 1);
indices.set([a, d, b, b, d, e], k);
k += 6;
}
}
return { positions, normals, indices };
}
/** Column-major perspective * view matrix for the fixed camera: at (0, eye, 0), pitched down, looking along -z. */
export function viewProjection(aspect: number): Float32Array {
const f = 1 / Math.tan(MESH.fov / 2);
const zn = 1;
const zf = MESH.far * 1.2;
const cp = Math.cos(MESH.pitch);
const sp = Math.sin(MESH.pitch);
// view = R(pitch down) * T(0, -eye, 0), column-major: y' = cp y - sp z, z' = sp y + cp z.
const view = [
1,
0,
0,
0, //
0,
cp,
sp,
0,
0,
-sp,
cp,
0,
0,
-MESH.eye * cp,
-MESH.eye * sp,
1,
];
const p = [
f / aspect,
0,
0,
0,
0,
f,
0,
0,
0,
0,
(zf + zn) / (zn - zf),
-1,
0,
0,
(2 * zf * zn) / (zn - zf),
0,
];
const out = new Float32Array(16);
for (let c = 0; c < 4; c += 1)
for (let r = 0; r < 4; r += 1) {
let s = 0;
for (let k = 0; k < 4; k += 1) s += p[k * 4 + r] * view[c * 4 + k];
out[c * 4 + r] = s;
}
return out;
}
/** View-space depth (distance along the view axis) of a world point, as the vertex shader computes it. */
export function viewDepth(y: number, z: number): number {
const cp = Math.cos(MESH.pitch);
const sp = Math.sin(MESH.pitch);
return -(sp * (y - MESH.eye) + cp * z);
}
/** Beer-Lambert transmittance at view-space depth d. */
export const hazeTransmittance = (d: number): number => Math.exp((-HAZE.extinction * d) / MESH.far);
/** `base` seen through the haze toward `sky`, then desaturated by HAZE.desaturate * (1 - T). The same arithmetic the fragment shader runs. */
export function hazed(base: Srgb, sky: Srgb, d: number): Srgb {
const t = hazeTransmittance(d);
const seen = (i: number) => base[i] * t + sky[i] * (1 - t);
const luma = 0.2126 * seen(0) + 0.7152 * seen(1) + 0.0722 * seen(2);
const keep = 1 - HAZE.desaturate * (1 - t);
return [0, 1, 2].map((i) => luma + (seen(i) - luma) * keep) as unknown as Srgb;
}

View file

@ -71,6 +71,23 @@ export function layerColour(spec: LayerSpec, sky: SkyColours): Srgb {
return [out(0), out(1), out(2)];
}
/**
* Comparison for the mesh tier: the same transmittance, but with distance read off the screen row. Over a ground
* plane a higher row is farther, so inside a layer the silhouette's top (`rowFrac` 0) is hazier than its foot (1):
* extra distance `ROW_DEPTH * (1 - rowFrac)` is added to the layer's own. Off by default (`rowHaze`).
*/
export const ROW_DEPTH = 0.35;
export function layerColourAtRow(spec: LayerSpec, sky: SkyColours, rowFrac: number): Srgb {
const h = hazeColour(sky);
const t = Math.exp(-AERIAL.extinction * (layerDistance(spec) + ROW_DEPTH * (1 - rowFrac)));
const seen = (i: number) => sky.ground[i] * t + h[i] * (1 - t);
const luma = 0.2126 * seen(0) + 0.7152 * seen(1) + 0.0722 * seen(2);
const keep = 1 - AERIAL.desaturate * (1 - t);
const out = (i: number) => Math.round(luma + (seen(i) - luma) * keep);
return [out(0), out(1), out(2)];
}
/** Edge softening for a layer, in canvas pixels at scale 1; 0 for the nearest. */
export const edgeBlur = (spec: LayerSpec): number => AERIAL.blurPx * (1 - transmittance(spec));

View file

@ -15,8 +15,11 @@ export interface Capabilities {
hardwareConcurrency?: number;
}
/** 'ridge': baked parallax ridges, no WebGL (default). 'raymarch': the sphere-traced world with its tree and clouds. */
export type SceneStyle = 'ridge' | 'raymarch';
/**
* 'ridge': baked parallax ridges, no WebGL (default). 'mesh': a low-poly far heightfield with depth haze, one WebGL pass.
* 'raymarch': the sphere-traced world with its tree and clouds.
*/
export type SceneStyle = 'ridge' | 'mesh' | 'raymarch';
export interface BackgroundSceneConfig {
tier?: SceneTier;