After a week of tweking the water shader, i can come back to this tutorial series to complete our second part…
Disclamer: ~~This tutorial contains derivatives… best enjoyed with a cup of coffee~~
Previously on Water Shaders:
What we achieved on the last tutorial, was the creation of reactive water that changes the tesselation/world displacement of each triangle on the mesh… giving us that nice wave that we wanted on our game… we also ended creating a SincWave that works wonders for single impacts on some point of the water (although they are not well suited for fully reactive water… but bear with me here). Basically we made this:
Water Displacement (reactive)
But if we just put a color on here, this is how our water really looks
It’s time to go deeper (huhx3) on our water shader… let’s talk about style and how to accomplish it:
Conforming to Style, what do we need?
Our first iteration of our water style (which changed btw) was something like this
Early water concept.
Although the concept did change on one of the many iterations of the shader, we will first try to recover most of the information i based myself… and then talk about the next iterations. What do we see in this picture that we don’t have?
- Watercolor translucent blue color.
- Lineart on borders
- Lineart ground hits (yeah, linearts are big on our game)
- Lineart on waves
Okay, it seems that Lineart its pretty important, so we need to tackle it first, let’s start by the easiest one, the top border of the water (pointing to the camera)
As this is really straight forward, we ended up adding this to our material function:
Top Lineart Graph Nodes
Basically what we’re doing here is taking the value of the texture coordinate (remember UV goes from top left) and multiplying the green chanel (Y of the UV) by a pretty effing big number… this creates a saturation on every pixel, except those REALLY close to the Y axis origin (the top border)… the VertexNormal filtering is just there to fix an issue of a line appearing on the bottom, so we just filter out the -Z Channel. After all this, we have a REALLY saturated image, with only a really small portion wich ranges from 0-1.. if we calmp this to 0-1 and Lerp the result, giving the near zero values the lineart colors.. we obtain our lineart
Resulting top lineart.
The top part of the graph, uses the Depth Fade node as a patch for giving us the ability to draw the collisions… normally we would use the distance fields to draw everything close to a occlusion volume, but we’re working with unlit sprites here (and even if they are lit, they have almost no depth, so it wouldn’t matter).
With this out of the way, let’s go to our next point: lineart on ground hits
Depth Fade for Collision Waves:
As stated previously, the “correct” way to create or draw something that is in close proximity to a occluding block is to use Distance Fields (a buffer created specially for lighting purposes, it was the ability to get the distance to the closest point.. so, please use that if you can), but since we do not understand the concept of a 3rd dimension here in Flatland we will use the next best thing… Depth Fade.
From UE4 Reference Depth Expressions this material expresion can be used only on translucent objects.. and they give an output of [0-1] when a intersection happens (this happens only from the rendering camera perspective), we can abuse this function to draw a panning image that simulate our waves that will have a near “1” value when close to an object (again, from the camera perspective) or “0” if no hit ocurred, with this the wave hit simulation can be done really nicely.
Collision Lineart Graph Nodes
With this taken into account, our material looks like this currently
First try at linearts for top and collisions.
As you can see, this method has their limitations, namely on the fact that the waves cannot go out of bounds from the intersection of the Opaque and Translucent Material…
Well, that wasn’t so difficult right? Ok, let’s take our final lineart requirement, lineart on waves… parepare your calculus 101 guys… we’re going full math.
Normals, Rate of Change, Don’t drink and derive
The last requirement for lineart it’s really not difficult to acomplish, it just needs a little bit of math and the concept of Normals, Binormals and Tangents.
For any of you that have taken courses on Calculus, you are already familiar with the concept of the “Rate of Change”, that is, the speed on which something changes (and in which direction). Since i like to catter to a wider audicence, i will give a TL;DR for all of you non Cubic friends.
If we take as a example our good ol’ sine wave… there is “change” happening on her (yeah, sine is woman, cosine is male)… if we were “advancing” on the X axis (horinzontal), the value of the sine wave would change depending of the speed that has in that moment… this speed could be represented like a tangent line like this:
So, when we are on top of the sine wave, on the very top, there is no change ocurring right now (or very little)… but when we are on going down the ramp, the rate of change starts to increase, and then cycling on and off again and again. The function (that is, the machine) that describes this input/output it’s called the Derivative of the Sine (derivative being the rate of change of a function).
So, why do we need this derivatives? the issue is this… we need to get the tips from the waves, if we see our graph here, we can understand that the derivatives are 0 near the tips and valleys. Since the function of our water shader (Traveling Sinc Wave and Gerstner Wave) are a function of F(x,y) i.e. x and y, we can obtain 2 derivatives: The speed of change by moving alongside X and from Y… this will give us our Tangent and Binormal vectors… both can give us (by doing a cross product) the “Normal” vector, that is the direction pointing OUT of our function.
So by using and obtaining the Normal vector (that points UP when on the top, and DOWN when on the bottom of the wave) and with that, we can easily distinguish between our tips and draw a lineart on the top of the waves.
Yellos vectors are the Normal, Light blue and Purple are the others.
As we won’t do a full course of Math here, just understand that normals come from the derivative (rate of change) of the functions from X and Y, crossed.
As an example.. i’ll show both derivatives from the traveling sine wave (not sinc) so you can understand how i got them, also as i already approved calculus on college, I feel like I deserve to use WolframAlpha for the hard work.
Let’s take a look at our traveling sine wave again, it had this values
- (x,y) the point of interest
- (a,b) the coordinates of the epicenter
- p the time constant
- w the distance constant
We have the following function: , with the derivative of X and Y being (btw, those are the partial derivatives).
Understanding that (x-a, y-b) is the vector from the center to our point, we can re write this functions as follow (excuse my lack of image):
df/dx = w * Du.x * cos(p*t – w*|D|)
df/dy = w * Du.y * cos(p*t – w*|D|)
- Du is the direction unit vector (normalized)
- |D| is the length of the direction vector (from center to our point)
This is way easier to manage and we can even translate it easily to a graph:
Partial derivatives on Graphs.
Having this, you only need to construct your Binormal and Tangent Vectors => (1,0,ddx) and (0,1,ddy) (this is because there is no change of X and Y when those are always the same value that the point itself, so deriving d(f=x)/dx = 1 and d(f=y)/dx = 0. Now just cross this and you get your normal.
If you want to, you can head to GPUGems and check the already solved formulas for an easier and simple life (specially for Gerstner Wave).
Now that we have the normals from the functions (not normalized) we can add them together and normalize them to get the overall pointing direction of the normal (each function calculates their own normal, so it’s useful to not normalize them and add them as they are, to get a better grasp of the corresponding input each function gives to the overall normal).
With this normal, we can now do a DotProduct to get the alignement of the vectors (remember, near 1 dot products vectors are almost parallel, 0 are totally orthogonal, we are searching for totally pointing upwards vectors, so we need a dot product of approximately 1 between the normal and the UP vector).
The material graph looks like this:
Max Detection Graph.
With this connected to the color, we can get this results
Thus ending the line art part of our water shaders.
Iterating the Style
As we approached the final style given by our artist, we had to transfer the materials from this empty and dark world, to one of our built scenes… context is key and we knew that for this shader to work correctly, we needed to see the full picture.
The shader as it is
Right off the bat, we discovered something that could break the style, the water on the guideline picture was way too clear, thus not giving that sense of “depth”… as one of our artist said.. “it looks like a pool”.. and i ABSOLUTELY despise pools… our artist iterated on a new concept, that takes this into consideration.
This new concept was spot on, so I started with what i knew i could solve immediately, the depth fade.
As we already stated, the depth fade will give a value of [0,1] depending on the distance from the translucent pixel to the opaque pixel… this is just the definition for our water (being darker the farther we have to look through it). A very simple fix like this will yield those results:
Depth fade as opacity input
Finally, since we have the normals of the functions, we can add a simple “Refraction” to our material (using a refraction of 1.3 by normals). This paired with the textures and colors give us this resulting water material.
With this we have our water, it’s not quite the same as the picture given by our artist, but all the parameters are exchangable, so we can tweak the colors to have a brighter or more swampy water if we do end needing it.
As a bonus, here’s the resulting water reacting to the character
As you can see, the water reacts nicely to our character, thus fulfilling all of our artist’s requirements… now we only need to polish the results and parameters to get the perfect water, but that’s an excercise on trial and error that we will be doing through the life of the project.
If you can spot though something quite obvious: The water reacts to the character movement, and not only to a impulse as we previously stated. This is because we, in that example, are using an improved version of the reactive water, using the technique of Heightmaps to simulate the water behaviour.
I guess you know already what is part 3
Water: How we discarded the sincwave and used Heightmaps instead… Also bouyancy.
If you liked our tutorial, please remember to follow us on
See you soon!