Tutorial 71: Memory Management in C++

CNA Tutorials  ·  C++ Fundamentals

ℹ

What you’ll learn

  • How C++ ownership differs from the C# model CNA's API was designed around.
  • RAII in CNA, and the Dispose(bool) override pattern.
  • std::unique_ptr versus raw pointers for GPU resources.
  • Move semantics, and where LoadContent / UnloadContent fit into resource lifetime.

Before you start — Tutorial 01: Introduction to CNA (the C++-versus-C# framing) and Tutorial 04: The Game Class Lifecycle (the callbacks that bracket resource lifetime).

C++ vs C# memory model

XNA games are written in C#, which has a garbage collector. When a Texture2D object goes out of scope in C#, the GC eventually reclaims the CPU-side managed memory, and the finalizer releases the underlying GPU resource. This can lead to GC pauses but the programmer rarely needs to think about it.

CNA games are written in C++, which has no garbage collector. Every GPU resource (Texture2D, VertexBuffer, Effect, RenderTarget2D) that you allocate must be released explicitly, or it leaks both the CPU backing allocation and the underlying GPU resource (texture object, VBO, FBO). The C++ idiom for deterministic cleanup is RAII.

RAII in CNA

RAII (Resource Acquisition Is Initialisation) ties resource lifetime to object lifetime. A Texture2D object's constructor acquires the GPU resource; its destructor releases it. As long as the Texture2D is held in a smart pointer (std::unique_ptr or std::shared_ptr), the resource is released when the smart pointer goes out of scope or is reset.

CNA's classes implement an IDisposable-style interface via a Dispose() virtual method, mirroring XNA's pattern. You can call Dispose() explicitly to release a resource early (e.g. when unloading a level), or let the destructor call it automatically. Several graphics types are cheap value wrappers around a shared renderer resource: a Texture2D can be copied, every copy shares the same GPU texture, and calling Dispose() on one copy drops only that copy’s share. The GPU texture is released when the last copy goes away. Other types, such as VertexBuffer, IndexBuffer and RenderTarget2D, own their resource exclusively and are move-only.

The Dispose(bool) override pattern

CNA base classes call a protected Dispose(bool disposing) virtual method. The public Dispose() calls Dispose(true). Override it in your subclass to release your own resources on that path:

class MyGame : public Game {
protected:
    // Called with disposing == true from the public Dispose().
    void Dispose(bool disposing) override {
        if (disposing) {
            // Release CNA objects in reverse LoadContent order,
            // while the GraphicsDevice is still alive
            spriteBatch_.reset();
            texture_.reset();
            effect_.reset();
        }
        // Call parent implementation (this also raises the device-disposing hook that runs UnloadContent)
        Game::Dispose(disposing);
    }

private:
    std::unique_ptr<SpriteBatch>  spriteBatch_;
    std::unique_ptr<Texture2D>    texture_;
    std::unique_ptr<BasicEffect>  effect_;
};
⚠

The destructor is not a shutdown hook for your override. ~Game() calls Dispose(false), but by then your derived class has already been destroyed, so C++ dispatches to Game::Dispose(bool), not to your override, and that path does not run UnloadContent() either. Likewise Run() returning does not dispose the game. If you want Dispose(bool) and UnloadContent() to run at exit, call game.Dispose() after Run() returns; if you do not, rely on ordinary member destruction, which is exactly why the next sections hold every GPU resource in a smart pointer.

std::unique_ptr vs raw pointers

Always prefer std::unique_ptr for game objects and GPU resources. Never use new/delete directly in game code.

PatternRecommendation
std::unique_ptr<T>Single owner. Default choice for assets, game objects, and GPU resources.
std::shared_ptr<T>Shared ownership. Use when multiple systems hold a reference (e.g. a texture used by many sprites in different systems).
Raw pointer T*Non-owning reference only. Use for passing a resource to a function that does not claim ownership.
new / deleteAvoid. If you must use them, immediately wrap in a smart pointer.

Proper RAII resource ownership in a game class

#include <memory>
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexBuffer.hpp"

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

// Builds the vertex data on the CPU; the caller uploads it and drops the vector.
static std::vector<VertexPositionColor> BuildTriangle() {
    return {
        VertexPositionColor(Vector3( 0.0f,  1.0f, 0.0f), Color::Red),
        VertexPositionColor(Vector3( 1.0f, -1.0f, 0.0f), Color::Green),
        VertexPositionColor(Vector3(-1.0f, -1.0f, 0.0f), Color::Blue),
    };
}

class MyGame final : public Game {
public:
    MyGame() : graphics_(this) {
        graphics_.setPreferredBackBufferWidthProperty(1280);
        graphics_.setPreferredBackBufferHeightProperty(720);
    }

    // Non-copyable, non-movable — Game owns the GraphicsDevice
    MyGame(const MyGame&) = delete;
    MyGame& operator=(const MyGame&) = delete;

protected:
    void LoadContent() override {
        auto& gd = getGraphicsDeviceProperty();

        // All GPU resources created here and stored in unique_ptr members.
        // They are released in reverse declaration order when the unique_ptrs are destroyed.
        spriteBatch_ = std::make_unique<SpriteBatch>(gd);
        // Load<T> returns the texture BY VALUE (a shared handle); we keep our own copy on the heap.
        // (CNAEXT alternative: std::make_unique<Texture2D>("assets/hero.png", gd) reads a file directly.)
        texture_     = std::make_unique<Texture2D>(getContentProperty().Load<Texture2D>("hero"));
        effect_      = std::make_unique<BasicEffect>(gd);

        // Procedurally built vertex data owned by a local vector,
        // then uploaded and the local vector discarded.
        std::vector<VertexPositionColor> verts = BuildTriangle();
        vb_ = std::make_unique<VertexBuffer>(
            gd, VertexPositionColor::getVertexDeclarationStatic(),
            static_cast<int>(verts.size()), BufferUsage::None);
        vb_->SetData(verts.data(), static_cast<int>(verts.size()));
        // verts is released here — GPU copy is all we need.
    }

    void UnloadContent() override {
        // Runs when the game is disposed (Dispose()), before the GraphicsDevice goes away.
        // Reset smart pointers in the opposite order of creation.
        vb_.reset();
        effect_.reset();
        texture_.reset();
        spriteBatch_.reset();
    }

    void Update(GameTime&) override { /* game logic */ }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color::CornflowerBlue);
        spriteBatch_->Begin();
        spriteBatch_->Draw(*texture_, Vector2(100.0f, 80.0f), Color::White);
        spriteBatch_->End();
        // No Present(): Game presents the frame after Draw() returns.
    }

private:
    // Members are destroyed in REVERSE declaration order, so graphics_ (declared first) is
    // destroyed last and the resources declared after it are gone before it.
    GraphicsDeviceManager              graphics_;
    std::unique_ptr<SpriteBatch>      spriteBatch_;
    std::unique_ptr<Texture2D>        texture_;
    std::unique_ptr<BasicEffect>      effect_;
    std::unique_ptr<VertexBuffer>     vb_;
};

Move semantics and copies

Whether a CNA graphics object can be copied depends on the type. Texture2D is a copyable value wrapper: a copy shares the same GPU texture (this is what ContentManager::Load<Texture2D> hands you). VertexBuffer, IndexBuffer and RenderTarget2D delete their copy operations and can only be moved, so ownership of the GPU resource is unambiguous:

// Texture2D: copying is allowed and shares the GPU texture
Texture2D a("logo.png", gd);
Texture2D b = a;               // OK: a and b refer to the same GPU texture

// VertexBuffer: not copyable
VertexBuffer vb1(gd, VertexPositionColor::getVertexDeclarationStatic(), 3, BufferUsage::None);
// VertexBuffer vb2 = vb1;     // compile error: copy constructor is deleted

// Correct: move transfers ownership; vb1 becomes empty
VertexBuffer vb3 = std::move(vb1);
// vb1 is now in a valid-but-unspecified state; do not use it.

// With unique_ptr (preferred for anything you store):
auto tex1 = std::make_unique<Texture2D>("logo.png", gd);
auto tex2 = std::move(tex1);
// tex1 is now nullptr; tex2 owns this Texture2D wrapper.

Common pitfalls

Double-free

Calling Dispose() twice on a CNA object is safe — subsequent calls are no-ops. However, deleting a raw pointer twice is undefined behaviour:

// WRONG — double delete, undefined behaviour
Texture2D* t = new Texture2D("img.png", gd);
delete t;
delete t;  // crash or heap corruption

// Correct — unique_ptr prevents this
auto t = std::make_unique<Texture2D>("img.png", gd);
// t is automatically deleted once, when it goes out of scope.

Dangling references

Storing a raw pointer or reference to a CNA object and then resetting its owning unique_ptr leaves a dangling pointer:

// WRONG
SpriteBatch* rawPtr = spriteBatch_.get();
spriteBatch_.reset();  // SpriteBatch is destroyed
rawPtr->Begin();       // dangling pointer — undefined behaviour

// Correct: only keep raw pointers for the duration of a function call
void DrawSprites(SpriteBatch& sb) {
    sb.Begin();
    // ... use sb
    sb.End();
    // sb is a reference, valid as long as the caller's unique_ptr is alive
}

Accessing released GPU resources

If you call Dispose() on a Texture2D and then try to bind it to a shader uniform, the underlying OpenGL or Vulkan handle is invalid. Always destroy resources after their last use, not before.

LoadContent / UnloadContent patterns

Game subscribes UnloadContent() to the graphics device’s device-disposing event, which is raised when the game is disposed with Dispose(). It runs while the GraphicsDevice is still valid, so it is the right place to release GPU resources explicitly; the default implementation is empty. If your game object is simply destroyed after Run() returns, UnloadContent() is not called, and correct destruction order is what keeps the device valid: declare graphics_ first and the resources after it, as in the example above, and never keep a GPU resource in a member that outlives the Game.

For level streaming, use a separate ContentManager per level. ContentManager::Unload() clears the manager’s asset cache; it does not dispose the assets you were given (they are values you hold). The GPU memory is released when the last copy of each asset goes away, so reset your own members too:

// In your Game subclass
std::unique_ptr<ContentManager> levelContent_;

void LoadLevel(const std::string& levelName) {
    // Drop the previous level's cache and our own copies of its assets
    if (levelContent_) levelContent_->Unload();
    playerTexture_.reset();
    backgroundTex_.reset();

    // Create a fresh ContentManager for this level
    levelContent_ = std::make_unique<ContentManager>(
        &getServicesProperty(), "Content/" + levelName);

    // Load assets for the new level (Load<T> returns by value)
    playerTexture_  = levelContent_->Load<Texture2D>("player");
    backgroundTex_  = levelContent_->Load<Texture2D>("background");
}

// members (declared after graphics_):
//   std::optional<Texture2D> playerTexture_, backgroundTex_;

SoundEffect is the exception: it is move-only, so Load<SoundEffect> is not cached, every call returns a new independently owned instance, and Unload() has nothing to release for it.

ℹ

Seeing what is alive. Textures, render targets and vertex and index buffers register resource metadata with CNA’s diagnostics layer when you configure with -DCNA_DIAGNOSTICS=STATS (or FULL). That is a practical way to spot leaked resources during level changes; see Tutorial 73 and the Diagnostics reference.