Tutorial 66: Post-Processing: Blur and Bloom

CNA Tutorials  ·  Advanced Rendering

ℹ

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.
  • Which renderers can run the chain, and what the HiDef profile changes.

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 3D-capable renderer with render targets that executes a custom ShaderEffect (the table below lists all 14 identities).

⚠

This tutorial needs a renderer that supports several RenderTarget2D objects and executes a custom ShaderEffect, and the shader text has to be in that renderer’s own language. The 2D-only SDL_RENDERER does not, and STUB draws nothing. Watch out for HEADLESS, which accepts a ShaderEffect, records it and never executes it, so a bloom pass there composites nothing and reports no error. FNA3D and SOFTWARE report CustomEffects false, so the hand-written route is unavailable on them; on METAL it needs an MSL port of each pass, drawn through SpriteBatch, and the blur weights cannot use the array setters, which Metal does not implement.

Renderer identities This tutorial’s custom-shader chain HDR (float) chain, HiDef
OPENGLES3, WEBGL2Runs; GLSL ES 3.00 (prepend #version 300 es and precision highp float;)If the float-target probe passes
OPENGL33Runs after switching to #version 330 coreIf the float-target probe passes
VULKANNeeds SPIR-V (GLSL text is refused)Per-format device properties decide
SDL_GPUSPIR-V, or GLSL only in a libshaderc build (Linux, Android)If the device supports the format
WEBGPUNeeds WGSLIf the device supports the format
DIRECTX11Needs HLSL (ps_5_0)If the device supports the format
DIRECTX9HLSL is compiled for SpriteBatch effects; not verified for this chainNo
METALNeeds an MSL port, drawn through SpriteBatch (no array uniform setters)Yes (HdrBlendable and the float and half formats)
FNA3D, SOFTWARENot available: CustomEffects is falseSOFTWARE has float targets but cannot run the shaders; FNA3D has none
HEADLESSAccepted, never executed (composites nothing)No
STUB, SDL_RENDERERNot available (no 3D; the 2D-only SDL_RENDERER throws on 3D calls)No
⚠

Requirements: graphics profile. CNA’s default GraphicsProfile is Reach, enforced on every renderer. The LDR chain below (RGBA8 SurfaceFormat::Color, one target bound at a time) runs on Reach, with one limit: a render target’s edge may be at most 2048 pixels, so a full-resolution scene target on a display wider or taller than that (a 4K back buffer, say) needs HiDef (4096). The HDR variant needs HiDef in any case: on Reach a request for SurfaceFormat::HdrBlendable or another float format silently becomes Color and your bloom clips. Request it in the Game constructor, before Initialize() applies your preferences:

BloomGame() : graphics_(this) {
    graphics_.setGraphicsProfileProperty(GraphicsProfile::HiDef);
    // Or for the whole project, before the Game is constructed:
    //   CNA::SetProjectGraphicsProfileEXT(GraphicsProfile::HiDef);   // "CNA/ProjectGraphicsProfile.hpp"
}

The full list of profile ceilings, and the errors you will see when one is exceeded, is in Tutorial 152: Reach vs HiDef.

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:

  1. Bright-pass filter — render the scene to a RenderTarget, then extract only pixels above a luminance threshold into a second RenderTarget.
  2. 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.
  3. 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; see the HDR variant below).
  • 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. This snapshot’s separate compiled XNA/FNA Effect Framework bytecode path is available on FNA3D always and on ten more identities only behind default-OFF CNA_*_COMPILED_EFFECTS build options. Here ShaderEffect takes renderer-native source and has no Parameters collection; uniforms go through SetUniformXxx() and textures through SetTexture(unit, tex), with Apply() called first. 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. IsEffectValid() is the quick
// check; GetCompileErrorEXT() returns the compiler log (also written to stderr).
if (!brightEffect_->IsEffectValid() || !blurEffect_->IsEffectValid()) {
    // The shader did not compile. Do not draw with it.
}

// Sampler unit assignments, set once. Apply() first is the portable order.
brightEffect_->Apply();
brightEffect_->SetUniformInt("Texture", 0);
blurEffect_->Apply();
blurEffect_->SetUniformInt("Texture", 0);

Bright-pass threshold shader

The two fragment shaders below are shown without their version header. On OPENGLES3 and WEBGL2 prepend #version 300 es and precision highp float;; on OPENGL33 prepend #version 330 core. Other renderers need the same maths in their own language (see the table above).

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() first is the portable order: it binds this effect's program before
// the SetUniformXxx() and SetTexture() calls below.
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. Be careful which additive state you mean: CNA’s BlendState::Additive preset is XNA’s, with source blend SourceAlpha and destination blend One, so it adds the RGB values scaled by the source alpha. That is wrong here, because the bright-pass output carries a fading alpha and Color::White * intensity scales alpha too. For a plain “destination plus source” composite, build a state with Blend::One on both sides. The presets are pre-bound and immutable, so construct a fresh BlendState (once, in LoadContent) and never change it after it has been used:

// LoadContent: a pure additive state, result = destination + source
addOnly_.setColorSourceBlendProperty(Blend::One);
addOnly_.setAlphaSourceBlendProperty(Blend::One);
addOnly_.setColorDestinationBlendProperty(Blend::One);
addOnly_.setAlphaDestinationBlendProperty(Blend::One);   // BlendState addOnly_; is a member

// 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, addOnly_);
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. Every stage is RGBA8 SurfaceFormat::Color, which every render-target renderer accepts. An HDR chain is possible on HiDef with the renderers that probe float targets (see the table above): ask gd.SupportsCapability(CNA::GraphicsCapability::HalfFloatRenderTargets) (or FloatRenderTargets) first, request SurfaceFormat::HdrBlendable, and read getFormatProperty() on the target you got, because a request the profile or renderer refuses silently becomes Color. Where you need one cross-renderer implementation, encode headroom explicitly in RGBA8.

// LoadContent (HiDef): an HDR scene target where the renderer can render to it
const SurfaceFormat sceneFormat =
    gd.SupportsCapability(CNA::GraphicsCapability::HalfFloatRenderTargets)
        ? SurfaceFormat::HdrBlendable
        : SurfaceFormat::Color;
sceneRT_ = std::make_unique<RenderTarget2D>(gd, w, h, false,
               sceneFormat, DepthFormat::Depth24Stencil8);
// sceneRT_->getFormatProperty() tells you what you actually got.

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, addOnly_);
    spriteBatch_->Draw(*blurVRT_,
        Rectangle(0, 0, gd.getPresentationParametersProperty().getBackBufferWidthProperty(), gd.getPresentationParametersProperty().getBackBufferHeightProperty()),
        Color::White * bloomIntensity_);
    spriteBatch_->End();
    // No gd.Present(): Game presents after Draw() returns, so a manual call
    // would present the frame twice.
}