Water ripples / Water drops - Graphic effect (Typescript)

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

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.

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.

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:

- Each pixel has a height value, representing the height of the water ripple at this position. In practice, such a buffer is implemented as a two-dimensional array with the same size as the image / canvas.
- However, one buffer is not sufficient to represent the movement of the water ripples. Two such buffers are needed, one for the previous frame and one for the current frames.
- At the beginning, both buffers are zero-initialized (no ripples exist).
- To create a water drop, at least one value is set to a value unequal to zero.
- This value will spread out in a circle, creating the illusion of a water ripple, according to following formula.

Formula:

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] = (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;

}

}

}

{

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:

0 | 0 | 0 | 0 | 0 |

0 | 0 | 0 | 0 | 0 |

0 | 0 | -10 | 0 | 0 |

0 | 0 | 0 | 0 | 0 |

0 | 0 | 0 | 0 | 0 |

Cycle 0

0 | 0 | 0 | 0 | 0 |

0 | 0 | 0 | 0 | 0 |

0 | 0 | 10 | 0 | 0 |

0 | 0 | 0 | 0 | 0 |

0 | 0 | 0 | 0 | 0 |

Cycle 1

0 | 0 | 0 | 0 | 0 |

0 | 0 | 5 | 0 | 0 |

0 | 5 | 0 | 5 | 0 |

0 | 0 | 5 | 0 | 0 |

0 | 0 | 0 | 0 | 0 |

Cycle 2

0 | 0 | 2 | 0 | 0 |

0 | 5 | 0 | 5 | 0 |

2 | 0 | 0 | 0 | 2 |

0 | 5 | 0 | 5 | 0 |

0 | 0 | 2 | 0 | 0 |

Cycle 3

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.

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):

}

}

}

{

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.

2023/12/03: Initial release.