Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Void
33e5f0ebf7 client: nudge sync on resume so a backgrounded tab stops waiting out the 30 s poll
Some checks are pending
CLA Assistant / CLAssistant (pull_request_target) Waiting to run
Check PR title / lint (pull_request_target) Waiting to run
Build pull request / Build pull request (pull_request) Waiting to run
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 <noreply@anthropic.com>
2026-11-03 12:01:38 +00:00
95e24aadb5 Merge pull request 'scene: Phacelle bound says measured-not-proved; neutral creature draws nothing' (#8) from nibu/phacelle-7-neutral-creature into ratatoskr/estate-navigator 2026-11-03 11:37:05 +00:00
Nibu
5eb0a63caa scene: ES2016-safe hasOwnProperty, tighten undefined-bucket assertion, reflow bound comment
Some checks are pending
CLA Assistant / CLAssistant (pull_request_target) Waiting to run
Build pull request / Build pull request (pull_request) Waiting to run
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 08:59:54 +00:00
Void
5e79a1853a 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 <noreply@anthropic.com>
2026-11-02 18:29:34 +00:00
Void
190e2e724b Merge PR #7: Soundscape neutral read first, human kind subset (provisional) 2026-11-02 18:28:16 +00:00
Void
f9cf99faff Merge PR #6: Soundscape audio socket over Aqua activity frame (flag off, muted) 2026-11-02 18:28:16 +00:00
Nibu
fff46492ca scene: Phacelle bound says measured-not-proved; neutral creature draws nothing
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
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 <noreply@anthropic.com>
2026-11-02 17:09:42 +00:00
686f72bcf1 Merge pull request 'Background scene: two-pass WebGL2 terrain and world tree with login pull-back' (#1) from nibu/background-scene into ratatoskr/estate-navigator 2026-11-02 16:50:13 +00:00
Void
cc99a3439d soundscape: neutral read first; human kind subset (provisional) per grammar 5e2eaa9
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-02 15:46:03 +00:00
Nibu
1f4341dab6 scene: live reduced-motion, chunk-load failure, per-stage CultMath
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
- 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 <noreply@anthropic.com>
2026-11-02 14:43:33 +00:00
Void
c4c00d7127 soundscape: audio socket over Aqua's activity frame (flag off, muted, untested by ear)
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
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❓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 <noreply@anthropic.com>
2026-11-02 14:39:00 +00:00
Nibu
0ec34ff19f creatures: cite creature-projection-v2
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-02 10:14:52 +00:00
Nibu
8c7113bcaf creatures: cite the Bifrost creature-props contract
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-02 07:22:58 +00:00
Nibu
093230a2c1 Background scene: lint and type clean, integration contract
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-02 06:20:53 +00:00
Nibu
7a498584e6 Background scene: sky costs no noise; one sun term
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
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=<names> selects variants.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 08:08:50 +00:00
Nibu
b2d6d32cc4 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 <noreply@anthropic.com>
2026-11-01 06:37:01 +00:00
e377d9f722 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 <noreply@anthropic.com>
2026-10-31 21:43:40 +00:00
43 changed files with 4141 additions and 2 deletions

View file

@ -17,5 +17,9 @@
"hashRouter": {
"enabled": false,
"basename": "/"
},
"backgroundScene": {
"tier": "off"
}
}

19
harness/scene/README.md Normal file
View file

@ -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 <outdir> # esbuild bundle of entry.ts plus an index.html
node drive.cjs <outdir> compile # compile and link each stage, JSON report
node drive.cjs <outdir> shots <pngdir> # rest, mid pull-back and pulled-back screenshots
node drive.cjs <outdir> measure # GPU time per pass by timer query, two viewports, two levels
node drive.cjs <outdir> variants # the same with source patches (no erosion, no tree, value-only fbm)
node drive.cjs <outdir> 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.

35
harness/scene/bundle.cjs Normal file
View file

@ -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 <outdir> (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'),
'<!doctype html><meta charset=utf-8><body style="margin:0"><canvas id=c style="width:100vw;height:100vh;display:block"></canvas><script src=bundle.js></script>'
);
});

164
harness/scene/drive.cjs Normal file
View file

@ -0,0 +1,164 @@
// Drives the scene in headless Chromium (software GL unless the host has a GPU).
// usage: node drive.cjs <bundledir> <mode> [outdir] modes: compile | shots | measure
// Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report.
const { chromium } = require('playwright');
const path = require('path');
const [, , dir, mode, outdir = '.'] = process.argv;
(async () => {
const b = await chromium.launch({
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
],
});
const page = await b.newPage({ viewport: { width: 960, height: 540 } });
page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`));
await page.goto('file://' + path.resolve(dir, 'index.html'));
const report = { mode };
report.renderer = await page.evaluate(() => {
const g = document.createElement('canvas').getContext('webgl2');
const e = g.getExtension('WEBGL_debug_renderer_info');
return g.getParameter(e ? e.UNMASKED_RENDERER_WEBGL : g.RENDERER);
});
if (mode === 'compile') {
report.stages = await page.evaluate(() => {
const gl = document.createElement('canvas').getContext('webgl2');
const S = window.__scene;
return ['terrain', 'atmosphere', 'resolve', 'composite'].map((stage) => {
const t0 = performance.now();
try {
const p = S.linkStage(gl, stage);
return {
stage,
ok: true,
ms: +(performance.now() - t0).toFixed(1),
sourceBytes: S.fragmentSource(stage).length,
log: gl.getProgramInfoLog(p),
};
} catch (e) {
return {
stage,
ok: false,
ms: +(performance.now() - t0).toFixed(1),
log: String(e.log || e),
};
}
});
});
}
if (mode === 'phacelle') {
report.probe = await page.evaluate(() =>
window.__probePhacelle(
window.__scene.WORLD.erodeSide.slice(),
window.__scene.WORLD.erodeNorm,
4
)
);
}
if (mode === 'shots') {
await page.evaluate(() => {
const c = document.getElementById('c');
window.__s = window.__scene.createScene({
canvas: c,
unthrottled: true,
fixedLevel: window.__scene.LEVELS[0],
});
});
const wait = (ms) => page.waitForTimeout(ms);
await wait(4000);
await page.screenshot({ path: path.join(outdir, 'rest.png') });
await page.evaluate(() => window.__s.login());
await wait(3500);
await page.screenshot({ path: path.join(outdir, 'mid-pullback.png') });
await wait(9000);
await page.screenshot({ path: path.join(outdir, 'pulled-back.png') });
report.shots = ['rest.png', 'mid-pullback.png', 'pulled-back.png'];
}
if (mode === 'measure' || mode === 'variants') {
const VARIANTS = {
baseline: null,
// What a public value-only fbm would cost: the same octaves without the gradient work.
valueOnlyFbm: [
'float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }',
'float terrainFbm(vec2 xz) { float a = 1.0, fr = 1.0, v = 0.0; for (int i = 0; i < W_OCTAVES; i++) { v += a * cultmath_snoise(vec3(xz * W_FREQ * fr, 0.0)); fr *= W_LACUNARITY; a *= W_GAIN; } return v; }',
],
noErosion: ['#define W_ERODE_ENABLED 1', '#define W_ERODE_ENABLED 0'],
// Upper bound on what skipping sky rays would save: every upward ray misses at once.
noSkyMarch: [' bool hit = false;\n bool onTree = false;', ' if (rd.y > 0.0) { o_color = vec4(0.0); o_depth = W_FAR; return; }\n bool hit = false;\n bool onTree = false;'],
noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'],
};
const wanted = mode === "variants" ? (process.env.ONLY ? process.env.ONLY.split(",") : Object.keys(VARIANTS)) : ['baseline'];
const sizes = [
[960, 540],
[540, 1200],
];
report.runs = [];
for (const [w, h] of sizes) {
for (const [li, variant] of wanted.flatMap((v) =>
mode === 'variants'
? [[0, v]]
: [
[0, v],
[3, v],
]
)) {
await page.setViewportSize({ width: w, height: h });
const r = await page.evaluate(
({ li, rep }) =>
new Promise((res) => {
const rows = [];
const c = document.getElementById('c');
const s = window.__scene.createScene({
canvas: c,
unthrottled: true,
fixedLevel: window.__scene.LEVELS[li],
patchSource: rep
? (st, src) =>
st === 'terrain'
? rep[0].startsWith('#define')
? src.replace(/#define W_ERODE_ENABLED \d/, rep[1])
: src.split(rep[0]).join(rep[1])
: src
: undefined,
onStats: (st) => {
rows.push(st);
if (rows.length === 40) {
s.dispose();
const keep = rows.slice(10);
const med = (k) =>
keep.map((x) => x.passMs[k]).sort((a, b) => a - b)[keep.length >> 1];
res({
solid: keep[0].solidSize,
atm: keep[0].atmosphereSize,
medianMs: {
terrain: med('terrain'),
atmosphere: med('atmosphere'),
resolve: med('resolve'),
composite: med('composite'),
},
});
}
},
});
if (!s) res({ error: 'createScene returned null' });
}),
{ li, rep: VARIANTS[variant] }
);
report.runs.push({ viewport: [w, h], level: li, variant, ...r });
}
}
}
console.log(JSON.stringify(report, null, 1));
await b.close();
})().catch((e) => {
console.log('ERR', String(e).slice(0, 2000));
process.exit(1);
});

87
harness/scene/entry.ts Normal file
View file

@ -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<string, unknown>).__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<string, unknown>).__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 };
};

View file

@ -0,0 +1,69 @@
# Background scene
A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath
noise with a world tree at its centre, dusk atmosphere and clouds, and a camera that pulls back
from the tree on login. It is a self-contained module; nothing in the client imports it yet.
Integration (a canvas under the app, `startBackgroundScene`, `login()` when the session starts,
`dispose()`) is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel
are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure
callbacks and the creature sizing rule. Nothing has drawn a creature.
## Entry points
- `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's
door. The client config's `backgroundScene.tier` (`off` default, `still`, `low`, `full`) is the
feature flag and the ceiling; `tier.ts` lowers it for `saveData`, low memory or few cores. At
`off` nothing is imported and no canvas context is requested; otherwise `Scene.ts` loads on
demand, so it stays out of the main chunk; 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` 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` (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.
- `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`).
- `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL
`#define`s are generated from it; no number is written twice.
- `shaders/` pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA),
`composite.frag.glsl`, plus CultLib's vendored GLSL (`shaders/README.md`).
## How it renders
1. **Solid pass** (`terrain.frag.glsl`, 0.4-0.75 of canvas resolution): sphere trace of
`min(terrain, tree)`. Terrain height is `mask(r) * (A * fbm + E * phacelle)`, with
`cultmath_fbm_grad(...).w` and `cultmath_phacelle(...).cos.w`. It writes lit colour with
coverage and the hit distance (second render target).
2. **Atmosphere pass** (0.25-0.5 of canvas): closed-form exponential height fog plus a cloud
slab marched with a few jittered steps, both only up to the hit distance from pass 1.
3. **Resolve**: TAA on the atmosphere (Halton jitter, history reprojected by the pass-1 depth,
3x3 neighbourhood clamp, 1/8 blend; reset on resize, level change and wake).
4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES
tonemap, dither.
## The step bound, and what is not proven
The trace steps by `(y - h(x, z)) / sqrt(1 + G^2)`, `G` the Lipschitz bound of the height field,
computed from `SNOISE_LIPSCHITZ` (CultMath's constant for `snoise`) in `world.ts`. Above the
envelope (fbm plus the erosion amplitude) the step uses the envelope's bound alone and Phacelle is
not evaluated.
- The Phacelle gradient bound `phacelleGradBound` (7.0) is Libby's measured sup (6.63 at
|side| = 1.5, n = 0, CultLib PR #1 head 990521b3) plus margin: a measurement, not a proof.
The shader fixes roughness n = 0; a nonzero n needs the bound divided by (1 - n).
- The bound makes grazing rays crawl. A ray that exhausts its steps while still below the highest
terrain is ended as a distant surface (fog hides it). That is a visible approximation, not a
hit proven by the march.
- The low-resolution atmosphere samples pass-1 depth nearest, so silhouettes of near objects
carry a thin fog halo.
## Measured and not measured
See the pull request or `harness/scene/README.md` for the numbers and how they were taken. They
come from a software rasterizer, not a GPU, and nothing has been run on a phone.

View file

@ -0,0 +1,305 @@
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<string, Fn> = {};
const spy = (name: string, impl: () => unknown = () => ({})): Fn => {
if (!calls[name]) calls[name] = vi.fn(impl);
return calls[name];
};
const gl = new Proxy({} as Record<string, unknown>, {
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 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: () => motion,
});
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<typeof fakeCanvas>) => 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('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();
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('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();
const scene = await startBackgroundScene(
{ tier: 'full' },
{},
{ canvas: asCanvas(fakeCanvas(gl)), onCompileFail }
);
expect(scene).toBeNull();
expect(onCompileFail).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,476 @@
import { fragmentSource, vertexSource, Stage } from './shaderSource';
import { cameraPose, pullProgress, CameraPose } from './camera';
import { BudgetLadder, Level, LEVELS } from './budget';
import type { SceneTier } from './tier';
import { PALETTE } from './palette';
/** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */
export const GPU_BUDGET_MS = 4.0;
const FRAME_INTERVAL_MS = 1000 / 30;
const REDUCED_MOTION_FRAMES = 32;
const MAX_DPR = 1.5;
export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite';
const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite'];
export interface SceneStats {
/** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */
passMs: Record<PassName, number>;
level: number;
solidSize: [number, number];
atmosphereSize: [number, number];
}
/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */
export type SourcePatch = (stage: Stage, source: string) => string;
export class ShaderCompileError extends Error {
constructor(readonly stage: Stage | 'vertex', readonly log: string) {
super(`${stage} shader failed to compile:\n${log}`);
}
}
/** Ladder level a 'low' scene starts at: the cost of a weak device's first 45 frames is never paid at full. */
const LOW_START_LEVEL = 2;
/** The 'still' tier draws one frame at this level and stops. */
const STILL_LEVEL = LEVELS[0];
export interface SceneOptions {
canvas: HTMLCanvasElement;
/**
* 'off' returns null without touching the canvas. 'still' draws one frame and stops. 'low' starts
* the adaptive ladder low. 'full' starts it at the top. Default 'full'.
*/
tier?: SceneTier;
/** A shader failed to compile or link. createScene frees what it built and returns null. */
onCompileFail?: (e: ShaderCompileError) => void;
/** The WebGL context was lost. The scene stops for good; the host keeps its ground. */
onContextLost?: () => void;
/** Called per finished frame when timer queries exist. */
onStats?: (s: SceneStats) => void;
/** Weather knob, 0 clear .. 1 overcast. */
coverage?: number;
/** Fixed level instead of the adaptive ladder (measurement). */
fixedLevel?: Level;
/** Ignore the 30 fps cap and the visibility pause (measurement). */
unthrottled?: boolean;
patchSource?: SourcePatch;
}
export interface Scene {
/** Start the camera pull-back from the tree. */
login(): void;
dispose(): void;
}
function compile(
gl: WebGL2RenderingContext,
type: number,
source: string,
stage: Stage | 'vertex'
): WebGLShader {
const sh = gl.createShader(type)!;
gl.shaderSource(sh, source);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(sh) ?? '';
gl.deleteShader(sh);
throw new ShaderCompileError(stage, log);
}
return sh;
}
export function linkStage(
gl: WebGL2RenderingContext,
stage: Stage,
patch?: SourcePatch
): WebGLProgram {
const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex');
const fs = compile(
gl,
gl.FRAGMENT_SHADER,
patch ? patch(stage, fragmentSource(stage)) : fragmentSource(stage),
stage
);
const prog = gl.createProgram()!;
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(prog) ?? '';
gl.deleteProgram(prog);
throw new ShaderCompileError(stage, log);
}
return prog;
}
interface Target {
tex: WebGLTexture;
w: number;
h: number;
}
function halton(i: number, base: number): number {
let f = 1;
let r = 0;
let n = i;
while (n > 0) {
f /= base;
r += f * (n % base);
n = Math.floor(n / base);
}
return r;
}
/** Null when WebGL2 or float colour buffers are missing; the host keeps its CSS ground. */
export function createScene(opts: SceneOptions): Scene | null {
const { canvas } = opts;
const tier = opts.tier ?? 'full';
if (tier === 'off') return null;
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
depth: false,
stencil: false,
powerPreference: 'low-power',
});
if (!gl || !gl.getExtension('EXT_color_buffer_float')) return null;
gl.getExtension('OES_texture_float_linear');
const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2') as {
TIME_ELAPSED_EXT: number;
GPU_DISJOINT_EXT: number;
} | null;
const programs = {} as Record<PassName, WebGLProgram>;
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<WebGLProgram, Map<string, WebGLUniformLocation | null>>();
const loc = (p: WebGLProgram, name: string) => {
let m = uniforms.get(p);
if (!m) uniforms.set(p, (m = new Map()));
if (!m.has(name)) m.set(name, gl.getUniformLocation(p, name));
return m.get(name) ?? null;
};
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const makeTarget = (w: number, h: number, format: number, filter: number): Target => {
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return { tex, w, h };
};
const fbos = {
solid: gl.createFramebuffer()!,
atm: gl.createFramebuffer()!,
hist: [gl.createFramebuffer()!, gl.createFramebuffer()!],
};
let solid: Target;
let depth: Target;
let atm: Target;
let hist: [Target, Target];
const freeTargets = () => {
[solid, depth, atm, hist?.[0], hist?.[1]].forEach((t) => {
if (t) gl.deleteTexture(t.tex);
});
};
const attach = (fbo: WebGLFramebuffer, targets: Target[]) => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
targets.forEach((t, i) =>
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, t.tex, 0)
);
gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i));
};
const still = tier === 'still';
const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0);
const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current);
let canvasW = 0;
let canvasH = 0;
let sizedFor: Level | undefined;
let resetHistory = true;
const ensureSize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
const w = Math.max(2, Math.round(canvas.clientWidth * dpr));
const h = Math.max(2, Math.round(canvas.clientHeight * dpr));
const lv = level();
if (w === canvasW && h === canvasH && lv === sizedFor) return;
canvasW = w;
canvasH = h;
sizedFor = lv;
canvas.width = w;
canvas.height = h;
freeTargets();
const sw = Math.max(2, Math.round(w * lv.solidScale));
const sh = Math.max(2, Math.round(h * lv.solidScale));
const aw = Math.max(2, Math.round(w * lv.atmosphereScale));
const ah = Math.max(2, Math.round(h * lv.atmosphereScale));
solid = makeTarget(sw, sh, gl.RGBA16F, gl.LINEAR);
depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST);
atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR);
hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)];
attach(fbos.solid, [solid, depth]);
attach(fbos.atm, [atm]);
attach(fbos.hist[0], [hist[0]]);
attach(fbos.hist[1], [hist[1]]);
resetHistory = true;
};
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
const start = performance.now();
let loginAt: number | undefined;
let frame = 0;
let histIndex = 0;
let prev: CameraPose | undefined;
let raf = 0;
let lastRendered = -Infinity;
let framesLeft = still ? 1 : Infinity; // a still frame, or reduced motion, bounds this
let disposed = false;
// Timer queries in flight: each holds one pass's query.
const pending: Array<{ q: WebGLQuery; pass: PassName; frame: number }> = [];
const finished = new Map<number, Partial<Record<PassName, number>>>();
const timed = (pass: PassName, f: number, body: () => void) => {
if (!timer || !opts.onStats) {
body();
return;
}
const q = gl.createQuery()!;
gl.beginQuery(timer.TIME_ELAPSED_EXT, q);
body();
gl.endQuery(timer.TIME_ELAPSED_EXT);
pending.push({ q, pass, frame: f });
};
const collect = () => {
if (!timer) return;
while (pending.length && gl.getQueryParameter(pending[0].q, gl.QUERY_RESULT_AVAILABLE)) {
const { q, pass, frame: f } = pending.shift()!;
const disjoint = gl.getParameter(timer.GPU_DISJOINT_EXT);
const ns = gl.getQueryParameter(q, gl.QUERY_RESULT) as number;
gl.deleteQuery(q);
const row = finished.get(f) ?? {};
if (!disjoint) row[pass] = ns / 1e6;
finished.set(f, row);
if (Object.keys(row).length === PASSES.length) {
finished.delete(f);
const passMs = row as Record<PassName, number>;
const total = PASSES.reduce((s, p) => s + passMs[p], 0);
if (!opts.fixedLevel && ladder.push(total)) sizedFor = undefined;
opts.onStats?.({
passMs,
level: ladder.index,
solidSize: [solid.w, solid.h],
atmosphereSize: [atm.w, atm.h],
});
}
}
};
const setCommon = (p: WebGLProgram, pose: CameraPose, time: number) => {
const aspect = canvasW / canvasH;
const th = Math.tan(pose.fov / 2);
gl.uniform3fv(loc(p, 'u_camPos'), pose.position as unknown as number[]);
gl.uniformMatrix3fv(loc(p, 'u_camBasis'), false, pose.basis as unknown as number[]);
gl.uniform2f(loc(p, 'u_tanHalf'), th * aspect, th);
gl.uniform1f(loc(p, 'u_time'), time);
gl.uniform3fv(loc(p, 'u_sunDir'), PALETTE.sunDir as unknown as number[]);
gl.uniform3fv(loc(p, 'u_sunColor'), PALETTE.sunColor as unknown as number[]);
gl.uniform3fv(loc(p, 'u_skyTop'), PALETTE.skyTop as unknown as number[]);
gl.uniform3fv(loc(p, 'u_skyHorizon'), PALETTE.skyHorizon as unknown as number[]);
gl.uniform3fv(loc(p, 'u_fogColor'), PALETTE.fogColor as unknown as number[]);
};
const bindTex = (unit: number, t: Target, p: WebGLProgram, name: string) => {
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, t.tex);
gl.uniform1i(loc(p, name), unit);
};
const draw = () => gl.drawArrays(gl.TRIANGLES, 0, 3);
const render = (now: number) => {
ensureSize();
const lv = level();
const frozen = reducedMotion?.matches ?? false;
const time = frozen ? 0 : (now - start) / 1000;
let elapsed: number | undefined;
if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000;
const pose = cameraPose(pullProgress(elapsed), time);
const f = frame;
frame += 1;
const jx = halton((f % 16) + 1, 2) - 0.5;
const jy = halton((f % 16) + 1, 3) - 0.5;
// Pass 1: solid surfaces, writes colour and depth.
timed('terrain', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid);
gl.viewport(0, 0, solid.w, solid.h);
const p = programs.terrain;
gl.useProgram(p);
setCommon(p, pose, time);
gl.uniform3fv(loc(p, 'u_grass'), PALETTE.grass as unknown as number[]);
gl.uniform3fv(loc(p, 'u_rock'), PALETTE.rock as unknown as number[]);
gl.uniform3fv(loc(p, 'u_bark'), PALETTE.bark as unknown as number[]);
gl.uniform3fv(loc(p, 'u_leaf'), PALETTE.leaf as unknown as number[]);
gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps);
draw();
});
// Pass 2: atmosphere up to pass 1's depth, low-res.
timed('atmosphere', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.atm);
gl.viewport(0, 0, atm.w, atm.h);
const p = programs.atmosphere;
gl.useProgram(p);
setCommon(p, pose, time);
bindTex(0, depth, p, 'u_depth');
gl.uniform1i(loc(p, 'u_steps'), lv.cloudSteps);
gl.uniform1f(loc(p, 'u_frame'), f);
gl.uniform2f(loc(p, 'u_jitter'), jx, jy);
gl.uniform2f(loc(p, 'u_size'), atm.w, atm.h);
gl.uniform1f(loc(p, 'u_coverage'), opts.coverage ?? 0.45);
draw();
});
// TAA resolve into the history that is not being read.
const write = histIndex === 0 ? 1 : 0;
timed('resolve', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]);
gl.viewport(0, 0, atm.w, atm.h);
const p = programs.resolve;
gl.useProgram(p);
setCommon(p, pose, time);
bindTex(0, atm, p, 'u_cur');
bindTex(1, hist[histIndex], p, 'u_history');
bindTex(2, depth, p, 'u_depth');
const pp = prev ?? pose;
const th = Math.tan(pp.fov / 2);
gl.uniform3fv(loc(p, 'u_prevPos'), pp.position as unknown as number[]);
gl.uniformMatrix3fv(loc(p, 'u_prevBasis'), false, pp.basis as unknown as number[]);
gl.uniform2f(loc(p, 'u_prevTanHalf'), th * (canvasW / canvasH), th);
gl.uniform1f(loc(p, 'u_blend'), resetHistory ? 1 : 0.125);
gl.uniform2f(loc(p, 'u_curSize'), atm.w, atm.h);
draw();
});
// Full-resolution composite to the canvas.
timed('composite', f, () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, canvasW, canvasH);
const p = programs.composite;
gl.useProgram(p);
setCommon(p, pose, time);
bindTex(0, solid, p, 'u_solid');
bindTex(1, hist[write], p, 'u_atm');
gl.uniform1f(loc(p, 'u_frame'), f);
draw();
});
histIndex = write;
prev = pose;
resetHistory = false;
collect();
};
const tick = (now: number) => {
raf = 0;
if (disposed) return;
const due = opts.unthrottled || now - lastRendered >= FRAME_INTERVAL_MS - 2;
if (due) {
if (
!opts.fixedLevel &&
!timer &&
lastRendered > 0 &&
now - lastRendered > FRAME_INTERVAL_MS * 1.5
) {
// no timer: a missed cap counts as over budget
if (ladder.push(GPU_BUDGET_MS * 2)) sizedFor = undefined;
}
lastRendered = now;
render(now);
framesLeft -= 1;
}
if (framesLeft > 0 && (opts.unthrottled || document.visibilityState === 'visible'))
raf = requestAnimationFrame(tick);
};
const wake = () => {
if (disposed || raf) return;
if (reducedMotion?.matches && framesLeft <= 0) return;
resetHistory = true; // the first frame after a pause has a real elapsed dt
raf = requestAnimationFrame(tick);
};
const undo: Array<() => void> = [];
const listen = (target: EventTarget, type: string, handler: (e: Event) => void) => {
target.addEventListener(type, handler);
undo.push(() => target.removeEventListener(type, handler));
};
const stop = () => {
disposed = true;
if (raf) cancelAnimationFrame(raf);
undo.forEach((f) => f());
};
if (!opts.unthrottled) {
listen(document, 'visibilitychange', () => {
if (document.visibilityState === 'visible') wake();
});
}
listen(window, 'resize', () => wake());
// A lost context takes every texture and program with it and nothing here rebuilds them, so the
// scene ends and the host decides what to show. preventDefault: otherwise the browser never
// restores the context, which is what we want, and a restore would find a dead scene.
listen(canvas, 'webglcontextlost', (e) => {
e.preventDefault();
stop();
opts.onContextLost?.();
});
if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES;
// 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 {
login() {
if (still || disposed) return; // a still has no pull-back
loginAt = performance.now();
if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES;
wake();
},
dispose() {
if (disposed) return;
stop();
freeTargets();
PASSES.forEach((p) => gl.deleteProgram(programs[p]));
gl.deleteVertexArray(vao);
[fbos.solid, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb));
},
};
}

View file

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { BudgetLadder, LEVELS } from './budget';
describe('budget ladder', () => {
it('steps down after a whole window over budget and never back up', () => {
const ladder = new BudgetLadder(4, 5);
Array.from({ length: 4 }).forEach(() => expect(ladder.push(9)).toBe(false));
expect(ladder.push(9)).toBe(true);
expect(ladder.index).toBe(1);
Array.from({ length: 100 }).forEach(() => ladder.push(0.1)); // fast frames never raise the level
expect(ladder.index).toBe(1);
});
it('judges the median, so one slow frame does not step down', () => {
const ladder = new BudgetLadder(4, 5);
[1, 1, 50, 1, 1].forEach((ms) => ladder.push(ms));
expect(ladder.index).toBe(0);
});
it('stops at the last level', () => {
const ladder = new BudgetLadder(4, 1);
Array.from({ length: 20 }).forEach(() => ladder.push(99));
expect(ladder.index).toBe(LEVELS.length - 1);
});
it('every level is cheaper than the one before in each cost axis', () => {
LEVELS.slice(1).forEach((lv, i) => {
const before = LEVELS[i];
expect(lv.solidScale).toBeLessThan(before.solidScale);
expect(lv.atmosphereScale).toBeLessThan(before.atmosphereScale);
expect(lv.cloudSteps).toBeLessThan(before.cloudSteps);
expect(lv.traceSteps).toBeLessThan(before.traceSteps);
});
});
it('starts at the level it is given, clamped into the ladder', () => {
expect(new BudgetLadder(4, 5, 2).index).toBe(2);
expect(new BudgetLadder(4, 5, 99).index).toBe(LEVELS.length - 1);
expect(new BudgetLadder(4, 5, -3).index).toBe(0);
});
});

View file

@ -0,0 +1,59 @@
// The adaptive step-down. The ladder only descends within a session; nothing here ever steps up.
export interface Level {
/** Pass 1 resolution as a fraction of the canvas. */
solidScale: number;
/** Pass 2 resolution as a fraction of the canvas. */
atmosphereScale: number;
/** Cloud march steps. */
cloudSteps: number;
/** Sphere-trace step cap. */
traceSteps: number;
}
export const LEVELS: readonly Level[] = [
{ solidScale: 0.75, atmosphereScale: 0.5, cloudSteps: 12, traceSteps: 96 },
{ solidScale: 0.6, atmosphereScale: 0.4, cloudSteps: 8, traceSteps: 80 },
{ solidScale: 0.5, atmosphereScale: 0.33, cloudSteps: 6, traceSteps: 64 },
{ solidScale: 0.4, atmosphereScale: 0.25, cloudSteps: 4, traceSteps: 48 },
];
/** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */
export class BudgetLadder {
private level: number;
private recent: number[] = [];
private readonly budgetMs: number;
private readonly window: number;
/** `startLevel` lets a weak device begin below level 0; it is clamped into the ladder. */
constructor(budgetMs: number, window = 45, startLevel = 0) {
this.budgetMs = budgetMs;
this.window = window;
this.level = Math.min(Math.max(startLevel, 0), LEVELS.length - 1);
}
get current(): Level {
return LEVELS[this.level];
}
get index(): number {
return this.level;
}
/** Feed one frame's GPU (or cadence) time. Returns true when the level changed. */
push(ms: number): boolean {
this.recent.push(ms);
if (this.recent.length < this.window) return false;
const sorted = [...this.recent].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
this.recent = [];
if (median > this.budgetMs && this.level < LEVELS.length - 1) {
this.level += 1;
return true;
}
return false;
}
}

View file

@ -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);
});
});

View file

@ -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),
};
}

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { BUCKET_SCALE, creatureScale, 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('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('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 })).toBeNull();
});
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();
});
});

View file

@ -0,0 +1,37 @@
// Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut.
//
// Props contract: heimdall-body:follow_up:ratatoskr-creature-projection-v2 (against GameCult/Bifrost; supersedes heimdall-body/ratatoskr-creature-projection).
// Nothing here is binding until Bifrost's steward cuts that contract.
//
// Shape: {id, estate, neutral, activity} plus sizeBucket when neutral is false.
// Invariants:
// - Exact vote weights stay in Bifrost; Ratatoskr only ever sees a bucket.
// - 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.
export type SizeBucket = 'small' | 'medium' | 'large';
export interface Creature {
id: string;
estate: string;
/** Absent when neutral. Read it only through creatureScale. */
sizeBucket?: SizeBucket;
neutral: boolean;
activity: 'idle' | 'active';
}
export const BUCKET_SCALE: Readonly<Record<SizeBucket, number>> = {
small: 0.7,
medium: 1,
large: 1.4,
};
/** 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<Creature, 'sizeBucket' | 'neutral'>): number | null {
if (c.neutral || c.sizeBucket === undefined) return null;
return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null;
}

View file

@ -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'));
});
});

View file

@ -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<string, Decl[]>();
decls.forEach((d) => byName.set(d.name, [...(byName.get(d.name) ?? []), d]));
const kept = new Set<Decl>();
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}`;
}

View file

@ -0,0 +1,33 @@
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<SceneOptions, 'tier' | 'patchSource' | 'onCompileFail'> & {
/** 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", 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,
capabilities: Capabilities,
options: StartOptions
): Promise<Scene | null> {
const tier = resolveTier(config, capabilities);
if (tier === 'off') return null;
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 });
}

View file

@ -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;

View file

@ -0,0 +1,34 @@
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';
import { trimGlsl } from './cultmathTrim';
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<Stage, string> = {
terrain: terrainFrag,
atmosphere: atmosphereFrag,
resolve: resolveFrag,
composite: compositeFrag,
};
/**
* 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 : '';
const math = trimGlsl(cultmath, [erosion, common, stages[stage]]);
return `${HEADER}${worldDefines()}\n${math}\n${erosion}\n${common}\n${stages[stage]}`;
}

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
# 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.
`../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.

View file

@ -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);
}

View file

@ -0,0 +1,31 @@
// Shared by every fragment stage; concatenated after the prelude, CultMath and the world defines.
uniform vec3 u_camPos;
uniform mat3 u_camBasis; // columns: right, up, forward
uniform vec2 u_tanHalf; // x: tan(fov/2) * aspect, y: tan(fov/2)
uniform float u_time;
uniform vec3 u_sunDir; // unit, towards the sun
uniform vec3 u_sunColor;
uniform vec3 u_skyTop;
uniform vec3 u_skyHorizon;
uniform vec3 u_fogColor;
vec3 camRay(vec2 uv)
{
vec2 ndc = uv * 2.0 - 1.0;
return normalize(u_camBasis * vec3(ndc * u_tanHalf, 1.0));
}
float ign(vec2 p, float frame)
{
p += 5.588238 * mod(frame, 64.0);
return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715))));
}
vec3 skyColor(vec3 rd)
{
float h = clamp(rd.y, 0.0, 1.0);
vec3 c = mix(u_skyHorizon, u_skyTop, pow(h, 0.45));
float s = max(dot(rd, u_sunDir), 0.0);
c += u_sunColor * (1.5 * pow(s, 256.0));
return c;
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -0,0 +1,184 @@
// Pass 1: solid surfaces (terrain + world tree), sphere-traced. Writes lit colour with coverage
// and, separately, the hit distance that bounds pass 2.
uniform vec3 u_grass;
uniform vec3 u_rock;
uniform vec3 u_bark;
uniform vec3 u_leaf;
uniform int u_maxSteps;
in vec2 v_uv;
layout(location = 0) out vec4 o_color; // rgb lit, a coverage
layout(location = 1) out float o_depth; // hit distance along the ray, W_FAR on a miss
// ---- terrain -------------------------------------------------------------------------------
float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); }
// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap.
float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }
#if W_ERODE_ENABLED
CultPhasor terrainPhacelle(vec2 xz)
{
// A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion.
return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM);
}
#endif
// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the
// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated.
float terrainDistance(vec3 p)
{
float m = terrainMask(p.xz);
// Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most
// of their steps here.
if (m <= 0.0)
return p.y * W_SLOPE_ENV_INV;
float hb = m * W_AMP * terrainFbm(p.xz);
float above = p.y - (hb + m * W_ERODE_AMP);
if (above > 0.0)
return above * W_SLOPE_ENV_INV;
#if W_ERODE_ENABLED
hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w;
#endif
return (p.y - hb) * W_SLOPE_FULL_INV;
}
vec3 terrainNormal(vec2 xz)
{
float r = length(xz);
float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r);
float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0);
float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0;
vec2 dir = r > 0.0 ? xz / r : vec2(0.0);
vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN);
float f = fg.w;
vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir;
#if W_ERODE_ENABLED
CultPhasor ph = terrainPhacelle(xz);
g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir;
#endif
return normalize(vec3(-g.x, 1.0, -g.y));
}
// ---- world tree ----------------------------------------------------------------------------
float smin(float a, float b, float k)
{
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz.
float treeDistance(vec3 p, out float leafness)
{
vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0);
float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y);
leafness = 0.0;
if (bound > 8.0)
return bound;
float ang = atan(p.z, p.x);
float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0);
float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15));
float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7;
vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R;
float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0));
float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp;
canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L)
float d = smin(trunk, canopy, 6.0);
leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0);
return d;
}
// ---- trace ---------------------------------------------------------------------------------
float sceneDistance(vec3 p, out float leafness, out bool onTree)
{
float dt = terrainDistance(p);
float dr = treeDistance(p, leafness);
onTree = dr < dt;
return min(dt, dr);
}
void main()
{
vec3 ro = u_camPos;
vec3 rd = camRay(v_uv);
float t = 0.0;
// Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright.
if (ro.y > W_MAX_HEIGHT)
{
if (rd.y >= 0.0)
{
o_color = vec4(0.0);
o_depth = W_FAR;
return;
}
t = (W_MAX_HEIGHT - ro.y) / rd.y;
}
bool hit = false;
bool onTree = false;
float leafness = 0.0;
int i = 0;
for (; i < u_maxSteps; i++)
{
vec3 p = ro + rd * t;
float d = sceneDistance(p, leafness, onTree);
if (d < 0.0006 * t + 0.01)
{
hit = true;
break;
}
t += d;
if (t > W_FAR || (p.y > W_MAX_HEIGHT && rd.y > 0.0))
break;
}
// The conservative bound makes grazing rays crawl: a ray that ran out of steps while still
// below the highest terrain is a distant surface under thick fog, so it ends there.
if (!hit && i == u_maxSteps && t < W_FAR)
{
hit = true;
sceneDistance(ro + rd * t, leafness, onTree);
}
if (!hit)
{
o_color = vec4(0.0);
o_depth = W_FAR;
return;
}
vec3 p = ro + rd * t;
vec3 n;
vec3 albedo;
if (onTree)
{
float e = 0.35;
float l;
n = normalize(vec3(
treeDistance(p + vec3(e, 0.0, 0.0), l) - treeDistance(p - vec3(e, 0.0, 0.0), l),
treeDistance(p + vec3(0.0, e, 0.0), l) - treeDistance(p - vec3(0.0, e, 0.0), l),
treeDistance(p + vec3(0.0, 0.0, e), l) - treeDistance(p - vec3(0.0, 0.0, e), l)));
float speckle = 0.75 + 0.25 * cultmath_snoise(p * 0.8);
albedo = mix(u_bark, u_leaf * speckle, leafness);
}
else
{
n = terrainNormal(p.xz);
float rockness = smoothstep(0.78, 0.55, n.y);
albedo = mix(u_grass, u_rock, rockness);
}
float ndl = dot(n, u_sunDir);
float wrap = clamp(ndl * 0.6 + 0.4, 0.0, 1.0); // half-lambert: no shadow ray in the cheap pass
vec3 ambient = mix(u_skyHorizon, u_skyTop, 0.5 + 0.5 * n.y) * 0.45;
vec3 col = albedo * (u_sunColor * wrap * wrap + ambient);
o_color = vec4(col, 1.0);
o_depth = t;
}

View file

@ -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');
});
});

View file

@ -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;
}

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import common from './shaders/common.glsl?raw';
import terrain from './shaders/terrain.frag.glsl?raw';
import atmosphere from './shaders/atmosphere.frag.glsl?raw';
import resolve from './shaders/resolve.frag.glsl?raw';
import composite from './shaders/composite.frag.glsl?raw';
import { terrainBounds, WORLD, worldDefines } from './world';
describe('world defines', () => {
it('define every W_ name any scene shader uses', () => {
const defined = new Set([...worldDefines().matchAll(/#define (W_\w+)/g)].map((m) => m[1]));
[common, terrain, atmosphere, resolve, composite]
.flatMap((src) => [...src.matchAll(/\bW_[A-Z0-9_]+\b/g)])
.forEach((m) => expect(defined, m[0]).toContain(m[0]));
});
it('emit only valid GLSL numbers', () => {
expect(worldDefines()).not.toMatch(/NaN|Infinity|undefined/);
});
});
describe('terrain step bound', () => {
it('is more conservative with erosion than without, and without erosion carries no phacelle term', () => {
const b = terrainBounds();
expect(b.slopeFull).toBeGreaterThan(b.slopeEnvelope);
const off = terrainBounds({ ...WORLD, phacelleGradBound: 0 });
expect(off.slopeFull).toBe(off.slopeEnvelope);
});
it('puts every surface under the height the tracer skips to', () => {
const maxHeight = Number(/#define W_MAX_HEIGHT (\S+)/.exec(worldDefines())![1]);
expect(maxHeight).toBeGreaterThanOrEqual(terrainBounds().maxHeight);
expect(maxHeight).toBeGreaterThanOrEqual(WORLD.tree.canopyY + WORLD.tree.canopyRadius[1]);
});
it('leaves the erosion bound turned on only when a bound exists', () => {
expect(worldDefines()).toContain('#define W_ERODE_ENABLED 1');
expect(worldDefines({ ...WORLD, phacelleGradBound: 0 })).toContain('#define W_ERODE_ENABLED 0');
});
});

View file

@ -0,0 +1,117 @@
// The scene's world constants. One owner: the shader prelude below is generated from this
// object, so a number lives here and nowhere else, and the Lipschitz bounds that make the
// sphere-trace step safe are computed beside the numbers they bound.
// Lipschitz constant of cultmath_snoise(vec3), CULTMATH_SNOISE_LIPSCHITZ in the vendored
// CultMath.glsl (provenance in CultLib math.Interval.cs).
export const SNOISE_LIPSCHITZ = 7.9640074;
export const WORLD = {
far: 1400,
// Terrain: height = mask(r) * (amp * fbm(xz * freq) + erodeAmp * phacelle(xz * erodeFreq)).
amp: 70,
freq: 1 / 520,
octaves: 4,
lacunarity: 2.0,
gain: 0.45,
// Flat ground (height 0) out to plateauInner, full terrain from plateauOuter. The login
// camera stays inside plateauInner, so it can never end up inside the terrain.
plateauInner: 135,
plateauOuter: 380,
erodeAmp: 2.5,
erodeFreq: 1 / 38,
// The stripe wave vector and normalization the terrain feeds cultmath_phacelle.
erodeSide: [1.7, 0, 1] as const,
erodeNorm: 0.5,
// Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a
// sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin.
// The 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,
// 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');
}

View file

@ -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);

View file

@ -0,0 +1,108 @@
// 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
// 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';
export type Energy = 'Low' | 'Mid' | 'High' | 'Peak';
export type Size = 'S' | 'M' | 'L';
export type Override = 'None' | 'Muted';
interface FrameBase {
mind: string;
estate: Estate;
override: Override;
seq: number;
/** Producer observation time, whole seconds (grammar: 1 s resolution). */
at: number;
}
/** 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;
}
export type ActivityFrame = NeutralFrame | LiveFrame;
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 = <T extends string>(set: readonly T[], v: unknown): T | undefined =>
set.find((s) => s === v);
/** 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).
* `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 => {
if (typeof raw !== 'object' || raw === null) return undefined;
const r = raw as Record<string, unknown>;
if (typeof r.neutral !== 'boolean') return undefined;
const estate = oneOf(ESTATES, r.estate);
const override =
r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override);
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, 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);
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). */
export class FrameTable {
private frames = new Map<string, ActivityFrame>();
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);
}
}

View file

@ -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<Size, number> = { 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);

View file

@ -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,
};
};

View file

@ -0,0 +1,233 @@
import { describe, expect, it, vi } from 'vitest';
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<LiveFrame> = {}): LiveFrame => ({
mind: 'void',
estate: 'mind',
kind: 'Working',
energy: 'Mid',
size: 'M',
neutral: false,
override: 'None',
seq: 1,
at: 100,
...o,
});
const neutralFrame = (o: Partial<ActivityFrame> = {}): 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 };
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) => neutralFrame({ 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('neutral is read first', () => {
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' }),
neutral: true,
});
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');
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');
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);
});
});
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();
});
});

View file

@ -0,0 +1,51 @@
import type { ActivityFrame, Energy, Kind, LiveFrame } 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<Energy, number> = { Low: 0, Mid: 1, High: 2, Peak: 3 };
/** Grammar section 3: Idle and Waiting default silent. */
const SILENT_KINDS: ReadonlySet<Kind> = new Set<Kind>(['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 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 }));
};

View file

@ -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<ClientConfig | null>(null);

View file

@ -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 = () => {

View file

@ -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<MatrixClient> => {
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) => {

View file

@ -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<void>((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);
});
});

86
src/client/resumeSync.ts Normal file
View file

@ -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<string | null> = 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<Host> = {
/** Event sources and visibility; default to the browser globals. */
doc?: Pick<Document, 'addEventListener' | 'removeEventListener' | 'visibilityState'>;
win?: Pick<Window, 'addEventListener' | 'removeEventListener'>;
/** Restart the client after stopClient; must resolve once startClient has been issued. */
restart: (mx: Host) => Promise<void>;
};
/**
* 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 = <Host extends SyncHost>(
mx: Host,
{ restart, doc = document, win = window }: ResumeSyncOptions<Host>
): (() => void) => {
let hiddenAt: number | undefined;
let debounce: ReturnType<typeof setTimeout> | undefined;
let healthCheck: ReturnType<typeof setTimeout> | 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);
};
};