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.

Open fullscreen
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);
}