Tutorial 62: Multiple Render Targets (MRT)
EasyGL and Vulkan backends only. MRT requires GL_ARB_draw_buffers (core since OpenGL 2.0) on the EasyGL backend, or multiple framebuffer attachments on Vulkan. The SDL_RENDERER backend does not support MRT and will throw if SetRenderTargets is called with more than one binding.
What is MRT?
Multiple Render Targets (MRT) is a GPU feature that lets a single fragment shader invocation write to several textures simultaneously. Normally, the output of the fragment shader goes to one colour attachment — the active render target. With MRT, the shader can declare multiple output variables (one per attachment) and the GPU writes each to a separate texture in a single draw call.
MRT is the foundation of deferred rendering, where the scene is rendered in two major passes:
- Geometry pass (G-buffer fill) — draw all opaque geometry once, writing albedo, normals, metalness, roughness, and depth into separate textures. This is where MRT is used.
- Lighting pass — draw a fullscreen triangle or quad, reading the G-buffer textures and computing all lighting in screen space. One fullscreen pass instead of one pass per light.
The advantage over forward rendering becomes apparent with many dynamic lights: a forward renderer must re-draw each object once per light it is affected by, while a deferred renderer separates geometry from lighting so each light only costs one fullscreen rectangle pass regardless of how many objects are in the scene.
Other uses of MRT include:
- Screen-Space Ambient Occlusion (SSAO) — write depth and normals to G-buffer targets, then sample them in the SSAO compute pass.
- Velocity buffer — write per-pixel motion vectors alongside colour for motion blur or temporal anti-aliasing.
- OIT accumulation — order-independent transparency techniques that accumulate weighted colour and alpha into two separate targets.
GraphicsDevice.SetRenderTargets()
CNA exposes MRT through GraphicsDevice::SetRenderTargets:
void GraphicsDevice::SetRenderTargets(
RenderTargetBinding* targets,
int count);
Pass an array of RenderTargetBinding structs, one per attachment. The maximum number of simultaneous render targets depends on the GPU but is at least 4 on OpenGL 2.0+ hardware and at least 8 on most modern GPUs. Query the actual limit with GraphicsDevice.GraphicsCapabilities.MaxMultipleRenderTargets.
To restore the default back buffer as the only render target, pass nullptr and 0:
gd.SetRenderTargets(nullptr, 0); // restore back buffer
You can also use the single-target overload from previous tutorials for convenience when only one target is needed:
gd.SetRenderTarget(rtAlbedo_.get()); // single target
RenderTarget2D Array for the G-Buffer
Create one RenderTarget2D per G-buffer slot. Choose the surface format carefully to balance precision against memory usage:
| G-Buffer Slot | Contents | Recommended Format | Bytes/Pixel |
|---|---|---|---|
| RT0 (Albedo) | RGB albedo colour + roughness in alpha | SurfaceFormat::Color | 4 |
| RT1 (Normal) | World-space XYZ normal + metalness in alpha | SurfaceFormat::Vector4 or HalfVector4 | 16 or 8 |
| RT2 (Depth) | Linear depth (or clip-space Z) | SurfaceFormat::Single (32-bit float) | 4 |
| RT3 (Emission) | Emissive colour for HDR glow | SurfaceFormat::Color | 4 |
HalfVector4 (16-bit half-float per channel) is preferred for the normal buffer on memory-constrained targets because normal vectors need less precision than positions. For desktop use, SurfaceFormat::Vector4 (32-bit float per channel) provides maximum precision.
All render targets in a single MRT bind must have the same width and height. They can have different surface formats.
G-Buffer Pattern
The standard G-buffer for a PBR (Physically Based Rendering) deferred pipeline stores:
- Albedo (RT0) — the base colour of the surface, RGB. The alpha channel can store roughness or AO factor to save a render target slot.
- Normal (RT1) — the world-space surface normal, XYZ. Normals are signed values in [−1, +1]; encode them to [0, 1] for storage in a
Colorformat or store directly in a float format. - Depth (RT2) — linear eye-space depth or the raw clip-space Z value from
gl_FragCoord.z. Used by the lighting pass to reconstruct world-space position from the depth and the inverse view-projection matrix.
The geometry pass renders every opaque mesh once. Alpha-tested surfaces can also be included in the geometry pass. Transparent surfaces must be rendered in a separate forward pass after the deferred lighting pass, since deferred renderers do not handle multiple transparent layers at a given pixel.
Reading Multiple Outputs in the Fragment Shader
In GLSL, declare one out vec4 variable per render target. The layout(location = N) qualifier maps each variable to the corresponding MRT attachment index (0-based):
#version 300 es
precision highp float;
in vec3 v_worldPos;
in vec3 v_normal;
in vec2 v_texcoord;
uniform sampler2D u_albedoTex;
uniform float u_roughness;
uniform float u_metalness;
// MRT outputs — one per render target
layout(location = 0) out vec4 gAlbedo; // RT0
layout(location = 1) out vec4 gNormal; // RT1
layout(location = 2) out vec4 gLinearDepth; // RT2
void main() {
// Sample surface albedo from texture
vec4 albedo = texture(u_albedoTex, v_texcoord);
// RT0: albedo in RGB, roughness in A
gAlbedo = vec4(albedo.rgb, u_roughness);
// RT1: encode normal from [-1,+1] to [0,1] range; metalness in A
vec3 n = normalize(v_normal);
gNormal = vec4(n * 0.5 + 0.5, u_metalness);
// RT2: linear depth (distance from camera) for position reconstruction
gLinearDepth = vec4(gl_FragCoord.z, 0.0, 0.0, 1.0);
}
Complete Example: Deferred G-Buffer Setup
class DeferredGame final : public Game {
std::unique_ptr<RenderTarget2D> rtAlbedo_; // RT0: albedo + roughness
std::unique_ptr<RenderTarget2D> rtNormal_; // RT1: normal + metalness
std::unique_ptr<RenderTarget2D> rtDepth_; // RT2: linear depth
std::unique_ptr<Effect> geoEffect_; // G-buffer fill shader
std::unique_ptr<Effect> lightEffect_;// Deferred lighting shader
std::unique_ptr<VertexBuffer> fullscreenVB_;
void LoadContent() override {
auto& gd = getGraphicsDeviceProperty();
int w = gd.getBackBufferWidth();
int h = gd.getBackBufferHeight();
// Create G-buffer render targets.
// Only RT0 needs a depth buffer (shared across the geometry pass).
rtAlbedo_ = std::make_unique<RenderTarget2D>(
gd, w, h, false,
SurfaceFormat::Color,
DepthFormat::Depth24Stencil8);
rtNormal_ = std::make_unique<RenderTarget2D>(
gd, w, h, false,
SurfaceFormat::Vector4, // higher precision for normals
DepthFormat::None);
rtDepth_ = std::make_unique<RenderTarget2D>(
gd, w, h, false,
SurfaceFormat::Single, // 32-bit float depth
DepthFormat::None);
geoEffect_ = Content.Load<Effect>("effects/gbuffer_fill");
lightEffect_ = Content.Load<Effect>("effects/deferred_light");
buildFullscreenTriangle(gd, fullscreenVB_);
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
// ---- Geometry pass: fill the G-buffer -------------------------
RenderTargetBinding targets[3] = {
RenderTargetBinding(rtAlbedo_.get()),
RenderTargetBinding(rtNormal_.get()),
RenderTargetBinding(rtDepth_.get()),
};
gd.SetRenderTargets(targets, 3);
gd.Clear(Color::Black);
geoEffect_->Parameters["u_view"].SetValue(camera_.View());
geoEffect_->Parameters["u_projection"].SetValue(camera_.Projection());
drawSceneGeometry(gd, *geoEffect_);
// ---- Lighting pass: deferred shading on fullscreen triangle ---
gd.SetRenderTargets(nullptr, 0); // restore back buffer
gd.Clear(Color::Black);
// Bind G-buffer textures as inputs to the lighting shader
lightEffect_->Parameters["u_albedo"].SetValue(rtAlbedo_.get());
lightEffect_->Parameters["u_normal"].SetValue(rtNormal_.get());
lightEffect_->Parameters["u_depth"].SetValue(rtDepth_.get());
// Camera parameters needed to reconstruct world positions from depth
lightEffect_->Parameters["u_invViewProj"].SetValue(
Matrix::Invert(camera_.View() * camera_.Projection()));
lightEffect_->Parameters["u_cameraPos"].SetValue(camera_.Position());
// Submit light data (example: one directional light)
lightEffect_->Parameters["u_lightDir"].SetValue(
Vector3::Normalize(Vector3(-1.0f, -2.0f, -1.0f)));
lightEffect_->Parameters["u_lightColor"].SetValue(
Vector3(1.0f, 0.95f, 0.85f));
gd.setVertexBuffer(*fullscreenVB_);
for (auto& pass : lightEffect_->getCurrentTechnique().Passes) {
pass.Apply();
gd.DrawPrimitives(PrimitiveType::TriangleList, 0, 1);
}
gd.Present();
}
};
Deferred Lighting Pass Shader
The lighting pass samples all three G-buffer textures and applies a simple Lambertian + specular lighting model. In a production deferred renderer you would loop over all lights (with each light contributing one additive pass, or using a clustered approach).
#version 300 es
precision highp float;
in vec2 v_texcoord;
uniform sampler2D u_albedo;
uniform sampler2D u_normal;
uniform sampler2D u_depth;
uniform mat4 u_invViewProj;
uniform vec3 u_cameraPos;
uniform vec3 u_lightDir; // normalised, pointing toward light
uniform vec3 u_lightColor;
out vec4 fragColor;
// Reconstruct world-space position from depth texture and NDC coords.
vec3 reconstructWorldPos(vec2 uv, float depth) {
vec4 ndc = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
vec4 world = u_invViewProj * ndc;
return world.xyz / world.w;
}
void main() {
// Sample G-buffer
vec4 albedoRough = texture(u_albedo, v_texcoord);
vec4 normalMetal = texture(u_normal, v_texcoord);
float rawDepth = texture(u_depth, v_texcoord).r;
vec3 albedo = albedoRough.rgb;
float roughness = albedoRough.a;
// Decode normal from [0,1] back to [-1,+1]
vec3 N = normalize(normalMetal.xyz * 2.0 - 1.0);
float metalness = normalMetal.a;
// Reconstruct world position
vec3 worldPos = reconstructWorldPos(v_texcoord, rawDepth);
vec3 V = normalize(u_cameraPos - worldPos);
vec3 L = normalize(-u_lightDir);
vec3 H = normalize(V + L);
// Simple Lambertian diffuse
float NdotL = max(dot(N, L), 0.0);
vec3 diffuse = albedo * u_lightColor * NdotL;
// Blinn-Phong specular (approximation; replace with GGX for PBR)
float shininess = mix(4.0, 128.0, 1.0 - roughness);
float NdotH = max(dot(N, H), 0.0);
vec3 specular = u_lightColor * pow(NdotH, shininess) * (1.0 - roughness);
// Ambient
vec3 ambient = albedo * 0.05;
fragColor = vec4(ambient + diffuse + specular, 1.0);
}
Combining Additional Passes
A full deferred pipeline stacks additional passes after the geometry and lighting passes:
- SSAO pass — reads the depth and normal G-buffer targets and writes an ambient occlusion factor to another render target.
- Multiple light accumulation — one additive fullscreen pass per dynamic point or spot light using scissor rectangles to limit cost.
- Forward transparency pass — transparent objects rendered with standard forward blending on top of the deferred result.
- Post-processing chain — bloom, colour grading, FXAA, motion blur applied to the final composited image.
Each of these passes can read the G-buffer textures as needed. Because they are ordinary RenderTarget2D objects on the CPU side, they can be passed as Effect parameters like any other texture.