Water shader tutorial
Last updated
Last updated
This tutorial will go over how to create a stylized water material using a custom shader.
For our scene, to see how our material will look, we need a landscape and a plane. Create the landscape and dig down to create something that looks like a small lake. For the water surface, we can just use a plane and rotate it -90 degrees around the X axis so that it faces up. Scale the plane and move it to cover the hole in the ground while intersecting with the sides like in the image below.
In the asset browser, click “Add new” and then click “Shader class”. Name it “WaterShader”. If we now open our code base, we should find a file at src/shaders/water-shader.ts.
We will need a couple of parameters later so we can define them here. Add the properties "shallowColor" and "normalMap" with the @Parameter
decorator like in the code below.
To create the appearance of small waves on top of our flat water surface, we will use a normal map which is an image in which the pixels describe the angle of the surface at that point. Download the image below.
You could also use any other seamless normal map for a different look.
Now that we have the shader parameters declared and the normal map texture asset we need, we can create the material asset. In the asset browser, click "Add new" and then "Material". You can name it "Water".
Configure the material with the following values.
Type = Custom shader
Shader = WaterShader
Side = Front
Transparent = Yes
Bloom = No
Color = #FE9397
Shallow Color = #O5FFB4
Normal Map = water_normal
Assign the material to your plane by either dragging the material asset onto the plane or by selecting the material in the object editor panel.
Now that the scene is prepared, we can go back into the shader code. Whenever you make changes to the shader code, it will automatically update the material in the Hology editor. It can therefore be useful to have both the code editor and the Hology editor open side by side.
To make the water appear more realistic, we want to adjust the color based on the depth, making it look murkier the deeper it is. This can be done using a built in function edgeDepthEffect(power: number)
. This function will return a number between 0 and 1 where 1 means that the fragment behind the fragment we render is at the same distance from the camera. This can be useful to calculate a different color or opacity where a geometry intersects with another or based on depth. The number given as the argument to the function is an exponent that can be used to create a sharper edge detection. In your shader code, we can replace the content of the output function with the following code. We can output the depth value as the color to see this effect.
With the depth value, we can now easily create a gradient with our two color parameters to render a different color where the water is more shallow. The mix function will calculate a color between the two given colors based on the depth value. As the color parameters are of type Color
, we need to convert them to RgbNodes using the rgb()
function.
Water is often transparent so we will adjust the opacity value of the color. To add to the appearance of depth, we will utilize the depth value again so that the water becomes less transparent when it is deeper.
In the code below, the opacity is calculated using 1 minus the depth raised to an exponent that we can tweak to change how much of the edge should be transparent. by using a higher exponent, the transparency will decrease faster as it gets deeper. Then, the gradient color and opacity is combined into and an RgbaNode.
The color we are outputing it currently not influenced by lights or shadows. To fix that, the function standardMaterial
can be used. This function adjusts the given color based on lights in the scene and shadows cast by other objects.
To give the appearance of waves, we will utilize the normal map that was previously imported.
To sample our normal map we need to use a coordinate vector with 2 values. While it is common to use the UV values contained in the geometry's vertex for many shaders, in this case that is less favourable. We may want to use this same material for several different water surfaces using planes with different scale. If we use the UV values, this would lead to the image being stretched unless the plane is completely square. Also, we likely want the waves to appear the same size regardless of how large the body of water is.
We can utilize the geometry's vertex position in the world using the built-in worldPosition
value and extract the x and z components to create a Vec2Node. To make the value interpolate across the surface of the triangle, the function varying
should be used. This coordinate will allow us to sample the normal map using the fragment's position in the world, independent of how stretches the geometry is.
We need to sample our normal map while also animating it so that it looks like it is moving. Normally, water will have smaller waves on top of other waves that move independently from the waves underneath. To accomplish this, we need to sample our normal map twice with different coordinates. We will also use the same normal map so we need to adjust the scale of the coordinates to make some waves smaller. You could also achieve this by using a second normal map with a different pattern. We can define a function like below that provides this functionality.
To sample the normal map, we first need to create a sampler with the function textureSampler2d
and pass in our normal map texture parameter.
Then we create two samples with different scale, direction and speed values.
The samples are mixed together evenly and the function colorToNormal
is used to convert the color value provided by the normal maps to a normal that we can use for light calculations. The last argument to this function can be used to adjust the normalScale
to increase or decrease the height of the waves.
Finally, we add this normal value to the standardMaterial
function. To see the light shimmer on the water we need to also adjust the roughness value to a small number. You may need to move the camera around in the editor to see this effect.
Bringing this all together, we now have a simple stylized water material.
To learn more about creating shaders with Typescript in Hology Engine, see our other articles on Typescript shaders