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
This commit is contained in:
commit
686f72bcf1
33 changed files with 3441 additions and 0 deletions
|
|
@ -17,5 +17,9 @@
|
|||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
},
|
||||
|
||||
"backgroundScene": {
|
||||
"tier": "off"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
harness/scene/README.md
Normal file
19
harness/scene/README.md
Normal 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
35
harness/scene/bundle.cjs
Normal 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
164
harness/scene/drive.cjs
Normal 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
87
harness/scene/entry.ts
Normal 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 };
|
||||
};
|
||||
69
src/app/features/scene/README.md
Normal file
69
src/app/features/scene/README.md
Normal 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.
|
||||
305
src/app/features/scene/Scene.test.ts
Normal file
305
src/app/features/scene/Scene.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
476
src/app/features/scene/Scene.ts
Normal file
476
src/app/features/scene/Scene.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
}
|
||||
41
src/app/features/scene/budget.test.ts
Normal file
41
src/app/features/scene/budget.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
59
src/app/features/scene/budget.ts
Normal file
59
src/app/features/scene/budget.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
42
src/app/features/scene/camera.test.ts
Normal file
42
src/app/features/scene/camera.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
56
src/app/features/scene/camera.ts
Normal file
56
src/app/features/scene/camera.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
23
src/app/features/scene/creatures.test.ts
Normal file
23
src/app/features/scene/creatures.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { BUCKET_SCALE, creatureScale, NEUTRAL_SCALE, SizeBucket } from './creatures';
|
||||
|
||||
const buckets: SizeBucket[] = ['small', 'medium', 'large'];
|
||||
|
||||
describe('creature scale', () => {
|
||||
it('follows sizeBucket for a creature with an estate', () => {
|
||||
buckets.forEach((sizeBucket) =>
|
||||
expect(creatureScale({ sizeBucket, neutral: false })).toBe(BUCKET_SCALE[sizeBucket])
|
||||
);
|
||||
});
|
||||
|
||||
it('is one fixed size for every neutral creature, whatever its sizeBucket', () => {
|
||||
buckets.forEach((sizeBucket) =>
|
||||
expect(creatureScale({ sizeBucket, neutral: true })).toBe(NEUTRAL_SCALE)
|
||||
);
|
||||
});
|
||||
|
||||
it('is only visible as an override where the bucket would have said something else', () => {
|
||||
expect(BUCKET_SCALE.small).not.toBe(NEUTRAL_SCALE);
|
||||
expect(BUCKET_SCALE.large).not.toBe(NEUTRAL_SCALE);
|
||||
});
|
||||
});
|
||||
37
src/app/features/scene/creatures.ts
Normal file
37
src/app/features/scene/creatures.ts
Normal 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, sizeBucket, neutral, activity}.
|
||||
// Invariants:
|
||||
// - Exact vote weights stay in Bifrost; Ratatoskr only ever sees a bucket.
|
||||
// - neutral=true overrides any bucket, and no size is drawn.
|
||||
// - activity comes from the Heimdall session.
|
||||
// Consent: humans get a coarse bucket by default plus a typed opt-out; Minds and Personas may show finer buckets.
|
||||
// Humans have three coarse buckets. The scales below (0.7 / 1 / 1.4) are placeholders, not fixed.
|
||||
|
||||
export type SizeBucket = 'small' | 'medium' | 'large';
|
||||
|
||||
export interface Creature {
|
||||
id: string;
|
||||
estate: string;
|
||||
sizeBucket: SizeBucket;
|
||||
/** A neutral creature (no estate allegiance) is drawn at one fixed size whatever its bucket. */
|
||||
neutral: boolean;
|
||||
activity: 'idle' | 'active';
|
||||
}
|
||||
|
||||
export const BUCKET_SCALE: Readonly<Record<SizeBucket, number>> = {
|
||||
small: 0.7,
|
||||
medium: 1,
|
||||
large: 1.4,
|
||||
};
|
||||
|
||||
/** Neutral creatures are medium sized, not necessarily their bucket's size. */
|
||||
export const NEUTRAL_SCALE = BUCKET_SCALE.medium;
|
||||
|
||||
/** Neutral overrides sizeBucket. */
|
||||
export function creatureScale(c: Pick<Creature, 'sizeBucket' | 'neutral'>): number {
|
||||
return c.neutral ? NEUTRAL_SCALE : BUCKET_SCALE[c.sizeBucket];
|
||||
}
|
||||
82
src/app/features/scene/cultmathTrim.test.ts
Normal file
82
src/app/features/scene/cultmathTrim.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
81
src/app/features/scene/cultmathTrim.ts
Normal file
81
src/app/features/scene/cultmathTrim.ts
Normal 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}`;
|
||||
}
|
||||
33
src/app/features/scene/index.ts
Normal file
33
src/app/features/scene/index.ts
Normal 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 });
|
||||
}
|
||||
27
src/app/features/scene/palette.ts
Normal file
27
src/app/features/scene/palette.ts
Normal 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;
|
||||
34
src/app/features/scene/shaderSource.ts
Normal file
34
src/app/features/scene/shaderSource.ts
Normal 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]}`;
|
||||
}
|
||||
99
src/app/features/scene/shaders/CultMath.Phacelle.glsl
Normal file
99
src/app/features/scene/shaders/CultMath.Phacelle.glsl
Normal 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
|
||||
1035
src/app/features/scene/shaders/CultMath.glsl
Normal file
1035
src/app/features/scene/shaders/CultMath.glsl
Normal file
File diff suppressed because it is too large
Load diff
19
src/app/features/scene/shaders/README.md
Normal file
19
src/app/features/scene/shaders/README.md
Normal 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.
|
||||
90
src/app/features/scene/shaders/atmosphere.frag.glsl
Normal file
90
src/app/features/scene/shaders/atmosphere.frag.glsl
Normal 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);
|
||||
}
|
||||
31
src/app/features/scene/shaders/common.glsl
Normal file
31
src/app/features/scene/shaders/common.glsl
Normal 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;
|
||||
}
|
||||
26
src/app/features/scene/shaders/composite.frag.glsl
Normal file
26
src/app/features/scene/shaders/composite.frag.glsl
Normal 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);
|
||||
}
|
||||
8
src/app/features/scene/shaders/fullscreen.vert.glsl
Normal file
8
src/app/features/scene/shaders/fullscreen.vert.glsl
Normal 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);
|
||||
}
|
||||
41
src/app/features/scene/shaders/resolve.frag.glsl
Normal file
41
src/app/features/scene/shaders/resolve.frag.glsl
Normal 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);
|
||||
}
|
||||
184
src/app/features/scene/shaders/terrain.frag.glsl
Normal file
184
src/app/features/scene/shaders/terrain.frag.glsl
Normal 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;
|
||||
}
|
||||
26
src/app/features/scene/tier.test.ts
Normal file
26
src/app/features/scene/tier.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
49
src/app/features/scene/tier.ts
Normal file
49
src/app/features/scene/tier.ts
Normal 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;
|
||||
}
|
||||
40
src/app/features/scene/world.test.ts
Normal file
40
src/app/features/scene/world.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
116
src/app/features/scene/world.ts
Normal file
116
src/app/features/scene/world.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// The scene's world constants. One owner: the shader prelude below is generated from this
|
||||
// object, so a number lives here and nowhere else, and the Lipschitz bounds that make the
|
||||
// sphere-trace step safe are computed beside the numbers they bound.
|
||||
|
||||
// Lipschitz constant of cultmath_snoise(vec3), CULTMATH_SNOISE_LIPSCHITZ in the vendored
|
||||
// CultMath.glsl (provenance in CultLib math.Interval.cs).
|
||||
export const SNOISE_LIPSCHITZ = 7.9640074;
|
||||
|
||||
export const WORLD = {
|
||||
far: 1400,
|
||||
// Terrain: height = mask(r) * (amp * fbm(xz * freq) + erodeAmp * phacelle(xz * erodeFreq)).
|
||||
amp: 70,
|
||||
freq: 1 / 520,
|
||||
octaves: 4,
|
||||
lacunarity: 2.0,
|
||||
gain: 0.45,
|
||||
// Flat ground (height 0) out to plateauInner, full terrain from plateauOuter. The login
|
||||
// camera stays inside plateauInner, so it can never end up inside the terrain.
|
||||
plateauInner: 135,
|
||||
plateauOuter: 380,
|
||||
erodeAmp: 2.5,
|
||||
erodeFreq: 1 / 38,
|
||||
// The stripe wave vector and normalization the terrain feeds cultmath_phacelle.
|
||||
erodeSide: [1.7, 0, 1] as const,
|
||||
erodeNorm: 0.5,
|
||||
// Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a
|
||||
// sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. The
|
||||
// shader passes roughness n = 0 as a literal, so the doc envelope max(10, 1.25|side|)/(1-n)
|
||||
// needs no 1/(1-n) factor here; if n ever becomes nonzero, divide this bound by (1 - n).
|
||||
// |value| <= 1 holds by construction (the output is a normalized phasor).
|
||||
// Typed number, not the literal 7, so callers can build variants such as { ...WORLD, phacelleGradBound: 0 }.
|
||||
phacelleGradBound: 7.0 as number,
|
||||
// World tree at the origin.
|
||||
tree: {
|
||||
trunkHeight: 38,
|
||||
baseRadius: 3.4,
|
||||
topRadius: 1.9,
|
||||
canopyY: 52,
|
||||
canopyRadius: [30, 17, 30] as const,
|
||||
},
|
||||
// Atmosphere.
|
||||
atmosphereFar: 1400,
|
||||
cloudBase: 130,
|
||||
cloudTop: 230,
|
||||
fogDensity: 0.0028,
|
||||
fogFalloff: 0.011,
|
||||
} as const;
|
||||
|
||||
const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`);
|
||||
|
||||
/** Sum over octaves of (lacunarity * gain)^i: the slope bound's octave factor. */
|
||||
function slopeSum(octaves: number, lacunarity: number, gain: number): number {
|
||||
let s = 0;
|
||||
for (let i = 0; i < octaves; i += 1) s += (lacunarity * gain) ** i;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Sum over octaves of gain^i: the fbm value bound. */
|
||||
function valueSum(octaves: number, gain: number): number {
|
||||
let s = 0;
|
||||
for (let i = 0; i < octaves; i += 1) s += gain ** i;
|
||||
return s;
|
||||
}
|
||||
|
||||
export interface TerrainBounds {
|
||||
/** Lipschitz bound of the envelope surface (no erosion). */
|
||||
slopeEnvelope: number;
|
||||
/** Lipschitz bound of the eroded surface. */
|
||||
slopeFull: number;
|
||||
/** Highest point the terrain can reach. */
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
export function terrainBounds(w: typeof WORLD = WORLD): TerrainBounds {
|
||||
const fbmMax = valueSum(w.octaves, w.gain);
|
||||
const maskSlope = 1.5 / (w.plateauOuter - w.plateauInner); // smoothstep's max slope
|
||||
const fbmSlope = w.amp * w.freq * SNOISE_LIPSCHITZ * slopeSum(w.octaves, w.lacunarity, w.gain);
|
||||
const slopeEnvelope = fbmSlope + (w.amp * fbmMax + w.erodeAmp) * maskSlope;
|
||||
const slopeFull = slopeEnvelope + w.erodeAmp * w.erodeFreq * w.phacelleGradBound;
|
||||
return { slopeEnvelope, slopeFull, maxHeight: w.amp * fbmMax + w.erodeAmp };
|
||||
}
|
||||
|
||||
/** The #defines every stage sees. */
|
||||
export function worldDefines(w: typeof WORLD = WORLD): string {
|
||||
const b = terrainBounds(w);
|
||||
const t = w.tree;
|
||||
const lines: Array<[string, string]> = [
|
||||
['W_FAR', f(w.far)],
|
||||
['W_AMP', f(w.amp)],
|
||||
['W_FREQ', f(w.freq)],
|
||||
['W_OCTAVES', String(w.octaves)],
|
||||
['W_LACUNARITY', f(w.lacunarity)],
|
||||
['W_GAIN', f(w.gain)],
|
||||
['W_PLATEAU_IN', f(w.plateauInner)],
|
||||
['W_PLATEAU_OUT', f(w.plateauOuter)],
|
||||
['W_ERODE_AMP', f(w.erodeAmp)],
|
||||
['W_ERODE_FREQ', f(w.erodeFreq)],
|
||||
['W_ERODE_SIDE', `vec3(${w.erodeSide.map(f).join(', ')})`],
|
||||
['W_ERODE_NORM', f(w.erodeNorm)],
|
||||
['W_ERODE_ENABLED', w.phacelleGradBound > 0 ? '1' : '0'],
|
||||
['W_SLOPE_ENV_INV', f(1 / Math.sqrt(1 + b.slopeEnvelope ** 2))],
|
||||
['W_SLOPE_FULL_INV', f(1 / Math.sqrt(1 + b.slopeFull ** 2))],
|
||||
['W_MAX_HEIGHT', f(Math.max(b.maxHeight, t.canopyY + t.canopyRadius[1] + 6))],
|
||||
['W_TRUNK_H', f(t.trunkHeight)],
|
||||
['W_TRUNK_R0', f(t.baseRadius)],
|
||||
['W_TRUNK_R1', f(t.topRadius)],
|
||||
['W_CANOPY_Y', f(t.canopyY)],
|
||||
['W_CANOPY_R', `vec3(${t.canopyRadius.map(f).join(', ')})`],
|
||||
['W_ATM_FAR', f(w.atmosphereFar)],
|
||||
['W_CLOUD_Y0', f(w.cloudBase)],
|
||||
['W_CLOUD_Y1', f(w.cloudTop)],
|
||||
['W_FOG_DENSITY', f(w.fogDensity)],
|
||||
['W_FOG_FALLOFF', f(w.fogFalloff)],
|
||||
];
|
||||
return lines.map(([k, v]) => `#define ${k} ${v}`).join('\n');
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue