Tutorial 62: Multiple Render Targets (MRT)

CNA Tutorials  ·  Advanced Rendering

What you’ll learn

  • Binding several render targets at once with SetRenderTargets().
  • Laying out a G-buffer as a RenderTarget2D array.
  • Writing to multiple outputs from one fragment shader.

Before you startTutorial 23: Render Targets for Off-Screen Rendering (a single render target first) and Tutorial 52: Writing Custom Shaders (ShaderEffect) (MRT is only useful with your own shader). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the thirteen 2D-only renderers (SDL_RENDERER, DIRECT2D, CANVAS, HTML_DOM, SKIA, BLEND2D, FREEDIRECT, DIRECTX1, GDI, SVG_DOM, OPENVG, NANOVG, PIXIJS) throw on 3D calls. Requires a renderer with render-target support.

MRT is a per-renderer capability. It requires GL_ARB_draw_buffers on the GL family, or multiple framebuffer attachments elsewhere. Two concrete negatives: OPENGLES2 and WEBGL1 lose MRT entirely, and the thirteen 2D-only renderers throw if SetRenderTargets is called with more than one binding.

Query the capability, and know its limits. Most base capability entries default to true; only multi-stream input and compiled effects opt out and stencil delegates. DIRECTX9, DIRECTX11, DIRECTX12 and SDL_GPU supply no override, so a positive MRT answer from them means only “not known to be unsupported”. Test on the renderer you ship.

Cube faces inside an MRT set are unimplemented on eight otherwise-capable 3D renderers. Plain 2D MRT — several RenderTarget2D objects bound at once, which is what this tutorial builds — works fine. What does not work is binding a RenderTargetCube face as one slot of a multi-target set. The eight are DIRECTX9, DIRECTX11, DIRECTX12, BGFX, the EasyGL GL family, SDL_GPU, OPENGL2 and OPENGL4. If your design calls for rendering cube faces and 2D targets in one pass — a point-light shadow cube alongside a G-buffer, say — split it into separate passes.

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:

  1. 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.
  2. 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 a single method on GraphicsDevice, which takes a vector, not a pointer and count:

void GraphicsDevice::SetRenderTargets(
    const std::vector<RenderTargetBinding>& renderTargets);

std::vector<RenderTargetBinding> GraphicsDevice::GetRenderTargets() const;

RenderTargetBinding associates one render target texture with one output slot. Its two-argument constructor is explicit and takes a Texture* — a raw pointer, not a reference:

explicit RenderTargetBinding(Texture* renderTarget, int arraySlice = 0);
RenderTargetBinding(Texture* renderTarget, CubeMapFace cubeMapFace);

Because the constructor is explicit, brace-initialising a vector of bindings from bare pointers does not compile; name the type at each element.

CNA exposes capability queries but no public numeric maximum for simultaneous render targets. XNA's general ceiling is four, GraphicsProfile::Reach may lower the active renderer to one, and a renderer can impose a smaller native cap. Treat four as a ceiling, not a guaranteed floor.

To restore the default back buffer as the only render target, call the single-target overload with nullptr:

gd.SetRenderTarget(nullptr); // restore back buffer

The same single-target overload is the convenient form when only one target is needed:

gd.SetRenderTarget(rtAlbedo_.get()); // single target

RenderTarget2D Array for the G-Buffer

This portable MRT layout uses SurfaceFormat::Color. The common shader-capable configurations here defer to the framework's Color-only public gate. Skia and IGL expose explicit format promotions, but those do not create one cross-renderer G-buffer contract, so wider-range quantities are encoded into RGBA8.

Create one RenderTarget2D per G-buffer slot. This example deliberately fixes the portable format and focuses on how to encode each quantity into RGBA8:

G-Buffer SlotContentsFormatEncoding
RT0 (Albedo)RGB albedo colour + roughness in alphaSurfaceFormat::ColorDirect; albedo is already in [0,1].
RT1 (Normal)World-space XYZ normal + metalness in alphaSurfaceFormat::Colorn * 0.5 + 0.5 per channel. 8 bits per axis is visibly coarse on smooth surfaces; octahedral encoding into RG with metalness in B is a better use of the same four bytes.
RT2 (Depth)Linear depth (or clip-space Z)SurfaceFormat::ColorPack a normalised float across all four channels — see the packDepth/unpackDepth pair in Tutorial 59.
RT3 (Emission)Emissive colour for glowSurfaceFormat::ColorDirect, but LDR only. Store an exposure scale in alpha if you need values above 1.0.

All render targets in a single MRT bind must have matching width, height and applied sample count, and the same subresource cannot occupy two slots. Format availability follows the active renderer; Color is the portable common denominator.

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], so they must be remapped to [0, 1] on write and back on read.
  • Depth (RT2) — the raw clip-space Z value from gl_FragCoord.z, packed across all four RGBA8 channels. 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

// Every target is RGBA8, so depth is spread across all four channels.
vec4 packDepth(float depth) {
    const vec4 bitShift = vec4(1.0, 255.0, 65025.0, 16581375.0);
    const vec4 bitMask  = vec4(1.0 / 255.0, 1.0 / 255.0, 1.0 / 255.0, 0.0);
    vec4 res = fract(depth * bitShift);
    res -= res.gbaa * bitMask;
    return res;
}

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: clip-space depth, packed into RGBA8 for position reconstruction
    gLinearDepth = packDepth(gl_FragCoord.z);
}

Writing vec4(gl_FragCoord.z, 0.0, 0.0, 1.0) instead would quantise depth to the 256 levels an 8-bit red channel can hold, which is far too coarse to reconstruct world position from. The packing above is what makes an RGBA8 depth slot usable.

Complete Example: Deferred G-Buffer Setup

Both shaders in this example are ShaderEffects. Alpha.1's separate XNA/FNA Effect Framework bytecode path is renderer-qualified; it works on FNA3D and on explicitly enabled SDL_GPU, EasyGL-family or Vulkan builds. Here ShaderEffect takes renderer-native source and has no Parameters collection; uniforms go through SetUniformXxx() and textures through SetTexture(unit, tex), after Apply(). See Tutorial 52.

#include "Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp"
#include "System/IO/File.hpp"

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<ShaderEffect>   geoEffect_;  // G-buffer fill shader
    std::unique_ptr<ShaderEffect>   lightEffect_;// Deferred lighting shader
    std::unique_ptr<VertexBuffer>   fullscreenVB_;

    // ShaderEffect's matrix setter takes raw column-major floats.
    static void SetMat4(ShaderEffect& fx, const char* name, const Matrix& m) {
        float cm[16];
        m.ToColumnMajor(cm);
        fx.SetUniformMat4(name, cm);
    }

    void LoadContent() override {
        auto& gd = getGraphicsDeviceProperty();
        int w = gd.getPresentationParametersProperty().getBackBufferWidthProperty();
        int h = gd.getPresentationParametersProperty().getBackBufferHeightProperty();

        // 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);

        // Every target is SurfaceFormat::Color for portability.
        // Normals are encoded to [0,1]; depth is packed to RGBA8.
        rtNormal_ = std::make_unique<RenderTarget2D>(
            gd, w, h, false,
            SurfaceFormat::Color,
            DepthFormat::None);

        rtDepth_ = std::make_unique<RenderTarget2D>(
            gd, w, h, false,
            SurfaceFormat::Color,
            DepthFormat::None);

        // Three arguments: device, vertex source, fragment source. Not file paths.
        geoEffect_ = std::make_unique<ShaderEffect>(
            gd,
            System::IO::File::ReadAllText("Content/effects/gbuffer_fill.vert.glsl"),
            System::IO::File::ReadAllText("Content/effects/gbuffer_fill.frag.glsl"));
        lightEffect_ = std::make_unique<ShaderEffect>(
            gd,
            System::IO::File::ReadAllText("Content/effects/deferred_light.vert.glsl"),
            System::IO::File::ReadAllText("Content/effects/deferred_light.frag.glsl"));

        // Neither constructor throws on a compile failure -- check both.
        if (!geoEffect_->IsEffectValid() || !lightEffect_->IsEffectValid()) {
            // The GLSL did not compile. Do not draw with it.
        }

        // Sampler unit assignments for the lighting pass, set once.
        lightEffect_->Apply();
        lightEffect_->SetUniformInt("u_albedo", 0);
        lightEffect_->SetUniformInt("u_normal", 1);
        lightEffect_->SetUniformInt("u_depth",  2);

        buildFullscreenTriangle(gd, fullscreenVB_);
    }

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

        // ---- Geometry pass: fill the G-buffer -------------------------
        // RenderTargetBinding's constructor is explicit and takes a Texture*,
        // so each element has to be named; SetRenderTargets takes a vector.
        const std::vector<RenderTargetBinding> targets = {
            RenderTargetBinding(rtAlbedo_.get()),
            RenderTargetBinding(rtNormal_.get()),
            RenderTargetBinding(rtDepth_.get()),
        };
        gd.SetRenderTargets(targets);
        gd.Clear(Color::Black);

        // Apply() binds the program; SetUniformXxx() writes into whatever program
        // is currently bound, so this order is mandatory.
        geoEffect_->Apply();
        SetMat4(*geoEffect_, "u_view",       camera_.View());
        SetMat4(*geoEffect_, "u_projection", camera_.Projection());
        drawSceneGeometry(gd, *geoEffect_);

        // ---- Lighting pass: deferred shading on fullscreen triangle ---
        gd.SetRenderTarget(nullptr);     // restore back buffer
        gd.Clear(Color::Black);

        const Vector3 eye      = camera_.Position();
        const Vector3 lightDir = Vector3::Normalize(Vector3(-1.0f, -2.0f, -1.0f));

        lightEffect_->Apply();

        // Bind G-buffer textures on the units assigned in LoadContent().
        // SetTexture takes a reference, and RenderTarget2D derives from Texture2D.
        lightEffect_->SetTexture(0, *rtAlbedo_);
        lightEffect_->SetTexture(1, *rtNormal_);
        lightEffect_->SetTexture(2, *rtDepth_);

        // Camera parameters needed to reconstruct world positions from depth
        SetMat4(*lightEffect_, "u_invViewProj",
                Matrix::Invert(camera_.View() * camera_.Projection()));
        lightEffect_->SetUniformVec3("u_cameraPos", eye.X, eye.Y, eye.Z);

        // Submit light data (example: one directional light)
        lightEffect_->SetUniformVec3("u_lightDir",
                                      lightDir.X, lightDir.Y, lightDir.Z);
        lightEffect_->SetUniformVec3("u_lightColor", 1.0f, 0.95f, 0.85f);

        gd.SetVertexBuffer(fullscreenVB_.get());
        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;

// Inverse of the geometry pass's packDepth().
float unpackDepth(vec4 rgba) {
    const vec4 bitShift = vec4(1.0,
                               1.0 / 255.0,
                               1.0 / 65025.0,
                               1.0 / 16581375.0);
    return dot(rgba, bitShift);
}

// 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    = unpackDepth(texture(u_depth, v_texcoord));

    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.