diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs
index 31accf37..125c2f63 100644
--- a/harness/scene/bundle.cjs
+++ b/harness/scene/bundle.cjs
@@ -34,7 +34,7 @@ esbuild
if (ridge) {
fs.writeFileSync(
path.join(out, 'index.html'),
- '
Ratatoskr ridge scene'
+ 'Ratatoskr ridge, mesh scene'
);
return;
}
diff --git a/harness/scene/ridgeDemo.ts b/harness/scene/ridgeDemo.ts
index 931a1ad4..aaf6377b 100644
--- a/harness/scene/ridgeDemo.ts
+++ b/harness/scene/ridgeDemo.ts
@@ -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)`;
});
diff --git a/harness/scene/ridgeDrive.cjs b/harness/scene/ridgeDrive.cjs
new file mode 100644
index 00000000..ff191473
--- /dev/null
+++ b/harness/scene/ridgeDrive.cjs
@@ -0,0 +1,29 @@
+// Drives the ridge/mesh page (bundle.cjs ridge) in headless Chromium and prints the probe readout.
+// usage: node ridgeDrive.cjs [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();
+})();
diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md
index 473a92a1..ec845b1c 100644
--- a/src/app/features/scene/README.md
+++ b/src/app/features/scene/README.md
@@ -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 ridge` builds a standalone page (`?tier=`, `?probe=1`).
+- `harness/scene/bundle.cjs ridge` builds a standalone page (`?tier=`, `?style=mesh`, `?rowhaze=1`, `?probe=1`);
+ `harness/scene/ridgeDrive.cjs [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
diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts
index a5b2de7d..a8ab2c6d 100644
--- a/src/app/features/scene/index.ts
+++ b/src/app/features/scene/index.ts
@@ -12,7 +12,7 @@ export type StartOptions = Omit = {};
+ 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();
+ });
+});
diff --git a/src/app/features/scene/ridge/Mesh.ts b/src/app/features/scene/ridge/Mesh.ts
new file mode 100644
index 00000000..272878dc
--- /dev/null
+++ b/src/app/features/scene/ridge/Mesh.ts
@@ -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);
+ },
+ };
+}
diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts
index 218d7c65..0df0c9f0 100644
--- a/src/app/features/scene/ridge/Ridge.ts
+++ b/src/app/features/scene/ridge/Ridge.ts
@@ -7,6 +7,7 @@ import {
edgeBlur,
hazeColour,
layerColour,
+ layerColourAtRow,
layerSize,
ridgeY,
scatterAlpha,
@@ -19,6 +20,8 @@ export type RidgeOptions = Pick & {
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;
diff --git a/src/app/features/scene/ridge/meshShaders.ts b/src/app/features/scene/ridge/meshShaders.ts
new file mode 100644
index 00000000..8f786685
--- /dev/null
+++ b/src/app/features/scene/ridge/meshShaders.ts
@@ -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);
+}`;
diff --git a/src/app/features/scene/ridge/meshWorld.test.ts b/src/app/features/scene/ridge/meshWorld.test.ts
new file mode 100644
index 00000000..e51d3bba
--- /dev/null
+++ b/src/app/features/scene/ridge/meshWorld.test.ts
@@ -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));
+ });
+});
diff --git a/src/app/features/scene/ridge/meshWorld.ts b/src/app/features/scene/ridge/meshWorld.ts
new file mode 100644
index 00000000..444053d4
--- /dev/null
+++ b/src/app/features/scene/ridge/meshWorld.ts
@@ -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;
+}
diff --git a/src/app/features/scene/ridge/ridgeWorld.ts b/src/app/features/scene/ridge/ridgeWorld.ts
index 048d1b50..0718e32c 100644
--- a/src/app/features/scene/ridge/ridgeWorld.ts
+++ b/src/app/features/scene/ridge/ridgeWorld.ts
@@ -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));
diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts
index e2c82325..fea5e024 100644
--- a/src/app/features/scene/tier.ts
+++ b/src/app/features/scene/tier.ts
@@ -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;