Shadow Gradient Effect, Command Buffers, Implicit Operators

Last time we learned about height maps and parallaxing. In this lesson we'll learn how to make an effect for the v2 post effects stack that tints the scene's shadows with a gradient! If you don't know what a post processing effect is, you can read up on them in Lesson 20!


Open a scene in your project. Create a plane, and drag in some models or add some new shapes. If you don't already know how to do that, check Lesson 1. It doesn't matter too much what you have but you'll want to be able to see cast shadows. In my scene I've got Claire, a free model from Mixamo, and two models from the Unity 3d game kit, which is also free. I'm also using the skybox we made in Lesson 22, but that's not at all related to what we're doing today.

Also, take the time now to arrange your directional light in a way that casts some nice shadows. That IS related to what we're doing today.

Before we begin, you will need to have the v2 post effects stack installed in your project. You can read how to do that in Lesson 6.

Since writing that lesson, I have tried every version available in the package manager - only v.2.1.6 doesn't have a bunch of out-of-the-box compiler errors, so I recommend "downgrading" to that version if you haven't already.

I didn't make you do so in that lesson, but you should also now add a layer to your project dedicated to effect volumes. Click the Main Camera in the hierarchy, then expand the Layer dropdown on the right side of the inspector. Click "Add Layer..."

Then in the first editable blank field you see, type in PostProcessing or any other name that makes sense to you. Since I am calling mine PostProcessing, if you call yours something else, when I talk about the PostProcessing layer later in the lesson, what I mean is the layer you've added in this step.

Select the camera in the hierarchy again and click the Add Component button at the bottom of the inspector. Start typing Post Process Layer and click it when you see it. Change the Layer on the Post Process Layer component from Nothing to the new layer you added (if you named yours the name as mine, you'll change it to PostProcessing.)

I also recommend picking an anti-aliasing mode. FXAA is more than good enough. And we don't care about fog so you can turn that off if you like.

Right click the camera in the hierarchy and select Create Empty. This will make an empty transform parented to the camera. Rename the new GameObject to PostEffects and change its layer to PostProcessing.

Click Add Component and add a Post Process Volume. Click the New button next to the Profile slot to generate a new Post Effects profile asset. Toggle the Is Global checkbox on. We can test to see if everything is working by adding some bloom. 

aw yiss.

Getting the shadowmap

Okay so. Now that's set up, let's think about how we're gonna approach the problem. We wanna color all these shadows as a post effect. In the past we've been able to change the shadow of an object on an individual basis via a custom shader, but if we use a post effect, we won't be able to know what shader anything is using, but we still need to be able to tint shadows across the board regardless of what shader is being used. How is this possible? We can do it if we have a shadow map. ie, if we have a texture of all the shadows on screen. Well that's great and all, but how do you get one of those? We'll get it from the thing in the scene that knows where all the shadows are at -- the light!

We are going to create a component to attach to the directional light that will extract the shadows from it, then put that information into a global shader variable. That's a variable that is the same across the entire project that is written to from code. 

Begin by going into your Scripts folder (or making a Scripts folder if you don't already have one) and making a subfolder called Effects or something along those lines. Make a new C# script called Sun and open it in Visual Studio.

Setting up the Sun component

Add these usings to the top of the file, and put the class into namespace Xibanya. That'll ensure that even if you have another class in your project that has the same name as any junk I tell you to put in, there won't be a conflict.

The stuff in brackets on top of the class are Attributes, and they can give Unity or the compiler additional information about how to treat different things. The two attributes we're using here are specific to unity. 

ExecuteAlways means this script will work even outside of Play mode.  If you're already somewhat familiar with Unity dev, you may be wondering the difference between ExecuteAlways and ExecuteInEditMode. ExecuteAlways is basically the same thing but is friendlier to the new nested prefab system, so Unity recommends you use ExecuteAlways instead of ExecuteInEditMode from now on. 

RequireComponent makes the specified component required, which means if you attach this script to an empty transform it'll add the components you need automatically, and it won't let you remove them if they already exist. It doesn't matter much when the game is compiled and running, but it's helpful for keeping you on the straight and narrow during development.

Let's use the #region directive to make a foldout section to put our field declarations. Remember that for every #region you put, you have to add an #endregion or you'll get compiler errors.

The first thing I want to declare are the names of the global shader variables we'll be using. We'll declare them as constants because these should never ever change once the game has been compiled. And any time we reference them in code, we should point to these constants rather than writing them in plain string literals so that if we want to change them in code, we only have to change them in this one place.

Next we'll add these

In the case of more than one active Sun component in a scene, we'll use the one assigned to "instance" as the source for our shader globals. We'll cache all Sun components in the list on enable so that if the Instance Sun object is disabled, we can just hop to the next available sun component to avoid disruptions in shader function during async scene transitions and etc. We will also cache light color so that we can check the Instance light color against it and only write the shader global if it has changed.

You'll notice our constants are in allcaps with words separated by underscores, and our static fields start with lower case letters and use camelCase to separate words. This is a common style convention in C# and isn't mandatory, but I find it a useful convention to follow. If there's another style convention you prefer, go ahead and use it. The important thing is to stay consistent so that when you read your own code, even if you forgot what you wrote (as inevitably happens) you know what to expect. 

Under our declarations section, we can add another region called Properties

We use properties like normal variables (or, "fields") but they have "getters" and "setters" and what that means is that we can make them run special functions any time something tries to get or set a value from them. 

You'll notice I really like using the => operator. It doesn't mean "equals greater than," I think it's actually supposed to look like an arrow. It's called a "lambda" and it basically means "when you get this thing, go do that thing." So in the case of Ready, what this means is, Ready is the data type bool, which means true or false. when you go to get Ready, actually look at if Instance exists and if it's active and enabled. If both of those are true, then you get true. In other words, it's the same as if I wrote this:

I like it when my code takes up less space, so I prefer to use things like lambdas when I can, but if you don't like it, that's OK! Write the code that is the most readable to YOU!

Once you've got those in, also add this one:

Have a look at the setter (that's the part in brackets after the word "set"). The setter is a function that runs when we try to set something equal to this property, so this would run if somewhere in code we put something like LightColor = Color.white;.

 in C#, in the setter of a property, the thing that we're trying to set the property equal to will be called value. So in the example I just gave, value would refer to Color.white.

Oh, and another thing you might not have seen before, in C# you can have a long line of things that have the = operator between them. That sets them all equal to the very last thing before the semicolon. So saying lightColor = instance.light.color = value; does not set lightColor to be instance.light.color then set instance.light.color equal to value, instead it sets both lightColor AND instance.light.color equal to value. It's the same as writing this:

But it takes up slightly less space, which is why I did it. But again, if you find it confusing, there's no harm in writing it the longer way.

Okay, we've got one more property to add! Haha I know some of you aren't going to like my style on this one:

This is the same as writing

And I will be the first to admit that the way I prefer writing it looks like a load of nonsense unless you're used to that style. And what does this mean?

If there is no Instance assigned, use the RenderSettings.sun as a fallback to keep any dependent shaders working (RenderSettings.sun is the light you drag into the sun slot in the Light Settings tab)

 If there isn't a RenderSettings.sun, return because Vector3 is a struct. A struct can't be null, so we can't return null - is a built-in shortcut for 0, 0, 0, and that's the closest we can get.

Oh, and transform.forward is a shortcut for the direction that the transform is pointing in. Convenient!

Note we gave all our properties capitalized names - it's a common convention to give methods and properties capitalized names in C# to indicate what they are, so it's easier to tell at a glance that doing something like writing a value to LightColor or Direction actually causes some additional code to run.  

Command Buffers

What's a command buffer? Well a buffer is a chunk of memory reserved for something. A command buffer in Unity is a chunk of instructions we're setting aside to be run at a specific time. Unity's rendering process goes though a series of steps, and there are some things that will only work if they're done at just the right moment in the process, so we can use command buffers to queue up instructions to be done then. Because they take up memory, we have to be careful with them and get rid of them when we don't need them any more or we could eat up all the memory we have and crash Unity.

Here are the different light events available. Each event is a different phase of the rendering process and each one is an opportunity to do different things!  We want to get the Screen Space Shadows from our directional light because we'll be using this shadow map in a post effect, which is something we apply to the entire screen, so we'll be using the light event AfterScreenspaceMask. 

Before we keep working on our Sun component, we're actually gonna add another script to our project called LightBuffer. This is going to be a container we'll use to easily attach command buffers to our lights. Download LightBuffer.cs, attached to this post, into the same folder that Sun.cs is in.  

You'll note that LightBuffer is actually not a class, it's a struct, which is basically just a way to tape some other variables together. For example, a Vector3 is a struct, it's just a way to tie a float for x, y, and z together with some frequently used functions in a convenient way.

Important to remember: a struct can't be initialized and can't be made null. 

You'll also note that LightBuffer has the Serializable attribute on top. This is a general C# attribute, and it marks things as, well, serializeable, meaning it can be turned into a message that can be understood outside of the program. When data is saved to a file on your local drive, that data is being serialized. This is how Unity shows you the public fields of your code in the inspector!

We don't need a special struct to use a CommandBuffer, but I wrote this because when you have an array of serializable structs in Unity, Unity will show you all the fields of each individual entry in the inspector, so it'll let us easily add and remove command buffers to the Sun without writing additional code, because we can expand the lightBuffers array foldout and add more items and edit them directly all from the same Sun component without needing to make a custom editor!

The reason why I made LightBuffer a struct rather than a class is that it needs to be serialized to show up in the array in the inspector, but if it were a serialized class, we'd be at a risk of causing nasty errors related to recursive serialization. Basically, you don't want any of your components that inherit from MonoBehaviour (like Sun) to initialize anything else that is serialized or that inherits from MonoBehaviour when they themselves are initialized, as a class is initialized when it's instantiated. And what gets initialized when a MonoBehaviour is initialized? Any default values that happen to be classes. So if you have something like 

List<SomeMonoBehaviour> localList = new List< SomeMonoBehaviour>();

that's totally fine, because the list is initialized when the class is, but it's empty so we're good. But 

List<SomeMonoBehaviour> localList = new List<SomeMonoBehaviour>() { new  SomeMonoBehaviour() };

uh ohhhh! 'Cause if we do that, anything in new SomeMonoBehaviour() is gonna get initialized, and then anything in those things would get initialized, going down forever! But a struct, just being some glue to stick some data together, doesn't get initialized, so if we did something like this:

 List<SomeStruct> localList = new List<SomeStruct>() { new SomeStruct() }; 

we're golden.

If you look at the fields in LightBuffer, you'll find there are no default values.

Because this is a struct, we can't assign default values here because default values are initialized in a class when the class itself is initialized, but it just so happens that structs are never initialized!

I commented the file pretty well so I think you'll be able to follow it, it's basically just got functions for safely adding and removing a command buffer to a light. 

However, even though I'm being nice and not making you type all that yourself, I want you to look at this line in the Add method (line 41)

When Shader.SetGlobalColor(LIGHT_COLOR, value); is called in the setter of Sun.LightColor, that writes that global shader variable immediately, but when I put buffer.SetGlobalTexture, it does not actually write SetGlobalTexture the moment it's called. What it actually does is queues an instruction to do that, and it'll be called during the event that the command buffer is attached to.

Okay back to Sun.cs!

Sun Setup & Teardown

With that out of the way, we can finish our Declarations section. Add these right before the end of it:

In Unity, by default public fields are serialized, aka, they show up in the inspector, and private fields are not. By having a public array filled with Serializable structs, we can add whatever command buffers we want. However we only need one kind of CommandBuffer for this lesson, so we'll just set it as the single default entry for now (which means we won't actually need to mess with this in the inspector at all, but you'll have the option available if you think of other things you want to try!)

Wait didn't I just say that default values can't be initialized in a struct? Well they can't be initialized by the struct, but default values in a struct can be initialized by a class that has that struct as a default value! Haaaaaahaha oh I love programming.

Oh and check out the new keyword at the start of new private Light light; The "new" keyword is used because I want to call this field "light" but if we do that straight up, we'll have a problem. See, a script we can attach to a gameobject has to inherit from MonoBehaviour

and MonoBehaviour inherits from Behaviour

And Behaviour inherits from Component

And Component has a property called light!

But it's there due to legacy support reasons and if you try to use it you'll get errors, so it's just uselessly taking up the name but not doing anything! So if we use the "new" keyword we can reuse the name and basically cover up the useless light property completely. Good riddance.

Okay. So, now we got all our fields and properties and so on, time to add some methods to our madness!

Between our Declarations and Properties section, let's add a new section, Methods. (I generally always organize my files in Delegates-Fields-Methods-Properties order, you don't have to do that, it's just generally a good idea to pick one organization style and stick with it so you can quickly home in on what you're looking for.) Let's add our methods going over the life cycle of the Sun. (well, the Sun as we're creating it.)

cool tip: as mentioned earlier, this class inherits from UnityEngine.Object (via Component), and anything that inherits from  UnityEngine.Object can be converted into a bool that returns true if the thing exists and false if it doesn't, so if (!Instance) is the same as writing if (Instance == null) 

(I'm writing UnityEngine.Object, specifying the namespace UnityEngine, to avoid confusion with the data type "object", which is a general C# thing. See, I told you namespaces were handy!)

When we convert one data type (one class, basically) into another data type, that's called "casting". Usually you can't cast a data type to something it doesn't inherit from, and Sun doesn't inherit from bool, but Object has a thing in it called an implicit operator, and that's basically special instructions on how to cast one data type as another data type.

Because Unity is closed source, we can't see what this implicit operator looks like, but if I had to hazard a guess, it looks something like this:

So whenever we treat any object that inherits from UnityEngine.Object like a bool, it'll actually get us the result of this implicit operator function. 

If we put something like this in Sun,

then elsewhere we could automatically use any Sun object as a Vector3, like this

But we have no reason to use that, so don't actually type that in. Anyway, nifty little trick, good one to keep in your back pocket.

Anyway, back to our regularly scheduled coding. 

All we're doing here in our OnEnable function is saying, if we don't have an Instance Sun assigned already, assign this one, since it's enabled and all (I mean, this is the OnEnable function.) Then by adding this to the list, we add this as a candidate to be set as Instance if the current Instance is ever disabled. Remember that the suns list is static, which means it's not specific to any one Suns component, so you could have 100 Sun components, there would only be that one list, which makes it convenient for keeping track of everything.

Incidentally, OnEnable is called automatically when the component is enabled, it fires off after Awake and before Start


Now we'll add this.

Hey check it out, we're calling those setters on the properties we added earlier!

Anyway as for what this does, right off the bat we cache the light component if we haven't already, 'cause we're gonna reference it a lot later, and using GetComponent is somewhat expensive computationally so we don't wanna use it more than we have to. Thanks to the RequireComponent(typeof(Light)) attribute at the top of the class, we should be guaranteed to get a light here.

Once we've gotten the component, light should never be null because this class has the RequireComponent(typeof(Light)) attribute, but years of working with Unity have made me extremely paranoid, so we do have an additional check to make sure light isn't null, but if it isn't, we proceed to set up the command buffers, then set the Direction shader global by writing to the property.

Alrighty now add this one

Now that we have those, let's go back to our Instance property to update it. Change the setter to look like this:

If what we're setting isn't the same as what's already set, if we already have an Instance, then it's about to not be the Instance anymore (since we wouldn't get past the if instance != value check if the thing being set wasn't something else) So we need to remove its command buffers or else they'll just keep writing those shader globals. we do this before reassigning "instance" or else we won't have an easy way to find it again. Once that's taken care of, we can write the new value to "instance," and if the newly written value isn't null, we then need to set up the command buffers, thus we call instance.SetActive();

So the convenient thing about this setter is that we don't ever have to worry about setting up our main Sun instance anymore, assigning it takes care of the setup automatically!

Okay back up to our methods, add OnDisable:

If this is disabled, it's useless to us as a possible Instance so we'll remove it from consideration by removing it from the list.

if this is Instance and is disabled, if there is another available Sun component in the scene, it will be made the Instance so that all the shaders that rely on the globals this writes to won't experience any interruptions.  Note that it shouldn't be necessry to add "s != this" to the query because we JUST removed this Sun from the list, but I always code extremely defensively (or I am very paranoid) because when it comes to Unity, it's always better safe than sorry.

Then OnDestroy 

We need to release the command buffers because they take up memory and don't get disposed of just because the component they're added to was destroyed. (In fact the same command buffer can be shared between several components.) We don't need to worry about removing this Sun from the suns list here, because OnDisable is also called when an object is destroyed in the scene, so we know that if this is being called, OnDisable has already been called first.

Okay, still with us? Final method, I swear!

In LateUpdate, we'll be checking to see if the position/rotation has changed and updating the Direction property accordingly. LateUpdate is called after Update, and we want to use it instead of Update because it's possible that maybe in some other script's Update, it moves the sun (sunset script?) so we don't want to check its position or rotation until after that's taken care of. 

It may seem a bit much to write the light color every frame, but remember there's a check within the LightColor property against the cached lightColor so that this only writes the shader global if the color has actually changed. 

Conveniently, transforms have a hasChanged flag that gets set to true if their position/rotation gets written to, so we can use that to only write the shader global if that's actually changed. After we do that we have to manually set hasChanged back to false though, hence our doing so here.

Whew whew whew that was a heck of a writeup. We must have written a novel's length of code.  Oh was that just 110 lines? Well it felt like a novel...

Save and go back to Unity. Click the Directional Light in the hierarchy, then add our newly created Sun component!

If we did everything right, then the Light component should now show our shiny new command buffer attached to it!

We've now got shadows being written to a global shader variable that can be used by any shader, so now let's get to writing a shader that can use it!

Writing the Shadow Gradient shader

In your Assets/Shaders/PostEffects folder, (make that folder if you don't have one) add a new shader called ShadowGradient and open it in Visual Studio. Change the name up top to Hidden/Xibanya/Effects/ShadowGradient

So the shader code we've been writing this whole time has been HLSL, but it had its own features specific to Unity, and this version of HLSL is called CG. There's a newer spinoff of HLSL that Unity is trying to get everyone to switch to that they are calling HLSL. It is HLSL, but it's not "pure" HLSL that everyone outside of Unity uses when they write HLSL, but Unity calls it HLSL. I call it "Unity HLSL". Confused yet? Yeah so am I. 

So we're gonna follow best practices as recommended by Unity itself and use Unity HLSL when writing the shader for the V2 post effects stack, and supposedly that will ensure that you can use this effect in any Unity project, even the ones in the LWRP or HDRP or TTRPG or whatever the heck is going on with the new scriptable render pipelines. (But you can totally write v2 post effects in CG if you want to!)

Using Unity HLSL means we're not opening with a properties section or a CG block, we're starting straight up with the frightening-looking HLSLINCLUDE

Just copy and paste this include right in, because it's long.

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

This is basically like UnityCG.cginc that we use all the time and has a bunch of common functions and so forth in it, but for Unity HLSL and not CG.

After that, paste this in:

#define tex2D(idx, uv) SAMPLE_TEXTURE2D(idx, sampler##idx, uv)

Long story short, they got rid of the tex2D function and replaced it with a macro that's longer and harder to use, and that's dumb, so we're adding it back in so we can keep unpacking textures like always.

also paste this in

#define sampler2D(idx) TEXTURE2D_SAMPLER2D(idx, sampler##idx)

they got rid of sampler2D too and make you declare a sampler following a specific name convention along with a texture 2D and this macro basically makes it almost like before, you just have to wrap your texture name in parenthesis.

And so this is how we'll declare the three textures we'll need in this shader:

okay I try not to get editorial in here, but I think it's real dumb that they changed up the macro names only to have them do basically the same thing, to the point that it's fairly trivial to change them back. It's just extra steps for forward porting old code. Ya'll are making years of experience and documentation very inaccessible for newcomers for like no real reason! Anyway cranky rant over, we got some shadering to do! Declare three more variables here:

We can go right into writing our frag function by using the builtin vert function in the StdLib.hlsl include. If you're curious, it looks like this

You can read the whole thing here if ya like: 

Note that the TransformTriangleVertexToUV function makes sure we have the right screen space coordinates for a blit to fullscreen triangle post effect, which is what we'll be doing later. If you end up writing a custom vertex function for a v2 post effect, you'll want to make sure you use this function if you're writing it in Unity HLSL or recreate this function if you're writing it in CG.

You know what, let's add one more define to the top of our shader.

For hate's sake, I spit my last breath at thee...

Okay so, on to that frag function! We've written this to accept a gradient as a texture. First, let's figure out what our shadow color oughtta be based on our gradient.

As with the image effect we made in Lesson 20, when this shader is used as an image effect, _MainTex is auto-filled with what the camera sees, so we can think of it as the unaltered camera view. In our first lerp, we're using the gradient multiplied by the main image, but using less of the gradient if it has transparency, in order to preserve the look of transparency. This isn't a transparent effect at all, in the sense that it doesn't use alpha (note we're returning a half3 and not a half4!) but mixing in the original camera view is effectively the same as transparency.

In our second lerp, we determine to what degree we mix in the gradient without the original camera view mixed in at all. These are two different things. The alpha value of gradient determines how much the gradient color is present, _Mix determines how much the original camera capture is present. 

Next add these two lines

 We're sampling our shadow map -- it'll be in black and white, so we only need one channel, and red's as good as any. The shadow map will be in black and white, with nonshadowed areas being white, so the lower the value, the more shadowy it is. Thus in our lerp, we'll put shadowColor as the first argument, so that it's the strongest when shadow is lowest, and mainTex, the original camera capture is strongest when shadow is highest.

And the final two lines of our frag function (I broke the abs into different lines to make the screencap easier to read, you can have it all on one line)

What's going on here? If _SunDirection is 0, 0, 0, ready will be 0. If it's anything else, ready will be 1. Then we multiply our gradient tinted shadows by ready and the original camera view by (1 - ready) and add those together. That makes finalMix * ready and mainTex * (1 - ready) mutually exclusive, we'll either have our tinted shadows or we'll just have the original camera view come through. Why are we doing this though?

Well remember how we put in Sun.cs that if there is no active Sun instance, we'll write 0, 0, 0 to our global shader variable? It's unlikely that we'll ever set our Sun light to that rotation on purpose, so it's pretty safe to assume that if _SunDirection is (0, 0, 0) what that actually means is we don't have a Sun, and if that's the case we do not want to use our gradient shadows because the shadow map we get is just gonna be black, which means the effect will tint the entire screen, which we don't want. We'd rather just let the original camera capture come through. And we're using an integer 1 or 0 because if statements are comparatively expensive in shaders, so it's our way of having a conditional statement without ever actually using if!

Now if we wanted to be really safe, instead of checking against _SunDirection we'd make a special shader global just for whether or not a Sun exists, but I'm not fond of making more shader global variables than necessary because they can add up and you have to remember all of them so you don't end up getting confused and forgetting about them then experiencing unexpected behavior. Well that's my two cents anyway.

Okay that was our HLSLINCLUDE section, now for the subshader! It'll be like any other vert/frag shader we've written so far, except we'll be using HLSLPROGRAM instead of CGPROGRAM and ENDHLSL instead of ENDCG.

The whole thing will look like this when it's done. Nice and compact!

But we've got no way of lookin at it until we write the post effect, so on to the finale!

Creating a v2 Post Effect

In your Assets/Scripts/Effects folder, create a new script called ShadowGradient and open it in Visual Studio. Add the references and namespace to the top.

Make the class ShadowGradient inherit from PostProcessEffectSettings and add the attributes (the things in the square brackets) on top of the class declaration. The "Xibanya/Shadow Gradient" part specifies how this will be organized in the menu of available effects, by putting Shadow Gradient after Xibanya/ that'll put it in a submenu called Xibanya so it doesn't clutter up the list (important as you start amassing a collection of effects!)

Remember how LightBuffer had [Serializable] in front of it and this let us see it as an entry in an array in the inspector? Well we need Serializable for ShadowGradient for the same reason (ayyy I sneakily illustrated that usage for ya ahead of time. I work hard on these things...!)  and that reason is these PostProcessEffectSettings objects are the entries you see in the post effect profile! So what we're putting here effectively forms the user-facing editor for this post effect.

Also, you know how we had LightBuffer as a way of wrapping functions around a CommandBuffer and making it something we could more easily manipulate in the editor? Well PostProcessEffectSettings requires the use of wrapper classes in much the same way. Put this line in at the top of the ShadowGradient class:

[Range(0, 1)] public FloatParameter mix = new FloatParameter { value = 0.5f };

Yeah it's a float. but it's a float wrapped in that FloatParameter class! This is for Unity to do special post effects stuff with it behind the scenes. It'll maybe make more sense later. The [Range(0, 1)] attribute turns this into a slider, much like how putting Range in our shader properties creates a slider in our material editors. Remember in the shader we're using _Mix as the third argument in a lerp, so we want it to only be between 0 and 1. Also, just as we set the default values of our LightBuffer array object, by declaring this FloatParameter with a value set in brackets, we're setting its default value.

FloatParameter inherits from ParameterOverride, a generic class that allows classes that inherit from it to be of any data type. (the <T> here is a generic type placeholder.) 

Well well well, would you look at that! And you thought I was off on a rambling tangent about implicit operators before! Turns out a class that inherits from ParameterOverride can automatically be cast as the data type the child class specifies in the <T> brackets.

In the same file as ShadowGradient, add this:

This works a lot like the post effect we made in Lesson 20, but cuts right to the chase. sheet effectively functions as our material. All the properties we declare in the ShadowGradient class are available through the variable settings, which is declared in the parent class.

note we can assign settings.mix as a float, even though it is NOT a float (it's that wrapper class, FloatParameter!) because of that implicit operator doing the cast.

Now we need our gradient, so we just add a GradientParameter, right? Not so fast!  Unity's post effects stack has the most common data types built in (float, int, bool, etc) but they haven't got one for a Gradient, so we need to make it ourselves.

Making a custom ParameterOverride

Make a new folder in your project, Assets/Scripts/Effects/ParameterOverrides, then add a new script to it, GradientParameter. This class will inherit from ParameterOverride, with the data type Gradient taking the place of the T placeholder we saw.

And now for some real shader magic:

Heck yeah we just went there. With that, this class now implicitly casts to a texture made out of the gradient. We may not know how to shader around here, but we know all the cool tricks.

So now, back in ShadowGradient.cs, we can add our GradientParameter!

I came up with the default value here by making a gradient I liked and copying and pasting the color values in, but if you're lazy 

works equally well. How these gradient color keys work is this, the first argument is the color, the second is where, between 0 and 1, this color is on the gradient, with 0 being the farthest left side, and 1 being the farthest right (kinda like a lerp!) and the alpha keys work the same way (except instead of a whole color it's just the alpha value and the location on the gradient.)

With that, we can use settings.gradient to set the _Gradient property of our shader. Add this line before the BlitFullscreenTriangle:"_Gradient", settings.gradient);

(btw that's not a link, Patreon keeps autoformatting to a link but it ain't, so don't click it, it doesn't do anything!) 

Your final ShadowGradientRenderer class should look like this. Short n' sweet!

And now back to Unity to see if any of this stuff even works! Go back to your PostEffects object and add the ShadowGradient effect to it (remember, it'll be in Xibanya > Shadow Gradient as we set up in the class attributes earlier!)

Aw yeahh! 

You may notice a weird "seam" on the shadow that moves as you zoom in and out. That's the transition between shadow cascades - by default Unity uses four shadow maps of different quality and swaps out the lower quality ones based on distance. 

You can make this go away by going to Edit > Project Settings > Quality and adjusting the shadow distance to be bigger or smaller until it's at a comfortable spot based on the distance at which you're looking at your scene objects.

Back to our lovely new post effect! Note that the checkboxes being unticked next to the paramters doesn't mean that those properties are disabled, it means the default values are being used. Check the boxes and try messing around with 'em!

Heckkk yeahhh is that sweet or what?

The best part is, we just picked up a load of skills today that we can use for some even fancier tricks. Honestly, if you've stuck with me for 25 lessons and made it this far, it's probably safe to say you actually do Know How To Shader Somewhat. Nice work! Send me screenshots of the neat gradients you come up with for this, I wanna see 'em!

The shaders and scripts we made in this tutorial 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!  

Next up, Deferred!

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. 

Become a patron to

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