This tutorial covers how to add support for a bloom effect to a camera. It assumes you're familiar with the material covered in the Rendering series.

This tutorial is made with Unity 2017.3.0p3.

Add this component as the only effect to the camera object. This completes our test scene.

Let's also apply this effect to the scene view, so it's easier to see the effect from a varying point of view. This is done by adding the ImageEffectAllowedInSceneView attribute to the class.

Create a new BloomEffect component. Just like DeferredFogEffect , have it execute in edit mode and give it an OnRenderImage method. Initially it does nothing extra, so just blit from the source to the destination render texture.

Normally, you'd apply tonemapping to a scene with linear and HDR rendering. You could do auto-exposure first, then apply bloom, and then perform the final tonemapping. But in this tutorial we'll focus on bloom exclusively and won't apply any other effects. This means that all colors that end up beyond LDR will be clamped in the final image.

Create a new scene with default lighting. Put a bunch of bright objects inside it, on a dark background. I used a black plane with a bunch of solid white, yellow, green, and red cubes and spheres of varying sizes. Make sure that the camera is HDR enabled. Also set the project to use linear color space, so we can best see the effect.

We're going to create our own bloom effect via a camera post-effect component, similar to how we created the deferred fog effect in Rendering 14, Fog . While you can start with a new project or continue from that tutorial, I used the previous advanced rendering tutorial, Surface Displacement , as the basis for this project.

Many people dislike bloom because it messes up otherwise crisp images and makes things appear to glow unrealistically. This isn't an inherent fault of bloom, it's simply how it happens to be used a lot. If you're aiming for realism, use bloom in moderation, when it makes sense. Bloom can also be used artistically for nonrealistic effects. Examples are dream sequences, to indicate wooziness, or for creative scene transitions.

Bloom is an effect which messes up an image by making a pixels' color bleed into adjacent pixels. It's like blurring an image, but based on brightness. This way, we could communicate overbright colors via blurring. It's somewhat similar to how light can diffuse inside our eyes, which can become noticeable in case of high brightness, but it's mostly a nonrealistic effect.

To make HDR colors visible, they have to be mapped to LDR, which is known as tonemapping. This boils down to nonlinearly darkening the image, so it becomes possible to distinguish between originally HDR colors. This is somewhat analogous to how our eyes adapt to deal with bright scenes, although tonemapping is constant. There's also the auto-exposure technique, which adjust the image brightness dynamically. Both can be used together. But our eyes aren't always able to do adapt sufficiently. Some scenes are simply too bright, which makes it harder for us to see. How could we show this effect, while limited to LDR displays?

To represent very bright colors, we can go beyond LDR into the high dynamic range – HDR. This simply means that we don't enforce a maximum of 1. Shaders have no trouble working with HDR colors, as long as the input and output formats can store values greater than 1. However, displays cannot go beyond their maximum brightness, so the final color is still clamped to LDR.

Real life isn't limited to LDR light. There isn't a maximum brightness. The more photons arrive at the same time, the brighter something appears, until it becomes painful to look at or even blinding. Directly looking at the sun will damage your eyes.

The amount of light that a display can produce is limited. It can go from black to full brightness, which in shaders correspond to RGB values 0 and 1. This is known as the low dynamic range – LDR – for light. How bright a fully white pixel is varies per display and can be adjusted by the used, but it's never going to be blinding.

Blurring

The bloom effect is created by taking the original image, blurring it somehow, then combining the result with the original image. So to create bloom, we must first be able to blur an image.

Rendering to Another Texture Applying an effect is done via rendering from one render texture to another. If we could perform all the work in a single pass, then we could simple blit from the source to the destination, using an appropriate shader. But blurring is a lot of work, so let's introduce an intermediate step. We first blit from the source to a temporary texture, then from that texture to the final destination. Getting hold of a temporary render texture is best done via invoking RenderTexture.GetTemporary . This method takes care of managing temporary textures for us, creating, caching, and destroying them as Unity sees fit. At minimum, we have to specify the texture's dimensions. We'll start with the same size as the source texture. void OnRenderImage (RenderTexture source, RenderTexture destination) { RenderTexture r = RenderTexture.GetTemporary( source.width, source.height ); Graphics.Blit(source, destination); } As we're going to blur the image, we're not going to do anything with the depth buffer. To indicate that, use 0 as the third parameter. RenderTexture r = RenderTexture.GetTemporary( source.width, source.height , 0 ); Because we're using HDR, we have to use an appropriate texture format. As the camera should have HDR enabled, the source texture's format will be correct, so we can use that. It's most likely ARGBHalf, but maybe another format is used. RenderTexture r = RenderTexture.GetTemporary( source.width, source.height, 0 , source.format ); Instead of blitting from source to destination directly, now first blit from the source to the temporary texture, then from that to the destination. // Graphics.Blit(source, destination); Graphics.Blit(source, r); Graphics.Blit(r, destination); After that, we no longer need the temporary texture. To make it available for reuse, release it by invoking RenderTexture.ReleaseTemporary . Graphics.Blit(source, r); Graphics.Blit(r, destination); RenderTexture.ReleaseTemporary(r); Although the result still looks the same, we're now moving it through a temporary texture.

Downsampling Blurring an image is done by averaging pixels. For each pixel, we have to decide on a bunch of nearby pixels to combine. Which pixels are included defines the filter kernel used for the effect. A little blurring can be done by averaging only a few pixels, which means a small kernel. A lot of blurring would require a large kernel, combining many pixels. The more pixels there are in the kernel, the more times we have to sample the input texture. As this is per pixel, a large kernel can require a huge amount of sampling work. So let's keep it as simple as possible. The simplest and quickest way to average pixels is to take advantage of the bilinear filtering built into the GPU. If we halve the resolution of the temporary texture, then we end up with one pixel for each group of four source pixels. The lower-resolution pixel will be sampled exactly in between the original four, so we end up with their average. We don't even have to use a custom shader for that. Bilinear downsampling. int width = source.width / 2; int height = source.height / 2; RenderTextureFormat format = source.format; RenderTexture r = RenderTexture.GetTemporary( width , height , 0, format ); Using a half-size intermediate texture means that we're downsampling the source texture to half resolution. After that step, we go from the temporary to the destination texture, thus upsampling again to the original resolution. Bilinear upsampling, showing interpolation for one pixel. This is a two-step blurring process where each pixel gets mixed up with the 4×4 pixel block surrounding it, in four possible configurations. Relaltive weights for indicated pixel, total 64. The result is an image that's blockier and a little blurrier that the original. Using a half-size intermediate texture. We could increase the effect by decreasing the size of the intermediate step further. Dividing dimensions by 4, 8, 16, and 32.

Progressive Downsampling Unfortunately, directly downsampling to a low resolution leads to poor result. We mostly end up discarding pixels, keeping only the averages of isolated groups of four pixels. Direcly going to quarter size eliminates 12 out of 16 pixels. A better approach is to downsample multiple times, halving the resolution each step until the desired level is reached. That way all pixels end up contributing to the end result. Downsampling to half resolution twice keeps information of all pixels. To control how many times we do this, add a public iterations field. Make it a slider with a range of 1–16. That would allow us to downsample a 655362 texture all the way down to a single pixel, which should be enough. [Range(1, 16)] public int iterations = 1; Iterations slider. To make this work, first refactor-rename r to currentDestination . After the first blit, add an explicit currentSource variable and assign currentDestination to it, then use that for the final blit and release it. RenderTexture currentDestination = RenderTexture.GetTemporary(width, height, 0, format); Graphics.Blit(source, currentDestination ); RenderTexture currentSource = currentDestination; Graphics.Blit( currentSource , destination); RenderTexture.ReleaseTemporary( currentSource ); Now we can put a loop in between the declaration of the current source and the final blit. As it comes after the first downsample, its iterator should start at 1. Each step, begin by halving the texture size again. Then grab a new temporary texture and blit the current source to it. Then release the current source and make the current destination the new source. RenderTexture currentSource = currentDestination; for (int i = 1; i < iterations; i++) { width /= 2; height /= 2; currentDestination = RenderTexture.GetTemporary(width, height, 0, format); Graphics.Blit(currentSource, currentDestination); RenderTexture.ReleaseTemporary(currentSource); currentSource = currentDestination; } Graphics.Blit(currentSource, destination); This works unless we end up with too many iterations, reducing the size to zero. To prevent that, break out of the loop before that happens. The height of a typical display is usually smaller than its width, so you can base this on the height only. Because a single-pixel line doesn't really add much, I already abort when the texture height drops below 2. width /= 2; height /= 2; if (height < 2) { break; } What about mobiles in portrait mode and other exceptions? It's good that you thought of that! If you want to support all aspect ratios, simply check both the width and the height. Progressive downsampling 2 to 5 iterations.

Progressive Upsampling While progressive downsampling is an improvement, the result still gets too blocky too fast. Let's see whether it helps if we progressively upsample as well. Iterating in both directions means that we end up rendering to every size twice, except for the smallest. Instead of releasing and then claiming the same textures twice per render, let's keep track of them in an array. We can simply use an array field fixed at size 16 for that, which should be more than enough. RenderTexture[] textures = new RenderTexture[16]; Each time we grab a temporary texture, also add it to the array. RenderTexture currentDestination = textures[0] = RenderTexture.GetTemporary(width, height, 0, format); … for (int i = 1; i < iterations; i++) { … currentDestination = textures[i] = RenderTexture.GetTemporary(width, height, 0, format); … } Then add a second loop after the initial one. This one starts one step from the lowest level. We can hoist the iterator out of the first loop, subtract 2 from it, and use that as the starting point of the other loop. The second loop goes backwards, decreasing the iterator all the way to 0. This is where we should release the old source texture, instead of in the first loop. Also, let's clean up the array here as well. int i = 1; for ( ; i < iterations; i++) { … Graphics.Blit(currentSource, currentDestination); // RenderTexture.ReleaseTemporary(currentSource); currentSource = currentDestination; } for (i -= 2; i >= 0; i--) { currentDestination = textures[i]; textures[i] = null; Graphics.Blit(currentSource, currentDestination); RenderTexture.ReleaseTemporary(currentSource); currentSource = currentDestination; } 5 iterations, with and without progressive upsampling. The results are a lot better, but still not good enough.

Custom Shading To improve our blurring, we have to switch to a more advanced filter kernel than simple bilinear filtering. This requires us to use a custom shader, so create a new Bloom shader. Just like the DeferredFog shader, begin with a simple shader that has a _MainTex property, has no culling, and doesn't use the depth buffer. Give it a single pass with a vertex and fragment program. Shader "Custom/Bloom" { Properties { _MainTex ("Texture", 2D) = "white" {} } SubShader { Cull Off ZTest Always ZWrite Off Pass { CGPROGRAM #pragma vertex VertexProgram #pragma fragment FragmentProgram ENDCG } } } The vertex program is even simpler than the one for the fog effect. It only has to transform the vertex position to clip space and pass through the texture coordinates of the full-screen quad. Because we'll end up with multiple passes, everything except the fragment program can be shared and defined in a CGINCLUDE block. Properties { _MainTex ("Texture", 2D) = "white" {} } CGINCLUDE #include "UnityCG.cginc" sampler2D _MainTex; struct VertexData { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct Interpolators { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; Interpolators VertexProgram (VertexData v) { Interpolators i; i.pos = UnityObjectToClipPos(v.vertex); i.uv = v.uv; return i; } ENDCG SubShader { … } We'll define the FragmentProgram function in the pass itself. Initially, simply sample the source texture and use that as the result, making it red to verify that we're using our custom shader. Typically HDR colors are stored in half-precision format, so let's be explicit and use half instead of float , even though this makes no difference for non-mobile platforms. Pass { CGPROGRAM #pragma vertex VertexProgram #pragma fragment FragmentProgram half4 FragmentProgram (Interpolators i) : SV_Target { return tex2D(_MainTex, i.uv) * half4(1, 0, 0, 0); } ENDCG } Add a public field to our effect to hold a reference to this shader, and hook it up in the inspector. public Shader bloomShader; Bloom effect with shader. Add a field to hold the material that will use this shader, which doesn't need to be serialized. Before rendering, check whether we have this material and if not create it. We don't need to see it in the hierarchy and neither do we need to save it, so set its hideFlags accordingly. [NonSerialized] Material bloom; void OnRenderImage (RenderTexture source, RenderTexture destination) { if (bloom == null) { bloom = new Material(bloomShader); bloom.hideFlags = HideFlags.HideAndDontSave; } … } Each time we blit, it should be done with this material instead of the default. void OnRenderImage (RenderTexture source, RenderTexture destination) { … Graphics.Blit(source, currentDestination , bloom ); … Graphics.Blit(currentSource, currentDestination , bloom ); … Graphics.Blit(currentSource, currentDestination , bloom ); … Graphics.Blit(currentSource, destination , bloom ); … } Using our custom shader.

Box Sampling We're going to adjust our shader so it uses a different sampling method that bilinear filtering. Because sampling depends on the pixel size, add the magic float4 _MainTex_TexelSize variable to the CGINCLUDE block. Keep in mind that this corresponds to the texel size of the source texture, not the destination. sampler2D _MainTex; float4 _MainTex_TexelSize; As we're always sampling the main texture and only care about the RGB channels, let's create a convenient minimal Sample function. half3 Sample (float2 uv) { return tex2D(_MainTex, uv).rgb; } Instead of relying on a bilinear filter only, we'll use a simple box filter kernel instead. It takes four samples instead of one, diagonally positioned so we get the averages of four adjacent 2×2 pixels blocks. Sum these samples and divide by four, so we end up with the average of a 4×4 pixel block, doubling our kernel size. Downsampling with a 4×4 box, showing the sample points. half3 SampleBox (float2 uv) { float4 o = _MainTex_TexelSize.xyxy * float2(-1, 1).xxyy; half3 s = Sample(uv + o.xy) + Sample(uv + o.zy) + Sample(uv + o.xw) + Sample(uv + o.zw); return s * 0.25f; } Use this sampling function in our fragment program. half4 FragmentProgram (Interpolators i) : SV_Target { // return tex2D(_MainTex, i.uv) * half4(1, 0, 0, 0); return half4(SampleBox(i.uv), 1); } 5 iterations, with 4×4 box sampling.