Custom shaders with depth sampling

Video of breakdown (see on Twitter)

Using Depth texture sampling in custom shaders

While making silo27 I had to make a custom shader for some glowing “electrical” water. I had to make some research on how to achieve it, with the depth fades and shoreline effect, how depth pass texture sampling works, so here is a rundown on how the final shader works.

Difficulty: Intermediate
This tutorial was made in Unity 2020.1 with the built-in render pipeline (will not work in URP or HDRP)

Contents

Mesh and preparation

Prepare a mesh to apply the water material on. I will use this scene from my game for the examples. You can use the built-in plane mesh from Unity or make one in a 3d modeling software like Blender. I made mine with ProBuilder right inside of Unity. The mesh should be a flat plane with UVs and at least some subdivisions. More polygons means smoother waves, but we’ll cover this better later on.

Screenshot of the scene before adding the mesh
Screenshot of the scene before adding the mesh
Mesh for the water with a placeholder texture
Mesh for the water with a placeholder texture

Creating shader and material

Now that you have your mesh ready it’s time to begin. Create a new material with a new Unlit Shader and assign the material to your mesh.

Below is the new shader, with some changes already made.
All comments and unneeded parts were removed. The texture was replaced with just a plain color (with the HDR tag to allow emissive colors). Tags and Blend were changed to make the shader transparent. Some #pragma parameters were added to disable some lighting features, since this shader does not use them.

Shader "Unlit/Tutorial"
{
    Properties
    {
        [HDR] _Color("Color", Color) = (1, 1, 1, 1)
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "IgnoreProjector"="True" "Queue" = "Transparent" }
        Blend SrcAlpha OneMinusSrcAlpha
        LOD 100

        Pass
        {
            Tags { "LightMode" = "ForwardBase" }

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fwdbase nolightmap nodirlightmap nodynlightmap novertexlight

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };

            float4 _Color;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);

                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = _Color;
                return col;
            }
            ENDCG
        }
    }
}

You can now set a color with alpha and intensity from the material inspector.

The shader with a transparent base color
The shader with a transparent base color

What is the depth texture

In 3D computer graphics and computer vision, a depth map is an image or image channel that contains information relating to the distance of the surfaces of scene objects from a viewpoint.

Depth map on Wikipedia

The depth texture is a special texture that gets rendered by Unity, at least on desktop. On mobile it’s often too expensive. It’s basically a greyscale image of your scene where the brightness of each pixel indicates how far it is from the camera.

It is used for effects like depth of field post-processing effects. It is also a component of certain implementations of shadow rendering of occlusion culling systems. Another use, which is what we’ll do in this tutorial is simulating the effect of dense semi-transparent surfaces, such as fog, smoke or large volumes of water.

A fully rendered frame and below it’s depth buffer
A fully rendered frame and below it’s depth buffer

How to sample the depth texture

We start by using a Unity macro to declare the depth texture sampler. This macro helps us be compatible with different platforms.
You can put this right after the float4 _Color; line (it is near the middle of the shader).

float4 _Color;
+ UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);

Let’s add a variable inside the v2f struct, where we will later store the screen-space position.

struct v2f
{
  float4 vertex : SV_POSITION;
+  float4 screenPos : TEXCOORD1;
}

You may notice that we used the TEXCOORD 1 instead of 0. This is because we’ll use the other one later in the tutorial.
Usually you would start using them in order, starting from 0.

We then compute the screen-space position and eye-depth inside the vert function, using some functions and macros provided by Unity. These will be then used later inside the frag function to calculate the actual depth value.

v2f vert (appdata v)
{
  v2f o;
  o.vertex = UnityObjectToClipPos(v.vertex);

  // compute depth
+  o.screenPos = ComputeScreenPos(o.vertex);
+  COMPUTE_EYEDEPTH(o.screenPos.z);

  return o;
}

And finally we can get the pixel depth in the frag function.
You can read more about using depth textures in Unity, and what the different functions and macros do, here.
We first use some functions and macros provided by Unity to, essentially, get the depth value of what is behind our mesh. Then we substract the depth value of our water mesh away from that. This leaves us with the distance between our water mesh surface and the surface of what is under it, we’ll store this value in the aptly named depth variable.

fixed4 frag (v2f i) : SV_Target
{
  fixed4 col = _Color;

  // compute depth
+  float sceneZ = LinearEyeDepth (SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)));
+  float depth = sceneZ - i.screenPos.z;

  return col;
}

Great! We have the depth value, now we can use it to make the effects we want.

Using the sampled depth

Continue by adding the following lines after the previous ones. This will make the base color fade with depth.

fixed4 frag (v2f i) : SV_Target
{
  fixed4 col = _Color;

  // compute depth
  float sceneZ = LinearEyeDepth (SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)));
  float depth = sceneZ - i.screenPos.z;

  // fade with depth
+  fixed depthFading = saturate((abs(pow(depth, _DepthPow))) / _DepthFactor);
+  col *= depthFading;

  return col;
}

_DepthFactor and _DepthPow are the configurable parameters. Add them in the Properties at the top of your shader

Properties
{
  [HDR] _Color("Color", Color) = (1, 1, 1, 1)
+  _DepthFactor("Depth Factor", float) = 1.0
+  _DepthPow("Depth Pow", float) = 1.0
}

and define them where we declared the depth texture before.

float4 _Color;
UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);
+ float _DepthFactor;
+ fixed _DepthPow;

Now you can play around with the values in the inspector. At first they might appear to both do the same thing but Depth Factor controls the length of the fade and Depth Pow(Power) changes the smoothness of the fade.

Use PostProcessing to have emissive colors render correctly (and nicer!)
Use PostProcessing to have emissive colors render correctly (and nicer!)

The next step is basically the same thing but reversed. Let’s do the shoreline!

This is still in the frag function.

fixed4 frag (v2f i) : SV_Target
{
  // [other code ...]

  // "foam line"
+  fixed intersect = saturate((abs(depth)) / _IntersectionThreshold);
+  col += _EdgeColor * pow(1 - intersect, 4) * _IntersectionPow;

  return col;
}

Add the properties to control how the line will look.

Properties
{
  // [...]

+  [HDR] _EdgeColor("Edge Color", Color) = (1, 1, 1, 1)
+  _IntersectionThreshold("Intersection threshold", Float) = 1
+  _IntersectionPow("Pow", Float) = 1
}

Then define the variables for those properties.

float4 _Color;
UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);
float _DepthFactor;
fixed _DepthPow;
+ float4 _EdgeColor;
+ fixed _IntersectionThreshold;
+ fixed _IntersectionPow;
depth effect and shoreline
depth effect and shoreline

And we are done with the depth-related effects.
However our water is still missing a very important feature: waves!

Making waves, using vertex displacement

The technique we’ll use for this is called vertex displacement, this is why you needed the many polygons in your mesh. We will sample a texture with random noise in it, and the use the value we get to move the vertices of out mesh up and down to make them move like waves.

This time we’ll start with adding the Properties first.

Properties
{
  // [...]

+  _NoiseTex("Noise Texture", 2D) = "white" {}
+  _WaveSpeed("Wave Speed", float) = 1
+  _WaveAmp("Wave Amp", float) = 0.2
+  _ExtraHeight("Extra Height", float) = 0.0
}

Next we’ll define the variables for the properties we just added.

float4 _Color;
UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);

float _DepthFactor;
fixed _DepthPow;

float4 _EdgeColor;
fixed _IntersectionThreshold;
fixed _IntersectionPow;

+ sampler2D _NoiseTex;
+ float _WaveSpeed;
+ float _WaveAmp;
+ float _ExtraHeight;

Since we are using a texture we’ll need to add a variable to store the texture coordinates in both the appdata and v2f structs.

struct appdata
{
  float4 vertex : POSITION;
+  float4 texCoord : TEXCOORD0;
};
struct v2f
{
  float4 vertex : SV_POSITION;
+  float4 texCoord : TEXCOORD0;
  float4 screenPos : TEXCOORD1;
};

And now that everything is added we can move the vertices by adding the following lines in the vert function. We first read the value from the noise texture _NoiseTex. Then we add that value to the vertex position on the y axis, to make it move up. But before adding the value gets multiplied by our different parameters that control the wave’s movement speed (_WaveSpeed) and height amplitude (_WaveAmp). We are also using _Time to make the waves move over time, otherwise we would add the waves displacemnt to our mesh but it would be frozen still.

v2f vert (appdata v)
{
  v2f o;
  o.vertex = UnityObjectToClipPos(v.vertex);

  // apply wave animation
+  float noiseSample = tex2Dlod(_NoiseTex, float4(v.texCoord.xy, 0, 0));
+  o.vertex.y += sin(_Time * _WaveSpeed * noiseSample) * _WaveAmp + _ExtraHeight;

  // compute depth
  // [...]

  return o;
}

Make sure you modify the vertex before you compute the depth, otherwise the depth will be wrong since it will be using a wrong position for it’s calculations.

The noise texture

To make the waves work you first have to select a noise texture from the material’s inspector.
You can find many free noise textures online that you can use. You can also obviously make your own if you can! If you want you can get the textures used in this tutorial from the files on patreon.
Download tutorial files here

The water now with moving waves


DONE!

Now add it in you scene. Then add some VFX and particles effects for a nicer atmosphere! Here I also made it fade in the distance to blend better with the skybox.

Finished material in scene with particle effects


Download files

Patron can download the files for Unity here.
Included is a scene showcasing the completed shader, with some nice lighting and some particle effects.


References and sources

« SILO27 Dev log 4 See other posts

Enjoying the tutorials? Are they useful? Want more?

If you think these posts have either helped or inspired you, please consider supporting this blog.

Help this blog continue existing

If the knowledge you have gained had a significant impact on your project, a mention in the credits would be very appreciated.

Enrico Monese

All around developer, from games and web to weird commandline utilities. 2D/3D art, with a good care for UI/UX and clean design.

Twitter Twitter Patreon Patreon Blog Blog   Reddit Reddit Github Github Gitlab Gitlab ArtStation ArtStation Mail Mail