Particles ocean

A deep-water surface driven by a real inverse FFT. A Phillips spectrum evolves in frequency space, Stockham passes produce a displacement field, and half a million particles ride the waves through an HDR bloom chain.

Open fullscreen
import type { Draw, Effect, Frame, Gpu, Surface, Target } from 'vgpu';
import type { BrowserRendererOptions, ExampleRenderer, RenderSize, ThumbnailOptions } from '../../lib/example-renderer';
import { oceanCamera } from './camera';
import { createIfftStageTable, OCEAN_RESOLUTION, type IfftStage, type SimulationTargetName } from './ocean-graph';
import { gaussianCoefficients, OCEAN_TUNING } from './tuning';
import noiseWgsl from './noise.wgsl';
import initialSpectrumWgsl from './initial-spectrum.wgsl';
import spectrumWgsl from './spectrum.wgsl';
import ifftStageWgsl from './ifft-stage.wgsl';
import normalFoamWgsl from './normal-foam.wgsl';
import particlesWgsl from './particles.wgsl';
import bloomBrightWgsl from './bloom-bright.wgsl';
import bloomBlurWgsl from './bloom-blur.wgsl';
import bloomCompositeWgsl from './bloom-composite.wgsl';
import presentWgsl from './present.wgsl';
import stagePreviewWgsl from './stage-preview.wgsl';
import { clock, draw, effect, frame, frameLoop, sampler, surface, target } from "vgpu";
 
type Output = Surface | Target;
interface ThumbOptions extends ThumbnailOptions {
  onVariantRendered?: (variant: 'time-delta', pixels: Uint8Array, size: readonly [number, number]) => void | Promise<void>;
  onIntermediateRendered?: (kind: 'displacement', pixels: Uint8Array, size: readonly [number, number]) => void | Promise<void>;
}
interface StageEffect { readonly spec: IfftStage; readonly effect: Effect; readonly output: Target }
interface BloomLevel { readonly horizontal: Target; readonly vertical: Target; readonly horizontalEffect: Effect; readonly verticalEffect: Effect }
interface OceanGraph {
  readonly noise: Target; readonly h0: Target; readonly spectrum: Target; readonly ping: Target; readonly pong: Target; readonly normalFoam: Target;
  readonly scene: Target; readonly bright: Target; readonly composite: Target;
  readonly levels: readonly BloomLevel[];
  readonly noiseEffect: Effect; readonly initialSpectrum: Effect; readonly evolveSpectrum: Effect; readonly ifft: readonly StageEffect[]; readonly normals: Effect;
  readonly particles: Draw; readonly brightEffect: Effect; readonly compositeEffect: Effect; readonly present: Effect;
  needsInitialSpectrum: boolean;
}
 
const SIM_FORMAT: GPUTextureFormat = 'rgba32float';
const HDR_FORMAT: GPUTextureFormat = 'rgba16float';
const CLEAR = [0, 0, 0, 1] as const;
const TRANSPARENT = [0, 0, 0, 0] as const;
const BLOOM_LEVELS = OCEAN_TUNING.bloom.levels;
 
export function createRenderer(options: BrowserRendererOptions): ExampleRenderer {
  let disposed = false;
  let reportedError = false;
  let gpu: Gpu | undefined;
  let canvasSurface: Surface | undefined;
  let graph: OceanGraph | 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;
 
  const applyResize = () => {
    resizeFrame = 0;
    const size = pendingSize;
    pendingSize = undefined;
    if (disposed || !size || !graph || !canvasSurface) return;
    resizeOutputGraph(graph, canvasSurface);
  };
  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);
    if (graph) destroyGraph(graph);
    graph = 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, 1.6] });
    const nextGraph = await createGraph(gpu, canvasSurface, 'fft-ocean-live');
    if (disposed) { destroyGraph(nextGraph); return; }
    graph = nextGraph;
    observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);
    observer?.observe(options.canvas);
    window.addEventListener('resize', onWindowResize);
    measure();
    const time = clock(gpu);
    loop = frameLoop(gpu, (currentFrame) => {
      if (disposed || !graph || !gpu || !canvasSurface) return;
      setDynamics(graph, time.time * OCEAN_TUNING.simulation.timeScale);
      renderGraph(currentFrame, graph, canvasSurface);
    });
  };
  const ready = initialize().catch((error: unknown) => {
    if (disposed) return;
    if (!reportedError) { reportedError = true; options.onError?.(error); }
    dispose();
    throw error;
  });
  return { ready, invalidate() {}, resize, dispose };
}
 
export async function renderThumbnail(gpu: Gpu, output: Target, opts: ThumbOptions = {}): Promise<void> {
  const graph = await createGraph(gpu, output, 'fft-ocean-thumb');
  try {
    const time = opts.time ?? 18;
    renderAt(gpu, graph, output, time);
    await gpu.gpu.queue.onSubmittedWorkDone();
    if (opts.onIntermediateRendered) {
      const displacement = graph.ifft.at(-1)!.output;
      const previewTarget = target(gpu, { size: displacement.size, format: 'rgba8unorm', label: 'fft-ocean-displacement-preview' });
      try {
        const preview = effect(gpu, stagePreviewWgsl, { label: 'fft-ocean-displacement-preview' });
        preview.set({ u: { outputWidth: displacement.size[0], outputHeight: displacement.size[1], stage: 1, gain: 16 }, u_input: displacement });
        await preview.compile(previewTarget);
        frame(gpu, (currentFrame) => currentFrame.pass({ target: previewTarget, clear: CLEAR }, (pass) => pass.draw(preview)));
        await gpu.gpu.queue.onSubmittedWorkDone();
        await opts.onIntermediateRendered('displacement', await previewTarget.read(), previewTarget.size);
      } finally {
        try {
          await gpu.gpu.queue.onSubmittedWorkDone();
        } finally {
          previewTarget.color.destroy();
        }
      }
    }
    renderAt(gpu, graph, output, time + 5);
    await gpu.gpu.queue.onSubmittedWorkDone();
    await opts.onVariantRendered?.('time-delta', await output.read(), output.size);
    renderAt(gpu, graph, output, time);
    await gpu.gpu.queue.onSubmittedWorkDone();
    await gpu.settled();
  } finally {
    try {
      await gpu.gpu.queue.onSubmittedWorkDone();
    } finally {
      destroyGraph(graph);
    }
  }
}
 
async function createGraph(gpu: Gpu, output: Output, label: string): Promise<OceanGraph> {
  const ownedTargets: Target[] = [];
  const own = (colorTarget: Target) => { ownedTargets.push(colorTarget); return colorTarget; };
  try {
  const resolution = OCEAN_RESOLUTION;
  const simTarget = (name: string) => own(target(gpu, { size: [resolution, resolution], format: SIM_FORMAT, label: `${label}-${name}` }));
  const noise = simTarget('noise'), h0 = simTarget('h0'), spectrum = simTarget('spectrum'), ping = simTarget('ping'), pong = simTarget('pong'), normalFoam = simTarget('normal-foam');
  const scene = own(target(gpu, { size: normalizedSize(output.size), format: HDR_FORMAT, label: `${label}-scene` }));
  const sizes = bloomSizes(output.size);
  const bright = own(target(gpu, { size: sizes[0]!, format: HDR_FORMAT, label: `${label}-bright` }));
  const composite = own(target(gpu, { size: sizes[0]!, format: HDR_FORMAT, label: `${label}-composite` }));
  const samplerState = sampler(gpu, { minFilter: 'linear', magFilter: 'linear' });
  const noiseEffect = effect(gpu, noiseWgsl, { label: `${label}-noise` });
  noiseEffect.set({ u: { seed: 0x6f636561, resolution } });
  const initialSpectrum = effect(gpu, initialSpectrumWgsl, { label: `${label}-initial-spectrum` });
  initialSpectrum.set({ u: { resolution, size: OCEAN_TUNING.simulation.oceanSize, windSpeed: OCEAN_TUNING.simulation.windSpeed, windAngle: OCEAN_TUNING.simulation.windAngle, amplitude: OCEAN_TUNING.simulation.amplitude }, u_noise: noise });
  const evolveSpectrum = effect(gpu, spectrumWgsl, { label: `${label}-spectrum` });
  evolveSpectrum.set({ u: { resolution, size: OCEAN_TUNING.simulation.oceanSize, time: 0, choppiness: OCEAN_TUNING.simulation.choppiness }, u_initialSpectrum: h0 });
  const targets: Record<SimulationTargetName, Target> = { spectrum, ping, pong };
  const ifft = createIfftStageTable().map((spec) => {
    const shader1 = effect(gpu, ifftStageWgsl, { label: `${label}-ifft-${spec.index}-${spec.horizontal ? 'h' : 'v'}` });
    shader1.set({ u: { resolution, subtransformSize: spec.subtransformSize, horizontal: spec.horizontal ? 1 : 0 }, u_input: targets[spec.input] });
    return { spec, effect: shader1, output: targets[spec.output] };
  });
  const displacement = targets[ifft.at(-1)!.spec.output];
  const normals = effect(gpu, normalFoamWgsl, { label: `${label}-normal-foam` });
  normals.set({ u: { resolution, worldSize: OCEAN_TUNING.simulation.worldSize, displacementScale: OCEAN_TUNING.simulation.displacementScale, choppiness: OCEAN_TUNING.simulation.choppiness, foamThreshold: OCEAN_TUNING.simulation.foamThreshold }, u_displacement: displacement });
  const particles = draw(gpu, {
    shader: particlesWgsl,
    vertices: 6,
    instances: resolution * resolution,
    blend: { color: { src: 'src-alpha', dst: 'one' }, alpha: { src: 'one', dst: 'one' } },
    label: `${label}-particles`,
  });
  particles.set({ u_displacement: displacement, u_normalFoam: normalFoam });
  setParticleConstants(particles, output);
  const brightEffect = effect(gpu, bloomBrightWgsl, { label: `${label}-bloom-bright` });
  brightEffect.set({ uniforms: { luminosityThreshold: OCEAN_TUNING.bloom.threshold, smoothWidth: OCEAN_TUNING.bloom.smoothWidth }, tDiffuse: scene, linearSampler: samplerState });
  let bloomInput = bright;
  const levels = sizes.map((size, index) => {
    const horizontal = own(target(gpu, { size, format: HDR_FORMAT, label: `${label}-bloom-h${index}` }));
    const vertical = own(target(gpu, { size, format: HDR_FORMAT, label: `${label}-bloom-v${index}` }));
    const kernelRadius = OCEAN_TUNING.bloom.kernelRadii[index]!;
    const horizontalEffect = makeBlur(gpu, `${label}-blur-h${index}`, bloomInput, horizontal, samplerState, [1, 0], kernelRadius);
    const verticalEffect = makeBlur(gpu, `${label}-blur-v${index}`, horizontal, vertical, samplerState, [0, 1], kernelRadius);
    bloomInput = vertical;
    return { horizontal, vertical, horizontalEffect, verticalEffect };
  });
  const compositeEffect = effect(gpu, bloomCompositeWgsl, { label: `${label}-bloom-composite` });
  compositeEffect.set({
    uniforms: { bloomStrength: OCEAN_TUNING.bloom.strength, bloomRadius: OCEAN_TUNING.bloom.radius, bloomFactors0: [1, 0.8, 0.6, 0.4], bloomFactors1: [0.2, 0, 0, 0] },
    blurTexture1: levels[0]!.vertical, blurTexture2: levels[1]!.vertical, blurTexture3: levels[2]!.vertical,
    blurTexture4: levels[3]!.vertical, blurTexture5: levels[4]!.vertical, linearSampler: samplerState,
  });
  const present = effect(gpu, presentWgsl, { label: `${label}-present` });
  present.set({ sceneHDR: scene, bloomTexture: composite, linearSampler: samplerState });
  const graph: OceanGraph = { noise, h0, spectrum, ping, pong, normalFoam, scene, bright, composite, levels, noiseEffect, initialSpectrum, evolveSpectrum, ifft, normals, particles, brightEffect, compositeEffect, present, needsInitialSpectrum: true };
  await prewarm(graph, output);
  return graph;
  } catch (error) {
    for (let i = ownedTargets.length - 1; i >= 0; i--) ownedTargets[i]!.color.destroy();
    throw error;
  }
}
 
function makeBlur(gpu: Gpu, label: string, source: Target, colorTarget: Target, samplerState: GPUSampler, direction: readonly [number, number], kernelRadius: number): Effect {
  const shader1 = effect(gpu, bloomBlurWgsl, { label });
  const c = gaussianCoefficients(kernelRadius);
  shader1.set({ uniforms: { direction, invSize: colorTarget.texelSize, gaussianCoefficients0: c.slice(0, 4), gaussianCoefficients1: c.slice(4, 8), gaussianCoefficients2: c.slice(8, 12), gaussianCoefficients3: c.slice(12, 16), gaussianCoefficients4: c.slice(16, 20), gaussianCoefficients5: c.slice(20, 24) }, colorTexture: source, linearSampler: samplerState });
  return shader1;
}
 
async function prewarm(g: OceanGraph, output: Output): Promise<void> {
  await Promise.all([
    g.noiseEffect.compile(g.noise), g.initialSpectrum.compile(g.h0), g.evolveSpectrum.compile(g.spectrum), ...g.ifft.map((s) => s.effect.compile(s.output)), g.normals.compile(g.normalFoam),
    g.particles.compile(g.scene), g.brightEffect.compile(g.bright),
    ...g.levels.flatMap((level) => [level.horizontalEffect.compile(level.horizontal), level.verticalEffect.compile(level.vertical)]),
    g.compositeEffect.compile(g.composite), g.present.compile({ colors: [output.format] }),
  ]);
}
 
function setDynamics(g: OceanGraph, timeSeconds: number): void {
  g.evolveSpectrum.set({ u: { time: timeSeconds * OCEAN_TUNING.simulation.spectrumTimeScale } });
}
function setParticleConstants(drawable: Draw, output: Output): void {
  const resolution = OCEAN_RESOLUTION;
  const camera = oceanCamera(output.size);
  const particles = OCEAN_TUNING.particles;
  const simulation = OCEAN_TUNING.simulation;
  drawable.set({ u: {
    view: camera.view,
    projection: camera.projection,
    viewport: [output.size[0], output.size[1], 1, resolution],
    simulation: [simulation.worldSize, simulation.oceanSize, simulation.gravity, 0],
    fade: [particles.fadeNear, particles.fadeFar, particles.fadePower, 0],
    oceanColor: particles.oceanColor,
    neonColor: particles.neonColor,
    foamColor: particles.foamColor,
    misc: [simulation.displacementScale, particles.pointSize, 0, 0],
  } });
}
function renderAt(gpu: Gpu, graph: OceanGraph, output: Target, time: number): void { setDynamics(graph, time); frame(gpu, (currentFrame) => renderGraph(currentFrame, graph, output)); }
function renderGraph(currentFrame: Frame, g: OceanGraph, output: Output): void {
  if (g.needsInitialSpectrum) {
    currentFrame.pass({ target: g.noise, clear: TRANSPARENT }, (p) => p.draw(g.noiseEffect));
    currentFrame.pass({ target: g.h0, clear: TRANSPARENT }, (p) => p.draw(g.initialSpectrum));
    g.needsInitialSpectrum = false;
  }
  currentFrame.pass({ target: g.spectrum, clear: TRANSPARENT }, (p) => p.draw(g.evolveSpectrum));
  for (const stage of g.ifft) currentFrame.pass({ target: stage.output, clear: TRANSPARENT }, (p) => p.draw(stage.effect));
  currentFrame.pass({ target: g.normalFoam, clear: TRANSPARENT }, (p) => p.draw(g.normals));
  currentFrame.pass({ target: g.scene, clear: TRANSPARENT }, (p) => p.draw(g.particles));
  currentFrame.pass({ target: g.bright, clear: TRANSPARENT }, (p) => p.draw(g.brightEffect));
  for (const level of g.levels) {
    currentFrame.pass({ target: level.horizontal, clear: TRANSPARENT }, (p) => p.draw(level.horizontalEffect));
    currentFrame.pass({ target: level.vertical, clear: TRANSPARENT }, (p) => p.draw(level.verticalEffect));
  }
  currentFrame.pass({ target: g.composite, clear: TRANSPARENT }, (p) => p.draw(g.compositeEffect));
  currentFrame.pass({ target: output, clear: TRANSPARENT }, (p) => p.draw(g.present));
}
 
function resizeOutputGraph(g: OceanGraph, output: Output): void {
  g.scene.resize(normalizedSize(output.size));
  const sizes = bloomSizes(output.size); g.bright.resize(sizes[0]!); g.composite.resize(sizes[0]!);
  for (let i = 0; i < g.levels.length; i++) { g.levels[i]!.horizontal.resize(sizes[i]!); g.levels[i]!.vertical.resize(sizes[i]!); }
  g.brightEffect.set({ tDiffuse: g.scene });
  let input = g.bright;
  for (const level of g.levels) {
    level.horizontalEffect.set({ colorTexture: input, uniforms: { invSize: level.horizontal.texelSize } });
    level.verticalEffect.set({ colorTexture: level.horizontal, uniforms: { invSize: level.vertical.texelSize } }); input = level.vertical;
  }
  g.compositeEffect.set({ blurTexture1: g.levels[0]!.vertical, blurTexture2: g.levels[1]!.vertical, blurTexture3: g.levels[2]!.vertical, blurTexture4: g.levels[3]!.vertical, blurTexture5: g.levels[4]!.vertical });
  g.present.set({ sceneHDR: g.scene, bloomTexture: g.composite });
  const camera = oceanCamera(output.size);
  g.particles.set({ u: { view: camera.view, projection: camera.projection, viewport: [output.size[0], output.size[1], 1, OCEAN_RESOLUTION] } });
}
function bloomSizes(size: readonly [number, number]): [number, number][] {
  let w = Math.max(1, Math.round(size[0] / 2)), h = Math.max(1, Math.round(size[1] / 2)); const out: [number, number][] = [];
  for (let i = 0; i < BLOOM_LEVELS; i++) { out.push([w, h]); w = Math.max(1, Math.round(w / 2)); h = Math.max(1, Math.round(h / 2)); }
  return out;
}
function normalizedSize(size: readonly [number, number]): [number, number] { return [Math.max(1, Math.floor(size[0])), Math.max(1, Math.floor(size[1]))]; }
function destroyGraph(g: OceanGraph): void { for (const colorTarget of [g.noise, g.h0, g.spectrum, g.ping, g.pong, g.normalFoam, g.scene, g.bright, g.composite, ...g.levels.flatMap((x) => [x.horizontal, x.vertical])]) colorTarget.color.destroy(); }