WIP: scene: far heightfield mesh tier with view-depth haze (style 'mesh') #23
13 changed files with 1003 additions and 15 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)`;
|
||||
});
|
||||
|
|
|
|||
29
harness/scene/ridgeDrive.cjs
Normal file
29
harness/scene/ridgeDrive.cjs
Normal 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();
|
||||
})();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
160
src/app/features/scene/ridge/Mesh.test.ts
Normal file
160
src/app/features/scene/ridge/Mesh.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
317
src/app/features/scene/ridge/Mesh.ts
Normal file
317
src/app/features/scene/ridge/Mesh.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
54
src/app/features/scene/ridge/meshShaders.ts
Normal file
54
src/app/features/scene/ridge/meshShaders.ts
Normal 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);
|
||||
}`;
|
||||
167
src/app/features/scene/ridge/meshWorld.test.ts
Normal file
167
src/app/features/scene/ridge/meshWorld.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
205
src/app/features/scene/ridge/meshWorld.ts
Normal file
205
src/app/features/scene/ridge/meshWorld.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue