Create a Wave Motion Effect on an Image with Three.js

Learn how to make a wave motion effect on an image with Three.js.

Waves! Because who does not enjoy the visual comfort an oscillating motion has on the human eye? Well, I do and for this tutorial, I would like to explain how to make waves on a 3D plane with Three.js using simplex noise.

To keep things short, we’ll just focus on the plane effect and not on the smooth scrolling or setup required to synchronize the DOM with WebGL. For these, check out Jesper Landberg’s codepen on smooth scrolling, and Luigi De Rosa’s article on how EPIC mixed WebGL and the DOM for WeCargo.

Tiny break: 📬 Want to stay up to date with frontend and trends in web design? Subscribe and get our Collective newsletter twice a tweek.

We’ll asume you have some basic understanding of Three.js, vertex and fragment shaders, so we?ll skip things like how to set up a scene.

Now let’s begin.

Creating the mesh

First, we’ll create a mesh using a PlaneGeometry and a ShaderMaterial.

Since we want to add a texture later, we’ll give out PlaneGeometry the same proportions as our 400×600 image. So, 0.4 for the width and 0.6 for the height. Having the same proportions makes it so the texture doesn’t stretch.

this.geometry = new THREE.PlaneGeometry(0.4, 0.6, 16, 16);
this.material = new THREE.ShaderMaterial({
  vertexShader,
  fragmentShader,
  uniforms: {
    uTime: { value: 0.0 }
  },
  wireframe: true,
});
this.mesh = new THREE.Mesh(this.geometry, this.material);
this.scene.add(this.mesh);

As a performance side note, if you are rendering more than one element to a scene, make sure to reuse the geometry object.

Making some noise

We’re going to displace the plane using 3D simplex noise.

Our simplex noise function snoise, outputs values between -1 and 1 based on the position values you give it.

Simplex noise is amazing because it’s random and seamless! So it’s really useful to create organic patterns. Which is exactly what we want for our wave effect.

Now inside the vertex shader. Using the vertex positions of the plane, we’re going to sample simplex noise to get a wave distortion, adjusting its frequency and amplitude to control it. The frequency will only change the x vector component to make horizontal waves on the plane and adding a time uniform to make them move. For the distortion to take place forwards and backwards, we need to add the value to the z vector component.

varying vec2 vUv;
uniform float uTime;

void main() {
  vUv = uv;

  vec3 pos = position;
  float noiseFreq = 3.5;
  float noiseAmp = 0.15; 
  vec3 noisePos = vec3(pos.x * noiseFreq + uTime, pos.y, pos.z);
  pos.z += snoise(noisePos) * noiseAmp;

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

And that’s really it for the motion! Even though it’s moving, the effect doesn’t quite sell because the inside looks flat.

To give that extra flare, as it is displaced, we need to modify colors accordingly. But before we do that, let’s add some pretty image!

Adding the texture

First, let’s create a new uniform that holds the texture for the ShaderMaterial.

this.material = new THREE.ShaderMaterial({
  vertexShader,
  fragmentShader,
  uniforms: {
    uTime: { value: 0.0 },
    uTexture: { value: new THREE.TextureLoader().load(img) },
  },
});

Second, let’s sample the texture in the fragment shader.

varying vec2 vUv;
uniform sampler2D uTexture;

void main() {
  vec3 texture = texture2D(uTexture, vUv).rgb;
  gl_FragColor = vec4(texture, 1.);
}

It looks solid already. Cool! Let’s go a step further and make it even more interesting by displacing the texture coordinates.

Displacing the texture coordinates

We can use the noise value from the vertex shader, pass it to the fragment shader, and add it to the texture coordinates so the image follows the motion of the displacement.

To share the distortion data from the vertex shader to the fragment shader we’ll use a varying, and for now let’s disable the vertex movement so we can appreciate more the texture displacement.

varying vec2 vUv;
varying float vWave;
uniform float uTime;

void main() {
  vUv = uv;

  vec3 pos = position;
  float noiseFreq = 3.5;
  float noiseAmp = 0.15; 
  vec3 noisePos = vec3(pos.x * noiseFreq + uTime, pos.y, pos.z);
  pos.z += snoise(noisePos) * noiseAmp;
  vWave = pos.z; // Off it goes!

  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);
}

A new variable inside the fragment shader will be needed to scale down the distortion so it’s not too sharp.

varying vec2 vUv;
varying float vWave;
uniform sampler2D uTexture;

void main() {
  float wave = vWave * 0.2;
  vec3 texture = texture2D(uTexture, vUv + wave).rgb;
  gl_FragColor = vec4(texture, 1.);
}

See how the texture moves in the same way but in two dimensions? What if instead of moving all the texture coordinates, we only did it for one color channel to give a more futuristic vibe?

Splitting the color channel

To move one color channel, we’ll need to separate each color vector of the texture, add the wave value to one of their coordinates, and combine them back together. I’ll try it on the blue channel, but you can experiment with any of them.

varying vec2 vUv;
varying float vWave;
uniform sampler2D uTexture;

void main() {
  float wave = vWave * 0.2;
  // Split each texture color vector
  float r = texture2D(uTexture, vUv).r;
  float g = texture2D(uTexture, vUv).g;
  float b = texture2D(uTexture, vUv + wave).b;
  // Put them back together
  vec3 texture = vec3(r, g, b);
  gl_FragColor = vec4(texture, 1.);
}

Now the movement is happening just on blue channel of the texture. Spooky!

Finally, we can enable again the vertex distortion to get the final result. Aaaand here it is:

I hope you made it until here! This is just one of countless possibilities you can do with shaders and Three.js. For example, in the second demo, I took a different approach making transitions using displacement maps ? if you’re interested in it, perhaps we can leave that for another time.

Make sure to tweak the values to get different results, mess with the noise, or use another kind of noise (psst psst Worley), create something and have fun with it! Oh, and don’t forget to share it with me on Twitter. If you got any questions or suggestions, please let me know, too.

Hope you learned something new. Until next time!

References and Credits

Mario Carrillo

Creative Coder. Exploring technology and art. Freelance.

Stay in the loop: Get your dose of frontend twice a week

Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!

Feedback 2

Comments are closed.
  1. Try to replicate it on blogger, and the image is not seen. There is only a black background instead of an image.

    • Hi dude 🙂 i had the same issues and it was cause in the fragment shader u may have forgotten to change : gl_FragColor = vec4(texture, 1.);