Ocean Shader — GLSL, Gerstner Waves & Fresnel in 3 Days

Realistic ocean water with animated vertex displacement, foam at crests and a Fresnel reflection effect — all written from scratch in GLSL over a long weekend.

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:

x(u, t) = u − (Q · A · k.x) · sin(dot(k, u) − ω · t)
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:

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.