Water ripples / Water drops - Graphic effect (Typescript)

Download Typescript (Visual Studio 2017 project) source code (613kb)

This page shows the graphic effect of a water ripples caused by water drops.


Drawing mode
Water drop creation mode
Algorithm parameters



Above the implementation of a water ripple effect is shown. It is implemented in Typescript using HTML5 canvas.
Feel free to download the source code at the top of the page to check out how it works.

Usage notes:

Move the mouse cursor on above black canvas to create water drops under the cursor. Alternatively, switch the mode to random water drop creation for the effect of falling water drops onto the canvas.
After the background image is loaded, it is possible to switch to image mode so that the water drop effect is not only black and white, but creates a ripple effect on an image.
The damping factor defines how fast the water drop waves are soften.

Implementation notes:

The underlying concept with the base algorithm (for black and white) is quite easy and very elegant. It is taken from [1], thus it is not my invention.
Without repeating the whole article, here a short summary of the key points:

Formula:
CurrentBuffer[x][y] = (PreviousBuffer[x - 1][y] + PreviousBuffer[x + 1][y] + PreviousBuffer[x][y - 1] + PreviousBuffer[x][y + 1] ) / 2 - CurrentBuffer[x][y]

In short, the new current height value is an average value (smoothened value) of the previous four neighbour values (multiplied by factor 2) combined with the vertical velocity (see [1] for a deeper explanation).

The new current value is multiplied by a factor < 1 so that is slowly damped (and will vanish after some time).
At the end of one cycle, the CurrentBuffer and the PreviousBuffer are swapped, so both buffers exchange their roles in each cycle.

In the following, the complete algorithm for propagating the water ripples is shown:

public void UpdateHeightMaps()
{
    for (int x = 1; x < width - 1; x++) {
        for (int y = 1; y < height - 1; y++) {

            currentBuffer[x][y] =
                (previousBuffer[x - 1][y] +
                 previousBuffer[x + 1][y] +
                 previousBuffer[x][y - 1] +
                 previousBuffer[x][y + 1]) / 2
                - currentBuffer[x][y];

            currentBuffer[x][y] = currentBuffer[x][y] * dampingFactor;

            if (currentBuffer[x][y] < 1)
                currentBuffer[x][y] = 0;
        }
    }
}

In the following, the first few cycles of the height map evolution are shown, after one value in the center of the height map is set to a value unequal to zero:

00000
00000
00-1000
00000
00000

Cycle 0

00000
00000
001000
00000
00000

Cycle 1

00000
00500
05050
00500
00000

Cycle 2

00200
05050
20002
05050
00200

Cycle 3

Visualization:

Black and white mode:

Drawing the water ripples in black and white only is easy as only gray-scale pixels are drawn. By default, each pixel is black. If a height value is unequal to 0, it is clampled into range [0, 255] and this value is used for each component R, G and B to get a gray-scale color respresenting the height of a water ripple.

Image mode:

Overlaying the water ripples on an image is more interesting. It raises the question how to modify the existing image pixels by using the current height map?
The height map is used to calculate the gradient for each pixel, for x-direction and y-direction. Here, the gradient is defined as the difference between the left and right neighbor (x-gradient) and between the top and bottom neighbot (y-gradient). If a gradient is unequal to zero, meaning there is water ripple causing the difference in the height, this gradient value is used as offset value: After checking for value for valid bounds, it is used to as offset to the actual current position access the RGBA value of the image.
Here the simple procedure in pseudo code that produces the nice ripple effect on an image:

for (int x = 1; x < Width - 1; x++)
{
  for (int y = 1; y < Height - 1; y++)
  {
    int xGradient = CurrentBufferValue(x - 1, y) - CurrentBufferValue(x + 1, y);
    int yGradient = CurrentBufferValue(x, y - 1) - CurrentBufferValue(x, y + 1);
    if ( (xGradient != 0) || (yGradient != 0))
    {
      // clip
      if (x + xGradient >= Width - 1) xGradient = Width - x - 1;
      if (y + yGradient >= Height - 1) yGradient = Height - y - 1;
      if (x + xGradient < 0) xGradient = -x;
      if (y + yGradient < 0) yGradient = -y;

      // copy RBG with offset
      new_pixel(x, y) = original_image_pixel(x + xGradient, y + yGradient)
    }
    else
    {
      // copy the image color value directly to the screen
      new_pixel(x, y) = original_image_pixel(x, y):
    }
  }
}

That's it for now, hope you learned something new. Go on and check out the source code for more details.



References

[1] 2D Water

History

2023/12/03: Initial release.