Radiance Cascades
Draw light with the pointer and watch it bounce: a jump-flooded distance field feeds six radiance cascades — base 4, geometric intervals, linear RGBA16F — merged top-down with visibility alpha into 2D global illumination.
import type { Effect, Gpu, Surface, Target } from 'vgpu';
import {
atlasSizeFor,
cascadeCountForSize,
jfaJumps,
RC_INTERVAL0,
RC_OVERLAP,
strokeRadiance,
type Vec2,
} from './math';
import type { PaintSegment } from './pointer-input';
import { resolveView, type RadianceView } from './types';
import paintEmitterWgsl from './paint-emitter.wgsl';
import jfaInitWgsl from './jfa-init.wgsl';
import jfaPassWgsl from './jfa-pass.wgsl';
import sdfFinalizeWgsl from './sdf-finalize.wgsl';
import radianceCascadeWgsl from './radiance-cascade.wgsl';
import presentWgsl from './present.wgsl';
import { effect, frame, sampler, target } from "vgpu";
type Output = Surface | Target;
/**
* Linear HDR everywhere upstream of `present`.
*
* rgba8 would band the falloff and clip the emitters long before the merge; the research
* lists it as one of the optimisations that quietly destroys the technique.
*/
const HDR_FORMAT: GPUTextureFormat = 'rgba16float';
/**
* Jump-flood seeds hold absolute pixel coordinates.
*
* f16 cannot represent every integer past 2048, and a seed rounded by one texel becomes a
* distance field wrong by one texel, which sphere tracing turns into visible leaks. The
* seeds are only ever `textureLoad`ed, so the format never needs filtering.
*/
const SEED_FORMAT: GPUTextureFormat = 'rgba32float';
/** Grid: ~1px darker rules every 48 physical pixels. Low contrast on purpose. */
const GRID_CELL = 48;
const GRID_LINE_WIDTH = 1;
const GRID_BASE_ALBEDO = 0.38;
const GRID_LINE_ALBEDO = 0.21;
/** Keeps unlit corners from being pure black without washing out the shadows. */
const AMBIENT = 0.01;
/** Below 1 so the lit grid keeps the colour of the light instead of clipping to white. */
const EXPOSURE = 0.85;
/** Stroke half-width in physical pixels. */
const STROKE_RADIUS = 5;
/** Triangle apex distance, as a fraction of the smaller canvas side. */
const TRIANGLE_SCALE = 0.11;
/** One band of the SDF debug ramp. */
const SDF_DEBUG_PERIOD = 64;
export interface RadianceScene {
readonly gpu: Gpu;
readonly label: string;
/** Emitter / SDF resolution, in physical pixels: exactly the surface. */
readonly size: Vec2;
/** Shared cascade atlas size; identical for every level. */
readonly atlas: Vec2;
readonly cascadeCount: number;
readonly jumps: readonly number[];
/** Ping-pong: `emitter[0]` always holds the current emitters. */
emitter: [Target, Target];
jfa: [Target, Target];
readonly sdf: Target;
/** Two recycled atlases; `cascades[0]` holds the most recently written level. */
cascades: [Target, Target];
readonly effects: {
readonly paint: Effect;
readonly jfaInit: Effect;
readonly jfaSteps: readonly Effect[];
readonly sdfFinalize: Effect;
readonly cascade: readonly Effect[];
readonly present: Effect;
};
readonly sampler: GPUSampler;
/** Increments on every painted stroke; drives the emitter palette. */
strokes: number;
/** Lowest cascade the last chain run computed. */
resolvedTo: number;
}
export function createScene(gpu: Gpu, size: Vec2, label: string): RadianceScene {
const width = Math.max(1, Math.floor(size[0]));
const height = Math.max(1, Math.floor(size[1]));
const cascadeCount = cascadeCountForSize(width, height);
const { atlas } = atlasSizeFor(width, height, cascadeCount);
const jumps = jfaJumps(Math.max(width, height));
const created: Target[] = [];
try {
const scene: Vec2 = [width, height];
const emitter: [Target, Target] = [
target(gpu, { size: [width, height], format: HDR_FORMAT, label: `${label}-emitter-a` }),
target(gpu, { size: [width, height], format: HDR_FORMAT, label: `${label}-emitter-b` }),
];
created.push(...emitter);
const jfa: [Target, Target] = [
target(gpu, { size: [width, height], format: SEED_FORMAT, label: `${label}-jfa-a` }),
target(gpu, { size: [width, height], format: SEED_FORMAT, label: `${label}-jfa-b` }),
];
created.push(...jfa);
const sdf = target(gpu, { size: [width, height], format: HDR_FORMAT, label: `${label}-sdf` });
created.push(sdf);
// Two atlases, reused from the top of the hierarchy down; six resident levels would
// cost six times the memory for exactly the same result.
const cascades: [Target, Target] = [
target(gpu, { size: [atlas[0], atlas[1]], format: HDR_FORMAT, label: `${label}-cascade-a` }),
target(gpu, { size: [atlas[0], atlas[1]], format: HDR_FORMAT, label: `${label}-cascade-b` }),
];
created.push(...cascades);
return {
gpu,
label,
size: scene,
atlas,
cascadeCount,
jumps,
emitter,
jfa,
sdf,
cascades,
effects: {
paint: effect(gpu, paintEmitterWgsl, { label: `${label}-paint` }),
jfaInit: effect(gpu, jfaInitWgsl, { label: `${label}-jfa-init` }),
// One effect instance per pass: `set()` writes immediately, so passes sharing an
// instance inside a frame would all run with the last jump distance written.
jfaSteps: jumps.map((_, index) => effect(gpu, jfaPassWgsl, { label: `${label}-jfa-${index}` })),
sdfFinalize: effect(gpu, sdfFinalizeWgsl, { label: `${label}-sdf-finalize` }),
cascade: Array.from({ length: cascadeCount }, (_, index) =>
effect(gpu, radianceCascadeWgsl, { label: `${label}-cascade-${index}` })),
present: effect(gpu, presentWgsl, { label: `${label}-present` }),
},
sampler: sampler(gpu, {
minFilter: 'linear',
magFilter: 'linear',
// Screen space: a ray leaving the canvas must not wrap around to the other side.
addressModeU: 'clamp-to-edge',
addressModeV: 'clamp-to-edge',
}),
strokes: 0,
resolvedTo: 0,
};
} catch (error) {
for (const colorTarget of created) destroyTarget(colorTarget);
throw error;
}
}
/** Compiles every pipeline against the formats it will actually render into. */
export async function prepareScene(scene: RadianceScene, outputFormat: GPUTextureFormat): Promise<void> {
await Promise.all([
scene.effects.paint.compile({ colors: [HDR_FORMAT] }),
scene.effects.jfaInit.compile({ colors: [SEED_FORMAT] }),
...scene.effects.jfaSteps.map((shader) => shader.compile({ colors: [SEED_FORMAT] })),
scene.effects.sdfFinalize.compile({ colors: [HDR_FORMAT] }),
...scene.effects.cascade.map((shader) => shader.compile({ colors: [HDR_FORMAT] })),
scene.effects.present.compile({ colors: [outputFormat] }),
]);
}
export function destroyScene(scene: RadianceScene): void {
for (const colorTarget of [...scene.emitter, ...scene.jfa, scene.sdf, ...scene.cascades]) destroyTarget(colorTarget);
}
function destroyTarget(colorTarget: Target): void {
(colorTarget as Target & { destroy?: () => void }).destroy?.();
}
export interface ChainOptions {
/** Segment to paint this run; omitted while the pointer is idle. */
readonly segment?: PaintSegment;
/** False resets the canvas to the triangle alone. */
readonly keepPrevious?: boolean;
/** Which view the present pass will show; decides how deep the descent has to go. */
readonly view?: RadianceView;
}
interface ChainPass {
readonly name: string;
readonly target: Target;
readonly effect: Effect;
}
/**
* Binds and orders the whole dirty chain, without submitting it.
*
* Returning the passes instead of encoding them lets the live renderer submit all of them
* in one frame, and the debug harness submit them one at a time so every intermediate can
* be read back — the same bindings and the same order either way.
*/
function buildChain(scene: RadianceScene, options: ChainOptions): ChainPass[] {
const { size, atlas, effects } = scene;
const stopAt = resolveView(options.view ?? 'final', scene.cascadeCount).stopAt;
const keepPrevious = options.keepPrevious ?? true;
const segment = options.segment;
if (segment) scene.strokes = Math.max(scene.strokes, segment.stroke);
const passes: ChainPass[] = [];
// 1. Emitters. Ping-pong because a pass cannot sample the target it draws into.
const [emitterRead, emitterWrite] = scene.emitter;
effects.paint.set({
paint: {
size: [size[0], size[1]],
stroke_from: segment ? [segment.from[0] * size[0], segment.from[1] * size[1]] : [0, 0],
stroke_to: segment ? [segment.to[0] * size[0], segment.to[1] * size[1]] : [0, 0],
radius: STROKE_RADIUS,
keep_previous: keepPrevious ? 1 : 0,
color: strokeRadiance(segment?.stroke ?? 0),
stroke_active: segment ? 1 : 0,
triangle_centre: [size[0] * 0.5, size[1] * 0.5],
triangle_radius: Math.min(size[0], size[1]) * TRIANGLE_SCALE,
_pad: 0,
},
previous: emitterRead,
});
passes.push({ name: 'emitters', target: emitterWrite, effect: effects.paint });
scene.emitter = [emitterWrite, emitterRead];
// 2. Jump flood: seed from the emitter mask, then halve the jump until it reaches 1.
effects.jfaInit.set({ jfa: { size: [size[0], size[1]], threshold: 0.5, _pad: 0 }, emitter: emitterWrite });
passes.push({ name: 'jfa-init', target: scene.jfa[0], effect: effects.jfaInit });
let seedRead = scene.jfa[0];
let seedWrite = scene.jfa[1];
scene.jumps.forEach((jump, index) => {
const shader = effects.jfaSteps[index]!;
shader.set({ jfa: { size: [size[0], size[1]], jump, _pad: 0 }, seeds: seedRead });
passes.push({ name: `jfa-step-${index}-jump-${jump}`, target: seedWrite, effect: shader });
const previous = seedRead;
seedRead = seedWrite;
seedWrite = previous;
});
scene.jfa = [seedRead, seedWrite];
// 3. Seeds become distances, in pixels.
effects.sdfFinalize.set({
sdf: { size: [size[0], size[1]], far: Math.hypot(size[0], size[1]) * 2, encode_scale: 1 },
seeds: seedRead,
});
passes.push({ name: 'sdf', target: scene.sdf, effect: effects.sdfFinalize });
// 4. Cascades, top-down: each level traces its own interval and merges the level above.
let atlasWrite = scene.cascades[0];
let atlasRead = scene.cascades[1];
for (let cascade = scene.cascadeCount - 1; cascade >= stopAt; cascade--) {
const shader = effects.cascade[cascade]!;
const hasUpper = cascade < scene.cascadeCount - 1;
shader.set({
rc: {
atlas_size: [atlas[0], atlas[1]],
scene_size: [size[0], size[1]],
cascade,
interval0: RC_INTERVAL0,
overlap: RC_OVERLAP,
sdf_scale: 1,
has_upper: hasUpper ? 1 : 0,
_pad0: 0,
_pad1: 0,
_pad2: 0,
},
sdf_tex: scene.sdf,
sdf_samp: scene.sampler,
emitter_tex: emitterWrite,
emitter_samp: scene.sampler,
// The top level has nothing above it; the binding still has to point somewhere.
upper_tex: atlasRead,
});
passes.push({ name: `cascade-${cascade}`, target: atlasWrite, effect: shader });
const previous = atlasRead;
atlasRead = atlasWrite;
atlasWrite = previous;
}
scene.cascades = [atlasRead, atlasWrite];
scene.resolvedTo = stopAt;
return passes;
}
/** The live path: paint, flood, trace and merge, all in one submit. */
export function runChain(scene: RadianceScene, options: ChainOptions = {}): void {
const passes = buildChain(scene, options);
frame(scene.gpu, (currentFrame) => {
for (const pass of passes) {
currentFrame.pass({ target: pass.target, clear: [0, 0, 0, 0] }, (encoder) => encoder.draw(pass.effect));
}
});
}
/**
* The debug path: the same passes, one submit each, with a hook after every one.
*
* Splitting the submits is what makes the intermediates readable — the live renderer never
* takes this route.
*/
export async function runChainStaged(
scene: RadianceScene,
options: ChainOptions,
onStage: (name: string, colorTarget: Target) => Promise<void> | void,
): Promise<void> {
for (const pass of buildChain(scene, options)) {
frame(scene.gpu, (currentFrame) => {
currentFrame.pass({ target: pass.target, clear: [0, 0, 0, 0] }, (encoder) => encoder.draw(pass.effect));
});
await scene.gpu.gpu.queue.onSubmittedWorkDone();
await onStage(pass.name, pass.target);
}
}
/** Presents the cached radiance. Cheap: no tracing happens here. */
export function presentScene(scene: RadianceScene, output: Output, view: RadianceView): void {
const resolved = resolveView(view, scene.cascadeCount);
scene.effects.present.set({
present: {
size: [output.size[0], output.size[1]],
atlas_size: [scene.atlas[0], scene.atlas[1]],
exposure: EXPOSURE,
view: resolved.mode,
sdf_period: SDF_DEBUG_PERIOD,
grid_cell: GRID_CELL,
grid_line_width: GRID_LINE_WIDTH,
grid_base: GRID_BASE_ALBEDO,
grid_line: GRID_LINE_ALBEDO,
ambient: AMBIENT,
},
cascade_tex: scene.cascades[0],
emitter_tex: scene.emitter[0],
sdf_tex: scene.sdf,
});
frame(scene.gpu, (currentFrame) => {
currentFrame.pass({ target: output, clear: [0, 0, 0, 1] }, (encoder) => encoder.draw(scene.effects.present));
});
}
export { HDR_FORMAT, SEED_FORMAT, STROKE_RADIUS, TRIANGLE_SCALE };