FFT ocean surface

A displaced ocean surface driven by a real inverse FFT. A Phillips spectrum evolves in frequency space, two compute passes run a shared-memory radix-2 IFFT into a displacement field, and a procedural grid rides it with per-pixel normals, foam and a Fresnel sky reflection under a tunable sunset. Orbit with the mouse; tweak the sea from the panel.

Open fullscreen
// Environment-agnostic FFT ocean scene. It holds the storage buffers, the four
// compute passes (spectrum init/update + the two IFFT passes), the skydome and
// ocean draws, and the tone-mapping composite. The vgpu runtime functions are
// injected as `api` so the exact same setup runs against `vgpu` (browser) and
// `vgpu/node` (headless render + tests). WGSL sources are injected too.
//
// All the knobs a user tweaks live in `scene.params`. Render/sun params are read
// live every frame; the spectrum params (wind/amplitude/patch) require rebuilding
// h0, so call `rebuildSpectrum()` after changing them.
 
import { sphere } from "vgpu/scene";
 
export type OceanApi = {
  compute: typeof import("vgpu").compute;
  storage: typeof import("vgpu").storage;
  draw: typeof import("vgpu").draw;
  geometry: typeof import("vgpu").geometry;
  effect: typeof import("vgpu").effect;
  target: typeof import("vgpu").target;
  sampler: typeof import("vgpu").sampler;
};
 
type Gpu = Parameters<OceanApi["compute"]>[0];
type ShaderSrc = Parameters<OceanApi["compute"]>[1];
 
export type OceanShaders = {
  spectrumInit: ShaderSrc;
  spectrumUpdate: ShaderSrc;
  fftRow: ShaderSrc;
  fftCol: ShaderSrc;
  bake: ShaderSrc;
  oceanSurface: ShaderSrc;
  skydome: ShaderSrc;
  composite: ShaderSrc;
};
 
/** Everything the lil-gui panel drives. */
export interface OceanParams {
  windSpeed: number; // m/s (spectrum)
  windAngle: number; // degrees (spectrum)
  amplitude: number; // Phillips amplitude (spectrum)
  patchSize: number; // meters per FFT tile (spectrum + render tiling)
  heightScale: number; // vertical displacement gain (render)
  choppyScale: number; // horizontal displacement gain (render)
  foamScale: number; // foam Jacobian threshold (render)
  sunElevation: number; // degrees above horizon (sky)
  sunAzimuth: number; // degrees (sky)
  timeScale: number; // simulation speed multiplier
}
 
export const DEFAULT_PARAMS: OceanParams = {
  windSpeed: 24,
  windAngle: 18,
  amplitude: 4,
  patchSize: 265,
  heightScale: 34,
  choppyScale: 14,
  foamScale: 0.5,
  sunElevation: 6.5,
  sunAzimuth: 236,
  timeScale: 1,
};
 
export interface OceanOptions {
  size: readonly [number, number];
  worldSize?: number;
  skyRadius?: number;
  params?: Partial<OceanParams>;
  clear?: readonly [number, number, number, number];
}
 
const N = 256;
const CPLX_BYTES = N * N * 2 * 4; // array<vec2f>
const VEC4_BYTES = N * N * 4 * 4; // array<vec4f>
 
// Procedural ocean grid resolution (cells per side). 6 vertices per cell, no
// vertex buffer, so this is not capped by 16-bit indices. Sent to the shader as
// the `GRID` override to stay in sync with the draw's vertex count.
const OCEAN_GRID = 512;
 
const DEG = Math.PI / 180;
 
export interface OceanScene {
  readonly params: OceanParams;
  /** Recompute the time-independent spectrum h0 (after wind/amplitude/patch changes). */
  rebuildSpectrum(): void;
  /** Advance the simulation by `dt` seconds (scaled by params.timeScale) and run the IFFT. */
  simulate(dt: number): void;
  /** Push camera + live render/sun params into the ocean and skydome uniforms. */
  updateCamera(viewProj: Float32Array, camPos: Float32Array): void;
  resize(size: readonly [number, number]): void;
  dispose(): void;
  readonly hdr: ReturnType<OceanApi["target"]>;
  readonly skydome: ReturnType<OceanApi["draw"]>;
  readonly ocean: ReturnType<OceanApi["draw"]>;
  readonly composite: ReturnType<OceanApi["effect"]>;
  readonly clear: readonly [number, number, number, number];
}
 
export function buildOcean(
  gpu: Gpu,
  api: OceanApi,
  shaders: OceanShaders,
  opts: OceanOptions,
): OceanScene {
  const worldSize = opts.worldSize ?? 1000;
  const skyRadius = opts.skyRadius ?? 6000;
  const clear = opts.clear ?? [0.02, 0.02, 0.04, 1];
  const params: OceanParams = { ...DEFAULT_PARAMS, ...opts.params };
 
  const windDir = (): [number, number] => {
    const a = params.windAngle * DEG;
    return [Math.cos(a), Math.sin(a)];
  };
  const sunDir = (): [number, number, number] => {
    const el = params.sunElevation * DEG;
    const az = params.sunAzimuth * DEG;
    return [Math.cos(el) * Math.cos(az), Math.sin(el), Math.cos(el) * Math.sin(az)];
  };
  const simUniform = (time: number) => ({
    windDir: windDir(),
    windSpeed: params.windSpeed,
    amplitude: params.amplitude,
    patchSize: params.patchSize,
    time,
  });
 
  // --- storage buffers -------------------------------------------------------
  const h0 = api.storage(gpu, VEC4_BYTES, "read-write");
  const specX = api.storage(gpu, CPLX_BYTES, "read-write");
  const specY = api.storage(gpu, CPLX_BYTES, "read-write");
  const specZ = api.storage(gpu, CPLX_BYTES, "read-write");
  const tmpX = api.storage(gpu, CPLX_BYTES, "read-write");
  const tmpY = api.storage(gpu, CPLX_BYTES, "read-write");
  const tmpZ = api.storage(gpu, CPLX_BYTES, "read-write");
  const disp = api.storage(gpu, VEC4_BYTES, "read-write");
 
  // --- compute passes --------------------------------------------------------
  const initPass = api.compute(gpu, shaders.spectrumInit, {
    label: "spectrum-init",
    set: { h0, sim: simUniform(0) },
  });
  const updatePass = api.compute(gpu, shaders.spectrumUpdate, {
    label: "spectrum-update",
    set: { h0, specX, specY, specZ, sim: simUniform(0) },
  });
  const rowPass = api.compute(gpu, shaders.fftRow, {
    label: "fft-row",
    set: { inX: specX, inY: specY, inZ: specZ, outX: tmpX, outY: tmpY, outZ: tmpZ },
  });
  const colPass = api.compute(gpu, shaders.fftCol, {
    label: "fft-col",
    set: { inX: tmpX, inY: tmpY, inZ: tmpZ, disp },
  });
 
  // Bake the IFFT storage buffer into a texture the render stages can sample —
  // storage buffers are not available in the vertex stage on all adapters.
  const dispTex = api.target(gpu, { size: [N, N], format: "rgba16float" });
  const dispSamp = api.sampler(gpu, {
    addressModeU: "repeat",
    addressModeV: "repeat",
    minFilter: "linear",
    magFilter: "linear",
  });
  const bake = api.effect(gpu, shaders.bake, { label: "bake-displacement", set: { disp } });
 
  // --- geometry / draws / composite -----------------------------------------
  const skyGeo = api.geometry(gpu, sphere({ radius: 1 }));
  const identityCam = new Float32Array(16);
 
  const skydome = api.draw(gpu, {
    label: "skydome",
    shader: shaders.skydome,
    geometry: skyGeo,
    cull: "front",
    set: { u: { viewProj: identityCam, camPos: [0, 0, 0], radius: skyRadius, sunDir: sunDir() } },
  });
  const ocean = api.draw(gpu, {
    label: "ocean",
    shader: shaders.oceanSurface,
    cull: "none",
    constants: { GRID: OCEAN_GRID },
    vertices: 6 * OCEAN_GRID * OCEAN_GRID,
    set: {
      u: {
        viewProj: identityCam,
        camPos: [0, 0, 0],
        worldSize,
        sunDir: sunDir(),
        patchSize: params.patchSize,
        heightScale: params.heightScale,
        choppyScale: params.choppyScale,
        foamScale: params.foamScale,
      },
      disp: dispTex,
      dispSamp,
    },
  });
 
  const hdr = api.target(gpu, { size: [opts.size[0], opts.size[1]], format: "rgba16float", depth: true });
  const samp = api.sampler(gpu, { minFilter: "linear", magFilter: "linear" });
  const composite = api.effect(gpu, shaders.composite, {
    label: "composite",
    set: { src: hdr, samp },
  });
 
  let simTime = 0;
 
  function rebuildSpectrum() {
    initPass.set({ sim: simUniform(0) });
    initPass.dispatch(N / 8, N / 8);
  }
 
  rebuildSpectrum(); // build h0 once at startup
 
  return {
    params,
    hdr,
    skydome,
    ocean,
    composite,
    clear,
    rebuildSpectrum,
    simulate(dt: number) {
      simTime += dt * params.timeScale;
      updatePass.set({ sim: simUniform(simTime) });
      updatePass.dispatch(N / 8, N / 8);
      rowPass.dispatch(N, 1);
      colPass.dispatch(N, 1);
      bake.draw(dispTex); // storage buffer -> sampleable displacement texture
    },
    updateCamera(viewProj: Float32Array, camPos: Float32Array) {
      const cp = [camPos[0], camPos[1], camPos[2]] as [number, number, number];
      const s = sunDir();
      skydome.set({ u: { viewProj, camPos: cp, radius: skyRadius, sunDir: s } });
      ocean.set({
        u: {
          viewProj,
          camPos: cp,
          worldSize,
          sunDir: s,
          patchSize: params.patchSize,
          heightScale: params.heightScale,
          choppyScale: params.choppyScale,
          foamScale: params.foamScale,
        },
      });
    },
    resize(size: readonly [number, number]) {
      hdr.resize([size[0], size[1]]);
      composite.set({ src: hdr, samp });
    },
    dispose() {
      // Buffers/targets are owned by the device; callers dispose `gpu` on teardown.
    },
  };
}