Depth-Based Post Effects

Last time we learned about custom colors in UI graphics. Today we're gonna shift back to post processing effects to learn how to use the depth texture to make a localized toon shading effect!

Setup

Make sure you have the post processing package installed in your project. If you don't know how to do that, read up on it here. I'm also assuming you have your project set up with the PostProcessing layer as directed in this lesson. If not, go back and skim those and come back when you have a Post Process Layer on your camera and a Post-process Volume enabled in your scene.

You'll also want your scene to use the Deferred render path. You can either enable this in project settings or select it from your scene camera. If you don't know how to do this, go back and read the lesson on deferred rendering.

You can confirm you're using the deferred render path by expanding the dropdown at the top left of the Scene tab and selecting the Render Paths debug view.

In this view, objects rendered in forward are yellow, and objects in deferred are...lilac? Violet? They look like this:

You can exit this debug view by selecting Shaded from the top of the same dropdown.

Arrange your scene so it's got stuff in it and it looks nice. I'm still using the scene I set up with Claire and the Unity 3D Game Kit.

Next, go to the folder Shaders > Post Effects > Scripts in your project files (or make this folder) and add a new script called DepthToon.cs. In Shaders > Post Effects > Shaders (or some folder along those lines) add a new Image Effect Shader called DepthToon.shader.

Stubbing out the Shader

In programming, to "stub something out" is to put in a placeholder that you'll fill out later.   We're going to stub out the effect and the effect renderer script so that we can attach it to our Post-process Volume and see the changes we make in scene view as we make them.

Open DepthToon.shader in VisualStudio and change the name up top to 

Shader "Hidden/Xibanya/Effects/DepthToon"

If you remember from last time, the best practice for the v2 post effects stack is to use HLSL rather than CG, so let's go ahead and switch the template over to HLSL. Delete the Properties section, straight up. We don't need it. And instead of a CGINCLUDE block, we'll have an HLSLINCLUDE block, like this

Between HLSLINCLUDE and ENDHLSL, we'll start off with our includes. You'll remember from last time that instead of UnityCG.cginc, v2 post effects written in HLSL use a different shared library. And you'll definitely want to copy and paste this in, 'cuz it's long.

#include "Packages/com.unity.postprocessing/PostProcessing/Shaders/StdLib.hlsl"

And while we're here, let's copy over the macros and other defines from ShadowGradient.shader (which we made the last time we looked at the v2 post effects stack!)

declare 

sampler2D(_MainTex); 

And copy the frag function at the bottom of the template underneath. Change i.uv to i.texcoord because that's what the UV interpolator is called in VertDefault, the vertex function defined in StdLib.hlsl. I have also changed the fixed data types to half so that they are blue, this is optional.

Then after that, this is all we need for our SubShader

Shader: Stubbed. Now let's get this going in our post effects renderer.

Stubbing out the Effect Renderer

Open DepthToon.cs.

At the top, add 

  • using System;
    using UnityEngine;
    using UnityEngine.Rendering.PostProcessing;

Then declare a public sealed class DepthToon : PostProcessEffectSettings { }

Above your class declaration, add these attributes.

[Serializable, PostProcess(typeof(DepthToonRenderer), PostProcessEvent.AfterStack, "Xibanya/DepthToon", allowInSceneView: true)]

If you don't know what the attributes are for, you can get a refresher by reading the previous lesson on post effects!  In that same file, add 

public sealed class DepthToonRenderer : PostProcessEffectRenderer<DepthToon>
}

You should have this:

You'll notice the IDE complaining because you haven't implemented the parent class PostProcessEffectRenderer's abstract method Render(PostProcessRenderContext context). In C#, if a member of a class is abstract, that means you HAVE to use it in any child class. (But if that doesn't mean anything to you, don't sweat it, just keep following the tutorial!)

If you're using Visual Studio 2017 or newer, hover over DepthToonRenderer so you see the tooltip.

Click the lightbulb to the left of the tooltip, click Show potential fixes at the bottom of the tooltip, press Alt + Enter, or press Ctrl + . (You have a lot of options!) You'll see this:

Click Implement Abstract Class and you'll get the method you need auto-added for you! Go ahead and delete the line throw new NotImplementedException(); 'cuz we're about to implement this.

If you're using a different IDE that doesn't have this feature, just copy what I've got here:

Above the Render method, let's add a constant to keep the shader name.

 private const string SHADER = "Hidden/Xibanya/Effects/DepthToon";

in C#, the convention is to put constants in all caps, so that's what we'll do here.

Then fill out the Render function like so:

Save everything and go back to Unity. In your Post-Process Volume, click Add effect... then navigate to Xibanya > DepthToon to attach the effect.

If you did everything right, you'll have something like this!

Not gonna win any beauty contests, but it won't look like this for long. 

Unpacking the Depth Texture

When we're creating post effects, we can figure out the depth (how far away from the camera) something is using the depth texture. It's not really a picture, it's more like a way for Unity to store data about scene depth in memory such that we can get access to it inside a shader. But if you want to see what it looks like, you can open up the frame debugger. If the Frame Debugger tab isn't in your editor, You can open the Frame Debugger by going to Window > Analysis > Frame Debugger.

Click Enable. If the editor doesn't automatically switch you to the Game tab, select it now.

Now that the frame debugger is enabled, in the left pane, select Camera.Render > Drawing

After that, in the left pane, open the RenderTarget dropdown. Change it from RT0 to Depth.

You'll see something like this in Game view.

Spooky! But you can also see how what we see correlates to depth. The closer to black, the farther away you can go from the camera without hitting something. 

Anyway, if you were following along with the Frame Debugger, you can now disable it. Let's unpack that depth texture. 

Back in the shader, we'll declare the depth texture and a few more variables.

In the frag shader, underneath where the main texture is unpacked, add this

float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, i.texcoord);
depth = Linear01Depth(depth);

This correctly extracts the red channel value from the depth texture and converts it to a number between 0 and 1. 

For now, we'll use the depth to determine the final color based on how far away something is from the camera. We'll figure out the toon shading part after that.

Now we'll add in those other variables we declared so that we can control the range of the effect.

float range = ((depth * _ProjectionParams.z) - _ProjectionParams.y) * _Range;

_ProjectionParams is defined in StdLib.hlsl and includes this helpful comment

We can use this to determine the min/max distance from the camera. Z is the farthest, Y is the closest. So the difference is the range we have to play with!

Under where we calculated the range, put

range = smoothstep(0.25, lerp(0.25, 1, _Falloff), range);

You'll recognize that pattern from loads of tutorials we've done before. That's how we'll control the hardness of the transition.

Wrap up the frag function by returning a blend of the main texture (which, if you recall from past lessons, is what the camera saw before rendering this effect) and the main texture multiplied by _Color.

return lerp(col, col * _Color, range);

But when you save and go back to Unity, everything will be blacked out! That's because we haven't written any value into _Color so it's defaulted to black. Let's fix that!

In DepthToon.cs, we'll add some parameters to the settings like this:

In DepthToonRenderer, add this line so that we can be sure the Camera will write to the depth texture (which we want because we are using the depth texture!)

public override DepthTextureMode GetCameraFlags() => DepthTextureMode.Depth;

And in the Render method we'll send those parameters to the shader like this

Save and go back to Unity and change the color on the effect. It works!

Although you'll notice the range in which the effect interacts in any meaningful range with our scene is really tiny. We don't have to change the min/max of our slider. Instead we can tweak how we handle range inside the shader.

put range = saturate(exp2(-range * range)); before the value is smoothed. 

This looks like a regular exponent, but it's actually unusual. range * range gets us the square of range, but -range * range gets us the negative square of range. This is relevant because what exp2 does is raise 2 to the power of the number inside the parenthesis. And negative exponents don't show how many times to multiply a number, they show how many times to divide a number. In other words, they result in numbers that get smaller exponentially.

Full disclosure, I totally copied this range calculation from Unity's own fog shader! But if it's good enough for Unity, it's good enough for us!

Now the Range slider in our effect is much more useful!

Post Process Toon Shading

Now for fun with toon shading! You'll note my scene's already toony, but we can toon it up differently as a post process. You'll see.

We'll declare a few more variables in our shader. Specifically, the textures for GBuffer 0 and GBuffer 2, which are albedo and world normals, respectively. (World normals are the direction something is pointing in in world space, as opposed to local space.)

Then in the frag function we'll use the gbuffer textures to do some basic toon shading

Note that we don't have to "unpack" the normals here after getting them from the texture the way we have to do when we use a normal map in other shaders because these normals are coming to us already pre-unpacked and ready to go.

Down where we handle the range, we'll invert the _Range value we feed in from the slider so that a larger value from the slider corresponds to higher away from the camera. And we'll use that step function to keep the effect from doing anything to the skybox.

If the depth is bigger than 0.9999 (so it's farther away than 99.99% of the entire possible distance) then notSky will be 0, meaning the original unaffected color will definitely be returned. Scene objects aren't gonna register as being that far away, so that means this'll only exclude the skybox.

Let's go back to DepthToon.cs to add the controls for these new parameters!

A note about using RenderSettings.sun here. Because we're using HLSL, we can't use the UnityDeferred library to retrieve the light direction. In the past we've attached a script to the main directional light to feed our shaders a global shader variable, but I figured I'd highlight an alternate approach. If you write the direction like this, you don't have to attach a special script to your main directional light, instead you have to make sure it's in the Sun Source slot in the Lighting tab.

Once you've made those changes, save and head back to Unity.

It works!

 It's cool, but it needs a last finishing touch to really work. The depth based falloff is this straight horizontal line. It's not that impressive, it looks like a blend you could do by distance from camera alone, no depth values involved. What would look cool is if there was like, a more obviously 3d zone of tooniness. Let's make this effect happen inside a sphere radius!

We just need a few more tweaks. In order to have a proper sphere, we'll need to know the world position of whatever pixel we're shading. And to do that, we'll need to be able to convert from camera space to world space. And to do that, we'll need the camera's inverse projection matrix. (A projection matrix is something that tells the camera how to convert all the verts into something sensible on a 2d screen!) Fortunately for us, that's pretty easy to come by. I'll show you how!

In the shader, add these:

Then we'll make a few key tweaks to the part of our frag function where we get the depth.

What's going on with the uvs from texcoord is that I'm using clip space position (the position on screen) and the inverse matrix (the directions on how to get from screen space to world space) to get the world position. Then the distance is inverted so that it's bigger the closer the point is to the camera.

Then I'm multiplying our final blend, range, by distance, so that it makes the final range smaller -- creating a sphere around the camera position. The _Size vector allows some tweaking of how tall and wide this toony sphere is.

Now we just have to feed these values into the shader and we'll be home free!

We can have separate Height and Width parameters so we can have sliders for them, then combine them into a single vector to send to the shader. And we won't need a parameter for the matrix, we get that directly from the camera.

Save and enjoy your magic ball of tooniness!

Adding back cast shadows

We are missing cast shadows though. But if this is the same scene you were using the last time we made a post effect, you should still have that Sun script attached to your main light. And that script is writing the shadowmap to a global shader variable. While we're here, might as well throw it in too! Declare the global Screen Space Shadows texture, and add an int we'll use as a cheap true/false.

Then before the final diffuse mix, inset the line

selfShadow *= tex2D(_GlobalScreenSpaceShadows, i.texcoord).r * _CastShadows + (1 - _CastShadows);

In the effect script, add a bool parameter to the settings class,

Then use it to write a 1 (true) or a 0 (false) to the _CastShadows value.

After you save everything, just tick the box and you've got cast shadows back!

Easy! 

The shader and script we made today are attached to this post. If you have any questions or want to share what you come up with, let me know in the comments here, on Twitter, or in Discord. And if this tutorial helped you out, please consider becoming a patron!  

This tutorial is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike4.0 International License. The code shared with this tutorial is licensed under a CreativeCommonsAttribution 4.0 International License. 


Team Dogpit released this post 2 days early for patrons. Become a patron

Become a patron to

36
Unlock 36 exclusive posts
Be part of the community
Connect via private message