Last time we learned how to make a custom material inspector. In this lesson we're going to learn how to make a Post Processing Effect!
The shaders we've looked at until now have been on individual objects in the scene. A Post Processing Effect (often called a "post effect," or in Unity, an "Image Effect") is an effect that's applied to the entire screen at once, as if you took a screenshot of the game and ran it through a filter in photoshop. Some common post effects you've probably seen include color grading, bloom (making stuff glow!), and depth of field (making stuff blurry or sharp based on distance.)
The post effect we'll write today will be a simple color splitting effect that we can use to give the game a bit more of a glitchy retro feel. I mentioned that post effects are like running a filter on a screenshot, and that's basically how they work - we'll write a shader that lets us take what the camera sees and do things to it like with the main texture in any other shader, then use that as the final picture that actually gets shown to the player.
Open up the scene we were using last time, or make a new scene and drag your favorite models into it. (I will be using the same scene as last time.)
In your Assets/Shaders folder, make a new folder called PostEffects and in that folder make a new ImageEffect shader called ColorSplit.
Then make a new C# script also called ColorSplit.
Select your Main Camera in the Hierarchy so that you can see it in the inspector. In the Inspector, click Add Component, then start typing ColorSplit until you see the new script. Click it to add it as a component to your camera.
Great! Now let's switch to Visual Studio. If you open up the ColorSplit shader, you'll find that the template already has an effect written in. As noted, this will invert the colors.
Let's change the name up top to "Xibanya/Effects/ColorSplit" then save.
You'll notice that the name used to be "Hidden/ColorSplit". If Hidden is in the shader path, then it won't show up in the shader picker dropdown on our materials. This is usually a good idea for post effect shaders that we'll never put on a 3d object so they don't clutter up the dropdown, but I took it out for now so that we could test our shader right away. If you make a new material and put that material on a mesh with the default template code, this is what you'll get.
So the post effect is that, but to the entire screen. Since we're here, we can go ahead and bang out our shader code! Let's split those colors!
The shader itself will be pretty simple. We will sample the main texture three times with a slightly different offset for the coordinates, then combine the red channel of the first sample, the green channel of the second, and blue channel of the third together for the final result.
add _ROffset, _GOffst, and _BOffset properties up top, then declare them in the subshader.
And make the frag function look like this
We've done stuff like this before! No sweat! Save and have a peek back at Unity. You'll now have a bunch of Vector fields in the material editor.
mess around with the values and you get cool results like this!
You'll find you get the best results with small values because 1 means the entire size of the texture. Incidentally, this sort of color splitting effect is often called "chromatic aberration" because it looks a lot like the actual thing.
Hooray, shader's done! You can go ahead and delete your test object if you like. Now to figure out how to get this thing on the camera.
Normally the shader gets the main texture from a material, but cameras don't have mesh renderers or materials attached to them, so we need the script to pass what the camera sees to the shader as a texture instead.
In ColorSplit.cs, get rid of all the using's up top except using UnityEngine; and add these attributes on top of the class declaration
The ImageEffectAllowedInSceneView attribute means that once the image effect is working, it'll be applied in both Game and Scene view. This is really handy for effects like color grading where the way you place lights and other objects might depend on how everything looks in the final picture. And it's usually a good idea to leave it out for effects that are super disruptive, like something that really heavily distorts the screen. I'm assuming responsible and tasteful ColorSplit usage! Don't make me revoke your ImageEffectAllowedInSceneView privileges!
ExecuteInEditMode causes, well, the script to be executed in edit mode. Image Effects can work without this, but we're going to be calling some functions that won't work without it.
Now we'll declare some variables.
In classes that inherit from Unity's MonoBehaviour (like this one) the public variables show up as fields in the inspector by default, and private variables do not. You can add attributes to individual variables to override this, but we don't need to do that today. The private variables will be set in code. We'll use them like cubbies to stash stuff so we don't have to keep looking for the same things all the time.
Next add this method
OnPreCull will be called before the camera starts to draw anything. We're using this function to grab the stuff we need if we don't already have it. In plain terms, we're saying, if we don't have the camera, get the camera. If we don't have the shader, get the shader. If we do have the shader, and we don't have the material, create a new material with the shader attached to it.
Next add OnDisable()
This is SUPER important, and if you make image effects without it, you run a risk of eventually crashing Unity. See, we're telling Unity to make a new material for this image effect, but because of some complicated rules behind all these cubby holes we're reserving, we also need to make sure we clear out the material when we're done with it, or we could end up reserving all the available cubby hole space for Unity just for old materials we don't want anymore and cause a crash due to a lack of memory.
(If you get into making complicated multi-pass effects that use multiple render textures, don't be surprised if you get this one a lot 😩) This isn't such a big risk with what we're up to right now, but better safe than losing work from a crash!
We talked about precompiler directives in C# a little bit last time, but as a refresher, we can't have Unity Editor code outside of the Editor assembly without causing issues with a build unless it's inside one of these #if UNITY_EDITOR blocks. And the reason why we want to use editor code here is that the way we clear up the space we reserved for the material is different depending on if we're in game or not. If we're in game, we want to use "Destroy" and if we aren't, we want to use "DestroyImmediate."
You may notice that if you fire up the game and make enemies spawn, when you stop the game, the spawned enemies get cleared. But if you copy an enemy in the Hierarchy when the game isn't running, it'll stay (as long as you save the scene!) Similarly, if you use Destroy while the game is running to get rid of monsters your hero killed, the actual monster game objects will still exist when the game is done. DestroyImmediate deletes stuff for real from the files. Since the material we're making for this image effect is temporary, if the game isn't running, we want to destroy it for real.
I know that probably sounded kinda complicated, but you'll thank me later/wish you had listened after crashing unity the fifth time that day.
ANYWAY on to the magic!
Add this OnRenderImage function to your script. You'll notice you're getting two things passed in, a source and a destination RenderTexture. A RenderTexture is basically a texture Unity makes in code while running. Here, source is what the camera actually saw and destination is what the camera is going to pass along as if that's what it saw.
if (cam == null || material == null) Graphics.Blit(source, destination);
First of all, if we don't have a camera or we don't have a material, we will just blit the source to the destination. Blit? What's this blit thing? It's graphics programming jargon, it means taking the image we have in one cubby hole and moving it into another cubby hole. There are complicated technical reasons why it's like that, but we don't really care right now. You can think of it like a rubber stamp for video game pictures.
What's especially neat about Graphics.Blit is that while you can use it to just stamp a picture from one place to another, you can also blit with a material, so you actually stamp the final color from the shader instead. I guess in this analogy that's like painting colors on the stamp before you stamp it, I don't know, I'm not a crafts scientist.
So, if we blit source to destination here, we are just saying, we're gonna have what the camera actually saw and what the camera says it saw be the same thing. Wowza.
And if we DO have a camera, and we DO have a material, we can set our shader properties based on what we put into the inspector and do the blit but with the material that has our shader attached too.
Graphics.Blit(source, destination, material);
Once you've got all that, save and have a peek at Unity. Mess with the values in the inspector and see what you get.
It works! Now all that's left to do is to situate your camera somewhere good so you can take a nice screenshot!
The image effect component we made today is attached as ColorSplit.cs and the shader is attached as ColorSplit.shader. 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.