Realistic and Fast Water Waves in Three.js

Franky Hung
12 min readMar 30, 2024

--

Ready for some wavy magic?

Two years ago I wrote How to Make Waves in Three.js. That simulation used an overlap of 2 travelling value noise waves, simple and decent. Now, we’re moving on to the next level in harnessing a more realistic water waves simulation: it’s actually been there all the time as one of the official Three.js examples; the “GPGPU Water” example.

https://threejs.org/examples/?q=water#webgl_gpgpu_water

It took me quite some time to understand the original code, so I will guide you through its code with my own simplified demo(took out the yellow balls and the smoothWater function), and teach you to harness the power of GPUComputationRenderer, which is used in calculating the movements of the water ripples in the example.

My github code for this tutorial: https://github.com/franky-adl/water-ripples

Live demos: https://projects.arkon.digital/threejs/water-ripples/

What is GPGPU and why we need GPUComputationRenderer

GPGPU stands for General-Purpose Graphic Processing Unit, which means the use of GPUs in computing tasks other than graphics, or tasks usually done by CPUs. This name in the context of Three.js, becomes a metaphor, which means a technique where we use the fragment shader to calculate other stuff such as particles’ positions or velocities, rather than to do its “day job” in calculating colors. By using GPU(GLSL shaders) instead of CPU(Javascript) to do these calculations, we gain a huge performance boost from the parallel computing.

However, we don’t often see this term mentioned in the Three.js community, more often the term “Frame Buffer Object” or just FBO comes to the eye. We call it the FBO technique literally because it uses an extra render target to save the extra calculations you want the GPU to do, and “Render Target” is essentially a “Frame Buffer”, as a frame buffer is what’s being used in the underling WebGL code, see docs.

Now you know how it got its name, let’s see what GPUComputationRenderer actually does(link to its source code). It is a helper class that lets you create data textures that store your computations via self-defined fragment shaders into 4 floating point numbers(xyzw) in each texel. In other words, 32-bit per channel, 16 byte per texel.

A pretty important point to note here is that with the floating point numbers, the data texture is capable of storing much larger numbers with greater precisions, compared to the normal image textures which only store up to 8-bit per color channel.

The powerful thing is, a data texture(termed as a “variable”) defined in GPUComputationRenderercan depend on its previous frame’s result to calculate the next; you can even set up several data textures that have multiple dependencies on each other.

Taking the “GPGPU Water” as an example, only one variable is defined and it’s named heightmap as its sole function is to calculate the heightmap for the water waves in every frame. In the first frame, the heightmap has an initial state(random values of our preference), but as the fragment shader has done its calculation, the output data texture is assigned back to the heightmap variable itself for use in the next frame, and the next, and so on.

Diving into Code

Getting bored? Let’s dive right in!

For an overview of our scene, we have:

  1. directional lighting, to light up the scene
  2. the plane mesh of water, the mesh that we apply our water waves heightmap onto
  3. the raycaster to pass mouse data to the compute fragment shader in the FBO
  4. the GPUComputationRenderer that creates the FBO

I’ll be explaining with reference to parts of my github code from now on. I recommend referring to the index.js file in my source code as you read this article because I won’t provide detailed instructions on where each code snippet should be placed exactly.

Let’s talk about setting up the water plane mesh first.

Water Mesh

We create the plane geometry and the material:

const plane = new THREE.PlaneGeometry( GEOM_WIDTH, GEOM_HEIGHT, FBO_WIDTH - 1, FBO_HEIGHT - 1 );
this.waterMat = new THREE.MeshPhongMaterial({
color: new THREE.Color( 0x0040C0 )
})

The four constants are defined previously:

const FBO_WIDTH = 128
const FBO_HEIGHT = 128
const GEOM_WIDTH = 512
const GEOM_HEIGHT = 512

Notice here we assign width/height segments of our plane geometry with the respective FBO dimension number but minus 1. This is because we want our geometry to have exactly the same number of vertexes as the number of texels in our FBO, you will understand better when it comes to setting up the FBO later.

Next we extend the Phong material via onBeforeCompile:

this.waterMat.userData.heightmap = { value: null }

this.waterMat.onBeforeCompile = (shader) => {
shader.uniforms.heightmap = this.waterMat.userData.heightmap
shader.vertexShader = shader.vertexShader.replace('#include <common>', `
uniform sampler2D heightmap;
#include <common>
`)
shader.vertexShader = shader.vertexShader.replace('#include <beginnormal_vertex>', `
// Compute normal from heightmap
vec2 cellSize = vec2( 1.0 / (${FBO_WIDTH.toFixed( 1 )}), 1.0 / ${FBO_HEIGHT.toFixed( 1 )} );
vec3 objectNormal = vec3(
( texture2D( heightmap, uv + vec2( - cellSize.x, 0 ) ).x - texture2D( heightmap, uv + vec2( cellSize.x, 0 ) ).x ) * ${FBO_WIDTH.toFixed( 1 )} / ${GEOM_WIDTH.toFixed( 1 )},
( texture2D( heightmap, uv + vec2( 0, - cellSize.y ) ).x - texture2D( heightmap, uv + vec2( 0, cellSize.y ) ).x ) * ${FBO_HEIGHT.toFixed( 1 )} / ${GEOM_HEIGHT.toFixed( 1 )},
1.0 );
`)
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', `
float heightValue = texture2D( heightmap, uv ).x;
vec3 transformed = vec3( position.x, position.y, heightValue );
`)
}

We set up a linkage between this.waterMat.userData.heightmap and shader.uniforms.heightmap, such that whenever new heightmap values are calculated from the FBO, we can save that into the material’s userData, and in turn update the uniform heightmap data within the shaders.

As you can see, we’re replacing the z position on the mesh with our heightmap’s values. It is the Z axis instead of Y because we rotate the mesh 90 degrees so it lies horizontally facing upwards. We take the x value from the heightmap texture as that’s the slot we store the current height of each point. And because we’ve altered the height positions, we also need to recalculate the normals.

Lastly, we build the mesh and add it to the scene:

this.waterMesh = new THREE.Mesh( plane, this.waterMat )
this.waterMesh.rotation.x = - Math.PI / 2
// as the mesh is static, we can turn auto update off: https://threejs.org/docs/#manual/en/introduction/Matrix-transformations
this.waterMesh.matrixAutoUpdate = false
this.waterMesh.updateMatrix()
scene.add( this.waterMesh )

Raycasting

If we want to make the ripples effect to be triggered by our mouse movement, we then need raycasting to cast our mouse’s screen coordinates into the intersected world coordinates in our scene.

First, let’s initialize some variables and the Raycaster:

this.mouseMoved = false
this.pointer = new THREE.Vector2()
this.raycaster = new THREE.Raycaster()

Then we define the function to be later bound to the pointermove event:

onPointerMove( event ) {
if ( event.isPrimary === false ) return
// converting mouse coordinates into -1 to +1 space
this.pointer.x = ( event.clientX / window.innerWidth ) * 2 - 1
this.pointer.y = - ( event.clientY / window.innerHeight ) * 2 + 1
this.mouseMoved = true
}

Bind it up(this.container is the html div node that hosts our Three.js scene):

this.container.addEventListener( 'pointermove', this.onPointerMove.bind(this) )

In our update function called per frame, we add the following code:

const hmUniforms = this.heightmapVariable.material.uniforms
if ( this.mouseMoved ) {
this.raycaster.setFromCamera( this.pointer, camera )
const intersects = this.raycaster.intersectObject( this.waterMesh )

if ( intersects.length > 0 ) {
const point = intersects[ 0 ].point
// point is in world coordinates
hmUniforms[ 'mousePos' ].value.set( point.x, point.z )
} else {
hmUniforms[ 'mousePos' ].value.set( 10000, 10000 )
}

this.mouseMoved = false
} else {
hmUniforms[ 'mousePos' ].value.set( 10000, 10000 )
}

hmUniforms is the uniforms in the compute fragment shader used by the FBO, which will be set up in the next section. When the mouse is moved, we calculate the intersection point of the mouse and the water mesh, and save that point’s world coordinates into hmUniforms[‘mousePos’]. As the compute fragment shader is responsible for calculating the wave heights, it’s natural that we pass the mouse-water intersection point to it. When there’s no mouse movement, we simply set it to somewhere off canvas.

Setting up the FBO

Finally, the centerpiece of this demo. Let’s leave the wave calculation bit in the compute fragment shader to the last and set the stage up first.

Do these imports:

import { GPUComputationRenderer } from "three/examples/jsm/misc/GPUComputationRenderer"
import { SimplexNoise } from "three/examples/jsm/math/SimplexNoise"
import HeightmapFragment from "./shaders/heightmapFragment.glsl"

We’ll need the SimplexNoise for generating initial height positions of the water mesh. Let’s create an empty heightmapFragment.glsl for now.

Next we define some user-changeable params:

const params = {
mouseSize: 20.0,
viscosity: 0.98,
waveHeight: 0.3,
}

For initializing the GPUComputationRenderer, we need to pass in the dimensions for the data texture that it uses to store the computed heightmap, and the existing renderer we use for our scene.

this.gpuCompute = new GPUComputationRenderer( FBO_WIDTH, FBO_HEIGHT, renderer )
if ( renderer.capabilities.isWebGL2 === false ) {
this.gpuCompute.setDataType( THREE.HalfFloatType )
}

That means our data texture will have a resolution of 128 x 128, such that each texel stores the height value of the corresponding vertex point on our water mesh. That is why previously we set the width/height segment numbers to be 127, because the total vertex number will then be 128 x 128(try visualize that in your head and you’ll get it).

Next we’ll need to create the real FBO, which is the this.heightmapVariable:

const heightmap0 = this.gpuCompute.createTexture()
this.fillTexture( heightmap0 )
this.heightmapVariable = this.gpuCompute.addVariable( 'heightmap', HeightmapFragment, heightmap0 )
this.gpuCompute.setVariableDependencies( this.heightmapVariable, [ this.heightmapVariable ] )

this.heightmapVariable mainly stores 3 things:

  • The material of the virtual/offscreen mesh that’s being rendered using the compute fragment shader
  • The render targets for this offscreen rendering
  • The dependencies to other FBOs

Here we set the FBO to depend on itself, such that we have the last frame’s heightmap positions as input to calculate the next frame’s heightmap positions.

We also use this.fillTexture to generate initial height data using Simplex Noise:

fillTexture( texture ) {
const waterMaxHeight = 2;
const simplex = new SimplexNoise()

function layeredNoise( x, y ) {
let multR = waterMaxHeight;
let mult = 0.025;
let r = 0;
for ( let i = 0; i < 10; i ++ ) {
r += multR * simplex.noise( x * mult, y * mult );
multR *= 0.5;
mult *= 2;
}

return r;
}

const pixels = texture.image.data;

let p = 0;
for ( let j = 0; j < FBO_HEIGHT; j ++ ) {
for ( let i = 0; i < FBO_WIDTH; i ++ ) {
const x = i * 128 / FBO_WIDTH;
const y = j * 128 / FBO_HEIGHT;

pixels[ p + 0 ] = layeredNoise( x, y );
pixels[ p + 1 ] = 0;
pixels[ p + 2 ] = 0;
pixels[ p + 3 ] = 1;

p += 4;
}
}
}

We use a 10-octave FBM here but it doesn’t really matter what noise function you use. Read more on FBM noise patterns here https://thebookofshaders.com/13/.

Then we set up the uniforms and definitions to be used in the compute fragment shader:

this.heightmapVariable.material.uniforms[ 'mousePos' ] = { value: new THREE.Vector2( 10000, 10000 ) }
this.heightmapVariable.material.uniforms[ 'mouseSize' ] = { value: params.mouseSize }
this.heightmapVariable.material.uniforms[ 'viscosityConstant' ] = { value: params.viscosity }
this.heightmapVariable.material.uniforms[ 'waveheightMultiplier' ] = { value: params.waveHeight }
this.heightmapVariable.material.defines.GEOM_WIDTH = GEOM_WIDTH.toFixed( 1 )
this.heightmapVariable.material.defines.GEOM_HEIGHT = GEOM_HEIGHT.toFixed( 1 )

We must call init to finish the initialisation!

const error = this.gpuCompute.init()
if ( error !== null ) {
console.error( error )
}

Let’s not miss adding the gui controls for the tweakable params:

const gui = new dat.GUI()
gui.add(params, "mouseSize", 1.0, 100.0, 1.0 ).onChange((newVal) => {
this.heightmapVariable.material.uniforms[ 'mouseSize' ].value = newVal
})
gui.add(params, "viscosity", 0.9, 0.999, 0.001 ).onChange((newVal) => {
this.heightmapVariable.material.uniforms[ 'viscosityConstant' ].value = newVal
})
gui.add(params, "waveHeight", 0.1, 2.0, 0.05 ).onChange((newVal) => {
this.heightmapVariable.material.uniforms[ 'waveheightMultiplier' ].value = newVal
})

Finally, in our update function called per frame, we gotta manually tell the this.gpuCompute to calculate the new heightmap data. Then we can immediately pass the calculated result to the water mesh’s shaders(remember how the userData acts as a bridge to pass new heightmaps into the water mesh’s shader uniforms):

this.gpuCompute.compute()
this.waterMat.userData.heightmap.value = this.gpuCompute.getCurrentRenderTarget( this.heightmapVariable ).texture

Quite some setting up to do! But the code isn’t finished, here comes the fun part.

How to simulate the waves

Now we move on to the final part of this tutorial.

Let’s see what’s in the heightmapFragment.glsl:

#define PI 3.1415926538

uniform vec2 mousePos;
uniform float mouseSize;
uniform float viscosityConstant;
uniform float waveheightMultiplier;

void main() {
// The size of the computation (sizeX * sizeY) is defined as 'resolution' automatically in the shader.
// sizeX and sizeY are passed as params when you make a new GPUComputationRenderer instance.
vec2 cellSize = 1.0 / resolution.xy;

// gl_FragCoord is in pixels (coordinates range from 0.0 to the width/height of the window,
// note that the window isn't the visible one on your browser here, since the gpgpu renders to its virtual screen
// thus the uv still is 0..1
vec2 uv = gl_FragCoord.xy * cellSize;

// heightmapValue.x == height from previous frame
// heightmapValue.y == height from penultimate frame
// heightmapValue.z, heightmapValue.w not used
vec4 heightmapValue = texture2D( heightmap, uv );

// Get neighbours
vec4 north = texture2D( heightmap, uv + vec2( 0.0, cellSize.y ) );
vec4 south = texture2D( heightmap, uv + vec2( 0.0, - cellSize.y ) );
vec4 east = texture2D( heightmap, uv + vec2( cellSize.x, 0.0 ) );
vec4 west = texture2D( heightmap, uv + vec2( - cellSize.x, 0.0 ) );

// https://web.archive.org/web/20080618181901/http://freespace.virgin.net/hugo.elias/graphics/x_water.htm
// change in height is proportional to the height of the wave 2 frames older
// so new height is equaled to the smoothed height plus the change in height
float newHeight = ( ( north.x + south.x + east.x + west.x ) * 0.5 - heightmapValue.y ) * viscosityConstant;

// Mouse influence
float mousePhase = clamp( length( ( uv - vec2( 0.5 ) ) * vec2(GEOM_WIDTH, GEOM_HEIGHT) - vec2( mousePos.x, - mousePos.y ) ) * PI / mouseSize, 0.0, PI );
newHeight += ( cos( mousePhase ) + 1.0 ) * waveheightMultiplier;

heightmapValue.y = heightmapValue.x;
heightmapValue.x = newHeight;

gl_FragColor = heightmapValue;

}

Several reminders before you try to understand the code here:

  • the resolution variable is auto defined as the resolution of the data texture, exactly from the width and height parameters we passed in when constructing the GPUComputationRenderer.
  • the heightmap variable is also auto defined here after we’ve defined the self dependency of the this.heightmapVariable. It’s supposed to have the same name as this.heightmapVariable.name.

Now, to understand the algorithm, you must first read the OG explanation at https://web.archive.org/web/20080618181901/http://freespace.virgin.net/hugo.elias/graphics/x_water.htm.

the height of the wave two frames older (wave 2), is proportional to the size of the arrows

To be honest, I don’t have much to add. In short, the algorithm uses 2 clever approximations to simulate the water waves:

  • To simulate the vertical movement of the points, it makes use of the observation that the rate of change in the wave height of any given point is proportional to its wave height 2 frames ago
  • To simulate the horizontal movement of the waves, it calculates the base height of each point by summing up the heights of its surrounding points and take an average

I don’t understand the algorithm fully. Say for example, why do we need to multiply the smoothed height by 2 to reduce the effect of the velocity? At least, I tried to tweak that number and see what happens. If it’s larger than 2, the waves will actually go out of control, getting bigger and taller until you don’t see the water mesh anymore lol. If it’s smaller than 2, the waves will quickly dwindle and flash in an unnatural way. So I would say the “2” here is quite the magic number. I’m not particularly good at Maths so I could only admire the creator of this algorithm 🙌🏻.

That about wraps up this tutorial. I actually made a couple of demos using this water wave algorithm and had quite a blast! Most probably you will find my code on the other demos to be outdated and uglier compared to the primary index.js file because I’m lazy doing code refactors🥲. Anyway, may the waves be with you!

2nd demo: Mirror-like water surface
3rd demo: Bioluminescence
4th demo: Water pool and light refractions
5th demo: Colorful waving cube grid

--

--

Franky Hung
Franky Hung

Written by Franky Hung

Founder of Arkon Digital. I’m not a code fanatic, but I’m always amazed by what code can do. The endless possibilities in coding is what fascinates me everyday.