return home

Procedural Water Shader and First Impressions of Unity's Shadergraph/HLSL pipeline

2/21/25

Intro

In my upcoming rhythm game hyperRunner2.5D I am developing a boss fight that will take the form of a street race. I was introduced to real-time computer graphics after taking a computer graphics class in C++ and OpenGL. I'm excited to apply what I learned to my game. Let's make a water shader. I already had implemented a curved world shader that resembles animal crossing, following this great youtube tutorial from Rigor Mortis Tortoise. This shader modifies the vertex positions of a mesh to emulate a curved world, lowering vertices that are farther away from the camera, similar to Subway Surfers or Animal Crossing. It looks great for the racing minigame. I used this shader as the base to create the dynamic water effect.

Preparing the Mesh with Blender

First thing I did was subdivide a plane in blender a couple times. After experimentation 4,225 vertices gave me enough detail for small/medium sized waves. These vertices will be manipulated by the water shader to emulate waves on the surface of water. The plane object was UV unwrapped as well, so it will work with our perlin noise.

Subdivided plane

Shadergraph

Below is the main section of the shader graph file. The curved world shader bits are added into the pipeline later, this image focuses on water. outPos(3) and normal(3) all the way on the top right are the end products that we feed into the Position() and Normal() channel of the Fragment shader node.

Shader graph main section

Lets split into sections.

Shader graph main section

Section 1: Preparing the plane UV coordinates

I first take the UV coordinate of the vertex/frag on the plane. Because I scaled the plane to have a ratio of x y scale 3:1 in Unity, I also need to scale the UV coordinates so that the noise is not disorted in the X direction. This is why we multiply the x UV coordinate by 3. We then feed the transformed UV coordinate into our animate waves function. This is what gives the waves motion.

Section 2:

AnimateWaves.hlsl
Animate waves picture

This is a Shadergraph custom node that I wrote. Shadergraph provides some nodes themself, such as noise nodes, math nodes (multiplication/addition etc.), and more. There is a lot you can do with the built in Shadergraph nodes, but I find it can be cleaner and more precise to write custom nodes when things get more complicated. That said this is a pretty simple script. The idea is that we get the UV coordinate from the vertex or fragment (Shadergraph decides this and I'm still unclear how they decide, more on this later), and we want to move the x UV coordinate over time. If we move the x UV coordinate over time, and keep the noise static, we create a horizontal scrolling effect emulating waves moving. Let's discuss the inputs and outputs!
Inputs

  1. uv is the UV coordinate obtained from Section 1
  2. uvDistanceForNormalCalc is a small value that was tweaked to calculate vertex normals (more on this later)
Outputs
  1. animatedUV The UV coordinate after movement for this render step
  2. uvToRight The UV coordinate after movement just to the right of this vert/frag
  3. uvToUpwards The UV coordinate after movement just above this vert/frag
animatedUV is calculated by adding a factor of _Time. x to the original UV x coordinate. _Time is a built in Unity shader variable that provides the Time since this scene has loaded. Through experimentation, we don't need to wrap the animatedUV between 0 and 1, which is normally what UV coordinates are wrapped between. Unity will do this wrapping for us so we can just keep adding _Time. x without clamping anything. uvToRight and uvUpwards are simply the UV coordinates just above the animated UV and just to the right of the animated UV. This is used to calculuate the normal value of this vert/frag. Because we are modifying the vertex positions in the shader, we need to also update their normals so that the lighting reflects these new positions. Finally, we feed these output UVs into noise nodes. The noise node takes in a UV coordinate, and gives us a value between 0 and 1. The gradient noise node gives us continous noise, which is random values that blend into each other, resembling topology of terrain. We decide that white values (1) is the highest height a wave can be, and that black values (0) is the lowest height a wave can be. In Section 3, we apply these noise values to actually change the position and normal of each vertex/frag.

Section 3

In this final section, we put everything together to get the position and normal of the vertex/fragment in pipeline. Create waves function
Height of the Wave (Orange Section)
The orange section of the code determines the height of the vert/frag. waveMaxHeight is the amplitude of the wave, which I chose to be 5.0f for this scene's purposes.

Now one may think- "This section could be one line! We could simply use our simpleNoise value from the noise texture, which will be between 0 and 1, and multiply it by waveMaxHeight and add it to the y position to get our final out position! Right?"

Right! But there's a catch. While this would work great for a singular body of water, what if we need multiple bodies of water to tile? This is the situation for this bossfight, as I want the effect of endlessly scrolling waves as the player races along the river. If we just use a singular line of code as suggested, the waves look like this when placing two water objects back to back.
No cos smoothing picture
The wave shape looks pretty good! But there is a issue. The bodies of water don't tile together. There is a seam where one water object starts and the other water object begins. This seam breaks the illusion of one continuous body of water.
No cos smoothing picture annotated
To remedy this, we need the height values at the top of one body of water to equal the height values of the bottom of the next body of water. For simplicity, I decided to set these wave heights to 0 when getting close to the vertical edge of the noise texture. This way, the waves will tile vertically and the two objects will seem like one continuous mesh. I experimented with this, and set the waveMaxHeight to 0 when UV coordinate was near the top or bottom of the texture and got the result below.
No cos but tiling picture
This looks pretty good! It looks like one continuous mesh now. But see how there's a little ramp where the two water objects meet? This also kinda breaks the illusion of a continuous body of water. This is because we are forcing the wave height to be 0 near the edges without any type of smoothing. Let's smooth this transition out.

We want to start smoothing when the vert/frags UV coordinate is closer to a vertical edge than startSmoothingNearYEdge. We want the smoothing to end and the waveMaxHeight to be 0 when the vert/frags UV coordinate is closer to the vertical edge than beFlatWhenReachUVYCoord. We calculate our smoothingFactor which is a value between 0 and 1, 0 indicating no smoothing (waveMaxHeight is unchanged) and 1 indicating full smoothing (which means waveMaxHeight should be 0). We then pipe this smoothingFactor into a cosine function, lerping it between 0 and pi. This give us a great smoothing curve that emulates a wave. We pipe the smoothingFactor into a slightly modified cos function that has amplitude between 0 and 1 to get our cosFactor value.
Modified cos function picture
Our waveMaxHeight is scaled by our cosFactor to ensure that when just inside entered the smoothing zone, amplitude of wave is mostly unchanged, but as we enter farther into the smoothing zone the max wave height will slowly become 0 in a sinusoidal fashion until it bottoms out at 0 to tile. The result is below.
Waves after cos smoothing
Pretty smooth tiling! One imperfection is that if a player looks hard enough, they may be able to see that the body of water is the same height for a horizontal strip. But during high speed racing gameplay, and even from a birds-eye view, I think this imperfection will go unnoticed.
Waves after cos smoothing
This is one of the things that I love about graphics programming. Small imperfections don't matter! As long as it looks good, it is good, even if some corners are cut.
Generating Normals (Blue Section)
In the blue section of our MakeWaves function, we calculate the normal of the frag/vert. We need normals so that diffuse lighting and specular lighting will work on our waves in real-time! Remember that we aren't actually changing the plane mesh that represents the water. We change the position of vertices of the plane in the shader itself, so we need to update the normals of the vert/frag depending on the topology of plane after new position value is determined.

I use 3 noise values from the previous section: noise value at this UV, slightly to the right of this UV, and slightly above this UV. The idea is that I use these values to generate a vector in the direction to the right, and a direction up. These two vectors describe a plane, which is a plane on the surface of the water at the vert/frag position. I then take the normal of this plane to generate the normal vector of the frag/vert, which is outputted as normal. To be honest, the axis directions in unity positional space and the axis directions in texture space didn't completely match up with my mental model I made in my head, so the last two lines of this section I flipped and negated after trial and error in Unity to get the normal colors to match with the world axis. Nevertheless the normals look good! And the normals from the algorithm are actually shown as the color of the water in the pictures above in the orange section of the code.

One thing to note is that we don't take into the smoothing factor while calculating the normals. This causes the flat section of the waves to have incorrect normal calculations as shown below.
Imperfection tiling and normals
This would be a somewhat easy fix, but during gameplay light bounces around fine and it is not noticeable, so I feel no need to fix this anytime soon!

Results

Here are some videos of the final result!

This video above is pretty nice. Shows the movement of the waves and some of the lighting. One thing I noticed is that There is a grid like pattern that pops up on the water in the shadows. Super interesting result, not sure why this is happening but pretty sure this artifact is from the normal calculation. This would be something fun look into in the future. For now though, during gameplay it isn't too noticeable and I think it adds some character! This boss level will be the bossfight for my Cyber World level, which already has a grid as the floor! So maybe this grid artifact is a happy accident. Gameplay below of cyber rhythm level.


Other Notes and Follow Up Questions

In Shadergraph, I still don't understand whether I am working with a vertex or a fragment. Coming from OpenGL, it was a little trippy as OpenGL has a strictly separated vertex and fragment shader. In Shadergraph, nodes can go to both the vertex or the fragment node. The vertex and fragment node both have a position, normal, and tangent. Will look into this more and update.