Water Shader

Water-ripples in WebGL

August 21, 2023 - Tommy Dräger

WebGL opens up a world of possibilities for creating dynamic and interactive visual effects directly within the browser. One of the most fascinating effects you can create is a water ripple simulation. This article will guide you through the concepts and implementation details of this effect using GLSL shaders.

I originally made this motion graphics for the company I am currently working in. I had to rewrite the project from scratch in order to publish it, just to be safe.


The sourcecode for this project is public and openSource (If you don't mind I'd be happy if you'd leave a star on github): https://github.com/MilesTails01/water_ripple_webgl


I made an online demonstration so that you can check it out for yourself. Use the mouse to drag around some watter waves. Have fun:


Vertex Shader Check

Try to create this screen first: It all starts very simple by rendering the uv coordinates (red = positive X value 0...1, green = postive Y value 0...1). So start by creating a vertex shader, pass the uv coordinates and use the value of u and v in order to create the final output color.

Creating the Aurora Effect

vec3 calcSine(      vec2 uv, 
                ,   float speed
                ,   float frequency
                ,   float amplitude
                ,   float shift
                ,   float offset
                ,   vec3 color
                ,   float width
                ,   float exponent
                ,   bool dir)
    float angle         = time * speed * frequency * -1.0 + (shift + uv.x) * 2.0;
    float y             = sin(angle) * amplitude + offset;
    float clampY        = clamp(0.0, y, y);
    float diffY         = y - uv.y;
    float dsqr          = distance(y, uv.y);
    float scale         = 1.0;

    /**/ if( dir && diffY > 0.0) { dsqr = dsqr * 4.0; }
    else if(!dir && diffY < 0.0) { dsqr = dsqr * 4.0; }

    scale               = pow(smoothstep(width * widthFactor, 0.0, dsqr), exponent);
    return min(color * scale, color);

at the heart of this effect is the calcSine Function. You pass in some parameter like speed, frequency and exponent to create an animated sine wave.

color += calcSine(uv, 0.2, 0.20, 0.20, 0.0, 0.5, vec3(0.3, 0.3, 0.3), 0.1, 15.0,false);
color += calcSine(uv, 0.4, 0.40, 0.15, 0.0, 0.5, vec3(0.3, 0.3, 0.3), 0.1, 17.0,false);
color += calcSine(uv, 0.3, 0.60, 0.15, 0.0, 0.5, vec3(0.3, 0.3, 0.3), 0.05, 23.0,false);
color += calcSine(uv, 0.1, 0.26, 0.07, 0.0, 0.3, vec3(0.3, 0.3, 0.3), 0.1, 17.0,true);
color += calcSine(uv, 0.5, 0.36, 0.40, 0.0, 0.3, vec3(0.3, 0.3, 0.3), 0.2, 17.0,true);
color += calcSine(uv, 0.5, 0.46, 0.07, 0.0, 0.3, vec3(0.3, 0.3, 0.3), 0.05, 23.0,true);
color += calcSine(uv, 0.2, 0.58, 0.05, 0.0, 0.3, vec3(0.3, 0.3, 0.3), 0.2, 15.0,true);

By stacking multiple calcSine with differen parameter you get this nice looking aurora effect. Simple!

Creating the Ripple Effect

until here I thought it would be enough.. but I wanted to make it more interesting by adding fluid simulation that can distort the the whole image ~ so I added a water ripple effect, cause I love automata simulation!!

understanding the math can be quite complex. But it can be boiled down to the following assumption.

this effect is called diffusion. Imagine an empty screen with just a simple red pixel in the middle. The shader would go cycle trough all pixel to calculate the color for each pixel. And the formular to calculate its value is something like: pixel[x].color + pixel[x+up~down~left~right] ... / 4. Its actually nothing else but a kernel blur.

float rgt       = texelFetch(u_texture, uv + ivec2( SAMPLE_STEP,  0), 0).r;
float lft       = texelFetch(u_texture, uv + ivec2(-SAMPLE_STEP,  0), 0).r;
float upw       = texelFetch(u_texture, uv + ivec2( 0,  SAMPLE_STEP), 0).r;
float dwn       = texelFetch(u_texture, uv + ivec2( 0, -SAMPLE_STEP), 0).r;

what happens here? Well like I described above: we read the pixel value to the top, left, right and buttom. The information of the pixel is stored in a texture or in our case in a texture buffer.. cause our texture is the texture that got generated from the dunes effect.

float delta     = 1.3; // a constant value
float force     = texelFetch(u_texture, uv, 0).x;
float velo      = texelFetch(u_texture, uv, 0).y;

velo            += delta * (-2.0 * force + rgt  + lft) / 4.0;
velo            += delta * (-2.0 * force + upw  + dwn) / 4.0;

this is the hardest part of the whole project. You see in order to make use of the navier stokes equation we have to calculate a velocity! The current force is stored at the current pixel red channel texelFetch(u_texture, uv, 0).x;. While the current velocity is stored at the green channel of the pixel texelFetch(u_texture, uv, 0).y;.

After that we add the velocity of the neighboring cells in 2 steps for right and left, and then for up and down!

currentPixel.r << (-2.0 * currentPixel.r + rightPixel.r + leftPixel.r) / 4.0

you can see that set -2.0 to the current value (this will reduce the overall velocity). but by adding right and left Velocity we keep the balance. This effect will let the velocity and there the force fade out over time!

force           += delta * velo;
velo            -= 0.004 * delta * force;
velo            *= 1.0 - 0.004 * delta;
force           *= 0.98;
fragColor.xyzw  = vec4(force, velo, (rgt - lft) / 2.0, (upw - dwn) / 2.0);

the rest of the equation is following more or less the concept of Navier-Stokes. you add some constants and factors until the value is just right (try and error!)

Try and Error

You will run into problems! But it kinda looks mesmerizing! It's true art!

Look we getting better you can already start to see same ripple motion. But the values and equations are somewhat broken.

I stuck for a while at this state. The diffusion part was just working fine. But I was missing the green part now. No ripple effect anymore. Took days to figure it out.

The most satisfying moment, when out of the sudden everything revealed in front of me. The blue part represents the up-down velocity, the red part is the force itself, green is the left-right velocity.

Now, this result goes back into the texture buffer. Using the so-called ping-pong swapping of the texture buffer allowed me to use that result as the UV texture for my dune texture again. In all honesty, effects like these are truly challenging. But the reward when you finally overcome the challenge is far greater than any recognition.