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.
- 01GoalDistort
- 02TechniqueVertex displacement
- 03ExecutionOcean swell
Parameters
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.
smoothstepSmoothStepsmoothstepsmoothstepsmoothstep PositionNormalViewTime01 Height of the moved surface
dotDotProductdotdotdot In02 Light on the new normal
powPowpowpowpow In03 Sun glint
powFresnelpowpowpow In04 Sky at grazing angles
mixMixmixmixmix In05 Water color
mixMixmixmixmix In06 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 shaderThe GPU program that runs once for every vertex of a mesh. It places the vertex on screen by writing gl_Position, and passes values on to the fragment shader. Vertex shader The GPU program that runs once for every vertex of a mesh. It places the vertex on screen by writing gl_Position, and passes values on to the fragment shader.
The vertex entry: three Gerstner waves
A plain sine wave moves each point up and down: Gerstner wave A model of a water wave in which each point of the surface moves in a circle instead of only up and down. The crests come out sharp and the troughs wide, as on a real sea.y = A * sin(k * x - w * t). Water doesn’t. In a
Gerstner waveA model of a water wave in which each point of the surface moves in a circle instead of only up and down. The crests come out sharp and the troughs wide, as on a real sea.P = (x + d.x·A·cos θ, A·sin θ, z + d.y·A·cos θ), θ = k·(d·xz) − ωt 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π / spanturns the wavelength into a wavenumber, and the phase isk * 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 sharep_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 normalA unit vector perpendicular to a surface, pointing the way it faces. Lighting and Fresnel effects compare it with other directions. Normal A unit vector perpendicular to a surface, pointing the way it faces. Lighting and Fresnel effects compare it with other directions. Vector A group of two to four numbers handled as one value (vec2, vec3, vec4). The same type holds a position, a direction, a color or a UV.
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. Fresnel effect Surfaces reflect more light when seen at a grazing angle than when seen head-on. Shaders often fake it with the dot product of the normal and the view direction.pow(1.0 - dot(normal, view), 4.0) is a cheap FresnelSurfaces reflect more light when seen at a grazing angle than when seen head-on. Shaders often fake it with the dot product of the normal and the view direction.F = F0 + (1 − F0)(1 − cos θ)^5
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.
- 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. - Compile the vertex shader and the fragment shader from the copied code and link them into one program.
- Each frame, set
u_viewProjectionto your camera’s projection times its view matrix,u_camerato the camera’s world position, andu_timeto the seconds since the start. Set thep_uniforms once, or when a slider changes; colors go in linear. - 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.
- Add a
MeshInstance3Dand give it aPlaneMeshof size 4 by 4, with Subdivide Width and Subdivide Depth set to 127. - Set Material Override to a new
ShaderMaterial, and its Shader to a newShader. - Paste the copied code into the shader. The waves start moving at once: Godot’s
TIMEdrivesi.time. - Under Geometry, raise Extra Cull Margin to about 1, so crests near the screen’s edge don’t pop.
- 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
#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);}// 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
- Effective Water Simulation from Physical Models (GPU Gems, chapter 1) (opens in a new tab)Book
- Trochoidal wave (opens in a new tab)Reference
- Dispersion (water waves) (opens in a new tab)Reference
- cross (opens in a new tab)Reference
- normalize (opens in a new tab)Reference
- Your first 3D shader (opens in a new tab)Docs