Detecting edges in images allows developers to write cartoon shaders to boldly outline objects. Typically, they would use object geometry, but we can achieve a cheap edge-detection effect using image effects. In this tutorial, we shall explore the Sobel-Feldman operator and look at bloom effects to implement the Line Drawing and Neon effects in Super Mario Odyssey.
Line Drawing
To detect edges using an image effect, letâs think about how we define an edge. Without being able to use object geometry to decide where an edge is, we will consider edges in the image to be places where the colour changes suddenly in lightness or hue. That means we will have to consider multiple pixels as we did with the Blur filters but using a different kernel. Letâs look at the Sobel filter.
A Sobel operator calculates âgradientsâ across an image and then uses the magnitude of those gradients to decide where there are edges. Since gradient calculations are inherently one-directional, we shall perform two calculations in the x- and y-directions - but this time, we thankfully wonât need to do them in separate passes, as youâll see. Each calculation involves a 3x3 kernel.
In fact, unlike the kernels we used for blurring in the last tutorial, the horizontal and vertical steps canât be combined into one matrix easily - we must complete them independently and use Pythagorasâ Theorem on the x-gradient and y-gradient to calculate an overall gradient. In theory, you can pick any pair of perpendicular directions such as the diagonals, but itâs far more convenient to pick the x- and y-directions.
Now letâs look at this in a shader - the template for this can be found in Shaders/EdgeDetect.shader
. Iâve defined a sobel()
function which will do the heavy lifting for us - the fragment shader is already complete. Running the shader now will give you a very underwhelming black screen, so letâs add some calculation to the sobel()
function.
Weâve defined accumulator variables for the horizontal (x) and vertical (y) passes, and for the sake of saving on some typing, texelSize
is its own variable. For each of the kernel values, we will want to multiply them by the corresponding pixel values, like the Blur shaders, but since we know the kernel is always 3x3, thereâs no point writing everything in a complex loop - letâs just hard-code the calculations. Between the texelSize
variable definition and the return statement, splice in this code:
x += tex2D(_MainTex, uv + float2(-texelSize.x, -texelSize.y)) * -1.0;
x += tex2D(_MainTex, uv + float2(-texelSize.x, 0)) * -2.0;
x += tex2D(_MainTex, uv + float2(-texelSize.x, texelSize.y)) * -1.0;
x += tex2D(_MainTex, uv + float2( texelSize.x, -texelSize.y)) * 1.0;
x += tex2D(_MainTex, uv + float2( texelSize.x, 0)) * 2.0;
x += tex2D(_MainTex, uv + float2( texelSize.x, texelSize.y)) * 1.0;
y += tex2D(_MainTex, uv + float2(-texelSize.x, -texelSize.y)) * -1.0;
y += tex2D(_MainTex, uv + float2( 0, -texelSize.y)) * -2.0;
y += tex2D(_MainTex, uv + float2( texelSize.x, -texelSize.y)) * -1.0;
y += tex2D(_MainTex, uv + float2(-texelSize.x, texelSize.y)) * 1.0;
y += tex2D(_MainTex, uv + float2( 0, texelSize.y)) * 2.0;
y += tex2D(_MainTex, uv + float2( texelSize.x, texelSize.y)) * 1.0;
If you look over the values, youâll see they correspond to the kernel calculations, but Iâve missed out the parts of the calculation where the kernel value is zero. Run the shader now, and you should see some lovely edge detection! Iâd suggest that if you intend to use this effect in a game as-is, then consider turning off shadows, because theyâll also be edge-detected and could look strange. Alternatively, use that as your aesthetic - be creative!
Youâll also notice the sqrt()
function that we use at the end - itâs short for âsquare rootâ, as youâd expect. This line is just doing Pythagorasâ Theorem on the independent horizontal and vertical gradients to get the overall gradient magnitude, and therefore the âedginessâ of the pixel.
return sqrt(x * x + y * y);
If you wanted it to look more like the Line Drawing effect in Super Mario Odyssey, then try inverting the colours and perhaps make the lines grey.
Neon
The Edge Detect shader was a bit of a breeze compared to the Blur shaders, so letâs take things up a notch and consider the Neon effect. Itâs easy to see this is based on Edge Detect with a bit of added colour, so as a first step letâs try multiplying the original image colours by the edge detect values. Youâll find the template in Shaders/Neon.shader
, although itâs essentially the same as EdgeDetect.shader
. Modify the fragment shader like this:
float3 s = sobel(i.uv);
float3 tex = tex2D(_MainTex, i.uv);
return float4(tex * s, 1.0);
Already itâs looking a bit neon! But if the source file has muted colours, then the result will also look fairly dull. Increasing the saturation of the colours will help inject a little more life into the scene. I wonât go into too much detail about colour theory, but weâll need to convert from RGB colour space to some other space such as HSV - which stands for âhue, saturation, valueâ - then modify the saturation and convert back to RGB. There isnât code built into the shader language or Unity to perform this conversion for us, so instead letâs use code from elsewhere. The code in that example is written in GLSL, so Iâve also converted it into HLSL so we can splice it into our shader.
// Credit for these two functions:
// http://lolengine.net/blog/2013/07/27/rgb-to-hsv-in-glsl
float3 rgb2hsv(float3 c)
{
float4 K = float4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
float4 p = c.g < c.b ? float4(c.bg, K.wz) : float4(c.gb, K.xy);
float4 q = c.r < p.x ? float4(p.xyw, c.r) : float4(c.r, p.yzx);
float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return float3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}
float3 hsv2rgb(float3 c)
{
float4 K = float4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
float3 p = abs(frac(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * lerp(K.xxx, saturate(p - K.xxx), c.y);
}
Now letâs perform the conversions. As well as modifying the saturation, I also modify the value (lightness) so that the bright colours pop even more.
float3 hsvTex = rgb2hsv(tex);
hsvTex.y = 1.0; // Modify saturation.
hsvTex.z = 1.0; // Modify lightness/value.
float3 col = hsv2rgb(hsvTex);
return float4(col * s, 1.0);
Run the shader now, and the neon colours should pop! The conversion makes pixels that were near greyscale look much more colourful than before, so the whole scene might look very different to what you were expecting.
The neon effect looks great, but we would usually expect such an effect to glow, especially the brightest parts of the image. We can do that by implementing another step on top of what weâve done so far - letâs discuss a simplified bloom shader.
Bloom
At its core, Bloom is used to make bright light sources stand out in a scene by adding a glowing effect around them. Neon sure is a bright light source! In this section, weâll introduce a powerful new concept that allows us to use a Pass from a different shader.
Letâs go over the theory behind Bloom. Weâll want to isolate the parts of the image that have a high brightness value - those are the âlight sourcesâ - then blur them a little to imitate the glowing effect. Once we have the blurred version of the image, we shall composite that image on top of the original image to obtain the final Bloom result. There are many more in-depth ways of implementing bloom, but this will suffice for our needs.
Now letâs implement this all in a shader. Open Shaders/Bloom.shader
and take a look at what weâve got - two passes, the first of which uses the same RGB-HSV conversion functions we saw back in the Neon
filter, and the second of which is just sampling a texture. You might have spotted something new, too - both new passes have names. These names are not required, but we will see an instance where we must Name
a pass very soon.
Letâs start with the first pass, named âThresholdPassâ, which is going to compare the brightness value with some threshold value. Letâs define this in Properties
.
// Properties.
_Threshold("Bloom Threshold", Range(0, 1)) = 0.5
// Variable declarations.
float _Threshold;
Now, instead of returning the original texture unscathed, weâll do a comparison between the brightness of the pixel and the threshold. We can use the ternary operator in shader language, just like you would in C#, so weâll use that to avoid lengthy if-statements and return either the original pixel or a completely black pixel.
float brightness = rgb2hsv(tex).y;
return (brightness > _Threshold) ? tex : float4(0.0, 0.0, 0.0, 1.0);
Thatâs it for the first pass. Now we should turn our attention to the script being used to control the shader, since it will act a little differently to the others we have implemented so far. Letâs create a new C# script called ImageEffectBloom.cs
and inherit from ImageEffectBase.cs
, as we did for ImageEffectGaussian.cs
:
using UnityEngine;
public class ImageEffectBloom : ImageEffectBase
{
protected override void OnRenderImage(RenderTexture src, RenderTexture dst)
{
}
}
First off, we shall need a temporary RenderTexture to hold the result of the thresholding pass. We will also define variables to keep track of the pass IDs for the shader so we donât throw too many magic numbers in our code. Then, we can use that pass ID in a Graphics.Blit()
in order to perform the thresholding step.
// Above the function.
private const int thresholdPass = 0;
// Inside the function.
RenderTexture thresholdTex =
RenderTexture.GetTemporary(src.width, src.height, 0, src.format);
Graphics.Blit(src, thresholdTex, material, thresholdPass);
Back in Unityâs Scene View, remove all Image Effect scripts from your camera for now, and attach this brand-new script. Place the Bloom shader in the shader slot and hit Play - all that should be visible are the brightest scene elements.
The second component of the Bloom effect is blurring the thresholded image. This is where weâll unlock the secrets of using Pass
es from other shaders: UsePass
. From here on, Iâll assume youâve followed Part 3 and implemented the Blur shaders the same way I have - if not, youâll have to tweak a few names.
By using UsePass we can reference other shader passes by name. To do this, weâre going to have to go back and make sure the shader passes we plan to use have names. Open the source files for the shader youâre going to use - either GaussianBlurSinglepass
or GaussianBlurMultipass
will do - and give their passes sensible names by using Name
at the top of the pass. Iâm going to do both simultaneously and add functionality to ImageEffectBloom
to switch between both.
// Singlepass:
Name "BlurPass"
// Multipass first pass:
Name "HorizontalPass"
// Multipass second pass:
Name "VerticalPass"
Iâve been very sneaky and already did this in the versions of these shaders found in the Complete
folder. Weâll now be able to reference these three shader passes inside the Bloom
shader using UsePass
. The syntax is simple - itâs UsePass
followed by the name of the shader and shader pass. Weâll put these Pass
es in between the two already in the file.
// If using single-pass blur.
UsePass "SMO/Complete/GaussianBlurSinglepass/BLURPASS"
// If using multipass blur.
UsePass "SMO/Complete/GaussianBlurMultipass/HORIZONTALPASS"
UsePass "SMO/Complete/GaussianBlurMultipass/VERTICALPASS"
The only modification you need to make is to state the name of the shader pass in all-caps, because this is the name that Unity gives to those passes internally. Itâs important to note that you should treat these as if they are full-fat passes - they will be given their own IDs - and that all Properties
or CGINCLUDE
s need to be redefined inside Bloom.shader
to work as intended. The template already copied over the properties used by the Gaussian blur filters, but to refresh your memory, here they are again.
// In Properties.
_KernelSize("Kernel Size (N)", Int) = 21
_Spread("St. dev. (sigma)", Float) = 5.0
Letâs go back over to ImageEffectBloom.cs
. We have a few new passes, so weâll add their IDs to variables with similar names to those used by the passes.
// Single-pass.
private const int blurPass = 1;
// Multi-pass.
private const int horizontalPass = 2;
private const int verticalPass = 3;
We wonât ever be using both versions of the Gaussian filter at the same time, so letâs add a âswitchâ we can use to pick the one weâre working with. Somewhere outside the class definition, letâs add an enum
that defines the two modes of operation. Alongside it, weâll also keep track of the state using a variable.
// At the top of class definition.
[SerializeField]
private BlurMode blurMode = BlurMode.MultiPass;
// Below ImageEffectBloom class definition.
enum BlurMode
{
SinglePass, MultiPass
}
If youâve never seen [SerializeField]
before, it lets us define a private variable thatâs still exposed in the Inspector in Unity. Now that weâre tracking the mode, letâs apply the blurring step based on the mode. Weâll need to tweak the material properties outside the shader, which we can do with a few functions available in Unity.
// After last Graphics.Blit().
RenderTexture blurTex =
RenderTexture.GetTemporary(src.width, src.height, 0, src.format);
// Tweak material properties.
material.SetInt("_KernelSize", 21);
material.SetFloat("_Spread", 5.0f);
if(blurMode == BlurMode.SinglePass)
{
Graphics.Blit(thresholdTex, blurTex, material, blurPass);
RenderTexture.ReleaseTemporary(thresholdTex);
}
else
{
RenderTexture temp =
RenderTexture.GetTemporary(src.width, src.height, 0, src.format);
Graphics.Blit(thresholdTex, temp, material, horizontalPass);
Graphics.Blit(temp, blurTex, material, verticalPass);
RenderTexture.ReleaseTemporary(thresholdTex);
RenderTexture.ReleaseTemporary(temp);
}
We base the number of, and IDs of, the blurring passes on the mode, like this. Now if we were to call Graphics.Blit(blurTex, dst)
and take a look at the screen output, we get a blurred version of the threshold texture, as expected. This is almost what we want, but far too blurry!
Letâs return to the Bloom
shader and fill in the final pass. This one is going to composite the original source image and the blurred threshold image together, so weâll have to make sure both are passed into the function. Weâll pass in the threshold image as the first parameter to Graphics.Blit()
, which gets passed into _MainTex
, so weâll add another variable for the source texture in this pass below the other variables.
// Texture representing the result of the bloom blur.
sampler2D _SrcTex;
Now all weâll do is sample both textures and add them together in the fragment shader - itâs as easy as that.
float3 originalTex = tex2D(_SrcTex, i.uv);
float3 blurredTex = tex2D(_MainTex, i.uv);
return float4(originalTex + blurredTex, 1.0);
The final thing we must do is pass the correct data to this shader pass and execute it. First off, add another shader pass ID constant for this final pass. Then, weâll simply put the source image into the correct shader variable and perform the final Blit()
. Do make sure you release the last temporary RenderTexture
too!
// After if-else statement.
// Set the source texture.
material.SetTexture("_SrcTex", src);
// Do the final Blit().
Graphics.Blit(blurTex, dst, material, bloomPass);
// Release the final temp texture.
RenderTexture.ReleaseTemporary(blurTex);
Now run the shader - itâs the bloom weâve been seeking all this time! We opted to write a cheap blur effect because we really donât need the highest fidelity, nor are we paying particular attention to HDR (High Dynamic Range) rendering in this example, but if youâd like to iterate on this design and create a better bloom effect, there are plenty of resources to take a look at. Good luck if you attempt something cool!
Multiple image effects
We havenât yet discussed how to run multiple image effects at once, but itâs simple - just attach multiple image effect scripts to your main camera and add the shaders you wish to run to those components. The image effects will run in order from top to bottom, so do make sure the effects are listed in the correct order! To complete our Neon Bloom effect, add an ImageEffectBase
script with the Neon
shader attached, then add an ImageEffectBloom
script below it and attach the Bloom
shader. Now our effect is looking just the way weâd like it!
Conclusion
Today we learned how to detected the edges of objects using screen-space image gradients, then took those edges and used them to implement a neon effect. A layer of bloom on top of that, obtained by using our previous work on blur shaders with UsePass
, resulted in a more refined look for the neon shader.
The next article in this series will explore some pixelated effects to emulate the NES, SNES and Game Boy filters. Together with those filters, Iâll also show you how to write a CRT TV effect to bring the effect right into the late 20th century.