Tutorial 66: Post-Processing: Blur and Bloom
What you’ll learn
- The bright-pass, blur, composite pipeline end to end.
- Separating a Gaussian blur into horizontal and vertical passes.
- Chaining render targets between passes and compositing additively.
Before you start — Tutorial 24: Post-Processing with Render Targets (the post-process pass structure), Tutorial 52: Writing Custom Shaders (ShaderEffect) (each pass is a shader) and Tutorial 22: Blend Modes and Alpha Compositing (the additive composite). Requires a renderer with render-target support and a working custom ShaderEffect.
This tutorial needs a renderer that supports several RenderTarget2D objects and executes a custom ShaderEffect — OPENGLES3 and VULKAN both do. The thirteen 2D-only renderers do not. Watch out for BGFX, which accepts a ShaderEffect and silently ignores it, so a bloom pass there composites nothing and reports no error.
Bloom pipeline overview
Bloom is a post-processing effect that makes bright areas of the scene appear to glow and bleed into surrounding pixels, simulating the way camera lenses handle very bright light sources. The standard real-time bloom pipeline has three stages:
- Bright-pass filter — render the scene to a RenderTarget, then extract only pixels above a luminance threshold into a second RenderTarget.
- Gaussian blur — blur the bright-pass result using a separable two-pass filter (horizontal, then vertical). Each pass reads one RenderTarget and writes to another.
- Additive composite — combine the blurred result additively on top of the original scene colour.
The key insight is separability: a 2D Gaussian blur of radius r can be decomposed into a 1D horizontal blur followed by a 1D vertical blur. This reduces the per-pixel sample count from O(r²) to O(r), which is essential for real-time performance.
Gaussian blur with two-pass (H + V)
A Gaussian kernel of radius 5 uses 11 samples (centre ±5). Because the kernel is symmetric we only need 6 unique weight values. The weights are pre-computed from the Gaussian formula and normalised so they sum to 1.
In the horizontal pass the sample offsets are along the X axis in texture-space: vec2(offset * texelSize.x, 0.0). In the vertical pass the same shader runs again with offsets along the Y axis. A vec2 uniform controlling the blur direction lets a single GLSL source cover both passes — set it with SetUniformVec2 between the two draws.
RenderTarget chain
Bloom requires a chain of off-screen surfaces:
sceneRT— full-resolution scene colour (SurfaceFormat::Color).brightRT— same resolution, holds only the bright-pass output.blurHRT— half-resolution (or same), holds the horizontally blurred bright pixels.blurVRT— half-resolution, holds the final blurred bloom texture.
Using half-resolution for the blur targets is an important optimisation: it halves the number of texture fetches per pass and the blurred result will be upsampled back to full resolution during the composite step anyway, so there is no visible quality loss.
// LoadContent — allocate the RenderTarget chain
auto& gd = getGraphicsDeviceProperty();
int w = gd.getPresentationParametersProperty().getBackBufferWidthProperty();
int h = gd.getPresentationParametersProperty().getBackBufferHeightProperty();
sceneRT_ = std::make_unique<RenderTarget2D>(gd, w, h,
false, SurfaceFormat::Color,
DepthFormat::Depth24Stencil8);
brightRT_ = std::make_unique<RenderTarget2D>(gd, w, h,
false, SurfaceFormat::Color, DepthFormat::None);
blurHRT_ = std::make_unique<RenderTarget2D>(gd, w/2, h/2,
false, SurfaceFormat::Color, DepthFormat::None);
blurVRT_ = std::make_unique<RenderTarget2D>(gd, w/2, h/2,
false, SurfaceFormat::Color, DepthFormat::None);
Building the two post-process shaders
Both bloom shaders in this example are ShaderEffects. Alpha.1's separate XNA/FNA Effect Framework bytecode path is available only on capable renderer builds. Here ShaderEffect takes renderer-native source and has no Parameters collection; uniforms go through SetUniformXxx() and textures through SetTexture(unit, tex), after Apply(). See Tutorial 52.
Both passes share one fullscreen vertex shader and differ only in the fragment stage. The constructor takes three arguments — the device and the two shader sources — and the strings are the source text itself, never a file path, so read the files yourself:
#include "Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp"
#include "System/IO/File.hpp"
const std::string fullscreenVert =
System::IO::File::ReadAllText("Content/Shaders/fullscreen.vert.glsl");
brightEffect_ = std::make_unique<ShaderEffect>(
gd, fullscreenVert,
System::IO::File::ReadAllText("Content/Shaders/brightpass.frag.glsl"));
blurEffect_ = std::make_unique<ShaderEffect>(
gd, fullscreenVert,
System::IO::File::ReadAllText("Content/Shaders/gaussblur.frag.glsl"));
// Neither constructor throws on a compile failure -- this is the only signal.
if (!brightEffect_->IsEffectValid() || !blurEffect_->IsEffectValid()) {
// The GLSL did not compile. Do not draw with it.
}
// Sampler unit assignments, set once. Apply() binds the program first;
// SetUniformXxx() writes into whatever program is currently bound.
brightEffect_->Apply();
brightEffect_->SetUniformInt("Texture", 0);
blurEffect_->Apply();
blurEffect_->SetUniformInt("Texture", 0);
Bright-pass threshold shader
The bright-pass fragment shader computes the luminance of each pixel and discards those below a threshold. A smooth knee function (instead of a hard cut) avoids aliasing at the boundary:
// brightpass.frag
uniform sampler2D Texture;
uniform float Threshold; // e.g. 0.7
uniform float Knee; // soft knee width, e.g. 0.1
in vec2 vTexCoord;
out vec4 fragColor;
void main() {
vec4 color = texture(Texture, vTexCoord);
// Perceptual luminance
float lum = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
// Soft knee: remap luminance around threshold
float rq = clamp(lum - Threshold + Knee, 0.0, 2.0 * Knee);
float weight = (Knee > 0.0)
? (rq * rq) / (4.0 * Knee + 0.00001)
: step(Threshold, lum);
fragColor = color * weight;
}
Two-pass Gaussian blur shader
The separable blur shader is parameterised by a BlurDirection uniform so the same GLSL covers both the horizontal and vertical passes:
// gaussblur.frag
uniform sampler2D Texture;
uniform vec2 TexelSize; // 1.0 / vec2(width, height)
uniform vec2 BlurDirection; // (1,0) horizontal, (0,1) vertical
in vec2 vTexCoord;
out vec4 fragColor;
// Pre-computed Gaussian weights for radius-5 kernel (normalised)
const float WEIGHT[6] = float[](
0.227027, 0.194595, 0.121622, 0.054054, 0.016216, 0.002703
);
void main() {
vec4 result = texture(Texture, vTexCoord) * WEIGHT[0];
for (int i = 1; i < 6; ++i) {
vec2 offset = float(i) * BlurDirection * TexelSize;
result += texture(Texture, vTexCoord + offset) * WEIGHT[i];
result += texture(Texture, vTexCoord - offset) * WEIGHT[i];
}
fragColor = result;
}
In the C++ Draw loop, apply the blur in two passes. Both write into whichever render target is currently bound, so wrap one pass in a helper and call it twice:
// Run one separable blur pass into the currently bound render target.
// Apply() binds this effect's compiled program; SetUniformXxx() and
// SetTexture() write into whatever program is bound, so the order is mandatory.
void ApplyBlur(GraphicsDevice& gd, Texture2D& src, Vector2 direction) {
blurEffect_->Apply();
// SetTexture takes a reference; RenderTarget2D derives from Texture2D.
// Unit 0 is the one "Texture" was pointed at in LoadContent().
blurEffect_->SetTexture(0, src);
blurEffect_->SetUniformVec2("BlurDirection", direction.X, direction.Y);
// Both blur targets are half-resolution, so one texel size covers both passes.
blurEffect_->SetUniformVec2("TexelSize",
2.0f / gd.getPresentationParametersProperty().getBackBufferWidthProperty(),
2.0f / gd.getPresentationParametersProperty().getBackBufferHeightProperty());
DrawFullscreenQuad(gd);
}
// Horizontal blur: brightRT -> blurHRT
gd.SetRenderTarget(blurHRT_.get());
gd.Clear(Color::Black);
ApplyBlur(gd, *brightRT_, Vector2(1.0f, 0.0f));
// Vertical blur: blurHRT -> blurVRT
gd.SetRenderTarget(blurVRT_.get());
gd.Clear(Color::Black);
ApplyBlur(gd, *blurHRT_, Vector2(0.0f, 1.0f));
Additive blending composite
The final composite step draws the blurred bloom texture over the original scene using additive blending. CNA's BlendState::Additive sets SourceBlend = One and DestinationBlend = One, which simply adds the RGB values:
// Composite: draw scene, then add bloom on top
gd.SetRenderTarget(nullptr); // back buffer
gd.Clear(Color::Black);
// Draw original scene
spriteBatch_->Begin(SpriteSortMode::Immediate, BlendState::Opaque);
spriteBatch_->Draw(*sceneRT_, Vector2::Zero, Color::White);
spriteBatch_->End();
// Add bloom additively
spriteBatch_->Begin(SpriteSortMode::Immediate, BlendState::Additive);
spriteBatch_->Draw(*blurVRT_,
Rectangle(0, 0,
gd.getPresentationParametersProperty().getBackBufferWidthProperty(),
gd.getPresentationParametersProperty().getBackBufferHeightProperty()),
Color::White);
spriteBatch_->End();
Tone mapping note
If your scene renders in HDR (values above 1.0), apply a tone mapping operator after the bloom composite but before the final blit to the swap chain. A simple Reinhard tone map is:
// In the composite/tonemapping shader
vec3 hdr = sceneColor.rgb + bloomColor.rgb;
// Reinhard
vec3 ldr = hdr / (hdr + vec3(1.0));
// Optional: gamma correction
fragColor = vec4(pow(ldr, vec3(1.0 / 2.2)), 1.0);
Without tone mapping, additively blended bloom will clip to white in LDR targets. Keep the bloom intensity low (multiply the bloom texture by a factor of 0.3–0.8) to avoid over-brightening.
This portable bloom chain is LDR. The EasyGL/Vulkan tutorial configurations defer to the framework's SurfaceFormat::Color-only gate, so every stage is RGBA8. Skia promotes HdrBlendable/Rgba64 and IGL promotes Single, but neither makes an HDR chain portable across the shader-capable renderers used here. Encode headroom explicitly if you need one cross-renderer implementation.
Putting it all together
A complete per-frame bloom Draw sequence looks like this:
void Draw(const GameTime& gameTime) override {
auto& gd = getGraphicsDeviceProperty();
// 1. Render scene to off-screen target
gd.SetRenderTarget(sceneRT_.get());
gd.Clear(Color::CornflowerBlue);
DrawScene(gd);
// 2. Bright-pass filter
gd.SetRenderTarget(brightRT_.get());
gd.Clear(Color::Black);
brightEffect_->Apply(); // bind the program before setting anything
brightEffect_->SetTexture(0, *sceneRT_);
brightEffect_->SetUniformFloat("Threshold", 0.7f);
brightEffect_->SetUniformFloat("Knee", 0.1f);
DrawFullscreenQuad(gd);
// 3. Horizontal Gaussian blur
gd.SetRenderTarget(blurHRT_.get());
gd.Clear(Color::Black);
ApplyBlur(gd, *brightRT_, Vector2(1.0f, 0.0f));
// 4. Vertical Gaussian blur
gd.SetRenderTarget(blurVRT_.get());
gd.Clear(Color::Black);
ApplyBlur(gd, *blurHRT_, Vector2(0.0f, 1.0f));
// 5. Composite to back buffer
gd.SetRenderTarget(nullptr);
gd.Clear(Color::Black);
spriteBatch_->Begin(SpriteSortMode::Immediate, BlendState::Opaque);
spriteBatch_->Draw(*sceneRT_, Vector2::Zero, Color::White);
spriteBatch_->End();
spriteBatch_->Begin(SpriteSortMode::Immediate, BlendState::Additive);
spriteBatch_->Draw(*blurVRT_,
Rectangle(0, 0, gd.getPresentationParametersProperty().getBackBufferWidthProperty(), gd.getPresentationParametersProperty().getBackBufferHeightProperty()),
Color::White * bloomIntensity_);
spriteBatch_->End();
gd.Present();
}