Navigate

Search topics across all sections

GitHub
Shader Recipes

Raging Sea

You are creating an ocean inside a glass box. A flat plane gets hundreds of segments, and the vertex shader pushes each one up and down like waves. The fragment shader paints deep blue in the valleys and foamy white on the peaks. No textures, no models -- just math making water.

You write a vertex shader with sin() waves on a plane, but the surface is completely flat. The code compiles fine, no errors. You can see the plane, but there are no waves at all.

terminal
No error -- the plane renders but remains perfectly flat despite vertex displacement code.

Real-world

Imagine you are building a storm in a glass box. You start with a perfectly flat sheet of rubber stretched across a frame. Now you start pushing and pulling the rubber from underneath at different spots, at different speeds, with different strengths.

One hand creates the big, slow ocean swells. Another adds medium choppy waves on top. A third adds tiny surface ripples. The trick is that each set of waves has its own frequency (how close together the peaks are) and amplitude (how tall they are). Layer them together and you get a convincing ocean.

The fragment shader then acts like a painter looking at the surface from above: wherever the rubber is pushed down (trough), paint it dark blue. Wherever it is pushed up (peak), add white foam. That is the entire recipe.

How the Raging Sea Works

From a flat plane to a stormy ocean in four stages.

High-Res Plane

128x128 segments = 16k vertices

Vertex Shader

Layered sin/cos waves displace Y

Elevation Varying

Pass height to fragment shader

Fragment Shader

Mix depth/surface/foam by height

Building the Sea Step by Step

Let us construct the ocean from a flat plane to a living, breathing surface.

Step 1 -- Create a high-resolution plane

Sea.tsxTSX
<mesh rotation={[-Math.PI * 0.45, 0, 0]}>
  <planeGeometry args={[4, 4, 128, 128]} />
  <shaderMaterial
    vertexShader={vertexShader}
    fragmentShader={fragmentShader}
    uniforms={uniforms}
    side={THREE.DoubleSide}
  />
</mesh>

The third and fourth arguments to planeGeometry are the segment counts. More segments means more vertices for the shader to displace. 128x128 gives us over 16,000 vertices -- enough for smooth waves.

Step 2 -- Layer multiple sine waves in the vertex shader

vertex.glslGLSL
uniform float uTime;
uniform float uWaveHeight;
uniform float uWaveFrequency;
uniform float uWaveSpeed;
uniform float uBigWaveFrequency;

varying float vElevation;

void main() {
  vec3 pos = position;

  // Big rolling waves
  float big = sin(pos.x * uBigWaveFrequency + uTime * uWaveSpeed * 0.6)
            * sin(pos.z * uBigWaveFrequency * 0.8 + uTime * uWaveSpeed * 0.4)
            * uWaveHeight;

  // Small choppy waves
  float small = sin(pos.x * uWaveFrequency * 3.0 + uTime * uWaveSpeed * 2.0)
              * cos(pos.z * uWaveFrequency * 2.5 + uTime * uWaveSpeed * 1.8)
              * uWaveHeight * 0.15;

  pos.y += big + small;
  vElevation = big + small;

  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}

Each sin/cos call creates a wave with its own frequency and speed. Multiplying two sine waves together (one for X, one for Z) creates a 2D wave pattern instead of simple stripes. The elevation is passed as a varying so the fragment shader knows how high each point is.

Step 3 -- Color by elevation in the fragment shader

fragment.glslGLSL
uniform vec3 uDepthColor;
uniform vec3 uSurfaceColor;
uniform vec3 uFoamColor;
uniform float uWaveHeight;

varying float vElevation;

void main() {
  // Normalize elevation to 0..1
  float t = (vElevation + uWaveHeight) / (uWaveHeight * 2.0);
  t = clamp(t, 0.0, 1.0);

  // Deep blue in troughs, surface color at mid-height
  vec3 color = mix(uDepthColor, uSurfaceColor, t);

  // Foam on the peaks
  float foam = smoothstep(0.7, 1.0, t);
  color = mix(color, uFoamColor, foam * 0.8);

  gl_FragColor = vec4(color, 1.0);
}

The key insight: the elevation varying tells the fragment shader how high this pixel is on the wave. We normalize it to a 0-1 range, then use mix() to blend between deep blue (0) and surface teal (0.5-0.7). Peaks above 0.7 get foam color blended in with smoothstep for a soft transition.

Step 4 -- Animate with useFrame

Sea.tsxTSX
const uniforms = useMemo(() => ({
  uTime: { value: 0 },
  uWaveSpeed: { value: 1.0 },
  uWaveHeight: { value: 0.25 },
  uWaveFrequency: { value: 3.0 },
  uBigWaveFrequency: { value: 1.2 },
  uSurfaceColor: { value: new THREE.Color("#1a8faa") },
  uDepthColor: { value: new THREE.Color("#041830") },
  uFoamColor: { value: new THREE.Color("#c8e6f0") },
}), []);

useFrame(({ clock }) => {
  materialRef.current.uniforms
    .uTime.value = clock.elapsedTime;
});

The uTime uniform is the heartbeat of the animation. Every frame, useFrame updates it with the elapsed time. Inside the vertex shader, uTime offsets the sine wave inputs, making the waves move. All other uniforms can be updated from Leva controls the same way.

What you just learned

Vertex displacement pushes geometry vertices in the vertex shader -- the geometry itself is a flat plane.

Layering multiple sine waves with different frequencies creates organic, natural-looking wave patterns.

A 'varying' passes per-vertex data (like elevation) from the vertex shader to the fragment shader for coloring.

mix() with smoothstep() creates soft color transitions between deep water, surface, and foam.

High segment counts (128x128) are essential -- without enough vertices, there is nothing to displace.

Question

The ocean waves are created entirely from layered sine functions. What would you need to change to make the waves look less uniform and more chaotic, like a real storm? Think about what real ocean waves have that perfect sine waves do not.

Think about it...

You increase the wave frequency from 3.0 to 30.0, but instead of getting more detailed waves, the surface looks like jagged noise. What went wrong?

Hint: Think about the relationship between wave frequency and vertex density. How many vertices do you have per wave cycle?

Try These Challenges

Put what you learned into practice. Try each challenge in the demo above using the Leva controls, then check the solution.

Try This!

Beginner

Max waveHeight — tsunami!

Try This!

Beginner

Set waveSpeed to 0 — frozen ocean

Try This!

Beginner

Change foamColor to pink — candy sea

These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.

1
Flat plane with no segments
Vertex displacement needs vertices to displace
Don't do this
Sea.tsxTSX
<planeGeometry args={[4, 4]} />
// Default: 1x1 segments = 4 vertices
// Waves have nothing to push around
A plane with the default 1x1 segments has only 4 corner vertices. The vertex shader can only move those 4 points, so you get a flat tilting rectangle instead of waves. You need many segments (64x64 minimum, 128x128 for smooth results) so there are enough vertices for the sine waves to sculpt.
2
Uniform object recreated every render
Inline uniforms cause shader recompilation each frame
Don't do this
Sea.tsxTSX
<shaderMaterial
  uniforms={{
    uTime: { value: 0 },
    uWaveHeight: { value: 0.3 },
  }}
  vertexShader={vs}
  fragmentShader={fs}
/>
Defining uniforms inline in JSX creates a new object every render, triggering expensive shader recompilation. Use useMemo to create the uniforms once, then mutate only the .value property inside useFrame.
3
Forgetting DoubleSide on a tilted plane
The back face of the plane is invisible by default
Don't do this
Sea.tsxTSX
<mesh rotation={[-Math.PI / 2, 0, 0]}>
  <planeGeometry args={[4, 4, 64, 64]} />
  <shaderMaterial ... />
  {/* Plane disappears from below! */}
</mesh>
Planes only have one face by default. When you tilt the plane to create a horizontal surface, viewing from certain camera angles shows the back face, which is culled. Set side={THREE.DoubleSide} so the sea is visible from any angle.

Best Practices

Layer 3-4 wave octaves

Use big waves for the swell, medium waves for chop, and small waves for surface detail. Each layer should have roughly double the frequency and half the amplitude of the previous one.

Match segments to frequency

Ensure you have at least 8-10 vertices per wave cycle. If your highest frequency is 6.0 on a 4-unit plane, you need at least 4 * 6 * 10 = 240 segments. Under-sampling causes jagged aliasing.

Vary wave speeds

Give each wave layer a different speed multiplier. If all waves move at the same speed, they look like a single wave rather than layered water. Real oceans have waves from many sources moving independently.

Use smoothstep for foam

Hard thresholds (step function) create sharp unrealistic foam edges. smoothstep gives a soft gradient between water and foam that looks natural. Adjust the two edge parameters to control how wide the foam band is.