Tutorial 24: Post-Processing with Render Targets
What you’ll learn
- Structuring a scene-to-target-to-screen post-processing pass.
- Writing a grayscale filter as a
ShaderEffectand applying it as a full-screen pass. - Vignette and bloom, and the cost of chaining several passes.
Before you start — Tutorial 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:
- 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 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:
- 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.
// 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.