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 { Gpu, Surface, Target } from 'vgpu';
import type { BrowserRendererOptions, ExampleRenderer, RenderSize, ThumbnailOptions } from '../../lib/example-renderer';
import { installLightPaintInput } from './pointer-input';
import { createScene, destroyScene, prepareScene, presentScene, runChain, type RadianceScene } from './simulation';
import { DEFAULT_RADIANCE_CASCADES_CONTROLS, type RadianceCascadesControls } from './types';
import { renderThumb, type RadianceCascadesStats } from './validation';
import { surface } from "vgpu";
export interface RadianceCascadesRenderer extends ExampleRenderer<RadianceCascadesControls> {
/** Removes every painted stroke; the triangle stays. */
clear(): void;
}
export interface RadianceCascadesRendererOptions extends BrowserRendererOptions<RadianceCascadesControls> {
/** Reports how many cascades the current canvas needs, so the view list can match. */
onCascadeCount?: (count: number) => void;
}
export interface RadianceCascadesThumbnailOptions extends ThumbnailOptions {
scriptedStroke?: boolean;
onStateValidated?: (stats: RadianceCascadesStats) => void;
}
export function createRenderer(options: RadianceCascadesRendererOptions): RadianceCascadesRenderer {
let disposed = false;
let reportedError = false;
let controls: RadianceCascadesControls = options.initialControls ?? DEFAULT_RADIANCE_CASCADES_CONTROLS;
let gpu: Gpu | undefined;
let canvasSurface: Surface | undefined;
let scene: RadianceScene | undefined;
let input: ReturnType<typeof installLightPaintInput> | undefined;
let observer: ResizeObserver | undefined;
let unsubscribeResize: (() => void) | undefined;
let animationFrame = 0;
let resizeFrame = 0;
let pendingSize: RenderSize | undefined;
let lastDpr = typeof window === 'undefined' ? 1 : window.devicePixelRatio;
let sawInitialResize = false;
let rebuilding = false;
/** The chain only runs when something it depends on changed. */
let dirty = true;
let clearRequested = false;
let lastView = controls.view;
const handleFailure = (error: unknown) => {
if (disposed) return;
if (!reportedError) {
reportedError = true;
try { options.onError?.(error); } catch { /* error reporting must not block teardown */ }
}
dispose();
};
const rebuildScene = () => {
if (disposed || !gpu || !canvasSurface) return;
rebuilding = true;
try {
const next = createScene(gpu, [canvasSurface.size[0], canvasSurface.size[1]], 'radiance-cascades-live');
if (scene) destroyScene(scene);
scene = next;
options.onCascadeCount?.(next.cascadeCount);
// A resized canvas is a new emitter texture: the triangle is redrawn, the strokes
// are gone, and the whole chain has to run again before anything can be presented.
clearRequested = true;
dirty = true;
void prepareScene(next, canvasSurface.format).catch(handleFailure);
} catch (error) {
handleFailure(error);
} finally {
rebuilding = false;
}
};
const onSurfaceResize = () => {
// The surface replays its size on subscribe, and the scene was just built at it.
if (!sawInitialResize) { sawInitialResize = true; return; }
if (rebuilding) return;
rebuildScene();
};
const applyResize = () => {
resizeFrame = 0;
const size = pendingSize;
pendingSize = undefined;
if (disposed || !size || !canvasSurface) return;
try {
canvasSurface.resize([
Math.max(1, Math.round(size.width * size.dpr)),
Math.max(1, Math.round(size.height * size.dpr)),
]);
} catch (error) {
handleFailure(error);
}
};
const resize = (size: RenderSize) => {
if (disposed || size.width <= 0 || size.height <= 0) return;
pendingSize = size;
if (!resizeFrame) resizeFrame = requestAnimationFrame(applyResize);
};
const measure = () => {
const rect = options.canvas.getBoundingClientRect();
resize({
width: rect.width,
height: rect.height,
dpr: Math.min(2, Math.max(1, window.devicePixelRatio || 1)),
});
};
const onWindowResize = () => {
if (window.devicePixelRatio === lastDpr) return;
lastDpr = window.devicePixelRatio;
measure();
};
const setControls = (next: Readonly<RadianceCascadesControls>) => {
if (disposed) return;
controls = { ...next };
// A cascade view stops the descent early, so switching views changes what the chain
// has to compute, not just what the present pass reads.
if (controls.view !== lastView) {
lastView = controls.view;
dirty = true;
}
};
const clear = () => {
if (disposed) return;
clearRequested = true;
dirty = true;
};
// One dirty frame is a paint, a jump flood and six cascade merges, all reading what the
// previous pass wrote. They go out as a single submit from a plain rAF loop instead of
// `frameLoop`, which owns the frame and would not let the present pass be its own.
const tick = () => {
animationFrame = 0;
if (disposed) return;
if (!document.hidden && gpu && canvasSurface && scene && input) {
try {
const segment = input.take();
if (segment) dirty = true;
if (dirty) {
runChain(scene, {
segment,
keepPrevious: !clearRequested,
view: controls.view,
});
clearRequested = false;
dirty = false;
}
// Presenting every frame keeps the swap chain fed; it costs one fullscreen pass
// and never re-traces, so an untouched canvas is essentially free.
presentScene(scene, canvasSurface, controls.view);
} catch (error) {
handleFailure(error);
return;
}
}
animationFrame = requestAnimationFrame(tick);
};
function dispose(): void {
if (disposed) return;
disposed = true;
if (animationFrame) cancelAnimationFrame(animationFrame);
animationFrame = 0;
if (resizeFrame) cancelAnimationFrame(resizeFrame);
resizeFrame = 0;
pendingSize = undefined;
observer?.disconnect();
observer = undefined;
if (typeof window !== 'undefined') window.removeEventListener('resize', onWindowResize);
unsubscribeResize?.();
unsubscribeResize = undefined;
input?.dispose();
input = undefined;
if (scene) destroyScene(scene);
scene = undefined;
canvasSurface?.dispose();
canvasSurface = undefined;
gpu?.dispose();
gpu = undefined;
}
const initialize = async () => {
const { init } = await import('vgpu');
if (disposed) return;
const nextGpu = await init();
if (disposed) { nextGpu.dispose(); return; }
gpu = nextGpu;
canvasSurface = surface(gpu, options.canvas, { dpr: [1, 2] });
scene = createScene(gpu, [canvasSurface.size[0], canvasSurface.size[1]], 'radiance-cascades-live');
options.onCascadeCount?.(scene.cascadeCount);
await prepareScene(scene, canvasSurface.format);
if (disposed) return;
input = installLightPaintInput(options.canvas);
unsubscribeResize = canvasSurface.onResize(onSurfaceResize);
observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);
observer?.observe(options.canvas);
window.addEventListener('resize', onWindowResize);
measure();
animationFrame = requestAnimationFrame(tick);
};
const ready = initialize().catch((error: unknown) => {
if (disposed) return;
handleFailure(error);
throw error;
});
return { ready, setControls, clear, invalidate() { dirty = true; }, resize, dispose };
}
export async function renderThumbnail(
gpu: Gpu,
target: Target,
options: RadianceCascadesThumbnailOptions = {},
): Promise<void> {
await renderThumb(gpu, target, options);
}