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.
// Ocean mesh. The grid is generated procedurally from @builtin(vertex_index) —
// no vertex/index buffers, so resolution is a single `override GRID` knob and is
// not capped by 16-bit indices. The vertex stage displaces each grid point by the
// FFT displacement TEXTURE (height + horizontal choppiness); a texture (not a
// storage buffer) keeps the vertex stage within conservative device limits.
// Crucially, the NORMAL and foam are reconstructed PER PIXEL in the fragment
// stage, so fine wave detail survives regardless of mesh density.
import { skyColor } from "./sky.wgsl";
override GRID: u32 = 512u; // cells per side (JS sets `vertices` = 6*GRID*GRID)
const NU: u32 = 256u; // FFT resolution
struct Ocean {
viewProj: mat4x4f,
camPos: vec3f,
worldSize: f32,
sunDir: vec3f,
patchSize: f32,
heightScale: f32,
choppyScale: f32,
foamScale: f32,
_pad: f32,
}
@group(0) @binding(0) var<uniform> u: Ocean;
@group(0) @binding(1) var disp: texture_2d<f32>;
@group(0) @binding(2) var dispSamp: sampler;
// Bilinear tap of the (Dx, Dy, Dz) displacement at `uv` in patch units (tiles).
// The sampler uses repeat addressing + linear filtering, so wrapping and the
// bilinear blend come for free and the field tiles seamlessly.
fn sampleDisp(uv: vec2f) -> vec3f {
return textureSampleLevel(disp, dispSamp, uv, 0.0).xyz;
}
fn scaled(d: vec3f) -> vec3f {
return vec3f(d.x * u.choppyScale, d.y * u.heightScale, d.z * u.choppyScale);
}
struct VOut {
@builtin(position) clip: vec4f,
@location(0) world: vec3f,
@location(1) uv: vec2f, // undisplaced grid parameter, for exact fragment sampling
}
@vertex fn vs_main(@builtin(vertex_index) vi: u32) -> VOut {
let cell = vi / 6u;
let corner = vi % 6u;
let cx = cell % GRID;
let cz = cell / GRID;
// Two triangles per cell.
var offs = array<vec2u, 6>(
vec2u(0u, 0u), vec2u(1u, 0u), vec2u(0u, 1u),
vec2u(0u, 1u), vec2u(1u, 0u), vec2u(1u, 1u),
);
let o = offs[corner];
let g = vec2f(f32(cx + o.x), f32(cz + o.y)) / f32(GRID); // [0,1]
let base = (g - 0.5) * u.worldSize; // world XZ
let uv = base / u.patchSize;
let world = vec3f(base.x, 0.0, base.y) + scaled(sampleDisp(uv));
var out: VOut;
out.clip = u.viewProj * vec4f(world, 1.0);
out.world = world;
out.uv = uv;
return out;
}
@fragment fn fs_main(@location(0) world: vec3f, @location(1) uv: vec2f) -> @location(0) vec4f {
let e = 1.0 / f32(NU);
let dwx = u.patchSize / f32(NU); // world units per texel
let dC = sampleDisp(uv);
let dX = sampleDisp(uv + vec2f(e, 0.0));
let dZ = sampleDisp(uv + vec2f(0.0, e));
// Per-pixel normal from displacement tangents.
let tX = vec3f(dwx + (dX.x - dC.x) * u.choppyScale, (dX.y - dC.y) * u.heightScale, (dX.z - dC.z) * u.choppyScale);
let tZ = vec3f((dZ.x - dC.x) * u.choppyScale, (dZ.y - dC.y) * u.heightScale, dwx + (dZ.z - dC.z) * u.choppyScale);
var n = normalize(cross(tZ, tX));
if (n.y < 0.0) { n = -n; }
// Foam where horizontal displacement folds (Jacobian < 1).
let dDxdx = (dX.x - dC.x) * u.choppyScale / dwx;
let dDzdz = (dZ.z - dC.z) * u.choppyScale / dwx;
let dDxdz = (dZ.x - dC.x) * u.choppyScale / dwx;
let dDzdx = (dX.z - dC.z) * u.choppyScale / dwx;
let jac = (1.0 + dDxdx) * (1.0 + dDzdz) - dDxdz * dDzdx;
// Only strong folds foam, and let crests carry a little spray.
let foam = smoothstep(u.foamScale, u.foamScale * 0.35, jac);
let v = normalize(u.camPos - world);
let sun = normalize(u.sunDir);
let dist = length(u.camPos - world);
// Sky reflection with Schlick Fresnel.
let r = reflect(-v, n);
let refl = skyColor(r, u.sunDir);
let f0 = 0.02;
let fres = f0 + (1.0 - f0) * pow(1.0 - max(dot(n, v), 0.0), 5.0);
// Water body: saturated teal in the troughs, brighter blue-green facing us.
let facing = max(dot(n, v), 0.0);
let deep = vec3f(0.002, 0.028, 0.055);
let shallow = vec3f(0.03, 0.16, 0.19);
var water = mix(deep, shallow, pow(facing, 0.5));
// Warm sub-surface scatter lifting the crests toward the sun.
let crest = clamp(world.y * 0.06 + 0.35, 0.0, 1.0);
let sss = pow(max(dot(v, -sun), 0.0), 3.0) * crest;
water += vec3f(0.95, 0.34, 0.14) * sss * 0.8;
// Keep a sliver of water color even at grazing angles so it never goes pure sky.
let fresW = mix(0.03, 0.92, fres);
var col = mix(water, refl, fresW);
// Warm sun specular (tight highlight; per-pixel normals make it sparkle).
let h = normalize(sun + v);
let spec = pow(max(dot(n, h), 0.0), 600.0);
col += vec3f(1.8, 1.1, 0.62) * spec * 4.5;
// Foam, slightly warm so it catches the sunset.
col = mix(col, vec3f(0.96, 0.90, 0.84), foam);
// Distance fog into the matching horizon sky so the mesh edge dissolves.
let dir = normalize(world - u.camPos);
let horizonCol = skyColor(normalize(vec3f(dir.x, 0.04, dir.z)), u.sunDir);
let fog = smoothstep(u.worldSize * 0.42, u.worldSize * 0.62, dist);
col = mix(col, horizonCol, fog);
return vec4f(col, 1.0);
}