Unity Pro offers some great looking water in the Water4 package. Edge fade, foam, refraction, reflection (sort of) and surface waves are just some of the features. For our environments in The Wild Eternal however, we needed flowing water and Water4 does not offer this out of the box. Though the water does move, it is a very simple directional movement. For our creeks and rivers, we need real movement to bring them to life. Not to mention we can use the flow direction to affect gameplay!
Unity’s Water4 shader provides a static flow direction.
Consequently, I ventured to add flow map support to the Water4 package instead, and have been met with favorable results. Here’s the result. Note: the modified source can be downloaded at the bottom of this post.
My modified Water4 shader gives the illusion of water flow. The source can be downloaded at the bottom of this post.
When I set out to make this addition, I did not know how flow maps worked, and a part of why I wanted to add them was also to understand them. It’s a neat thing, and I’m all about understanding neat things. If you are too, I hope my explanation can get you there. If you want a comprehensive quantitative understanding, I suggest reading this great article by Kyle Hayward at Graphics Runner: Animating Water Using Flow Maps.
How Flow Map Shaders Work
It turns out the effect is fairly simple at its core. Like most materials, we want water to be lit according to some bump map texture. The bump map influences both lighting and refraction distortion, and the flow effect is all about animating the bump map.
For every pixel, we decide what normal it should have by sampling the bump map at some uv-coordinate plus some uv-offset. The uv-offset is the key here, and it requires a flow direction, flow speed, and total flow time. In pseudocode:
uvOffset = flowDirection * flowSpeed * totalTime bump = ReadTexture(bumpMap, uv + uvOffset)
If we do just this, we will get flowing water, it’s really that simple. But there is a very obvious problem with this technique. Because our texture is being panned across the mesh in arbitrary directions for each pixel, the material will become heavily distorted as time goes on. The effect looks good right at the start, but it quickly becomes an ugly mess.
Using a continually increasing time variable eventually results in large distortions.
How do we solve this? Since it looks good at the start, we take advantage of that by keeping the uv-offset small. If we cycle the time variable rather than let it grow continually, the offset will never get too big and the distortion will not be noticeable. So when our time variable exceeds, say, 1, we set it back to 0 and let it cycle again. If we do just this, we remove the ugly mess as a possibility, but incur a jolting animation reset every time the time variable cycles over:
Resetting the time variable stops distortion from getting out of control, but we have to deal with an animation reset.
So our last problem is to devise a technique to hide the reset. It helps to look at a table to understand how we will accomplish this:
|Game Time||Cycling Time||Bump Quality|
The effect looks good for the first half of every second, and worse as time goes on until it resets. If we were to add 0.5 to our cycling time variable, the effect will look good for the second half of every second, and poor for the first half. This is what we capitalize on to hide the reset. With two cycling time variables that increase at the same rate, but which are offset in their cycles so that one looks good when the other looks bad, we can compute the bump for each and fade from one to the other accordingly. Lets look at another table:
|Game Time||Cycling Time 1||Bump 1 Quality||Cycling Time 2||Bump 2 Quality|
|0.25||0.25||Looks good||0.75||Some distortion|
|0.75||0.75||Some distortion||0.25||Looks good|
|1.25||0.25||Looks good||0.75||Some distortion|
|1.75||0.75||Some distortion||0.25||Looks good|
Since one bump looks good when the other looks bad, its simply a matter of linearly interpolating between the two based on their quality. We want a value which ping-pongs between 0 and 1, in-sync with the quality of the bump results.
selector = 2 * |CyclingTime1 – 0.5| bump = LinearlyInterpolate(bump1, bump2, selector)
Absolute value is a nice way to implement a ping-pong function. This final table shows how the quality selector evolves. Notice that I have decreased the time increment from 0.25 to 0.1 to show the granular changes in the selector variable:
|Game Time||Cycling Time 1||Bump 1 Quality||Cycling Time 2||Bump 2 Quality||Selector|
|0||0||Looks good||0.5||Looks good (100%)||1|
|0.1||0.1||Looks good||0.6||Looks good (80%)||0.8|
|0.2||0.2||Looks good||0.7||Looks good (60%)||0.6|
|0.3||0.3||Looks good (60%)||0.8||Some distortion||0.4|
|0.4||0.4||Looks good (80%)||0.9||Some distortion||0.2|
|0.5||0.5||Looks good (100%)||1||RESET!||0|
|0.6||0.6||Looks good (80%)||0.1||Looks good||0.2|
|0.7||0.7||Looks good (60%)||0.2||Looks good||0.4|
|0.8||0.8||Some distortion||0.3||Looks good (60%)||0.6|
|0.9||0.9||Some distortion||0.4||Looks good (80%)||0.8|
|1||1||RESET!||0.5||Looks good (100%)||1|
So there we have it! Our final pseudocode looks something like this:
uvOffset1 = flowDirection * flowSpeed * cyclingTime uvOffset2 = flowDirection * flowSpeed * (cyclingTime + 0.5) bump1 = ReadTexture(bumpMap, uv + uvOffset1) bump2 = ReadTexture(bumpMap, uv + uvOffset2) selector = 2 * |cyclingTime – 0.5| bump = LinearlyInterpolate(bump1, bump2, selector)
Linearly interpolating between two out-of-sync cycles produces a satisfying result.
How Flow Maps Work (and how to make them)
For some of us (like me), generating the textures are just as hard, if not harder, than getting the shader working. Flow map textures are a fairly straight-forward information map, but that does not mean they are easy to make. Still, lets understand them first, then see about making them.
For each pixel in the texture, we want to embed the flow direction and flow speed. This can be done just like a normal map — store a vector which has a direction and magnitude — except we only need two components, not three. Pretty easy, right? Unfortunately, just like making normal maps, its very hard to draw vectors like this. Fortunately, a developer named LoTekK has produced (see if you recognize the engine!) a Flow Map Painter program which can be used to generate flow maps by drawing in the direction of flow. Though it has some usability issues, its the best solution I have found so far for free. I would love to hear about alternatives if anyone finds or makes one!
An example flow map texture for The Wild Eternal. Beautiful, isn’t it?
I have not packaged together the perfect product for you. Sorry. I have instead packaged up the implementation that we use in The Wild Eternal. Below are some details about what I have changed in the Water4 package and what I have not:
- My modifications only extend to the high quality version of the shader. Medium and low quality implementations have not been changed.
- The vertex displacement effect that comes packaged with Water4 (called Gerstner Displacement) animates trigonometric waves according to some global directions. The flow of these waves is not in accordance with our flow map system, but still looks good alongside it.
- Tiling is now forced to be square, and foam tiling can be set explicitly, rather than being derived from the bump tiling.
- The foam can be animated just like the bump map. This is implemented as well. Bump and foam flow speeds are independent of one another.
- I have changed the foam implementation to have a smoother falloff and to use the alpha channel for alpha blending.
- Bump size has been made proportional to local flow speed according to the flow map, which produces a satisfying result for both lighting and refractive distortion.
- The flow map needs to be stretched across the water surface. In The Wild Eternal we use terrains heavily within our scenes, so it is natural for us to unwrap our flow map according to the terrain size. This is how it is implemented. You will need to provide your own terrain or world-space coordinates in a script.
I am more than happy to answer any questions in the comments section below.