profile picture of Olivier Larose

Olivier Larose

April 18, 2024

/

Advanced

/

Medium

3D Wave on Scroll

How to Make a 3D Wave Distortion using Three.js and React

A website animation tutorial featuring a vertex shader with a wave animation applied on a plane. Made with React-three-fiber, Framer Motion and Next.js

Live DemoSource code
background video

Initializing the project

Let's start the project by creating a Next.js application. We can do that by running npx create-next-app@latest client inside of a terminal.

We can delete everything in the page.js , global.css and page.module.css and add our own HTML and CSS, to start with a nice blank application.

  • We will use React Three Fiber for the 3D, so we can run npm i three @react-three/fiber.
  • We will use React Three Drei for utility functions, so we can run npm i @react-three/drei.
  • We will use Framer Motion for tracking the progress of the scroll, so we can run npm i framer-motion.

Setting up a Scene

For the scene, I'm creating an external component that I'm lazy loading using the Dynamic import, which is a Next.js built in function.

page.js

1

import styles from './page.module.css'

2

import dynamic from 'next/dynamic'

3

4

const Scene = dynamic(() => import('@/components/Scene'), {

5

ssr: false,

6

})

7

8

export default function Home() {

9

return (

10

<main className="h-screen">

11

<Scene />

12

</main>

13

)

14

}

Note: Here I use the ssr: false option to force the component to strictly be rendered client-side.

  • The upside is I could eventually render a placeholder while the 3D scene is loading.

Creating a Canvas

Here I'm creating a React Three Fiber Canvas and inside of it I'm adding an external Model Component where I'll render the 3D models.

Scene.jsx

1

import { Canvas } from '@react-three/fiber'

2

import React from 'react'

3

import Model from './Model'

4

5

export default function Scene() {

6

return (

7

<Canvas>

8

<Model/>

9

</Canvas>

10

)

11

}

Adding a Plane

We'll start by adding a 2D plane with some distortion with a custom shader and then we'll later add the image as a texture on top of it. For now I'm adding a regular plane with a certain scale.

Model.jsx

1

import React from 'react'

2

3

export default function Model() {

4

return (

5

<mesh scale={[3, 3, 1]}>

6

<planeGeometry args={[1, 1, 15, 15]}/>

7

<meshBasicMaterial wireframe={true} color="red"/>

8

</mesh>

9

)

10

}

We should have something like this:

Screenshot of the plane

Waves and Sine function

To add a wave distortion, the first thing we need to understand is the structure of a wave:

Structure of a wave

We'll play with two parameters of the wave:

  • The amplitude
  • The wavelength

To do that, we need to create a custom shader material and distort the vertices of the plane. We can actually use the Sine function, which is very similar to a wave pattern:

The Sine function

I found that the Sine function is used a lot in web animations, so don't shy away from it even tho it might be scary at first. Basically, there are 2 important things to know about the Sine function for this specific animation:

  • It always returns a value between -1 and 1
  • It returns 1 when we give it π/2
Screenshot of the SVG bézier curve

Creating our two parameters

Like I said earlier, we will play with the amplitude and the wavelength, so for that we can use the Leva library to store parameters and change them on the fly with a GUI.

Wave configs

1

import { useControls } from 'leva';

2

3

const { amplitude, waveLength } = useControls({

4

amplitude: { value: 0.25, min: 0, max: 2, step: 0.1 },

5

waveLength: { value: 5, min: 0, max: 20, step: 0.5 },

6

})

Adding a Wave Distortion

Now that we have our 2 parameters ready, we can create a custom shader where we'll use the Sine function to modify the amplitude and the wavelength.

Wave distortion function

1

...

2

import { fragment, vertex } from './Shader';

3

import { useFrame } from '@react-three/fiber';

4

5

export default function Model() {

6

7

const image = useRef();

8

9

const { amplitude, waveLength } = useControls({

10

amplitude: { value: 0.25, min: 0, max: 2, step: 0.1 },

11

waveLength: { value: 5, min: 0, max: 20, step: 0.5 },

12

})

13

14

const uniforms = useRef({

15

uTime: { value: 0 },

16

uAmplitude: { value: amplitude },

17

uWaveLength: { value: waveLength }

18

})

19

20

useFrame( () => {

21

image.current.material.uniforms.uTime.value += 0.04;

22

image.current.material.uniforms.uAmplitude.value = amplitude

23

image.current.material.uniforms.uWaveLength.value = waveLength;

24

})

25

26

return (

27

<mesh ref={image} scale={[3, 3, 1]}>

28

<planeGeometry args={[1, 1, 15, 15]}/>

29

<shaderMaterial

30

wireframe={true}

31

fragmentShader={fragment}

32

vertexShader={vertex}

33

uniforms={uniforms.current}

34

/>

35

</mesh>

36

)

37

}

Here I'm adding 3 unfiforms that will be passed to the shaders

  • uTime: we need to pass an ever increasing value to animate or 'drive' the wave
  • uAmplitude: the height of each waves
  • uWaveLength: the length of each waves

Adding the Fragment shader

For now the fragment shader will simply return a red color.

Fragment shader

1

export const fragment = `

2

void main() {

3

gl_FragColor = vec4(1., 0., 0., 1.);

4

}

5

`

Adding the Vertex shader

The vertex takes the 3 uniforms and the sine function to create the wave pattern:

Vertex shader

1

export const vertex = `

2

uniform float uTime;

3

uniform float uAmplitude;

4

uniform float uWaveLength;

5

void main() {

6

vec3 newPosition = position;

7

8

float wave = uAmplitude * sin(position.x * uWaveLength + uTime);

9

newPosition.z = position.z + wave;

10

11

gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);

12

}

13

`

Here I'm creating a float value for the wave. I use the x position of each vertices inside the sin function to create the wave pattern. I then use that wave value and add it to the z position of each vertices.

  • Line 8: I multiply the uWaveLength with the x position of each vertices to create the length of each wave.
  • Line 8: I add the uTime to the sin function to make each wave 'move'.
  • Line 8: I then multiply the result of the sin function by the uAmplitude, to accentuate the height of the wave.
  • Line 9: Finally I use the wave variable and add it to the z position of each vertices.
Screenshot of the SVG bézier curve

We should have something like this:

Adding the Image as Texture

To add the image to the plane, we simply need to create a texture from that image and pass it to the fragment shader:

Here I'm using two methods from react-three/drei:

  • useTexture: Extract the texture from an image
  • useAspect: Calculates an aspect ratio to make an image fill the screen (similar to image-size: cover). Here I reduce the scaling factor so the image does not take the full screen

Model.jsx

1

...

2

import { useTexture, useAspect } from '@react-three/drei'

3

4

export default function Model() {

5

6

const texture = useTexture("/images/car.jpg")

7

const { width, height } = texture.image;

8

9

const scale = useAspect(

10

width,

11

height,

12

0.3

13

)

14

15

const uniforms = useRef({

16

uTime: { value: 0 },

17

uAmplitude: { value: amplitude },

18

uWaveLength: { value: waveLength },

19

uTexture: { value: texture },

20

})

21

22

...

23

24

return (

25

<mesh ref={image} scale={scale}>

26

<planeGeometry args={[1, 1, 15, 15]}/>

27

<shaderMaterial

28

wireframe={false}

29

fragmentShader={fragment}

30

vertexShader={vertex}

31

uniforms={uniforms.current}

32

/>

33

</mesh>

34

)

35

}
  • The Uv's are extracted from the vertex shader and given to the fragment shader through a varying variable.
  • The texture is given to the fragment shader by putting it inside a uniform.

modified shaders

1

export const vertex = `

2

varying vec2 vUv;

3

...

4

void main() {

5

vUv = uv;

6

...

7

}`

8

9

export const fragment = `

10

uniform sampler2D uTexture;

11

varying vec2 vUv;

12

void main() {

13

vec4 color = texture2D(uTexture, vUv);

14

gl_FragColor = color;

15

}`

We should have something like this:

Scaling the Image on Scroll

Now that we've added a wave, we can add an extra effect by scaling the plane to fullscreen on scroll. That's going to require some understanding of scaling the UVs, which is a good exercise.

The firs thing to do is to make the section sticky and track the progress of the scroll:

Scene.jsx

1

import React, { useRef } from 'react';

2

import { useScroll } from "framer-motion";

3

...

4

5

export default function Home() {

6

const container = useRef();

7

const { scrollYProgress } = useScroll({

8

target: container,

9

offset: ['start start', 'end end']

10

});

11

12

return (

13

<main>

14

<div ref={container} className="h-[300vh]">

15

<div className="sticky top-0 h-screen">

16

<Scene scrollProgress={scrollYProgress}/>

17

</div>

18

</div>

19

<div className="h-screen"></div>

20

</main>

21

);

22

}
  • Here I'm tracking the progress of the scroll for the main container, which is 300vh of height.
  • The scrollYProgress is a value between 0 and 1.

We should have something like this:

Scaling on scroll

Now that we have a value that represents the progress of the scroll, we can scale the plane and slowly reduce the amplitude based on that.

Scaling the plane

1

import { transform } from "framer-motion"

2

import { useFrame, useThree } from '@react-three/fiber';

3

import { useTexture, useAspect } from '@react-three/drei'

4

...

5

6

const scale = useAspect(

7

width,

8

height,

9

0.3

10

)

11

12

const { viewport } = useThree();

13

14

useFrame( () => {

15

//scale image based on progress of the scroll

16

const scaleX = transform(scrollProgress.get(), [0, 1], [scale[0], viewport.width])

17

const scaleY = transform(scrollProgress.get(), [0, 1], [scale[1], viewport.height])

18

image.current.scale.x = scaleX;

19

image.current.scale.y = scaleY;

20

21

//animate wave based on progress of the scroll

22

const modifiedAmplitude = transform(scrollProgress.get(), [0, 1], [amplitude, 0])

23

24

image.current.material.uniforms.uTime.value += 0.04;

25

image.current.material.uniforms.uAmplitude.value = modifiedAmplitude

26

image.current.material.uniforms.uWaveLength.value = waveLength;

27

})

28

29

...
  • Here the scrollProgress is a value between 0 and 1 and I'm using it to scale the image to fullscreen and reduce the amplitude to 0 as we scroll.

We should have something like this:

As you can see, the texture is getting stretched as we scale the plane, so we need to scale them as well:

Scaling the textures:

Scaling the textures

1

const texture = useTexture("/images/car.jpg")

2

const { width, height } = texture.image;

3

4

const uniforms = useRef({

5

...

6

vUvScale: { value: new THREE.Vector2(0, 0) },

7

})

8

9

useFrame( () => {

10

//scale image based on progress of the scroll

11

const scaleX = transform(scrollProgress.get(), [0, 1], [scale[0], viewport.width])

12

const scaleY = transform(scrollProgress.get(), [0, 1], [scale[1], viewport.height])

13

image.current.scale.x = scaleX;

14

image.current.scale.y = scaleY;

15

16

...

17

18

//Adjust texture to new scale

19

const scaleRatio = scaleX / scaleY;

20

const aspectRatio = width / height

21

//scale texture inside shader

22

image.current.material.uniforms.vUvScale.value.set(1, aspectRatio / scaleRatio)

23

24

})

To scale the texture, I need to pass a new uniform vUvScale to the fragment shader. This uniform will hold the new scale of the texture, based on the aspect ratio of the image and the scale ratio of the plane

Adjusted Fragment shader

1

export const fragment = `

2

uniform sampler2D uTexture;

3

uniform vec2 vUvScale;

4

varying vec2 vUv;

5

void main() {

6

vec2 uv = (vUv - 0.5) * vUvScale + 0.5;

7

vec4 color = texture2D(uTexture, uv);

8

gl_FragColor = color;

9

}

10

`

We should have something like this:

Wrapping up

That's it for this tutorial!

Very interesting to see how we can use mathematical function like the sine wave to create an awesome effect! Also interesting to know about the different type of scaling that's required to effectively scale a plane. Hope you learned something!

-Oli

Related Animations

image

April 21, 2024

Ripple Shader

A website animation tutorial featuring a ripple shader effect using React Three Fiber, Next.js and React. Inspired by https://homunculus.jp/ and Yuri Artiukh.

image

April 21, 2024

Bulge Effect

A website tutorial featuring a bulge distortion animation, made with a shader in GLSL, using React Three Fiber, Next.js and React.

image

Feb 17, 2024

3D Glass Effect

A website tutorial on how to create a Glass looking Material with distortion by using the MeshTransmissionMaterial with Three.js, React, React Three Fiber and Next.js.