From 3273293f940c6a71c087abca17afffc90fa96e81 Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 6 Nov 2026 22:44:30 +0000 Subject: [PATCH] scene: far heightfield mesh tier with view-depth haze (style 'mesh'), row-haze comparison on the baked ridges A third scene style next to 'ridge' and 'raymarch': one WebGL pass, a gradient sky and a 3840-triangle heightfield far from a fixed camera. The fragment shader hazes by view-space depth, T = exp(-k d / far), toward hazeColour(skyColours()) with slight desaturation; no ray march, no second sun. Tier flag still defaults off; the ridge and sky-only paths are untouched except an opt-in rowHaze on the baked layers (crest hazier than foot, through the same transmittance) as a comparison. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/bundle.cjs | 2 +- harness/scene/ridgeDemo.ts | 17 +- harness/scene/ridgeDrive.cjs | 29 ++ src/app/features/scene/README.md | 22 +- src/app/features/scene/index.ts | 4 +- src/app/features/scene/ridge/Mesh.test.ts | 160 +++++++++ src/app/features/scene/ridge/Mesh.ts | 317 ++++++++++++++++++ src/app/features/scene/ridge/Ridge.ts | 17 +- src/app/features/scene/ridge/meshShaders.ts | 54 +++ .../features/scene/ridge/meshWorld.test.ts | 167 +++++++++ src/app/features/scene/ridge/meshWorld.ts | 205 +++++++++++ src/app/features/scene/ridge/ridgeWorld.ts | 17 + src/app/features/scene/tier.ts | 7 +- 13 files changed, 1003 insertions(+), 15 deletions(-) create mode 100644 harness/scene/ridgeDrive.cjs create mode 100644 src/app/features/scene/ridge/Mesh.test.ts create mode 100644 src/app/features/scene/ridge/Mesh.ts create mode 100644 src/app/features/scene/ridge/meshShaders.ts create mode 100644 src/app/features/scene/ridge/meshWorld.test.ts create mode 100644 src/app/features/scene/ridge/meshWorld.ts 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; -- 2.47.3