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.
import type { Gpu, Surface, Target } from 'vgpu';
import type { BrowserRendererOptions, ExampleRenderer, RenderSize, ThumbnailOptions } from '../../lib/example-renderer';
import { clock, compute, draw, effect, frame, frameLoop, geometry, sampler, storage, surface, target } from 'vgpu';
import { orbitControls, perspectiveCamera } from 'vgpu/scene';
import GUI from 'lil-gui';
import { buildOcean, type OceanApi, type OceanScene, type OceanShaders } from './scene';
import spectrumInit from './spectrum-init.wgsl';
import spectrumUpdate from './spectrum-update.wgsl';
import fftRow from './fft-row.wgsl';
import fftCol from './fft-col.wgsl';
import bake from './bake.wgsl';
import oceanSurface from './ocean-surface.wgsl';
import skydomeShader from './skydome.wgsl';
import compositeShader from './composite.wgsl';
const api: OceanApi = { compute, storage, draw, geometry, effect, target, sampler };
const shaders: OceanShaders = {
spectrumInit,
spectrumUpdate,
fftRow,
fftCol,
bake,
oceanSurface,
skydome: skydomeShader,
composite: compositeShader,
};
const CAMERA = { fov: 48, near: 1, far: 8000, position: [0, 24, 128] as const, target: [0, 5, 0] as const };
export function createRenderer(options: BrowserRendererOptions): ExampleRenderer {
let disposed = false;
let gpu: Gpu | undefined;
let canvasSurface: Surface | undefined;
let scene: OceanScene | undefined;
let camera: ReturnType<typeof perspectiveCamera> | undefined;
let controls: ReturnType<typeof orbitControls> | undefined;
let gui: GUI | 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 view = { autoRotate: false, rotateSpeed: 0.12 };
const applyResize = () => {
resizeFrame = 0;
const size = pendingSize;
pendingSize = undefined;
if (disposed || !size || !canvasSurface || !scene || !camera) return;
try {
const px: [number, number] = [
Math.max(1, Math.round(size.width * size.dpr)),
Math.max(1, Math.round(size.height * size.dpr)),
];
canvasSurface.resize(px);
scene.resize(px);
camera.set({ aspect: px[0] / px[1] });
} 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 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);
controls?.dispose();
controls = undefined;
gui?.destroy();
gui = 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 = buildOcean(gpu, api, shaders, { size: canvasSurface.size });
camera = perspectiveCamera({
fov: CAMERA.fov,
aspect: canvasSurface.size[0] / canvasSurface.size[1],
near: CAMERA.near,
far: CAMERA.far,
position: CAMERA.position,
target: CAMERA.target,
});
controls = orbitControls(camera, {
element: options.canvas,
target: CAMERA.target,
damping: 0.12,
distance: { min: 20, max: 700 },
pitch: { min: -0.05, max: 1.35 },
});
gui = buildGui(scene, view, options.canvas.parentElement);
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 || !canvasSurface || !scene || !camera || !controls) return;
const dt = gpuClock.deltaTime;
controls.update(dt);
if (view.autoRotate) controls.set({ yaw: controls.yaw + dt * view.rotateSpeed });
scene.simulate(dt);
scene.updateCamera(camera.viewProjection, camera.worldPosition);
currentFrame.pass({ target: scene.hdr, clear: scene.clear }, (pass) => {
pass.draw(scene!.skydome);
pass.draw(scene!.ocean);
});
currentFrame.pass(canvasSurface, scene.composite);
});
};
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 };
}
function buildGui(scene: OceanScene, view: { autoRotate: boolean; rotateSpeed: number }, container: HTMLElement | null): GUI {
const gui = new GUI({ title: 'Ocean', container: container ?? undefined });
// With a container, lil-gui drops its fixed auto-placement, so pin it as an
// overlay in the example's top-right corner and let it scroll if cramped.
Object.assign(gui.domElement.style, {
position: 'absolute',
top: '8px',
right: '8px',
zIndex: '10',
maxHeight: 'calc(100% - 16px)',
overflowY: 'auto',
});
const p = scene.params;
const rebuild = () => scene.rebuildSpectrum();
const waves = gui.addFolder('Waves');
waves.add(p, 'windSpeed', 2, 60, 0.5).name('wind speed').onChange(rebuild);
waves.add(p, 'windAngle', 0, 360, 1).name('wind angle').onChange(rebuild);
waves.add(p, 'amplitude', 0.2, 16, 0.1).onChange(rebuild);
waves.add(p, 'patchSize', 60, 600, 1).name('patch size (m)').onChange(rebuild);
const look = gui.addFolder('Look');
look.add(p, 'heightScale', 0, 80, 0.5).name('height');
look.add(p, 'choppyScale', 0, 40, 0.5).name('choppiness');
look.add(p, 'foamScale', 0.05, 1.2, 0.01).name('foam');
const sun = gui.addFolder('Sun');
sun.add(p, 'sunElevation', -2, 60, 0.5).name('elevation');
sun.add(p, 'sunAzimuth', 0, 360, 1).name('azimuth');
const sim = gui.addFolder('Sim');
sim.add(p, 'timeScale', 0, 3, 0.05).name('speed');
sim.add(view, 'autoRotate').name('auto-rotate');
sim.add(view, 'rotateSpeed', 0.02, 0.6, 0.01).name('rotate speed');
return gui;
}
export async function renderThumbnail(gpu: Gpu, output: Target, opts: ThumbnailOptions = {}): Promise<void> {
try {
const scene = buildOcean(gpu, api, shaders, { size: output.size });
const camera = perspectiveCamera({
fov: CAMERA.fov,
aspect: output.size[0] / output.size[1],
near: CAMERA.near,
far: CAMERA.far,
position: CAMERA.position,
target: CAMERA.target,
});
// Advance the simulation to a settled, wave-rich moment, then draw one frame.
const dt = opts.dt ?? 1 / 60;
const warmup = Math.max(0, opts.warmupFrames ?? 0);
for (let i = 0; i < warmup; i++) scene.simulate(dt);
scene.simulate((opts.time ?? 9) - warmup * dt);
scene.updateCamera(camera.viewProjection, camera.worldPosition);
frame(gpu, (currentFrame) => {
currentFrame.pass({ target: scene.hdr, clear: scene.clear }, (pass) => {
pass.draw(scene.skydome);
pass.draw(scene.ocean);
});
currentFrame.pass(output, scene.composite);
});
} finally {
await Promise.allSettled([
Promise.resolve().then(() => gpu.gpu.queue.onSubmittedWorkDone()),
Promise.resolve().then(() => gpu.settled()),
]);
}
}