vidar.games

FFT ocean

2026-11-03 · Discuss on X

Most game seas are a few sine waves added together.
This one starts from a wave spectrum: random waves on four 256 × 256 frequency grids, turned into a surface by an inverse FFT every frame.

The problem

Sum 4 Gerstner waves and the sea looks fine for a second, then repeats. Every crest has the same shape, and each extra wave costs more per vertex. Wind-driven water spreads its energy over thousands of wavelengths and directions.

How it works

  1. A wave spectrum (JONSWAP) takes wind speed and fetch and says how much energy sits at each frequency. A spreading function aims it around the wind. A longer swell spectrum is added on top.
  2. Each cell of a 256 × 256 grid of wave vectors k gets a Gaussian random amplitude scaled by the square root of that energy. Built once.
  3. Each frame, every cell's phase turns at its own speed: ω² = g·k + 7.4e-5·k³ (gravity, plus surface tension for tiny ripples).
  4. One inverse FFT turns the grid into heights. The same passes also produce the sideways offsets that sharpen crests, and the slopes.

What I hit

Foam comes from the Jacobian of the sideways offsets: below 0.05 the surface is folding over itself. The sim keeps a memory of it, so foam appears at once and fades over time. At 9 m/s that was only about 0.3% of a 120 m patch, too little to see. At 15 m/s about 0.9%. So the last shot raises the wind to 17 m/s.

That wind change rebuilds the spectrum every frame with the same random numbers. Waves grow smoothly but instantly: a parameter sweep, not a sea building up.

My first Gerstner waves were too low and the left half looked like a lake. I doubled them so the comparison is about shape, not height.

My build

Spectrum and FFT are Ultra-Water's OceanSim, unchanged, on three.js 0.170:
· 4 cascades of 256², tiles of 600, 85, 12 and 2.1 m in one 256 × 1024 texture
· each cascade keeps only its own band of k
· Stockham inverse FFT: 8 horizontal + 8 vertical passes for all 4 cascades, in fp16
· wind 9 m/s, fetch 120 km, choppiness 0.85

The surface shader is my own, simpler than the repo's: displacement from the 3 largest cascades, normals from all 4, Fresnel sky reflection, GGX sun, haze. The buoy reads the same textures back from the GPU. The left half is 4 Gerstner waves (60, 33, 17, 8.5 m) heading the same way as the FFT wind.

Trade-offs

When to use it

Open sea seen from a boat, a plane or a cliff: anywhere the water fills half the screen for minutes.

New breakdowns go out on X first: @vidar_0208. If this one helped, you can buy me a coffee.

Source and credits

Repo: github.com/Humpalumps/Ultra-Water (MIT license), branch master, commit ce9248f

Code: src/water/OceanSim.js (JONSWAP + swell spectrum, directional spreading, gravity-capillary dispersion, Stockham inverse FFT over 4 cascades, choppy displacement, slopes and Jacobian foam) and src/engine/fullscreen.js are used unchanged; only one import path was adjusted. The foam threshold (Jacobian < 0.05, scale 1.3) follows src/water/OceanSurface.js. The surface shading, sky, buoy, Gerstner comparison and video overlay are my own. No video or screenshots from the original post or repo.

Topic credit: @Ian_cadle's open-source three.js ocean, original post https://x.com/Ian_cadle/status/2115489006083596524