scene: cone prepass starts pass-1 rays past empty space; opt-in steps/pixel readout
Pass 0 marches one ray per 8x8 tile of pass 1, each step shrunk by the widest any ray of the tile strays from it, and stores the distance no ray of the tile can have hit before (W_FAR when the whole tile climbs out above the highest geometry). Pass 1 starts from it. The distance bound moves to sdf.glsl so both passes step by the same function. onTraceStats adds a step-count render target to pass 1 and reads it back every 8th frame through a pixel-pack buffer and fence, outside the timed span. Unset, neither the output nor the target exists. prepass:false is the baseline. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
95e24aadb5
commit
87c9ca6030
7 changed files with 369 additions and 123 deletions
|
|
@ -31,11 +31,16 @@ callbacks and the creature sizing rule. Nothing has drawn a creature.
|
|||
- `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),
|
||||
- `shaders/` `sdf.glsl` (the distance bound pass 0 and pass 1 share), pass 0 `prepass.frag.glsl`, 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
|
||||
|
||||
0. **Cone prepass** (`prepass.frag.glsl`, one texel per 8x8 tile of pass 1): marches one ray per
|
||||
tile, shrinking each step by the widest any ray of the tile strays from it, so every step is
|
||||
free for the whole tile. It stores the distance no ray of the tile can have hit before, or
|
||||
`W_FAR` when the whole tile climbs out above the highest geometry. Pass 1 starts its rays
|
||||
there. `createScene({ prepass: false })` skips it (the measurement baseline).
|
||||
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
|
||||
|
|
@ -47,6 +52,10 @@ callbacks and the creature sizing rule. Nothing has drawn a creature.
|
|||
4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES
|
||||
tonemap, dither.
|
||||
|
||||
`createScene({ onTraceStats })` measures the trace: pass 1 gets a step-count output and an extra
|
||||
render target, read back every 8th frame through a pixel-pack buffer and a fence (never waited
|
||||
on), and reports mean steps per pixel. Unset, neither the output nor the target exists.
|
||||
|
||||
## 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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { createScene, ShaderCompileError } from './Scene';
|
||||
import { LEVELS } from './budget';
|
||||
import { fragmentSource } from './shaderSource';
|
||||
import { startBackgroundScene } from './index';
|
||||
|
||||
type Fn = Mock;
|
||||
|
|
@ -94,7 +95,7 @@ describe('createScene tiers', () => {
|
|||
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(calls.drawArrays).toHaveBeenCalledTimes(5); // prepass and four passes, one frame
|
||||
expect(rafQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
|
@ -132,6 +133,44 @@ describe('createScene tiers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('cone prepass', () => {
|
||||
const programsFor = (prepass?: boolean) => {
|
||||
rafQueue.length = 0;
|
||||
const { gl, calls } = fakeGl();
|
||||
createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass });
|
||||
return calls.createProgram.mock.calls.length;
|
||||
};
|
||||
|
||||
it('is one more program than the baseline, and off means off', () => {
|
||||
expect(programsFor(true)).toBe(programsFor(false) + 1);
|
||||
expect(programsFor(undefined)).toBe(programsFor(true));
|
||||
});
|
||||
|
||||
it('draws before pass 1 and only when on', () => {
|
||||
const drawsFor = (prepass: boolean) => {
|
||||
rafQueue.length = 0;
|
||||
const { gl, calls } = fakeGl();
|
||||
createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass });
|
||||
runFrame(1000);
|
||||
return calls.drawArrays.mock.calls.length;
|
||||
};
|
||||
expect(drawsFor(true)).toBe(drawsFor(false) + 1);
|
||||
});
|
||||
|
||||
it('the trace-step output exists in pass 1 only when the host asked to measure', () => {
|
||||
expect(fragmentSource('terrain')).not.toContain('TRACE_STATS 1');
|
||||
expect(fragmentSource('terrain', '#define TRACE_STATS 1\n')).toContain('#define TRACE_STATS 1');
|
||||
});
|
||||
|
||||
it('shares one distance bound between pass 1 and the prepass', () => {
|
||||
const bound = (stage: 'terrain' | 'prepass') =>
|
||||
fragmentSource(stage).match(/float sceneDistance\([^)]*\)\s*\{[^}]*\}/)?.[0];
|
||||
expect(bound('prepass')).toBeDefined();
|
||||
expect(bound('prepass')).toBe(bound('terrain'));
|
||||
expect(fragmentSource('atmosphere')).not.toContain('sceneDistance');
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
|
|
@ -265,7 +304,7 @@ describe('startBackgroundScene', () => {
|
|||
);
|
||||
expect(scene).not.toBeNull();
|
||||
runFrame(1000);
|
||||
expect(calls.drawArrays).toHaveBeenCalledTimes(4);
|
||||
expect(calls.drawArrays).toHaveBeenCalledTimes(5);
|
||||
expect(rafQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ 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 type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite' | 'prepass';
|
||||
const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite', 'prepass'];
|
||||
/** Pass 1 pixels per prepass tile edge. */
|
||||
const TILE = 8;
|
||||
|
||||
export interface SceneStats {
|
||||
/** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */
|
||||
|
|
@ -21,6 +23,16 @@ export interface SceneStats {
|
|||
atmosphereSize: [number, number];
|
||||
}
|
||||
|
||||
/** Trace cost over one finished readback: mean distance evaluations per pass-1 pixel. */
|
||||
export interface TraceStats {
|
||||
/** Mean pass-1 steps per pixel. */
|
||||
traceSteps: number;
|
||||
/** Mean prepass steps per tile; 0 without the prepass. */
|
||||
prepassSteps: number;
|
||||
/** traceSteps plus the prepass's steps spread over the pixels of each tile. */
|
||||
stepsPerPixel: 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;
|
||||
|
||||
|
|
@ -48,10 +60,19 @@ export interface SceneOptions {
|
|||
onContextLost?: () => void;
|
||||
/** Called per finished frame when timer queries exist. */
|
||||
onStats?: (s: SceneStats) => void;
|
||||
/**
|
||||
* Measurement: mean sphere-trace steps per pass-1 pixel, read back every few frames. Setting it
|
||||
* compiles pass 1 with a step-count output and an extra render target; unset, neither exists.
|
||||
*/
|
||||
onTraceStats?: (s: TraceStats) => void;
|
||||
/** Weather knob, 0 clear .. 1 overcast. */
|
||||
coverage?: number;
|
||||
/** Fixed level instead of the adaptive ladder (measurement). */
|
||||
fixedLevel?: Level;
|
||||
/** Cone prepass that starts pass-1 rays past empty space. Default true; false is the measurement baseline. */
|
||||
prepass?: boolean;
|
||||
/** Hold the camera at this pull-back progress, 0..1, instead of following login (measurement). */
|
||||
fixedPull?: number;
|
||||
/** Ignore the 30 fps cap and the visibility pause (measurement). */
|
||||
unthrottled?: boolean;
|
||||
patchSource?: SourcePatch;
|
||||
|
|
@ -83,13 +104,14 @@ function compile(
|
|||
export function linkStage(
|
||||
gl: WebGL2RenderingContext,
|
||||
stage: Stage,
|
||||
patch?: SourcePatch
|
||||
patch?: SourcePatch,
|
||||
defines?: string
|
||||
): WebGLProgram {
|
||||
const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex');
|
||||
const fs = compile(
|
||||
gl,
|
||||
gl.FRAGMENT_SHADER,
|
||||
patch ? patch(stage, fragmentSource(stage)) : fragmentSource(stage),
|
||||
patch ? patch(stage, fragmentSource(stage, defines)) : fragmentSource(stage, defines),
|
||||
stage
|
||||
);
|
||||
const prog = gl.createProgram()!;
|
||||
|
|
@ -143,13 +165,20 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
GPU_DISJOINT_EXT: number;
|
||||
} | null;
|
||||
|
||||
const usePrepass = opts.prepass ?? true;
|
||||
const passes = usePrepass ? PASSES : PASSES.filter((p) => p !== 'prepass');
|
||||
const programs = {} as Record<PassName, WebGLProgram>;
|
||||
try {
|
||||
PASSES.forEach((p) => {
|
||||
programs[p] = linkStage(gl, p, opts.patchSource);
|
||||
passes.forEach((p) => {
|
||||
programs[p] = linkStage(
|
||||
gl,
|
||||
p,
|
||||
opts.patchSource,
|
||||
p === 'terrain' && opts.onTraceStats ? '#define TRACE_STATS 1\n' : undefined
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
PASSES.forEach((p) => {
|
||||
passes.forEach((p) => {
|
||||
if (programs[p]) gl.deleteProgram(programs[p]);
|
||||
});
|
||||
if (!(e instanceof ShaderCompileError)) throw e;
|
||||
|
|
@ -180,15 +209,18 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
|
||||
const fbos = {
|
||||
solid: gl.createFramebuffer()!,
|
||||
pre: gl.createFramebuffer()!,
|
||||
atm: gl.createFramebuffer()!,
|
||||
hist: [gl.createFramebuffer()!, gl.createFramebuffer()!],
|
||||
};
|
||||
let solid: Target;
|
||||
let depth: Target;
|
||||
let steps: Target | undefined;
|
||||
let startTex: Target;
|
||||
let atm: Target;
|
||||
let hist: [Target, Target];
|
||||
const freeTargets = () => {
|
||||
[solid, depth, atm, hist?.[0], hist?.[1]].forEach((t) => {
|
||||
[solid, depth, steps, startTex, atm, hist?.[0], hist?.[1]].forEach((t) => {
|
||||
if (t) gl.deleteTexture(t.tex);
|
||||
});
|
||||
};
|
||||
|
|
@ -205,6 +237,71 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0);
|
||||
const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current);
|
||||
|
||||
// Trace-step readback: one pixel-pack read in flight at a time, polled by fence, never waited on.
|
||||
// Pass 1's step bytes and the prepass's RGBA floats (g: steps) come back together.
|
||||
const READBACK_EVERY = 8;
|
||||
interface Readback {
|
||||
fence: WebGLSync;
|
||||
pixels: number;
|
||||
tiles: number;
|
||||
}
|
||||
let readback: Readback | undefined;
|
||||
const packSteps = opts.onTraceStats ? gl.createBuffer() : null;
|
||||
const packStart = opts.onTraceStats && usePrepass ? gl.createBuffer() : null;
|
||||
const discardReadback = () => {
|
||||
if (readback) gl.deleteSync(readback.fence);
|
||||
readback = undefined;
|
||||
};
|
||||
const startReadback = () => {
|
||||
if (!steps || readback) return;
|
||||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps);
|
||||
gl.bufferData(gl.PIXEL_PACK_BUFFER, steps.w * steps.h, gl.STREAM_READ);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid);
|
||||
gl.readBuffer(gl.COLOR_ATTACHMENT0 + 2);
|
||||
gl.readPixels(0, 0, steps.w, steps.h, gl.RED, gl.UNSIGNED_BYTE, 0);
|
||||
let tiles = 0;
|
||||
if (packStart) {
|
||||
tiles = startTex.w * startTex.h;
|
||||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart);
|
||||
gl.bufferData(gl.PIXEL_PACK_BUFFER, tiles * 16, gl.STREAM_READ);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre);
|
||||
gl.readPixels(0, 0, startTex.w, startTex.h, gl.RGBA, gl.FLOAT, 0);
|
||||
}
|
||||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
|
||||
readback = {
|
||||
fence: gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0)!,
|
||||
pixels: steps.w * steps.h,
|
||||
tiles,
|
||||
};
|
||||
};
|
||||
const pollReadback = () => {
|
||||
if (!readback) return;
|
||||
const status = gl.clientWaitSync(readback.fence, 0, 0);
|
||||
if (status !== gl.ALREADY_SIGNALED && status !== gl.CONDITION_SATISFIED) return;
|
||||
const { pixels, tiles } = readback;
|
||||
const bytes = new Uint8Array(pixels);
|
||||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps);
|
||||
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, bytes);
|
||||
let preSum = 0;
|
||||
if (packStart) {
|
||||
const floats = new Float32Array(tiles * 4);
|
||||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart);
|
||||
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, floats);
|
||||
for (let i = 1; i < floats.length; i += 4) preSum += floats[i];
|
||||
}
|
||||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < bytes.length; i += 1) sum += bytes[i];
|
||||
discardReadback();
|
||||
const traceSteps = sum / pixels;
|
||||
const prepassSteps = tiles ? preSum / tiles : 0;
|
||||
opts.onTraceStats?.({
|
||||
traceSteps,
|
||||
prepassSteps,
|
||||
stepsPerPixel: traceSteps + (prepassSteps * tiles) / pixels,
|
||||
});
|
||||
};
|
||||
|
||||
let canvasW = 0;
|
||||
let canvasH = 0;
|
||||
let sizedFor: Level | undefined;
|
||||
|
|
@ -228,9 +325,16 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
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);
|
||||
// Without the prepass pass 1 reads a 1x1 zero start: every ray from the camera, as before.
|
||||
startTex = usePrepass
|
||||
? makeTarget(Math.ceil(sw / TILE), Math.ceil(sh / TILE), gl.RGBA32F, gl.NEAREST)
|
||||
: makeTarget(1, 1, gl.RGBA32F, gl.NEAREST);
|
||||
steps = opts.onTraceStats ? makeTarget(sw, sh, gl.R8, gl.NEAREST) : undefined;
|
||||
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.solid, steps ? [solid, depth, steps] : [solid, depth]);
|
||||
discardReadback();
|
||||
attach(fbos.pre, [startTex]);
|
||||
attach(fbos.atm, [atm]);
|
||||
attach(fbos.hist[0], [hist[0]]);
|
||||
attach(fbos.hist[1], [hist[1]]);
|
||||
|
|
@ -274,10 +378,10 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
const row = finished.get(f) ?? {};
|
||||
if (!disjoint) row[pass] = ns / 1e6;
|
||||
finished.set(f, row);
|
||||
if (Object.keys(row).length === PASSES.length) {
|
||||
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);
|
||||
const passMs = { prepass: 0, ...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,
|
||||
|
|
@ -318,12 +422,26 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
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 pose = cameraPose(opts.fixedPull ?? 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 0: cone march at one ray per 8x8 tile of pass 1, the start distance for its rays.
|
||||
if (usePrepass) {
|
||||
timed('prepass', f, () => {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre);
|
||||
gl.viewport(0, 0, startTex.w, startTex.h);
|
||||
const p = programs.prepass;
|
||||
gl.useProgram(p);
|
||||
setCommon(p, pose, time);
|
||||
gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps);
|
||||
gl.uniform2f(loc(p, 'u_tileHalf'), 0.5 / startTex.w, 0.5 / startTex.h);
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
// Pass 1: solid surfaces, writes colour and depth.
|
||||
timed('terrain', f, () => {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid);
|
||||
|
|
@ -336,8 +454,10 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
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);
|
||||
bindTex(0, startTex, p, 'u_start');
|
||||
draw();
|
||||
});
|
||||
if (f % READBACK_EVERY === 0) startReadback(); // outside the timed span: not pass 1's cost
|
||||
|
||||
// Pass 2: atmosphere up to pass 1's depth, low-res.
|
||||
timed('atmosphere', f, () => {
|
||||
|
|
@ -393,6 +513,7 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
prev = pose;
|
||||
resetHistory = false;
|
||||
collect();
|
||||
pollReadback();
|
||||
};
|
||||
|
||||
const tick = (now: number) => {
|
||||
|
|
@ -468,9 +589,11 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
if (disposed) return;
|
||||
stop();
|
||||
freeTargets();
|
||||
PASSES.forEach((p) => gl.deleteProgram(programs[p]));
|
||||
passes.forEach((p) => gl.deleteProgram(programs[p]));
|
||||
discardReadback();
|
||||
[packSteps, packStart].forEach((b) => b && gl.deleteBuffer(b));
|
||||
gl.deleteVertexArray(vao);
|
||||
[fbos.solid, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb));
|
||||
[fbos.solid, fbos.pre, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ 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 sdf from './shaders/sdf.glsl?raw';
|
||||
import prepassFrag from './shaders/prepass.frag.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';
|
||||
|
|
@ -9,7 +11,7 @@ import compositeFrag from './shaders/composite.frag.glsl?raw';
|
|||
import { WORLD, worldDefines } from './world';
|
||||
import { trimGlsl } from './cultmathTrim';
|
||||
|
||||
export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite';
|
||||
export type Stage = 'prepass' | 'terrain' | 'atmosphere' | 'resolve' | 'composite';
|
||||
|
||||
const HEADER =
|
||||
'#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n';
|
||||
|
|
@ -17,6 +19,7 @@ const HEADER =
|
|||
export const vertexSource = HEADER + fullscreenVert;
|
||||
|
||||
const stages: Record<Stage, string> = {
|
||||
prepass: prepassFrag,
|
||||
terrain: terrainFrag,
|
||||
atmosphere: atmosphereFrag,
|
||||
resolve: resolveFrag,
|
||||
|
|
@ -25,10 +28,14 @@ const stages: Record<Stage, string> = {
|
|||
|
||||
/**
|
||||
* 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.
|
||||
* shared block, the distance bound (stages that trace), the stage. `defines` is whole `#define`
|
||||
* lines (measurement flags) ahead of the world's. 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]}`;
|
||||
export function fragmentSource(stage: Stage, defines = ''): string {
|
||||
const solid = stage === 'terrain' || stage === 'prepass';
|
||||
const erosion = solid && WORLD.phacelleGradBound > 0 ? phacelle : '';
|
||||
const world = solid ? sdf : '';
|
||||
const math = trimGlsl(cultmath, [erosion, common, world, stages[stage]]);
|
||||
return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${stages[stage]}`;
|
||||
}
|
||||
|
|
|
|||
49
src/app/features/scene/shaders/prepass.frag.glsl
Normal file
49
src/app/features/scene/shaders/prepass.frag.glsl
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Pass 0: a cone march at one ray per tile of pass-1 pixels. Writes the distance along the tile's
|
||||
// centre ray that no ray in the tile can have hit before, so pass 1 starts there instead of at the
|
||||
// camera. Every step is safe for every ray of the tile: the cone's radius at t is the widest any
|
||||
// ray strays from the centre ray, and the centre's distance bound minus that radius is a free ball
|
||||
// around each ray's own point.
|
||||
uniform int u_maxSteps;
|
||||
uniform vec2 u_tileHalf; // half a tile in uv: the footprint of this texel, as pass 1 samples it
|
||||
|
||||
in vec2 v_uv;
|
||||
layout(location = 0) out vec4 o_start; // r: start distance, W_FAR when the tile misses; g: steps spent
|
||||
|
||||
void main()
|
||||
{
|
||||
vec3 ro = u_camPos;
|
||||
vec3 rd = camRay(v_uv);
|
||||
|
||||
// Widest stray of any ray in the tile from the centre ray, per unit t. The rays are the unit
|
||||
// directions through a rectangle on the view plane, whose sublevel sets about rd are convex, so
|
||||
// the maximum sits at a corner. The margin is for float rounding.
|
||||
float spread = 0.0;
|
||||
for (int c = 0; c < 4; c++)
|
||||
{
|
||||
vec2 corner = vec2(c & 1, c >> 1) * 2.0 - 1.0;
|
||||
spread = max(spread, length(camRay(v_uv + corner * u_tileHalf) - rd));
|
||||
}
|
||||
spread *= 1.05;
|
||||
|
||||
// Past tEnd every ray of the tile is above W_MAX_HEIGHT and still rising (or beyond W_FAR):
|
||||
// none can hit anything.
|
||||
float rise = rd.y - spread;
|
||||
float tEnd = W_FAR;
|
||||
if (rise > 0.0)
|
||||
tEnd = min(W_FAR, max(0.0, (W_MAX_HEIGHT - ro.y) / rise));
|
||||
|
||||
float t = 0.0;
|
||||
float leafness;
|
||||
bool onTree;
|
||||
int evals = 0;
|
||||
for (int i = 0; i < u_maxSteps && t < tEnd; i++)
|
||||
{
|
||||
evals++;
|
||||
// Pass 1's hit tolerance is kept clear, so a ray never starts inside its own hit radius.
|
||||
float s = sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01);
|
||||
if (s <= 0.0)
|
||||
break;
|
||||
t += s;
|
||||
}
|
||||
o_start = vec4(t >= tEnd ? W_FAR : t, float(evals), 0.0, 0.0);
|
||||
}
|
||||
95
src/app/features/scene/shaders/sdf.glsl
Normal file
95
src/app/features/scene/shaders/sdf.glsl
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// The solid world as a distance bound: terrain graph plus tree. Shared by the pass-1 trace and the
|
||||
// cone prepass, so both step by the same bound.
|
||||
|
||||
// ---- 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);
|
||||
}
|
||||
|
|
@ -5,128 +5,49 @@ uniform vec3 u_rock;
|
|||
uniform vec3 u_bark;
|
||||
uniform vec3 u_leaf;
|
||||
uniform int u_maxSteps;
|
||||
uniform sampler2D u_start; // prepass: r = distance no ray of this pixel's tile hits before; W_FAR = tile misses
|
||||
|
||||
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);
|
||||
}
|
||||
#ifdef TRACE_STATS
|
||||
layout(location = 2) out float o_steps; // distance evaluations this ray spent, over 255 (measurement only)
|
||||
#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.
|
||||
float t = texture(u_start, v_uv).r;
|
||||
// Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. A tile the
|
||||
// prepass proved empty arrives as W_FAR.
|
||||
bool missAtOnce = t >= W_FAR;
|
||||
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;
|
||||
missAtOnce = missAtOnce || rd.y >= 0.0;
|
||||
t = max(t, (W_MAX_HEIGHT - ro.y) / rd.y);
|
||||
}
|
||||
if (missAtOnce)
|
||||
{
|
||||
o_color = vec4(0.0);
|
||||
o_depth = W_FAR;
|
||||
#ifdef TRACE_STATS
|
||||
o_steps = 0.0;
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
bool hit = false;
|
||||
bool onTree = false;
|
||||
float leafness = 0.0;
|
||||
int i = 0;
|
||||
int evals = 0;
|
||||
for (; i < u_maxSteps; i++)
|
||||
{
|
||||
evals++;
|
||||
vec3 p = ro + rd * t;
|
||||
float d = sceneDistance(p, leafness, onTree);
|
||||
if (d < 0.0006 * t + 0.01)
|
||||
|
|
@ -145,6 +66,9 @@ void main()
|
|||
hit = true;
|
||||
sceneDistance(ro + rd * t, leafness, onTree);
|
||||
}
|
||||
#ifdef TRACE_STATS
|
||||
o_steps = float(evals) / 255.0;
|
||||
#endif
|
||||
|
||||
if (!hit)
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue