Tutorial 62: Multiple Render Targets (MRT)

CNA Tutorials  ·  Advanced Rendering

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:

  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 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 SlotContentsRecommended FormatBytes/Pixel
RT0 (Albedo)RGB albedo colour + roughness in alphaSurfaceFormat::Color4
RT1 (Normal)World-space XYZ normal + metalness in alphaSurfaceFormat::Vector4 or HalfVector416 or 8
RT2 (Depth)Linear depth (or clip-space Z)SurfaceFormat::Single (32-bit float)4
RT3 (Emission)Emissive colour for HDR glowSurfaceFormat::Color4

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 Color format 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.