Navigate
Search topics across all sections
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.
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
<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
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
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
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.
<planeGeometry args={[4, 4]} />
// Default: 1x1 segments = 4 vertices
// Waves have nothing to push around<shaderMaterial
uniforms={{
uTime: { value: 0 },
uWaveHeight: { value: 0.3 },
}}
vertexShader={vs}
fragmentShader={fs}
/><mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[4, 4, 64, 64]} />
<shaderMaterial ... />
{/* Plane disappears from below! */}
</mesh>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.