Black Hole

Raymarched gravitational lensing — null geodesics bend starlight around an event horizon while a Keplerian accretion disk glows with Doppler beaming, graded through an HDR bloom chain.

Open fullscreen
import type { Effect, Frame, Gpu, Surface, Target } from 'vgpu';
 
import blackHoleWgsl from './black-hole.wgsl';
import blurWgsl from './blur.wgsl';
import brightPassWgsl from './bright-pass.wgsl';
import compositeWgsl from './composite.wgsl';
 
import type { BrowserRendererOptions, ExampleRenderer, RenderSize, ThumbnailOptions } from '../../lib/example-renderer';
import { clock, effect, frame, frameLoop, sampler, surface, target } from "vgpu";
 
type Output = Surface | Target;
type Orbit = readonly [number, number];
 
interface ThumbOptions extends ThumbnailOptions {
  onVariantRendered?: (
    variant: 'time-delta' | 'pointer-orbit',
    pixels: Uint8Array,
    size: readonly [number, number],
  ) => void | Promise<void>;
}
 
interface Effects {
  scene: Effect;
  brightPass: Effect;
  blurH1: Effect;
  blurV1: Effect;
  blurH2: Effect;
  blurV2: Effect;
  composite: Effect;
  sampler: GPUSampler;
}
 
interface Targets {
  scene: Target;
  bloomA: Target;
  bloomB: Target;
}
 
const HDR_FORMAT: GPUTextureFormat = 'rgba16float';
const BLOOM_HEIGHT = 360;
const CLEAR: readonly [number, number, number, number] = [0, 0, 0, 1];
 
export function createRenderer(options: BrowserRendererOptions): ExampleRenderer {
  let disposed = false;
  let gpu: Gpu | undefined;
  let canvasSurface: Surface | undefined;
  let effects: Effects | undefined;
  let targets: Targets | undefined;
  let input: ReturnType<typeof installOrbitInput> | undefined;
  let loop: { stop(): void } | undefined;
  let observer: ResizeObserver | undefined;
  let resizeFrame = 0;
  let pendingSize: RenderSize | undefined;
  let lastDpr = typeof window === 'undefined' ? 1 : window.devicePixelRatio;
  let reportedError = false;
 
  const applyResize = () => {
    resizeFrame = 0;
    const size = pendingSize;
    pendingSize = undefined;
    if (disposed || !size || !gpu || !effects || !targets || !canvasSurface) return;
    try {
      const previousTargets = targets;
      const nextTargets = createTargets(gpu, [
        Math.max(1, Math.round(size.width * size.dpr)),
        Math.max(1, Math.round(size.height * size.dpr)),
      ], 'black-hole-live');
      try {
        setBindings(effects, nextTargets, canvasSurface);
      } catch (error) {
        destroyTargets(nextTargets);
        throw error;
      }
      targets = nextTargets;
      destroyTargets(previousTargets);
    } 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(1.6, Math.max(1, window.devicePixelRatio || 1)) });
  };
  const onWindowResize = () => {
    if (window.devicePixelRatio === lastDpr) return;
    lastDpr = window.devicePixelRatio;
    measure();
  };
 
  const dispose = () => {
    if (disposed) return;
    disposed = true;
    loop?.stop();
    loop = undefined;
    if (resizeFrame) cancelAnimationFrame(resizeFrame);
    resizeFrame = 0;
    pendingSize = undefined;
    observer?.disconnect();
    observer = undefined;
    if (typeof window !== 'undefined') window.removeEventListener('resize', onWindowResize);
    input?.dispose();
    input = undefined;
    if (targets) destroyTargets(targets);
    targets = undefined;
    canvasSurface?.dispose();
    canvasSurface = undefined;
    gpu?.dispose();
    gpu = undefined;
    effects = 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, 1.6] });
    effects = createEffects(gpu, 'black-hole-live');
    targets = createTargets(gpu, canvasSurface.size, 'black-hole-live');
    setConstants(effects);
    setBindings(effects, targets, canvasSurface);
    await prewarm(effects, targets, canvasSurface);
    if (disposed) return;
    input = installOrbitInput(options.canvas);
    observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);
    observer?.observe(options.canvas);
    window.addEventListener('resize', onWindowResize);
    measure();
    const gpuClock = clock(gpu);
    loop = frameLoop(gpu, (currentFrame) => {
      if (disposed || !effects || !targets || !canvasSurface || !input) return;
      effects.scene.set({ params: { pointer: input.update(), time: gpuClock.time } });
      renderChain(currentFrame, effects, targets, canvasSurface);
    });
  };
 
  function handleFailure(error: unknown): void {
    if (disposed) return;
    if (!reportedError) {
      reportedError = true;
      try { options.onError?.(error); } catch { /* error reporting must not block teardown */ }
    }
    dispose();
  }
 
  const ready = initialize().catch((error: unknown) => {
    if (disposed) return;
    handleFailure(error);
    throw error;
  });
 
  return { ready, invalidate() {}, resize, dispose };
}
 
export async function renderThumbnail(gpu: Gpu, colorTarget: Target, opts: ThumbOptions = {}): Promise<void> {
  const effects = createEffects(gpu, 'black-hole-thumb');
  const targets = createTargets(gpu, colorTarget.size, 'black-hole-thumb');
  try {
    const time = opts.time ?? 8.5;
    setConstants(effects);
    setBindings(effects, targets, colorTarget);
    await prewarm(effects, targets, colorTarget);
 
    renderAt(gpu, effects, targets, colorTarget, time, [0, 0.05]);
    await gpu.gpu.queue.onSubmittedWorkDone();
 
    renderAt(gpu, effects, targets, colorTarget, time + 7, [0, 0.05]);
    await gpu.gpu.queue.onSubmittedWorkDone();
    await opts.onVariantRendered?.('time-delta', await colorTarget.read(), colorTarget.size);
 
    renderAt(gpu, effects, targets, colorTarget, time, [0.72, 0.34]);
    await gpu.gpu.queue.onSubmittedWorkDone();
    await opts.onVariantRendered?.('pointer-orbit', await colorTarget.read(), colorTarget.size);
 
    // Leave the deterministic poster framing in the output target.
    renderAt(gpu, effects, targets, colorTarget, time, [0, 0.05]);
  } finally {
    await Promise.allSettled([
      Promise.resolve().then(() => gpu.gpu.queue.onSubmittedWorkDone()),
      Promise.resolve().then(() => gpu.settled()),
    ]);
    destroyTargets(targets);
  }
}
 
function createEffects(gpu: Gpu, label: string): Effects {
  return {
    scene: effect(gpu, blackHoleWgsl, { label: `${label}-scene` }),
    brightPass: effect(gpu, brightPassWgsl, { label: `${label}-bright-pass` }),
    // Each pass owns its uniform buffer; mutating one effect repeatedly in a frame
    // would make all encoded passes observe the final direction and radius.
    blurH1: effect(gpu, blurWgsl, { label: `${label}-blur-h1` }),
    blurV1: effect(gpu, blurWgsl, { label: `${label}-blur-v1` }),
    blurH2: effect(gpu, blurWgsl, { label: `${label}-blur-h2` }),
    blurV2: effect(gpu, blurWgsl, { label: `${label}-blur-v2` }),
    composite: effect(gpu, compositeWgsl, { label: `${label}-composite` }),
    sampler: sampler(gpu, { minFilter: 'linear', magFilter: 'linear' }),
  };
}
 
function createTargets(gpu: Gpu, size: readonly [number, number], label: string): Targets {
  const full = normalizeSize(size);
  const bloom = bloomSize(full);
  let scene: Target | undefined;
  let bloomA: Target | undefined;
  try {
    scene = target(gpu, { size: full, format: HDR_FORMAT, label: `${label}-scene` });
    bloomA = target(gpu, { size: bloom, format: HDR_FORMAT, label: `${label}-bloom-a` });
    const bloomB = target(gpu, { size: bloom, format: HDR_FORMAT, label: `${label}-bloom-b` });
    return { scene, bloomA, bloomB };
  } catch (error) {
    destroyTarget(bloomA);
    destroyTarget(scene);
    throw error;
  }
}
 
function destroyTargets(targets: Targets): void {
  destroyTarget(targets.bloomB);
  destroyTarget(targets.bloomA);
  destroyTarget(targets.scene);
}
 
function destroyTarget(colorTarget: Target | undefined): void {
  (colorTarget as { destroy?: () => void } | undefined)?.destroy?.();
}
 
function setConstants(effects: Effects): void {
  effects.scene.set({ params: { pointer: [0, 0.05], time: 0, motion: 1 } });
  effects.brightPass.set({ samp: effects.sampler, bright: { threshold: 1, knee: 0.6 } });
  effects.blurH1.set({ samp: effects.sampler, blur: { direction: [1, 0], radius: 1 } });
  effects.blurV1.set({ samp: effects.sampler, blur: { direction: [0, 1], radius: 1 } });
  effects.blurH2.set({ samp: effects.sampler, blur: { direction: [1, 0], radius: 2.4 } });
  effects.blurV2.set({ samp: effects.sampler, blur: { direction: [0, 1], radius: 2.4 } });
  effects.composite.set({ samp: effects.sampler, composite: { exposure: 1.15, bloomStrength: 0.9 } });
}
 
function setBindings(effects: Effects, targets: Targets, output: Output): void {
  effects.scene.set({ params: { resolution: targets.scene.size } });
  effects.brightPass.set({ src: targets.scene });
  effects.blurH1.set({ src: targets.bloomA, blur: { texelSize: targets.bloomA.texelSize } });
  effects.blurV1.set({ src: targets.bloomB, blur: { texelSize: targets.bloomB.texelSize } });
  effects.blurH2.set({ src: targets.bloomA, blur: { texelSize: targets.bloomA.texelSize } });
  effects.blurV2.set({ src: targets.bloomB, blur: { texelSize: targets.bloomB.texelSize } });
  effects.composite.set({ scene: targets.scene, bloom: targets.bloomA });
  void output;
}
 
async function prewarm(effects: Effects, targets: Targets, output: Output): Promise<void> {
  await Promise.all([
    effects.scene.compile(targets.scene), effects.brightPass.compile(targets.bloomA),
    effects.blurH1.compile(targets.bloomB), effects.blurV1.compile(targets.bloomA),
    effects.blurH2.compile(targets.bloomB), effects.blurV2.compile(targets.bloomA),
    effects.composite.compile({ colors: [output.format] }),
  ]);
}
 
function renderChain(currentFrame: Frame, effects: Effects, targets: Targets, output: Output): void {
  currentFrame.pass({ target: targets.scene, clear: CLEAR }, (pass) => pass.draw(effects.scene));
  currentFrame.pass({ target: targets.bloomA, clear: CLEAR }, (pass) => pass.draw(effects.brightPass));
  currentFrame.pass({ target: targets.bloomB, clear: CLEAR }, (pass) => pass.draw(effects.blurH1));
  currentFrame.pass({ target: targets.bloomA, clear: CLEAR }, (pass) => pass.draw(effects.blurV1));
  currentFrame.pass({ target: targets.bloomB, clear: CLEAR }, (pass) => pass.draw(effects.blurH2));
  currentFrame.pass({ target: targets.bloomA, clear: CLEAR }, (pass) => pass.draw(effects.blurV2));
  currentFrame.pass({ target: output, clear: CLEAR }, (pass) => pass.draw(effects.composite));
}
 
function renderAt(gpu: Gpu, effects: Effects, targets: Targets, output: Target, time: number, pointer: Orbit): void {
  effects.scene.set({ params: { pointer, time } });
  frame(gpu, (currentFrame) => renderChain(currentFrame, effects, targets, output));
}
 
function resizeTargets(targets: Targets, size: readonly [number, number]): void {
  const full = normalizeSize(size);
  targets.scene.resize(full);
  targets.bloomA.resize(bloomSize(full));
  targets.bloomB.resize(bloomSize(full));
}
 
function normalizeSize(size: readonly [number, number]): [number, number] {
  return [Math.max(1, Math.floor(size[0])), Math.max(1, Math.floor(size[1]))];
}
 
function bloomSize(size: readonly [number, number]): [number, number] {
  const height = Math.max(1, Math.min(BLOOM_HEIGHT, size[1]));
  return [Math.max(1, Math.round(height * size[0] / size[1])), height];
}
 
function installOrbitInput(canvas: HTMLCanvasElement) {
  let yaw = 0, pitch = 0.05, targetYaw = 0, targetPitch = 0.05;
  let activePointer: number | undefined;
  const previousTouchAction = canvas.style.touchAction;
  canvas.style.touchAction = 'none';
 
  const down = (event: PointerEvent) => {
    if (!event.isPrimary || activePointer !== undefined) return;
    activePointer = event.pointerId;
    canvas.setPointerCapture?.(event.pointerId);
  };
  const move = (event: PointerEvent) => {
    if (!event.isPrimary || (activePointer !== undefined && event.pointerId !== activePointer)) return;
    const rect = canvas.getBoundingClientRect();
    const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width)));
    const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height)));
    targetYaw = (0.5 - x) * Math.PI * 1.4;
    targetPitch = Math.max(-Math.PI * 0.42, Math.min(Math.PI * 0.42, (y - 0.5) * Math.PI * 0.7));
  };
  const end = (event: PointerEvent) => {
    if (event.pointerId !== activePointer) return;
    if (canvas.hasPointerCapture?.(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
    activePointer = undefined;
  };
  canvas.addEventListener('pointerdown', down);
  canvas.addEventListener('pointermove', move);
  canvas.addEventListener('pointerup', end);
  canvas.addEventListener('pointercancel', end);
  return {
    update(): Orbit {
      yaw += (targetYaw - yaw) * 0.12;
      pitch += (targetPitch - pitch) * 0.12;
      return [yaw, pitch];
    },
    dispose() {
      canvas.removeEventListener('pointerdown', down);
      canvas.removeEventListener('pointermove', move);
      canvas.removeEventListener('pointerup', end);
      canvas.removeEventListener('pointercancel', end);
      if (activePointer !== undefined && canvas.hasPointerCapture?.(activePointer)) canvas.releasePointerCapture(activePointer);
      activePointer = undefined;
      canvas.style.touchAction = previousTouchAction;
    },
  };
}