Tutorial 24: Post-Processing with Render Targets

Graphics  ·  Shaders  ·  Post-Processing  ·  GLSL

What you’ll learn

  • Structuring a scene-to-target-to-screen post-processing pass.
  • Writing a grayscale filter as a ShaderEffect and applying it as a full-screen pass.
  • Vignette and bloom, and the cost of chaining several passes.

Before you startTutorial 23: Render Targets for Off-Screen Rendering (the off-screen target this depends on) and Tutorial 52: Writing Custom Shaders (ShaderEffect) — that later tutorial is where ShaderEffect is actually taught, so skim it first if the shader code here is unfamiliar. Requires a renderer with render-target support — WEBGPU has none at all.

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 as a ShaderEffect

This tutorial uses ShaderEffect. Alpha.1 can also load XNA/FNA D3D9 Effect Framework bytecode through Effect and XNB EffectReader on FNA3D or an explicitly enabled SDL_GPU, EasyGL-family or Vulkan build. That path does not compile HLSL .fx source. The example stays with renderer-native source so every assumption is visible. See Tutorial 52.

Write the two shader stages as ordinary GLSL files under Content/Shaders/. A .cnj descriptor lets ContentManager read them for you — it has exactly two shader fields, and missing either one raises a ContentLoadException:

{
  "cnjVersion": 1,
  "type": "Effect",
  "vertex": "grayscale.vert.glsl",
  "fragment": "grayscale.frag.glsl"
}

There is no "parameters" block. A ShaderEffect has no Parameters collection at all — uniforms are set by name with SetUniformFloat/Vec2/Vec3/Vec4/Mat4/Int, and extra textures with SetTexture(unit, tex).

// Content/Shaders/grayscale.vert.glsl
#version 300 es
precision highp float;

// Matches CNA's SpriteBatch vertex layout exactly.
layout(location = 0) in vec2 aPos;        // position, in pixel coordinates
layout(location = 1) in vec2 aTexCoord;
layout(location = 2) in vec4 aColor;

out vec2 v_texCoord;
out vec4 v_color;

uniform mat4 projection;   // SpriteBatch sets this on whichever program is bound

void main() {
    gl_Position = projection * vec4(aPos, 0.0, 1.0);
    v_texCoord  = aTexCoord;
    v_color     = aColor;
}
// Content/Shaders/grayscale.frag.glsl
#version 300 es
precision mediump float;

in  vec2 v_texCoord;
in  vec4 v_color;
out vec4 FragColor;

uniform sampler2D texture1;   // defaults to unit 0, where SpriteBatch binds the sprite
uniform float     Intensity;  // 0.0 = full colour, 1.0 = full grayscale

void main() {
    vec4 colour = texture(texture1, v_texCoord) * v_color;

    // 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/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/RenderTarget2D.hpp"
#include "Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp"
#include "System/IO/File.hpp"

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

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

protected:
    void LoadContent() override {
        auto& gd = getGraphicsDeviceProperty();
        spriteBatch_ = std::make_unique<SpriteBatch>(gd);
        worldTex_    = getContentProperty().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);

        // Build the grayscale shader from source text. The two strings are the
        // shader source itself -- never a file path -- so read them yourself:
        grayFx_ = std::make_unique<ShaderEffect>(
            gd,
            System::IO::File::ReadAllText("Content/Shaders/grayscale.vert.glsl"),
            System::IO::File::ReadAllText("Content/Shaders/grayscale.frag.glsl"));

        // The constructor does not throw on a compile failure -- this is the only signal.
        if (!grayFx_->IsEffectValid()) {
            // The GLSL did not compile. Do not draw with it.
        }
    }

    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.getElapsedGameTimeProperty().getTotalSecondsProperty();
        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);

        // Apply() binds this effect's compiled program; SetUniformXxx() writes into
        // whatever program is currently bound, so the order matters. SpriteBatch then
        // binds that same program for the batch, so the value survives to the draw.
        grayFx_->Apply();
        grayFx_->SetUniformFloat("Intensity", 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_;
    Texture2D        worldTex_;
    std::unique_ptr<RenderTarget2D> sceneRT_;
    std::unique_ptr<ShaderEffect>   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.
// Content/Shaders/bloom_threshold.frag.glsl
#version 300 es
precision mediump float;
in vec2 v_texCoord;
out vec4 FragColor;
uniform sampler2D texture1;
uniform float Threshold;   // set with fx->SetUniformFloat("Threshold", 0.7f)

void main() {
    vec4 c = texture(texture1, 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:

// Content/Shaders/vignette.frag.glsl
#version 300 es
precision mediump float;
in vec2 v_texCoord;
out vec4 FragColor;
uniform sampler2D texture1;
uniform float Strength;   // fx->SetUniformFloat("Strength", 0.5f)
uniform float Softness;   // fx->SetUniformFloat("Softness", 0.45f)

void main() {
    vec4 colour = texture(texture1, 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 -- each ShaderEffect owns its own compiled program, so keep
// both alive for the lifetime of the game rather than rebuilding them per frame.
void ApplyEffect(Texture2D* src, ShaderEffect& 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.