FFT ocean
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
- 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.
- 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.
- Each frame, every cell's phase turns at its own speed: ω² = g·k + 7.4e-5·k³ (gravity, plus surface tension for tiny ripples).
- 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
- Thousands of waves at a fixed cost: 21 GPU passes per frame, whatever the sea state
- Wind speed and direction are real inputs
- Loops seamlessly: every ω is snapped to a multiple of 2π/300 s
- Each cascade tiles; odd size ratios hide it, a flat calm shows it
- Heights live on the GPU, so physics needs a readback
- No shore or breaking waves; foam marks folding, not spray
When to use it
Open sea seen from a boat, a plane or a cliff: anywhere the water fills half the screen for minutes.
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