Tutorial 66: Post-Processing: Blur and Bloom
This tutorial requires the EASYGL or VULKAN backend. Custom Effect shaders and multiple RenderTarget2D objects are not available on the SDL_RENDERER backend.
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. CNA's Effect system allows you to set a uniform controlling the blur direction, so a single GLSL source covers both passes.
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.getBackBufferWidth();
int h = gd.getBackBufferHeight();
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);
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:
// Horizontal blur: brightRT -> blurHRT
gd.setRenderTarget(blurHRT_.get());
gd.Clear(Color::Black);
blurEffect_->Parameters()["BlurDirection"].SetValue(Vector2(1.0f, 0.0f));
blurEffect_->Parameters()["TexelSize"].SetValue(
Vector2(2.0f / gd.getBackBufferWidth(), 2.0f / gd.getBackBufferHeight()));
blurEffect_->Parameters()["Texture"].SetValue(*brightRT_);
blurEffect_->CurrentTechnique().Passes[0].Apply();
DrawFullscreenQuad(gd);
// Vertical blur: blurHRT -> blurVRT
gd.setRenderTarget(blurVRT_.get());
gd.Clear(Color::Black);
blurEffect_->Parameters()["BlurDirection"].SetValue(Vector2(0.0f, 1.0f));
blurEffect_->Parameters()["Texture"].SetValue(*blurHRT_);
blurEffect_->CurrentTechnique().Passes[0].Apply();
DrawFullscreenQuad(gd);
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.getBackBufferWidth(),
gd.getBackBufferHeight()),
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. For LDR pipelines keep the bloom intensity low (multiply the bloom texture by a factor of 0.3–0.8) to avoid over-brightening. A dedicated HDR RenderTarget (SurfaceFormat::HdrBlendable or SurfaceFormat::Rgba64) is recommended when accurate HDR bloom is needed.
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_->Parameters()["Threshold"].SetValue(0.7f);
brightEffect_->Parameters()["Knee"].SetValue(0.1f);
brightEffect_->Parameters()["Texture"].SetValue(*sceneRT_);
brightEffect_->CurrentTechnique().Passes[0].Apply();
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.getBackBufferWidth(), gd.getBackBufferHeight()),
Color::White * bloomIntensity_);
spriteBatch_->End();
gd.Present();
}