Skip to content
Distort · Vertex displacementIntermediate

Ocean swell

A patch of open sea with sharp crests and wide troughs, made by moving each vertex of a flat plane around a circle with three Gerstner waves.

PositionNormalViewTimeVertex
Open in Playground
  1. 01GoalDistort
  2. 02TechniqueVertex displacement
  3. 03ExecutionOcean swell
Test mesh

Parameters

1.20
0.70
0.40
#083352
#9ec7e0
#ebf5ff

Copy for your platform

  • GLSLCopy
  • GodotCopy

Node chain

Built step by step, in GLSL style, with its node names.

Built step by step, in Godot style, with its node names.

No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.

No Unity version of this card yet, so the nodes show the GLSL names.

No Blender version of this card yet, so the nodes show the GLSL names.

  1. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep
    Position
    Normal
    View
    Time

    01 Height of the moved surface

  2. dotDotProductdotdotdot

    02 Light on the new normal

  3. powPowpowpowpow

    03 Sun glint

  4. powFresnelpowpowpow

    04 Sky at grazing angles

  5. mixMixmixmixmix

    05 Water color

  6. mixMixmixmixmix

    06 Foam and glint

How it works

The plane in the preview is flat: 65 by 65 vertices at height 0. Every wave you see is made in the vertex shader, which moves each vertex before it is projected, and the fragment shader only colors the result. That is the difference from the other Distort cards: the surface itself moves, so its silhouette and its depth are real.

The vertex entry: three Gerstner waves

A plain sine wave moves each point up and down: y = A * sin(k * x - w * t). Water doesn’t. In a Gerstner wave each point of the surface travels around a small circle as the wave passes: forward on the crest, back in the trough. The horizontal motion bunches the points together on the crests and spreads them out in the troughs, so the crests come out sharp and the troughs wide. That one change is most of what makes a swell read as water.

The gerstner function computes one wave at a vertex’s rest position:

  • k = 2π / span turns the wavelength into a wavenumber, and the phase is k * dot(direction, rest) - w * t.
  • The speed comes from deep-water physics: w = sqrt(9.8 * k). Long waves outrun short ones, so the three waves slide over each other and the pattern never repeats exactly.
  • The amplitude is steepness / k. Steepness is how far toward a loop the wave is pushed: at 1, the crest becomes a cusp; above it, the circles cross and the surface folds through itself. The three waves share p_steepness (half, three tenths, a fifth), so their sum stays at 1 or below at the slider’s top.

swell adds three waves from different directions, with wavelengths in ratios (1, 0.61, 0.37) that don’t divide each other, and moves the vertex by the sum.

Recomputing the normal

Moving the vertices isn’t enough. The plane’s normal points straight up everywhere, and lighting with it would shade the waves as if they were still flat. The new normal comes from the moved surface itself: its two tangents are the derivatives of the moved position along x and along z, and their cross product is perpendicular to both.

For a Gerstner wave those derivatives have a closed form, so the function adds each wave’s slope to alongX and alongZ as it goes, with no extra evaluations. normalize(cross(alongZ, alongX)) then gives a normal that points up out of the water. The order matters: crossing them the other way gives a normal pointing down, and the sea goes dark.

The fragment entry: coloring the water

1. Height of the moved surface. crest is high where the vertex was lifted near the main wave’s full height. The fragment entry sees the moved position, so the color follows the waves. The preview shows white crest lines.

2. Light on the new normal. max(dot(normal, sun), 0.0) is the Lambert term. With the recomputed normal, the faces of the waves turned toward the sun brighten and the backs darken; with the flat normal this would be one value.

3. Sun glint. The half vector between the sun and the view direction, dotted with the normal and raised to 120, gives a small, sharp highlight that dances along the slopes facing both the sun and the camera.

4. Sky at grazing angles. pow(1.0 - dot(normal, view), 4.0) is a cheap Fresnel term. Water reflects more at grazing angles, so the slopes seen edge-on, and the far side of the plane, take the sky’s color.

5 and 6. Water color, foam and glint. The deep color, lit by the Lambert term, is mixed toward the sky by the Fresnel term, the crests are mixed toward foam, and the glint is added on top.

Cost and production notes

The vertex entry costs about 40 operations per wave, 130 for all three plus the normal, once per vertex: about 550,000 operations a frame for this 4,225-vertex plane, which a GPU doesn’t notice. The fragment entry is about 45 operations per pixel. No texture reads, one pass.

  • Vertex density is the real limit. A wave needs at least six or eight vertices per wavelength, or the crests become straight segments and the sharp tips are cut off. Here the plane has about 32 vertices per unit, so the shortest wave (0.37 of the wavelength slider) starts to break down below a wavelength of about 0.6. In a game, use a plane that is dense near the camera and coarse far away, or tessellation.
  • Everything that needs the surface needs this math. The shadow pass and depth prepass run the same vertex function automatically. A boat’s buoyancy runs on the CPU and must evaluate the same three waves there. Gerstner waves move points sideways, so finding the height above a given x and z takes a couple of iterations.
  • Three waves look like a pattern over a large sea. Add more with smaller steepness each (keeping the sum at or under 1), vary their directions within about 60 degrees of the wind, or move to a spectrum.
  • World space. The waves are computed from the world position, so neighbouring water meshes line up seamlessly. The preview’s plane has no transform, so its object space is world space; in an engine, keep the displacement in world space as the exported shader does.

Make it in GLSL

The copy button gives two shaders for one program: a vertex shader that calls swell() and projects the moved vertex, and a fragment shader that calls effect(). Both include the whole effect, so they declare the same uniforms.

  1. Build a plane in the XZ plane facing +Y, subdivided into a grid, for example 128 by 128 cells over 4 by 4 units, with positions at attribute location 0, normals (all 0, 1, 0) at 1 and UVs at 2.
  2. Compile the vertex shader and the fragment shader from the copied code and link them into one program.
  3. Each frame, set u_viewProjection to your camera’s projection times its view matrix, u_camera to the camera’s world position, and u_time to the seconds since the start. Set the p_ uniforms once, or when a slider changes; colors go in linear.
  4. Draw the grid with depth testing on. The fragment shader writes opaque, sRGB-encoded color.

The light direction and the sky color are inside effect(). To light the water with your scene’s sun, replace the sun constant with a uniform, and pass your sky’s horizon color as horizon.

Make it in Godot

The shader is a spatial shader with render_mode world_vertex_coords, so vertex() moves the plane in world space and neighbouring water meshes line up. It is unshaded, like the preview; its own sun and sky are in effect().

The exported shader moves the vertices in vertex() and colors them in fragment(), both from the same effect code, so a single ShaderMaterial does the whole card.

  1. Add a MeshInstance3D and give it a PlaneMesh of size 4 by 4, with Subdivide Width and Subdivide Depth set to 127.
  2. Set Material Override to a new ShaderMaterial, and its Shader to a new Shader.
  3. Paste the copied code into the shader. The waves start moving at once: Godot’s TIME drives i.time.
  4. Under Geometry, raise Extra Cull Margin to about 1, so crests near the screen’s edge don’t pop.
  5. Tune the params under Shader Parameters: the same names as on this card, without the p_ prefix.

Godot’s shadow and depth passes run the same vertex(), so the shadow the water casts follows the waves, and once you remove unshaded (see the pitfalls) so do shadows falling on it. Physics doesn’t: a floating RigidBody3D needs the three waves evaluated in GDScript at its position.

A VisualShader can do the vertex part with Expression nodes, but the three waves and their tangents make a large graph; the text shader is the practical form. For the fragment part, the nodes are DotProduct and Max for the light, Pow for the glint, Fresnel for the sky, and Mix to combine them.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

ocean-swell.glslGLSL
#version 300 es// Vertex displacement → Ocean swell, vertex shader, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform mat4 u_viewProjection;uniform vec3 u_camera;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_wavelength; // Wavelength: 1.2 (0.3 to 3.0)uniform float p_steepness; // Steepness: 0.7 (0.0 to 1.0)uniform float p_speed; // Speed: 0.4 (0.0 to 2.0)uniform vec3 p_deep; // Deep water: vec3(0.03, 0.2, 0.32), sRGB; upload it decoded to linearuniform vec3 p_horizon; // Sky reflection: vec3(0.62, 0.78, 0.88), sRGB; upload it decoded to linearuniform vec3 p_foam; // Foam: vec3(0.92, 0.96, 1.0), sRGB; upload it decoded to linearlayout(location = 0) in vec3 a_position;layout(location = 1) in vec3 a_normal;layout(location = 2) in vec2 a_uv;out vec2 v_uv;out vec3 v_position;out vec3 v_normal;out vec3 v_view;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// One Gerstner wave at the rest position `rest`: returns how far it moves the point and adds its slope to the two// tangents of the surface, along x and along z. The point moves on a circle, so the crests come out sharper than a// sine's and the troughs flatter.vec3 gerstner(vec2 rest, vec2 direction, float span, float q, float t, inout vec3 alongX, inout vec3 alongZ) {	vec2 d = normalize(direction);	float k = 6.2831853 / span;	// Deep-water waves: speed grows with wavelength (w^2 = g * k), so long swells outrun short chop	float phase = k * dot(d, rest) - sqrt(9.8 * k) * t;	float a = q / k;	float s = q * sin(phase);	float c = q * cos(phase);	alongX += vec3(-d.x * d.x * s, d.x * c, -d.x * d.y * s);	alongZ += vec3(-d.x * d.y * s, d.y * c, -d.y * d.y * s);	return vec3(d.x * a * cos(phase), a * sin(phase), d.y * a * cos(phase));}Inputs swell(Inputs i) {	vec2 rest = i.position.xz;	float t = i.time * p_speed;	vec3 alongX = vec3(1.0, 0.0, 0.0);	vec3 alongZ = vec3(0.0, 0.0, 1.0);	// Three waves from different directions, sharing the steepness slider so their sum never passes 1, which	// keeps the crests from folding over into loops	vec3 moved = gerstner(rest, vec2(1.0, 0.35), p_wavelength, p_steepness * 0.5, t, alongX, alongZ);	moved += gerstner(rest, vec2(0.6, -0.8), p_wavelength * 0.61, p_steepness * 0.3, t, alongX, alongZ);	moved += gerstner(rest, vec2(-0.3, 1.0), p_wavelength * 0.37, p_steepness * 0.2, t, alongX, alongZ);	i.position += moved;	i.normal = normalize(cross(alongZ, alongX));	return i;}vec4 effect(Inputs i) {	vec3 sun = normalize(vec3(-0.5, 0.6, -0.6));	// Foam from 30% to 100% of the main wave's amplitude (steepness / k);	// the smaller waves stacked on top lift some crests higher	float peak = p_steepness * 0.5 * p_wavelength / 6.2831853;	float crest = smoothstep(0.3 * peak, peak, i.position.y);	float diffuse = max(dot(i.normal, sun), 0.0);	float shine = pow(max(dot(i.normal, normalize(sun + i.view)), 0.0), 120.0);	float fresnel = pow(1.0 - max(dot(i.normal, i.view), 0.0), 4.0);	vec3 water = mix(p_deep * (0.4 + 0.6 * diffuse), p_horizon, fresnel);	vec3 color = mix(water, p_foam, crest * 0.7) + shine;	return vec4(color, 1.0);}void main() {	Inputs i;	i.uv = a_uv;	// The test meshes have no model transform: object space is world space	i.position = a_position;	i.normal = normalize(a_normal);	i.view = normalize(u_camera - a_position);	i.time = u_time;	// No pixel yet in a vertex shader	i.fragCoord = vec2(0.0);	i.resolution = u_resolution;	i = swell(i);	v_uv = a_uv;	v_position = i.position;	v_normal = i.normal;	// From the moved surface toward the camera; the fragment shader normalizes it	v_view = u_camera - i.position;	gl_Position = u_viewProjection * vec4(i.position, 1.0);}#version 300 es// Vertex displacement → Ocean swell, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_wavelength; // Wavelength: 1.2 (0.3 to 3.0)uniform float p_steepness; // Steepness: 0.7 (0.0 to 1.0)uniform float p_speed; // Speed: 0.4 (0.0 to 2.0)uniform vec3 p_deep; // Deep water: vec3(0.03, 0.2, 0.32), sRGB; upload it decoded to linearuniform vec3 p_horizon; // Sky reflection: vec3(0.62, 0.78, 0.88), sRGB; upload it decoded to linearuniform vec3 p_foam; // Foam: vec3(0.92, 0.96, 1.0), sRGB; upload it decoded to linearin vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// One Gerstner wave at the rest position `rest`: returns how far it moves the point and adds its slope to the two// tangents of the surface, along x and along z. The point moves on a circle, so the crests come out sharper than a// sine's and the troughs flatter.vec3 gerstner(vec2 rest, vec2 direction, float span, float q, float t, inout vec3 alongX, inout vec3 alongZ) {	vec2 d = normalize(direction);	float k = 6.2831853 / span;	// Deep-water waves: speed grows with wavelength (w^2 = g * k), so long swells outrun short chop	float phase = k * dot(d, rest) - sqrt(9.8 * k) * t;	float a = q / k;	float s = q * sin(phase);	float c = q * cos(phase);	alongX += vec3(-d.x * d.x * s, d.x * c, -d.x * d.y * s);	alongZ += vec3(-d.x * d.y * s, d.y * c, -d.y * d.y * s);	return vec3(d.x * a * cos(phase), a * sin(phase), d.y * a * cos(phase));}Inputs swell(Inputs i) {	vec2 rest = i.position.xz;	float t = i.time * p_speed;	vec3 alongX = vec3(1.0, 0.0, 0.0);	vec3 alongZ = vec3(0.0, 0.0, 1.0);	// Three waves from different directions, sharing the steepness slider so their sum never passes 1, which	// keeps the crests from folding over into loops	vec3 moved = gerstner(rest, vec2(1.0, 0.35), p_wavelength, p_steepness * 0.5, t, alongX, alongZ);	moved += gerstner(rest, vec2(0.6, -0.8), p_wavelength * 0.61, p_steepness * 0.3, t, alongX, alongZ);	moved += gerstner(rest, vec2(-0.3, 1.0), p_wavelength * 0.37, p_steepness * 0.2, t, alongX, alongZ);	i.position += moved;	i.normal = normalize(cross(alongZ, alongX));	return i;}vec4 effect(Inputs i) {	vec3 sun = normalize(vec3(-0.5, 0.6, -0.6));	// Foam from 30% to 100% of the main wave's amplitude (steepness / k);	// the smaller waves stacked on top lift some crests higher	float peak = p_steepness * 0.5 * p_wavelength / 6.2831853;	float crest = smoothstep(0.3 * peak, peak, i.position.y);	float diffuse = max(dot(i.normal, sun), 0.0);	float shine = pow(max(dot(i.normal, normalize(sun + i.view)), 0.0), 120.0);	float fresnel = pow(1.0 - max(dot(i.normal, i.view), 0.0), 4.0);	vec3 water = mix(p_deep * (0.4 + 0.6 * diffuse), p_horizon, fresnel);	vec3 color = mix(water, p_foam, crest * 0.7) + shine;	return vec4(color, 1.0);}void main() {	Inputs i;	i.uv = v_uv;	i.position = v_position;	// Interpolation shortens the vectors between vertices	i.normal = normalize(v_normal);	i.view = normalize(v_view);	i.time = u_time;	i.fragCoord = gl_FragCoord.xy;	i.resolution = u_resolution;	vec4 frag_out = effect(i);	// The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1	fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}
ocean-swell.gdshaderGodot shader
// Vertex displacement → Ocean swell, Godot 4. From Creative Center, MIT license.shader_type spatial;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch it// world_vertex_coords: vertex() sees VERTEX and NORMAL in world space, as the effect's vertex entry expectsrender_mode unshaded, world_vertex_coords;uniform float wavelength : hint_range(0.3, 3.0, 0.05) = 1.2;uniform float steepness : hint_range(0.0, 1.0, 0.01) = 0.7;uniform float speed : hint_range(0.0, 2.0, 0.05) = 0.4;uniform vec3 deep : source_color = vec3(0.03, 0.2, 0.32);uniform vec3 horizon : source_color = vec3(0.62, 0.78, 0.88);uniform vec3 foam : source_color = vec3(0.92, 0.96, 1.0);struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// One Gerstner wave at the rest position `rest`: returns how far it moves the point and adds its slope to the two// tangents of the surface, along x and along z. The point moves on a circle, so the crests come out sharper than a// sine's and the troughs flatter.vec3 gerstner(vec2 rest, vec2 direction, float span, float q, float t, inout vec3 alongX, inout vec3 alongZ) {	vec2 d = normalize(direction);	float k = 6.2831853 / span;	// Deep-water waves: speed grows with wavelength (w^2 = g * k), so long swells outrun short chop	float phase = k * dot(d, rest) - sqrt(9.8 * k) * t;	float a = q / k;	float s = q * sin(phase);	float c = q * cos(phase);	alongX += vec3(-d.x * d.x * s, d.x * c, -d.x * d.y * s);	alongZ += vec3(-d.x * d.y * s, d.y * c, -d.y * d.y * s);	return vec3(d.x * a * cos(phase), a * sin(phase), d.y * a * cos(phase));}Inputs swell(Inputs i) {	vec2 rest = i.position.xz;	float t = i.time * speed;	vec3 alongX = vec3(1.0, 0.0, 0.0);	vec3 alongZ = vec3(0.0, 0.0, 1.0);	// Three waves from different directions, sharing the steepness slider so their sum never passes 1, which	// keeps the crests from folding over into loops	vec3 moved = gerstner(rest, vec2(1.0, 0.35), wavelength, steepness * 0.5, t, alongX, alongZ);	moved += gerstner(rest, vec2(0.6, -0.8), wavelength * 0.61, steepness * 0.3, t, alongX, alongZ);	moved += gerstner(rest, vec2(-0.3, 1.0), wavelength * 0.37, steepness * 0.2, t, alongX, alongZ);	i.position += moved;	i.normal = normalize(cross(alongZ, alongX));	return i;}vec4 effect(Inputs i) {	vec3 sun = normalize(vec3(-0.5, 0.6, -0.6));	// Foam from 30% to 100% of the main wave's amplitude (steepness / k);	// the smaller waves stacked on top lift some crests higher	float peak = steepness * 0.5 * wavelength / 6.2831853;	float crest = smoothstep(0.3 * peak, peak, i.position.y);	float diffuse = max(dot(i.normal, sun), 0.0);	float shine = pow(max(dot(i.normal, normalize(sun + i.view)), 0.0), 120.0);	float fresnel = pow(1.0 - max(dot(i.normal, i.view), 0.0), 4.0);	vec3 water = mix(deep * (0.4 + 0.6 * diffuse), horizon, fresnel);	vec3 color = mix(water, foam, crest * 0.7) + shine;	return vec4(color, 1.0);}void vertex() {	Inputs i;	// Godot's UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(UV.x, 1.0 - UV.y);	i.position = VERTEX;	i.normal = normalize(NORMAL);	i.view = normalize(CAMERA_POSITION_WORLD - VERTEX);	i.time = TIME;	// No pixel yet in vertex()	i.fragCoord = vec2(0.0);	i.resolution = VIEWPORT_SIZE;	i = swell(i);	VERTEX = i.position;	NORMAL = i.normal;}void fragment() {	Inputs i;	// Godot's UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(UV.x, 1.0 - UV.y);	// VERTEX, NORMAL and VIEW are in view space here; the effect works in world space	i.position = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;	i.normal = normalize((INV_VIEW_MATRIX * vec4(NORMAL, 0.0)).xyz);	i.view = normalize((INV_VIEW_MATRIX * vec4(VIEW, 0.0)).xyz);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = VIEWPORT_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	ALBEDO = frag_out.rgb;}

No Unreal Engine 5 code for this card yet.

No Unity code for this card yet.

No Blender code for this card yet.

Further reading