From e377d9f722670462b14b8dc23e3aefad31dc0aae Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 31 Oct 2026 21:43:40 +0000 Subject: [PATCH 01/13] Background scene: two-pass WebGL2 terrain and world tree with login pull-back Pass 1 sphere-traces CultMath noise terrain (fbm plus Phacelle erosion) and the world tree with a conservative Lipschitz step, writing colour and depth. Pass 2 marches fog and clouds only up to that depth at low resolution with TAA. The camera pulls back from the tree on login. Vendors CultLib's generated GLSL (658c09e). The Phacelle gradient bound is an empirical probe, not a proof. Headless harness compiles, renders and times it; nothing run on a GPU. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/README.md | 19 + harness/scene/bundle.cjs | 35 + harness/scene/drive.cjs | 162 +++ harness/scene/entry.ts | 87 ++ src/app/features/scene/README.md | 54 + src/app/features/scene/Scene.ts | 414 +++++++ src/app/features/scene/budget.test.ts | 34 + src/app/features/scene/budget.ts | 49 + src/app/features/scene/camera.test.ts | 42 + src/app/features/scene/camera.ts | 56 + src/app/features/scene/palette.ts | 27 + src/app/features/scene/shaderSource.ts | 29 + .../scene/shaders/CultMath.Phacelle.glsl | 99 ++ src/app/features/scene/shaders/CultMath.glsl | 1035 +++++++++++++++++ src/app/features/scene/shaders/README.md | 15 + .../scene/shaders/atmosphere.frag.glsl | 90 ++ src/app/features/scene/shaders/common.glsl | 31 + .../scene/shaders/composite.frag.glsl | 26 + .../scene/shaders/fullscreen.vert.glsl | 8 + .../features/scene/shaders/resolve.frag.glsl | 41 + .../features/scene/shaders/terrain.frag.glsl | 180 +++ src/app/features/scene/world.test.ts | 40 + src/app/features/scene/world.ts | 114 ++ 23 files changed, 2687 insertions(+) create mode 100644 harness/scene/README.md create mode 100644 harness/scene/bundle.cjs create mode 100644 harness/scene/drive.cjs create mode 100644 harness/scene/entry.ts create mode 100644 src/app/features/scene/README.md create mode 100644 src/app/features/scene/Scene.ts create mode 100644 src/app/features/scene/budget.test.ts create mode 100644 src/app/features/scene/budget.ts create mode 100644 src/app/features/scene/camera.test.ts create mode 100644 src/app/features/scene/camera.ts create mode 100644 src/app/features/scene/palette.ts create mode 100644 src/app/features/scene/shaderSource.ts create mode 100644 src/app/features/scene/shaders/CultMath.Phacelle.glsl create mode 100644 src/app/features/scene/shaders/CultMath.glsl create mode 100644 src/app/features/scene/shaders/README.md create mode 100644 src/app/features/scene/shaders/atmosphere.frag.glsl create mode 100644 src/app/features/scene/shaders/common.glsl create mode 100644 src/app/features/scene/shaders/composite.frag.glsl create mode 100644 src/app/features/scene/shaders/fullscreen.vert.glsl create mode 100644 src/app/features/scene/shaders/resolve.frag.glsl create mode 100644 src/app/features/scene/shaders/terrain.frag.glsl create mode 100644 src/app/features/scene/world.test.ts create mode 100644 src/app/features/scene/world.ts 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..3b41c567 --- /dev/null +++ b/harness/scene/drive.cjs @@ -0,0 +1,162 @@ +// 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'], + noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'], + }; + const wanted = mode === 'variants' ? 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..deed806a --- /dev/null +++ b/src/app/features/scene/README.md @@ -0,0 +1,54 @@ +# 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, `createScene`, `login()` when the session starts, `dispose()`) +is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel are later cuts. + +## Entry points + +- `Scene.ts` `createScene({ canvas, ... })` returns `{ login, dispose }`, or `null` when WebGL2 or + `EXT_color_buffer_float` is missing (keep the CSS ground then). 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. +- `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` (6.3) is **an empirical probe, not a proof**: + the maximum `|grad|` of the cos component over 4,194,304 random points (5.04) times 1.25 + (`harness/scene/drive.cjs phacelle`). Replace it with Libby's bound; until then the march is + only as safe as that probe. +- 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.ts b/src/app/features/scene/Scene.ts new file mode 100644 index 00000000..367e6cc9 --- /dev/null +++ b/src/app/features/scene/Scene.ts @@ -0,0 +1,414 @@ +import { fragmentSource, vertexSource, Stage } from './shaderSource'; +import { cameraPose, pullProgress, CameraPose } from './camera'; +import { BudgetLadder, Level } from './budget'; +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]; +} + +export interface SceneOptions { + canvas: HTMLCanvasElement; + /** 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; +} + +/** 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}`); + } +} + +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 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; + for (const p of PASSES) programs[p] = linkStage(gl, p, opts.patchSource); + 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 = () => { + for (const t of [solid, depth, atm, hist?.[0], hist?.[1]]) 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 ladder = new BudgetLadder(GPU_BUDGET_MS); + const level = (): Level => opts.fixedLevel ?? 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 = Infinity; // 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) return body(); + 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; + const elapsed = loginAt === undefined ? undefined : frozen ? 1e9 : (now - loginAt) / 1000; + const pose = cameraPose(pullProgress(elapsed), time); + const f = frame++; + 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 ^ 1; + 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 + ) + ladder.push(GPU_BUDGET_MS * 2) && (sizedFor = undefined); // no timer: a missed cap counts as over budget + 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 onVisibility = () => { + if (document.visibilityState === 'visible') wake(); + }; + const onResize = () => wake(); + if (!opts.unthrottled) document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('resize', onResize); + if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; + wake(); + + return { + login() { + loginAt = performance.now(); + if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; + wake(); + }, + dispose() { + disposed = true; + if (raf) cancelAnimationFrame(raf); + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('resize', onResize); + freeTargets(); + for (const p of PASSES) gl.deleteProgram(programs[p]); + gl.deleteVertexArray(vao); + for (const f of [fbos.solid, fbos.atm, ...fbos.hist]) gl.deleteFramebuffer(f); + }, + }; +} diff --git a/src/app/features/scene/budget.test.ts b/src/app/features/scene/budget.test.ts new file mode 100644 index 00000000..27dd0487 --- /dev/null +++ b/src/app/features/scene/budget.test.ts @@ -0,0 +1,34 @@ +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); + for (let i = 0; i < 4; i++) expect(ladder.push(9)).toBe(false); + expect(ladder.push(9)).toBe(true); + expect(ladder.index).toBe(1); + for (let i = 0; i < 100; i++) 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); + for (const ms of [1, 1, 50, 1, 1]) ladder.push(ms); + expect(ladder.index).toBe(0); + }); + + it('stops at the last level', () => { + const ladder = new BudgetLadder(4, 1); + for (let i = 0; i < 20; i++) ladder.push(99); + expect(ladder.index).toBe(LEVELS.length - 1); + }); + + it('every level is cheaper than the one before in each cost axis', () => { + for (let i = 1; i < LEVELS.length; i++) { + expect(LEVELS[i].solidScale).toBeLessThan(LEVELS[i - 1].solidScale); + expect(LEVELS[i].atmosphereScale).toBeLessThan(LEVELS[i - 1].atmosphereScale); + expect(LEVELS[i].cloudSteps).toBeLessThan(LEVELS[i - 1].cloudSteps); + expect(LEVELS[i].traceSteps).toBeLessThan(LEVELS[i - 1].traceSteps); + } + }); +}); diff --git a/src/app/features/scene/budget.ts b/src/app/features/scene/budget.ts new file mode 100644 index 00000000..9550571c --- /dev/null +++ b/src/app/features/scene/budget.ts @@ -0,0 +1,49 @@ +// 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 = 0; + private recent: number[] = []; + + constructor(private readonly budgetMs: number, private readonly window = 45) {} + + 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[sorted.length >> 1]; + 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/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..ac3bb784 --- /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 * (0.25 * pow(s, 8.0) + 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..770325b0 --- /dev/null +++ b/src/app/features/scene/shaders/terrain.frag.glsl @@ -0,0 +1,180 @@ +// 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); + 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/world.test.ts b/src/app/features/scene/world.test.ts new file mode 100644 index 00000000..87fa1983 --- /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])); + for (const src of [common, terrain, atmosphere, resolve, composite]) { + for (const m of src.matchAll(/\bW_[A-Z0-9_]+\b/g)) 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..82afdf94 --- /dev/null +++ b/src/app/features/scene/world.ts @@ -0,0 +1,114 @@ +// 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, + // Lipschitz bound of the phasor's cos component, NOT a proof: max |grad| over 4,194,304 random + // points of the z = 0 slice with the side and norm above was 5.04 (`drive.cjs phacelle`, + // 2026-10-31), x 1.25. |value| <= 1 holds by construction (the output is a normalized phasor). + // Replace with the bound Libby supplies; until then the march is as safe as that probe. + phacelleGradBound: 6.3, + // 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'); +} From b2d6d32cc4d6f14502230e69488162b8789d47ec Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 1 Nov 2026 06:37:01 +0000 Subject: [PATCH 02/13] Background scene: phacelleGradBound 6.3 -> 7.0 from Libby's measured sup Libby measured sup |grad| 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. The shader passes roughness 0 as a literal, so no 1/(1-n) factor is needed; the comment records it. Pass 1 under SwiftShader: terrain 288.8 ms vs 287.5 at 960x540 level 0, 75.3 vs 75.7 at level 3 (noise). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 7 +++---- src/app/features/scene/world.ts | 11 ++++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index deed806a..d265c332 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -38,10 +38,9 @@ computed from `SNOISE_LIPSCHITZ` (CultMath's constant for `snoise`) in `world.ts envelope (fbm plus the erosion amplitude) the step uses the envelope's bound alone and Phacelle is not evaluated. -- The Phacelle gradient bound `phacelleGradBound` (6.3) is **an empirical probe, not a proof**: - the maximum `|grad|` of the cos component over 4,194,304 random points (5.04) times 1.25 - (`harness/scene/drive.cjs phacelle`). Replace it with Libby's bound; until then the march is - only as safe as that probe. +- 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. diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts index 82afdf94..a1c6cb57 100644 --- a/src/app/features/scene/world.ts +++ b/src/app/features/scene/world.ts @@ -23,11 +23,12 @@ export const WORLD = { // The stripe wave vector and normalization the terrain feeds cultmath_phacelle. erodeSide: [1.7, 0, 1] as const, erodeNorm: 0.5, - // Lipschitz bound of the phasor's cos component, NOT a proof: max |grad| over 4,194,304 random - // points of the z = 0 slice with the side and norm above was 5.04 (`drive.cjs phacelle`, - // 2026-10-31), x 1.25. |value| <= 1 holds by construction (the output is a normalized phasor). - // Replace with the bound Libby supplies; until then the march is as safe as that probe. - phacelleGradBound: 6.3, + // 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). + phacelleGradBound: 7.0, // World tree at the origin. tree: { trunkHeight: 38, From 7a498584e6cffb2863c3f5d7c5cf8e7e4b041c51 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 1 Nov 2026 08:08:45 +0000 Subject: [PATCH 03/13] Background scene: sky costs no noise; one sun term Sky rays were ~90% of pass 1 (noSkyMarch variant: 292 -> 26 ms, SwiftShader 960x540 L0). terrainDistance now returns early where the plateau mask is 0 (height exactly 0), skipping fbm and Phacelle there: pass 1 292 -> 159 ms, same step values. skyColor keeps one sun term (the wide glow is gone; screenshots differ by that and by TAA/time, not compared pixelwise). Harness: noSkyMarch variant, ONLY= selects variants. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/drive.cjs | 4 +++- src/app/features/scene/shaders/common.glsl | 2 +- src/app/features/scene/shaders/terrain.frag.glsl | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs index 3b41c567..74c4a8b0 100644 --- a/harness/scene/drive.cjs +++ b/harness/scene/drive.cjs @@ -90,9 +90,11 @@ const [, , dir, mode, outdir = '.'] = process.argv; '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' ? Object.keys(VARIANTS) : ['baseline']; + const wanted = mode === "variants" ? (process.env.ONLY ? process.env.ONLY.split(",") : Object.keys(VARIANTS)) : ['baseline']; const sizes = [ [960, 540], [540, 1200], diff --git a/src/app/features/scene/shaders/common.glsl b/src/app/features/scene/shaders/common.glsl index ac3bb784..6378a2e6 100644 --- a/src/app/features/scene/shaders/common.glsl +++ b/src/app/features/scene/shaders/common.glsl @@ -26,6 +26,6 @@ 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 * (0.25 * pow(s, 8.0) + 1.5 * pow(s, 256.0)); + c += u_sunColor * (1.5 * pow(s, 256.0)); return c; } diff --git a/src/app/features/scene/shaders/terrain.frag.glsl b/src/app/features/scene/shaders/terrain.frag.glsl index 770325b0..b8f2f718 100644 --- a/src/app/features/scene/shaders/terrain.frag.glsl +++ b/src/app/features/scene/shaders/terrain.frag.glsl @@ -30,6 +30,10 @@ CultPhasor terrainPhacelle(vec2 xz) 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) From 093230a2c118f635c34617ffc6673e176466e988 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 06:20:53 +0000 Subject: [PATCH 04/13] Background scene: lint and type clean, integration contract Fixes the 21 eslint errors and 2 tsc errors Void's review found in features/scene (WORLD.phacelleGradBound is typed number, not the literal 7). Adds the contract the host needs: client config backgroundScene.tier as flag and ceiling (off by default, lowered by saveData / memory / cores), lazy startBackgroundScene, still and low tiers, onCompileFail (createScene frees linked programs and returns null), webglcontextlost handling, and creatureScale where neutral overrides sizeBucket. Scene.ts gets its first tests, on a fake GL. Co-Authored-By: Claude Sonnet 5.5 --- config.json | 4 + src/app/features/scene/README.md | 22 ++- src/app/features/scene/Scene.test.ts | 218 +++++++++++++++++++++++ src/app/features/scene/Scene.ts | 121 +++++++++---- src/app/features/scene/budget.test.ts | 27 +-- src/app/features/scene/budget.ts | 16 +- src/app/features/scene/creatures.test.ts | 23 +++ src/app/features/scene/creatures.ts | 26 +++ src/app/features/scene/index.ts | 23 +++ src/app/features/scene/tier.test.ts | 26 +++ src/app/features/scene/tier.ts | 49 +++++ src/app/features/scene/world.test.ts | 6 +- src/app/features/scene/world.ts | 3 +- src/app/hooks/useClientConfig.ts | 3 + 14 files changed, 513 insertions(+), 54 deletions(-) create mode 100644 src/app/features/scene/Scene.test.ts create mode 100644 src/app/features/scene/creatures.test.ts create mode 100644 src/app/features/scene/creatures.ts create mode 100644 src/app/features/scene/index.ts create mode 100644 src/app/features/scene/tier.test.ts create mode 100644 src/app/features/scene/tier.ts 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/src/app/features/scene/README.md b/src/app/features/scene/README.md index d265c332..0d49d57d 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -3,15 +3,29 @@ 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, `createScene`, `login()` when the session starts, `dispose()`) -is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel are later cuts. +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 -- `Scene.ts` `createScene({ canvas, ... })` returns `{ login, dispose }`, or `null` when WebGL2 or - `EXT_color_buffer_float` is missing (keep the CSS ground then). It caps at 30 fps, pauses when +- `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. 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 index 367e6cc9..10dacc58 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -1,6 +1,7 @@ import { fragmentSource, vertexSource, Stage } from './shaderSource'; import { cameraPose, pullProgress, CameraPose } from './camera'; -import { BudgetLadder, Level } from './budget'; +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. */ @@ -20,8 +21,31 @@ export interface SceneStats { 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. */ @@ -39,15 +63,6 @@ export interface Scene { dispose(): void; } -/** 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}`); - } -} - function compile( gl: WebGL2RenderingContext, type: number, @@ -112,6 +127,8 @@ function halton(i: number, base: number): number { /** 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, @@ -127,7 +144,18 @@ export function createScene(opts: SceneOptions): Scene | null { } | null; const programs = {} as Record; - for (const p of PASSES) programs[p] = linkStage(gl, p, opts.patchSource); + 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); @@ -160,7 +188,9 @@ export function createScene(opts: SceneOptions): Scene | null { let atm: Target; let hist: [Target, Target]; const freeTargets = () => { - for (const t of [solid, depth, atm, hist?.[0], hist?.[1]]) if (t) gl.deleteTexture(t.tex); + [solid, depth, atm, hist?.[0], hist?.[1]].forEach((t) => { + if (t) gl.deleteTexture(t.tex); + }); }; const attach = (fbo: WebGLFramebuffer, targets: Target[]) => { @@ -171,8 +201,9 @@ export function createScene(opts: SceneOptions): Scene | null { gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i)); }; - const ladder = new BudgetLadder(GPU_BUDGET_MS); - const level = (): Level => opts.fixedLevel ?? ladder.current; + 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; @@ -214,7 +245,7 @@ export function createScene(opts: SceneOptions): Scene | null { let prev: CameraPose | undefined; let raf = 0; let lastRendered = -Infinity; - let framesLeft = Infinity; // reduced motion bounds this + 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. @@ -222,7 +253,10 @@ export function createScene(opts: SceneOptions): Scene | null { const finished = new Map>>(); const timed = (pass: PassName, f: number, body: () => void) => { - if (!timer || !opts.onStats) return body(); + if (!timer || !opts.onStats) { + body(); + return; + } const q = gl.createQuery()!; gl.beginQuery(timer.TIME_ELAPSED_EXT, q); body(); @@ -282,9 +316,11 @@ export function createScene(opts: SceneOptions): Scene | null { const lv = level(); const frozen = reducedMotion?.matches ?? false; const time = frozen ? 0 : (now - start) / 1000; - const elapsed = loginAt === undefined ? undefined : frozen ? 1e9 : (now - loginAt) / 1000; + let elapsed: number | undefined; + if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000; const pose = cameraPose(pullProgress(elapsed), time); - const f = frame++; + const f = frame; + frame += 1; const jx = halton((f % 16) + 1, 2) - 0.5; const jy = halton((f % 16) + 1, 3) - 0.5; @@ -320,7 +356,7 @@ export function createScene(opts: SceneOptions): Scene | null { }); // TAA resolve into the history that is not being read. - const write = histIndex ^ 1; + const write = histIndex === 0 ? 1 : 0; timed('resolve', f, () => { gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]); gl.viewport(0, 0, atm.w, atm.h); @@ -369,8 +405,10 @@ export function createScene(opts: SceneOptions): Scene | null { !timer && lastRendered > 0 && now - lastRendered > FRAME_INTERVAL_MS * 1.5 - ) - ladder.push(GPU_BUDGET_MS * 2) && (sizedFor = undefined); // no timer: a missed cap counts as over budget + ) { + // no timer: a missed cap counts as over budget + if (ladder.push(GPU_BUDGET_MS * 2)) sizedFor = undefined; + } lastRendered = now; render(now); framesLeft -= 1; @@ -385,30 +423,47 @@ export function createScene(opts: SceneOptions): Scene | null { resetHistory = true; // the first frame after a pause has a real elapsed dt raf = requestAnimationFrame(tick); }; - const onVisibility = () => { - if (document.visibilityState === 'visible') wake(); + 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 onResize = () => wake(); - if (!opts.unthrottled) document.addEventListener('visibilitychange', onVisibility); - window.addEventListener('resize', onResize); - if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; + 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() { - disposed = true; - if (raf) cancelAnimationFrame(raf); - document.removeEventListener('visibilitychange', onVisibility); - window.removeEventListener('resize', onResize); + if (disposed) return; + stop(); freeTargets(); - for (const p of PASSES) gl.deleteProgram(programs[p]); + PASSES.forEach((p) => gl.deleteProgram(programs[p])); gl.deleteVertexArray(vao); - for (const f of [fbos.solid, fbos.atm, ...fbos.hist]) gl.deleteFramebuffer(f); + [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 index 27dd0487..9ac8f0af 100644 --- a/src/app/features/scene/budget.test.ts +++ b/src/app/features/scene/budget.test.ts @@ -4,31 +4,38 @@ 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); - for (let i = 0; i < 4; i++) expect(ladder.push(9)).toBe(false); + Array.from({ length: 4 }).forEach(() => expect(ladder.push(9)).toBe(false)); expect(ladder.push(9)).toBe(true); expect(ladder.index).toBe(1); - for (let i = 0; i < 100; i++) ladder.push(0.1); // fast frames never raise the level + 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); - for (const ms of [1, 1, 50, 1, 1]) ladder.push(ms); + [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); - for (let i = 0; i < 20; i++) ladder.push(99); + 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', () => { - for (let i = 1; i < LEVELS.length; i++) { - expect(LEVELS[i].solidScale).toBeLessThan(LEVELS[i - 1].solidScale); - expect(LEVELS[i].atmosphereScale).toBeLessThan(LEVELS[i - 1].atmosphereScale); - expect(LEVELS[i].cloudSteps).toBeLessThan(LEVELS[i - 1].cloudSteps); - expect(LEVELS[i].traceSteps).toBeLessThan(LEVELS[i - 1].traceSteps); - } + 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 index 9550571c..3b373d86 100644 --- a/src/app/features/scene/budget.ts +++ b/src/app/features/scene/budget.ts @@ -20,10 +20,20 @@ export const LEVELS: readonly Level[] = [ /** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */ export class BudgetLadder { - private level = 0; + private level: number; + private recent: number[] = []; - constructor(private readonly budgetMs: number, private readonly window = 45) {} + 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]; @@ -38,7 +48,7 @@ export class BudgetLadder { this.recent.push(ms); if (this.recent.length < this.window) return false; const sorted = [...this.recent].sort((a, b) => a - b); - const median = sorted[sorted.length >> 1]; + const median = sorted[Math.floor(sorted.length / 2)]; this.recent = []; if (median > this.budgetMs && this.level < LEVELS.length - 1) { this.level += 1; 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..7bb5d440 --- /dev/null +++ b/src/app/features/scene/creatures.ts @@ -0,0 +1,26 @@ +// Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut. + +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/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 index 87fa1983..caa04fad 100644 --- a/src/app/features/scene/world.test.ts +++ b/src/app/features/scene/world.test.ts @@ -9,9 +9,9 @@ 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])); - for (const src of [common, terrain, atmosphere, resolve, composite]) { - for (const m of src.matchAll(/\bW_[A-Z0-9_]+\b/g)) expect(defined, m[0]).toContain(m[0]); - } + [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', () => { diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts index a1c6cb57..ba9aec53 100644 --- a/src/app/features/scene/world.ts +++ b/src/app/features/scene/world.ts @@ -28,7 +28,8 @@ export const WORLD = { // 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). - phacelleGradBound: 7.0, + // 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, 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); From 8c7113bcafab90fed550e41c5666da4b7823ab79 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 07:22:58 +0000 Subject: [PATCH 05/13] creatures: cite the Bifrost creature-props contract Header names heimdall-body/ratatoskr-creature-projection, the shape, invariants and consent rule, and marks the bucket scales as placeholders. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/creatures.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index 7bb5d440..2daa541e 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -1,4 +1,15 @@ // Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut. +// +// Props contract: heimdall-body/ratatoskr-creature-projection (against GameCult/Bifrost). +// 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'; From 0ec34ff19fe9c0de3d45e8532ef25931de09d439 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 10:14:52 +0000 Subject: [PATCH 06/13] creatures: cite creature-projection-v2 Header cites heimdall-body:follow_up:ratatoskr-creature-projection-v2, which supersedes heimdall-body/ratatoskr-creature-projection. Citation only. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/creatures.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index 2daa541e..5026d2e2 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -1,6 +1,6 @@ // Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut. // -// Props contract: heimdall-body/ratatoskr-creature-projection (against GameCult/Bifrost). +// 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}. From c4c00d7127d9c29582f591431278ddc47b69360c Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 2 Nov 2026 14:39:00 +0000 Subject: [PATCH 07/13] soundscape: audio socket over Aqua's activity frame (flag off, muted, untested by ear) Reads the grammar frame (AquaSynth docs/soundscape-grammar.md @ aeae510) into a per-mind table, plans voices (neutral overrides all, Muted, cap, master mute, weak-device off) and the drawn set. No activity source exists yet (ratatoskr:question:activity-feed-source) and no audio assets, so nothing sounds. Cites heimdall-body:follow_up:ratatoskr-creature-projection-v2. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/soundscape/device.ts | 12 ++ src/app/features/soundscape/frame.ts | 95 ++++++++++ src/app/features/soundscape/scene.ts | 8 + src/app/features/soundscape/socket.ts | 47 +++++ .../features/soundscape/soundscape.test.ts | 171 ++++++++++++++++++ src/app/features/soundscape/voices.ts | 49 +++++ src/app/state/settings.ts | 11 ++ 7 files changed, 393 insertions(+) create mode 100644 src/app/features/soundscape/device.ts create mode 100644 src/app/features/soundscape/frame.ts create mode 100644 src/app/features/soundscape/scene.ts create mode 100644 src/app/features/soundscape/socket.ts create mode 100644 src/app/features/soundscape/soundscape.test.ts create mode 100644 src/app/features/soundscape/voices.ts diff --git a/src/app/features/soundscape/device.ts b/src/app/features/soundscape/device.ts new file mode 100644 index 00000000..ecdf1593 --- /dev/null +++ b/src/app/features/soundscape/device.ts @@ -0,0 +1,12 @@ +export interface DeviceHints { + deviceMemory?: number; + hardwareConcurrency?: number; +} + +/** + * Off on weak phones. The thresholds are placeholders: no phone has been measured (grammar + * section 7, item 4). Unknown hints (Safari reports neither) do not count as weak. + */ +export const isWeakDevice = ({ deviceMemory, hardwareConcurrency }: DeviceHints): boolean => + (deviceMemory !== undefined && deviceMemory < 4) || + (hardwareConcurrency !== undefined && hardwareConcurrency < 4); diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts new file mode 100644 index 00000000..7d7b2476 --- /dev/null +++ b/src/app/features/soundscape/frame.ts @@ -0,0 +1,95 @@ +// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar.md at aeae510, +// section 2). The Heimdall session produces it; this file only reads it. Shape follows +// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. + +export type Estate = 'human' | 'mind' | 'persona'; +export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert'; +export type Energy = 'Low' | 'Mid' | 'High' | 'Peak'; +export type Size = 'S' | 'M' | 'L'; +export type Override = 'None' | 'Muted'; + +export interface ActivityFrame { + mind: string; + estate: Estate; + kind: Kind; + energy: Energy; + size: Size; + neutral: boolean; + override: Override; + seq: number; + /** Producer observation time, whole seconds (grammar: 1 s resolution). */ + at: number; +} + +const ESTATES: readonly Estate[] = ['human', 'mind', 'persona']; +const KINDS: readonly Kind[] = [ + 'Idle', + 'Reading', + 'Thinking', + 'Working', + 'Speaking', + 'Waiting', + 'Alert', +]; +const ENERGIES: readonly Energy[] = ['Low', 'Mid', 'High', 'Peak']; +const SIZES: readonly Size[] = ['S', 'M', 'L']; + +/** Grammar S2 (proposed, unmeasured): a frame older than this is absent. */ +export const STALE_AFTER_S = 10; + +const oneOf = (set: readonly T[], v: unknown): T | undefined => + set.find((s) => s === v); + +/** Grammar estate rule: a consumer clamps a human's energy to Low/Mid/High. */ +export const clampEnergy = (estate: Estate, energy: Energy): Energy => + estate === 'human' && energy === 'Peak' ? 'High' : energy; + +/** + * Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a + * room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3). + * Returns undefined for anything malformed; the caller treats that as no frame (A4). + */ +export const readFrame = (raw: unknown): ActivityFrame | undefined => { + if (typeof raw !== 'object' || raw === null) return undefined; + const r = raw as Record; + const estate = oneOf(ESTATES, r.estate); + const kind = oneOf(KINDS, r.kind); + const energy = oneOf(ENERGIES, r.energy); + const size = oneOf(SIZES, r.size); + const override = + r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override); + if (!estate || !kind || !energy || !size || !override) return undefined; + if (typeof r.mind !== 'string' || r.mind === '' || typeof r.neutral !== 'boolean') + return undefined; + if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined; + return { + mind: r.mind, + estate, + kind, + energy: clampEnergy(estate, energy), + size, + neutral: r.neutral, + override, + seq: r.seq as number, + at: r.at as number, + }; +}; + +/** Latest frame per mind (S1: consumers drop seq <= last). */ +export class FrameTable { + private frames = new Map(); + + offer(raw: unknown): boolean { + const f = readFrame(raw); + if (!f) return false; + const last = this.frames.get(f.mind); + if (last && f.seq <= last.seq) return false; + this.frames.set(f.mind, f); + return true; + } + + /** Frames not stale at `nowS`; a missing or stale frame is simply absent (A4), not neutral. */ + fresh(nowS: number): ActivityFrame[] { + return [...this.frames.values()].filter((f) => nowS - f.at <= STALE_AFTER_S); + } +} diff --git a/src/app/features/soundscape/scene.ts b/src/app/features/soundscape/scene.ts new file mode 100644 index 00000000..027cc27a --- /dev/null +++ b/src/app/features/soundscape/scene.ts @@ -0,0 +1,8 @@ +import type { ActivityFrame, Size } from './frame'; + +/** Placeholders owned by the orbit renderer, not the frame (grammar, Estate section). Unmeasured. */ +export const PLACEHOLDER_SIZE_SCALE: Record = { S: 0.7, M: 1, L: 1.4 }; + +/** O1: a neutral participant gets no orbit. There is no neutral pose; it is not in the list. */ +export const drawnCreatures = (frames: readonly ActivityFrame[]): ActivityFrame[] => + frames.filter((f) => !f.neutral); diff --git a/src/app/features/soundscape/socket.ts b/src/app/features/soundscape/socket.ts new file mode 100644 index 00000000..e1d23010 --- /dev/null +++ b/src/app/features/soundscape/socket.ts @@ -0,0 +1,47 @@ +import { FrameTable } from './frame'; +import { drawnCreatures } from './scene'; +import { planVoices, type SoundscapePrefs, type VoiceCommand } from './voices'; +import { isWeakDevice, type DeviceHints } from './device'; + +/** + * Where frames come from is `ratatoskr:question:activity-feed-source`, still open. Until the + * Heimdall session feed exists the only source is `silentSource`. A source never reads a room's + * member list (A1): being in a room says nothing about being active. + */ +export interface ActivitySource { + subscribe(onFrame: (raw: unknown) => void): () => void; +} +const noop = () => undefined; +export const silentSource: ActivitySource = { subscribe: () => noop }; + +/** Receives the plan. No audio assets exist yet, so nothing in this repo sounds. */ +export interface VoiceSink { + play(commands: readonly VoiceCommand[]): void; +} + +export interface AudioSocketDeps { + source: ActivitySource; + sink: VoiceSink; + prefs: () => SoundscapePrefs; + device: () => DeviceHints; + /** Whole seconds. */ + nowS: () => number; +} + +export const createAudioSocket = (deps: AudioSocketDeps) => { + const table = new FrameTable(); + const unsubscribe = deps.source.subscribe((raw) => table.offer(raw)); + return { + /** Called by the scheduler tick; a hidden page simply stops calling it (S5). */ + tick(): VoiceCommand[] { + const prefs = deps.prefs(); + const effective = { ...prefs, enabled: prefs.enabled && !isWeakDevice(deps.device()) }; + const commands = planVoices(table.fresh(deps.nowS()), effective); + deps.sink.play(commands); + return commands; + }, + /** What the orbit renderer draws: the same table, neutral removed. */ + drawn: () => drawnCreatures(table.fresh(deps.nowS())), + close: unsubscribe, + }; +}; diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts new file mode 100644 index 00000000..e07db5b9 --- /dev/null +++ b/src/app/features/soundscape/soundscape.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame } from './frame'; +import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene'; +import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices'; +import { isWeakDevice } from './device'; +import { createAudioSocket, silentSource, type ActivitySource } from './socket'; + +const frame = (o: Partial = {}): ActivityFrame => ({ + mind: 'void', + estate: 'mind', + kind: 'Working', + energy: 'Mid', + size: 'M', + neutral: false, + override: 'None', + seq: 1, + at: 100, + ...o, +}); +const noop = () => undefined; +const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 }; + +describe('neutral overrides everything', () => { + it('draws nothing and sounds nothing for any kind, energy, size or estate', () => { + const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) => + (['Speaking', 'Alert', 'Working'] as const).flatMap((kind) => + (['High', 'Peak'] as const).flatMap((energy) => + (['S', 'M', 'L'] as const).map((size) => + frame({ neutral: true, estate, kind, energy, size }) + ) + ) + ) + ); + expect(cases).toHaveLength(54); + cases.forEach((f) => { + expect(drawnCreatures([f])).toEqual([]); + expect(planVoices([f], on)).toEqual([]); + }); + }); + it('is not the same as an absent frame: absent reads as idle, neutral as hidden', () => { + const t = new FrameTable(); + t.offer(frame({ at: 100 })); + expect(drawnCreatures(t.fresh(100 + STALE_AFTER_S + 1))).toEqual([]); + expect(drawnCreatures(t.fresh(100)).length).toBe(1); + }); +}); + +describe('human energy is clamped to three buckets by the consumer', () => { + it('turns Peak into High for humans only', () => { + expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High'); + expect(readFrame(frame({ estate: 'mind', energy: 'Peak' }))?.energy).toBe('Peak'); + expect(readFrame(frame({ estate: 'persona', energy: 'Peak' }))?.energy).toBe('Peak'); + }); + it('keeps the clamp through the voice plan', () => { + const t = new FrameTable(); + t.offer(frame({ estate: 'human', energy: 'Peak' })); + expect(planVoices(t.fresh(100), on)[0].energy).toBe('High'); + }); +}); + +describe('activity is a stub, never the room member list', () => { + it('the silent source and the socket yield nothing, however many members a room has', () => { + const members = ['@a:x', '@b:x', '@c:x']; + const sock = createAudioSocket({ + source: silentSource, + sink: { play: noop }, + prefs: () => on, + device: () => ({}), + nowS: () => 100, + }); + expect(members.length).toBeGreaterThan(0); + expect(sock.tick()).toEqual([]); + expect(sock.drawn()).toEqual([]); + }); + it('only frames the source pushes produce voices', () => { + const pushes: ((raw: unknown) => void)[] = []; + const source: ActivitySource = { + subscribe: (cb) => { + pushes.push(cb); + return noop; + }, + }; + const sock = createAudioSocket({ + source, + sink: { play: noop }, + prefs: () => on, + device: () => ({}), + nowS: () => 100, + }); + pushes[0](frame({ mind: 'aqua' })); + expect(sock.tick().map((v) => v.mind)).toEqual(['aqua']); + }); +}); + +describe('no vote weights', () => { + it('drops fields the grammar does not define, and the plan carries only user volume as gain', () => { + const f = readFrame({ ...frame(), weight: 9, votes: 40, roomId: '!r:x', brief: 'secret' }); + expect(Object.keys(f ?? {}).sort()).toEqual( + ['at', 'energy', 'estate', 'kind', 'mind', 'neutral', 'override', 'seq', 'size'].sort() + ); + const t = new FrameTable(); + t.offer({ ...frame(), weight: 9 }); + expect(planVoices(t.fresh(100), on)[0].gain).toBe(0.5); + }); +}); + +describe('size scales are placeholders', () => { + it('live in the renderer table, not in the frame, and change nothing about voices', () => { + expect(PLACEHOLDER_SIZE_SCALE).toEqual({ S: 0.7, M: 1, L: 1.4 }); + const s = planVoices([frame({ size: 'S' })], on); + const l = planVoices([frame({ size: 'L' })], on); + expect(s).toEqual(l); + }); +}); + +describe('socket defaults and limits', () => { + it('ships off, muted, with the proposed cap', async () => { + vi.stubGlobal('localStorage', { getItem: () => null }); + const { getSettings } = await import('../../state/settings'); + const s = getSettings(); + expect(s.soundscapeEnabled).toBe(false); + expect(s.soundscapeMuted).toBe(true); + expect(s.soundscapeVoiceCap).toBe(DEFAULT_VOICE_CAP); + }); + it('master mute, disabled flag and zero volume all silence', () => { + const f = [frame()]; + expect(planVoices(f, { ...on, muted: true })).toEqual([]); + expect(planVoices(f, { ...on, enabled: false })).toEqual([]); + expect(planVoices(f, { ...on, volume: 0 })).toEqual([]); + }); + it('caps voices, dropping the lowest energy first', () => { + const f = ['a', 'b', 'c', 'd', 'e'].map((mind, i) => + frame({ mind, energy: (['Low', 'Mid', 'High', 'Peak', 'Mid'] as const)[i] }) + ); + const voices = planVoices(f, { ...on, voiceCap: 4 }).map((v) => v.mind); + expect(voices).toHaveLength(4); + expect(voices).not.toContain('a'); + }); + it('Muted silences the voice but the creature is still drawn; Idle/Waiting are silent', () => { + const f = [frame({ override: 'Muted' }), frame({ mind: 'x', kind: 'Idle' })]; + expect(planVoices(f, on)).toEqual([]); + expect(drawnCreatures(f)).toHaveLength(2); + }); + it('is off on weak phones even when the flag is on, and unknown hints are not weak', () => { + expect(isWeakDevice({ deviceMemory: 2 })).toBe(true); + expect(isWeakDevice({ hardwareConcurrency: 2 })).toBe(true); + expect(isWeakDevice({})).toBe(false); + const played: number[] = []; + const sock = createAudioSocket({ + source: { + subscribe: (cb) => { + cb(frame()); + return noop; + }, + }, + sink: { play: (c) => played.push(c.length) }, + prefs: () => on, + device: () => ({ deviceMemory: 1 }), + nowS: () => 100, + }); + expect(sock.tick()).toEqual([]); + expect(played).toEqual([0]); + }); + it('drops frames with seq <= last, and malformed frames', () => { + const t = new FrameTable(); + expect(t.offer(frame({ seq: 5 }))).toBe(true); + expect(t.offer(frame({ seq: 5 }))).toBe(false); + expect(t.offer(frame({ seq: 4 }))).toBe(false); + expect(t.offer({ ...frame(), kind: 'Dancing' })).toBe(false); + }); +}); diff --git a/src/app/features/soundscape/voices.ts b/src/app/features/soundscape/voices.ts new file mode 100644 index 00000000..d20e55ad --- /dev/null +++ b/src/app/features/soundscape/voices.ts @@ -0,0 +1,49 @@ +import type { ActivityFrame, Energy, Kind } from './frame'; + +/** Grammar V3 (proposed, unmeasured): voices audible at once. */ +export const DEFAULT_VOICE_CAP = 4; + +/** Local listener preferences. Never written back into a frame (O3). */ +export interface SoundscapePrefs { + /** The feature flag. Default off. */ + enabled: boolean; + /** Master mute. Default on. */ + muted: boolean; + /** 0..1 */ + volume: number; + voiceCap: number; +} + +export interface VoiceCommand { + mind: string; + kind: Kind; + energy: Energy; + gain: number; +} + +const ENERGY_RANK: Record = { Low: 0, Mid: 1, High: 2, Peak: 3 }; +/** Grammar section 3: Idle and Waiting default silent. */ +const SILENT_KINDS: ReadonlySet = new Set(['Idle', 'Waiting']); + +const clamp01 = (v: number) => (Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0); + +/** + * Which voices speak now. Neutral overrides everything (O1); `Muted` silences one Mind (O2); + * over the cap the lowest energy drops first (V3, ties by mind id so the result is stable). + * Identity is the mind id only (V1); `gain` is the user's volume, not a vote weight. + * Lead exemption in V3 is not implemented: the grammar does not say how a lead is chosen. + */ +export const planVoices = ( + frames: readonly ActivityFrame[], + prefs: SoundscapePrefs +): VoiceCommand[] => { + if (!prefs.enabled || prefs.muted) return []; + const gain = clamp01(prefs.volume); + if (gain === 0) return []; + const cap = Math.max(0, Math.floor(prefs.voiceCap)); + return frames + .filter((f) => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind)) + .sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind)) + .slice(0, cap) + .map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain })); +}; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 6a894350..d4deb39c 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -47,6 +47,12 @@ export interface Settings { dateFormatString: string; developerTools: boolean; + + /** Soundscape flag: default off, and muted by default even when on. */ + soundscapeEnabled: boolean; + soundscapeMuted: boolean; + soundscapeVolume: number; + soundscapeVoiceCap: number; } const defaultSettings: Settings = { @@ -81,6 +87,11 @@ const defaultSettings: Settings = { dateFormatString: 'D MMM YYYY', developerTools: false, + + soundscapeEnabled: false, + soundscapeMuted: true, + soundscapeVolume: 0.5, + soundscapeVoiceCap: 4, }; export const getSettings = () => { From 1f4341dab64d6c90fe07ba4f98056200314142d7 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 14:43:33 +0000 Subject: [PATCH 08/13] scene: live reduced-motion, chunk-load failure, per-stage CultMath - matchMedia 'change' listener freezes/thaws a running scene; removed on dispose and context loss - startBackgroundScene catches a failed scene chunk import: onCompileFail, null, no throw - shaderSource compiles each stage with only the CultMath declarations it reaches (cultmathTrim.ts); Phacelle stays a separate file, terrain only - README: 'still' is a live rendered frame Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 10 ++- src/app/features/scene/Scene.test.ts | 89 ++++++++++++++++++++- src/app/features/scene/Scene.ts | 7 ++ src/app/features/scene/cultmathTrim.test.ts | 82 +++++++++++++++++++ src/app/features/scene/cultmathTrim.ts | 81 +++++++++++++++++++ src/app/features/scene/index.ts | 16 +++- src/app/features/scene/shaderSource.ts | 9 ++- src/app/features/scene/shaders/README.md | 4 + 8 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 src/app/features/scene/cultmathTrim.test.ts create mode 100644 src/app/features/scene/cultmathTrim.ts diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 0d49d57d..be117a0a 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -14,15 +14,17 @@ callbacks and the creature sizing rule. Nothing has drawn a creature. 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. + demand, so it stays out of the main chunk; if that chunk fails to load the error goes to + `onCompileFail` and the door returns null, it does not throw. - `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 + `ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a + baked image: it runs the full pipeline once 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 + the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion` (a change of the + preference while it runs freezes or thaws it), 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. diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index 97988687..25b7f08d 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -49,14 +49,16 @@ function fakeCanvas(gl: WebGL2RenderingContext | null) { let rafQueue: Array<(t: number) => void>; let cancelled: number; let win: EventTarget; +let motion: EventTarget & { matches: boolean }; let doc: EventTarget & { visibilityState: string }; beforeEach(() => { rafQueue = []; cancelled = 0; + motion = Object.assign(new EventTarget(), { matches: false }); win = Object.assign(new EventTarget(), { devicePixelRatio: 1, - matchMedia: () => ({ matches: false }), + matchMedia: () => motion, }); doc = Object.assign(new EventTarget(), { visibilityState: 'visible' }); vi.stubGlobal('window', win); @@ -180,6 +182,69 @@ describe('context loss', () => { }); }); +describe('prefers-reduced-motion', () => { + const runAll = (t0: number) => { + let t = t0; + let n = 0; + while (rafQueue.length && n < 200) { + runFrame(t); + t += 40; + n += 1; + } + return n; + }; + + it('freezes to a bounded run when the preference turns on mid-flight', () => { + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)) }); + runFrame(1000); + expect(rafQueue).toHaveLength(1); // moving: keeps going + motion.matches = true; + motion.dispatchEvent(new Event('change')); + expect(runAll(1040)).toBeLessThan(200); // ends + expect(rafQueue).toHaveLength(0); + }); + + it('thaws a frozen scene when the preference turns off', () => { + motion.matches = true; + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)) }); + runAll(1000); + expect(rafQueue).toHaveLength(0); + motion.matches = false; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(1); + runFrame(5000); + expect(rafQueue).toHaveLength(1); // unbounded again + }); + + it('removes the listener on dispose and on context loss', () => { + const { gl } = fakeGl(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)) }); + scene?.dispose(); + rafQueue.length = 0; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(0); + + const lostCanvas = fakeCanvas(fakeGl().gl); + createScene({ canvas: asCanvas(lostCanvas) }); + lostCanvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + rafQueue.length = 0; + motion.matches = true; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(0); + }); + + it('a still does not listen: one frame stays one frame', () => { + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); + runFrame(1000); + motion.matches = false; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(0); + }); +}); + describe('startBackgroundScene', () => { it("does not request a context when the config leaves the tier 'off'", async () => { const { gl } = fakeGl(); @@ -204,6 +269,28 @@ describe('startBackgroundScene', () => { expect(rafQueue).toHaveLength(0); }); + it('reports a failed scene chunk to onCompileFail and returns null instead of throwing', async () => { + vi.resetModules(); + vi.doMock('./Scene', () => { + throw new Error('Failed to fetch dynamically imported module'); + }); + try { + const { startBackgroundScene: start } = await import('./index'); + const onCompileFail = vi.fn(); + const canvas = fakeCanvas(fakeGl().gl); + const scene = await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas), onCompileFail }); + expect(scene).toBeNull(); + expect(onCompileFail).toHaveBeenCalledTimes(1); + expect(onCompileFail.mock.calls[0][0]).toBeInstanceOf(Error); + expect(canvas.getContext).not.toHaveBeenCalled(); + // and with no callback it still does not throw + expect(await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas) })).toBeNull(); + } finally { + vi.doUnmock('./Scene'); + vi.resetModules(); + } + }); + 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(); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 10dacc58..40ce300d 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -448,6 +448,13 @@ export function createScene(opts: SceneOptions): Scene | null { opts.onContextLost?.(); }); if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES; + // The preference can flip while the scene lives: freeze to a bounded run, or thaw to a free one. + if (reducedMotion && !still) { + listen(reducedMotion, 'change', () => { + framesLeft = reducedMotion.matches ? REDUCED_MOTION_FRAMES : Infinity; + wake(); + }); + } wake(); return { diff --git a/src/app/features/scene/cultmathTrim.test.ts b/src/app/features/scene/cultmathTrim.test.ts new file mode 100644 index 00000000..cef6b281 --- /dev/null +++ b/src/app/features/scene/cultmathTrim.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { trimGlsl } from './cultmathTrim'; +import { fragmentSource, Stage } from './shaderSource'; +import cultmath from './shaders/CultMath.glsl?raw'; + +const LIB = `// header +#ifndef LIB_GLSL +#define LIB_GLSL +const float LIB_PI = 3.14; + +float lib_half(float v) { return v * 0.5; } +vec2 lib_half(vec2 v) { return v * 0.5; } + +// uses the constant and an overload set +float lib_pi_half(float v) { return lib_half(v) * LIB_PI; } + +struct LibPair { float a; float b; }; + +LibPair lib_pair(float v) { LibPair p; p.a = v; p.b = lib_half(v); return p; } +float lib_unused(float v) { return v; } +#endif +`; + +describe('trimGlsl', () => { + it('keeps what a root names and what that reaches, and drops the rest', () => { + const out = trimGlsl(LIB, ['float x = lib_pi_half(1.0);']); + expect(out).toContain('lib_pi_half'); + expect(out).toContain('LIB_PI'); + expect(out).toContain('float lib_half'); + expect(out).toContain('vec2 lib_half'); // overloads stay together + expect(out).not.toContain('lib_unused'); + expect(out).not.toContain('LibPair'); + expect(out).not.toContain('lib_pair'); + }); + + it('keeps a struct that a kept function returns', () => { + const out = trimGlsl(LIB, ['LibPair p = lib_pair(1.0);']); + expect(out).toContain('struct LibPair'); + expect(out).toContain('lib_half'); + expect(out).not.toContain('lib_pi_half'); + }); + + it('keeps the include guard balanced', () => { + const out = trimGlsl(LIB, ['lib_half(1.0)']); + expect(out).toMatch(/^#ifndef LIB_GLSL\n#define LIB_GLSL\n/); + expect(out.trimEnd().endsWith('#endif')).toBe(true); + }); + + it('keeps nothing but the guard for a root that names nothing', () => { + const out = trimGlsl(LIB, ['void main() {}']); + expect(out).not.toContain('lib_'); + expect(out).toContain('#ifndef LIB_GLSL'); + }); +}); + +describe('CultMath per shader stage', () => { + const stages: Stage[] = ['terrain', 'atmosphere', 'resolve', 'composite']; + const cultmathNames = (text: string) => new Set(text.match(/\bcultmath_\w+/g) ?? []); + const defined = (src: string, name: string) => + new RegExp(`^\\s*(?:float|vec[234]|mat3|int|\\w+)\\s+${name}\\s*\\(`, 'm').test(src); + + it.each(stages)('%s defines every CultMath function it, or Phacelle, calls', (stage) => { + const src = fragmentSource(stage); + cultmathNames(src).forEach((n) => expect(defined(src, n), n).toBe(true)); + }); + + it.each(stages)('%s is smaller than the whole library', (stage) => { + expect(fragmentSource(stage).length).toBeLessThan(cultmath.length / 2); + }); + + it('puts the noise a stage calls in that stage and not in the others', () => { + expect(fragmentSource('terrain')).toContain('cultmath_snoise'); + expect(fragmentSource('composite')).not.toContain('cultmath_snoise'); + }); + + it('adds Phacelle to terrain alone, as its own file', () => { + expect(fragmentSource('terrain')).toContain('cultmath_phacelle'); + stages + .filter((s) => s !== 'terrain') + .forEach((s) => expect(fragmentSource(s)).not.toContain('cultmath_phacelle')); + }); +}); diff --git a/src/app/features/scene/cultmathTrim.ts b/src/app/features/scene/cultmathTrim.ts new file mode 100644 index 00000000..fc246e5c --- /dev/null +++ b/src/app/features/scene/cultmathTrim.ts @@ -0,0 +1,81 @@ +/** + * Keeps the part of CultMath's GLSL that a stage can reach. The vendored file is whole and never + * edited; each stage compiles only the declarations its own source (and the source concatenated + * beside it) names, directly or through other kept declarations. Overloads share a name and are + * kept together. + */ + +interface Decl { + name: string; + text: string; +} + +const GUARD_OPEN = /^#ifndef\s+\w+\s*\n#define\s+\w+\s*\n/m; +const GUARD_CLOSE = /\n#endif\s*$/; + +/** Name a top-level declaration defines: function, struct or const. */ +function declName(text: string): string | undefined { + const code = text.replace(/\/\/.*$/gm, ''); + if (!code.includes('{')) return /\b(\w+)\s*=/.exec(code)?.[1]; + return /\bstruct\s+(\w+)/.exec(code)?.[1] ?? /\b(\w+)\s*\(/.exec(code)?.[1]; +} + +/** Splits at brace depth 0: a declaration ends at the `;` or `}` that returns to depth 0. */ +function declarations(body: string): Decl[] { + const out: Decl[] = []; + let depth = 0; + let start = 0; + let i = 0; + while (i < body.length) { + const c = body[i]; + if (c === '/' && body[i + 1] === '/') { + const eol = body.indexOf('\n', i); + i = eol < 0 ? body.length : eol; + } else if (c === '{') { + depth += 1; + } else if ((c === '}' || c === ';') && depth - (c === '}' ? 1 : 0) === 0) { + if (c === '}') depth -= 1; + // a struct's closing brace is followed by `;` + const end = c === '}' && /^\s*;/.test(body.slice(i + 1)) ? body.indexOf(';', i) : i; + const text = body.slice(start, end + 1); + const name = declName(text); + if (name) out.push({ name, text }); + start = end + 1; + i = end; + } else if (c === '}') { + depth -= 1; + } + i += 1; + } + return out; +} + +const idents = (text: string): string[] => text.match(/\b[A-Za-z_]\w*\b/g) ?? []; + +/** `library` trimmed to what `roots` reach; the include guard is kept. */ +export function trimGlsl(library: string, roots: string[]): string { + const open = GUARD_OPEN.exec(library)?.[0] ?? ''; + const close = GUARD_CLOSE.test(library) ? '\n#endif\n' : ''; + const body = library + .slice(open ? library.indexOf(open) + open.length : 0) + .replace(GUARD_CLOSE, ''); + const decls = declarations(body); + const byName = new Map(); + decls.forEach((d) => byName.set(d.name, [...(byName.get(d.name) ?? []), d])); + + const kept = new Set(); + const queue = roots.flatMap(idents); + while (queue.length) { + const group = byName.get(queue.pop() ?? ''); + group?.forEach((d) => { + if (kept.has(d)) return; + kept.add(d); + queue.push(...idents(d.text)); + }); + } + const text = decls + .filter((d) => kept.has(d)) + .map((d) => d.text.replace(/^\s*\n/, '')) + .join('\n'); + return `${open}${text}${close}`; +} diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index f4109796..c896649e 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -4,12 +4,16 @@ import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier'; export type { Scene, SceneStats } from './Scene'; export type { SceneTier } from './tier'; -export type StartOptions = Omit; +export type StartOptions = Omit & { + /** A shader failed to compile, or the scene chunk failed to load. The host keeps its ground. */ + onCompileFail?: (e: Error) => void; +}; /** * 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". + * main chunk. Null means "keep the CSS ground", including when that chunk fails to load (offline, + * stale deploy): the failure goes to onCompileFail and nothing throws. */ export async function startBackgroundScene( config: BackgroundSceneConfig | undefined, @@ -18,6 +22,12 @@ export async function startBackgroundScene( ): Promise { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - const { createScene } = await import('./Scene'); + let createScene: typeof import('./Scene').createScene; + try { + ({ createScene } = await import('./Scene')); + } catch (e) { + options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); + return null; + } return createScene({ ...options, tier }); } diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts index aa44d9b2..c7a2a03f 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -7,6 +7,7 @@ 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'; +import { trimGlsl } from './cultmathTrim'; export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; @@ -22,8 +23,12 @@ const stages: Record = { composite: compositeFrag, }; -/** Fragment source for a stage: header, world defines, CultMath, the shared block, the stage. */ +/** + * Fragment source for a stage: header, world defines, the part of CultMath the stage reaches, the + * shared block, the stage. Phacelle stays a separate file, added only where it is called. + */ 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]}`; + const math = trimGlsl(cultmath, [erosion, common, stages[stage]]); + return `${HEADER}${worldDefines()}\n${math}\n${erosion}\n${common}\n${stages[stage]}`; } diff --git a/src/app/features/scene/shaders/README.md b/src/app/features/scene/shaders/README.md index 617644fb..b970f82b 100644 --- a/src/app/features/scene/shaders/README.md +++ b/src/app/features/scene/shaders/README.md @@ -13,3 +13,7 @@ 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. + +`../shaderSource.ts` compiles each stage with only the part of `CultMath.glsl` that stage reaches +(`../cultmathTrim.ts`); the vendored file itself stays whole. Phacelle is added to the terrain stage +alone. From cc99a3439d21cca1cbe870175e272246d5be2a1a Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 2 Nov 2026 15:46:03 +0000 Subject: [PATCH 09/13] soundscape: neutral read first; human kind subset (provisional) per grammar 5e2eaa9 A neutral frame yields no kind and no energy: nothing drawn, nothing sounded, never an Idle. For a non-neutral human, a kind outside {Idle, Working, Speaking} or omitted becomes Idle and Peak reads as High. Minds and personas keep all seven kinds. The subset is provisional until Bifrost's steward cuts the list. Cites heimdall-body:follow_up:ratatoskr-creature-projection-v2. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/soundscape/frame.ts | 61 ++++++++++-------- .../features/soundscape/soundscape.test.ts | 62 +++++++++++++++++-- src/app/features/soundscape/voices.ts | 6 +- 3 files changed, 97 insertions(+), 32 deletions(-) diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts index 7d7b2476..87a7d2da 100644 --- a/src/app/features/soundscape/frame.ts +++ b/src/app/features/soundscape/frame.ts @@ -1,6 +1,7 @@ -// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar.md at aeae510, +// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar at 5e2eaa9, // section 2). The Heimdall session produces it; this file only reads it. Shape follows -// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. +// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. The human +// subset below is PROVISIONAL until Bifrost's steward cuts the list. export type Estate = 'human' | 'mind' | 'persona'; export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert'; @@ -8,18 +9,27 @@ export type Energy = 'Low' | 'Mid' | 'High' | 'Peak'; export type Size = 'S' | 'M' | 'L'; export type Override = 'None' | 'Muted'; -export interface ActivityFrame { +interface FrameBase { mind: string; estate: Estate; - kind: Kind; - energy: Energy; size: Size; - neutral: boolean; override: Override; seq: number; /** Producer observation time, whole seconds (grammar: 1 s resolution). */ at: number; } +/** O1: a neutral frame carries no kind and no energy; it is not an Idle and nothing reads one. */ +export interface NeutralFrame extends FrameBase { + neutral: true; + kind?: undefined; + energy?: undefined; +} +export interface LiveFrame extends FrameBase { + neutral: false; + kind: Kind; + energy: Energy; +} +export type ActivityFrame = NeutralFrame | LiveFrame; const ESTATES: readonly Estate[] = ['human', 'mind', 'persona']; const KINDS: readonly Kind[] = [ @@ -40,39 +50,40 @@ export const STALE_AFTER_S = 10; const oneOf = (set: readonly T[], v: unknown): T | undefined => set.find((s) => s === v); -/** Grammar estate rule: a consumer clamps a human's energy to Low/Mid/High. */ -export const clampEnergy = (estate: Estate, energy: Energy): Energy => +/** PROVISIONAL (grammar section 2, awaiting the projection-v2 field list): a human's kinds. */ +export const HUMAN_KINDS: readonly Kind[] = ['Idle', 'Working', 'Speaking']; + +/** Estate clamp, only ever revealing less: a human's out-of-subset or omitted kind is Idle. */ +const clampKind = (estate: Estate, kind: unknown): Kind | undefined => + estate === 'human' ? oneOf(HUMAN_KINDS, kind) ?? 'Idle' : oneOf(KINDS, kind); + +/** A human's energy is three buckets: Peak reads as High. */ +const clampEnergy = (estate: Estate, energy: Energy): Energy => estate === 'human' && energy === 'Peak' ? 'High' : energy; /** * Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a * room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3). - * Returns undefined for anything malformed; the caller treats that as no frame (A4). + * `neutral` is read first: a neutral frame yields no kind and no energy even if the producer sent + * them (O1). Returns undefined for anything malformed; the caller treats that as no frame (A4). */ export const readFrame = (raw: unknown): ActivityFrame | undefined => { if (typeof raw !== 'object' || raw === null) return undefined; const r = raw as Record; + if (typeof r.neutral !== 'boolean') return undefined; const estate = oneOf(ESTATES, r.estate); - const kind = oneOf(KINDS, r.kind); - const energy = oneOf(ENERGIES, r.energy); const size = oneOf(SIZES, r.size); const override = r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override); - if (!estate || !kind || !energy || !size || !override) return undefined; - if (typeof r.mind !== 'string' || r.mind === '' || typeof r.neutral !== 'boolean') - return undefined; + if (!estate || !size || !override) return undefined; + if (typeof r.mind !== 'string' || r.mind === '') return undefined; if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined; - return { - mind: r.mind, - estate, - kind, - energy: clampEnergy(estate, energy), - size, - neutral: r.neutral, - override, - seq: r.seq as number, - at: r.at as number, - }; + const base = { mind: r.mind, estate, size, override, seq: r.seq as number, at: r.at as number }; + if (r.neutral) return { ...base, neutral: true }; + const kind = clampKind(estate, r.kind); + const energy = oneOf(ENERGIES, r.energy); + if (!kind || !energy) return undefined; + return { ...base, neutral: false, kind, energy: clampEnergy(estate, energy) }; }; /** Latest frame per mind (S1: consumers drop seq <= last). */ diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts index e07db5b9..c292222a 100644 --- a/src/app/features/soundscape/soundscape.test.ts +++ b/src/app/features/soundscape/soundscape.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame } from './frame'; +import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame, type LiveFrame } from './frame'; import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene'; import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices'; import { isWeakDevice } from './device'; import { createAudioSocket, silentSource, type ActivitySource } from './socket'; -const frame = (o: Partial = {}): ActivityFrame => ({ +const frame = (o: Partial = {}): LiveFrame => ({ mind: 'void', estate: 'mind', kind: 'Working', @@ -17,6 +17,11 @@ const frame = (o: Partial = {}): ActivityFrame => ({ at: 100, ...o, }); +const neutralFrame = (o: Partial = {}): ActivityFrame => + ({ ...frame(), ...o, neutral: true } as unknown as ActivityFrame); +/** A raw frame as a producer sends it, with the named keys omitted. */ +const without = (f: object, ...keys: string[]) => + Object.fromEntries(Object.entries(f).filter(([k]) => !keys.includes(k))); const noop = () => undefined; const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 }; @@ -25,9 +30,7 @@ describe('neutral overrides everything', () => { const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) => (['Speaking', 'Alert', 'Working'] as const).flatMap((kind) => (['High', 'Peak'] as const).flatMap((energy) => - (['S', 'M', 'L'] as const).map((size) => - frame({ neutral: true, estate, kind, energy, size }) - ) + (['S', 'M', 'L'] as const).map((size) => neutralFrame({ estate, kind, energy, size })) ) ) ); @@ -45,6 +48,55 @@ describe('neutral overrides everything', () => { }); }); +describe('neutral is read first', () => { + it('yields no kind and no energy, even when the producer sent them', () => { + (['human', 'mind', 'persona'] as const).forEach((estate) => { + const f = readFrame({ + ...frame({ estate, kind: 'Speaking', energy: 'High' }), + neutral: true, + }); + expect(f).toBeDefined(); + expect(f?.neutral).toBe(true); + expect(f?.kind).toBeUndefined(); + expect(f?.energy).toBeUndefined(); + expect(Object.keys(f ?? {})).not.toContain('kind'); + expect(Object.keys(f ?? {})).not.toContain('energy'); + }); + }); + it('a neutral human with kind omitted is not an Idle', () => { + const bare = without(frame({ estate: 'human' }), 'kind', 'energy'); + const f = readFrame({ ...bare, neutral: true }); + expect(f?.kind).toBeUndefined(); + expect(drawnCreatures([f as ActivityFrame])).toEqual([]); + expect(planVoices([f as ActivityFrame], on)).toEqual([]); + }); +}); + +describe('human kinds are the provisional subset', () => { + it.each(['Reading', 'Thinking', 'Waiting', 'Alert'] as const)('clamps %s to Idle', (kind) => { + expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe('Idle'); + }); + it('keeps Idle, Working and Speaking', () => { + (['Idle', 'Working', 'Speaking'] as const).forEach((kind) => + expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe(kind) + ); + }); + it('an omitted kind is Idle for a non-neutral human only', () => { + const noKind = without(frame({ estate: 'human' }), 'kind'); + expect(readFrame(noKind)?.kind).toBe('Idle'); + expect(readFrame({ ...noKind, neutral: true })?.kind).toBeUndefined(); + }); + it('minds and personas keep all seven kinds, and omitted is malformed for them', () => { + (['mind', 'persona'] as const).forEach((estate) => { + (['Idle', 'Reading', 'Thinking', 'Working', 'Speaking', 'Waiting', 'Alert'] as const).forEach( + (kind) => expect(readFrame(frame({ estate, kind }))?.kind).toBe(kind) + ); + const noKind = without(frame({ estate }), 'kind'); + expect(readFrame(noKind)).toBeUndefined(); + }); + }); +}); + describe('human energy is clamped to three buckets by the consumer', () => { it('turns Peak into High for humans only', () => { expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High'); diff --git a/src/app/features/soundscape/voices.ts b/src/app/features/soundscape/voices.ts index d20e55ad..676ae853 100644 --- a/src/app/features/soundscape/voices.ts +++ b/src/app/features/soundscape/voices.ts @@ -1,4 +1,4 @@ -import type { ActivityFrame, Energy, Kind } from './frame'; +import type { ActivityFrame, Energy, Kind, LiveFrame } from './frame'; /** Grammar V3 (proposed, unmeasured): voices audible at once. */ export const DEFAULT_VOICE_CAP = 4; @@ -42,7 +42,9 @@ export const planVoices = ( if (gain === 0) return []; const cap = Math.max(0, Math.floor(prefs.voiceCap)); return frames - .filter((f) => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind)) + .filter( + (f): f is LiveFrame => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind) + ) .sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind)) .slice(0, cap) .map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain })); From fff46492caf4e1ed4dcdc53132222342009439b0 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 17:09:42 +0000 Subject: [PATCH 10/13] scene: Phacelle bound says measured-not-proved; neutral creature draws nothing world.ts already carries Libby's 7.0 (CultLib PR #1 990521b3); the comment now states the 6.63 sup was measured, not proved. creatures.ts follows creature-projection-v2: a neutral creature publishes no sizeBucket, and one that arrives is ignored. creatureScale returns null (draw nothing) for neutral, absent or unknown buckets; it never defaults to medium. NEUTRAL_SCALE is removed. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/creatures.test.ts | 18 +++++++++++------- src/app/features/scene/creatures.ts | 20 ++++++++++---------- src/app/features/scene/world.ts | 3 ++- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts index 8e59175b..011f19e3 100644 --- a/src/app/features/scene/creatures.test.ts +++ b/src/app/features/scene/creatures.test.ts @@ -10,14 +10,18 @@ describe('creature scale', () => { ); }); - it('is one fixed size for every neutral creature, whatever its sizeBucket', () => { - buckets.forEach((sizeBucket) => - expect(creatureScale({ sizeBucket, neutral: true })).toBe(NEUTRAL_SCALE) - ); + it('draws nothing for a neutral creature, whatever sizeBucket it was sent', () => { + expect(creatureScale({ neutral: true })).toBeNull(); + buckets.forEach((sizeBucket) => expect(creatureScale({ sizeBucket, neutral: true })).toBeNull()); }); - 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); + it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => { + expect(creatureScale({ neutral: false })).toBeNull(); + expect(creatureScale({ sizeBucket: undefined, neutral: false })).not.toBe(BUCKET_SCALE.medium); + }); + + it('draws nothing for an unknown bucket, including inherited property names', () => { + expect(creatureScale({ sizeBucket: 'huge' as SizeBucket, neutral: false })).toBeNull(); + expect(creatureScale({ sizeBucket: 'toString' as SizeBucket, neutral: false })).toBeNull(); }); }); diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index 5026d2e2..b67a423c 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -3,10 +3,12 @@ // 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}. +// Shape: {id, estate, neutral, activity} plus sizeBucket when neutral is false. // Invariants: // - Exact vote weights stay in Bifrost; Ratatoskr only ever sees a bucket. -// - neutral=true overrides any bucket, and no size is drawn. +// - neutral=true means Bifrost publishes no sizeBucket, no kind and no energy. A received value is +// ignored, and a neutral creature is not drawn. +// - A missing or unknown sizeBucket is not drawn either; no size is ever defaulted. // - 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. @@ -16,8 +18,8 @@ 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. */ + /** Absent when neutral. Read it only through creatureScale. */ + sizeBucket?: SizeBucket; neutral: boolean; activity: 'idle' | 'active'; } @@ -28,10 +30,8 @@ export const BUCKET_SCALE: Readonly> = { 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]; +/** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */ +export function creatureScale(c: Pick): number | null { + if (c.neutral || c.sizeBucket === undefined) return null; + return Object.hasOwn(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; } diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts index ba9aec53..ba366a6e 100644 --- a/src/app/features/scene/world.ts +++ b/src/app/features/scene/world.ts @@ -24,7 +24,8 @@ export const WORLD = { 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 + // sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. + // The sup was measured, not proved. 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). From 5e79a1853a5076699bfa724f7185d855e4e46c3f Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 2 Nov 2026 18:29:34 +0000 Subject: [PATCH 11/13] soundscape: neutral carries no size; cite grammar head 0091d01 NeutralFrame drops size alongside kind and energy; the reader ignores a size a producer sends on a neutral frame, and a live frame without a valid size is malformed. The human kind subset (Idle/Working/Speaking) stays PROVISIONAL, still proposed. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/soundscape/frame.ts | 24 ++++++++++--------- .../features/soundscape/soundscape.test.ts | 12 +++++++++- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts index 87a7d2da..947d2bb5 100644 --- a/src/app/features/soundscape/frame.ts +++ b/src/app/features/soundscape/frame.ts @@ -1,7 +1,8 @@ -// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar at 5e2eaa9, -// section 2). The Heimdall session produces it; this file only reads it. Shape follows +// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar, grammar head +// 0091d01, section 2). The Heimdall session produces it; this file only reads it. Shape follows // heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. The human -// subset below is PROVISIONAL until Bifrost's steward cuts the list. +// kind subset (Idle/Working/Speaking) below is PROVISIONAL: still proposed, not ruled, until +// Bifrost's steward cuts the list. export type Estate = 'human' | 'mind' | 'persona'; export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert'; @@ -12,20 +13,21 @@ export type Override = 'None' | 'Muted'; interface FrameBase { mind: string; estate: Estate; - size: Size; override: Override; seq: number; /** Producer observation time, whole seconds (grammar: 1 s resolution). */ at: number; } -/** O1: a neutral frame carries no kind and no energy; it is not an Idle and nothing reads one. */ +/** O1: a neutral frame carries no size, kind or energy; it is not an Idle and nothing reads one. */ export interface NeutralFrame extends FrameBase { neutral: true; + size?: undefined; kind?: undefined; energy?: undefined; } export interface LiveFrame extends FrameBase { neutral: false; + size: Size; kind: Kind; energy: Energy; } @@ -64,7 +66,7 @@ const clampEnergy = (estate: Estate, energy: Energy): Energy => /** * Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a * room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3). - * `neutral` is read first: a neutral frame yields no kind and no energy even if the producer sent + * `neutral` is read first: a neutral frame yields no size, kind or energy even if the producer sent * them (O1). Returns undefined for anything malformed; the caller treats that as no frame (A4). */ export const readFrame = (raw: unknown): ActivityFrame | undefined => { @@ -72,18 +74,18 @@ export const readFrame = (raw: unknown): ActivityFrame | undefined => { const r = raw as Record; if (typeof r.neutral !== 'boolean') return undefined; const estate = oneOf(ESTATES, r.estate); - const size = oneOf(SIZES, r.size); const override = r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override); - if (!estate || !size || !override) return undefined; + if (!estate || !override) return undefined; if (typeof r.mind !== 'string' || r.mind === '') return undefined; if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined; - const base = { mind: r.mind, estate, size, override, seq: r.seq as number, at: r.at as number }; + const base = { mind: r.mind, estate, override, seq: r.seq as number, at: r.at as number }; if (r.neutral) return { ...base, neutral: true }; const kind = clampKind(estate, r.kind); const energy = oneOf(ENERGIES, r.energy); - if (!kind || !energy) return undefined; - return { ...base, neutral: false, kind, energy: clampEnergy(estate, energy) }; + const size = oneOf(SIZES, r.size); + if (!kind || !energy || !size) return undefined; + return { ...base, neutral: false, size, kind, energy: clampEnergy(estate, energy) }; }; /** Latest frame per mind (S1: consumers drop seq <= last). */ diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts index c292222a..294456a1 100644 --- a/src/app/features/soundscape/soundscape.test.ts +++ b/src/app/features/soundscape/soundscape.test.ts @@ -49,7 +49,7 @@ describe('neutral overrides everything', () => { }); describe('neutral is read first', () => { - it('yields no kind and no energy, even when the producer sent them', () => { + it('yields no size, kind or energy, even when the producer sent them', () => { (['human', 'mind', 'persona'] as const).forEach((estate) => { const f = readFrame({ ...frame({ estate, kind: 'Speaking', energy: 'High' }), @@ -57,6 +57,8 @@ describe('neutral is read first', () => { }); expect(f).toBeDefined(); expect(f?.neutral).toBe(true); + expect(f?.size).toBeUndefined(); + expect(Object.keys(f ?? {})).not.toContain('size'); expect(f?.kind).toBeUndefined(); expect(f?.energy).toBeUndefined(); expect(Object.keys(f ?? {})).not.toContain('kind'); @@ -221,3 +223,11 @@ describe('socket defaults and limits', () => { expect(t.offer({ ...frame(), kind: 'Dancing' })).toBe(false); }); }); + +describe('neutral carries no size', () => { + it('reads without a size, and a live frame without one is malformed', () => { + const raw = frame(); + expect(readFrame({ ...without(raw, 'size', 'kind', 'energy'), neutral: true })).toBeDefined(); + expect(readFrame(without(raw, 'size'))).toBeUndefined(); + }); +}); From 5eb0a63caa254824dbddc6c0e3319c35bf3a65de Mon Sep 17 00:00:00 2001 From: Nibu Date: Tue, 3 Nov 2026 08:59:54 +0000 Subject: [PATCH 12/13] scene: ES2016-safe hasOwnProperty, tighten undefined-bucket assertion, reflow bound comment Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/creatures.test.ts | 4 ++-- src/app/features/scene/creatures.ts | 2 +- src/app/features/scene/world.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts index 011f19e3..938c217f 100644 --- a/src/app/features/scene/creatures.test.ts +++ b/src/app/features/scene/creatures.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { BUCKET_SCALE, creatureScale, NEUTRAL_SCALE, SizeBucket } from './creatures'; +import { BUCKET_SCALE, creatureScale, SizeBucket } from './creatures'; const buckets: SizeBucket[] = ['small', 'medium', 'large']; @@ -17,7 +17,7 @@ describe('creature scale', () => { it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => { expect(creatureScale({ neutral: false })).toBeNull(); - expect(creatureScale({ sizeBucket: undefined, neutral: false })).not.toBe(BUCKET_SCALE.medium); + expect(creatureScale({ sizeBucket: undefined, neutral: false })).toBeNull(); }); it('draws nothing for an unknown bucket, including inherited property names', () => { diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index b67a423c..a0b2bd29 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -33,5 +33,5 @@ export const BUCKET_SCALE: Readonly> = { /** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */ export function creatureScale(c: Pick): number | null { if (c.neutral || c.sizeBucket === undefined) return null; - return Object.hasOwn(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; + return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; } diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts index ba366a6e..e9594a68 100644 --- a/src/app/features/scene/world.ts +++ b/src/app/features/scene/world.ts @@ -25,9 +25,9 @@ export const WORLD = { 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 sup was measured, not proved. 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). + // The sup was measured, not proved. 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, From 33e5f0ebf70309c946060543fd67102a6632ef94 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 3 Nov 2026 12:01:38 +0000 Subject: [PATCH 13/13] client: nudge sync on resume so a backgrounded tab stops waiting out the 30 s poll On visible (after >=5 s hidden), pageshow(persisted) and online, call retryImmediately; if sync is not Prepared/Syncing/Catchup 3 s later, one non-overlapping stop/start. Debounced, listeners disposable, started once after startClient. Co-Authored-By: Claude Sonnet 5.5 --- src/client/initMatrix.ts | 9 ++- src/client/resumeSync.test.ts | 132 ++++++++++++++++++++++++++++++++++ src/client/resumeSync.ts | 86 ++++++++++++++++++++++ 3 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 src/client/resumeSync.test.ts create mode 100644 src/client/resumeSync.ts diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 498d4f75..871a7625 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -3,6 +3,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; +import { startResumeSync } from './resumeSync'; type Session = { baseUrl: string; @@ -40,10 +41,14 @@ export const initClient = async (session: Session): Promise => { return mx; }; -export const startClient = async (mx: MatrixClient) => { - await mx.startClient({ +const startSync = (mx: MatrixClient) => + mx.startClient({ lazyLoadMembers: true, }); + +export const startClient = async (mx: MatrixClient) => { + await startSync(mx); + startResumeSync(mx, { restart: startSync }); }; export const clearCacheAndReload = async (mx: MatrixClient) => { diff --git a/src/client/resumeSync.test.ts b/src/client/resumeSync.test.ts new file mode 100644 index 00000000..eada97af --- /dev/null +++ b/src/client/resumeSync.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { HEALTH_CHECK_MS, MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync'; + +class Target extends EventTarget { + visibilityState: 'visible' | 'hidden' = 'visible'; +} + +const setup = (state: string | null = 'SYNCING') => { + const doc = new Target(); + const win = new Target(); + const mx = { + state, + getSyncState: () => mx.state, + retryImmediately: vi.fn(() => true), + stopClient: vi.fn(), + }; + const restart = vi.fn(async () => {}); + const stop = startResumeSync(mx, { + restart, + doc: doc as never, + win: win as never, + }); + const hide = () => { + doc.visibilityState = 'hidden'; + doc.dispatchEvent(new Event('visibilitychange')); + }; + const show = () => { + doc.visibilityState = 'visible'; + doc.dispatchEvent(new Event('visibilitychange')); + }; + return { doc, win, mx, restart, stop, hide, show }; +}; + +const settle = () => vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS); + +describe('startResumeSync', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('nudges the poll when the tab returns after a real absence', async () => { + const t = setup(); + t.hide(); + await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS); + t.show(); + expect(t.mx.retryImmediately).not.toHaveBeenCalled(); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + }); + + it('ignores a hide shorter than the minimum', async () => { + const t = setup(); + t.hide(); + await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS - 1); + t.show(); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.mx.retryImmediately).not.toHaveBeenCalled(); + }); + + it('handles pageshow only when restored from bfcache, and online always', async () => { + const t = setup(); + t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: false })); + await settle(); + expect(t.mx.retryImmediately).not.toHaveBeenCalled(); + t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true })); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); + t.win.dispatchEvent(new Event('online')); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); + }); + + it('debounces a burst of triggers into one nudge', async () => { + const t = setup(); + t.win.dispatchEvent(new Event('online')); + t.win.dispatchEvent(new Event('online')); + t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true })); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + }); + + it('does not restart when sync is healthy after the check window', async () => { + for (const state of ['PREPARED', 'SYNCING', 'CATCHUP']) { + const t = setup(state); + t.win.dispatchEvent(new Event('online')); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.restart).not.toHaveBeenCalled(); + t.stop(); + } + }); + + it('does one stop/start when sync is still unhealthy, never overlapping', async () => { + const t = setup('RECONNECTING'); + let release!: () => void; + t.restart.mockImplementation( + () => + new Promise((r) => { + release = r; + }) + ); + t.win.dispatchEvent(new Event('online')); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.mx.stopClient).toHaveBeenCalledTimes(1); + expect(t.restart).toHaveBeenCalledTimes(1); + // a trigger while the restart is in flight does nothing + t.win.dispatchEvent(new Event('online')); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + expect(t.restart).toHaveBeenCalledTimes(1); + release(); + await vi.advanceTimersByTimeAsync(0); + t.win.dispatchEvent(new Event('online')); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); + }); + + it('removes listeners and cancels pending work on dispose', async () => { + const t = setup('ERROR'); + t.win.dispatchEvent(new Event('online')); + await settle(); + t.stop(); + await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); + expect(t.restart).not.toHaveBeenCalled(); + t.win.dispatchEvent(new Event('online')); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/client/resumeSync.ts b/src/client/resumeSync.ts new file mode 100644 index 00000000..5f81547b --- /dev/null +++ b/src/client/resumeSync.ts @@ -0,0 +1,86 @@ +/** The slice of MatrixClient this module drives; a MatrixClient satisfies it. */ +export type SyncHost = { + getSyncState(): string | null; + retryImmediately(): boolean; + stopClient(): void; +}; + +// matrix-js-sdk SyncState values for a live sync loop. +const HEALTHY: ReadonlySet = new Set(['PREPARED', 'SYNCING', 'CATCHUP']); + +export const RESUME_DEBOUNCE_MS = 250; +export const MIN_HIDDEN_MS = 5000; +export const HEALTH_CHECK_MS = 3000; + +type ResumeSyncOptions = { + /** Event sources and visibility; default to the browser globals. */ + doc?: Pick; + win?: Pick; + /** Restart the client after stopClient; must resolve once startClient has been issued. */ + restart: (mx: Host) => Promise; +}; + +/** + * A backgrounded tab or PWA leaves the sync long-poll dead until its 30 s timeout. + * On return (visible after a real absence, pageshow from bfcache, online) nudge the + * poll with retryImmediately; if sync is still unhealthy after HEALTH_CHECK_MS, do one + * stop/start. Returns a disposer that removes every listener and timer. + */ +export const startResumeSync = ( + mx: Host, + { restart, doc = document, win = window }: ResumeSyncOptions +): (() => void) => { + let hiddenAt: number | undefined; + let debounce: ReturnType | undefined; + let healthCheck: ReturnType | undefined; + let restarting = false; + let disposed = false; + + const check = async () => { + healthCheck = undefined; + if (disposed || restarting || HEALTHY.has(mx.getSyncState())) return; + restarting = true; + try { + mx.stopClient(); + await restart(mx); + } finally { + restarting = false; + } + }; + + const resume = () => { + if (debounce !== undefined) clearTimeout(debounce); + debounce = setTimeout(() => { + debounce = undefined; + if (disposed || restarting || healthCheck !== undefined) return; + mx.retryImmediately(); + healthCheck = setTimeout(check, HEALTH_CHECK_MS); + }, RESUME_DEBOUNCE_MS); + }; + + const onVisibility = () => { + if (doc.visibilityState === 'hidden') { + hiddenAt = Date.now(); + return; + } + const away = hiddenAt === undefined ? Infinity : Date.now() - hiddenAt; + hiddenAt = undefined; + if (away >= MIN_HIDDEN_MS) resume(); + }; + const onPageShow = (e: Event) => { + if ((e as PageTransitionEvent).persisted) resume(); + }; + + doc.addEventListener('visibilitychange', onVisibility); + win.addEventListener('pageshow', onPageShow); + win.addEventListener('online', resume); + + return () => { + disposed = true; + if (debounce !== undefined) clearTimeout(debounce); + if (healthCheck !== undefined) clearTimeout(healthCheck); + doc.removeEventListener('visibilitychange', onVisibility); + win.removeEventListener('pageshow', onPageShow); + win.removeEventListener('online', resume); + }; +};