Tutorial 24: Post-Processing with Render Targets
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:
- Scene pass — render all game content into
sceneRTusing the regular pipeline. - Effect pass — draw
sceneRTonto 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:
- Threshold pass — extract pixels brighter than a luminance threshold into a bright-pass render target.
- Blur pass (horizontal) — apply a Gaussian blur horizontally to the bright-pass RT.
- Blur pass (vertical) — apply the Gaussian blur vertically (separable filter).
- 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.