Tutorial 23: Render Targets for Off-Screen Rendering

Graphics  ·  RenderTarget2D  ·  Off-Screen  ·  Minimap

ℹ

What you’ll learn

  • Creating a RenderTarget2D and switching to it with SetRenderTarget().
  • Drawing into a texture and then drawing that texture to the screen.
  • A minimap as a worked example.
  • Surviving device loss and window resize.

Before you start — Tutorial 21: SpriteBatch Deep Dive — you render into a target with the same SpriteBatch calls. Requires a renderer with render-target support: every 3D renderer (not STUB, which reports no capability at all), plus the 2D-only renderers for plain colour targets (no depth buffer). The default Reach profile caps a target at 2048 texels per side.

A render target is a texture that the GPU can draw into instead of the back buffer. Once you have rendered a scene into a render target you can use that texture as the source for subsequent draw calls — scaling it, applying effects to it, or compositing it onto the screen. This tutorial covers creation, usage, and common patterns.

What is a render target?

Normally, everything you draw goes into the back buffer — the texture that is presented to the display each frame. A RenderTarget2D is an off-screen texture you create and control. You redirect GPU output to it, draw your scene, then switch back to the back buffer and draw the render target as a texture.

Common use cases:

  • Post-processing — render the world to a texture, then apply a shader (grayscale, bloom, vignette) before presenting.
  • Minimap — render the world at small scale to a tiny render target, display as a HUD element.
  • UI canvas — render complex UI once per state change rather than every frame.
  • Shadow maps — render the scene from the light's perspective, store depth in a render target.
  • Portals / mirrors — render the reflection scene into a texture, sample it on the mirror surface.

RenderTarget2D creation

#include "Microsoft/Xna/Framework/Graphics/RenderTarget2D.hpp"

using namespace Microsoft::Xna::Framework::Graphics;

// Create a render target matching the back buffer size
auto& gd = getGraphicsDeviceProperty();
renderTarget_ = std::make_unique<RenderTarget2D>(
    gd,
    800,                         // width  (pixels)
    600,                         // height (pixels)
    false,                       // mipMap — usually false for render targets
    SurfaceFormat::Color,        // pixel format
    DepthFormat::None            // no depth buffer: fine for 2D. Use Depth24 or
                                 // Depth24Stencil8 when you draw 3D into the target
);

// The short form is equivalent: SurfaceFormat::Color, DepthFormat::None, no mip chain.
// RenderTarget2D shortForm(gd, 800, 600);

A render target is not copyable (it can be moved), so keep it in a std::unique_ptr or as a member. Two limits are worth knowing up front: under the default Reach profile a target may be at most 2048 texels on a side (4096 under HiDef), and a surface format other than the colour formats silently becomes SurfaceFormat::Color unless you ask for HiDef — see the Render Targets reference and Tutorial 152: Reach vs HiDef.

For a post-process target that matches the window size dynamically:

Viewport vp = gd.getViewportProperty();
renderTarget_ = std::make_unique<RenderTarget2D>(
    gd,
    vp.getWidthProperty(), vp.getHeightProperty(),
    false,
    SurfaceFormat::Color,
    DepthFormat::None    // no depth needed for 2D post-process
);

GraphicsDevice.SetRenderTarget()

// Redirect rendering to the render target
gd.SetRenderTarget(renderTarget_.get());

// Now everything you draw goes to renderTarget_ (not the screen)
gd.Clear(Color::Transparent);   // clear the RT, not the back buffer

// ... draw your scene here ...

// Restore the back buffer
gd.SetRenderTarget(nullptr);    // nullptr = back buffer

// Now everything you draw goes to the screen again

After calling SetRenderTarget(nullptr) the render target texture is finalised and ready to sample as a normal Texture2D.

Drawing to texture and back to screen

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

    // --- Pass 1: render scene into render target ---
    gd.SetRenderTarget(renderTarget_.get());
    gd.Clear(Color::CornflowerBlue);

    spriteBatch_->Begin();
    DrawWorldSprites();   // all world content
    spriteBatch_->End();

    gd.SetRenderTarget(nullptr);

    // --- Pass 2: draw the render target onto the screen ---
    gd.Clear(Color::Black);

    spriteBatch_->Begin();
    // Draw the render target as a texture — stretch to fill screen
    spriteBatch_->Draw(*renderTarget_,
                        Rectangle(0, 0, 800, 600),
                        Color::White);
    spriteBatch_->End();

    // No gd.Present() here: Game presents in EndDraw() after Draw() returns.
}
⚠

Do not call Present() yourself. Game presents the back buffer in EndDraw() after your Draw() returns, so a manual gd.Present() at the end of Draw() presents twice. What you must do is restore the back buffer with SetRenderTarget(nullptr) before Draw() returns.

At this point the output is identical to rendering directly to the back buffer. The power comes when you add a shader or transformation in the second pass (see Tutorial 24).

Minimap example

A minimap renders the world at reduced scale into a small render target, then displays it as a HUD corner element:

// In LoadContent():
minimapRT_ = std::make_unique<RenderTarget2D>(gd, 200, 150,
    false, SurfaceFormat::Color, DepthFormat::None);

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

    // --- Minimap pass (render world at 1/10 scale) ---
    gd.SetRenderTarget(minimapRT_.get());
    gd.Clear(Color::Black);
    {
        // Scale the camera to fit the entire world into 200x150 px
        Matrix minimapTransform =
            Matrix::CreateTranslation(-worldBounds_.X, -worldBounds_.Y, 0.0f) *
            Matrix::CreateScale(
                200.0f / static_cast<float>(worldBounds_.Width),
                150.0f / static_cast<float>(worldBounds_.Height),
                1.0f);

        spriteBatch_->Begin(SpriteSortMode::Deferred,
                             BlendState::AlphaBlend, nullptr, nullptr, nullptr, nullptr,
                             minimapTransform);
        DrawWorldTiles();      // just the tile layer, not UI
        DrawEntityDots();      // simplified coloured dots for entities
        spriteBatch_->End();
    }
    gd.SetRenderTarget(nullptr);

    // --- Main scene pass ---
    gd.Clear(Color::CornflowerBlue);
    spriteBatch_->Begin(SpriteSortMode::Deferred,
                         BlendState::AlphaBlend, nullptr, nullptr, nullptr, nullptr,
                         camera_->getTransform());
    DrawWorld();
    spriteBatch_->End();

    // --- HUD pass (no camera transform) ---
    spriteBatch_->Begin();
    // Draw minimap in top-right corner with a 2 px border
    spriteBatch_->Draw(*minimapRT_,
                        Rectangle(590, 10, 200, 150),
                        Color::White);
    // Draw minimap border
    // (use a 1x1 white pixel texture scaled to border shape)
    spriteBatch_->End();
    // (Game presents in EndDraw(); no Present() call here.)
}

Handling lost device / resize

Render targets are default-pool resources: their contents can be lost when the device is reset or, on renderers that detect it, lost and recreated (a GL context recreation, a Direct3D device loss). CNA gives you two hooks. GraphicsDevice::DeviceReset is raised after every GraphicsDevice::Reset() — including the one GraphicsDeviceManager::ApplyChanges() performs when you change the resolution or full-screen mode — and a Reset() unbinds every render target and does not restore the binding. Each render target also has its own ContentLost event and getIsContentLostProperty(), raised only when a renderer really lost and recreated its resources (the device-lost notification itself, DeviceLost, is XNA compatibility and is not raised on ordinary desktop renderers). The handler signature is the usual XNA one, (System::Object*, const System::EventArgs&):

void Initialize() override {
    Game::Initialize();
    getGraphicsDeviceProperty().DeviceReset +=
        [this](System::Object*, const System::EventArgs&) { RecreateRenderTargets(); };
}

void RecreateRenderTargets() {
    auto& gd = getGraphicsDeviceProperty();
    Viewport vp = gd.getViewportProperty();
    renderTarget_ = std::make_unique<RenderTarget2D>(
        gd, vp.getWidthProperty(), vp.getHeightProperty(), false, SurfaceFormat::Color, DepthFormat::None);
}

// Optional, in LoadContent(): redraw a cached target (e.g. a UI canvas) if its contents were lost.
void LoadContent() override {
    // ... create renderTarget_ as above, then:
    renderTarget_->ContentLost += [this](System::Object*, const System::EventArgs&) {
        uiCanvasDirty_ = true;
    };
}