Rendering a convincing ocean surface is one of the most rewarding challenges in real-time graphics. Games like Sea of Thieves, Uncharted 4, and The Legend of Zelda: Wind Waker each take a different approach — from physically-based deep-ocean simulations to hand-painted stylized waves — but they all share a common set of building blocks: Gerstner wave displacement, Fresnel-driven reflection/refraction blending, subsurface scattering approximation, and procedural foam generation. In this post we will build a complete stylized ocean shader from scratch in a single GLSL fragment shader, using raymarching over a procedural heightfield. Every technique discussed here transfers directly to vertex-displaced mesh workflows in Unity, Unreal, or Godot.
Below is the full, standalone WebGL1 fragment shader. Copy it into any ShaderToy-style host that provides iResolution and iTime uniforms and you will see an animated, stylized ocean viewed from a low angle with rolling Gerstner waves, sun specular highlights, depth-based color variation, subsurface scattering glow, and foam on the crests.
precision mediump float;
uniform vec2 iResolution;
uniform float iTime;
// ============================================================
// STYLIZED OCEAN SHADER
// Gerstner waves, Fresnel blending, subsurface scattering,
// foam, sun specular, depth-based absorption.
// ============================================================
#define PI 3.14159265
#define TAU 6.28318530
#define NUM_WAVES 5
#define MARCH_STEPS 80
#define MARCH_MAX_DIST 150.0
#define NORMAL_EPS 0.02
// ------- Color palette -------
const vec3 SKY_TOP = vec3(0.25, 0.55, 0.92);
const vec3 SKY_HORIZON = vec3(0.70, 0.85, 0.98);
const vec3 SUN_COLOR = vec3(1.0, 0.95, 0.8);
const vec3 WATER_DEEP = vec3(0.01, 0.06, 0.14);
const vec3 WATER_SHALLOW = vec3(0.0, 0.22, 0.38);
const vec3 SSS_COLOR = vec3(0.08, 0.6, 0.45);
const vec3 FOAM_COLOR = vec3(0.95, 0.98, 1.0);
// Sun direction (normalized)
const vec3 SUN_DIR = normalize(vec3(0.8, 0.35, -0.5));
// -----------------------------------------------------------
// Hash & noise helpers (value noise, no textures needed)
// -----------------------------------------------------------
float hash(vec2 p) {
// Simple 2D hash — fast, no texture lookups
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float valueNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
// Smooth interpolation (Hermite)
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
// Fractal Brownian Motion — 4 octaves
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
mat2 rot = mat2(0.8, 0.6, -0.6, 0.8); // rotate each octave
for (int i = 0; i < 4; i++) {
v += a * valueNoise(p);
p = rot * p * 2.0;
a *= 0.5;
}
return v;
}
// -----------------------------------------------------------
// Gerstner wave — returns vertical displacement (y)
// Each wave is defined by: direction, steepness, wavelength
// -----------------------------------------------------------
float gerstnerWaveY(vec2 pos, vec2 dir, float steepness, float waveLen, float phase) {
// Angular frequency and wave number
float k = TAU / waveLen;
float speed = sqrt(9.8 / k); // deep-water dispersion
float d = dot(dir, pos);
float t = k * d - speed * iTime + phase;
// Vertical component of Gerstner displacement
return steepness * sin(t) / k;
}
// Sum of multiple Gerstner waves at position 'pos'
float oceanHeight(vec2 pos) {
float h = 0.0;
// Wave 0: dominant swell
h += gerstnerWaveY(pos, normalize(vec2(1.0, 0.3)), 0.45, 12.0, 0.0);
// Wave 1: secondary swell at an angle
h += gerstnerWaveY(pos, normalize(vec2(0.7, -0.5)), 0.30, 8.0, 1.3);
// Wave 2: shorter chop
h += gerstnerWaveY(pos, normalize(vec2(-0.4, 0.8)), 0.20, 4.5, 2.7);
// Wave 3: fine ripple
h += gerstnerWaveY(pos, normalize(vec2(0.2, 1.0)), 0.12, 2.5, 4.1);
// Wave 4: micro-detail
h += gerstnerWaveY(pos, normalize(vec2(-0.8, -0.3)),0.07, 1.3, 5.5);
// Layered value-noise detail (simulates wind chop / capillary waves)
float detail = fbm(pos * 0.6 + iTime * 0.15) * 0.35;
h += detail;
return h;
}
// -----------------------------------------------------------
// Compute surface normal from the heightfield via central
// differences (the same technique used in normal-from-heightmap)
// -----------------------------------------------------------
vec3 oceanNormal(vec2 pos) {
float e = NORMAL_EPS;
float h = oceanHeight(pos);
float hx = oceanHeight(pos + vec2(e, 0.0));
float hz = oceanHeight(pos + vec2(0.0, e));
// Tangent vectors in x and z, cross product gives normal
return normalize(vec3(h - hx, e, h - hz));
}
// -----------------------------------------------------------
// Sky color with simple gradient + sun disc
// -----------------------------------------------------------
vec3 sky(vec3 rd) {
// Vertical gradient
float t = clamp(rd.y * 0.5 + 0.5, 0.0, 1.0);
vec3 col = mix(SKY_HORIZON, SKY_TOP, t);
// Sun disc (soft edge)
float sun = max(dot(rd, SUN_DIR), 0.0);
col += SUN_COLOR * pow(sun, 256.0) * 1.5; // hard core
col += SUN_COLOR * pow(sun, 32.0) * 0.3; // soft glow
col += SUN_COLOR * pow(sun, 4.0) * 0.05; // wide halo
return col;
}
// -----------------------------------------------------------
// Foam: driven by wave crest height + noise turbulence
// -----------------------------------------------------------
float foamMask(vec2 pos, float waveH) {
// Foam appears on crests (high wave height)
float crest = smoothstep(0.35, 0.85, waveH);
// Noisy breakup so foam isn't a solid band
float turb = fbm(pos * 3.0 + iTime * 0.4);
float mask = crest * smoothstep(0.35, 0.6, turb);
// Trailing foam streaks (wind lines)
float streak = smoothstep(0.62, 0.68,
valueNoise(pos * vec2(1.5, 8.0) + vec2(iTime * 0.2, 0.0)));
mask += streak * 0.25;
return clamp(mask, 0.0, 1.0);
}
// -----------------------------------------------------------
// Main rendering
// -----------------------------------------------------------
void main() {
// ---- UV & ray setup ----
vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y;
// Camera: low angle over the water
float camTime = iTime * 0.12;
vec3 ro = vec3(camTime * 3.0, 2.8, camTime * 1.5); // ray origin
vec3 target = ro + vec3(4.0, -0.5, 2.0);
// Simple look-at camera matrix
vec3 fwd = normalize(target - ro);
vec3 right = normalize(cross(fwd, vec3(0.0, 1.0, 0.0)));
vec3 up = cross(right, fwd);
vec3 rd = normalize(fwd + uv.x * right + uv.y * up);
// ---- Sky (default color if we miss the water) ----
vec3 col = sky(rd);
// ---- Raymarch the ocean heightfield ----
float t = 0.1;
float h = 0.0;
bool hit = false;
for (int i = 0; i < MARCH_STEPS; i++) {
vec3 p = ro + rd * t;
h = oceanHeight(p.xz);
float diff = p.y - h;
if (diff < 0.01) {
hit = true;
break;
}
// Adaptive step size: larger steps far from surface
t += max(diff * 0.6, 0.05);
if (t > MARCH_MAX_DIST) break;
}
if (hit) {
vec3 hitPos = ro + rd * t;
vec3 N = oceanNormal(hitPos.xz);
// ---- Fresnel effect ----
// Controls blend between reflection and water body color.
// Schlick approximation: F = F0 + (1 - F0)(1 - cos theta)^5
float cosTheta = max(dot(N, -rd), 0.0);
float F0 = 0.02; // water IOR ~1.33
float fresnel = F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
// ---- Reflection (sky) ----
vec3 reflDir = reflect(rd, N);
vec3 reflColor = sky(reflDir);
// ---- Depth-based water body color (Beer's law approx) ----
// Fake depth: higher wave = shallower = lighter color
float fakeDepth = clamp((hitPos.y - h + 1.5) * 0.4, 0.0, 1.0);
vec3 waterBody = mix(WATER_DEEP, WATER_SHALLOW, fakeDepth);
// ---- Subsurface scattering approximation ----
// Light that passes through the wave crest, visible
// when the sun is behind thin wave peaks.
float sss = pow(max(dot(rd, SUN_DIR), 0.0), 4.0);
sss *= smoothstep(0.0, 0.6, h); // only on crests
waterBody += SSS_COLOR * sss * 0.6;
// ---- Fresnel blend: reflection vs body ----
vec3 waterColor = mix(waterBody, reflColor, fresnel);
// ---- Sun specular highlight (Blinn-Phong) ----
vec3 halfVec = normalize(SUN_DIR - rd);
float spec = pow(max(dot(N, halfVec), 0.0), 256.0);
waterColor += SUN_COLOR * spec * 1.8;
// Broader soft specular for sun road
float specSoft = pow(max(dot(N, halfVec), 0.0), 16.0);
waterColor += SUN_COLOR * specSoft * 0.08;
// ---- Foam ----
float foam = foamMask(hitPos.xz, h);
waterColor = mix(waterColor, FOAM_COLOR, foam * 0.7);
// ---- Distance fog (blend toward horizon) ----
float fogT = 1.0 - exp(-t * 0.012);
waterColor = mix(waterColor, sky(rd), fogT);
col = waterColor;
}
// ---- Tone mapping & gamma ----
col = col / (col + vec3(1.0)); // Reinhard
col = pow(col, vec3(1.0 / 2.2));
gl_FragColor = vec4(col, 1.0);
}
The shader operates as a full-screen raymarcher over a procedural heightfield. For every pixel we cast a ray from a virtual camera positioned low above the water surface. We step along the ray, sampling the ocean height function at each XZ position until the ray dips below the surface. Once we find a hit point, we compute the surface normal, lighting, and shading. This is the same fundamental approach used in offline ocean renderers; in a game engine you would typically displace a mesh grid instead, but the shading math remains identical.
Simple sine waves produce a surface that looks like corrugated metal — the crests and troughs are symmetrical, which never happens in nature. Gerstner waves (also called trochoidal waves) solve this by displacing points in a circular orbit, producing sharp crests and broad troughs. In the full 3D vertex-displacement form, each point moves both vertically and horizontally. In our heightfield shader we use only the vertical component for simplicity, but the visual result is still convincing because we layer multiple waves at different angles, frequencies, and amplitudes.
// Single Gerstner wave — vertical displacement only.
// dir: normalized wave propagation direction
// steepness: controls crest sharpness (0 = flat sine, 1 = looping)
// waveLen: distance between crests in world units
// phase: offset so waves don't all start in sync
float gerstnerWaveY(vec2 pos, vec2 dir, float steepness, float waveLen, float phase) {
float k = TAU / waveLen;
float speed = sqrt(9.8 / k); // deep-water dispersion relation
float d = dot(dir, pos);
float t = k * d - speed * iTime + phase;
return steepness * sin(t) / k;
}
The sqrt(9.8 / k) term is the deep-water dispersion relation — it ensures that longer wavelengths travel faster than shorter ones, exactly as in real oceans. This single detail makes the surface motion feel physically grounded even in a stylized shader. We sum five waves with decreasing amplitude and wavelength, each propagating in a different direction, to get the final height. In production engines like Unreal's Water plugin, 8-16 Gerstner waves are common, sometimes supplemented by an FFT ocean spectrum (Tessendorf's method).
Once we have a height function h(x, z), we need the surface normal for lighting. The standard technique is central (or forward) finite differences: sample the height at two neighboring points offset along X and Z, then construct tangent vectors and take their cross product.
vec3 oceanNormal(vec2 pos) {
float e = 0.02; // epsilon — controls normal smoothness
float h = oceanHeight(pos);
float hx = oceanHeight(pos + vec2(e, 0.0));
float hz = oceanHeight(pos + vec2(0.0, e));
// Cross product of tangent vectors:
// T_x = (e, hx - h, 0) and T_z = (0, hz - h, e)
return normalize(vec3(h - hx, e, h - hz));
}
A smaller epsilon produces sharper normals that reveal fine detail; a larger epsilon smooths them out. In a game engine with normal maps, you would compute a low-frequency normal from the mesh displacement and blend in a high-frequency detail normal from a tiling normal map — the two are combined in tangent space. The same concept applies here: our FBM noise layer adds the high-frequency detail that the Gerstner waves alone cannot provide.
The Fresnel effect is arguably the single most important visual property of water. When you look straight down into a pool, you see through to the bottom (low reflectance). When you look at a glancing angle toward the horizon, the surface becomes a near-perfect mirror (high reflectance). The Schlick approximation models this cheaply:
// Schlick's Fresnel approximation // F0 = reflectance at normal incidence (0.02 for water, IOR ~1.33) float cosTheta = max(dot(N, -rd), 0.0); float F0 = 0.02; float fresnel = F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); // Blend between the water body color and the reflected sky vec3 waterColor = mix(waterBody, reflColor, fresnel);
At normal incidence (cosTheta = 1), the Fresnel term is just F0 = 0.02, so 98% of the color comes from the water body. At grazing angles (cosTheta → 0), the term approaches 1.0 and the surface becomes a mirror. This transition is what gives oceans that characteristic look where the near water is dark and transparent but the far water is bright and reflective.
When sunlight hits a thin wave crest from behind, some of it passes through the water and scatters out toward the viewer, creating a beautiful translucent green/teal glow. This is subsurface scattering (SSS). Full SSS simulation is expensive, but a convincing approximation is surprisingly simple: check how closely the view direction aligns with the sun direction (a proxy for "light is behind the wave and passing through it"), then modulate by wave height so only crests — the thinnest parts — glow.
// SSS approximation: glow when sun is behind thin wave crests float sss = pow(max(dot(rd, SUN_DIR), 0.0), 4.0); sss *= smoothstep(0.0, 0.6, waveHeight); waterBody += SSS_COLOR * sss * 0.6;
This is the technique used in Sea of Thieves and many other stylized water shaders. The key insight is that dot(rd, sunDir) is maximized when you are looking toward the sun — exactly when back-lit translucency would be visible in the real world. The smoothstep on wave height ensures only the peaks glow, not the troughs.
Foam appears where waves break — primarily on crests and where water meets obstacles. In our shader we use two layers of procedural foam:
float foamMask(vec2 pos, float waveH) {
// 1. Crest foam: appears on the highest parts of waves
float crest = smoothstep(0.35, 0.85, waveH);
// 2. Turbulent breakup: FBM noise prevents solid white bands
float turb = fbm(pos * 3.0 + iTime * 0.4);
float mask = crest * smoothstep(0.35, 0.6, turb);
// 3. Wind streaks: elongated noise simulates Langmuir circulation
float streak = smoothstep(0.62, 0.68,
valueNoise(pos * vec2(1.5, 8.0) + vec2(iTime * 0.2, 0.0)));
mask += streak * 0.25;
return clamp(mask, 0.0, 1.0);
}
Crest foam uses wave height as a mask — the higher the water at a point, the more likely it is to have foam. This is a simplified version of the Jacobian-based foam used in FFT ocean systems, where foam appears wherever the surface folds and the Jacobian determinant drops below zero. Turbulent breakup with FBM noise prevents the foam from looking like uniform white stripes. Wind streaks use anisotropically stretched noise (note the vec2(1.5, 8.0) scaling) to simulate the long foam lines you see on real oceans caused by Langmuir circulation cells.
Real ocean water changes color with depth due to wavelength-dependent absorption (Beer-Lambert law). Red light is absorbed within the first few meters, leaving blue and green. We approximate this with a simple mix between a deep navy and a brighter teal, driven by wave height as a proxy for depth. Additionally, atmospheric perspective (distance fog) blends distant water toward the sky color, which is critical for selling the illusion of a vast ocean. Without it, the horizon line between water and sky is unnaturally sharp.
// Depth-based color (Beer's law approximation) float fakeDepth = clamp((hitPos.y - h + 1.5) * 0.4, 0.0, 1.0); vec3 waterBody = mix(WATER_DEEP, WATER_SHALLOW, fakeDepth); // Distance fog — exponential falloff toward sky color float fogT = 1.0 - exp(-t * 0.012); waterColor = mix(waterColor, sky(rd), fogT);
In a production pipeline, the raymarching approach shown here would be replaced with a displaced mesh grid. The translation is straightforward:
1. Vertex shader: Apply Gerstner wave displacement (both horizontal and vertical components) to a flat grid mesh. Pass the world position and displaced normal to the fragment shader.
2. Fragment shader: The shading logic — Fresnel blending, SSS, specular, foam, depth coloring — transfers almost verbatim from the raymarched version. Replace the heightfield normal computation with per-vertex normals combined with a tiling detail normal map.
3. Reflections: Replace our analytical sky function with a skybox cubemap sample, planar reflections, or screen-space reflections depending on your quality target.
4. Refraction: Sample the scene color buffer with a UV offset derived from the water normal to simulate the distortion you see when looking through water. Blend this with the water body color using Fresnel.
5. Foam: Use a scrolling foam texture instead of (or in addition to) procedural noise. Drive its opacity from the wave Jacobian or a simple height threshold as we do here.
The principles remain the same across all rendering frameworks — Gerstner waves for shape, Fresnel for reflection balance, SSS for translucent crests, and procedural or texture-driven foam for whitecaps. Mastering these building blocks gives you the vocabulary to create anything from a photorealistic deep ocean to a stylized toon-shaded pond.