diff --git a/config.json b/config.json index e945d1c4..d1c461a4 100644 --- a/config.json +++ b/config.json @@ -17,5 +17,9 @@ "hashRouter": { "enabled": false, "basename": "/" + }, + + "backgroundScene": { + "tier": "off" } } diff --git a/harness/scene/README.md b/harness/scene/README.md new file mode 100644 index 00000000..382819ec --- /dev/null +++ b/harness/scene/README.md @@ -0,0 +1,19 @@ +# Scene harness + +Compiles, renders and times the background scene (`src/app/features/scene/`) in headless +Chromium. Not shipped, not run by CI. + + node bundle.cjs # esbuild bundle of entry.ts plus an index.html + node drive.cjs compile # compile and link each stage, JSON report + node drive.cjs shots # rest, mid pull-back and pulled-back screenshots + node drive.cjs measure # GPU time per pass by timer query, two viewports, two levels + node drive.cjs variants # the same with source patches (no erosion, no tree, value-only fbm) + node drive.cjs phacelle # the Phacelle gradient probe behind world.ts `phacelleGradBound` + +Needs `esbuild` and `playwright@1.48.2` on `NODE_PATH` and a Chromium. The first run was done on a +host with no GPU, so `drive.cjs` launches with `--use-angle=swiftshader` (software Vulkan). On a +machine with a GPU, remove those flags to measure the real device. `bundle.cjs` must run from a +directory whose ancestors hold no empty `package.json` (esbuild reads them). + +Timings from SwiftShader are CPU time: use them for the split between passes and for before and +after of one change, never as a phone frame time. diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs new file mode 100644 index 00000000..62a47078 --- /dev/null +++ b/harness/scene/bundle.cjs @@ -0,0 +1,35 @@ +// Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html. +// usage: node bundle.cjs (esbuild from NODE_PATH) +const esbuild = require('esbuild'); +const fs = require('fs'); +const path = require('path'); +const out = process.argv[2]; +fs.mkdirSync(out, { recursive: true }); +const rawPlugin = { + name: 'raw', + setup(b) { + b.onResolve({ filter: /\?raw$/ }, (a) => ({ + path: path.resolve(a.resolveDir, a.path.slice(0, -4)), + namespace: 'raw', + })); + b.onLoad({ filter: /.*/, namespace: 'raw' }, (a) => ({ + contents: fs.readFileSync(a.path, 'utf8'), + loader: 'text', + })); + }, +}; +esbuild + .build({ + entryPoints: [path.join(__dirname, 'entry.ts')], + bundle: true, + outfile: path.join(out, 'bundle.js'), + plugins: [rawPlugin], + format: 'iife', + target: 'es2020', + }) + .then(() => { + fs.writeFileSync( + path.join(out, 'index.html'), + '' + ); + }); diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs new file mode 100644 index 00000000..74c4a8b0 --- /dev/null +++ b/harness/scene/drive.cjs @@ -0,0 +1,164 @@ +// Drives the scene in headless Chromium (software GL unless the host has a GPU). +// usage: node drive.cjs [outdir] modes: compile | shots | measure +// Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report. +const { chromium } = require('playwright'); +const path = require('path'); +const [, , dir, mode, outdir = '.'] = process.argv; + +(async () => { + const b = await chromium.launch({ + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader', + '--ignore-gpu-blocklist', + ], + }); + const page = await b.newPage({ viewport: { width: 960, height: 540 } }); + page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`)); + await page.goto('file://' + path.resolve(dir, 'index.html')); + const report = { mode }; + report.renderer = await page.evaluate(() => { + const g = document.createElement('canvas').getContext('webgl2'); + const e = g.getExtension('WEBGL_debug_renderer_info'); + return g.getParameter(e ? e.UNMASKED_RENDERER_WEBGL : g.RENDERER); + }); + + if (mode === 'compile') { + report.stages = await page.evaluate(() => { + const gl = document.createElement('canvas').getContext('webgl2'); + const S = window.__scene; + return ['terrain', 'atmosphere', 'resolve', 'composite'].map((stage) => { + const t0 = performance.now(); + try { + const p = S.linkStage(gl, stage); + return { + stage, + ok: true, + ms: +(performance.now() - t0).toFixed(1), + sourceBytes: S.fragmentSource(stage).length, + log: gl.getProgramInfoLog(p), + }; + } catch (e) { + return { + stage, + ok: false, + ms: +(performance.now() - t0).toFixed(1), + log: String(e.log || e), + }; + } + }); + }); + } + + if (mode === 'phacelle') { + report.probe = await page.evaluate(() => + window.__probePhacelle( + window.__scene.WORLD.erodeSide.slice(), + window.__scene.WORLD.erodeNorm, + 4 + ) + ); + } + + if (mode === 'shots') { + await page.evaluate(() => { + const c = document.getElementById('c'); + window.__s = window.__scene.createScene({ + canvas: c, + unthrottled: true, + fixedLevel: window.__scene.LEVELS[0], + }); + }); + const wait = (ms) => page.waitForTimeout(ms); + await wait(4000); + await page.screenshot({ path: path.join(outdir, 'rest.png') }); + await page.evaluate(() => window.__s.login()); + await wait(3500); + await page.screenshot({ path: path.join(outdir, 'mid-pullback.png') }); + await wait(9000); + await page.screenshot({ path: path.join(outdir, 'pulled-back.png') }); + report.shots = ['rest.png', 'mid-pullback.png', 'pulled-back.png']; + } + + if (mode === 'measure' || mode === 'variants') { + const VARIANTS = { + baseline: null, + // What a public value-only fbm would cost: the same octaves without the gradient work. + valueOnlyFbm: [ + 'float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }', + 'float terrainFbm(vec2 xz) { float a = 1.0, fr = 1.0, v = 0.0; for (int i = 0; i < W_OCTAVES; i++) { v += a * cultmath_snoise(vec3(xz * W_FREQ * fr, 0.0)); fr *= W_LACUNARITY; a *= W_GAIN; } return v; }', + ], + noErosion: ['#define W_ERODE_ENABLED 1', '#define W_ERODE_ENABLED 0'], + // Upper bound on what skipping sky rays would save: every upward ray misses at once. + noSkyMarch: [' bool hit = false;\n bool onTree = false;', ' if (rd.y > 0.0) { o_color = vec4(0.0); o_depth = W_FAR; return; }\n bool hit = false;\n bool onTree = false;'], + noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'], + }; + const wanted = mode === "variants" ? (process.env.ONLY ? process.env.ONLY.split(",") : Object.keys(VARIANTS)) : ['baseline']; + const sizes = [ + [960, 540], + [540, 1200], + ]; + report.runs = []; + for (const [w, h] of sizes) { + for (const [li, variant] of wanted.flatMap((v) => + mode === 'variants' + ? [[0, v]] + : [ + [0, v], + [3, v], + ] + )) { + await page.setViewportSize({ width: w, height: h }); + const r = await page.evaluate( + ({ li, rep }) => + new Promise((res) => { + const rows = []; + const c = document.getElementById('c'); + const s = window.__scene.createScene({ + canvas: c, + unthrottled: true, + fixedLevel: window.__scene.LEVELS[li], + patchSource: rep + ? (st, src) => + st === 'terrain' + ? rep[0].startsWith('#define') + ? src.replace(/#define W_ERODE_ENABLED \d/, rep[1]) + : src.split(rep[0]).join(rep[1]) + : src + : undefined, + onStats: (st) => { + rows.push(st); + if (rows.length === 40) { + s.dispose(); + const keep = rows.slice(10); + const med = (k) => + keep.map((x) => x.passMs[k]).sort((a, b) => a - b)[keep.length >> 1]; + res({ + solid: keep[0].solidSize, + atm: keep[0].atmosphereSize, + medianMs: { + terrain: med('terrain'), + atmosphere: med('atmosphere'), + resolve: med('resolve'), + composite: med('composite'), + }, + }); + } + }, + }); + if (!s) res({ error: 'createScene returned null' }); + }), + { li, rep: VARIANTS[variant] } + ); + report.runs.push({ viewport: [w, h], level: li, variant, ...r }); + } + } + } + + console.log(JSON.stringify(report, null, 1)); + await b.close(); +})().catch((e) => { + console.log('ERR', String(e).slice(0, 2000)); + process.exit(1); +}); diff --git a/harness/scene/entry.ts b/harness/scene/entry.ts new file mode 100644 index 00000000..d43a5453 --- /dev/null +++ b/harness/scene/entry.ts @@ -0,0 +1,87 @@ +// Browser entry for the harness: exposes the scene's seams to the driver. Not shipped. +import { createScene, linkStage, ShaderCompileError } from '../../src/app/features/scene/Scene'; +import { LEVELS } from '../../src/app/features/scene/budget'; +import { fragmentSource } from '../../src/app/features/scene/shaderSource'; +import { WORLD } from '../../src/app/features/scene/world'; + +(window as unknown as Record).__scene = { + createScene, + linkStage, + ShaderCompileError, + LEVELS, + fragmentSource, + WORLD, +}; + +// ---- Phacelle gradient probe ----------------------------------------------------------------- +import cultmath from '../../src/app/features/scene/shaders/CultMath.glsl?raw'; +import phacelle from '../../src/app/features/scene/shaders/CultMath.Phacelle.glsl?raw'; +import { vertexSource } from '../../src/app/features/scene/shaderSource'; + +const PROBE_FRAG = `#version 300 es +precision highp float; precision highp int; +${cultmath} +${phacelle} +uniform float u_seed; uniform vec3 u_side; uniform float u_norm; uniform float u_span; +in vec2 v_uv; layout(location=0) out vec4 o; +void main() { + // The scene samples the z = 0 slice: random points in a u_span square of it. + vec3 h = vec3(cultmath_pcg3d(vec3(gl_FragCoord.xy, u_seed))) ; + vec2 xy = vec2(float(uint(int(h.x)) >> 8), float(uint(int(h.y)) >> 8)) * (1.0 / 16777216.0) * u_span; + CultPhasor ph = cultmath_phacelle(vec3(xy, 0.0), u_side, 0.0, u_norm); + o = vec4(length(ph.cos.xyz), length(ph.sin.xyz), ph.cos.w, ph.sin.w); +}`; + +/** Max |grad| of the phasor over `draws * 1024^2` random points; min/max of the cos component. */ +(window as unknown as Record).__probePhacelle = ( + side: number[], + norm: number, + draws: number +) => { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl2')!; + gl.getExtension('EXT_color_buffer_float'); + const mk = (t: number, s: string) => { + const x = gl.createShader(t)!; + gl.shaderSource(x, s); + gl.compileShader(x); + if (!gl.getShaderParameter(x, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(x)!); + return x; + }; + const p = gl.createProgram()!; + gl.attachShader(p, mk(gl.VERTEX_SHADER, vertexSource)); + gl.attachShader(p, mk(gl.FRAGMENT_SHADER, PROBE_FRAG)); + gl.linkProgram(p); + gl.useProgram(p); + const N = 1024; + const tex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, N, N); + const fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); + gl.viewport(0, 0, N, N); + gl.bindVertexArray(gl.createVertexArray()); + gl.uniform3fv(gl.getUniformLocation(p, 'u_side'), side); + gl.uniform1f(gl.getUniformLocation(p, 'u_norm'), norm); + gl.uniform1f(gl.getUniformLocation(p, 'u_span'), 400); + const buf = new Float32Array(N * N * 4); + let gradCos = 0, + gradSin = 0, + vMin = 1e9, + vMax = -1e9, + n = 0; + for (let d = 0; d < draws; d++) { + gl.uniform1f(gl.getUniformLocation(p, 'u_seed'), d + 1); + gl.drawArrays(gl.TRIANGLES, 0, 3); + gl.readPixels(0, 0, N, N, gl.RGBA, gl.FLOAT, buf); + for (let i = 0; i < buf.length; i += 4) { + if (buf[i] > gradCos) gradCos = buf[i]; + if (buf[i + 1] > gradSin) gradSin = buf[i + 1]; + if (buf[i + 2] < vMin) vMin = buf[i + 2]; + if (buf[i + 2] > vMax) vMax = buf[i + 2]; + n++; + } + } + return { samples: n, maxGradCos: gradCos, maxGradSin: gradSin, minCos: vMin, maxCos: vMax }; +}; diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md new file mode 100644 index 00000000..0d49d57d --- /dev/null +++ b/src/app/features/scene/README.md @@ -0,0 +1,67 @@ +# Background scene + +A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath +noise with a world tree at its centre, dusk atmosphere and clouds, and a camera that pulls back +from the tree on login. It is a self-contained module; nothing in the client imports it yet. +Integration (a canvas under the app, `startBackgroundScene`, `login()` when the session starts, +`dispose()`) is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel +are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure +callbacks and the creature sizing rule. Nothing has drawn a creature. + +## Entry points + +- `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's + door. The client config's `backgroundScene.tier` (`off` default, `still`, `low`, `full`) is the + feature flag and the ceiling; `tier.ts` lowers it for `saveData`, low memory or few cores. At + `off` nothing is imported and no canvas context is requested; otherwise `Scene.ts` loads on + demand, so it stays out of the main chunk. +- `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns + `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 or + `EXT_color_buffer_float` is missing, or a shader fails to compile (`onCompileFail` gets the + `ShaderCompileError`; programs already linked are freed). `still` draws one frame at the top + level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context + stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to + show. It caps at 30 fps, pauses when + the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion`, and steps down a + quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist. +- `creatures.ts` the creature record and `creatureScale`: a neutral creature is one fixed size and + overrides `sizeBucket`. The bucket names and scales are placeholders until the persona cut owns them. +- `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`). +- `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL + `#define`s are generated from it; no number is written twice. +- `shaders/` pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA), + `composite.frag.glsl`, plus CultLib's vendored GLSL (`shaders/README.md`). + +## How it renders + +1. **Solid pass** (`terrain.frag.glsl`, 0.4-0.75 of canvas resolution): sphere trace of + `min(terrain, tree)`. Terrain height is `mask(r) * (A * fbm + E * phacelle)`, with + `cultmath_fbm_grad(...).w` and `cultmath_phacelle(...).cos.w`. It writes lit colour with + coverage and the hit distance (second render target). +2. **Atmosphere pass** (0.25-0.5 of canvas): closed-form exponential height fog plus a cloud + slab marched with a few jittered steps, both only up to the hit distance from pass 1. +3. **Resolve**: TAA on the atmosphere (Halton jitter, history reprojected by the pass-1 depth, + 3x3 neighbourhood clamp, 1/8 blend; reset on resize, level change and wake). +4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES + tonemap, dither. + +## The step bound, and what is not proven + +The trace steps by `(y - h(x, z)) / sqrt(1 + G^2)`, `G` the Lipschitz bound of the height field, +computed from `SNOISE_LIPSCHITZ` (CultMath's constant for `snoise`) in `world.ts`. Above the +envelope (fbm plus the erosion amplitude) the step uses the envelope's bound alone and Phacelle is +not evaluated. + +- The Phacelle gradient bound `phacelleGradBound` (7.0) is Libby's measured sup (6.63 at + |side| = 1.5, n = 0, CultLib PR #1 head 990521b3) plus margin: a measurement, not a proof. + The shader fixes roughness n = 0; a nonzero n needs the bound divided by (1 - n). +- The bound makes grazing rays crawl. A ray that exhausts its steps while still below the highest + terrain is ended as a distant surface (fog hides it). That is a visible approximation, not a + hit proven by the march. +- The low-resolution atmosphere samples pass-1 depth nearest, so silhouettes of near objects + carry a thin fog halo. + +## Measured and not measured + +See the pull request or `harness/scene/README.md` for the numbers and how they were taken. They +come from a software rasterizer, not a GPU, and nothing has been run on a phone. diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts new file mode 100644 index 00000000..97988687 --- /dev/null +++ b/src/app/features/scene/Scene.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { createScene, ShaderCompileError } from './Scene'; +import { LEVELS } from './budget'; +import { startBackgroundScene } from './index'; + +type Fn = Mock; + +/** A GL context where every call is a spy and every constant is 1. Compiles succeed until `failFrom`. */ +function fakeGl(failFrom = Infinity) { + let compiles = 0; + const calls: Record = {}; + const spy = (name: string, impl: () => unknown = () => ({})): Fn => { + if (!calls[name]) calls[name] = vi.fn(impl); + return calls[name]; + }; + const gl = new Proxy({} as Record, { + get(_, prop: string) { + if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 1; + if (prop === 'compileShader') { + return spy(prop, () => { + compiles += 1; + }); + } + if (prop === 'getShaderParameter') return spy(prop, () => compiles < failFrom); + if (prop === 'getShaderInfoLog') return spy(prop, () => 'ERROR: 0:1: bad'); + if (prop === 'getProgramParameter') return spy(prop, () => true); + return spy(prop); + }, + }); + return { gl: gl as unknown as WebGL2RenderingContext, calls }; +} + +function fakeCanvas(gl: WebGL2RenderingContext | null) { + const canvas = new EventTarget() as EventTarget & { + clientWidth: number; + clientHeight: number; + width: number; + height: number; + getContext: Fn; + }; + canvas.clientWidth = 400; + canvas.clientHeight = 300; + canvas.width = 0; + canvas.height = 0; + canvas.getContext = vi.fn(() => gl); + return canvas; +} + +let rafQueue: Array<(t: number) => void>; +let cancelled: number; +let win: EventTarget; +let doc: EventTarget & { visibilityState: string }; + +beforeEach(() => { + rafQueue = []; + cancelled = 0; + win = Object.assign(new EventTarget(), { + devicePixelRatio: 1, + matchMedia: () => ({ matches: false }), + }); + doc = Object.assign(new EventTarget(), { visibilityState: 'visible' }); + vi.stubGlobal('window', win); + vi.stubGlobal('document', doc); + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); + vi.stubGlobal('cancelAnimationFrame', () => { + cancelled += 1; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const runFrame = (now: number) => { + const cb = rafQueue.shift(); + if (!cb) throw new Error('no frame was scheduled'); + cb(now); +}; +const asCanvas = (c: ReturnType) => c as unknown as HTMLCanvasElement; + +describe('createScene tiers', () => { + it("'off' returns null without asking the canvas for a context", () => { + const { gl } = fakeGl(); + const canvas = fakeCanvas(gl); + expect(createScene({ canvas: asCanvas(canvas), tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + expect(rafQueue).toHaveLength(0); + }); + + it("'still' draws one frame and schedules no more", () => { + const { gl, calls } = fakeGl(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); + expect(scene).not.toBeNull(); + runFrame(1000); + expect(calls.drawArrays).toHaveBeenCalledTimes(4); // four passes, one frame + expect(rafQueue).toHaveLength(0); + }); + + it("'still' ignores login(): the pull-back is for a moving scene", () => { + const { gl } = fakeGl(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); + runFrame(1000); + scene?.login(); + expect(rafQueue).toHaveLength(0); + }); + + it("'full' keeps scheduling frames", () => { + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'full' }); + runFrame(1000); + expect(rafQueue).toHaveLength(1); + }); + + it("'low' traces with a cheaper level than 'full'", () => { + const traceSteps = (tier: 'low' | 'full') => { + rafQueue.length = 0; // each call is a fresh scene + const { gl, calls } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), tier }); + runFrame(1000); + // u_maxSteps is the only uniform1i whose value is a trace-step count + return calls.uniform1i.mock.calls.map((c) => c[1]).filter((v) => v >= 40); + }; + expect(traceSteps('full')).toContain(LEVELS[0].traceSteps); + expect(traceSteps('low')).toContain(LEVELS[2].traceSteps); + expect(traceSteps('low')).not.toContain(LEVELS[0].traceSteps); + }); + + it('returns null when WebGL2 is missing, so the host keeps its ground', () => { + expect(createScene({ canvas: asCanvas(fakeCanvas(null)) })).toBeNull(); + }); +}); + +describe('shader compile failure', () => { + it('reports through onCompileFail, returns null, and frees the programs already linked', () => { + const { gl, calls } = fakeGl(4); // terrain compiles both shaders; atmosphere's fragment is the 4th and fails + const onCompileFail = vi.fn(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onCompileFail }); + expect(scene).toBeNull(); + expect(onCompileFail).toHaveBeenCalledTimes(1); + const err = onCompileFail.mock.calls[0][0] as ShaderCompileError; + expect(err).toBeInstanceOf(ShaderCompileError); + expect(err.stage).toBe('atmosphere'); + expect(err.log).toContain('bad'); + expect(calls.deleteProgram).toHaveBeenCalled(); // the terrain program that did link + expect(rafQueue).toHaveLength(0); + }); + + it('does not throw out of createScene when no callback is given', () => { + const { gl } = fakeGl(0); + expect(() => createScene({ canvas: asCanvas(fakeCanvas(gl)) })).not.toThrow(); + }); +}); + +describe('context loss', () => { + it('stops the loop, tells the host, and detaches every listener', () => { + const { gl } = fakeGl(); + const canvas = fakeCanvas(gl); + const onContextLost = vi.fn(); + createScene({ canvas: asCanvas(canvas), onContextLost }); + const lost = new Event('webglcontextlost', { cancelable: true }); + canvas.dispatchEvent(lost); + expect(lost.defaultPrevented).toBe(true); + expect(onContextLost).toHaveBeenCalledTimes(1); + expect(cancelled).toBe(1); + // listeners are gone: a second loss, a resize and a visibility change do nothing + canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + win.dispatchEvent(new Event('resize')); + doc.dispatchEvent(new Event('visibilitychange')); + expect(onContextLost).toHaveBeenCalledTimes(1); + expect(rafQueue.length).toBe(1); // only the frame queued before the loss, never run + }); + + it('does not render a queued frame after the loss', () => { + const { gl, calls } = fakeGl(); + const canvas = fakeCanvas(gl); + createScene({ canvas: asCanvas(canvas) }); + canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + runFrame(1000); + expect(calls.drawArrays).toBeUndefined(); + }); +}); + +describe('startBackgroundScene', () => { + it("does not request a context when the config leaves the tier 'off'", async () => { + const { gl } = fakeGl(); + const canvas = fakeCanvas(gl); + expect(await startBackgroundScene(undefined, {}, { canvas: asCanvas(canvas) })).toBeNull(); + expect( + await startBackgroundScene({ tier: 'off' }, {}, { canvas: asCanvas(canvas) }) + ).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('applies the device ceiling: saveData runs a still, one frame', async () => { + const { gl, calls } = fakeGl(); + const scene = await startBackgroundScene( + { tier: 'full' }, + { saveData: true }, + { canvas: asCanvas(fakeCanvas(gl)) } + ); + expect(scene).not.toBeNull(); + runFrame(1000); + expect(calls.drawArrays).toHaveBeenCalledTimes(4); + expect(rafQueue).toHaveLength(0); + }); + + it('hands compile failure to the host and returns null, the fall to the CSS ground', async () => { + const { gl } = fakeGl(0); + const onCompileFail = vi.fn(); + const scene = await startBackgroundScene( + { tier: 'full' }, + {}, + { canvas: asCanvas(fakeCanvas(gl)), onCompileFail } + ); + expect(scene).toBeNull(); + expect(onCompileFail).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts new file mode 100644 index 00000000..10dacc58 --- /dev/null +++ b/src/app/features/scene/Scene.ts @@ -0,0 +1,469 @@ +import { fragmentSource, vertexSource, Stage } from './shaderSource'; +import { cameraPose, pullProgress, CameraPose } from './camera'; +import { BudgetLadder, Level, LEVELS } from './budget'; +import type { SceneTier } from './tier'; +import { PALETTE } from './palette'; + +/** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */ +export const GPU_BUDGET_MS = 4.0; +const FRAME_INTERVAL_MS = 1000 / 30; +const REDUCED_MOTION_FRAMES = 32; +const MAX_DPR = 1.5; + +export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; +const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite']; + +export interface SceneStats { + /** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */ + passMs: Record; + level: number; + solidSize: [number, number]; + atmosphereSize: [number, number]; +} + +/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ +export type SourcePatch = (stage: Stage, source: string) => string; + +export class ShaderCompileError extends Error { + constructor(readonly stage: Stage | 'vertex', readonly log: string) { + super(`${stage} shader failed to compile:\n${log}`); + } +} + +/** Ladder level a 'low' scene starts at: the cost of a weak device's first 45 frames is never paid at full. */ +const LOW_START_LEVEL = 2; +/** The 'still' tier draws one frame at this level and stops. */ +const STILL_LEVEL = LEVELS[0]; + +export interface SceneOptions { + canvas: HTMLCanvasElement; + /** + * 'off' returns null without touching the canvas. 'still' draws one frame and stops. 'low' starts + * the adaptive ladder low. 'full' starts it at the top. Default 'full'. + */ + tier?: SceneTier; + /** A shader failed to compile or link. createScene frees what it built and returns null. */ + onCompileFail?: (e: ShaderCompileError) => void; + /** The WebGL context was lost. The scene stops for good; the host keeps its ground. */ + onContextLost?: () => void; + /** Called per finished frame when timer queries exist. */ + onStats?: (s: SceneStats) => void; + /** Weather knob, 0 clear .. 1 overcast. */ + coverage?: number; + /** Fixed level instead of the adaptive ladder (measurement). */ + fixedLevel?: Level; + /** Ignore the 30 fps cap and the visibility pause (measurement). */ + unthrottled?: boolean; + patchSource?: SourcePatch; +} + +export interface Scene { + /** Start the camera pull-back from the tree. */ + login(): void; + dispose(): void; +} + +function compile( + gl: WebGL2RenderingContext, + type: number, + source: string, + stage: Stage | 'vertex' +): WebGLShader { + const sh = gl.createShader(type)!; + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(sh) ?? ''; + gl.deleteShader(sh); + throw new ShaderCompileError(stage, log); + } + return sh; +} + +export function linkStage( + gl: WebGL2RenderingContext, + stage: Stage, + patch?: SourcePatch +): WebGLProgram { + const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex'); + const fs = compile( + gl, + gl.FRAGMENT_SHADER, + patch ? patch(stage, fragmentSource(stage)) : fragmentSource(stage), + stage + ); + const prog = gl.createProgram()!; + gl.attachShader(prog, vs); + gl.attachShader(prog, fs); + gl.linkProgram(prog); + gl.deleteShader(vs); + gl.deleteShader(fs); + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(prog) ?? ''; + gl.deleteProgram(prog); + throw new ShaderCompileError(stage, log); + } + return prog; +} + +interface Target { + tex: WebGLTexture; + w: number; + h: number; +} + +function halton(i: number, base: number): number { + let f = 1; + let r = 0; + let n = i; + while (n > 0) { + f /= base; + r += f * (n % base); + n = Math.floor(n / base); + } + return r; +} + +/** Null when WebGL2 or float colour buffers are missing; the host keeps its CSS ground. */ +export function createScene(opts: SceneOptions): Scene | null { + const { canvas } = opts; + const tier = opts.tier ?? 'full'; + if (tier === 'off') return null; + const gl = canvas.getContext('webgl2', { + antialias: false, + alpha: false, + depth: false, + stencil: false, + powerPreference: 'low-power', + }); + if (!gl || !gl.getExtension('EXT_color_buffer_float')) return null; + gl.getExtension('OES_texture_float_linear'); + const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2') as { + TIME_ELAPSED_EXT: number; + GPU_DISJOINT_EXT: number; + } | null; + + const programs = {} as Record; + try { + PASSES.forEach((p) => { + programs[p] = linkStage(gl, p, opts.patchSource); + }); + } catch (e) { + PASSES.forEach((p) => { + if (programs[p]) gl.deleteProgram(programs[p]); + }); + if (!(e instanceof ShaderCompileError)) throw e; + opts.onCompileFail?.(e); + return null; + } + const uniforms = new Map>(); + const loc = (p: WebGLProgram, name: string) => { + let m = uniforms.get(p); + if (!m) uniforms.set(p, (m = new Map())); + if (!m.has(name)) m.set(name, gl.getUniformLocation(p, name)); + return m.get(name) ?? null; + }; + + const vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + + const makeTarget = (w: number, h: number, format: number, filter: number): Target => { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + return { tex, w, h }; + }; + + const fbos = { + solid: gl.createFramebuffer()!, + atm: gl.createFramebuffer()!, + hist: [gl.createFramebuffer()!, gl.createFramebuffer()!], + }; + let solid: Target; + let depth: Target; + let atm: Target; + let hist: [Target, Target]; + const freeTargets = () => { + [solid, depth, atm, hist?.[0], hist?.[1]].forEach((t) => { + if (t) gl.deleteTexture(t.tex); + }); + }; + + const attach = (fbo: WebGLFramebuffer, targets: Target[]) => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + targets.forEach((t, i) => + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, t.tex, 0) + ); + gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i)); + }; + + const still = tier === 'still'; + const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0); + const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current); + + let canvasW = 0; + let canvasH = 0; + let sizedFor: Level | undefined; + let resetHistory = true; + + const ensureSize = () => { + const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); + const w = Math.max(2, Math.round(canvas.clientWidth * dpr)); + const h = Math.max(2, Math.round(canvas.clientHeight * dpr)); + const lv = level(); + if (w === canvasW && h === canvasH && lv === sizedFor) return; + canvasW = w; + canvasH = h; + sizedFor = lv; + canvas.width = w; + canvas.height = h; + freeTargets(); + const sw = Math.max(2, Math.round(w * lv.solidScale)); + const sh = Math.max(2, Math.round(h * lv.solidScale)); + const aw = Math.max(2, Math.round(w * lv.atmosphereScale)); + const ah = Math.max(2, Math.round(h * lv.atmosphereScale)); + solid = makeTarget(sw, sh, gl.RGBA16F, gl.LINEAR); + depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST); + atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR); + hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)]; + attach(fbos.solid, [solid, depth]); + attach(fbos.atm, [atm]); + attach(fbos.hist[0], [hist[0]]); + attach(fbos.hist[1], [hist[1]]); + resetHistory = true; + }; + + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + const start = performance.now(); + let loginAt: number | undefined; + let frame = 0; + let histIndex = 0; + let prev: CameraPose | undefined; + let raf = 0; + let lastRendered = -Infinity; + let framesLeft = still ? 1 : Infinity; // a still frame, or reduced motion, bounds this + let disposed = false; + + // Timer queries in flight: each holds one pass's query. + const pending: Array<{ q: WebGLQuery; pass: PassName; frame: number }> = []; + const finished = new Map>>(); + + const timed = (pass: PassName, f: number, body: () => void) => { + if (!timer || !opts.onStats) { + body(); + return; + } + const q = gl.createQuery()!; + gl.beginQuery(timer.TIME_ELAPSED_EXT, q); + body(); + gl.endQuery(timer.TIME_ELAPSED_EXT); + pending.push({ q, pass, frame: f }); + }; + + const collect = () => { + if (!timer) return; + while (pending.length && gl.getQueryParameter(pending[0].q, gl.QUERY_RESULT_AVAILABLE)) { + const { q, pass, frame: f } = pending.shift()!; + const disjoint = gl.getParameter(timer.GPU_DISJOINT_EXT); + const ns = gl.getQueryParameter(q, gl.QUERY_RESULT) as number; + gl.deleteQuery(q); + const row = finished.get(f) ?? {}; + if (!disjoint) row[pass] = ns / 1e6; + finished.set(f, row); + if (Object.keys(row).length === PASSES.length) { + finished.delete(f); + const passMs = row as Record; + const total = PASSES.reduce((s, p) => s + passMs[p], 0); + if (!opts.fixedLevel && ladder.push(total)) sizedFor = undefined; + opts.onStats?.({ + passMs, + level: ladder.index, + solidSize: [solid.w, solid.h], + atmosphereSize: [atm.w, atm.h], + }); + } + } + }; + + const setCommon = (p: WebGLProgram, pose: CameraPose, time: number) => { + const aspect = canvasW / canvasH; + const th = Math.tan(pose.fov / 2); + gl.uniform3fv(loc(p, 'u_camPos'), pose.position as unknown as number[]); + gl.uniformMatrix3fv(loc(p, 'u_camBasis'), false, pose.basis as unknown as number[]); + gl.uniform2f(loc(p, 'u_tanHalf'), th * aspect, th); + gl.uniform1f(loc(p, 'u_time'), time); + gl.uniform3fv(loc(p, 'u_sunDir'), PALETTE.sunDir as unknown as number[]); + gl.uniform3fv(loc(p, 'u_sunColor'), PALETTE.sunColor as unknown as number[]); + gl.uniform3fv(loc(p, 'u_skyTop'), PALETTE.skyTop as unknown as number[]); + gl.uniform3fv(loc(p, 'u_skyHorizon'), PALETTE.skyHorizon as unknown as number[]); + gl.uniform3fv(loc(p, 'u_fogColor'), PALETTE.fogColor as unknown as number[]); + }; + + const bindTex = (unit: number, t: Target, p: WebGLProgram, name: string) => { + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, t.tex); + gl.uniform1i(loc(p, name), unit); + }; + + const draw = () => gl.drawArrays(gl.TRIANGLES, 0, 3); + + const render = (now: number) => { + ensureSize(); + const lv = level(); + const frozen = reducedMotion?.matches ?? false; + const time = frozen ? 0 : (now - start) / 1000; + let elapsed: number | undefined; + if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000; + const pose = cameraPose(pullProgress(elapsed), time); + const f = frame; + frame += 1; + const jx = halton((f % 16) + 1, 2) - 0.5; + const jy = halton((f % 16) + 1, 3) - 0.5; + + // Pass 1: solid surfaces, writes colour and depth. + timed('terrain', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); + gl.viewport(0, 0, solid.w, solid.h); + const p = programs.terrain; + gl.useProgram(p); + setCommon(p, pose, time); + gl.uniform3fv(loc(p, 'u_grass'), PALETTE.grass as unknown as number[]); + gl.uniform3fv(loc(p, 'u_rock'), PALETTE.rock as unknown as number[]); + gl.uniform3fv(loc(p, 'u_bark'), PALETTE.bark as unknown as number[]); + gl.uniform3fv(loc(p, 'u_leaf'), PALETTE.leaf as unknown as number[]); + gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps); + draw(); + }); + + // Pass 2: atmosphere up to pass 1's depth, low-res. + timed('atmosphere', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.atm); + gl.viewport(0, 0, atm.w, atm.h); + const p = programs.atmosphere; + gl.useProgram(p); + setCommon(p, pose, time); + bindTex(0, depth, p, 'u_depth'); + gl.uniform1i(loc(p, 'u_steps'), lv.cloudSteps); + gl.uniform1f(loc(p, 'u_frame'), f); + gl.uniform2f(loc(p, 'u_jitter'), jx, jy); + gl.uniform2f(loc(p, 'u_size'), atm.w, atm.h); + gl.uniform1f(loc(p, 'u_coverage'), opts.coverage ?? 0.45); + draw(); + }); + + // TAA resolve into the history that is not being read. + const write = histIndex === 0 ? 1 : 0; + timed('resolve', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]); + gl.viewport(0, 0, atm.w, atm.h); + const p = programs.resolve; + gl.useProgram(p); + setCommon(p, pose, time); + bindTex(0, atm, p, 'u_cur'); + bindTex(1, hist[histIndex], p, 'u_history'); + bindTex(2, depth, p, 'u_depth'); + const pp = prev ?? pose; + const th = Math.tan(pp.fov / 2); + gl.uniform3fv(loc(p, 'u_prevPos'), pp.position as unknown as number[]); + gl.uniformMatrix3fv(loc(p, 'u_prevBasis'), false, pp.basis as unknown as number[]); + gl.uniform2f(loc(p, 'u_prevTanHalf'), th * (canvasW / canvasH), th); + gl.uniform1f(loc(p, 'u_blend'), resetHistory ? 1 : 0.125); + gl.uniform2f(loc(p, 'u_curSize'), atm.w, atm.h); + draw(); + }); + + // Full-resolution composite to the canvas. + timed('composite', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, canvasW, canvasH); + const p = programs.composite; + gl.useProgram(p); + setCommon(p, pose, time); + bindTex(0, solid, p, 'u_solid'); + bindTex(1, hist[write], p, 'u_atm'); + gl.uniform1f(loc(p, 'u_frame'), f); + draw(); + }); + + histIndex = write; + prev = pose; + resetHistory = false; + collect(); + }; + + const tick = (now: number) => { + raf = 0; + if (disposed) return; + const due = opts.unthrottled || now - lastRendered >= FRAME_INTERVAL_MS - 2; + if (due) { + if ( + !opts.fixedLevel && + !timer && + lastRendered > 0 && + now - lastRendered > FRAME_INTERVAL_MS * 1.5 + ) { + // no timer: a missed cap counts as over budget + if (ladder.push(GPU_BUDGET_MS * 2)) sizedFor = undefined; + } + lastRendered = now; + render(now); + framesLeft -= 1; + } + if (framesLeft > 0 && (opts.unthrottled || document.visibilityState === 'visible')) + raf = requestAnimationFrame(tick); + }; + + const wake = () => { + if (disposed || raf) return; + if (reducedMotion?.matches && framesLeft <= 0) return; + resetHistory = true; // the first frame after a pause has a real elapsed dt + raf = requestAnimationFrame(tick); + }; + const undo: Array<() => void> = []; + const listen = (target: EventTarget, type: string, handler: (e: Event) => void) => { + target.addEventListener(type, handler); + undo.push(() => target.removeEventListener(type, handler)); + }; + const stop = () => { + disposed = true; + if (raf) cancelAnimationFrame(raf); + undo.forEach((f) => f()); + }; + if (!opts.unthrottled) { + listen(document, 'visibilitychange', () => { + if (document.visibilityState === 'visible') wake(); + }); + } + listen(window, 'resize', () => wake()); + // A lost context takes every texture and program with it and nothing here rebuilds them, so the + // scene ends and the host decides what to show. preventDefault: otherwise the browser never + // restores the context, which is what we want, and a restore would find a dead scene. + listen(canvas, 'webglcontextlost', (e) => { + e.preventDefault(); + stop(); + opts.onContextLost?.(); + }); + if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES; + wake(); + + return { + login() { + if (still || disposed) return; // a still has no pull-back + loginAt = performance.now(); + if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; + wake(); + }, + dispose() { + if (disposed) return; + stop(); + freeTargets(); + PASSES.forEach((p) => gl.deleteProgram(programs[p])); + gl.deleteVertexArray(vao); + [fbos.solid, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb)); + }, + }; +} diff --git a/src/app/features/scene/budget.test.ts b/src/app/features/scene/budget.test.ts new file mode 100644 index 00000000..9ac8f0af --- /dev/null +++ b/src/app/features/scene/budget.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { BudgetLadder, LEVELS } from './budget'; + +describe('budget ladder', () => { + it('steps down after a whole window over budget and never back up', () => { + const ladder = new BudgetLadder(4, 5); + Array.from({ length: 4 }).forEach(() => expect(ladder.push(9)).toBe(false)); + expect(ladder.push(9)).toBe(true); + expect(ladder.index).toBe(1); + Array.from({ length: 100 }).forEach(() => ladder.push(0.1)); // fast frames never raise the level + expect(ladder.index).toBe(1); + }); + + it('judges the median, so one slow frame does not step down', () => { + const ladder = new BudgetLadder(4, 5); + [1, 1, 50, 1, 1].forEach((ms) => ladder.push(ms)); + expect(ladder.index).toBe(0); + }); + + it('stops at the last level', () => { + const ladder = new BudgetLadder(4, 1); + Array.from({ length: 20 }).forEach(() => ladder.push(99)); + expect(ladder.index).toBe(LEVELS.length - 1); + }); + + it('every level is cheaper than the one before in each cost axis', () => { + LEVELS.slice(1).forEach((lv, i) => { + const before = LEVELS[i]; + expect(lv.solidScale).toBeLessThan(before.solidScale); + expect(lv.atmosphereScale).toBeLessThan(before.atmosphereScale); + expect(lv.cloudSteps).toBeLessThan(before.cloudSteps); + expect(lv.traceSteps).toBeLessThan(before.traceSteps); + }); + }); + + it('starts at the level it is given, clamped into the ladder', () => { + expect(new BudgetLadder(4, 5, 2).index).toBe(2); + expect(new BudgetLadder(4, 5, 99).index).toBe(LEVELS.length - 1); + expect(new BudgetLadder(4, 5, -3).index).toBe(0); + }); +}); diff --git a/src/app/features/scene/budget.ts b/src/app/features/scene/budget.ts new file mode 100644 index 00000000..3b373d86 --- /dev/null +++ b/src/app/features/scene/budget.ts @@ -0,0 +1,59 @@ +// The adaptive step-down. The ladder only descends within a session; nothing here ever steps up. + +export interface Level { + /** Pass 1 resolution as a fraction of the canvas. */ + solidScale: number; + /** Pass 2 resolution as a fraction of the canvas. */ + atmosphereScale: number; + /** Cloud march steps. */ + cloudSteps: number; + /** Sphere-trace step cap. */ + traceSteps: number; +} + +export const LEVELS: readonly Level[] = [ + { solidScale: 0.75, atmosphereScale: 0.5, cloudSteps: 12, traceSteps: 96 }, + { solidScale: 0.6, atmosphereScale: 0.4, cloudSteps: 8, traceSteps: 80 }, + { solidScale: 0.5, atmosphereScale: 0.33, cloudSteps: 6, traceSteps: 64 }, + { solidScale: 0.4, atmosphereScale: 0.25, cloudSteps: 4, traceSteps: 48 }, +]; + +/** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */ +export class BudgetLadder { + private level: number; + + private recent: number[] = []; + + private readonly budgetMs: number; + + private readonly window: number; + + /** `startLevel` lets a weak device begin below level 0; it is clamped into the ladder. */ + constructor(budgetMs: number, window = 45, startLevel = 0) { + this.budgetMs = budgetMs; + this.window = window; + this.level = Math.min(Math.max(startLevel, 0), LEVELS.length - 1); + } + + get current(): Level { + return LEVELS[this.level]; + } + + get index(): number { + return this.level; + } + + /** Feed one frame's GPU (or cadence) time. Returns true when the level changed. */ + push(ms: number): boolean { + this.recent.push(ms); + if (this.recent.length < this.window) return false; + const sorted = [...this.recent].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + this.recent = []; + if (median > this.budgetMs && this.level < LEVELS.length - 1) { + this.level += 1; + return true; + } + return false; + } +} diff --git a/src/app/features/scene/camera.test.ts b/src/app/features/scene/camera.test.ts new file mode 100644 index 00000000..f7199a65 --- /dev/null +++ b/src/app/features/scene/camera.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { cameraPose, pullProgress, PULL_BACK_SECONDS } from './camera'; +import { WORLD } from './world'; + +const dist = (p: readonly [number, number, number]) => Math.hypot(p[0], p[2]); + +describe('login pull-back', () => { + it('is at rest before login and complete after PULL_BACK_SECONDS', () => { + expect(pullProgress(undefined)).toBe(0); + expect(pullProgress(0)).toBe(0); + expect(pullProgress(PULL_BACK_SECONDS)).toBe(1); + expect(pullProgress(PULL_BACK_SECONDS * 10)).toBe(1); + }); + + it('only moves away from the tree and never reverses', () => { + let last = -1; + for (let s = 0; s <= PULL_BACK_SECONDS; s += 0.1) { + const d = dist(cameraPose(pullProgress(s), 0).position); + expect(d).toBeGreaterThanOrEqual(last); + last = d; + } + }); + + it('ends over the flat ground so it cannot be inside the terrain, whatever the orbit angle', () => { + for (let t = 0; t < 2000; t += 37) { + expect(dist(cameraPose(1, t).position)).toBeLessThan(WORLD.plateauInner); + } + }); + + it('looks at the tree: forward points at the axis and the basis is orthonormal', () => { + const pose = cameraPose(0.5, 12); + const [rx, ry, rz, ux, uy, uz, fx, fy, fz] = pose.basis; + const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + expect(dot([rx, ry, rz], [ux, uy, uz])).toBeCloseTo(0, 6); + expect(dot([rx, ry, rz], [fx, fy, fz])).toBeCloseTo(0, 6); + expect(dot([ux, uy, uz], [fx, fy, fz])).toBeCloseTo(0, 6); + expect(Math.hypot(fx, fy, fz)).toBeCloseTo(1, 6); + // Forward points back across the tree axis, not away from it. + const [px, , pz] = pose.position; + expect(fx * px + fz * pz).toBeLessThan(0); + }); +}); diff --git a/src/app/features/scene/camera.ts b/src/app/features/scene/camera.ts new file mode 100644 index 00000000..2b08f0d1 --- /dev/null +++ b/src/app/features/scene/camera.ts @@ -0,0 +1,56 @@ +// The scene's camera: close under the tree at rest, pulled back on login, drifting after. +// Pure functions of time so the pull-back is testable without a GL context. + +export type Vec3 = readonly [number, number, number]; + +export interface CameraPose { + position: Vec3; + /** Column-major right, up, forward: the layout of the shader's mat3 uniform. */ + basis: readonly [number, number, number, number, number, number, number, number, number]; + /** Vertical field of view in radians. */ + fov: number; +} + +export const PULL_BACK_SECONDS = 6; + +// Keep the end distance inside WORLD.plateauInner so the camera is always over flat ground. +const NEAR = { radius: 20, camY: 5, targetY: 34, fov: 1.15 }; +const FAR = { radius: 110, camY: 50, targetY: 36, fov: 0.82 }; +const DRIFT_RAD_PER_SEC = 0.025; + +const smoother = (x: number) => x * x * x * (x * (x * 6 - 15) + 10); +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + +/** 0 before login, easing to 1 over PULL_BACK_SECONDS. `elapsed` is seconds since login. */ +export function pullProgress(elapsed: number | undefined): number { + if (elapsed === undefined || elapsed <= 0) return 0; + return smoother(Math.min(1, elapsed / PULL_BACK_SECONDS)); +} + +function norm(v: Vec3): Vec3 { + const l = Math.hypot(v[0], v[1], v[2]) || 1; + return [v[0] / l, v[1] / l, v[2] / l]; +} +function cross(a: Vec3, b: Vec3): Vec3 { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +} + +/** + * @param pull 0..1 from pullProgress + * @param time seconds since the scene started, for the slow orbit + */ +export function cameraPose(pull: number, time: number): CameraPose { + const radius = lerp(NEAR.radius, FAR.radius, pull); + const camY = lerp(NEAR.camY, FAR.camY, pull); + const targetY = lerp(NEAR.targetY, FAR.targetY, pull); + const azimuth = 0.9 + time * DRIFT_RAD_PER_SEC; + const position: Vec3 = [radius * Math.cos(azimuth), camY, radius * Math.sin(azimuth)]; + const forward = norm([-position[0], targetY - position[1], -position[2]]); + const right = norm(cross([0, 1, 0], forward)); + const up = cross(forward, right); + return { + position, + basis: [...right, ...up, ...forward] as unknown as CameraPose['basis'], + fov: lerp(NEAR.fov, FAR.fov, pull), + }; +} diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts new file mode 100644 index 00000000..8e59175b --- /dev/null +++ b/src/app/features/scene/creatures.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { BUCKET_SCALE, creatureScale, NEUTRAL_SCALE, SizeBucket } from './creatures'; + +const buckets: SizeBucket[] = ['small', 'medium', 'large']; + +describe('creature scale', () => { + it('follows sizeBucket for a creature with an estate', () => { + buckets.forEach((sizeBucket) => + expect(creatureScale({ sizeBucket, neutral: false })).toBe(BUCKET_SCALE[sizeBucket]) + ); + }); + + it('is one fixed size for every neutral creature, whatever its sizeBucket', () => { + buckets.forEach((sizeBucket) => + expect(creatureScale({ sizeBucket, neutral: true })).toBe(NEUTRAL_SCALE) + ); + }); + + it('is only visible as an override where the bucket would have said something else', () => { + expect(BUCKET_SCALE.small).not.toBe(NEUTRAL_SCALE); + expect(BUCKET_SCALE.large).not.toBe(NEUTRAL_SCALE); + }); +}); diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts new file mode 100644 index 00000000..5026d2e2 --- /dev/null +++ b/src/app/features/scene/creatures.ts @@ -0,0 +1,37 @@ +// Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut. +// +// Props contract: heimdall-body:follow_up:ratatoskr-creature-projection-v2 (against GameCult/Bifrost; supersedes heimdall-body/ratatoskr-creature-projection). +// Nothing here is binding until Bifrost's steward cuts that contract. +// +// Shape: {id, estate, sizeBucket, neutral, activity}. +// Invariants: +// - Exact vote weights stay in Bifrost; Ratatoskr only ever sees a bucket. +// - neutral=true overrides any bucket, and no size is drawn. +// - activity comes from the Heimdall session. +// Consent: humans get a coarse bucket by default plus a typed opt-out; Minds and Personas may show finer buckets. +// Humans have three coarse buckets. The scales below (0.7 / 1 / 1.4) are placeholders, not fixed. + +export type SizeBucket = 'small' | 'medium' | 'large'; + +export interface Creature { + id: string; + estate: string; + sizeBucket: SizeBucket; + /** A neutral creature (no estate allegiance) is drawn at one fixed size whatever its bucket. */ + neutral: boolean; + activity: 'idle' | 'active'; +} + +export const BUCKET_SCALE: Readonly> = { + small: 0.7, + medium: 1, + large: 1.4, +}; + +/** Neutral creatures are medium sized, not necessarily their bucket's size. */ +export const NEUTRAL_SCALE = BUCKET_SCALE.medium; + +/** Neutral overrides sizeBucket. */ +export function creatureScale(c: Pick): number { + return c.neutral ? NEUTRAL_SCALE : BUCKET_SCALE[c.sizeBucket]; +} diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts new file mode 100644 index 00000000..f4109796 --- /dev/null +++ b/src/app/features/scene/index.ts @@ -0,0 +1,23 @@ +import type { Scene, SceneOptions } from './Scene'; +import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier'; + +export type { Scene, SceneStats } from './Scene'; +export type { SceneTier } from './tier'; + +export type StartOptions = Omit; + +/** + * The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no + * canvas context is requested; otherwise the scene code loads on demand, so it stays out of the + * main chunk. Null means "keep the CSS ground". + */ +export async function startBackgroundScene( + config: BackgroundSceneConfig | undefined, + capabilities: Capabilities, + options: StartOptions +): Promise { + const tier = resolveTier(config, capabilities); + if (tier === 'off') return null; + const { createScene } = await import('./Scene'); + return createScene({ ...options, tier }); +} diff --git a/src/app/features/scene/palette.ts b/src/app/features/scene/palette.ts new file mode 100644 index 00000000..54a84ad3 --- /dev/null +++ b/src/app/features/scene/palette.ts @@ -0,0 +1,27 @@ +// The scene's colours, as linear RGB uniforms. The sky tints are GameCult's three nebula inputs +// (orange 255 138 42, violet 109 96 255, sky 89 183 255) over the brand base #07111a, so the +// background reads as the same dusk as gamecult.org. The shaders hold no colour literal. + +export type Rgb = readonly [number, number, number]; + +const srgb = (r: number, g: number, b: number): Rgb => [ + (r / 255) ** 2.2, + (g / 255) ** 2.2, + (b / 255) ** 2.2, +]; + +export const PALETTE = { + sunColor: srgb(255, 150, 70).map((c) => c * 3.2) as unknown as Rgb, + skyTop: srgb(30, 36, 92), + skyHorizon: srgb(255, 138, 42).map((c) => c * 0.55) as unknown as Rgb, + fogColor: srgb(109, 96, 255).map((c) => c * 0.12) as unknown as Rgb, + grass: srgb(72, 112, 86), + rock: srgb(112, 104, 122), + bark: srgb(62, 46, 40), + leaf: srgb(89, 183, 255).map((c) => c * 0.7) as unknown as Rgb, + sunDir: (() => { + const v = [0.55, 0.16, -0.82]; + const l = Math.hypot(v[0], v[1], v[2]); + return [v[0] / l, v[1] / l, v[2] / l] as Rgb; + })(), +} as const; diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts new file mode 100644 index 00000000..aa44d9b2 --- /dev/null +++ b/src/app/features/scene/shaderSource.ts @@ -0,0 +1,29 @@ +import cultmath from './shaders/CultMath.glsl?raw'; +import phacelle from './shaders/CultMath.Phacelle.glsl?raw'; +import common from './shaders/common.glsl?raw'; +import fullscreenVert from './shaders/fullscreen.vert.glsl?raw'; +import terrainFrag from './shaders/terrain.frag.glsl?raw'; +import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw'; +import resolveFrag from './shaders/resolve.frag.glsl?raw'; +import compositeFrag from './shaders/composite.frag.glsl?raw'; +import { WORLD, worldDefines } from './world'; + +export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; + +const HEADER = + '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; + +export const vertexSource = HEADER + fullscreenVert; + +const stages: Record = { + terrain: terrainFrag, + atmosphere: atmosphereFrag, + resolve: resolveFrag, + composite: compositeFrag, +}; + +/** Fragment source for a stage: header, world defines, CultMath, the shared block, the stage. */ +export function fragmentSource(stage: Stage): string { + const erosion = stage === 'terrain' && WORLD.phacelleGradBound > 0 ? phacelle : ''; + return `${HEADER}${worldDefines()}\n${cultmath}\n${erosion}\n${common}\n${stages[stage]}`; +} diff --git a/src/app/features/scene/shaders/CultMath.Phacelle.glsl b/src/app/features/scene/shaders/CultMath.Phacelle.glsl new file mode 100644 index 00000000..11405fa2 --- /dev/null +++ b/src/app/features/scene/shaders/CultMath.Phacelle.glsl @@ -0,0 +1,99 @@ +// Generated from shaders/CultMath.Phacelle.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MPL-2.0; requires CultMath.glsl before it. +#ifndef CULTMATH_PHACELLE_GLSL +#define CULTMATH_PHACELLE_GLSL +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the +// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke +// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact +// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after +// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md). + +// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit +// against CultMath.CultPhasor, same field order. +struct CultPhasor +{ + vec4 cos; + vec4 sin; +}; + +// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave +// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors +// the C# body operation for operation; the C#-only internal overload that switches the prune off +// has no shader twin. +CultPhasor cultmath_phacelle(vec3 p, vec3 side, float offset, float normalization) +{ + vec3 cell = floor(p); + vec3 local = p - cell; + float phaseOffset = offset * CULTMATH_TAU; + float sumW = 0.0; + float sumCos = 0.0; + float sumSin = 0.0; + vec3 gradW = vec3(0.0, 0.0, 0.0); + vec3 gradCos = vec3(0.0, 0.0, 0.0); + vec3 gradSin = vec3(0.0, 0.0, 0.0); + + for (int dz = -1; dz <= 2; dz++) + { + float gz = max(0.0, abs(local.z - float(dz)) - 0.5); + for (int dy = -1; dy <= 2; dy++) + { + float gy = max(0.0, abs(local.y - float(dy)) - 0.5); + for (int dx = -1; dx <= 2; dx++) + { + float gx = max(0.0, abs(local.x - float(dx)) - 0.5); + if (gx * gx + gy * gy + gz * gz >= 2.25) + continue; + + vec3 gridStep = vec3(dx, dy, dz); + ivec3 hash = cultmath_pcg3d(ivec3(cell + gridStep)); + vec3 jitter = vec3( + float(uint(hash.x) >> 8) * (1.0 / 16777216.0) - 0.5, + float(uint(hash.y) >> 8) * (1.0 / 16777216.0) - 0.5, + float(uint(hash.z) >> 8) * (1.0 / 16777216.0) - 0.5); + vec3 v = local - gridStep - jitter; + float falloff = exp(-2.0 * dot(v, v)); + float w = max(0.0, falloff - 0.01111); + vec3 dw = w > 0.0 ? (-4.0 * falloff) * v : vec3(0.0, 0.0, 0.0); + float phase = dot(v, side) + phaseOffset; + float c = cos(phase); + float s = sin(phase); + + sumW += w; + sumCos += w * c; + sumSin += w * s; + gradW += dw; + gradCos += dw * c - (w * s) * side; + gradSin += dw * s + (w * c) * side; + } + } + } + + float invW = 1.0 / sumW; + float rawCos = sumCos * invW; + float rawSin = sumSin * invW; + vec3 gradRawCos = (gradCos - rawCos * gradW) * invW; + vec3 gradRawSin = (gradSin - rawSin * gradW) * invW; + float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin); + float floorLength = 1.0 - normalization; + + CultPhasor result; + if (rawLength > floorLength) + { + float outCos = rawCos / rawLength; + float outSin = rawSin / rawLength; + vec3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength; + result.cos = vec4((gradRawCos - outCos * gradLength) / rawLength, outCos); + result.sin = vec4((gradRawSin - outSin * gradLength) / rawLength, outSin); + } + else + { + result.cos = vec4(gradRawCos / floorLength, rawCos / floorLength); + result.sin = vec4(gradRawSin / floorLength, rawSin / floorLength); + } + + return result; +} + +#endif diff --git a/src/app/features/scene/shaders/CultMath.glsl b/src/app/features/scene/shaders/CultMath.glsl new file mode 100644 index 00000000..8a380a96 --- /dev/null +++ b/src/app/features/scene/shaders/CultMath.glsl @@ -0,0 +1,1035 @@ +// Generated from shaders/CultMath.hlsl and CultMath.Interval.hlsl and CultMath.Affine.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MIT. cultmath_phacelle is in CultMath.Phacelle.glsl, to be concatenated after this file by consumers that call it. cultmath_cycles_divide, cultmath_cycles_power, cultmath_cycles_logarithm, cultmath_cycles_sqrt, cultmath_cycles_inverse_sqrt, cultmath_cycles_modulo, cultmath_cycles_floored_modulo, cultmath_cycles_wrap, cultmath_cycles_snap, cultmath_cycles_pingpong, cultmath_cycles_smooth_min, cultmath_cycles_smooth_max, cultmath_cycles_compare, cultmath_cycles_round, cultmath_cycles_trunc, cultmath_cycles_fract, cultmath_cycles_sign, cultmath_cycles_arcsine, cultmath_cycles_arccosine, cultmath_cycles_arctan2, cultmath_cycles_map_range_linear, cultmath_cycles_map_range_stepped, cultmath_cycles_map_range_smoothstep, cultmath_cycles_map_range_smootherstep, cultmath_cycles_clamp_minmax, cultmath_cycles_clamp_range, cultmath_cycles_mix_float, cultmath_cycles_mix_vector, cultmath_cycles_mix_blend, cultmath_cycles_mix_add, cultmath_cycles_mix_mul, cultmath_cycles_mix_screen, cultmath_cycles_mix_overlay, cultmath_cycles_mix_sub, cultmath_cycles_mix_div, cultmath_cycles_mix_diff, cultmath_cycles_mix_exclusion, cultmath_cycles_mix_dark, cultmath_cycles_mix_light, cultmath_cycles_mix_dodge, cultmath_cycles_mix_burn, cultmath_cycles_rgb_to_hsv, cultmath_cycles_hsv_to_rgb, cultmath_cycles_mix_hue, cultmath_cycles_mix_sat, cultmath_cycles_mix_val, cultmath_cycles_mix_color, cultmath_cycles_mix_soft, cultmath_cycles_mix_linear, cultmath_cycles_rgb_to_bw, cultmath_cycles_ramp_index is in CultMath.Cycles.glsl, to be concatenated after this file by consumers that call it. +#ifndef CULTMATH_GLSL +#define CULTMATH_GLSL +// CultMath HLSL mirror. +// +// HLSL already owns the vec2/vec3/vec4 type names and most primitive +// intrinsics. This include names the CultMath semantic surface explicitly so +// shader code, C# wrappers, and Rust/native parity fixtures can share the same +// small vocabulary without local helper drift. + +const float CULTMATH_PI = 3.14159265358979323846; +const float CULTMATH_TAU = 6.28318530717958647692; +const float CULTMATH_HALF_PI = 1.57079632679489661923; + +float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); } +float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); } + +float cultmath_frac(float value) { return value - floor(value); } +vec2 cultmath_frac(vec2 value) { return value - floor(value); } +vec3 cultmath_frac(vec3 value) { return value - floor(value); } +vec4 cultmath_frac(vec4 value) { return value - floor(value); } + +float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); } +vec2 cultmath_clamp(vec2 value, vec2 minimum, vec2 maximum) { return min(max(value, minimum), maximum); } +vec3 cultmath_clamp(vec3 value, vec3 minimum, vec3 maximum) { return min(max(value, minimum), maximum); } +vec4 cultmath_clamp(vec4 value, vec4 minimum, vec4 maximum) { return min(max(value, minimum), maximum); } + +// DXIL Saturate is FMin(1, FMax(0, x)); dxc lowers min/max to the same FMin/FMax, NaN rules included. +float cultmath_saturate(float value) { return min(1.0, max(0.0, value)); } +vec2 cultmath_saturate(vec2 value) { return min(vec2(1.0, 1.0), max(vec2(0.0, 0.0), value)); } +vec3 cultmath_saturate(vec3 value) { return min(vec3(1.0, 1.0, 1.0), max(vec3(0.0, 0.0, 0.0), value)); } +vec4 cultmath_saturate(vec4 value) { return min(vec4(1.0, 1.0, 1.0, 1.0), max(vec4(0.0, 0.0, 0.0, 0.0), value)); } + +float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; } +vec2 cultmath_lerp(vec2 start, vec2 end, vec2 amount) { return start + (end - start) * amount; } +vec3 cultmath_lerp(vec3 start, vec3 end, vec3 amount) { return start + (end - start) * amount; } +vec4 cultmath_lerp(vec4 start, vec4 end, vec4 amount) { return start + (end - start) * amount; } + +float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; } +vec2 cultmath_step(vec2 edge, vec2 value) { return vec2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); } +vec3 cultmath_step(vec3 edge, vec3 value) { return vec3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); } +vec4 cultmath_step(vec4 edge, vec4 value) { return vec4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); } + +float cultmath_smoothstep(float minimum, float maximum, float value) +{ + float t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0 - 2.0 * t); +} + +vec2 cultmath_smoothstep(vec2 minimum, vec2 maximum, vec2 value) +{ + vec2 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * t); +} + +vec3 cultmath_smoothstep(vec3 minimum, vec3 maximum, vec3 value) +{ + vec3 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (vec3(3.0, 3.0, 3.0) - vec3(2.0, 2.0, 2.0) * t); +} + +vec4 cultmath_smoothstep(vec4 minimum, vec4 maximum, vec4 value) +{ + vec4 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (vec4(3.0, 3.0, 3.0, 3.0) - vec4(2.0, 2.0, 2.0, 2.0) * t); +} + +float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); } + +float cultmath_smootherstep(float value) +{ + value = cultmath_saturate(value); + return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); +} + +vec2 cultmath_snoise_mod289(vec2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +vec3 cultmath_snoise_mod289(vec3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +vec4 cultmath_snoise_mod289(vec4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +vec3 cultmath_snoise_permute(vec3 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } +vec4 cultmath_snoise_permute(vec4 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } + +// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation +// order as the C# math.snoise(vec3) mirror. Ties resolve in simplexnoise1234's +// total order; see math.snoise. +float cultmath_snoise(vec3 value) +{ + vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); + vec3 i = floor(value + dot(value, c.yyy)); + vec3 x0 = value - i + dot(i, c.xxx); + vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + c.xxx; + vec3 x2 = x0 - i2 + c.yyy; + vec3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + vec4 m = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + m = m * m; + return 105.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); +} + +float cultmath_snoise(vec2 value) +{ + vec4 c = vec4( + 0.211324865405187, + 0.366025403784439, + -0.577350269189626, + 0.024390243902439); + vec2 i = floor(value + dot(value, c.yy)); + vec2 x0 = value - i + dot(i, c.xx); + vec2 i1 = x0.x > x0.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0); + vec4 x12 = vec4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z); + + i = cultmath_snoise_mod289(i); + vec3 p = cultmath_snoise_permute( + cultmath_snoise_permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)); + vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); + m *= m; + m *= m; + + vec3 x = 2.0 * fract(p * c.w) - 1.0; + vec3 h = abs(x) - 0.5; + vec3 ox = floor(x + 0.5); + vec3 a0 = x - ox; + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); + + vec3 g = vec3( + a0.x * x0.x + h.x * x0.y, + a0.y * x12.x + h.y * x12.y, + a0.z * x12.z + h.z * x12.w); + return 130.0 * dot(m, g); +} + +// Analytic gradient of cultmath_snoise(vec3) (invariant 8), following webgl-noise +// src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT); same kernel, permutation and scale as +// cultmath_snoise(vec3) (stegu/webgl-noise 22434e04d7). See the C# math.snoise_grad comment for the +// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise(vec3)'s own derivation +// verbatim. +vec4 cultmath_snoise_grad(vec3 value) +{ + vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); + vec3 i = floor(value + dot(value, c.yyy)); + vec3 x0 = value - i + dot(i, c.xxx); + vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + c.xxx; + vec3 x2 = x0 - i2 + c.yyy; + vec3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + vec4 m2 = m0 * m0; + vec4 m3 = m2 * m0; + vec4 m4 = m2 * m2; + + vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); + float value2 = 105.0 * dot(m4, px); + vec3 grad = -8.0 * m3.x * x0 * px.x + m4.x * p0 + + -8.0 * m3.y * x1 * px.y + m4.y * p1 + + -8.0 * m3.z * x2 * px.z + m4.z * p2 + + -8.0 * m3.w * x3 * px.w + m4.w * p3; + grad *= 105.0; + + return vec4(grad, value2); +} + +// Analytic Hessian of cultmath_snoise(vec3); see the C# math.snoise_hessian comment for the +// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise_grad's own. The matrix is +// built from nine scalars, which read as rows in HLSL and as columns in GLSL; it is symmetric, so the +// two agree. +mat3 cultmath_snoise_hessian(vec3 value) +{ + vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); + vec3 i = floor(value + dot(value, c.yyy)); + vec3 x0 = value - i + dot(i, c.xxx); + vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + c.xxx; + vec3 x2 = x0 - i2 + c.yyy; + vec3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + vec4 m2 = m0 * m0; + vec4 m3 = m2 * m0; + vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); + vec4 wide = 48.0 * m2 * px; + vec4 narrow = 8.0 * m3; + + vec4 cxx = vec4(x0.x, x1.x, x2.x, x3.x); + vec4 cxy = vec4(x0.y, x1.y, x2.y, x3.y); + vec4 cxz = vec4(x0.z, x1.z, x2.z, x3.z); + vec4 cpx = vec4(p0.x, p1.x, p2.x, p3.x); + vec4 cpy = vec4(p0.y, p1.y, p2.y, p3.y); + vec4 cpz = vec4(p0.z, p1.z, p2.z, p3.z); + + float hxx = 105.0 * (dot(wide * cxx, cxx) - dot(narrow, 2.0 * cpx * cxx + px)); + float hyy = 105.0 * (dot(wide * cxy, cxy) - dot(narrow, 2.0 * cpy * cxy + px)); + float hzz = 105.0 * (dot(wide * cxz, cxz) - dot(narrow, 2.0 * cpz * cxz + px)); + float hxy = 105.0 * (dot(wide * cxx, cxy) - dot(narrow, cpx * cxy + cpy * cxx)); + float hxz = 105.0 * (dot(wide * cxx, cxz) - dot(narrow, cpx * cxz + cpz * cxx)); + float hyz = 105.0 * (dot(wide * cxy, cxz) - dot(narrow, cpy * cxz + cpz * cxy)); + return mat3(hxx, hxy, hxz, hxy, hyy, hyz, hxz, hyz, hzz); +} + +// The integral over [t0, t1] of clamp(a + b t, lo, hi); see the C# math.integral_clamp_linear comment. +float cultmath_integral_clamp_linear(float a, float b, float lo, float hi, float t0, float t1) +{ + lo = min(lo, hi); + if (b == 0.0) + return t1 != t0 ? clamp(a, lo, hi) * (t1 - t0) : 0.0; + + float direction = t1 < t0 ? -1.0 : 1.0; + float start = min(t0, t1); + float stop = max(t0, t1); + float atLo = (lo - a) / b; + float atHi = (hi - a) / b; + float enter = clamp(min(atLo, atHi), start, stop); + float leave = clamp(max(atLo, atHi), start, stop); + float before = b > 0.0 ? lo : hi; + float after = b > 0.0 ? hi : lo; + float held = enter > start ? before * (enter - start) : 0.0; + float kept = stop > leave ? after * (stop - leave) : 0.0; + float total = held + (a + b * (0.5 * (enter + leave))) * (leave - enter) + kept; + return direction * total; +} + +// Octave sum of cultmath_snoise_grad (invariant 8); see the C# math.fbm_grad comment for the +// per-octave frequency/amplitude derivation and why octaves is clamped. +vec4 cultmath_fbm_grad(vec3 p, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + float value = 0.0; + vec3 gradient = vec3(0.0, 0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + vec4 n = cultmath_snoise_grad(p * frequency); + value += amplitude * n.w; + gradient += amplitude * frequency * n.xyz; + frequency *= lacunarity; + amplitude *= gain; + } + + return vec4(gradient, value); +} + +// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad +// comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. +vec4 cultmath_ridged_grad(vec3 p, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + float value = 0.0; + vec3 gradient = vec3(0.0, 0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + vec4 n = cultmath_snoise_grad(p * frequency); + float s = sign(n.w); + value += amplitude * (1.0 - abs(n.w)); + gradient += -amplitude * frequency * s * n.xyz; + frequency *= lacunarity; + amplitude *= gain; + } + + return vec4(gradient, value); +} + +float cultmath_lengthsq(vec2 value) { return dot(value, value); } +float cultmath_lengthsq(vec3 value) { return dot(value, value); } +float cultmath_lengthsq(vec4 value) { return dot(value, value); } + +float cultmath_distance(vec2 left, vec2 right) { return length(left - right); } +float cultmath_distance(vec3 left, vec3 right) { return length(left - right); } +float cultmath_distance(vec4 left, vec4 right) { return length(left - right); } + +vec2 cultmath_reflect(vec2 incident, vec2 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +vec3 cultmath_reflect(vec3 incident, vec3 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +vec2 cultmath_rotate(vec2 value, float radians_value) +{ + float s = sin(radians_value); + float c = cos(radians_value); + return vec2(value.x * c - value.y * s, value.x * s + value.y * c); +} + +vec2 cultmath_rotate_degrees(vec2 value, float degrees_value) +{ + return cultmath_rotate(value, cultmath_radians(degrees_value)); +} + +float cultmath_csum(vec2 value) { return value.x + value.y; } +float cultmath_csum(vec3 value) { return value.x + value.y + value.z; } +float cultmath_csum(vec4 value) { return value.x + value.y + value.z + value.w; } + +float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +vec2 cultmath_decay(vec2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +vec3 cultmath_decay(vec3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +vec4 cultmath_decay(vec4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } + +float cultmath_damp(float start, float end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +vec2 cultmath_damp(vec2 start, vec2 end, float lambda_value, float dt) +{ + float amount = 1.0 - exp(-lambda_value * dt); + return cultmath_lerp(start, end, vec2(amount, amount)); +} + +vec3 cultmath_damp(vec3 start, vec3 end, float lambda_value, float dt) +{ + float amount = 1.0 - exp(-lambda_value * dt); + return cultmath_lerp(start, end, vec3(amount, amount, amount)); +} + +vec4 cultmath_damp(vec4 start, vec4 end, float lambda_value, float dt) +{ + float amount = 1.0 - exp(-lambda_value * dt); + return cultmath_lerp(start, end, vec4(amount, amount, amount, amount)); +} + +float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +vec2 cultmath_catmullrom(vec2 p0, vec2 p1, vec2 p2, vec2 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +vec3 cultmath_catmullrom(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +vec4 cultmath_catmullrom(vec4 p0, vec4 p1, vec4 p2, vec4 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float cultmath_quadratic_bezier(float p0, float p1, float p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +vec3 cultmath_quadratic_bezier(vec3 p0, vec3 p1, vec3 p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +vec3 cultmath_cubic_bezier(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453); } +float cultmath_hash(vec2 value) { return cultmath_hash(dot(value, vec2(127.1, 311.7))); } +float cultmath_hash(vec3 value) { return cultmath_hash(dot(value, vec3(127.1, 311.7, 74.7))); } + +// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's ivec3/ivec4 carry the bits. +uint cultmath_pcg(uint value) +{ + uint state = value * 747796405u + 2891336453u; + uint word = ((state >> int((state >> 28) + 4u)) ^ state) * 277803737u; + return (word >> 22) ^ word; +} + +ivec3 cultmath_pcg3d(ivec3 value) +{ + uint x = uint(value.x) * 1664525u + 1013904223u, y = uint(value.y) * 1664525u + 1013904223u, z = uint(value.z) * 1664525u + 1013904223u; + x += y * z; y += z * x; z += x * y; + x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; + x += y * z; y += z * x; z += x * y; + return ivec3(int(x), int(y), int(z)); +} + +ivec3 cultmath_pcg3d(vec3 value) { return cultmath_pcg3d(ivec3(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)))); } +ivec3 cultmath_pcg3d(vec2 value) { return cultmath_pcg3d(vec3(value, 0.0)); } + +ivec4 cultmath_pcg4d(ivec4 value) +{ + uint x = uint(value.x) * 1664525u + 1013904223u, y = uint(value.y) * 1664525u + 1013904223u; + uint z = uint(value.z) * 1664525u + 1013904223u, w = uint(value.w) * 1664525u + 1013904223u; + x += y * w; y += z * x; z += x * y; w += y * z; + x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16; + x += y * w; y += z * x; z += x * y; w += y * z; + return ivec4(int(x), int(y), int(z), int(w)); +} + +ivec4 cultmath_pcg4d(vec4 value) { return cultmath_pcg4d(ivec4(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)), int(floatBitsToUint(value.w)))); } + +// Inigo Quilez, "smooth minimum": quadratic-polynomial smin carried to value-and-gradient form. See +// math.smin_grad's comment (math.cs) for why the gradient blend is exact, not an approximation. +vec4 cultmath_smin_grad(vec4 a, vec4 b, float k) +{ + float h = clamp(0.5 + 0.5 * (b.w - a.w) / k, 0.0, 1.0); + float value = mix(b.w, a.w, h) - k * h * (1.0 - h); + vec3 gradient = mix(b.xyz, a.xyz, h); + return vec4(gradient, value); +} + +// Invariant 8's one struct return shape (design.md, "HLSL Target: Source Transformations"): +// compared field by field and bit for bit against CultMath.CultCellular, same field order. +struct CultCellular +{ + vec4 nearest; + vec4 edge; + float id; +}; + +// Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996). See math.cellular's comment +// (math.cs) for the hashing, gradient, search-radius exactness proof, id source and +// degenerate-point conventions this mirrors exactly, including the 5x5x5 lower-bound prune. +CultCellular cultmath_cellular(vec3 p) +{ + vec3 cell = floor(p); + float f1 = 1.0e30; + float f2 = 1.0e30; + vec3 c1 = vec3(0.0, 0.0, 0.0); + vec3 c2 = vec3(0.0, 0.0, 0.0); + vec3 cellId = vec3(0.0, 0.0, 0.0); + + for (int dz = -2; dz <= 2; dz++) + { + float lz = max(0.0, abs(float(dz)) - 1.0); + for (int dy = -2; dy <= 2; dy++) + { + float ly = max(0.0, abs(float(dy)) - 1.0); + for (int dx = -2; dx <= 2; dx++) + { + float lx = max(0.0, abs(float(dx)) - 1.0); + float lowerBound = sqrt(lx * lx + ly * ly + lz * lz); + if (lowerBound >= f2) + continue; + + vec3 neighbor = cell + vec3(dx, dy, dz); + ivec3 hash = cultmath_pcg3d(ivec3(neighbor)); + vec3 jitter = vec3( + float(uint(hash.x) >> 8) * (1.0 / 16777216.0), + float(uint(hash.y) >> 8) * (1.0 / 16777216.0), + float(uint(hash.z) >> 8) * (1.0 / 16777216.0)); + vec3 feature = neighbor + jitter; + float d = cultmath_distance(p, feature); + + if (d < f1) + { + f2 = f1; c2 = c1; + f1 = d; c1 = feature; cellId = neighbor; + } + else if (d < f2) + { + f2 = d; c2 = feature; + } + } + } + } + + // F1 = 0 and F2 = 0 share the same degenerate-gradient guard; see math.cellular's comment + // (math.cs) for why F2 = 0 is reachable past roughly |p| = 2^23. + vec3 grad1 = f1 > 0.0 ? (p - c1) / f1 : vec3(0.0, 0.0, 0.0); + vec3 grad2 = f2 > 0.0 ? (p - c2) / f2 : vec3(0.0, 0.0, 0.0); + + // id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing rule + // as the jitter above), never from the pcg3d hash that produced its jitter (F4). + ivec4 idHash = cultmath_pcg4d(ivec4(ivec3(cellId), 0)); + + CultCellular result; + result.nearest = vec4(grad1, f1); + result.edge = vec4(grad2 - grad1, f2 - f1); + result.id = float(uint(idHash.w) >> 8) * (1.0 / 16777216.0); + return result; +} + + + + +// Interval arithmetic over vec2(lo, hi), lo <= hi, both finite. Every cultmath_iv_* function returns +// an interval containing f(x) for every x in its input intervals, mirroring math.Interval.cs bit for +// bit; see that file and docs/design.md ("Intervals") for each op's enclosure and its ulp widening. +// Included by CultMath.hlsl after its own definitions of cultmath_smoothstep and cultmath_snoise. + +// Lipschitz constant of cultmath_snoise(vec3); provenance in math.Interval.cs (SNOISE_LIPSCHITZ). +const float CULTMATH_SNOISE_LIPSCHITZ = 7.9640074; + +// Lipschitz constant of cultmath_snoise(vec2); provenance in math.Interval.cs (SNOISE2_LIPSCHITZ). +const float CULTMATH_SNOISE2_LIPSCHITZ = 8.117002; + +vec2 cultmath_iv_point(float value) { return vec2(value, value); } +vec2 cultmath_iv_add(vec2 a, vec2 b) { return vec2(a.x + b.x, a.y + b.y); } +vec2 cultmath_iv_sub(vec2 a, vec2 b) { return vec2(a.x - b.y, a.y - b.x); } +vec2 cultmath_iv_neg(vec2 a) { return vec2(-a.y, -a.x); } + +vec2 cultmath_iv_mul(vec2 a, vec2 b) +{ + float p0 = a.x * b.x; + float p1 = a.x * b.y; + float p2 = a.y * b.x; + float p3 = a.y * b.y; + return vec2(min(min(p0, p1), min(p2, p3)), max(max(p0, p1), max(p2, p3))); +} + +vec2 cultmath_iv_scale(vec2 a, float s) { return s >= 0.0 ? vec2(a.x * s, a.y * s) : vec2(a.y * s, a.x * s); } + +vec2 cultmath_iv_abs(vec2 a) +{ + if (a.x >= 0.0) + return a; + if (a.y <= 0.0) + return vec2(-a.y, -a.x); + return vec2(0.0, max(-a.x, a.y)); +} + +vec2 cultmath_iv_min(vec2 a, vec2 b) { return vec2(min(a.x, b.x), min(a.y, b.y)); } +vec2 cultmath_iv_max(vec2 a, vec2 b) { return vec2(max(a.x, b.x), max(a.y, b.y)); } + +vec2 cultmath_iv_sqr(vec2 a) +{ + float l = a.x * a.x; + float h = a.y * a.y; + if (a.x >= 0.0) + return vec2(l, h); + if (a.y <= 0.0) + return vec2(h, l); + return vec2(0.0, max(l, h)); +} + +vec2 cultmath_iv_sqrt(vec2 a) { return vec2(sqrt(max(a.x, 0.0)), sqrt(max(a.y, 0.0))); } + +// Widened by one ulp on each side: exp is not required to be correctly rounded. +vec2 cultmath_iv_exp(vec2 a) +{ + float lo = exp(a.x); + float hi = exp(a.y); + return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, uintBitsToFloat(floatBitsToUint(hi) + 1u)); +} + +vec2 cultmath_iv_clamp(vec2 a, float minimum, float maximum) +{ + return vec2(cultmath_clamp(a.x, minimum, maximum), cultmath_clamp(a.y, minimum, maximum)); +} + +vec2 cultmath_iv_saturate(vec2 a) { return vec2(cultmath_saturate(a.x), cultmath_saturate(a.y)); } + +// The endpoints, ordered, widened by one ulp inside [0, 1]: the float32 evaluation of smoothstep is +// monotone only to within one ulp. +vec2 cultmath_iv_smoothstep(float minimum, float maximum, vec2 a) +{ + float s0 = cultmath_smoothstep(minimum, maximum, a.x); + float s1 = cultmath_smoothstep(minimum, maximum, a.y); + float lo = min(s0, s1); + float hi = max(s0, s1); + return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, hi < 1.0 ? uintBitsToFloat(floatBitsToUint(hi) + 1u) : 1.0); +} + +// The natural interval extension of x + (y - x) * amount, which is how cultmath_lerp evaluates. +vec2 cultmath_iv_lerp(vec2 a, vec2 b, float amount) { return cultmath_iv_add(a, cultmath_iv_scale(cultmath_iv_sub(b, a), amount)); } + +// [n - L r, n + L r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only. +vec2 cultmath_iv_snoise_ball(vec3 centre, float radius) +{ + float n = cultmath_snoise(centre); + float e = CULTMATH_SNOISE_LIPSCHITZ * radius; + return vec2(max(n - e, -1.0), min(n + e, 1.0)); +} + +// The 2D overload: [n - L2 r, n + L2 r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only. +vec2 cultmath_iv_snoise_ball(vec2 centre, float radius) +{ + float n = cultmath_snoise(centre); + float e = CULTMATH_SNOISE2_LIPSCHITZ * radius; + return vec2(max(n - e, -1.0), min(n + e, 1.0)); +} + +// Octave sum of cultmath_iv_snoise_ball, compounding frequency and amplitude as the value-and-gradient fBm does. +vec2 cultmath_iv_fbm_ball(vec3 centre, float radius, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + vec2 sum = vec2(0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + sum = cultmath_iv_add(sum, cultmath_iv_scale(cultmath_iv_snoise_ball(centre * frequency, radius * abs(frequency)), amplitude)); + frequency *= lacunarity; + amplitude *= gain; + } + + return sum; +} + +// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth segment +// [z0, z1], warped by at most warp. Camera frame: pinhole at the origin looking down +z, ray (m z, z). +// footprintPerDepth = N / (sqrt(2) f) for an N x N tile at focal length f. The radius is widened by +// (|c|_1 + radius) 2^-20, which covers the ball's own float32 rounding. Derivation in math.Interval.cs. +vec4 cultmath_iv_frustum_ball(vec2 centreSlope, float z0, float z1, float footprintPerDepth, float warp) +{ + float zm = (z0 + z1) * 0.5; + float axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0); + float cx = centreSlope.x * zm; + float cy = centreSlope.y * zm; + float radius = (z1 - z0) * 0.5 * axis + z1 * footprintPerDepth + warp; + radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7; + return vec4(cx, cy, zm, radius); +} + + + + + + +// Reduced affine forms with one shared symbol over vec3(x0, a, e): the set { x0 + a eps + e delta : +// delta in [-1, 1] } at the region's shared eps in [-1, 1], e >= 0. Every cultmath_af_* function mirrors +// math.Affine.cs bit for bit; see that file and docs/design.md ("Affine forms") for each op's enclosure +// and the float32 rounding allowance it folds into e. Included by CultMath.hlsl after +// CultMath.Interval.hlsl, whose CULTMATH_SNOISE_LIPSCHITZ it shares, and after its own +// definition of cultmath_snoise_grad. + +// An upper bound on the spectral norm of the Hessian of cultmath_snoise(vec3); provenance in +// math.Affine.cs (SNOISE_HESSIAN). +const float CULTMATH_SNOISE_HESSIAN = 55.642143; + +// The absolute rounding of one float32 read of cultmath_snoise_grad, value and gradient; the owner is +// math.Affine.cs (SNOISE_READ_VALUE, SNOISE_READ_GRADIENT), read here by cultmath_af_snoise and in +// CultMath.hlsl by cultmath_span_slack. +const float CULTMATH_SNOISE_READ_VALUE = 6.103515625e-5; +const float CULTMATH_SNOISE_READ_GRADIENT = 2.44140625e-4; + +// An upper bound on the operator norm of the third-derivative tensor of cultmath_snoise(vec3), the +// next order after CULTMATH_SNOISE_HESSIAN; provenance in math.Span.cs (SNOISE_THIRD). +const float CULTMATH_SNOISE_THIRD = 535.8009; + +vec3 cultmath_af_point(float value) { return vec3(value, 0.0, 0.0); } +vec3 cultmath_af_symbol(float x0, float a) { return vec3(x0, a, 0.0); } + +vec3 cultmath_af_from_iv(vec2 interval) +{ + float x0 = interval.x * 0.5 + interval.y * 0.5; + float e = interval.y * 0.5 - interval.x * 0.5; + return vec3(x0, 0.0, e + (abs(x0) + e) * 9.5367431640625e-7 + 1.17549435e-38); +} + +// An endpoint that would be NaN is the infinity on its side. NaN is found by its bit pattern: dxc marks +// compares fast, so a test that needs NaN to make `lo == lo` false is deleted under its default flags. +vec2 cultmath_af_range(vec3 x) +{ + float r = abs(x.y) + x.z; + float w = abs(x.x) * 9.5367431640625e-7 + r * 9.5367431640625e-7; + float lo = x.x - r - w; + float hi = x.x + r + w; + return vec2((floatBitsToUint(lo) & 0x7fffffffu) > 0x7f800000u ? uintBitsToFloat(0xff800000u) : lo, + (floatBitsToUint(hi) & 0x7fffffffu) > 0x7f800000u ? uintBitsToFloat(0x7f800000u) : hi); +} + +vec3 cultmath_af_add(vec3 x, vec3 y) +{ + float x0 = x.x + y.x; + float a = x.y + y.y; + float e = x.z + y.z; + return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7); +} + +vec3 cultmath_af_sub(vec3 x, vec3 y) +{ + float x0 = x.x - y.x; + float a = x.y - y.y; + float e = x.z + y.z; + return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7); +} + +vec3 cultmath_af_neg(vec3 x) { return vec3(-x.x, -x.y, x.z); } + +vec3 cultmath_af_scale(vec3 x, float s) +{ + float x0 = x.x * s; + float a = x.y * s; + float e = x.z * abs(s); + return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7 + 1.17549435e-38); +} + +vec3 cultmath_af_add_iv(vec3 x, vec2 interval) { return cultmath_af_add(x, cultmath_af_from_iv(interval)); } + +vec3 cultmath_af_mul(vec3 x, vec3 y) +{ + float x0 = x.x * y.x; + float p = x.x * y.y; + float q = y.x * x.y; + float a = p + q; + float e = abs(x.x) * y.z + abs(y.x) * x.z + (abs(x.y) + x.z) * (abs(y.y) + y.z); + return vec3(x0, a, e + (abs(x0) + abs(p) + abs(q) + e) * 9.5367431640625e-7 + 1.17549435e-38); +} + +// The narrower of the Lipschitz form and the centred (Taylor) form over every p = centre + axis eps + w, +// |w| <= radius, from one cultmath_snoise_grad at the centre; the allowance for the float32 evaluation is +// in math.Affine.cs. +vec3 cultmath_af_snoise(vec3 centre, vec3 axis, float radius) +{ + vec4 g = cultmath_snoise_grad(centre); + vec3 gradient = vec3(g.x, g.y, g.z); + float reach = length(axis) + radius; + float lipschitz = CULTMATH_SNOISE_LIPSCHITZ * reach; + float a = dot(gradient, axis); + float remainder = length(gradient) * radius + 0.5 * CULTMATH_SNOISE_HESSIAN * reach * reach; + float allowance = CULTMATH_SNOISE_READ_VALUE + CULTMATH_SNOISE_READ_GRADIENT * reach + + CULTMATH_SNOISE_LIPSCHITZ * 9.5367431640625e-7 * (abs(centre.x) + abs(centre.y) + abs(centre.z) + reach); + bool centred = abs(a) + remainder < lipschitz; + float slope = centred ? a : 0.0; + float e = (centred ? remainder : lipschitz) + allowance; + return vec3(g.w, slope, e + (abs(g.w) + abs(slope) + e) * 9.5367431640625e-7); +} + +// Octave sum of cultmath_af_snoise, compounding frequency and amplitude as the value-and-gradient fBm does. +vec3 cultmath_af_fbm(vec3 centre, vec3 axis, float radius, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + vec3 sum = vec3(0.0, 0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + sum = cultmath_af_add(sum, cultmath_af_scale(cultmath_af_snoise(centre * frequency, axis * frequency, radius * abs(frequency)), amplitude)); + frequency *= lacunarity; + amplitude *= gain; + } + + return sum; +} + +// The axis of a screen tile's slice over the depth segment [z0, z1]; camera frame of +// cultmath_iv_frustum_ball. +vec3 cultmath_af_frustum_axis(vec2 centreSlope, float z0, float z1) +{ + float h = (z1 - z0) * 0.5; + return vec3(centreSlope.x * h, centreSlope.y * h, h); +} + +// The affine slice of a screen tile over [z0, z1]: centre xyz, radius w. Derivation in math.Affine.cs. +vec4 cultmath_af_frustum_ball(vec2 centreSlope, float z0, float z1, float footprintPerDepth, float warpVariation) +{ + float zm = (z0 + z1) * 0.5; + float h = (z1 - z0) * 0.5; + float cx = centreSlope.x * zm; + float cy = centreSlope.y * zm; + float spread = (abs(centreSlope.x) + abs(centreSlope.y) + 1.0) * h; + float radius = z1 * footprintPerDepth + warpVariation; + radius += (abs(cx) + abs(cy) + abs(zm) + spread + radius) * 9.5367431640625e-7; + return vec4(cx, cy, zm, radius); +} + + + + +// Span stages (invariant 8): the SpanPath, SpanStage and SpanNoise of math.Span.cs, field for field in +// C#'s order, and cultmath_span_stage, its octave stage, statement for statement so the text is a bit-for-bit +// mirror. They follow the includes because the bounds read CULTMATH_SNOISE_LIPSCHITZ, _HESSIAN and _THIRD. +// A shader caller builds a SpanStage itself (SpanStage(1.0, 1.0, vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0)) +// is the identity of math.SpanStage.Identity, which has no shader form); see math.span_stage's comments for +// the span, the transform, the bounds and the allowances. cultmath_span_rounding and cultmath_span_slack +// are math.span_rounding and math.span_slack, the owners of S_o and of the three allowances. +struct SpanPath +{ + vec3 midpoint; + vec3 velocity; + float speed; + float acceleration; + float halfLength; +}; + +struct SpanStage +{ + float frequency; + float stretch; + vec3 axis; + vec3 shift; + vec3 offset; + vec3 octaveStep; +}; + +struct SpanNoise +{ + vec4 field; + float curvature; + float fold; +}; + +float cultmath_span_rounding(float scale, vec3 mq, SpanStage stage, vec3 octaveShift, float nu, float halfLength) +{ + return 9.5367431640625e-7 * (abs(scale) * (cultmath_csum(abs(mq)) + cultmath_csum(abs(stage.shift))) + cultmath_csum(abs(stage.offset)) + cultmath_csum(abs(octaveShift)) + 1.7320508 * nu * halfLength); +} + +vec3 cultmath_span_slack(float rounding) +{ + return vec3(CULTMATH_SNOISE_READ_VALUE + CULTMATH_SNOISE_LIPSCHITZ * rounding, + CULTMATH_SNOISE_READ_GRADIENT + CULTMATH_SNOISE_HESSIAN * rounding, + 9.765625e-4 + CULTMATH_SNOISE_THIRD * rounding); +} + +// The octave stage behind cultmath_span_fbm and cultmath_span_ridged, as math.span_stage is behind span_fbm +// and span_ridged. The Frobenius norm of the Hessian is written out in C#'s order (GLSL has no _mRC member +// access); the C# local named step is octaveShift here (it would shadow GLSL's step()). +SpanNoise cultmath_span_stage(bool ridged, SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) +{ + vec3 q = path.midpoint; + float halfLength = path.halfLength; + octaves = clamp(octaves, 0, 16); + bool stretched = dot(stage.axis, stage.axis) > 0.0 && stage.stretch != 1.0; + vec3 direction = stretched ? normalize(stage.axis) : vec3(0.0, 0.0, 0.0); + float squeeze = stretched ? 1.0 / stage.stretch - 1.0 : 0.0; + float norm = abs(stage.frequency) * (stretched ? max(1.0, 1.0 / stage.stretch) : 1.0); + vec3 mq = stretched ? stage.frequency * (q + squeeze * dot(direction, q) * direction) : stage.frequency * q; + vec3 mv = stretched ? stage.frequency * (path.velocity + squeeze * dot(direction, path.velocity) * direction) : stage.frequency * path.velocity; + float amplitude = 1.0; + float scale = 1.0; + float value = 0.0; + vec3 gradient = vec3(0.0, 0.0, 0.0); + float curvature = 0.0; + float fold = 0.0; + for (int i = 0; i < octaves; i++) + { + vec3 octaveShift = float(i) * stage.octaveStep; + vec3 x = scale * (mq + stage.shift) + (stage.offset + octaveShift); + vec4 n = cultmath_snoise_grad(x); + vec3 g = vec3(n.x, n.y, n.z); + vec3 mg = stretched ? stage.frequency * (g + squeeze * dot(direction, g) * direction) : stage.frequency * g; + if (ridged) + { + float s = sign(n.w); + value += amplitude * (1.0 - abs(n.w)); + gradient += -amplitude * scale * s * mg; + } + else + { + value += amplitude * n.w; + gradient += amplitude * scale * mg; + } + + float weight = abs(amplitude); + float factor = abs(scale) * norm; + float nu = factor * path.speed; + float rounding = cultmath_span_rounding(scale, mq, stage, octaveShift, nu, halfLength); + vec3 slack = cultmath_span_slack(rounding); + float valueSlack = slack.x; + float gradientSlack = slack.y; + float hessianSlack = slack.z; + mat3 hm = cultmath_snoise_hessian(x); + float frobenius = sqrt(hm[0][0] * hm[0][0] + hm[0][1] * hm[0][1] + hm[0][2] * hm[0][2] + hm[1][0] * hm[1][0] + hm[1][1] * hm[1][1] + hm[1][2] * hm[1][2] + + hm[2][0] * hm[2][0] + hm[2][1] * hm[2][1] + hm[2][2] * hm[2][2]); + float hessianBound = min(CULTMATH_SNOISE_HESSIAN, frobenius + hessianSlack + CULTMATH_SNOISE_THIRD * nu * halfLength); + curvature += weight * factor * factor * hessianBound; + if (ridged) + { + vec3 w = scale * mv; + float reach = hessianBound * nu * halfLength; + float gl = min(CULTMATH_SNOISE_LIPSCHITZ, length(g) + gradientSlack + reach); + float travel = (abs(dot(g, w)) + gradientSlack * length(w)) * halfLength + (hessianBound * nu * length(w) + gl * factor * path.acceleration) * (0.5 * halfLength * halfLength) + 2.0 * valueSlack; + fold += 2.0 * weight * max(travel - abs(n.w), 0.0); + } + + scale *= lacunarity; + amplitude *= gain; + } + + SpanNoise result; + result.field = vec4(gradient, value); + result.curvature = curvature; + result.fold = fold; + return result; +} + +SpanNoise cultmath_span_fbm(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) +{ + return cultmath_span_stage(false, path, stage, octaves, lacunarity, gain); +} + +SpanNoise cultmath_span_ridged(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) +{ + return cultmath_span_stage(true, path, stage, octaves, lacunarity, gain); +} + +float cultmath_value_noise(vec2 position) +{ + vec2 cell = floor(position); + vec2 local = cultmath_frac(position); + vec2 u = local * local * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * local); + + float a = cultmath_hash(cell); + float b = cultmath_hash(cell + vec2(1.0, 0.0)); + float c = cultmath_hash(cell + vec2(0.0, 1.0)); + float d = cultmath_hash(cell + vec2(1.0, 1.0)); + + return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y); +} + +float cultmath_value_noise_bicubic(vec2 position) +{ + vec2 cell = floor(position); + vec2 local = cultmath_frac(position); + + float y0 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, -1.0)), cultmath_hash(cell + vec2(0.0, -1.0)), cultmath_hash(cell + vec2(1.0, -1.0)), cultmath_hash(cell + vec2(2.0, -1.0)), local.x); + float y1 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 0.0)), cultmath_hash(cell + vec2(0.0, 0.0)), cultmath_hash(cell + vec2(1.0, 0.0)), cultmath_hash(cell + vec2(2.0, 0.0)), local.x); + float y2 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 1.0)), cultmath_hash(cell + vec2(0.0, 1.0)), cultmath_hash(cell + vec2(1.0, 1.0)), cultmath_hash(cell + vec2(2.0, 1.0)), local.x); + float y3 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 2.0)), cultmath_hash(cell + vec2(0.0, 2.0)), cultmath_hash(cell + vec2(1.0, 2.0)), cultmath_hash(cell + vec2(2.0, 2.0)), local.x); + + return cultmath_catmullrom(y0, y1, y2, y3, local.y); +} + +#endif diff --git a/src/app/features/scene/shaders/README.md b/src/app/features/scene/shaders/README.md new file mode 100644 index 00000000..617644fb --- /dev/null +++ b/src/app/features/scene/shaders/README.md @@ -0,0 +1,15 @@ +# Vendored CultMath GLSL + +`CultMath.glsl` and `CultMath.Phacelle.glsl` are CultLib's generated WebGL2 +lowering of `packages/cultmath/shaders/CultMath.hlsl` and +`CultMath.Phacelle.hlsl`, copied unedited from CultLib branch +`eureka/cultmath-tapes-interval-ops-r5` at `658c09e` (the branch that carries +the interval ops and `CULTMATH_SNOISE2_LIPSCHITZ`; the repo default branch +does not carry the GLSL yet). Never edit them here: regenerate in CultLib and +re-copy. `CultMath.glsl` is MIT; `CultMath.Phacelle.glsl` is MPL-2.0 (Rune +Skovbo Johansen's Phacelle noise, see its header), so that file keeps its +header and any change to it is published under MPL-2.0. + +The other `*.glsl` files here are the scene's own stages. They are +concatenated by `../shaderSource.ts` after a prelude; they use no colour +literal (the palette is uniforms) and define no noise. diff --git a/src/app/features/scene/shaders/atmosphere.frag.glsl b/src/app/features/scene/shaders/atmosphere.frag.glsl new file mode 100644 index 00000000..adbf1b5b --- /dev/null +++ b/src/app/features/scene/shaders/atmosphere.frag.glsl @@ -0,0 +1,90 @@ +// Pass 2: atmosphere, marched only up to pass 1's depth. Height fog is closed-form; the cloud +// slab is marched with a few jittered steps. Output: rgb in-scattered light, a transmittance. +uniform sampler2D u_depth; // pass 1 hit distance, nearest +uniform int u_steps; // cloud march steps +uniform float u_frame; +uniform vec2 u_jitter; // sub-pixel jitter in pixels of this target +uniform vec2 u_size; // this target's size in pixels +uniform float u_coverage; // weather knob: 0 clear .. 1 overcast + +in vec2 v_uv; +layout(location = 0) out vec4 o_atm; + +float hg(float cosTheta, float g) +{ + float g2 = g * g; + return (1.0 - g2) / (12.566370614 * pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5)); +} + +float cloudDensity(vec3 p) +{ + float wind = u_time * 2.5; + float f = cultmath_fbm_grad(vec3(p.x * 0.0042 + wind * 0.0042, p.y * 0.0075, p.z * 0.0042), 3, 2.0, 0.5).w; + float h = (p.y - W_CLOUD_Y0) / (W_CLOUD_Y1 - W_CLOUD_Y0); + float profile = smoothstep(0.0, 0.2, h) * smoothstep(1.0, 0.55, h); + return clamp((f + 0.55 * u_coverage - 0.25) * 2.4, 0.0, 1.0) * profile; +} + +void main() +{ + vec2 jittered = (gl_FragCoord.xy + u_jitter) / u_size; + vec3 rd = camRay(jittered); + vec3 ro = u_camPos; + float depth = texture(u_depth, v_uv).r; + float tEnd = min(depth, W_ATM_FAR); + + // Exponential height fog, closed form: optical depth of density d0 * exp(-k y) along the ray. + float k = W_FOG_FALLOFF; + float dy = rd.y * tEnd; + float tau = W_FOG_DENSITY * exp(-k * ro.y) * (abs(dy) > 1e-3 ? (1.0 - exp(-k * dy)) / (k * rd.y) : tEnd); + float fogT = exp(-max(tau, 0.0)); + float cosSun = dot(rd, u_sunDir); + vec3 fogLight = u_fogColor + u_sunColor * hg(cosSun, 0.55) * 0.9; + vec3 inscatter = fogLight * (1.0 - fogT); + float trans = fogT; + + // Clouds: only the part of the ray inside the slab and in front of the surface. + float ta = 0.0; + float tb = tEnd; + if (abs(rd.y) > 1e-4) + { + float t0 = (W_CLOUD_Y0 - ro.y) / rd.y; + float t1 = (W_CLOUD_Y1 - ro.y) / rd.y; + ta = max(0.0, min(t0, t1)); + tb = min(tEnd, max(t0, t1)); + } + else if (ro.y < W_CLOUD_Y0 || ro.y > W_CLOUD_Y1) + tb = 0.0; + + if (tb > ta && u_steps > 0) + { + float dt = (tb - ta) / float(u_steps); + float t = ta + dt * ign(gl_FragCoord.xy, u_frame); + float T = 1.0; + vec3 L = vec3(0.0); + float phase = hg(cosSun, 0.6) + 0.35 * hg(cosSun, -0.2); + for (int i = 0; i < u_steps; i++) + { + vec3 p = ro + rd * t; + float d = cloudDensity(p); + if (d > 0.01) + { + // One sample toward the sun stands in for self-shadowing. + float shade = exp(-cloudDensity(p + u_sunDir * 38.0) * 2.2); + vec3 light = u_sunColor * (phase * 6.0) * shade + mix(u_skyHorizon, u_skyTop, 0.6) * 0.35; + float ext = d * dt * 0.035; + float stepT = exp(-ext); + L += T * (1.0 - stepT) * light; + T *= stepT; + if (T < 0.03) + break; + } + t += dt; + } + // Fog in front of the slab is already in `inscatter`; clouds sit behind it. + inscatter += trans * L; + trans *= T; + } + + o_atm = vec4(inscatter, trans); +} diff --git a/src/app/features/scene/shaders/common.glsl b/src/app/features/scene/shaders/common.glsl new file mode 100644 index 00000000..6378a2e6 --- /dev/null +++ b/src/app/features/scene/shaders/common.glsl @@ -0,0 +1,31 @@ +// Shared by every fragment stage; concatenated after the prelude, CultMath and the world defines. +uniform vec3 u_camPos; +uniform mat3 u_camBasis; // columns: right, up, forward +uniform vec2 u_tanHalf; // x: tan(fov/2) * aspect, y: tan(fov/2) +uniform float u_time; +uniform vec3 u_sunDir; // unit, towards the sun +uniform vec3 u_sunColor; +uniform vec3 u_skyTop; +uniform vec3 u_skyHorizon; +uniform vec3 u_fogColor; + +vec3 camRay(vec2 uv) +{ + vec2 ndc = uv * 2.0 - 1.0; + return normalize(u_camBasis * vec3(ndc * u_tanHalf, 1.0)); +} + +float ign(vec2 p, float frame) +{ + p += 5.588238 * mod(frame, 64.0); + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +vec3 skyColor(vec3 rd) +{ + float h = clamp(rd.y, 0.0, 1.0); + vec3 c = mix(u_skyHorizon, u_skyTop, pow(h, 0.45)); + float s = max(dot(rd, u_sunDir), 0.0); + c += u_sunColor * (1.5 * pow(s, 256.0)); + return c; +} diff --git a/src/app/features/scene/shaders/composite.frag.glsl b/src/app/features/scene/shaders/composite.frag.glsl new file mode 100644 index 00000000..9dce31f1 --- /dev/null +++ b/src/app/features/scene/shaders/composite.frag.glsl @@ -0,0 +1,26 @@ +// Full-resolution composite to the canvas: solid over sky, then the accumulated atmosphere. +uniform sampler2D u_solid; // rgb lit, a coverage (bilinear) +uniform sampler2D u_atm; // TAA'd atmosphere (bilinear) +uniform float u_frame; + +in vec2 v_uv; +out vec4 o_frag; + +vec3 tonemap(vec3 x) +{ + // Narkowicz ACES fit. + return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); +} + +void main() +{ + vec3 rd = camRay(v_uv); + vec4 solid = texture(u_solid, v_uv); + vec4 atm = texture(u_atm, v_uv); + vec3 base = mix(skyColor(rd), solid.rgb, solid.a); + vec3 col = base * atm.a + atm.rgb; + col = tonemap(col); + col = pow(col, vec3(1.0 / 2.2)); + col += (ign(gl_FragCoord.xy, u_frame) - 0.5) / 255.0; + o_frag = vec4(col, 1.0); +} diff --git a/src/app/features/scene/shaders/fullscreen.vert.glsl b/src/app/features/scene/shaders/fullscreen.vert.glsl new file mode 100644 index 00000000..cab0e527 --- /dev/null +++ b/src/app/features/scene/shaders/fullscreen.vert.glsl @@ -0,0 +1,8 @@ +out vec2 v_uv; +void main() +{ + // One oversized triangle; uv spans 0..1 over the visible part. + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + v_uv = p; + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/src/app/features/scene/shaders/resolve.frag.glsl b/src/app/features/scene/shaders/resolve.frag.glsl new file mode 100644 index 00000000..cc6dd2e0 --- /dev/null +++ b/src/app/features/scene/shaders/resolve.frag.glsl @@ -0,0 +1,41 @@ +// TAA resolve for the atmosphere target: reproject history by the pass-1 depth, clamp it to the +// current frame's 3x3 neighbourhood, blend. +uniform sampler2D u_cur; +uniform sampler2D u_history; +uniform sampler2D u_depth; +uniform vec3 u_prevPos; +uniform mat3 u_prevBasis; +uniform vec2 u_prevTanHalf; +uniform float u_blend; // weight of the current frame; 1 resets +uniform vec2 u_curSize; + +in vec2 v_uv; +layout(location = 0) out vec4 o_hist; + +void main() +{ + vec2 px = 1.0 / u_curSize; + vec4 cur = texture(u_cur, v_uv); + vec4 lo = cur; + vec4 hi = cur; + for (int y = -1; y <= 1; y++) + for (int x = -1; x <= 1; x++) + { + vec4 s = texture(u_cur, v_uv + vec2(float(x), float(y)) * px); + lo = min(lo, s); + hi = max(hi, s); + } + + // Where this pixel's ray ended, reprojected into last frame's camera. + vec3 rd = camRay(v_uv); + float t = min(texture(u_depth, v_uv).r, 600.0); + vec3 v = u_camPos + rd * t - u_prevPos; + vec3 local = transpose(u_prevBasis) * v; + vec2 prevNdc = local.z > 0.0 ? local.xy / (local.z * u_prevTanHalf) : vec2(2.0); + vec2 prevUv = prevNdc * 0.5 + 0.5; + bool inside = all(greaterThanEqual(prevUv, vec2(0.0))) && all(lessThanEqual(prevUv, vec2(1.0))); + + float a = (inside && u_blend < 1.0) ? u_blend : 1.0; + vec4 hist = clamp(texture(u_history, prevUv), lo, hi); + o_hist = mix(hist, cur, a); +} diff --git a/src/app/features/scene/shaders/terrain.frag.glsl b/src/app/features/scene/shaders/terrain.frag.glsl new file mode 100644 index 00000000..b8f2f718 --- /dev/null +++ b/src/app/features/scene/shaders/terrain.frag.glsl @@ -0,0 +1,184 @@ +// Pass 1: solid surfaces (terrain + world tree), sphere-traced. Writes lit colour with coverage +// and, separately, the hit distance that bounds pass 2. +uniform vec3 u_grass; +uniform vec3 u_rock; +uniform vec3 u_bark; +uniform vec3 u_leaf; +uniform int u_maxSteps; + +in vec2 v_uv; +layout(location = 0) out vec4 o_color; // rgb lit, a coverage +layout(location = 1) out float o_depth; // hit distance along the ray, W_FAR on a miss + +// ---- terrain ------------------------------------------------------------------------------- + +float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); } + +// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap. +float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; } + +#if W_ERODE_ENABLED +CultPhasor terrainPhacelle(vec2 xz) +{ + // A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion. + return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM); +} +#endif + +// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the +// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated. +float terrainDistance(vec3 p) +{ + float m = terrainMask(p.xz); + // Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most + // of their steps here. + if (m <= 0.0) + return p.y * W_SLOPE_ENV_INV; + float hb = m * W_AMP * terrainFbm(p.xz); + float above = p.y - (hb + m * W_ERODE_AMP); + if (above > 0.0) + return above * W_SLOPE_ENV_INV; +#if W_ERODE_ENABLED + hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w; +#endif + return (p.y - hb) * W_SLOPE_FULL_INV; +} + +vec3 terrainNormal(vec2 xz) +{ + float r = length(xz); + float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r); + float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0); + float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0; + vec2 dir = r > 0.0 ? xz / r : vec2(0.0); + vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); + float f = fg.w; + vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; +#if W_ERODE_ENABLED + CultPhasor ph = terrainPhacelle(xz); + g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; +#endif + return normalize(vec3(-g.x, 1.0, -g.y)); +} + +// ---- world tree ---------------------------------------------------------------------------- + +float smin(float a, float b, float k) +{ + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * k * 0.25; +} + +// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz. +float treeDistance(vec3 p, out float leafness) +{ + vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0); + float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y); + leafness = 0.0; + if (bound > 8.0) + return bound; + + float ang = atan(p.z, p.x); + float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0); + float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15)); + float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7; + + vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R; + float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0)); + float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp; + canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L) + + float d = smin(trunk, canopy, 6.0); + leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0); + return d; +} + +// ---- trace --------------------------------------------------------------------------------- + +float sceneDistance(vec3 p, out float leafness, out bool onTree) +{ + float dt = terrainDistance(p); + float dr = treeDistance(p, leafness); + onTree = dr < dt; + return min(dt, dr); +} + +void main() +{ + vec3 ro = u_camPos; + vec3 rd = camRay(v_uv); + + float t = 0.0; + // Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. + if (ro.y > W_MAX_HEIGHT) + { + if (rd.y >= 0.0) + { + o_color = vec4(0.0); + o_depth = W_FAR; + return; + } + t = (W_MAX_HEIGHT - ro.y) / rd.y; + } + + bool hit = false; + bool onTree = false; + float leafness = 0.0; + int i = 0; + for (; i < u_maxSteps; i++) + { + vec3 p = ro + rd * t; + float d = sceneDistance(p, leafness, onTree); + if (d < 0.0006 * t + 0.01) + { + hit = true; + break; + } + t += d; + if (t > W_FAR || (p.y > W_MAX_HEIGHT && rd.y > 0.0)) + break; + } + // The conservative bound makes grazing rays crawl: a ray that ran out of steps while still + // below the highest terrain is a distant surface under thick fog, so it ends there. + if (!hit && i == u_maxSteps && t < W_FAR) + { + hit = true; + sceneDistance(ro + rd * t, leafness, onTree); + } + + if (!hit) + { + o_color = vec4(0.0); + o_depth = W_FAR; + return; + } + + vec3 p = ro + rd * t; + vec3 n; + vec3 albedo; + if (onTree) + { + float e = 0.35; + float l; + n = normalize(vec3( + treeDistance(p + vec3(e, 0.0, 0.0), l) - treeDistance(p - vec3(e, 0.0, 0.0), l), + treeDistance(p + vec3(0.0, e, 0.0), l) - treeDistance(p - vec3(0.0, e, 0.0), l), + treeDistance(p + vec3(0.0, 0.0, e), l) - treeDistance(p - vec3(0.0, 0.0, e), l))); + float speckle = 0.75 + 0.25 * cultmath_snoise(p * 0.8); + albedo = mix(u_bark, u_leaf * speckle, leafness); + } + else + { + n = terrainNormal(p.xz); + float rockness = smoothstep(0.78, 0.55, n.y); + albedo = mix(u_grass, u_rock, rockness); + } + + float ndl = dot(n, u_sunDir); + float wrap = clamp(ndl * 0.6 + 0.4, 0.0, 1.0); // half-lambert: no shadow ray in the cheap pass + vec3 ambient = mix(u_skyHorizon, u_skyTop, 0.5 + 0.5 * n.y) * 0.45; + vec3 col = albedo * (u_sunColor * wrap * wrap + ambient); + + o_color = vec4(col, 1.0); + o_depth = t; +} diff --git a/src/app/features/scene/tier.test.ts b/src/app/features/scene/tier.test.ts new file mode 100644 index 00000000..d35b6bb6 --- /dev/null +++ b/src/app/features/scene/tier.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { resolveTier } from './tier'; + +describe('scene tier', () => { + it('is off unless the config turns it on, which is the feature flag', () => { + expect(resolveTier(undefined)).toBe('off'); + expect(resolveTier({})).toBe('off'); + expect(resolveTier({ tier: 'off' }, { deviceMemory: 16 })).toBe('off'); + expect(resolveTier({ tier: 'full' })).toBe('full'); + }); + + it('lets the device lower the configured tier and never raise it', () => { + expect(resolveTier({ tier: 'full' }, { saveData: true })).toBe('still'); + expect(resolveTier({ tier: 'full' }, { deviceMemory: 2 })).toBe('low'); + expect(resolveTier({ tier: 'full' }, { hardwareConcurrency: 4 })).toBe('low'); + expect(resolveTier({ tier: 'low' }, { saveData: true })).toBe('still'); + expect(resolveTier({ tier: 'still' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe( + 'still' + ); + expect(resolveTier({ tier: 'low' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe('low'); + }); + + it('treats unknown capabilities as capable', () => { + expect(resolveTier({ tier: 'full' }, {})).toBe('full'); + }); +}); diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts new file mode 100644 index 00000000..1c1668c7 --- /dev/null +++ b/src/app/features/scene/tier.ts @@ -0,0 +1,49 @@ +// Which quality the background scene runs at. The client config's `backgroundScene.tier` is both +// the feature flag (absent or 'off' means the scene is not even imported) and the ceiling; the +// device can only lower it. + +export type SceneTier = 'off' | 'still' | 'low' | 'full'; + +/** Cheapest first. */ +export const TIERS: readonly SceneTier[] = ['off', 'still', 'low', 'full']; + +export interface Capabilities { + /** `navigator.connection.saveData`. */ + saveData?: boolean; + /** `navigator.deviceMemory` in GiB (Chromium only). */ + deviceMemory?: number; + hardwareConcurrency?: number; +} + +export interface BackgroundSceneConfig { + tier?: SceneTier; +} + +export function readCapabilities(nav: Navigator): Capabilities { + const n = nav as Navigator & { + deviceMemory?: number; + connection?: { saveData?: boolean }; + }; + return { + saveData: n.connection?.saveData, + deviceMemory: n.deviceMemory, + hardwareConcurrency: n.hardwareConcurrency, + }; +} + +/** The most the device is allowed to run, before the configured ceiling is applied. */ +function deviceCeiling(c: Capabilities): SceneTier { + if (c.saveData) return 'still'; + if ((c.deviceMemory ?? Infinity) <= 2 || (c.hardwareConcurrency ?? Infinity) <= 4) return 'low'; + return 'full'; +} + +/** The tier to run: the configured one (default 'off'), lowered by what the device can carry. */ +export function resolveTier( + config: BackgroundSceneConfig | undefined, + capabilities: Capabilities = {} +): SceneTier { + const configured = config?.tier ?? 'off'; + const ceiling = deviceCeiling(capabilities); + return TIERS.indexOf(configured) <= TIERS.indexOf(ceiling) ? configured : ceiling; +} diff --git a/src/app/features/scene/world.test.ts b/src/app/features/scene/world.test.ts new file mode 100644 index 00000000..caa04fad --- /dev/null +++ b/src/app/features/scene/world.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import common from './shaders/common.glsl?raw'; +import terrain from './shaders/terrain.frag.glsl?raw'; +import atmosphere from './shaders/atmosphere.frag.glsl?raw'; +import resolve from './shaders/resolve.frag.glsl?raw'; +import composite from './shaders/composite.frag.glsl?raw'; +import { terrainBounds, WORLD, worldDefines } from './world'; + +describe('world defines', () => { + it('define every W_ name any scene shader uses', () => { + const defined = new Set([...worldDefines().matchAll(/#define (W_\w+)/g)].map((m) => m[1])); + [common, terrain, atmosphere, resolve, composite] + .flatMap((src) => [...src.matchAll(/\bW_[A-Z0-9_]+\b/g)]) + .forEach((m) => expect(defined, m[0]).toContain(m[0])); + }); + + it('emit only valid GLSL numbers', () => { + expect(worldDefines()).not.toMatch(/NaN|Infinity|undefined/); + }); +}); + +describe('terrain step bound', () => { + it('is more conservative with erosion than without, and without erosion carries no phacelle term', () => { + const b = terrainBounds(); + expect(b.slopeFull).toBeGreaterThan(b.slopeEnvelope); + const off = terrainBounds({ ...WORLD, phacelleGradBound: 0 }); + expect(off.slopeFull).toBe(off.slopeEnvelope); + }); + + it('puts every surface under the height the tracer skips to', () => { + const maxHeight = Number(/#define W_MAX_HEIGHT (\S+)/.exec(worldDefines())![1]); + expect(maxHeight).toBeGreaterThanOrEqual(terrainBounds().maxHeight); + expect(maxHeight).toBeGreaterThanOrEqual(WORLD.tree.canopyY + WORLD.tree.canopyRadius[1]); + }); + + it('leaves the erosion bound turned on only when a bound exists', () => { + expect(worldDefines()).toContain('#define W_ERODE_ENABLED 1'); + expect(worldDefines({ ...WORLD, phacelleGradBound: 0 })).toContain('#define W_ERODE_ENABLED 0'); + }); +}); diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts new file mode 100644 index 00000000..ba9aec53 --- /dev/null +++ b/src/app/features/scene/world.ts @@ -0,0 +1,116 @@ +// The scene's world constants. One owner: the shader prelude below is generated from this +// object, so a number lives here and nowhere else, and the Lipschitz bounds that make the +// sphere-trace step safe are computed beside the numbers they bound. + +// Lipschitz constant of cultmath_snoise(vec3), CULTMATH_SNOISE_LIPSCHITZ in the vendored +// CultMath.glsl (provenance in CultLib math.Interval.cs). +export const SNOISE_LIPSCHITZ = 7.9640074; + +export const WORLD = { + far: 1400, + // Terrain: height = mask(r) * (amp * fbm(xz * freq) + erodeAmp * phacelle(xz * erodeFreq)). + amp: 70, + freq: 1 / 520, + octaves: 4, + lacunarity: 2.0, + gain: 0.45, + // Flat ground (height 0) out to plateauInner, full terrain from plateauOuter. The login + // camera stays inside plateauInner, so it can never end up inside the terrain. + plateauInner: 135, + plateauOuter: 380, + erodeAmp: 2.5, + erodeFreq: 1 / 38, + // The stripe wave vector and normalization the terrain feeds cultmath_phacelle. + erodeSide: [1.7, 0, 1] as const, + erodeNorm: 0.5, + // Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a + // sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. The + // shader passes roughness n = 0 as a literal, so the doc envelope max(10, 1.25|side|)/(1-n) + // needs no 1/(1-n) factor here; if n ever becomes nonzero, divide this bound by (1 - n). + // |value| <= 1 holds by construction (the output is a normalized phasor). + // Typed number, not the literal 7, so callers can build variants such as { ...WORLD, phacelleGradBound: 0 }. + phacelleGradBound: 7.0 as number, + // World tree at the origin. + tree: { + trunkHeight: 38, + baseRadius: 3.4, + topRadius: 1.9, + canopyY: 52, + canopyRadius: [30, 17, 30] as const, + }, + // Atmosphere. + atmosphereFar: 1400, + cloudBase: 130, + cloudTop: 230, + fogDensity: 0.0028, + fogFalloff: 0.011, +} as const; + +const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`); + +/** Sum over octaves of (lacunarity * gain)^i: the slope bound's octave factor. */ +function slopeSum(octaves: number, lacunarity: number, gain: number): number { + let s = 0; + for (let i = 0; i < octaves; i += 1) s += (lacunarity * gain) ** i; + return s; +} + +/** Sum over octaves of gain^i: the fbm value bound. */ +function valueSum(octaves: number, gain: number): number { + let s = 0; + for (let i = 0; i < octaves; i += 1) s += gain ** i; + return s; +} + +export interface TerrainBounds { + /** Lipschitz bound of the envelope surface (no erosion). */ + slopeEnvelope: number; + /** Lipschitz bound of the eroded surface. */ + slopeFull: number; + /** Highest point the terrain can reach. */ + maxHeight: number; +} + +export function terrainBounds(w: typeof WORLD = WORLD): TerrainBounds { + const fbmMax = valueSum(w.octaves, w.gain); + const maskSlope = 1.5 / (w.plateauOuter - w.plateauInner); // smoothstep's max slope + const fbmSlope = w.amp * w.freq * SNOISE_LIPSCHITZ * slopeSum(w.octaves, w.lacunarity, w.gain); + const slopeEnvelope = fbmSlope + (w.amp * fbmMax + w.erodeAmp) * maskSlope; + const slopeFull = slopeEnvelope + w.erodeAmp * w.erodeFreq * w.phacelleGradBound; + return { slopeEnvelope, slopeFull, maxHeight: w.amp * fbmMax + w.erodeAmp }; +} + +/** The #defines every stage sees. */ +export function worldDefines(w: typeof WORLD = WORLD): string { + const b = terrainBounds(w); + const t = w.tree; + const lines: Array<[string, string]> = [ + ['W_FAR', f(w.far)], + ['W_AMP', f(w.amp)], + ['W_FREQ', f(w.freq)], + ['W_OCTAVES', String(w.octaves)], + ['W_LACUNARITY', f(w.lacunarity)], + ['W_GAIN', f(w.gain)], + ['W_PLATEAU_IN', f(w.plateauInner)], + ['W_PLATEAU_OUT', f(w.plateauOuter)], + ['W_ERODE_AMP', f(w.erodeAmp)], + ['W_ERODE_FREQ', f(w.erodeFreq)], + ['W_ERODE_SIDE', `vec3(${w.erodeSide.map(f).join(', ')})`], + ['W_ERODE_NORM', f(w.erodeNorm)], + ['W_ERODE_ENABLED', w.phacelleGradBound > 0 ? '1' : '0'], + ['W_SLOPE_ENV_INV', f(1 / Math.sqrt(1 + b.slopeEnvelope ** 2))], + ['W_SLOPE_FULL_INV', f(1 / Math.sqrt(1 + b.slopeFull ** 2))], + ['W_MAX_HEIGHT', f(Math.max(b.maxHeight, t.canopyY + t.canopyRadius[1] + 6))], + ['W_TRUNK_H', f(t.trunkHeight)], + ['W_TRUNK_R0', f(t.baseRadius)], + ['W_TRUNK_R1', f(t.topRadius)], + ['W_CANOPY_Y', f(t.canopyY)], + ['W_CANOPY_R', `vec3(${t.canopyRadius.map(f).join(', ')})`], + ['W_ATM_FAR', f(w.atmosphereFar)], + ['W_CLOUD_Y0', f(w.cloudBase)], + ['W_CLOUD_Y1', f(w.cloudTop)], + ['W_FOG_DENSITY', f(w.fogDensity)], + ['W_FOG_FALLOFF', f(w.fogFalloff)], + ]; + return lines.map(([k, v]) => `#define ${k} ${v}`).join('\n'); +} diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e5fc6cc6..7a0586eb 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -1,4 +1,5 @@ import { createContext, useContext } from 'react'; +import type { BackgroundSceneConfig } from '../features/scene/tier'; export type HashRouterConfig = { enabled?: boolean; @@ -18,6 +19,8 @@ export type ClientConfig = { }; hashRouter?: HashRouterConfig; + + backgroundScene?: BackgroundSceneConfig; }; const ClientConfigContext = createContext(null);