Gaussian Blur is an extremely useful trick that all technical artists should keep in their box of tricks. Surprisingly, Unity doesnāt come with its own blur effect, besides a Depth of Field effect, which is a blur based on distance from the camera. Sometimes, you want to blur the entire screen uniformly, so itās up to us to build that effect ourselves. In this tutorial, Iāll create a post processing effect in URP for Unity 2021.
In a previous article, I used Unity 2022ās new Fullscreen Shader Graph to create an outline post process. However, it doesnāt support multi-pass effects (spoiler alert: Iāll be using a two-pass blur technique), nor can you use Fullscreen Shader Graph in Unity 2021. So here we are, with URPās code-based Scriptable Renderer Features as our best option.
Check out this tutorial over on YouTube too!
Gaussian Blur Basics
You may have heard of the Gaussian curve being described as a ābell curveā. It has a distinctive shape, controlled by two parameters: the standard deviation
, Ļ, which controls how spread out the curve shape is, and the mean
, Ī¼, which controls the positioning of the curve away from 0 along the x-axis. The ātailsā of the curve never quite reach zero, although they get very close.
Itās possible to extend the Gaussian function into an extra dimension, which sort of looks like youāve draped a tablecloth over something, but itās the same idea: a mean, which is now two-dimensional, and a spread value.
(image from: Wikipedia)
How does this help us create a blur effect? If we take an image, for each pixel, we can overlay a small grid of weight values based on Gaussian curve values - thereās a large weight value in the center which gets smaller as you go towards the edge of the grid. If we multiply each pixel color by its associated weight value, sum the new colors, and assign the result back to the center pixel on a fresh new image, then carry out the same process on every pixel of the source image, we end up with a blurred image. This process is called convolution, and the idea is that the weights add up to about 1 so we end up mixing lots of the center pixel color with smaller bits of the nearby pixelsā colors, so pixels bleed into each other a bit.
The resultant image depends on the shape of the Gaussian curve - higher spread means a more blurred image. Thereās an alternative called box blur which instead uses 1 over the number of grid pixels as the weight for every pixel, but you end up with slightly uglier āboxyā artifacts along edges with this method.
A nice property of the Gaussian blur (and the box blur, actually) is that it is linearly separable. Instead of using an n-by-n grid over each pixel, which requires n^2 operations, we can use a 1-by-n grid and run it across the image horizontally, then use an n-by-1 grid with the same values and run it across the resulting image vertically. With those two passes, you end up with an identical result with only 2n operations. The savings you get going from n^2 to 2n are huge, and the savings only get better when n increases.
In theory, the grid would have to be infinitely large (or at least the same size as the image) because the Gaussian curve never touches the axis so pixels far from the center pixel have tiny, but non-zero, weights. In practice, most of those weights are so close to zero that they can be ignored. I found that a grid size of about 6Ļ works well (and Wikipedia agrees), then Iāll round up to the next pixel so the grid has a center pixel.
Creating the Blur Effect
Post processing effects in URP are a bit overly complicated in code, but itās all we have if we need more control than Unity 2022ās Fullscreen Shader Graph. URP Renderer Features are the magic sauce that power post processing effects in Unity, including those in my premium Snapshot Shaders Pro asset pack. Weāll need three scripts for this effect: BlurSettings
holds the shader properties that we can tweak to our liking, BlurRendererFeature
handles injecting our custom code into the rendering loop, and BlurRenderPass
which deals with creating render textures and actually running a material over the image, then of course thereās the Blur
shader file which contains the horizontal and vertical passes. Thatās a lot to get through!
BlurSettings
Start by creating a new C# script by right-clicking and going to Create -> C# Script, and name it BlurSettings
. First, weāll need to import a couple of extra namespaces: UnityEngine.Rendering
, and UnityEngine.Rendering.Universal
, which I hope are self-explanatory. Then, Iāll change the inheritance of the script from MonoBehavior
to VolumeComponent
and IPostProcessComponent
. VolumeComponent
is going to make our effect compatible with the volume system whereby we can choose to make the effect run globally or only within a certain collider; without it, the blur would just run constantly with no easy option to turn it off. Weāll need to add a couple of attributes so that weāre able to choose our blur effect in the volume effect drop-down, so add System.Serializable
and VolumeComponentMenu
with whatever path you want. You can include folders in the name, but Iām going to stick with just āBlurā.
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
[System.Serializable, VolumeComponentMenu("Blur")]
public class BlurSettings : VolumeComponent, IPostProcessComponent
{
}
This script is where we will add our properties that control the behavior of the blur effect. We only need one property to control the shape of the Gaussian curve: a public ClampedFloatParameter
named strength. This takes three parameters: the initial float value, and minimum and maximum values. In the Inspector, this will be represented as a slider that can only take on values between the min and max. Iāll set the default as 0 and clamp it between 0 and 10. Itās good practice to set your properties to a neutral default value so you donāt add the effects to your game and suddenly get a jarring visual change. There are other kinds of Parameter
type if you need them, Clamped
or otherwise, like IntParameter
, ColorParameter
, TextureParameter
and so on. Try and clamp them if you can so you donāt get people trying to set a strength value of 5 million. Iāve also added a Tooltip
attribute that will pop up whenever someone hovers over this field in the Inspector.
public class BlurSettings : VolumeComponent, IPostProcessComponent
{
[Tooltip("Standard deviation (spread) of the blur. Grid size is approx. 3x larger.")]
public ClampedFloatParameter strength = new ClampedFloatParameter(0.0f, 0.0f, 15.0f);
}
The IPostProcessComponent
interface requires us to add two methods called IsActive
and IsTileCompatible
. IsActive
is used to determine whether the effect uses valid variable values, so in this case, the effect should run only if the strength is above 0. We also have access to a Boolean called active
which is true if the effect is ticked on and false if not, so weāll include that here.
The other method is called IsTileCompatible
, and Iām not entirely sure what it does. Unity says āif it can run on-tileā which I figured might have something to do with tile-based rendering on certain GPUs, but itās also marked Obsolete
for Unity 2023 onwards - if Unity are planning on throwing it in the bin, then it canāt be that important. I just return false and donāt look back, it hasnāt broken anything so far.
public class BlurSettings : VolumeComponent, IPostProcessComponent
{
[Tooltip("Standard deviation (spread) of the blur. Grid size is approx. 3x larger.")]
public ClampedFloatParameter strength = new ClampedFloatParameter(0.0f, 0.0f, 15.0f);
public bool IsActive()
{
return (strength.value > 0.0f) && active;
}
public bool IsTileCompatible()
{
return false;
}
}
Thatās the BlurSettings
script done, so we can move on to the largest of the three scripts, BlurRenderPass
.
BlurRenderPass
We need to import the same namespaces as before, and this time, the inherited type is ScriptableRenderPass
. This script is the ābrainā of our effect, as it handles creating textures and materials, and tells Unity how to apply the effect. This class requires us to override just one method called Execute
, but we will optionally also override Configure
and FrameCleanup
. Iām also going to add my own method called Setup
.
This script will require a few variables, all of which can stay private: a Material
, which will hold our blur shader, a reference to our BlurSettings
, a RenderTargetIdentifier
called source
, which is basically the screen texture before we apply our shader, a RenderTargetHandle
called blurTex
, which is an intermediate texture that holds the result of the first of two blur passes, and an int
called blurTexID
which Iāll explain shortly.
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class BlurRenderPass : ScriptableRenderPass
{
private Material material;
private BlurSettings blurSettings;
private RenderTargetIdentifier source;
private RenderTargetHandle blurTex;
private int blurTexID;
public bool Setup(ScriptableRenderer renderer)
{
}
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
}
public override void FrameCleanup(CommandBuffer cmd)
{
}
}
The Setup
method comes first. It does some one-time setup for things that need to be ready before BlurRenderPass
can do anything, but it needs access to a ScriptableRenderer
object, which is the glue that holds together all the features supported by the renderer, the lighting, and the textures output by the camera. Christ, thereās so many types to keep track of with similar names. This isnāt automatically called by Unity, so we will end up calling this method manually later.
In our Setup
method, weāll retrieve the cameraColorTarget
from the renderer
, which is the camera output texture. Weāll grab a reference to our BlurSettings
script from the VolumeManager
- if you have several volumes in your scene using a Blur
effect, then this GetComponent
method gets the correct one. Next, weāll set the renderPassEvent
. Essentially, we can make our Blur effect run at a handful of predetermined points in the rendering loop. For a post-processing effect, like weāre making, youāll pick either BeforeRenderingPostProcessing
or AfterRenderingPostProcessing
, which are confusing names because we are doing post processing. The Before
and After
in these names are referring to URPās already-included post processing effects like Bloom
or Depth of Field
. Iām choosing Before
, but if youāre ever writing an effect and it doesnāt work, swap it to After
and that sometimes makes all the scary bugs go away.
Lastly, weāll create a material to run our shader, but first, we need to check that blurSettings
is not null, meaning the camera is inside a volume that contains a Blur effect, and that the effect is active - it has a strength above 0. If so, weāll find the shader using its name. Obviously, we havenāt written the shader yet, but PostProcessing/Blur
is the name I will be using. You might have noticed that the return type of the method is bool
, so Iāll return true if we successfully created a material, and false if not.
public bool Setup(ScriptableRenderer renderer)
{
source = renderer.cameraColorTarget;
blurSettings = VolumeManager.instance.stack.GetComponent<BlurSettings>();
renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
if (blurSettings != null && blurSettings.IsActive())
{
material = new Material(Shader.Find("PostProcessing/Blur"));
return true;
}
return false;
}
Next up is the Configure
method override. This gets called each frame just before applying the effect, and we use it to set up any temporary resources required during this frame. It takes in a CommandBuffer
and a RenderTextureDescriptor
as parameters; the CommandBuffer
is a list of instructions for the GPU to carry out, and the RenderTextureDescriptor
describes the size of and sort of information stored in a texture, in this case the camera texture. Inside the method, Iāll stop running any code immediately if thereās no valid blur effect. If there is, Iāll create a temporary texture with an ID _BlurTex
and exactly the same properties as the camera texture. Lastly, weāll call base.Configure
, which runs whatever code the base ScriptableRenderPass
class has in its Configure
method.
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
if (blurSettings == null || !blurSettings.IsActive())
{
return;
}
blurTexID = Shader.PropertyToID("_BlurTex");
blurTex = new RenderTargetHandle();
blurTex.id = blurTexID;
cmd.GetTemporaryRT(blurTex.id, cameraTextureDescriptor);
base.Configure(cmd, cameraTextureDescriptor);
}
Next up we have the Execute
method, which has nothing to do with capital punishment - this is the core bit of code across our three classes. This method runs once per frame, and we use it to set up shader properties and apply the shader to the camera texture. It takes a ScriptableRenderContext
and a RenderingData
as parameters; the ScriptableRenderContext
is a conduit for passing our instructions to the renderer, and the RenderingData
ā¦ itās sort of in the name, I guess!
Inside the method, Iāll check again that we have valid blur effect. Then, Iāll create a command buffer using CommandBufferPool.Get
and pass in a profiler tag named āBlur Post Processā - this identifier is used by the Profiler to track the performance of our code. Next, we need to set up the shader properties that will be used for our effect. Our BlurSettings
script dealt with one property: the strength of the effect. From this, weāll derive two shader properties. If you remember my description of the Gaussian function earlier, I mentioned that a grid size of 6Ļ works well. Iāll round it up to the next integer value, and then add one if the result is even so that our grid has a central pixel. Then, to set our shader properties, itās a matter of using the Set
methods on the material. Thereās a Set
method for each type, so for the grid size Iāll use SetInteger
with the _GridSize
property, and for the spread Iāll use SetFloat
with the _Spread
property. We havenāt written the shader yet, but those are the property names Iāll be using.
Now we can run the effect over the screen. For that, we use a method called Blit
, which takes an input texture that we can read color data from, a texture weāll write the result to, and optionally, a material to run over the first texture, and also optionally, a number representing which pass inside the materialās shader to use. Our shader will use two passes, so weāre going to Blit
from the source texture (the camera texture) to our temporary blur texture using a horizontal blur pass, which has a pass index of 0, then from the blur texture back to the source texture with a vertical blur pass with an index of 1. These Blit
commands get added to our command buffer, and to actually get URP to process those commands, we use context.ExecuteCommandBuffer
and pass in the command buffer as an argument. After that, we can clear the command buffer and then call CommandBufferPool.Release
to clean up resources related to the command buffer, since weāre now done with it for this frame.
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
if (blurSettings == null || !blurSettings.IsActive())
{
return;
}
CommandBuffer cmd = CommandBufferPool.Get("Blur Post Process");
// Set Blur effect properties.
int gridSize = Mathf.CeilToInt(blurSettings.strength.value * 6.0f);
if (gridSize % 2 == 0)
{
gridSize++;
}
material.SetInteger("_GridSize", gridSize);
material.SetFloat("_Spread", blurSettings.strength.value);
// Execute effect using effect material with two passes.
cmd.Blit(source, blurTex.id, material, 0);
cmd.Blit(blurTex.id, source, material, 1);
context.ExecuteCommandBuffer(cmd);
cmd.Clear();
CommandBufferPool.Release(cmd);
}
The only method left is FrameCleanup
, which we use to free up any resources we created during this frame. Itās called at the end of the frame. You donāt often need to deal with manual memory management like this in Unity, but be warned that if you donāt clean up things like temporary textures after using them, you may encounter memory leaks and Unity will get upset with you. The only thing we need to free is the temporary blurTex
, which we can do with cmd.ReleaseTemporaryRT
- we need to pass in the textureās ID rather than the texture itself. Then we can call base.FrameCleanup
to run the default cleanup code.
public override void FrameCleanup(CommandBuffer cmd)
{
cmd.ReleaseTemporaryRT(blurTexID);
base.FrameCleanup(cmd);
}
And thatās the BlurRenderPass
script complete!
BlurRendererFeature
Now weāll tackle the third and final script, BlurRendererFeature
. This one only needs to import the UnityEngine.Rendering.Universal
namespace. The inherited type this time is ScriptableRendererFeature
, which needs us to override two methods called Create
and AddRenderPasses
. Before those, add a variable of type BlurRenderPass
to hold our pass.
using UnityEngine.Rendering.Universal;
public class BlurRendererFeature : ScriptableRendererFeature
{
BlurRenderPass blurRenderPass;
public override void Create()
{
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
}
}
The Create
method deals with creating any passes and other resources your effect requires to work. In our case, we just need to create one blurRenderPass
, but you might end up making complex effects with several passes. Iāll also change the name
variable to āBlurā, which sets the default name of the Renderer Feature when we add it to our Renderer Features list.
public override void Create()
{
blurRenderPass = new BlurRenderPass();
name = "Blur";
}
The AddRenderPasses
method handles everything to do with inserting passes into the URP rendering loop. It takes a ScriptableRenderer
and a RenderingData
as parameters - these are both types weāve seen before. This is also where Iām going to call the Setup
method on BlurRenderPass
, because this AddRenderPasses
method gets called before the Configure
and Execute
methods on BlurRenderPass
. Setup
also requires a ScriptableRenderer
as a parameter, and we conveniently have one right here. If you remember, Setup
returns true
if everything got setup successfully and false
otherwise, so weāll check the result with an if
statement, and inside the if
statement weāll use EnqueuePass
to add blurRenderPass
to our renderer
.
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
if (blurRenderPass.Setup(renderer))
{
renderer.EnqueuePass(blurRenderPass);
}
}
With that, all the C# scripting is done and we can look at how the shader works.
The Blur Shader
In the Unity Editor, Iāll create a Resources folder inside my Assets folder, then create a shader inside it via Create -> Shader -> Unlit Shader. These presets were created for the built-in pipeline so we wonāt actually use the boilerplate code provided, but we need a shader file to work with. Iāll name it Blur
. The Resources folder guarantees that anything placed within will be included in a game build, which sometimes wonāt happen if you donāt directly reference your shader in any asset contained within a scene. Using Shader.Find
doesnāt count as referencing it, so inside the Resources folder it goes.
This shader file has a few key parts: the name of the shader is PostProcessing/Blur
, then we have the Properties
block which contains all the information we send to the shader and the SubShader
that includes most of the code. Inside the SubShader
, we have a Tags
list, a HLSLINCLUDE
block where we write shader code that gets placed inside every shader pass, then after the HLSLINCLUDE
block we have the horizontal shader pass, then the vertical shader pass, and thatās it. Hereās the basic code that weāre going to fill in from top to bottom.
Shader "PostProcessing/Blur"
{
Properties
{
}
SubShader
{
Tags
{
"RenderType" = "Opaque"
"RenderPipeline" = "UniversalPipeline"
}
HLSLINCLUDE
ENDHLSL
Pass
{
}
Pass
{
}
}
}
The Properties
block contains three properties: _MainTex
is the input texture that gets passed to the shader with the Blit
method, then _Spread
and _GridSize
are the two values we set on the material. Iām going to gloss over some of the shader syntax a bit because Iāve covered it in previous tutorials and thereās a lot to get through, so if youāre completely new to shaders I would recommend reading my introduction guide to vertex and fragment shaders, then coming back here.
Properties
{
_MainTex("Texture", 2D) = "white" {}
_Spread("Standard Deviation (Spread)", Float) = 0
_GridSize("Grid Size", Integer) = 1
}
In the Tags
block Iāll set the RenderType
to Opaque
and the RenderPipeline
to UniversalPipeline
to prevent the shader being used on other pipelines.
Tags
{
"RenderType" = "Opaque"
"RenderPipeline" = "UniversalPipeline"
}
The HLSLINCLUDE
block will contain everything common to both passes of the blur effect. On top of importing the Core.hlsl
shader file and defining the e
constant (2.718 and so on), weāll have the standard appdata
and v2f
structs plus a basic vert
shader. Weāll need to define all of our variables again, including the texel size of _MainTex
. Then, Iāll write a gaussian
function which will calculate our grid values. This version of the function always assumes the mean of the Gaussian curve is zero.
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#define E 2.71828f
sampler2D _MainTex;
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_TexelSize;
uint _GridSize;
float _Spread;
CBUFFER_END
float gaussian(int x)
{
float sigmaSqu = _Spread * _Spread;
return (1 / sqrt(TWO_PI * sigmaSqu)) * pow(E, -(x * x) / (2 * sigmaSqu));
}
struct appdata
{
float4 positionOS : Position;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 positionCS : SV_Position;
float2 uv : TEXCOORD0;
};
v2f vert(appdata v)
{
v2f o;
o.positionCS = TransformObjectToHClip(v.positionOS.xyz);
o.uv = v.uv;
return o;
}
ENDHLSL
Now we can write our two shader passes. Weāll start with the Horizontal
pass by opening up an HLSLPROGRAM
block and using #pragma statements to specify the vert
function for the vertex shader and the frag_horizontal
function (which we need to now write) for the fragment shader.
Pass
{
Name "Horizontal"
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag_horizontal
float4 frag_horizontal (v2f i) : SV_Target
{
}
ENDHLSL
}
Inside the function, Iāll define a color
variable which will accumulate the weighted color values from each grid pixel, and a gridSum
value that will accumulate the weights themselves. Then, Iāll work out the upper
and lower
bounds for the grid - basically, how many pixels to the left and right Iāll sample based on the grid size.
Now I can loop over each of the pixels, and for each one, Iāll use the gaussian
function to calculate the weight, then work out a UV offset from the center pixel and use those new UVs to sample one of the grid pixels, multiply its color by the weight, and update the color
and gridSum
variables with those values. Once the loop has finished, Iāll divide color
by gridSum
because sometimes the grid weights do not sum to 1. That color
value can then be output by the shader.
float4 frag_horizontal (v2f i) : SV_Target
{
float3 col = float3(0.0f, 0.0f, 0.0f);
float gridSum = 0.0f;
int upper = ((_GridSize - 1) / 2);
int lower = -upper;
for (int x = lower; x <= upper; ++x)
{
float gauss = gaussian(x);
gridSum += gauss;
float2 uv = i.uv + float2(_MainTex_TexelSize.x * x, 0.0f);
col += gauss * tex2D(_MainTex, uv).xyz;
}
col /= gridSum;
return float4(col, 1.0f);
}
The second pass is basically identical, except the name is now Vertical
, the fragment function is now called frag_vertical
, and everything that operated in the x-direction now goes in the y-direction - especially keep an eye on the UV offset.
Pass
{
Name "Vertical"
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag_vertical
float4 frag_vertical (v2f i) : SV_Target
{
float3 col = float3(0.0f, 0.0f, 0.0f);
float gridSum = 0.0f;
int upper = ((_GridSize - 1) / 2);
int lower = -upper;
for (int y = lower; y <= upper; ++y)
{
float gauss = gaussian(y);
gridSum += gauss;
float2 uv = i.uv + float2(0.0f, _MainTex_TexelSize.y * y);
col += gauss * tex2D(_MainTex, uv).xyz;
}
col /= gridSum;
return float4(col, 1.0f);
}
ENDHLSL
}
Thatās the shader complete! Now letās set up a scene to use everything weāve created.
Adding Volumes
First, find your Universal Renderer asset. In a brand new URP project, you will find a few of these inside the Assets/Settings folder. My game uses the one named āHighFidelityā by default but yours might be different. At the bottom, click the Add Renderer Feature button and find your Blur effect. This step is important - it essentially enables the effect, although we donāt yet have any volumes in the scene which run the effect.
You can add a volume via GameObject -> Volume -> Box Volume (there are other options available). There is no volume profile attached to the volume, so click the New button and Unity will create one somewhere in your Assets folder. We can add effects to the volume directly from this Inspector window via the Add Override button, so once again, find your Blur effect in this menu and select it. Finally, tick the override option next to the strength
setting and increase it to something above 0, then whenever your camera passes through the box volume in either the Scene View of Game View, your screen will blur!
Subscribe to my Patreon for perks including early access, your name in the credits of my videos, and bonus access to several premium shader packs!
Acknowledgements
Special thanks to my Patreon backers
for April - May 2023!
Bo JP Jack Dixon kai Juan Huang Morrie Mr.FoxQC Josh Swanson Leonard Moishi Rand Alexis Lessard claudio croci Jay Liu Mikel Bulnes Ming Lei Muhammad Azman Olly J Paul Froggatt Will Poillion Zachary Alstadt ęŗ å