Advanced shaders · lesson 5 of 20 · Advanced · about 7 min

Ocean 5: the same sea on a real mesh

You will build: Build the sea on a real mesh: waves in vert(), a normal worked out in frag() from the same waves plus fine ripples, then sky reflection, sun glitter, foam and haze.

The picture produced by the shader in the lesson "Ocean 5: the same sea on a real mesh"
The target picture. In the lesson you write the shader that draws it, and a match bar shows how close you are.

In Ocean 1 to 4 there was no 3D object at all. The whole sea was drawn by the pixel shader on a flat rectangle: for every pixel you shot a ray into an imaginary sea and walked along it until you hit the waves. That is a fine way to draw an endless ocean, and it needs nothing but a pixel shader, but it is not how a game engine usually does water. Unity, Unreal and Godot draw water as a real mesh: a big grid of triangles whose vertices are lifted by waves.

The two jobs are split between the two shaders you already know from the mesh lessons:

  1. vert() runs once per vertex and moves the vertex: pos.y += waveH(pos.xz). The wave function is the very same waveH as in Ocean 1. The GPU then builds the real, bumpy shape and draws it with the correct depth, so the sea can meet the shore, a boat or the camera properly.
  2. frag() runs once per pixel and does the lighting: sky reflection, sun glitter, foam and haze, exactly as in Ocean 3 and 4.

The grid can only show what it can resolve

This mesh has 180 by 180 squares across 2.8 units, so one square is about 0.016 wide. A wave needs roughly eight squares to be drawn as a smooth bump; shorter than that it turns into jagged noise. So the big waves go into the geometry, and the fine ripples must not. Instead they go into the normal, the direction the surface faces, which only changes the lighting and costs nothing in vertices. This is the standard trick: geometry for the swell, normals for the detail.

Normals from the wave function

Moving a vertex does not change the normal the mesh carries, so the pixel shader works the normal out itself. The slope of the water in each direction is how much the height changes when you step a tiny distance e sideways. Measure the height just either side of the pixel, in x and in z, and build a vector from the differences:

float e = 0.012;
float3 n = normalize(float3(
    h(xz - float2(e, 0.0)) - h(xz + float2(e, 0.0)),
    2.0 * e,
    h(xz - float2(0.0, e)) - h(xz + float2(0.0, e))));

Here h is the big waves plus the fine ripples, so the ripples catch the light without touching the shape. The pixel's position comes from i_worldPos.

The rest is the same sea

With a good normal, the lighting is what you already built: a Fresnel blend between deep water color and the reflected sky (reflect(-i_viewDir, n)), a sharp sun glitter, foam on the wave tops with lacy edges from noise, and haze that grows with the distance to the camera, length(_WorldSpaceCameraPos - i_worldPos). Drag the picture to orbit around the sea and see it as a real object. Zoom in on a wave: the silhouette is real geometry, and the glitter lives in the normals.

Which approach should you use? A mesh gives correct depth, works with every other object in the scene and uses the hardware the way engines expect. The cost scales with the number of vertices, and the shape can only be as detailed as the grid. The pixel-shader ray march gives you an endless horizon and detail down to the pixel, but it cannot interact with other geometry. Real games often combine both: a mesh near the camera, and a cheaper treatment far away.

The numbers you need

Everything the picture depends on is given, so the challenge is the code, not guessing.

The mesh
a flat grid, 2.8 x 2.8 units, 180 x 180 squares; the pixel position is i_worldPos.xz
vert()
pos.y += waveH(pos.xz)
Waves (amplitude, direction, frequency, speed)
0.060, (0.80, 0.60), 3.2, 1.1 | 0.040, (-0.60, 0.80), 4.6, 1.3 | 0.024, (0.95, -0.30), 7.5, 1.8 | 0.014, (-0.20, -0.98), 12.0, 2.4 | 0.008, (0.55, 0.83), 20.0, 3.1
Fine ripples (normal only)
0.0045, (-0.85, 0.52), 31.0, 4.0 | 0.0027, (0.30, 0.95), 48.0, 5.2
Normal
e = 0.012; n = normalize(float3(H(x - e) - H(x + e), 2.0 * e, H(z - e) - H(z + e))) with H = waveH + ripples
Fresnel and sky
r = reflect(-i_viewDir, n); r.y = abs(r.y); fres = 0.02 + 0.98 * pow(1.0 - saturate(dot(n, i_viewDir)), 5.0); col = lerp(water, sky(r), fres)
Water color
lerp(float3(0.02, 0.1, 0.18), float3(0.05, 0.3, 0.33), saturate(h * 6.0 + 0.5))
Sun glitter
float3(1.0, 0.9, 0.7) * pow(max(dot(r, sun), 0.0), 400.0) * 2.0
Foam
lace = fbm2(xz * 9.0 + float2(time * 0.3, 0.0)); foam = smoothstep(0.075, 0.105, h + (lace - 0.5) * 0.05); mix in float3(0.95, 0.97, 1.0) * 0.85
Haze
dist = length(_WorldSpaceCameraPos - i_worldPos); lerp towards float3(0.75, 0.82, 0.9) by 1.0 - exp(-dist * 0.06)

Try it in your own language

The lesson is written once and shown in every language. Pick yours: Unity (Built-in), Unity (URP), Unreal, Shadertoy / WebGL, OpenGL (LWJGL, raylib), three.js, GameMaker, LÖVE (Lua), Godot, Apple (iOS, macOS), WebGPU / Bevy.

Start coding