Interactive Watershader

Marlon van Ommeren

Table of Content

  • Intro
  • Research question
  • Small waves
    • distortion flow vs directional flow
    • Performance
  • Big waves
    • Sum of sines vs Gerstner vs Perlin Noise
    • Performance
  • Interaction
    • Vertex displacement vs Rendertexture
    • Performance
  • Reflections
  • Sub surface scattering (SSS)
  • Conclusion
  • Sources

Intro

I’ve always been fascinated with water in games. I dont know why but I often catch myself checking out the water when I play a new game.  So throughout my years of gaming I’ve witnessed some cool looking water. The first one being wave racer 64 and more recently the beautiful sea of Uncharted 4.

So its only right that I try to make one myself. Before this assignment I never made a shader before, so this whole ordeal was a adventurous proces and I didn’t exactly knew were my journey would lead to. At the start I kinda knew I wanted to make visually realistic looking water, but it also had to be interactive. So I started to look for sources concerning this topic.  I learned about the use of cubemap reflections and how to simulate waterflows of still water with the distortion of normal and noise texture. I also learned about creating ripple effects with rendertextures and particles. These were certainly interresting findings but during my search I also learned about more turbulent flows of water and how to make waves with the vertex shader and that you can combine normal mapping with vertex discplacemet to create even more convincing water. So during the second week my interest shifted to creating a shader for larger waterbodies like a sea or oceans instead of a pool or a pond. My goal became a bit clearer and I started to make a list of what I wanted for my shader.

  • The larger waves had to be done with the vertex displacement technique and the small ones had to be done by distorting or moving a texture. That way I was learning multiple ways to create waves.
  • The shader had to be reflective and have a realistic looking color.
  • The shader will make use of the built in lighting unity provides.
  • GameObjects must leave ripples when they interacts with the shader.
  • It doesnt have to simulate real life , just have to be a stylistic interpretation of it.
  • The shader needs to cover the largest area as possible so that it can be used for oceans.
  • The shader needs to run on my laptop on a resolution of 1920 * 1080 and a framerate of 60 fps.

What follows in this blog is nothing particularly difficult in itself, nor anything new or groundbreaking, just lots of little details and tricks I learned to make your water look more interesting. I’ll be covering water features from a flow maping ,computing gerstner waves , subsurface scattering to vertex & pixel shading. But before I get into all that I will need to formulate my research question.

Research question

For this research I wanted to make a semi realistic watershader. This was not always the case. In the first week I wanted to make a shader which had more things than the one I came up with like transparency, refraction, fresneleffects and foam. I quickly realized however that the possibilities to create water as real as possible is endless and that the step of going for super realistic water might be a bit to steep, so I went with creating a shader that’s a step between realistic and cartoony. I called it semi- realistic because it had to look kinda real, but it also had to look a bit stylized similar to the waters you see in games like gta 5.

The shader had to include the following things:

  • Small waves
  • Big waves
  • Interactive water ripple effects
  • Reflection

I wanted to make a shader that you can use for large waterbodies like oceans, so for each bulletpoint I mentioned I wanted to search for the best practices and compare how they perform when you increase planes areas. I will start of with a plane of 500 * 500 units and double it untill the performance hit becomes noticible. I will look for the frametime in ms and will use Unity profiler for that. The shader needs to perform well on my laptop and have the following specs:

  • intel core i7 CPU with a geforce GTX 1650 GPU and with 16 GB of Ram

By performing well I mean the shader cannot go below 60 fps when its rendered on a 1920 * 1080 resolution. Another criteria was that I rated each best practice on its realism. I used a special rating system for that. Let’s say the above mentioned bulletpoint ‘Small waves’ got 2 best practices I will give the bestpractice who is more realistic a 2 and the other one a 1. And the bestpractice with the best realism, performance ratio will be chosen. So my research question is the following:

What techniques has the best realism-performance ratio for creating a watershader for a ocean ?

I have conducted the same test for all best practices. And I did the test in a way that let’s me get the worst case scenario. The planes were viewed directly from the top, so that all planes could be seen and fitted exactly to the height of the screen. This will suppress the view port clipping but also let me achieve a maximised zoom level of the planes. This is important because the  surf shader has to execute much more instructions then the vertex shader, because if the planes are viewed from as close as possible, there are many more pixels visible than when viewed from a distance, hence resulting in more executions of the surf shader. The maximum number of pixels that can be seen at once is dependent on the screen resolution. As mentioned before these tests have been conducted with a resolution of 1920 * 1080 pixels.

Small waves

Directional Flow

I did some research on how I could produce small waves for a shader. During this process I learned about the flowmap. A flowmap is a texture with direction data baked into its color channels . You can use it to give the illusion of water movement on the surface of the mesh.

distortion flow vs directional flow

I learned about two kinds of flows , the first flow you can achieve through texture distortion and the second one by directional distortion.  The texture distortion effect works best for either very turbulent or very sluggish flows. It doesn’t work well for more calm flows that manifest clear ripple patterns, because such ripples have a clear direction to them. This is not the case with texture distortion because you have to use textures that don’t have any specific direction to them (isotropic) for them to  be convincing and not getting too stretched or squashed. Directional distortion however can use textures with a clear direction to them (anisotropic) and distort them properly, because its technique relies heavily on aligning patterns with the flow instead of distorting them. Before I chose one I compared them to each other and rated them on the  following criteria. Which of the two is more realistic and how do they affect the performance of my laptop. Below you see the two flowmaps. On the left is the distortion flow map and on the right is the directional flowmap.

performance

Both flowmap performs extremely well even when you increase the size of the planes. The vertex and the surf shaders are executed equally often. So this method scales well with the size of the waterplane. See results below:

performance of the distortion map

Below you can see an example of the frametime when the plane is 1000 * 1000 Units. You see that the spikes are near the 100 fps mark, but for the majority of the time the framerate stays around the 120 fps mark. I analyzed what would happen when I double the size of the planes but it doesn’t take huge performance hits. The fps only slightly decreases (see the distortion flow table below). If you compare these result with that of the directional map you will notice similar performance stats.

Performance of distortion flowmap when planesize is 2000 * 2000 units

performance of the directional map

The directional flowmap also doesn’t have a big loss in performance when you double the size of the waterplanes. Although the performance of the distortion map is slightly better, it’s neglectable. So it doesn’t matter which of the two flowmaps you want to implement. Both perform almost equally well.

performance of directional flowmap when planesize is 2000 * 2000 units
directional flow table

So I based my choice solely on the realism factor in which I must go with the directional flowmap, because in reality water flows in a direction, and the directional flowmap does exactly that. So I gave the directional flowmap a two on the Realism Scale and the distortion flowmap a one. See below:

Big waves

In the first week when I was searching for resources I was not planning on adding big waves to the watershader. I only focused on still water and ripple effects. At that time I still wanted to make water for small waterbodies like a pond. Later on I realised that it will only benefit the shader if I atleast provide a option to add waves tot the shader. Eventually in the second week I did not wanted to make a shader for small waterbodies anymore, but for big waterbodies like seas and oceans, because I felt like its more of a challenge to create them. So I looked into different type of waves that are used for computing waterbodies. I filtered out the ones that fits my purpose and that is to make semi realistic waves. And eventually three methods caught my eyes. The summed of sine wave, the perlin noise wave and the Gerstner waves. I will describe each briefly:

The sum of sine wave

The sum of sines waves are waves created in the shader through vert discplacement of the affected grid.  The waves are being calculated by adding up multiple sine equations in the vert function and will move each verticepoint on the grid. These grid points will then change positions by displacing their position in the y-axis (height). Below you see the how the waves look like and on the right you see how it performs when the plane as a area of 1000 * 1000 units. But I will adress that in the performance chapter.

The Gerstner wave

The gerstner waves look almost similar to the sine waves but they are steeper and have pointier tops. This because like the sine wave each point moves up and down around a phase, but they also move in a circular motion simultaneously.  These waves are also produced through vert displacement and are closer to how oceanwaves actually behaves in real life. Below you see a a picture of the Gertner waves and how it almost looks similar to that of the sum of sines wave.

The perlin noise wave

The perlin noise waves are being calculated by pseudorandom numbers so no waves look the same. It is a form of gradient noise that can be generated for arbitrary dimensions. Just like the other two techniques, functions with different amplitudes and frequencies get added to form the final result.

Performance

I used the same test criteria  for the big waves that I used for the flowmap. See the results below:

performance of summ of sines waves

When I compare the performance of these shaders , I looked at the frame time in ms , wich it the delta time between 2 frames. When comparing the frame time of different sizes of the summed up sine shader, I noticed that when the area is quadrupled, the consumed frame time roughly doubles. But as soon I make a grid of 4,000 by 4,000 the frame time increases considerably. If you observe the table you will see its nearly five times bigger than the frametime for a 2,000 by 2,000 grid. This is probably caused by an bus bottleneck. My conclusion it that if you want to have an even bigger plane with this shader technique its better to implement a LOD system or look for another technique who’s frame times is lower.

sum of sines wave performance table

performance of Gerstner waves

If you look at the performance of the Gerstner waves (see table below) it shows similar results than the summed up sine waves. You will also see that the frame time doubles when you quadruple the size of the planes and you will also see the bottleneck at the 4000 by 4000 planes, that is caused by the large amount of vertices. The only difference its that Gerstner waves score higher on the realism scale, so by now I was leaning toward this technique.

Gerstner wave performance table

performance of perlin noise waves

Although the Perlin noise wave has the advantage that you can create waves that don’t repeat, wich is closer to reality, the performance cost is too much if you compare it to that of the gerstner wave. If you watch the table below you’ll see it’s very demanding. When looking at the frame time, even an area of 200 by 200 units needs 16.60 ms, which is nearly the whole available frame time of a game that runs at 60FPS. The cause of this problem has to do with the normal calculations which are done per fragments instead of per vertex. It has to be done that way because the quality of the waves wouldn’t have been sufficiënt.

Perlin noise wave performance table

After analyzing all three techniques the Gerstner waves was obviously the best pick out of the three. It outperformed the Perlin noise version and scored highest on the realism scale. It beats the Perlin Noise waves in realism if  you consider the pointy crests of the waves. The only advantage Perlin noise got over Gerstner are more variety in waves. Below you will see how I rated the practices on it’s realism.

Interaction

After my choice for the gertsner waves. I looked into best practices to add interaction between gameobjects and the shader surface. But before I could implement them I noticed that the gameobject could not interact with the gertsner waves. So I did some more deskresearsch and found out that a surface shader can not give waveheight information back to the cpu wich was needed to calculate the buoyancy of the objects. The only way to make the gameobject interact with the waves is by also writing the same wave equations from the shader wich its GPU based on the cpu in a script. But you probably would guess that this will increase the performance cost considerably , so this was never a option for me. Luckily I found the solution through a guild meeting and that was writing a compute shader because unlike surfshaders these shaders can share the new wave height data with the cpu. So I removed all wave related code from the surf shader and rewrote it in an compute shader. With these shaders you can do the calculations and write the new wave positions data to a buffer . And you can fetch the new data through a script and use it to calculate the new position of the gameobjects. See the code below:

#pragma kernel CSMain

struct VertexData
{
	float3 pos;
	float3 nor;
	float2 uv;
};

RWStructuredBuffer<VertexData> vertexBuffer;

float _Time;
//uint _VertexCount;
static const uint numWaves = 2;

struct Wave
{
	float3 direction;
	float steepness;
	float waveLength;
	float amplitude;
	float speed;
};

Wave set_Wave(float3 direction, float steepness, float waveLength, float amplitude, float speed)
{
	Wave wave;
	wave.direction = direction;
	wave.steepness = steepness;
	wave.waveLength = waveLength;
	wave.amplitude = amplitude;
	wave.speed = speed;
	
	return wave;
};

[numthreads(32, 1, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
	//Shortcut
	float3 pos = vertexBuffer[id.x].pos;
	Wave wave = set_Wave(float3(0.3,0,-0.7), 0.1, 4, 1, 2); 
	
	float numWaves = 1;
	Wave waves[1];
	//waves[0] = set_Wave(float3(0.3, 0, -0.7), 0.1, 10, 0.8, 2); // float3 direction, float steepness, float waveLength, float amplitude, float speed
	//waves[1] = set_Wave(float3(0.5, 0, -0.2), 0.5, 5, 2, 2);

	float frequency = 2.0 / wave.waveLength;
	float phaseConstant = wave.speed * frequency;
	float qi = wave.steepness / (wave.amplitude * frequency * numWaves);
	float rad = frequency * dot(wave.direction.xz, pos.xz) + _Time * phaseConstant;
	float sinR = sin(rad);
	float cosR = cos(rad);
	float3 binormal, tangent, normal;

	pos.x = pos.x + qi * wave.amplitude * wave.direction.x * cosR;
	pos.z = pos.z + qi * wave.amplitude * wave.direction.z * cosR;
	pos.y = wave.amplitude * sinR;

	float waFactor = frequency * wave.amplitude;
	float radN = frequency * dot(wave.direction, pos) + _Time * phaseConstant;
	float sinN = sin(radN);
	float cosN = cos(radN);

	binormal.x = 1 - (qi * wave.direction.x * wave.direction.x * waFactor * sinN);
	binormal.z = -1 * (qi * wave.direction.x * wave.direction.z * waFactor * sinN);
	binormal.y = wave.direction.x * waFactor * cosN;

	tangent.x = -1 * (qi * wave.direction.x * wave.direction.z * waFactor * sinN);
	tangent.z = 1 - (qi * wave.direction.z * wave.direction.z * waFactor * sinN);
	tangent.y = wave.direction.z * waFactor * cosN;

	normal.x = -1 * (wave.direction.x * waFactor * cosN);
	normal.z = -1 * (wave.direction.z * waFactor * cosN);
	normal.y = 1 - (qi * waFactor * sinN);

	binormal = normalize(binormal);
	tangent = normalize(tangent);
	normal = normalize(normal);
	
	vertexBuffer[id.x].nor = normal;
	vertexBuffer[id.x].pos.y = pos.y;	
}

Vertex displacement vs Render texture

After that problem was fixed I continued my search for best practice techniques to handle the interaction between shader and gameobjects. When a gameobject comes in contact with the shader , ripples should be shown on the surface. I learned about two techniques. One is done with a rendertexture and particles projected onto the surface of the shader and the second one is by creating capillary waves trough vertex displacement. It will displace the vertices in a radial sine motion on the point where a gameobject touch the vertice of the watermesh. Its important that the gameobject ends up on the closest watermesh vertice from its position to make this work. So I regurlarly must check the position of the gameobject and translate them to closest vertice of the watermesh. This part is done through script and can be quite cpu intensive, so in order to relief the cpu a bit I spread the workload of this check between multiple frames. I used coroutine for that.

Performance

For this test I didn’t need to double the plane sizes to check the overall performance of the shader because the ripple effects only gets called when an object interacts with it. So it’s much more important to know what the frametime is of the things that are responsible for the ripple effect. I used a plane of a 1000 * 1000 units to test the ripple effects on.

Performance Rendertexture

The rendertexture requires a second camera to display the rendertexture on the uv of the shader. It will render a ripple texture on top of the surface of the shader. If you observe the performance in the profiler you will notice a slight overhead from the second camera. If you watch the results you will notice that it will take 0,294 milliseconds to render the camera which it’s the same amount of time it takes for the main camera to render the entire scene. The projected texture doesn’t take that long project, only 0.02 s .

Performance ripple effect by Vertex displacement

When I analyze the performance by vertex displacement. I noticed that the gpu is working harder. This is probably because the vertexshader does the calculations for the ripples now. So both the vertexshader as the fragmentshader its being used to create the ripples, where as for the rendertexture only the fragmentshader is being used. I also see that the physics of the computeshader is causing spikes which occasionally exceeds the 60 fps mark.

performance of the shader when vertex ripple displacement is being applied

When I analyzed the testresults I came to the conclusion that the vertexdisplacement rippleffect beats the rendertexture in realism, but the performance cost are signifcly higher than that of the rendertexture. The vertexdisplacement ripple effect looks more realistic because the vertices are literally displaced in 3d space , and the other one is just a optical illusion. But the performance are to high to consider using it, so my choice went to the render texture ripple effect.

Reflections

sky is reflected and also the hill a bit

The reflections were quite simple to make. The Standard Surface Shader already had a property that handles the glossiness of the texture. So i gave this a value of 0.7.  I also placed a reflectionprobe in the scene that ensures that the environment and objects will be reflected on the watermesh.

Sub Surface Scattering (SSS)

SSS on the front right

To make the water even more convincing I wanted to make a fake SSS effect that will give the water its transclucant apeareance. A real SSS calculation is very intensive , wich it’s not what I was aiming for. The standard shader doesn’t support SSS , so I had to make an custom pbs lighting model that factors in the subsurface colors in its lighting calculation.

Conclusion

To sum it up, the main setup of the shader goes as follows:

  • Vertex animation with Gerstner waves that adjust the vertex position and normal with a compute shader. A total of 2 waves can be combined to create a wave landscape.
  • A flow map that make the normal maps move in the direction of its flow, just like you see in fast rivers.
  • Water reflection and sub surface scattering is added by extending the Unity light function.
  • Interactive water ripples visualised by a render texture when a gameobject collides with the shader.

I chose this setup because this had the best perfomance realism ratio according to my tests. So if I had to answer my research question I would advise the following:

If you want to make a semi- realistic watershader for a large waterbody and you’re laptop has the specs described in the ‘Research question’ chapter, I would go for a shader with a directional flowmap for the small waves and gerstner waves for the larger ones and for the ripple effects I would use a rendertexture with particle effects. The max plane size would be 1000 * 1000 units.

What i could improve:

  • Better interaction with shallow shores or other watergrids. High waves will look weird when they interact with them. I could’ve added a edgedampening to make sure the waves at the mesh borders are lowered.
  • I could’ve added a Depth map to determine the amount of foam on a object in water.
  • I could’ve added some transparancy and refraction properties to the water.
  • A LOD system (tessellation) for even better performances

Sources

  • Catlike Coding : Unity Tutorials Flow, 05 2018.
  • Finch, M. : GPUGems 1 -Chapter1. Effective Water Simulation from Physical Models, 09 2007.
  • He, H. : Simulating Water Surfaces With Perlin Noise, 09 2018.
  • Hoesel, van F. : the Tiled Directional Flow algorithm, 2010.
  • Perlin, K.: GPUGems 1 -Chapter 5. Implementing Improved PerlinNoise, 09 2007.
  • Unity Technologies : Performance and optimization
  • Zuconni A, : Fast Sub Surface Scattering in Unity, 08 2017.

Related Posts