Why Write Your Own Ocean Shader?
Three.js's built-in Water from the examples folder is
beautiful and works well. But using it is a black box — you tweak
numbers and hope. Writing it from scratch means understanding
why water looks the way it does.
Also: I once saw a production ocean shader that crashed on WebGL2 due to an extension dependency in the Three.js Water code. Writing your own means zero surprise dependencies.
Gerstner Waves — The Physics
Real ocean waves are not sinusoidal. The water surface is closer to a Gerstner wave (also called trochoidal wave) — the crest is sharper and the trough is flatter than a pure sine:
y(u, t) = A · cos(dot(k, u) − ω · t)
Where A is amplitude, k is the wave vector (direction + frequency), ω = sqrt(g · |k|) is the angular frequency (from the deep water dispersion relation), and Q is the steepness parameter (0 = sine, 1 = sharp crest).
In the vertex shader, summing 4–8 Gerstner waves with different directions, amplitudes and frequencies gives a convincing ocean surface:
// GLSL vertex shader — Gerstner wave function
vec3 gerstner(vec2 uv, float A, vec2 dir, float steepness, float speed, float t) {
vec2 k = normalize(dir);
float w = sqrt(9.8 * length(dir));
float phase = dot(k, uv) * length(dir) - w * speed * t;
float s = steepness * A;
return vec3(
s * k.x * cos(phase),
A * sin(phase),
s * k.y * cos(phase)
);
}
// Sum multiple waves
vec3 total = vec3(0.0);
total += gerstner(vUv * 20.0, 0.15, vec2(1.0, 0.8), 0.4, 0.8, uTime);
total += gerstner(vUv * 15.0, 0.10, vec2(-0.7, 1.0), 0.3, 1.1, uTime);
total += gerstner(vUv * 30.0, 0.05, vec2(0.5, -0.9), 0.6, 1.4, uTime);
vec3 displaced = position + total;
Fresnel — Why Water Looks Like a Mirror at Grazing Angles
Look at a calm lake from above and you see through it. Look at it from a low angle (near-horizontal) and you see only reflections. This is the Fresnel effect — the reflectance of a surface increases as the viewing angle becomes more grazing.
The Schlick approximation gives a fast GPU-friendly formula:
// Fragment shader — Schlick Fresnel
float fresnel(vec3 viewDir, vec3 normal, float ior) {
float r0 = pow((1.0 - ior) / (1.0 + ior), 2.0);
float cosTheta = clamp(1.0 - dot(viewDir, normal), 0.0, 1.0);
return r0 + (1.0 - r0) * pow(cosTheta, 5.0);
}
float f = fresnel(normalize(vViewDir), vNormal, 1.33); // water IOR ≈ 1.33
vec4 color = mix(refractColor, reflectColor, f);
Foam at Wave Crests
Real waves foam at the crest because of turbulent air entrainment. In the shader, I detect crests by checking whether the displaced vertex height exceeds a threshold, then blend in a white foam texture scaled by the excess height:
float foamMask = smoothstep(0.6, 1.0, (waveHeight - 0.5) / 0.5);
vec3 foamColor = vec3(1.0);
color.rgb = mix(color.rgb, foamColor, foamMask * 0.85);
Day 3: Putting It Together
By the end of day 3, the ocean had:
- 8 summed Gerstner waves with varied direction, frequency and steepness
- Fresnel-blended sky reflection and depth-attenuated underwater colour
- Foam on wave crests driven by displacement height
- Normal map for high-frequency surface detail (ripples)
- Sun specular highlight using Blinn-Phong
Total shader code: ~180 lines of GLSL. Runs at a constant 60 FPS even on integrated graphics.
The ocean simulation is live at /ocean/. Try changing the wind direction slider and watch the wave pattern shift.