Simulating Water Ripples
This article shows how to generate semi-realistic ripples at specified locations, as well as how to simulate waves reflecting off of walls. This method uses a modified version of the Hugo Elias method and is visualized using Unreal Engine.
Introduction
Having no prior experience in water simulation, I was a little hesitant going into this project as there seemed to be many complex equations and complicated algorithms involved, but luckily I was guided to this article explaining how to produce semi-realisitic water ripples without needing to understand any complex ideas. However, while there are a lot of excellent resources that describe how to implement this algorithm, unfortunately none of them provided an in-depth explanation to what is going on and where everything comes from. This article serves to explain what I was able to figure out on my own, and shows how the algorithm can be modified based on that understanding.
The Algorithm
# "force", "current", and "previous" must all have the same dimensions and can either be arrays or images
# "dampening" and "flowFactor" are both floats between 0 and 1
def HeightSim( force, current, previous, dampening, flowFactor = 1 )
next = []
current = current + force
for each element/pixel
flow = (current(x+1,y) +
current(x-1,y) +
current(x,y+1) +
current(x,y-1)) / 4 # Gets average of surrounding pixels
curr = current(x,y)
prev = previous(x,y)
next[x,y] = (2 * (flow*flowFactor + curr) / (flowFactor + 1) - prev) * dampening
# Swap buffers
previous = current
current = next
return nextHeight
Algorithm Explanation
The basic idea is that we need to find our next position based on our current and previous positions. According to Hugo Elias’ page on this algorithm, the previous position can be thought of the velocity (where the velocity of the next position matches the negated value of the previous position - much like taking the derivative of a cosine wave - see image on the right), and so the idea is to add the velocity to our current position to get our next position, shown in the following equation:
However, the current position is strangely doubled in the process, which was explained almost as it if it’s optional: “The multiplication by two reduces the effect of the velocity.” Unfortunately, this is all that is said regarding this issue, and no other article I’ve come across explains it any further - and it seems to be necessary, as omitting it will cause the ripples to rapidly oscillate in an undesirable way. Furthermore, this idea didn’t make sense to me due to the fact that this velocity only describes the next position, and not the current one - why then is it being added to the current position? This was indeed a major obstacle to my understanding… until I rearranged the terms in a different way, and suddenly everything became clear:
The current position is merely the average of the next and previous positions, and so the equation was rearranged in order to get the next position in terms of the previous two positions. And thus, the foundation to the simulation is complete: after swapping the buffers by setting Current to Previous and Next to Current, we will have completed a single iteration - and with this process occurring hundreds of times a second, the illusion of water moving up and down as waves pass through will seem really natural.
However, we are not yet finished with the implementation: if the simulation were to be performed now, the ripples would be stationary at the location of the added force, forever increasing in height without ever slowing or coming back down (see gif below). To solve this issue, we will need to add two more components to this equation: a parameter that represents ripple movement (with higher positions flowing towards lower positions, and vice versa), and a parameter that represents constant height dampening (so that ripples will steadily decrease in power over time).
First, we will look at flow. Flow can be understood as the tendency for all heights to reach the same level. For example, if we apply a force of 100 to the middle of a 5x5 grid, we would expect that the force would “flow” or spread out to the rest of the grid until each pixel has stabilized to a height of 4 (which is 100 equally distributed over 25 pixels). To create such an effect, flow can be represented as the average height of the surrounding pixels: higher values will decrease while lower values will increase.
In Hugo Elias’ method, Flow simply replaces Current in the equation. However, this reduces accuracy as we are ignoring the relationship between Current and Flow: if Current is relatively lower than Flow, then we would expect the lower heights to have more influence than the higher ones (and vice versa). As such, we will instead replace Current with the average between the two, shown in the following equation:
which can be conveniently simplified to:
Next, we will add the dampening effect. While the addition of flow may give the impression that the waves are being dampened, this is only an illusion as the heights are merely being spread out to lower areas, with the end result being a still water level that is a little higher than what was started with. As such, we need a separate parameter to control water dampening - and given that all we need to do is reduce all heights by a little bit on each iteration, the solution becomes very simple:
Note that, for the sake of realism, Dampening must be between 0 and 1 (usually a value really close to 1). If it was greater than 1, the waves would gradually increase in height, and if it was less than 0, the waves would rapidly oscillate between positive and negative heights.
Now, if we perform the simulation, we will be much more satisfied with the results as the waves now look much more realistic (the following gifs show the simulation with an update rate of 60 iterations per second on a 256x256 grid):
Interestingly, this algorithm provides an incredibly simple means of simulating waves reflecting off of walls: simply set all heights in unwanted regions to always be zero, and any wave that touches it will find itself reflecting in another direction with it’s height negated (ie. if a wave with a positive height hits a wall, it will reflect with negative height, and vice versa). For example, study the following two simulations: on the right, we see a texture that has its coordinates wrapped, so that a wave on one end will simply teleport to the other side and keep moving; on the left, the coordinates are clamped so that there are no values beyond the texture, and thus the waves reflect off the edges.
With each of the previous simulations, the speed of the waves are the same. So how would we go about changing them? This algorithm also presents an easy solution: simply change the resolution of the array/texture and how often the simulation updates. Given that waves are limited to moving a pixel at a time, increasing the resolution would decrease a wave’s speed. Moreover, the more often the simulation updates, the faster the waves will move. However, while this is a simple solution, it has a glaring flaw: the lower the resolution and update rate, the more pixelated the movements will seem. In addition, this equation limits all heights to flow at the same rate. How can we give ourselves more control over the speed?
Luckily, another easy solution presents itself, albeit requiring a little bit of math to understand. Recall that, without Flow in the equation, the waves would stay in place without flowing anywhere. And given that we can’t flow faster than a pixel per iteration, we are now given a certain degree of control over the flow’s speed. The resolution and update rate would now define the maximum speed of a wave, and we can create a parameter that controls how much power Flow has in the equation:
Note that we are dividing by Factor+1 so that the range of the resulting value matches the range of the original average between Flow and Current. Thus, by changing Factor between 0 and 1 (using a value beyond 1 will only reduce the influence of Current), we can change the speed of a wave to be between 0 and the maximum speed, respectively.
Unreal Engine Implementation
The Unreal Engine documentation already has a tutorial for using render targets to simulate water ripples, which I used as a foundation for this project. After applying this modified Hugo Elias algorithm to the Material used for the height simulation, it now looks like this:
The rest of the algorithm is implemented in a blueprint by drawing this material to the current render target, and then updating the previous render target to prepare for the next iteration (note that, in this implementation, it cycles through 3 different buffers with each iteration instead of swapping 2 of them). Finally, after adding the blueprint nodes that handle collisions (taken straight from the tutorial), the simulation can now be practically used: