Tutorial 24: Post-Processing with Render Targets

Graphics  ·  Shaders  ·  Post-Processing  ·  GLSL

Post-processing applies a screen-space effect to the entire rendered frame. The technique is always the same: render the scene to a RenderTarget2D, then draw that texture onto the back buffer through a custom Effect shader. This tutorial builds a grayscale shader from scratch, outlines bloom and vignette, and shows how to chain multiple passes.

Post-processing pipeline

The fundamental two-pass loop:

  1. Scene pass — render all game content into sceneRT using the regular pipeline.
  2. Effect pass — draw sceneRT onto the back buffer using a full-screen quad and a custom shader.
// Pseudocode — expanded into real code in sections below
SetRenderTarget(sceneRT);   Clear; DrawWorld;
SetRenderTarget(nullptr);   DrawFullScreen(sceneRT, postFxShader);
Present();

Grayscale shader in .shader.json + GLSL

CNA shader effects are described by a .shader.json descriptor that references GLSL source files. Place these in your assets/Shaders/ folder:

// assets/Shaders/grayscale.shader.json
{
  "name": "Grayscale",
  "vertex":   "grayscale.vert.glsl",
  "fragment": "grayscale.frag.glsl",
  "parameters": [
    { "name": "Intensity", "type": "float", "default": 1.0 }
  ]
}
// assets/Shaders/grayscale.vert.glsl
#version 300 es
precision highp float;

in  vec4 a_position;
in  vec2 a_texCoord;
out vec2 v_texCoord;

uniform mat4 MatrixTransform;   // provided by SpriteBatch

void main() {
    gl_Position = MatrixTransform * a_position;
    v_texCoord  = a_texCoord;
}
// assets/Shaders/grayscale.frag.glsl
#version 300 es
precision mediump float;

in  vec2      v_texCoord;
out vec4      FragColor;

uniform sampler2D Texture;
uniform float     Intensity;   // 0.0 = full colour, 1.0 = full grayscale

void main() {
    vec4 colour = texture(Texture, v_texCoord);

    // Perceptual luminance weights (ITU-R BT.709)
    float luma = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722));
    vec3  gray = vec3(luma);

    colour.rgb = mix(colour.rgb, gray, Intensity);
    FragColor  = colour;
}

Loading and applying the shader as a post-process pass

#include <memory>
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/RenderTarget2D.hpp"
#include "Microsoft/Xna/Framework/Graphics/Effect.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;

class PostFxDemo final : public Game {
public:
    PostFxDemo() : graphics_(this) {
        graphics_.setPreferredBackBufferWidth(800);
        graphics_.setPreferredBackBufferHeight(600);
    }

protected:
    void LoadContent() override {
        auto& gd = getGraphicsDeviceProperty();
        spriteBatch_ = std::make_unique<SpriteBatch>(gd);
        worldTex_    = Content.Load<Texture2D>("Sprites/world");

        // Create a full-res render target for the scene
        sceneRT_ = std::make_unique<RenderTarget2D>(
            gd, 800, 600, false, SurfaceFormat::Color, DepthFormat::None);

        // Load the grayscale shader
        grayFx_ = Content.Load<Effect>("Shaders/grayscale");
    }

    void Update(GameTime& gt) override {
        using namespace Input;
        auto kb = Keyboard::GetState();

        // Hold G to toggle grayscale intensity
        Single target = kb.IsKeyDown(Keys::G) ? 1.0f : 0.0f;
        intensity_ += (target - intensity_) * 5.0f *
                      (Single)gt.getElapsedGameTime().TotalSeconds();
        intensity_ = std::clamp(intensity_, 0.0f, 1.0f);
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();

        // --- Scene pass ---
        gd.SetRenderTarget(sceneRT_.get());
        gd.Clear(Color::CornflowerBlue);

        spriteBatch_->Begin();
        spriteBatch_->Draw(*worldTex_, Vector2::Zero, Color::White);
        spriteBatch_->End();

        gd.SetRenderTarget(nullptr);

        // --- Post-process pass ---
        gd.Clear(Color::Black);

        // Set shader parameter
        grayFx_->Parameters["Intensity"].SetValue(intensity_);

        spriteBatch_->Begin(SpriteSortMode::Deferred,
                             &BlendState::Opaque,
                             nullptr, nullptr, nullptr,
                             grayFx_.get());   // <-- shader here
        spriteBatch_->Draw(*sceneRT_,
                            Rectangle(0, 0, 800, 600),
                            Color::White);
        spriteBatch_->End();

        gd.Present();
    }

private:
    GraphicsDeviceManager            graphics_;
    std::unique_ptr<SpriteBatch>    spriteBatch_;
    std::unique_ptr<Texture2D>      worldTex_;
    std::unique_ptr<RenderTarget2D> sceneRT_;
    std::unique_ptr<Effect>         grayFx_;
    Single                           intensity_ = 0.0f;
};

int main() { PostFxDemo game; game.Run(); }

Bloom overview

Bloom is a multi-pass effect that makes bright areas appear to glow. The technique requires:

  1. Threshold pass — extract pixels brighter than a luminance threshold into a bright-pass render target.
  2. Blur pass (horizontal) — apply a Gaussian blur horizontally to the bright-pass RT.
  3. Blur pass (vertical) — apply the Gaussian blur vertically (separable filter).
  4. Composite pass — additively blend the blurred bright-pass onto the original scene.
// assets/Shaders/bloom_threshold.frag.glsl
#version 300 es
precision mediump float;
in vec2 v_texCoord;
out vec4 FragColor;
uniform sampler2D Texture;
uniform float Threshold;   // e.g. 0.7

void main() {
    vec4 c = texture(Texture, v_texCoord);
    float luma = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722));
    // Extract only bright pixels
    FragColor = (luma > Threshold) ? c : vec4(0.0);
}

Vignette effect

A vignette darkens the edges of the screen. It can be applied as a final composite pass or baked into the grayscale shader:

// assets/Shaders/vignette.frag.glsl
#version 300 es
precision mediump float;
in vec2 v_texCoord;
out vec4 FragColor;
uniform sampler2D Texture;
uniform float Strength;   // e.g. 0.5
uniform float Softness;   // e.g. 0.45

void main() {
    vec4 colour = texture(Texture, v_texCoord);

    // Distance from centre (0,0)
    vec2  uv   = v_texCoord - 0.5;
    float dist = length(uv);

    // Smooth dark ring at edge
    float vignette = smoothstep(0.8, Softness * 0.799, dist * (Strength + Softness));
    colour.rgb *= vignette;
    FragColor = colour;
}

Combining effects

Chain effects by ping-ponging between two render targets:

// Render scene -> sceneRT
// Apply grayscale:  sceneRT -> pingRT  (grayFx)
// Apply vignette:   pingRT  -> screen  (vignetteFx)

gd.SetRenderTarget(pingRT_.get());
ApplyEffect(sceneRT_.get(),  *grayFx_);

gd.SetRenderTarget(nullptr);
ApplyEffect(pingRT_.get(),   *vignetteFx_);

// Helper function
void ApplyEffect(Texture2D* src, Effect& fx) {
    spriteBatch_->Begin(SpriteSortMode::Deferred,
                         &BlendState::Opaque,
                         nullptr, nullptr, nullptr, &fx);
    spriteBatch_->Draw(*src, Rectangle(0,0,800,600), Color::White);
    spriteBatch_->End();
}

Performance considerations

  • Each post-process pass is a full-screen draw — costs one GPU draw call and reads the entire framebuffer. Two or three passes are typically invisible on modern hardware.
  • For blur effects use a separable Gaussian (one horizontal + one vertical pass) rather than a 2D kernel — it reduces sample count from O(r²) to O(r).
  • On mobile and web, consider lower-resolution render targets for bloom (render at ½ or ¼ resolution for the blur passes).
  • Avoid post-processing on render targets with depth buffers if the effect does not need depth — depth attachments consume extra bandwidth on tile-based mobile GPUs.