Background scene: two-pass WebGL2 terrain and world tree with login pull-back
Pass 1 sphere-traces CultMath noise terrain (fbm plus Phacelle erosion) and the world tree with a conservative Lipschitz step, writing colour and depth. Pass 2 marches fog and clouds only up to that depth at low resolution with TAA. The camera pulls back from the tree on login. Vendors CultLib's generated GLSL (658c09e). The Phacelle gradient bound is an empirical probe, not a proof. Headless harness compiles, renders and times it; nothing run on a GPU. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2c04bbefa3
commit
e377d9f722
23 changed files with 2687 additions and 0 deletions
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>'
|
||||
);
|
||||
});
|
||||
162
harness/scene/drive.cjs
Normal file
162
harness/scene/drive.cjs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// 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'],
|
||||
noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'],
|
||||
};
|
||||
const wanted = mode === 'variants' ? Object.keys(VARIANTS) : ['baseline'];
|
||||
const sizes = [
|
||||
[960, 540],
|
||||
[540, 1200],
|
||||
];
|
||||
report.runs = [];
|
||||
for (const [w, h] of sizes) {
|
||||
for (const [li, variant] of wanted.flatMap((v) =>
|
||||
mode === 'variants'
|
||||
? [[0, v]]
|
||||
: [
|
||||
[0, v],
|
||||
[3, v],
|
||||
]
|
||||
)) {
|
||||
await page.setViewportSize({ width: w, height: h });
|
||||
const r = await page.evaluate(
|
||||
({ li, rep }) =>
|
||||
new Promise((res) => {
|
||||
const rows = [];
|
||||
const c = document.getElementById('c');
|
||||
const s = window.__scene.createScene({
|
||||
canvas: c,
|
||||
unthrottled: true,
|
||||
fixedLevel: window.__scene.LEVELS[li],
|
||||
patchSource: rep
|
||||
? (st, src) =>
|
||||
st === 'terrain'
|
||||
? rep[0].startsWith('#define')
|
||||
? src.replace(/#define W_ERODE_ENABLED \d/, rep[1])
|
||||
: src.split(rep[0]).join(rep[1])
|
||||
: src
|
||||
: undefined,
|
||||
onStats: (st) => {
|
||||
rows.push(st);
|
||||
if (rows.length === 40) {
|
||||
s.dispose();
|
||||
const keep = rows.slice(10);
|
||||
const med = (k) =>
|
||||
keep.map((x) => x.passMs[k]).sort((a, b) => a - b)[keep.length >> 1];
|
||||
res({
|
||||
solid: keep[0].solidSize,
|
||||
atm: keep[0].atmosphereSize,
|
||||
medianMs: {
|
||||
terrain: med('terrain'),
|
||||
atmosphere: med('atmosphere'),
|
||||
resolve: med('resolve'),
|
||||
composite: med('composite'),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
if (!s) res({ error: 'createScene returned null' });
|
||||
}),
|
||||
{ li, rep: VARIANTS[variant] }
|
||||
);
|
||||
report.runs.push({ viewport: [w, h], level: li, variant, ...r });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 1));
|
||||
await b.close();
|
||||
})().catch((e) => {
|
||||
console.log('ERR', String(e).slice(0, 2000));
|
||||
process.exit(1);
|
||||
});
|
||||
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 };
|
||||
};
|
||||
54
src/app/features/scene/README.md
Normal file
54
src/app/features/scene/README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Background scene
|
||||
|
||||
A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath
|
||||
noise with a world tree at its centre, dusk atmosphere and clouds, and a camera that pulls back
|
||||
from the tree on login. It is a self-contained module; nothing in the client imports it yet.
|
||||
Integration (a canvas under the app, `createScene`, `login()` when the session starts, `dispose()`)
|
||||
is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel are later cuts.
|
||||
|
||||
## Entry points
|
||||
|
||||
- `Scene.ts` `createScene({ canvas, ... })` returns `{ login, dispose }`, or `null` when WebGL2 or
|
||||
`EXT_color_buffer_float` is missing (keep the CSS ground then). It caps at 30 fps, pauses when
|
||||
the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion`, and steps down a
|
||||
quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist.
|
||||
- `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`).
|
||||
- `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL
|
||||
`#define`s are generated from it; no number is written twice.
|
||||
- `shaders/` pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA),
|
||||
`composite.frag.glsl`, plus CultLib's vendored GLSL (`shaders/README.md`).
|
||||
|
||||
## How it renders
|
||||
|
||||
1. **Solid pass** (`terrain.frag.glsl`, 0.4-0.75 of canvas resolution): sphere trace of
|
||||
`min(terrain, tree)`. Terrain height is `mask(r) * (A * fbm + E * phacelle)`, with
|
||||
`cultmath_fbm_grad(...).w` and `cultmath_phacelle(...).cos.w`. It writes lit colour with
|
||||
coverage and the hit distance (second render target).
|
||||
2. **Atmosphere pass** (0.25-0.5 of canvas): closed-form exponential height fog plus a cloud
|
||||
slab marched with a few jittered steps, both only up to the hit distance from pass 1.
|
||||
3. **Resolve**: TAA on the atmosphere (Halton jitter, history reprojected by the pass-1 depth,
|
||||
3x3 neighbourhood clamp, 1/8 blend; reset on resize, level change and wake).
|
||||
4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES
|
||||
tonemap, dither.
|
||||
|
||||
## The step bound, and what is not proven
|
||||
|
||||
The trace steps by `(y - h(x, z)) / sqrt(1 + G^2)`, `G` the Lipschitz bound of the height field,
|
||||
computed from `SNOISE_LIPSCHITZ` (CultMath's constant for `snoise`) in `world.ts`. Above the
|
||||
envelope (fbm plus the erosion amplitude) the step uses the envelope's bound alone and Phacelle is
|
||||
not evaluated.
|
||||
|
||||
- The Phacelle gradient bound `phacelleGradBound` (6.3) is **an empirical probe, not a proof**:
|
||||
the maximum `|grad|` of the cos component over 4,194,304 random points (5.04) times 1.25
|
||||
(`harness/scene/drive.cjs phacelle`). Replace it with Libby's bound; until then the march is
|
||||
only as safe as that probe.
|
||||
- The bound makes grazing rays crawl. A ray that exhausts its steps while still below the highest
|
||||
terrain is ended as a distant surface (fog hides it). That is a visible approximation, not a
|
||||
hit proven by the march.
|
||||
- The low-resolution atmosphere samples pass-1 depth nearest, so silhouettes of near objects
|
||||
carry a thin fog halo.
|
||||
|
||||
## Measured and not measured
|
||||
|
||||
See the pull request or `harness/scene/README.md` for the numbers and how they were taken. They
|
||||
come from a software rasterizer, not a GPU, and nothing has been run on a phone.
|
||||
414
src/app/features/scene/Scene.ts
Normal file
414
src/app/features/scene/Scene.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
import { fragmentSource, vertexSource, Stage } from './shaderSource';
|
||||
import { cameraPose, pullProgress, CameraPose } from './camera';
|
||||
import { BudgetLadder, Level } from './budget';
|
||||
import { PALETTE } from './palette';
|
||||
|
||||
/** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */
|
||||
export const GPU_BUDGET_MS = 4.0;
|
||||
const FRAME_INTERVAL_MS = 1000 / 30;
|
||||
const REDUCED_MOTION_FRAMES = 32;
|
||||
const MAX_DPR = 1.5;
|
||||
|
||||
export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite';
|
||||
const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite'];
|
||||
|
||||
export interface SceneStats {
|
||||
/** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */
|
||||
passMs: Record<PassName, number>;
|
||||
level: number;
|
||||
solidSize: [number, number];
|
||||
atmosphereSize: [number, number];
|
||||
}
|
||||
|
||||
export interface SceneOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
/** Called per finished frame when timer queries exist. */
|
||||
onStats?: (s: SceneStats) => void;
|
||||
/** Weather knob, 0 clear .. 1 overcast. */
|
||||
coverage?: number;
|
||||
/** Fixed level instead of the adaptive ladder (measurement). */
|
||||
fixedLevel?: Level;
|
||||
/** Ignore the 30 fps cap and the visibility pause (measurement). */
|
||||
unthrottled?: boolean;
|
||||
patchSource?: SourcePatch;
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
/** Start the camera pull-back from the tree. */
|
||||
login(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */
|
||||
export type SourcePatch = (stage: Stage, source: string) => string;
|
||||
|
||||
export class ShaderCompileError extends Error {
|
||||
constructor(readonly stage: Stage | 'vertex', readonly log: string) {
|
||||
super(`${stage} shader failed to compile:\n${log}`);
|
||||
}
|
||||
}
|
||||
|
||||
function compile(
|
||||
gl: WebGL2RenderingContext,
|
||||
type: number,
|
||||
source: string,
|
||||
stage: Stage | 'vertex'
|
||||
): WebGLShader {
|
||||
const sh = gl.createShader(type)!;
|
||||
gl.shaderSource(sh, source);
|
||||
gl.compileShader(sh);
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||||
const log = gl.getShaderInfoLog(sh) ?? '';
|
||||
gl.deleteShader(sh);
|
||||
throw new ShaderCompileError(stage, log);
|
||||
}
|
||||
return sh;
|
||||
}
|
||||
|
||||
export function linkStage(
|
||||
gl: WebGL2RenderingContext,
|
||||
stage: Stage,
|
||||
patch?: SourcePatch
|
||||
): WebGLProgram {
|
||||
const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex');
|
||||
const fs = compile(
|
||||
gl,
|
||||
gl.FRAGMENT_SHADER,
|
||||
patch ? patch(stage, fragmentSource(stage)) : fragmentSource(stage),
|
||||
stage
|
||||
);
|
||||
const prog = gl.createProgram()!;
|
||||
gl.attachShader(prog, vs);
|
||||
gl.attachShader(prog, fs);
|
||||
gl.linkProgram(prog);
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
||||
const log = gl.getProgramInfoLog(prog) ?? '';
|
||||
gl.deleteProgram(prog);
|
||||
throw new ShaderCompileError(stage, log);
|
||||
}
|
||||
return prog;
|
||||
}
|
||||
|
||||
interface Target {
|
||||
tex: WebGLTexture;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
function halton(i: number, base: number): number {
|
||||
let f = 1;
|
||||
let r = 0;
|
||||
let n = i;
|
||||
while (n > 0) {
|
||||
f /= base;
|
||||
r += f * (n % base);
|
||||
n = Math.floor(n / base);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Null when WebGL2 or float colour buffers are missing; the host keeps its CSS ground. */
|
||||
export function createScene(opts: SceneOptions): Scene | null {
|
||||
const { canvas } = opts;
|
||||
const gl = canvas.getContext('webgl2', {
|
||||
antialias: false,
|
||||
alpha: false,
|
||||
depth: false,
|
||||
stencil: false,
|
||||
powerPreference: 'low-power',
|
||||
});
|
||||
if (!gl || !gl.getExtension('EXT_color_buffer_float')) return null;
|
||||
gl.getExtension('OES_texture_float_linear');
|
||||
const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2') as {
|
||||
TIME_ELAPSED_EXT: number;
|
||||
GPU_DISJOINT_EXT: number;
|
||||
} | null;
|
||||
|
||||
const programs = {} as Record<PassName, WebGLProgram>;
|
||||
for (const p of PASSES) programs[p] = linkStage(gl, p, opts.patchSource);
|
||||
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 = () => {
|
||||
for (const t of [solid, depth, atm, hist?.[0], hist?.[1]]) if (t) gl.deleteTexture(t.tex);
|
||||
};
|
||||
|
||||
const attach = (fbo: WebGLFramebuffer, targets: Target[]) => {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||
targets.forEach((t, i) =>
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, t.tex, 0)
|
||||
);
|
||||
gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i));
|
||||
};
|
||||
|
||||
const ladder = new BudgetLadder(GPU_BUDGET_MS);
|
||||
const level = (): Level => opts.fixedLevel ?? ladder.current;
|
||||
|
||||
let canvasW = 0;
|
||||
let canvasH = 0;
|
||||
let sizedFor: Level | undefined;
|
||||
let resetHistory = true;
|
||||
|
||||
const ensureSize = () => {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||
const w = Math.max(2, Math.round(canvas.clientWidth * dpr));
|
||||
const h = Math.max(2, Math.round(canvas.clientHeight * dpr));
|
||||
const lv = level();
|
||||
if (w === canvasW && h === canvasH && lv === sizedFor) return;
|
||||
canvasW = w;
|
||||
canvasH = h;
|
||||
sizedFor = lv;
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
freeTargets();
|
||||
const sw = Math.max(2, Math.round(w * lv.solidScale));
|
||||
const sh = Math.max(2, Math.round(h * lv.solidScale));
|
||||
const aw = Math.max(2, Math.round(w * lv.atmosphereScale));
|
||||
const ah = Math.max(2, Math.round(h * lv.atmosphereScale));
|
||||
solid = makeTarget(sw, sh, gl.RGBA16F, gl.LINEAR);
|
||||
depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST);
|
||||
atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR);
|
||||
hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)];
|
||||
attach(fbos.solid, [solid, depth]);
|
||||
attach(fbos.atm, [atm]);
|
||||
attach(fbos.hist[0], [hist[0]]);
|
||||
attach(fbos.hist[1], [hist[1]]);
|
||||
resetHistory = true;
|
||||
};
|
||||
|
||||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
||||
const start = performance.now();
|
||||
let loginAt: number | undefined;
|
||||
let frame = 0;
|
||||
let histIndex = 0;
|
||||
let prev: CameraPose | undefined;
|
||||
let raf = 0;
|
||||
let lastRendered = -Infinity;
|
||||
let framesLeft = Infinity; // reduced motion bounds this
|
||||
let disposed = false;
|
||||
|
||||
// Timer queries in flight: each holds one pass's query.
|
||||
const pending: Array<{ q: WebGLQuery; pass: PassName; frame: number }> = [];
|
||||
const finished = new Map<number, Partial<Record<PassName, number>>>();
|
||||
|
||||
const timed = (pass: PassName, f: number, body: () => void) => {
|
||||
if (!timer || !opts.onStats) return body();
|
||||
const q = gl.createQuery()!;
|
||||
gl.beginQuery(timer.TIME_ELAPSED_EXT, q);
|
||||
body();
|
||||
gl.endQuery(timer.TIME_ELAPSED_EXT);
|
||||
pending.push({ q, pass, frame: f });
|
||||
};
|
||||
|
||||
const collect = () => {
|
||||
if (!timer) return;
|
||||
while (pending.length && gl.getQueryParameter(pending[0].q, gl.QUERY_RESULT_AVAILABLE)) {
|
||||
const { q, pass, frame: f } = pending.shift()!;
|
||||
const disjoint = gl.getParameter(timer.GPU_DISJOINT_EXT);
|
||||
const ns = gl.getQueryParameter(q, gl.QUERY_RESULT) as number;
|
||||
gl.deleteQuery(q);
|
||||
const row = finished.get(f) ?? {};
|
||||
if (!disjoint) row[pass] = ns / 1e6;
|
||||
finished.set(f, row);
|
||||
if (Object.keys(row).length === PASSES.length) {
|
||||
finished.delete(f);
|
||||
const passMs = row as Record<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;
|
||||
const elapsed = loginAt === undefined ? undefined : frozen ? 1e9 : (now - loginAt) / 1000;
|
||||
const pose = cameraPose(pullProgress(elapsed), time);
|
||||
const f = frame++;
|
||||
const jx = halton((f % 16) + 1, 2) - 0.5;
|
||||
const jy = halton((f % 16) + 1, 3) - 0.5;
|
||||
|
||||
// Pass 1: solid surfaces, writes colour and depth.
|
||||
timed('terrain', f, () => {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid);
|
||||
gl.viewport(0, 0, solid.w, solid.h);
|
||||
const p = programs.terrain;
|
||||
gl.useProgram(p);
|
||||
setCommon(p, pose, time);
|
||||
gl.uniform3fv(loc(p, 'u_grass'), PALETTE.grass as unknown as number[]);
|
||||
gl.uniform3fv(loc(p, 'u_rock'), PALETTE.rock as unknown as number[]);
|
||||
gl.uniform3fv(loc(p, 'u_bark'), PALETTE.bark as unknown as number[]);
|
||||
gl.uniform3fv(loc(p, 'u_leaf'), PALETTE.leaf as unknown as number[]);
|
||||
gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps);
|
||||
draw();
|
||||
});
|
||||
|
||||
// Pass 2: atmosphere up to pass 1's depth, low-res.
|
||||
timed('atmosphere', f, () => {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.atm);
|
||||
gl.viewport(0, 0, atm.w, atm.h);
|
||||
const p = programs.atmosphere;
|
||||
gl.useProgram(p);
|
||||
setCommon(p, pose, time);
|
||||
bindTex(0, depth, p, 'u_depth');
|
||||
gl.uniform1i(loc(p, 'u_steps'), lv.cloudSteps);
|
||||
gl.uniform1f(loc(p, 'u_frame'), f);
|
||||
gl.uniform2f(loc(p, 'u_jitter'), jx, jy);
|
||||
gl.uniform2f(loc(p, 'u_size'), atm.w, atm.h);
|
||||
gl.uniform1f(loc(p, 'u_coverage'), opts.coverage ?? 0.45);
|
||||
draw();
|
||||
});
|
||||
|
||||
// TAA resolve into the history that is not being read.
|
||||
const write = histIndex ^ 1;
|
||||
timed('resolve', f, () => {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]);
|
||||
gl.viewport(0, 0, atm.w, atm.h);
|
||||
const p = programs.resolve;
|
||||
gl.useProgram(p);
|
||||
setCommon(p, pose, time);
|
||||
bindTex(0, atm, p, 'u_cur');
|
||||
bindTex(1, hist[histIndex], p, 'u_history');
|
||||
bindTex(2, depth, p, 'u_depth');
|
||||
const pp = prev ?? pose;
|
||||
const th = Math.tan(pp.fov / 2);
|
||||
gl.uniform3fv(loc(p, 'u_prevPos'), pp.position as unknown as number[]);
|
||||
gl.uniformMatrix3fv(loc(p, 'u_prevBasis'), false, pp.basis as unknown as number[]);
|
||||
gl.uniform2f(loc(p, 'u_prevTanHalf'), th * (canvasW / canvasH), th);
|
||||
gl.uniform1f(loc(p, 'u_blend'), resetHistory ? 1 : 0.125);
|
||||
gl.uniform2f(loc(p, 'u_curSize'), atm.w, atm.h);
|
||||
draw();
|
||||
});
|
||||
|
||||
// Full-resolution composite to the canvas.
|
||||
timed('composite', f, () => {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.viewport(0, 0, canvasW, canvasH);
|
||||
const p = programs.composite;
|
||||
gl.useProgram(p);
|
||||
setCommon(p, pose, time);
|
||||
bindTex(0, solid, p, 'u_solid');
|
||||
bindTex(1, hist[write], p, 'u_atm');
|
||||
gl.uniform1f(loc(p, 'u_frame'), f);
|
||||
draw();
|
||||
});
|
||||
|
||||
histIndex = write;
|
||||
prev = pose;
|
||||
resetHistory = false;
|
||||
collect();
|
||||
};
|
||||
|
||||
const tick = (now: number) => {
|
||||
raf = 0;
|
||||
if (disposed) return;
|
||||
const due = opts.unthrottled || now - lastRendered >= FRAME_INTERVAL_MS - 2;
|
||||
if (due) {
|
||||
if (
|
||||
!opts.fixedLevel &&
|
||||
!timer &&
|
||||
lastRendered > 0 &&
|
||||
now - lastRendered > FRAME_INTERVAL_MS * 1.5
|
||||
)
|
||||
ladder.push(GPU_BUDGET_MS * 2) && (sizedFor = undefined); // no timer: a missed cap counts as over budget
|
||||
lastRendered = now;
|
||||
render(now);
|
||||
framesLeft -= 1;
|
||||
}
|
||||
if (framesLeft > 0 && (opts.unthrottled || document.visibilityState === 'visible'))
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const wake = () => {
|
||||
if (disposed || raf) return;
|
||||
if (reducedMotion?.matches && framesLeft <= 0) return;
|
||||
resetHistory = true; // the first frame after a pause has a real elapsed dt
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') wake();
|
||||
};
|
||||
const onResize = () => wake();
|
||||
if (!opts.unthrottled) document.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('resize', onResize);
|
||||
if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES;
|
||||
wake();
|
||||
|
||||
return {
|
||||
login() {
|
||||
loginAt = performance.now();
|
||||
if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES;
|
||||
wake();
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
window.removeEventListener('resize', onResize);
|
||||
freeTargets();
|
||||
for (const p of PASSES) gl.deleteProgram(programs[p]);
|
||||
gl.deleteVertexArray(vao);
|
||||
for (const f of [fbos.solid, fbos.atm, ...fbos.hist]) gl.deleteFramebuffer(f);
|
||||
},
|
||||
};
|
||||
}
|
||||
34
src/app/features/scene/budget.test.ts
Normal file
34
src/app/features/scene/budget.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { BudgetLadder, LEVELS } from './budget';
|
||||
|
||||
describe('budget ladder', () => {
|
||||
it('steps down after a whole window over budget and never back up', () => {
|
||||
const ladder = new BudgetLadder(4, 5);
|
||||
for (let i = 0; i < 4; i++) expect(ladder.push(9)).toBe(false);
|
||||
expect(ladder.push(9)).toBe(true);
|
||||
expect(ladder.index).toBe(1);
|
||||
for (let i = 0; i < 100; i++) ladder.push(0.1); // fast frames never raise the level
|
||||
expect(ladder.index).toBe(1);
|
||||
});
|
||||
|
||||
it('judges the median, so one slow frame does not step down', () => {
|
||||
const ladder = new BudgetLadder(4, 5);
|
||||
for (const ms of [1, 1, 50, 1, 1]) ladder.push(ms);
|
||||
expect(ladder.index).toBe(0);
|
||||
});
|
||||
|
||||
it('stops at the last level', () => {
|
||||
const ladder = new BudgetLadder(4, 1);
|
||||
for (let i = 0; i < 20; i++) ladder.push(99);
|
||||
expect(ladder.index).toBe(LEVELS.length - 1);
|
||||
});
|
||||
|
||||
it('every level is cheaper than the one before in each cost axis', () => {
|
||||
for (let i = 1; i < LEVELS.length; i++) {
|
||||
expect(LEVELS[i].solidScale).toBeLessThan(LEVELS[i - 1].solidScale);
|
||||
expect(LEVELS[i].atmosphereScale).toBeLessThan(LEVELS[i - 1].atmosphereScale);
|
||||
expect(LEVELS[i].cloudSteps).toBeLessThan(LEVELS[i - 1].cloudSteps);
|
||||
expect(LEVELS[i].traceSteps).toBeLessThan(LEVELS[i - 1].traceSteps);
|
||||
}
|
||||
});
|
||||
});
|
||||
49
src/app/features/scene/budget.ts
Normal file
49
src/app/features/scene/budget.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// The adaptive step-down. The ladder only descends within a session; nothing here ever steps up.
|
||||
|
||||
export interface Level {
|
||||
/** Pass 1 resolution as a fraction of the canvas. */
|
||||
solidScale: number;
|
||||
/** Pass 2 resolution as a fraction of the canvas. */
|
||||
atmosphereScale: number;
|
||||
/** Cloud march steps. */
|
||||
cloudSteps: number;
|
||||
/** Sphere-trace step cap. */
|
||||
traceSteps: number;
|
||||
}
|
||||
|
||||
export const LEVELS: readonly Level[] = [
|
||||
{ solidScale: 0.75, atmosphereScale: 0.5, cloudSteps: 12, traceSteps: 96 },
|
||||
{ solidScale: 0.6, atmosphereScale: 0.4, cloudSteps: 8, traceSteps: 80 },
|
||||
{ solidScale: 0.5, atmosphereScale: 0.33, cloudSteps: 6, traceSteps: 64 },
|
||||
{ solidScale: 0.4, atmosphereScale: 0.25, cloudSteps: 4, traceSteps: 48 },
|
||||
];
|
||||
|
||||
/** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */
|
||||
export class BudgetLadder {
|
||||
private level = 0;
|
||||
private recent: number[] = [];
|
||||
|
||||
constructor(private readonly budgetMs: number, private readonly window = 45) {}
|
||||
|
||||
get current(): Level {
|
||||
return LEVELS[this.level];
|
||||
}
|
||||
|
||||
get index(): number {
|
||||
return this.level;
|
||||
}
|
||||
|
||||
/** Feed one frame's GPU (or cadence) time. Returns true when the level changed. */
|
||||
push(ms: number): boolean {
|
||||
this.recent.push(ms);
|
||||
if (this.recent.length < this.window) return false;
|
||||
const sorted = [...this.recent].sort((a, b) => a - b);
|
||||
const median = sorted[sorted.length >> 1];
|
||||
this.recent = [];
|
||||
if (median > this.budgetMs && this.level < LEVELS.length - 1) {
|
||||
this.level += 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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),
|
||||
};
|
||||
}
|
||||
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;
|
||||
29
src/app/features/scene/shaderSource.ts
Normal file
29
src/app/features/scene/shaderSource.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import cultmath from './shaders/CultMath.glsl?raw';
|
||||
import phacelle from './shaders/CultMath.Phacelle.glsl?raw';
|
||||
import common from './shaders/common.glsl?raw';
|
||||
import fullscreenVert from './shaders/fullscreen.vert.glsl?raw';
|
||||
import terrainFrag from './shaders/terrain.frag.glsl?raw';
|
||||
import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw';
|
||||
import resolveFrag from './shaders/resolve.frag.glsl?raw';
|
||||
import compositeFrag from './shaders/composite.frag.glsl?raw';
|
||||
import { WORLD, worldDefines } from './world';
|
||||
|
||||
export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite';
|
||||
|
||||
const HEADER =
|
||||
'#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n';
|
||||
|
||||
export const vertexSource = HEADER + fullscreenVert;
|
||||
|
||||
const stages: Record<Stage, string> = {
|
||||
terrain: terrainFrag,
|
||||
atmosphere: atmosphereFrag,
|
||||
resolve: resolveFrag,
|
||||
composite: compositeFrag,
|
||||
};
|
||||
|
||||
/** Fragment source for a stage: header, world defines, CultMath, the shared block, the stage. */
|
||||
export function fragmentSource(stage: Stage): string {
|
||||
const erosion = stage === 'terrain' && WORLD.phacelleGradBound > 0 ? phacelle : '';
|
||||
return `${HEADER}${worldDefines()}\n${cultmath}\n${erosion}\n${common}\n${stages[stage]}`;
|
||||
}
|
||||
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
15
src/app/features/scene/shaders/README.md
Normal file
15
src/app/features/scene/shaders/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Vendored CultMath GLSL
|
||||
|
||||
`CultMath.glsl` and `CultMath.Phacelle.glsl` are CultLib's generated WebGL2
|
||||
lowering of `packages/cultmath/shaders/CultMath.hlsl` and
|
||||
`CultMath.Phacelle.hlsl`, copied unedited from CultLib branch
|
||||
`eureka/cultmath-tapes-interval-ops-r5` at `658c09e` (the branch that carries
|
||||
the interval ops and `CULTMATH_SNOISE2_LIPSCHITZ`; the repo default branch
|
||||
does not carry the GLSL yet). Never edit them here: regenerate in CultLib and
|
||||
re-copy. `CultMath.glsl` is MIT; `CultMath.Phacelle.glsl` is MPL-2.0 (Rune
|
||||
Skovbo Johansen's Phacelle noise, see its header), so that file keeps its
|
||||
header and any change to it is published under MPL-2.0.
|
||||
|
||||
The other `*.glsl` files here are the scene's own stages. They are
|
||||
concatenated by `../shaderSource.ts` after a prelude; they use no colour
|
||||
literal (the palette is uniforms) and define no noise.
|
||||
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 * (0.25 * pow(s, 8.0) + 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);
|
||||
}
|
||||
180
src/app/features/scene/shaders/terrain.frag.glsl
Normal file
180
src/app/features/scene/shaders/terrain.frag.glsl
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// Pass 1: solid surfaces (terrain + world tree), sphere-traced. Writes lit colour with coverage
|
||||
// and, separately, the hit distance that bounds pass 2.
|
||||
uniform vec3 u_grass;
|
||||
uniform vec3 u_rock;
|
||||
uniform vec3 u_bark;
|
||||
uniform vec3 u_leaf;
|
||||
uniform int u_maxSteps;
|
||||
|
||||
in vec2 v_uv;
|
||||
layout(location = 0) out vec4 o_color; // rgb lit, a coverage
|
||||
layout(location = 1) out float o_depth; // hit distance along the ray, W_FAR on a miss
|
||||
|
||||
// ---- terrain -------------------------------------------------------------------------------
|
||||
|
||||
float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); }
|
||||
|
||||
// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap.
|
||||
float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }
|
||||
|
||||
#if W_ERODE_ENABLED
|
||||
CultPhasor terrainPhacelle(vec2 xz)
|
||||
{
|
||||
// A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion.
|
||||
return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the
|
||||
// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated.
|
||||
float terrainDistance(vec3 p)
|
||||
{
|
||||
float m = terrainMask(p.xz);
|
||||
float hb = m * W_AMP * terrainFbm(p.xz);
|
||||
float above = p.y - (hb + m * W_ERODE_AMP);
|
||||
if (above > 0.0)
|
||||
return above * W_SLOPE_ENV_INV;
|
||||
#if W_ERODE_ENABLED
|
||||
hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w;
|
||||
#endif
|
||||
return (p.y - hb) * W_SLOPE_FULL_INV;
|
||||
}
|
||||
|
||||
vec3 terrainNormal(vec2 xz)
|
||||
{
|
||||
float r = length(xz);
|
||||
float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r);
|
||||
float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0);
|
||||
float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0;
|
||||
vec2 dir = r > 0.0 ? xz / r : vec2(0.0);
|
||||
vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN);
|
||||
float f = fg.w;
|
||||
vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir;
|
||||
#if W_ERODE_ENABLED
|
||||
CultPhasor ph = terrainPhacelle(xz);
|
||||
g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir;
|
||||
#endif
|
||||
return normalize(vec3(-g.x, 1.0, -g.y));
|
||||
}
|
||||
|
||||
// ---- world tree ----------------------------------------------------------------------------
|
||||
|
||||
float smin(float a, float b, float k)
|
||||
{
|
||||
float h = max(k - abs(a - b), 0.0) / k;
|
||||
return min(a, b) - h * h * k * 0.25;
|
||||
}
|
||||
|
||||
// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz.
|
||||
float treeDistance(vec3 p, out float leafness)
|
||||
{
|
||||
vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0);
|
||||
float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y);
|
||||
leafness = 0.0;
|
||||
if (bound > 8.0)
|
||||
return bound;
|
||||
|
||||
float ang = atan(p.z, p.x);
|
||||
float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0);
|
||||
float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15));
|
||||
float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7;
|
||||
|
||||
vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R;
|
||||
float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0));
|
||||
float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp;
|
||||
canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L)
|
||||
|
||||
float d = smin(trunk, canopy, 6.0);
|
||||
leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0);
|
||||
return d;
|
||||
}
|
||||
|
||||
// ---- trace ---------------------------------------------------------------------------------
|
||||
|
||||
float sceneDistance(vec3 p, out float leafness, out bool onTree)
|
||||
{
|
||||
float dt = terrainDistance(p);
|
||||
float dr = treeDistance(p, leafness);
|
||||
onTree = dr < dt;
|
||||
return min(dt, dr);
|
||||
}
|
||||
|
||||
void main()
|
||||
{
|
||||
vec3 ro = u_camPos;
|
||||
vec3 rd = camRay(v_uv);
|
||||
|
||||
float t = 0.0;
|
||||
// Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright.
|
||||
if (ro.y > W_MAX_HEIGHT)
|
||||
{
|
||||
if (rd.y >= 0.0)
|
||||
{
|
||||
o_color = vec4(0.0);
|
||||
o_depth = W_FAR;
|
||||
return;
|
||||
}
|
||||
t = (W_MAX_HEIGHT - ro.y) / rd.y;
|
||||
}
|
||||
|
||||
bool hit = false;
|
||||
bool onTree = false;
|
||||
float leafness = 0.0;
|
||||
int i = 0;
|
||||
for (; i < u_maxSteps; i++)
|
||||
{
|
||||
vec3 p = ro + rd * t;
|
||||
float d = sceneDistance(p, leafness, onTree);
|
||||
if (d < 0.0006 * t + 0.01)
|
||||
{
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
t += d;
|
||||
if (t > W_FAR || (p.y > W_MAX_HEIGHT && rd.y > 0.0))
|
||||
break;
|
||||
}
|
||||
// The conservative bound makes grazing rays crawl: a ray that ran out of steps while still
|
||||
// below the highest terrain is a distant surface under thick fog, so it ends there.
|
||||
if (!hit && i == u_maxSteps && t < W_FAR)
|
||||
{
|
||||
hit = true;
|
||||
sceneDistance(ro + rd * t, leafness, onTree);
|
||||
}
|
||||
|
||||
if (!hit)
|
||||
{
|
||||
o_color = vec4(0.0);
|
||||
o_depth = W_FAR;
|
||||
return;
|
||||
}
|
||||
|
||||
vec3 p = ro + rd * t;
|
||||
vec3 n;
|
||||
vec3 albedo;
|
||||
if (onTree)
|
||||
{
|
||||
float e = 0.35;
|
||||
float l;
|
||||
n = normalize(vec3(
|
||||
treeDistance(p + vec3(e, 0.0, 0.0), l) - treeDistance(p - vec3(e, 0.0, 0.0), l),
|
||||
treeDistance(p + vec3(0.0, e, 0.0), l) - treeDistance(p - vec3(0.0, e, 0.0), l),
|
||||
treeDistance(p + vec3(0.0, 0.0, e), l) - treeDistance(p - vec3(0.0, 0.0, e), l)));
|
||||
float speckle = 0.75 + 0.25 * cultmath_snoise(p * 0.8);
|
||||
albedo = mix(u_bark, u_leaf * speckle, leafness);
|
||||
}
|
||||
else
|
||||
{
|
||||
n = terrainNormal(p.xz);
|
||||
float rockness = smoothstep(0.78, 0.55, n.y);
|
||||
albedo = mix(u_grass, u_rock, rockness);
|
||||
}
|
||||
|
||||
float ndl = dot(n, u_sunDir);
|
||||
float wrap = clamp(ndl * 0.6 + 0.4, 0.0, 1.0); // half-lambert: no shadow ray in the cheap pass
|
||||
vec3 ambient = mix(u_skyHorizon, u_skyTop, 0.5 + 0.5 * n.y) * 0.45;
|
||||
vec3 col = albedo * (u_sunColor * wrap * wrap + ambient);
|
||||
|
||||
o_color = vec4(col, 1.0);
|
||||
o_depth = t;
|
||||
}
|
||||
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]));
|
||||
for (const src of [common, terrain, atmosphere, resolve, composite]) {
|
||||
for (const m of src.matchAll(/\bW_[A-Z0-9_]+\b/g)) expect(defined, m[0]).toContain(m[0]);
|
||||
}
|
||||
});
|
||||
|
||||
it('emit only valid GLSL numbers', () => {
|
||||
expect(worldDefines()).not.toMatch(/NaN|Infinity|undefined/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('terrain step bound', () => {
|
||||
it('is more conservative with erosion than without, and without erosion carries no phacelle term', () => {
|
||||
const b = terrainBounds();
|
||||
expect(b.slopeFull).toBeGreaterThan(b.slopeEnvelope);
|
||||
const off = terrainBounds({ ...WORLD, phacelleGradBound: 0 });
|
||||
expect(off.slopeFull).toBe(off.slopeEnvelope);
|
||||
});
|
||||
|
||||
it('puts every surface under the height the tracer skips to', () => {
|
||||
const maxHeight = Number(/#define W_MAX_HEIGHT (\S+)/.exec(worldDefines())![1]);
|
||||
expect(maxHeight).toBeGreaterThanOrEqual(terrainBounds().maxHeight);
|
||||
expect(maxHeight).toBeGreaterThanOrEqual(WORLD.tree.canopyY + WORLD.tree.canopyRadius[1]);
|
||||
});
|
||||
|
||||
it('leaves the erosion bound turned on only when a bound exists', () => {
|
||||
expect(worldDefines()).toContain('#define W_ERODE_ENABLED 1');
|
||||
expect(worldDefines({ ...WORLD, phacelleGradBound: 0 })).toContain('#define W_ERODE_ENABLED 0');
|
||||
});
|
||||
});
|
||||
114
src/app/features/scene/world.ts
Normal file
114
src/app/features/scene/world.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// The scene's world constants. One owner: the shader prelude below is generated from this
|
||||
// object, so a number lives here and nowhere else, and the Lipschitz bounds that make the
|
||||
// sphere-trace step safe are computed beside the numbers they bound.
|
||||
|
||||
// Lipschitz constant of cultmath_snoise(vec3), CULTMATH_SNOISE_LIPSCHITZ in the vendored
|
||||
// CultMath.glsl (provenance in CultLib math.Interval.cs).
|
||||
export const SNOISE_LIPSCHITZ = 7.9640074;
|
||||
|
||||
export const WORLD = {
|
||||
far: 1400,
|
||||
// Terrain: height = mask(r) * (amp * fbm(xz * freq) + erodeAmp * phacelle(xz * erodeFreq)).
|
||||
amp: 70,
|
||||
freq: 1 / 520,
|
||||
octaves: 4,
|
||||
lacunarity: 2.0,
|
||||
gain: 0.45,
|
||||
// Flat ground (height 0) out to plateauInner, full terrain from plateauOuter. The login
|
||||
// camera stays inside plateauInner, so it can never end up inside the terrain.
|
||||
plateauInner: 135,
|
||||
plateauOuter: 380,
|
||||
erodeAmp: 2.5,
|
||||
erodeFreq: 1 / 38,
|
||||
// The stripe wave vector and normalization the terrain feeds cultmath_phacelle.
|
||||
erodeSide: [1.7, 0, 1] as const,
|
||||
erodeNorm: 0.5,
|
||||
// Lipschitz bound of the phasor's cos component, NOT a proof: max |grad| over 4,194,304 random
|
||||
// points of the z = 0 slice with the side and norm above was 5.04 (`drive.cjs phacelle`,
|
||||
// 2026-10-31), x 1.25. |value| <= 1 holds by construction (the output is a normalized phasor).
|
||||
// Replace with the bound Libby supplies; until then the march is as safe as that probe.
|
||||
phacelleGradBound: 6.3,
|
||||
// World tree at the origin.
|
||||
tree: {
|
||||
trunkHeight: 38,
|
||||
baseRadius: 3.4,
|
||||
topRadius: 1.9,
|
||||
canopyY: 52,
|
||||
canopyRadius: [30, 17, 30] as const,
|
||||
},
|
||||
// Atmosphere.
|
||||
atmosphereFar: 1400,
|
||||
cloudBase: 130,
|
||||
cloudTop: 230,
|
||||
fogDensity: 0.0028,
|
||||
fogFalloff: 0.011,
|
||||
} as const;
|
||||
|
||||
const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`);
|
||||
|
||||
/** Sum over octaves of (lacunarity * gain)^i: the slope bound's octave factor. */
|
||||
function slopeSum(octaves: number, lacunarity: number, gain: number): number {
|
||||
let s = 0;
|
||||
for (let i = 0; i < octaves; i += 1) s += (lacunarity * gain) ** i;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Sum over octaves of gain^i: the fbm value bound. */
|
||||
function valueSum(octaves: number, gain: number): number {
|
||||
let s = 0;
|
||||
for (let i = 0; i < octaves; i += 1) s += gain ** i;
|
||||
return s;
|
||||
}
|
||||
|
||||
export interface TerrainBounds {
|
||||
/** Lipschitz bound of the envelope surface (no erosion). */
|
||||
slopeEnvelope: number;
|
||||
/** Lipschitz bound of the eroded surface. */
|
||||
slopeFull: number;
|
||||
/** Highest point the terrain can reach. */
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
export function terrainBounds(w: typeof WORLD = WORLD): TerrainBounds {
|
||||
const fbmMax = valueSum(w.octaves, w.gain);
|
||||
const maskSlope = 1.5 / (w.plateauOuter - w.plateauInner); // smoothstep's max slope
|
||||
const fbmSlope = w.amp * w.freq * SNOISE_LIPSCHITZ * slopeSum(w.octaves, w.lacunarity, w.gain);
|
||||
const slopeEnvelope = fbmSlope + (w.amp * fbmMax + w.erodeAmp) * maskSlope;
|
||||
const slopeFull = slopeEnvelope + w.erodeAmp * w.erodeFreq * w.phacelleGradBound;
|
||||
return { slopeEnvelope, slopeFull, maxHeight: w.amp * fbmMax + w.erodeAmp };
|
||||
}
|
||||
|
||||
/** The #defines every stage sees. */
|
||||
export function worldDefines(w: typeof WORLD = WORLD): string {
|
||||
const b = terrainBounds(w);
|
||||
const t = w.tree;
|
||||
const lines: Array<[string, string]> = [
|
||||
['W_FAR', f(w.far)],
|
||||
['W_AMP', f(w.amp)],
|
||||
['W_FREQ', f(w.freq)],
|
||||
['W_OCTAVES', String(w.octaves)],
|
||||
['W_LACUNARITY', f(w.lacunarity)],
|
||||
['W_GAIN', f(w.gain)],
|
||||
['W_PLATEAU_IN', f(w.plateauInner)],
|
||||
['W_PLATEAU_OUT', f(w.plateauOuter)],
|
||||
['W_ERODE_AMP', f(w.erodeAmp)],
|
||||
['W_ERODE_FREQ', f(w.erodeFreq)],
|
||||
['W_ERODE_SIDE', `vec3(${w.erodeSide.map(f).join(', ')})`],
|
||||
['W_ERODE_NORM', f(w.erodeNorm)],
|
||||
['W_ERODE_ENABLED', w.phacelleGradBound > 0 ? '1' : '0'],
|
||||
['W_SLOPE_ENV_INV', f(1 / Math.sqrt(1 + b.slopeEnvelope ** 2))],
|
||||
['W_SLOPE_FULL_INV', f(1 / Math.sqrt(1 + b.slopeFull ** 2))],
|
||||
['W_MAX_HEIGHT', f(Math.max(b.maxHeight, t.canopyY + t.canopyRadius[1] + 6))],
|
||||
['W_TRUNK_H', f(t.trunkHeight)],
|
||||
['W_TRUNK_R0', f(t.baseRadius)],
|
||||
['W_TRUNK_R1', f(t.topRadius)],
|
||||
['W_CANOPY_Y', f(t.canopyY)],
|
||||
['W_CANOPY_R', `vec3(${t.canopyRadius.map(f).join(', ')})`],
|
||||
['W_ATM_FAR', f(w.atmosphereFar)],
|
||||
['W_CLOUD_Y0', f(w.cloudBase)],
|
||||
['W_CLOUD_Y1', f(w.cloudTop)],
|
||||
['W_FOG_DENSITY', f(w.fogDensity)],
|
||||
['W_FOG_FALLOFF', f(w.fogFalloff)],
|
||||
];
|
||||
return lines.map(([k, v]) => `#define ${k} ${v}`).join('\n');
|
||||
}
|
||||
Loading…
Reference in a new issue