Asset Management

Optimize image loading using shared pointers and an asset manager for better memory use and simpler code.
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Get Started for Free
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Posted

Our previous ImageComponent loads images but duplicates data and requires complex manual memory management. In this lesson, we'll refactor it using std::shared_ptr and an AssetManager to share image resources efficiently.

This will significantly reduce memory usage and simplifying our code by achieving the rule of zero. You'll learn:

  • How std::shared_ptr manages shared resources.
  • How to build a caching AssetManager for SDL_Surface data.
  • How these techniques eliminate the need for custom copy/move/destructor code.
  • How to dynamically change an entity's image.

Starting Point

In the previous lesson, we created the following ImageComponent class. We’ll continue working on it in this lesson.

#pragma once
#include <string>
#include <SDL.h>
#include "Component.h"

class ImageComponent : public Component {
 public:
  using Component::Component;
  ImageComponent(
    Entity* Owner,
    const std::string& FilePath
  );

  // Copy Semantics
  ImageComponent(const ImageComponent& Other);
  ImageComponent& operator=(
    const ImageComponent& Other);

  // Move Semantics
  ImageComponent(
    ImageComponent&& Other) noexcept;
  ImageComponent& operator=(
    ImageComponent&& Other) noexcept;

  bool LoadNewImage(
    const std::string& NewFilePath);

  ~ImageComponent() override;

  void Initialize() override;
  void Render(SDL_Surface* Surface) override;

private:
  SDL_Surface* ImageSurface{nullptr};
  std::string ImageFilePath;
  void LoadSurfaceInternal();
};
#include "ImageComponent.h"
#include "Entity.h"
#include "SDL_image.h"

ImageComponent::ImageComponent(
  Entity* Owner,
  const std::string& FilePath
) : Component(Owner),
    ImageFilePath(FilePath
) {
  ImageSurface = IMG_Load(FilePath.c_str());

  if (!ImageSurface) {
    std::cerr
      << "Failed to load image: " << FilePath
      << " Error: " << IMG_GetError() << '\n';
  } else {
    std::cout << "Loaded image: "
      << FilePath << '\n';
  }
}

void ImageComponent::Render(
  SDL_Surface* Surface
) {
  if (!ImageSurface) return;

  TransformComponent* Transform{
    GetOwner()->GetTransformComponent()};

  if (Transform) {
    std::cout << "ImageComponent ("
      << ImageFilePath
      << ") ready to render at: "
      << Transform->GetPosition() << '\n';
  } else {
    std::cout << "ImageComponent ("
      << ImageFilePath
      << ") ready, but no TransformComponent found\n";
  }
}

void ImageComponent::Initialize() {
  Entity* Owner{GetOwner()};
  if (!Owner->GetTransformComponent()) {
    std::cout <<
      "Error: ImageComponent requires"
      " TransformComponent on its Owner\n";

    Owner->RemoveComponent(this);
  }
}

ImageComponent::~ImageComponent() {
  SDL_FreeSurface(ImageSurface);
  ImageSurface = nullptr;
}

void ImageComponent::LoadSurfaceInternal() {
  if (ImageSurface) {
    SDL_FreeSurface(ImageSurface);
    ImageSurface = nullptr;
  }

  if (!ImageFilePath.empty()) {
    ImageSurface = IMG_Load(
      ImageFilePath.c_str());
    if (!ImageSurface) {
      std::cerr
        << "Failed to load image: "
        << ImageFilePath
        << " Error: " << IMG_GetError() << '\n';
    } else {
      std::cout << "Loaded image: "
        << ImageFilePath << '\n';
    }
  }
}

ImageComponent::ImageComponent(
  const ImageComponent& Other
)
  : Component(Other.GetOwner()),
    ImageFilePath(Other.ImageFilePath)
{
  // Now load our *own* copy of the surface
  LoadSurfaceInternal();
}

// Copy Assignment Operator
ImageComponent& ImageComponent::operator=(
    const ImageComponent& Other
) {
  if (this == &Other) { return *this; }

  Component::operator=(Other);
  ImageFilePath = Other.ImageFilePath;
  LoadSurfaceInternal();

  return *this;
}

// Move Constructor
ImageComponent::ImageComponent(
  ImageComponent&& Other) noexcept
  : Component(Other.GetOwner()),
    ImageSurface(Other.ImageSurface),
    ImageFilePath(std::move(
      Other.ImageFilePath))
{
  Other.ImageSurface = nullptr;
}

// Move Assignment Operator
ImageComponent& ImageComponent::operator=(
  ImageComponent&& Other) noexcept {
  if (this == &Other) { return *this; }

  SDL_FreeSurface(ImageSurface);
  ImageSurface = Other.ImageSurface;

  Component::operator=(std::move(Other));
  ImageFilePath = std::move(Other.ImageFilePath);

  Other.ImageSurface = nullptr;

  return *this;
}

bool ImageComponent::LoadNewImage(
    const std::string& NewFilePath
) {
  ImageFilePath = NewFilePath;
  LoadSurfaceInternal();
  return ImageSurface != nullptr;
}

Design Problems

The current version of our component is a good foundation - it has established all the basics and is managing it’s SDL_Surface effectively without risking memory issues. But, it has a couple of design problems.

The first is related to maintainability - this is a lot of code for a component that isn’t doing much yet. The second is a potential performance issue - when we have a lot of ImageComponent instances in our scene, that correponds to a lot of SDL_Surface objects being in memory, and many of them may be storing the exact same image.

We’ll fix both of these problems in this lesson.

Code Maintainability

The reason we wrote so much code was to implement the rule of five - the destructor, copy constructor, copy assignment operator, move constructor, and move assignment operator.

Aside from the large amount of code we have already written, this represents an ongoing maintenance issue. Every time we add a new member variable to our component, we need to update most of these functions and, if we forget, we have a bug.

We could make this easier - for example, they could all call some private helper function - but ideally, we’d like to create a situation where we don’t need any of these functions at all. This design is sometimes called the rule of zero, which we’ll implement in this section.

Memory Usage

Think about a game with lots of identical enemies, environment assets, or background tiles. Our current ImageComponent, with its deep copy behavior, creates a separate SDL_Surface in memory for every single component, even if they loaded the exact same file (like "tree.png").

If you have 100 trees in your scene, you're storing the tree pixel data 100 times. This can consume a lot of memory unnecessarily.

Implementing the Rule of Zero

Let’s address the code maintainability problem first. Our ImageComponent needs to manage a dynamically allocated resource (the SDL_Surface) to do its job. So, how can we delete the destructor, copy, and move semantics whilst ensuring this resource is still managed correctly?

The key principle that lets us achieve the rule of zero is that, when a dynamically allocated resource needs managed, we enlist the help of a type whose single responsibility is the managing of that resource.

This requires less work than we might thing, as the process of resource management is often easy to generalise into a reusable type. In fact, we’ve already been using such a type: std::unique_ptr.

When a type manages a resource through a std::unique_ptr, we rarely need to write any additional code to manage the memory of that resource. The unique_ptr takes care of that for us.

One restriction of std::unique_ptr is that it prevents our object from being copied. We’ll introduce std::shared_ptr in this lesson, which can be copied but, for scenarios where we don’t need to support copying, std::unique_ptr lets us go from this huge blob of code:

class Component {
public:
  Component()
  : Resource{new int{42}}
  {}

  ~Component() { delete Resource; }

  // We don't support copying
  Component(const Component& Other) = delete;
  Component& operator=(const Component& Other)
    = delete;

  // Move Semantics
  Component(Component&& Other) {
    Resource = Other.Resource;
    Other.Resource = nullptr;
  }

  Component& operator=(Component&& Other) {
    if (&Other == this) return *this;
    delete Resource;
    Resource = Other.Resource;
    Other.Resource = nullptr;
  }

private:
  int* Resource{nullptr};
};

To this, which behaves in substantitively the same way whilst being much easier to create and maintain:

#include <memory>

class Component {
public:
  Component()
  : Resource{std::make_unique<int>(42)}
  {}
  
private:
  std::unique_ptr<int> Resource{nullptr};  
};

Custom Deleters

There is some additional complexity involved if we wanted to let a std::unique_ptr manage a resource like an SDL_Surface: a std::unique_ptr doesn’t know how to delete an SDL_Surface.

Unless we intervene, smart pointers assume they can delete resources just by using the delete operator. That’s a sensible default, but it’s not always the case. For example, we shouldn’t delete an SDL_Surface. SDL is designed such that we need to pass its pointer to SDL_FreeSurface() instead.

To let a std::unique_ptr know how to delete a resource, we can provide it with an additional argument. This argument will be a function (or something that behaves like a function) that the smart pointer will call when it’s time to delete the resource. The function will be provided with the raw pointer to the resource, and it is responsible for deleting it.

Let’s see a minimalist example of this. We’ll provide our custom deleter as an object that overloads the () operator, thereby allowing it to be called like a function. Such an object is sometimes called a function object or a functor:

#include <iostream>
#include <memory>

struct IntDeleter {
  void operator()(int* Ptr) const {
    std::cout << "Deleting that " << *Ptr;
    delete Ptr;
  }
};

class Component {
  std::unique_ptr<int, IntDeleter> Resource{
    new int{42}};
};

int main(int argc, char** argv) {
  Component C;
  return 0;
}
Deleting that 42

Let’s see an SDL_Surface example, where our deleter uses SDL_FreeSurface() rather than delete:

#include <SDL.h>
#include <iostream>
#include <memory>

struct SurfaceDeleter {
  void operator()(SDL_Surface* Ptr) const {
    std::cout << "Deleting that surface";
    SDL_FreeSurface(Ptr);
  }
};

using SDLSurfacePtr =
  std::unique_ptr<SDL_Surface, SurfaceDeleter>;
class Component {
  SDLSurfacePtr Resource{new SDL_Surface{}};
};

int main(int argc, char** argv) {
  Component C;
  return 0;
}
Deleting that surface

This approach let’s the smart pointer maintain responsibility for when a resource is deleted, whilst we take responsibility for defining how it is deleted.

Note that the std::make_unique() function doesn’t allow us to define a custom deleter. If we need a custom deleter, we need to construct a std::unique_ptr directly.

If you want to learn more about functors, we have a dedicated lesson here:

Shared Pointers

Letting our SDL_Surface be managed using a unique_ptr with a custom deleter would simplify our ImageComponent quite a lot, but it wouldn’t solve the memory usage problem.

A std::unique_ptr is inherently designed such that the object that holds the unique pointer (the ImageComponent) is the unique owner of the resource (the SDL_Surface).

To solve the memory usage problem, our image components need to be able to share surfaces. Predictably, we have the std::shared_ptr to manage ownership in these scenarios. Below, we’ll introduce the key characteristics of std::shared_ptr that we’ll need for our ImageComponent, but our advanced course has a full lesson if you want more detail:

Shared pointers have two key differences:

  • They can be copied, meaning multiple objects and/or functions can have a shared pointer to the same underlying resource. Conceptually, any object with a copy of the shared pointer is sharing ownership of the underlying resource.
  • Behind the scenes, the std::shared_ptr keeps track of how many owners it has. It uses a technique called reference counting - a simple integer that is incremented every time a pointer is copied, and decremented very time a copy is destroyed. When the reference count reaches 0, that implies the resource has no owners remaining, and can safely be deleted.

Shared pointers include a use_count() function that lets us return the reference count - that is, how many owners the resource currently has.

Here’s an example that shows a shared pointer in action:

#include <iostream>
#include <memory>

struct IntDeleter {
  void operator()(int* Ptr) const {
    std::cout << "Finally deleting that resource";
    delete Ptr;
  }
};

struct Component {
  std::shared_ptr<int> Resource{
    new int{42}, IntDeleter{}};
};

int main(int argc, char** argv) {
  Component* C1{new Component{}};
  std::cout << "Resource is shared by "
    << C1->Resource.use_count() << " object\n";

  // Create a copy
  Component* C2{new Component{*C1}};
  std::cout << "Resource is shared by "
    << C1->Resource.use_count() << " objects\n";

  // Delete a copy
  delete C2;
  std::cout << "Resource is shared by "
    << C1->Resource.use_count() << " object\n";

  // Delete the last copy
  delete C1;

  return 0;
}
Resource is shared by 1 object
Resource is shared by 2 objects
Resource is shared by 1 object
Finally deleting that resource

Reference Counting Requires Copying

Note that the reference counting mechanism of shared pointers requires that, if we want a shared pointer to be added to a the group of shared pointers that owns a resource, we need to copy one of those pointers.

If we create a new shared pointer that just happens to point to the same memory address, we’ve broken our memory ownership model. That new pointer won’t know the other pointers exist, and vice versa.

#include <iostream>
#include <memory>

int main(int argc, char** argv) {
  std::shared_ptr<int> Resource{new int{42}};
  std::cout << "Resource is shared by "
    << Resource.use_count() << " object\n";

  // Don't do this
  std::shared_ptr<int> Resource2{Resource.get()};

  std::cout << "Resource still thinks it is "
    << "shared by only " << Resource.use_count()
    << " object";

  return 0;
}
Resource is shared by 1 object
Resource still thinks it is shared by only 1 object

This leads to eventual memory issues where the resource will be deleted too early - that is, the first shared pointer group to reach a reference count of 0 will delete the resource, even though the other group still exists,

Creating an Asset Manager

Let’s apply these concepts to address the memory usage problem in our ImageComponent. We want to let image components that are showing the same image share the SDL_Surface data for that image.

To implement this, we need some central repository - an asset manager - where all of our components can retrieve the shared data from. We’ll create one for SDL_Surface data in this section, but the same technique can be applied to any data type.

Central to this mechanism is the idea of caching. Caching is useful in situations where two things are true:

  • Our program needs access to some result that is expensive to generate, such as an SDL_Surface pointer loaded with data from an image file
  • Our program needs that result multiple times, such as multiple ImageComponent instances needing that same SDL_Surface.

We can apply caching to this process by storing the result of each unique request. The first time a component requests the data for some image, the asset manager will construct an SDL_Surface containing that data, and will dutifully return it.

However, the asset manager will also cache (remember) that result so, if another component requests the surface for that exact same image, it doesn’t need to load it again. That result is already in our cache, so we can just retrieve it from there and return it.

If a caching system is able to return a result that is stored in its cache, that is called a cache hit. If it wasn’t in the cache and we had to perform the more expensive operation of generating a new result, that is called a cache miss.

An unordered map is one of the easiest ways we can implement a cache:

  • The key of each entry will be something that let’s us uniquely identify the surface being requested. The source image’s file name is the natural choice here.
  • The value of each entry will be a pointer to SDL_Surface associated with the image. Our goal is to let our ImageComponent instances share the surfaces, and we’d like to automate the memory management, so our values will be std::shared_ptr<SDL_Surface> instances

Let’s create an AssetManager class to manage our cache:

// AssetManager.h
#pragma once
#include <SDL.h>
#include <memory> // for std::shared_ptr
#include <string>
#include <unordered_map>

// Custom deleter for an SDL_Surface
struct SDLSurfaceDeleter {
  void operator()(SDL_Surface* Surface) const {
    SDL_FreeSurface(Surface);
  }
};

using SurfacePtr = std::shared_ptr<SDL_Surface>;

// Map file path to shared surface pointer
using CacheMap =
  std::unordered_map<std::string, SurfacePtr>;

class AssetManager {
private:
  CacheMap SurfaceCache;
};

When a component requests a surface from our asset manager, it will first check its cache to see if it has already loaded that surface. If it has, it will return it from the cache - no loading necessary:

// AssetManager.h
// ...

#include <iostream> // for std::cout

// ...

class AssetManager {
 public:
  // Load a surface, reusing if already loaded
  SurfacePtr LoadSurface(const std::string& Path) {
    if (SurfaceCache.contains(Path)) {
      // This surface was previously requested
      // and is in the cache
      std::cout << "CACHE HIT: " << Path << '\n';
      return SurfaceCache[Path];
    }
    
    // We haven't seen a request for this surface
    // before.  We'll handle this next
    // ...
  }
  
  // ...
};

If it is not in the cache, the asset manager will load the resource, store it in the cache in case another component requests the same resource in the future, and then return it:

// AssetManager.h
// ...

#include <SDL_image.h> // for IMG_Load

// ...

class AssetManager {
 public:
  // Load a surface, reusing if already loaded
  SurfacePtr LoadSurface(const std::string& Path) {
// Cache Miss - need to load a new surface std::cout << "CACHE MISS: " << Path << '\n'; SDL_Surface* Surface{IMG_Load(Path.c_str())}; if (!Surface) { std::cerr << "AssetManager failed to load: " << Path << " Error: " << IMG_GetError() << '\n'; return nullptr; // Return null on failure } // Create shared_ptr with custom deleter SurfacePtr SharedSurface( Surface, SDLSurfaceDeleter{} ); // Store in cache and return SurfaceCache[Path] = SharedSurface; return SharedSurface; } // ... };

Our asset managers don’t need to be movable or copyable, so let’s finish off our class by deleting those operations, and readding the default constructor:

// AssetManager.h
// ...

class AssetManager {
public:
  AssetManager() = default;

  // Prevent copying/moving
  AssetManager(const AssetManager&) = delete;
  AssetManager& operator=(const AssetManager&) = delete;
  AssetManager(AssetManager&&) = delete;
  AssetManager& operator=(AssetManager&&) = delete;
  
  // ...
};

If you want to learn more about std::unordered_map, we have a dedicated lesson here:

Advanced: Deleting Cache Entries

Our simple caching implementation never removes anything from the cache - every new file path we encounter has its surface added to the cache until our program ends. This is fine in simple games - we won’t have that many unique images. However, in projects with a lot of different assets, we typically need to limit the size of our cache.

Enforcing that limit, and deciding which items to remove naturally requires additional design decisions and logic in our asset manager. A common design is to specify a size limit and, when we breach that limit, delete the cache items that haven’t been accessed recently. A cache that follows this pattern is called an LRU cache, where LRU is an abbreviation for least recently used.

Remember, just because we delete an item from the cache, that doesn’t mean the resource is deleted in memory. We’re using std::shared_ptr - the pointer held by the cache is just one of potentially many owners. The other owners can continue to use the underlying resource safely, and it won’t be deleted until all them are deleted or they give up their ownership in some other way.

Using the AssetManager

Now that we can create asset managers, let’s add one for our scene and give our components access to it.

Constructing the Asset Manager

We’d like to create our asset manager in a single place, and ideally that place is somewhere it can be easily accessed by ImageComponent instances. In out design, the Scene seems like the natural choice, so let’s construct it there, and provide a getter.

We’ll also load some duplicate images so, once our ImageComponent has been updated to use our asset manager, we can see it working:

// Scene.h
// ...
#include "AssetManager.h" 
// ...

class Scene {
 public:
  Scene() {
    EntityPtr& Forest{Entities.emplace_back(
      std::make_unique<Entity>())};

    Forest->AddTransformComponent();
    Forest->AddImageComponent("tree.png");
    Forest->AddImageComponent("tree.png");
    Forest->AddImageComponent("tree.png");
    Forest->AddImageComponent("tree.png");
    Forest->AddImageComponent("tree.png");
  }

  // Public access to the Asset Manager if
  // needed elsewhere
  AssetManager& GetAssetManager() {
    return Assets;
  }
  
  // ...
  
 private:
  AssetManager Assets;
  // ...
};

Retrieving the Asset Manager from Components

Neither our entities nor our components currently have access to the Scene they’re part of. Let’s fix that.

We’ll update our Entity class to receive the Scene as a constructor argument, store it in a member variable, and provide access to it through a getter:

// Entity.h
// ...

class Scene;

class Entity {
public:
  Entity(Scene& Scene) : OwningScene{Scene} {}
  Scene& GetScene() const { return OwningScene; }
  // ...

private:
  Scene& OwningScene;
  // ...
};

Let’s provide this argument from our Scene:

// Scene.h
// ...

class Scene {
 public:
  Scene() {
    EntityPtr& Forest{Entities.emplace_back(
      std::make_unique<Entity>(*this))};
    // ...
  }
  
  // ...
};

Components can now access the scene and asset manager through a chain of getters:

GetOwner()->GetScene().GetAssetManager()

We can make this easier for our components by providing some helpers in the Component base class:

// Component.h
// ...

class Scene;
class AssetManager;

class Component {
 public:
  // ...
  Scene& GetScene() const;
  AssetManager& GetAssetManager() const;
  // ...
};
// Component.cpp
#include "Component.h"
#include "Entity.h"
#include "Scene.h"

Scene& Component::GetScene() const {
  return GetOwner()->GetScene();
}

AssetManager& Component::GetAssetManager() const {
  return GetScene().GetAssetManager();
}

Components can now retrieve the scene and asset manager through GetScene() and GetAssetManager() respectively.

Updating ImageComponent

Let’s update our ImageComponent to make use of our asset manager. Thankfully, this mostly involves deleting things, which is a good indicator that our code is becoming easier to maintain

The ImageComponent would then be modified in the following ways:

  1. Instead of holding a raw SDL_Surface*, we hold a shared_ptr<SDL_Surface>. Any time we need the raw SDL_Surface* - such as for use with functions like SDL_BlitSurface() - we can retrieve it using the shared pointer’s get() method.
  2. We’ll replace any calls to IMG_Load() with a call to our asset manager’s LoadSurface() function. We are currently calling IMG_Load() in the ImageComponent constructor.
  3. Because shared_ptr handles all the ownership and cleanup logic correctly, the ImageComponent doesn’t need custom destructor, copy constructor, copy assignment, move constructor, or move assignment operators anymore. The default compiler-generated versions would work perfectly with the shared_ptr member (following the Rule of Zero).

Here are our changes:

// ImageComponent.h
#pragma once
#include <memory> // for std::shared_ptr 
#include <string>
#include <SDL.h>
#include "Component.h"

class ImageComponent : public Component {
 public:
  using Component::Component;
  ImageComponent(
    Entity* Owner,
    const std::string& FilePath
  );

  // No custom destructor, copy/move ops needed! 
  ~ImageComponent() override; 
  ImageComponent(const ImageComponent& Other); 
  ImageComponent& operator=(const ImageComponent& Other); 
  ImageComponent(ImageComponent&& Other) noexcept; 
  ImageComponent& operator=(ImageComponent&& Other) noexcept; 

  void Initialize() override;
  void Render(SDL_Surface* Surface) override;

private:
  // ImageSurface has a new type
  SDL_Surface* ImageSurface{nullptr};
  std::shared_ptr<SDL_Surface> ImageSurface{nullptr};
  std::string ImageFilePath;
  
  // We don't need this any more, either
  void LoadSurfaceInternal();
};
// ImageComponent.cpp
#include "ImageComponent.h"
#include "Entity.h"
#include "SDL_image.h" 
#include "AssetManager.h" 

ImageComponent::ImageComponent(
  Entity* Owner,
  const std::string& FilePath
) : Component(Owner),
    ImageFilePath(FilePath) {
  ImageSurface = IMG_Load(FilePath.c_str()); 
  ImageSurface = GetAssetManager()
    .LoadSurface(ImageFilePath);
}

void ImageComponent::Render(
  SDL_Surface* Surface
) {
  // ...
  // The render function is unchanged for now, but
  // in the future, we will need the SDL_Surface*
  // raw pointer for interaction with SDL functions
  // such as SDL_BlitSurface().  We can retrieve it
  // from the shared_ptr using ImageSurface.get()
}

void ImageComponent::Initialize() {/*...*/} // All other functions are deleted

Now, even though our Scene is showing five tree images, we only load and store the data from tree.png the first time it is requested. All subsequent requests receive a pointer to the data we already have in the cache:

CACHE MISS: tree.png
CACHE HIT: tree.png
CACHE HIT: tree.png
CACHE HIT: tree.png
CACHE HIT: tree.png

Image Switching

What if we want our character to change its appearance during the game? Maybe it picks up a power-up, takes damage, or equips different armor. We need a way for an existing ImageComponent to switch to displaying a different image file.

Let's add a public method called LoadNewImage(). It will take the file path of the new image as an argument:

It's helpful if this method returns bool to indicate whether loading the new image was successful. First, let’s declare LoadNewImage() in ImageComponent.h.

// ImageComponent.h
// ...

class ImageComponent : public Component {
 public:
  // ... 

  bool LoadNewImage(const std::string& NewPath);  

 // ...
};

Inside, it should update the component's ImageFilePath, loading the new surface from the asset manager:

// ImageComponent.cpp
// ...

bool ImageComponent::LoadNewImage(
  const std::string& NewPath
) {
  // Store the new file path
  ImageFilePath = NewPath;
  
  // Use our asset manager
  ImageSurface = GetAssetManager()
    .LoadSurface(NewPath);
    
  // Return true if the load was
  // successful (surface is not null)
  return ImageSurface != nullptr;
}

Now, code elsewhere in our game can easily change an entity's image on the fly! For example, imagine some game logic:

// Somewhere in your game (e.g., Player class)

void Player::TakeDamage() {
  // Assume Health is a member variable
  Health -= 10;
  if (Health < 50) {
    // Load damaged state if health is low
    // Assume Img is an ImageComponent
    Img->LoadNewImage("player_damaged.png");
  }
}

void Player::PickupPowerup() {
  if (Img->LoadNewImage("player_powered_up.png")) {
    std::cout << "Player image changed!\n";
  } else {
    std::cerr << "Failed to load powerup image!\n";
    // Maybe revert to old image or handle error
  }
}

This gives us a lot more dynamic control over the visual representation of our entities without needing to destroy and recreate components.

Complete Code

Below, we’ve provided the complete AssetManager and ImageComponent classes we created in this lesson. We’ll continue working on these in the next lesson.

#pragma once
#include <memory>
#include <string>
#include <SDL.h>
#include "Component.h"

class ImageComponent : public Component {
 public:
  using Component::Component;
  ImageComponent(
    Entity* Owner,
    const std::string& FilePath
  );

  void Initialize() override;
  void Render(SDL_Surface* Surface) override;
  bool LoadNewImage(const std::string& NewPath);

private:
  std::shared_ptr<SDL_Surface> ImageSurface{nullptr};
  std::string ImageFilePath;
};
#include "ImageComponent.h"
#include "Entity.h"
#include "AssetManager.h"

ImageComponent::ImageComponent(
  Entity* Owner,
  const std::string& FilePath
) : Component(Owner),
    ImageFilePath(FilePath
) {
  ImageSurface = GetAssetManager()
    .LoadSurface(ImageFilePath);
}

void ImageComponent::Render(
  SDL_Surface* Surface
) {
  if (!ImageSurface) return;

  TransformComponent* Transform{
    GetOwner()->GetTransformComponent()};

  if (Transform) {
    std::cout << "ImageComponent ("
      << ImageFilePath
      << ") ready to render at: "
      << Transform->GetPosition() << '\n';
  } else {
    std::cout << "ImageComponent ("
      << ImageFilePath
      << ") ready, but no TransformComponent found\n";
  }
}

bool ImageComponent::LoadNewImage(
  const std::string& NewPath
) {
  ImageFilePath = NewPath;
  ImageSurface = GetAssetManager()
    .LoadSurface(NewPath);
  return ImageSurface != nullptr;
}

void ImageComponent::Initialize() {
  Entity* Owner{GetOwner()};
  if (!Owner->GetTransformComponent()) {
    std::cout <<
      "Error: ImageComponent requires"
      " TransformComponent on its Owner\n";

    Owner->RemoveComponent(this);
  }
}
#pragma once
#include <SDL.h>
#include <memory>
#include <string>
#include <unordered_map>
#include <SDL_image.h>
#include <iostream>

struct SDLSurfaceDeleter {
  void operator()(SDL_Surface* Surface) const {
    SDL_FreeSurface(Surface);
  }
};

using SurfacePtr = std::shared_ptr<SDL_Surface>;
using CacheMap =
  std::unordered_map<std::string, SurfacePtr>;

class AssetManager {
 public:
  SurfacePtr LoadSurface(const std::string& Path) {
    if (SurfaceCache.contains(Path)) {
      std::cout << "CACHE HIT: " << Path << '\n';
      return SurfaceCache[Path];
    }

    std::cout << "CACHE MISS: " << Path << '\n';
    SDL_Surface* Surface{IMG_Load(Path.c_str())};
    if (!Surface) {
      std::cerr << "AssetManager failed to load: "
                << Path << " Error: "
                << IMG_GetError() << '\n';
      return nullptr;  // Return null on failure
    }

    SurfacePtr SharedSurface(
      Surface, SDLSurfaceDeleter{}
    );

    SurfaceCache[Path] = SharedSurface;
    return SharedSurface;
  }

  // Prevent copying/moving
  AssetManager() = default;
  AssetManager(const AssetManager&) = delete;
  AssetManager& operator=(const AssetManager&) = delete;
  AssetManager(AssetManager&&) = delete;
  AssetManager& operator=(AssetManager&&) = delete;

 private:
  CacheMap SurfaceCache;
};

We also made smaller changes to our Scene, Entity, and Component classes. These are provided below, with our changes highlighted:

#pragma once
#include <SDL.h>
#include <vector>
#include "AssetManager.h"
#include "Entity.h"

using EntityPtr = std::unique_ptr<Entity>;
using EntityPtrs = std::vector<EntityPtr>;

class Scene {
 public:
  Scene() {
    // Entities constructor now requires a
    // reference to the scene
    EntityPtr& Player{Entities.emplace_back(
      std::make_unique<Entity>(*this))};

    Player->AddTransformComponent();
    Player->AddImageComponent("player.png");
  }

  void HandleEvent(SDL_Event& E) {
    for (EntityPtr& Entity : Entities) {
      Entity->HandleEvent(E);
    }
  }

  void Tick(float DeltaTime) {
    for (EntityPtr& Entity : Entities) {
      Entity->Tick(DeltaTime);
    }
  }

  void Render(SDL_Surface* Surface) {
    for (EntityPtr& Entity : Entities) {
      Entity->Render(Surface);
    }
  }

  AssetManager& GetAssetManager() {
    return Assets;
  }

 private:
  EntityPtrs Entities;
  AssetManager Assets;
};
#pragma once
#include <memory>
#include <vector>
#include <SDL.h>
#include "Component.h"
#include "TransformComponent.h"
#include "ImageComponent.h"

class Scene;
using ComponentPtr = std::unique_ptr<Component>;
using ComponentPtrs = std::vector<ComponentPtr>;

class Entity {
public:
  Entity(Scene& Scene) : OwningScene{Scene} {}
  Scene& GetScene() const { return OwningScene; }

  virtual void HandleEvent(const SDL_Event& E) {
    for (ComponentPtr& C : Components) {
      C->HandleEvent(E);
    }
  }

  virtual void Tick(float DeltaTime) {
    for (ComponentPtr& C : Components) {
      C->Tick(DeltaTime);
    }
  }

  virtual void Render(SDL_Surface* Surface) {
    for (ComponentPtr& C : Components) {
      C->Render(Surface);
    }
  }

  virtual ~Entity() = default;

  TransformComponent* AddTransformComponent() {
    if (GetTransformComponent()) {
      std::cout << "Error: Cannot have "
        "multiple transform components";
      return nullptr;
    }

    std::unique_ptr<Component>& NewComponent{
      Components.emplace_back(
        std::make_unique<
          TransformComponent>(this))};

    NewComponent->Initialize();

    return static_cast<TransformComponent*>(
      NewComponent.get());
  }

  TransformComponent* GetTransformComponent() const {
    for (const ComponentPtr& C : Components) {
      if (auto Ptr{
        dynamic_cast<TransformComponent*>(C.get())
      }) {
        return Ptr;
      }
    }
    return nullptr;
  }

  ImageComponent* AddImageComponent(
    const std::string& FilePath
  ) {
    std::unique_ptr<Component>& NewComponent{
      Components.emplace_back(
        std::make_unique<ImageComponent>(
          this, FilePath))};

    NewComponent->Initialize();

    return static_cast<ImageComponent*>(
      NewComponent.get());
  }

  using ImageComponents =
    std::vector<ImageComponent*>;
  ImageComponents GetImageComponents() const {
    ImageComponents Result;
    for (const ComponentPtr& C : Components) {
      if (auto Ptr{dynamic_cast<
        ImageComponent*>(C.get())}
      ) {
        Result.push_back(Ptr);
      }
    }
    return Result;
  }

  void RemoveComponent(Component* PtrToRemove) {
    for (size_t i{0}; i < Components.size(); ++i) {
      if (Components[i].get() == PtrToRemove) {
        Components.erase(Components.begin() + i);
        return;
      }
    }

    std::cout << "Warning: Attempted to remove "
      "a component not found on this entity.\n";
  }

private:
  ComponentPtrs Components;
  Scene& OwningScene;
};
#pragma once
#include <SDL.h>

class Entity;
class Scene;
class AssetManager;

class Component {
 public:
  Component(Entity* Owner) : Owner(Owner) {}
  virtual void Initialize() {}
  virtual void HandleEvent(const SDL_Event& E) {}
  virtual void Tick(float DeltaTime) {}
  virtual void Render(SDL_Surface* Surface) {}
  virtual ~Component() = default;

  Entity* GetOwner() const { return Owner; }
  Scene& GetScene() const;
  AssetManager& GetAssetManager() const;

private:
  Entity* Owner{nullptr};
};
#include "Component.h"
#include "Entity.h"
#include "Scene.h"

Scene& Component::GetScene() const {
  return GetOwner()->GetScene();
}

AssetManager& Component::GetAssetManager() const {
  return GetScene().GetAssetManager();
}

Summary

This lesson focused on optimizing the ImageComponent. We moved away from individual components managing their own unique SDL_Surface copies and the associated Rule of Five complexity. Instead, we implemented a shared resource model using std::shared_ptr and an AssetManager.

This central manager caches surfaces, ensuring that an image file is loaded only once, and components share pointers to this cached data, saving memory and simplifying the component's implementation to follow the Rule of Zero.

Key Takeaways:

  • The Rule of Five becomes necessary when a class directly manages raw resources, increasing code complexity.
  • Storing duplicate data (e.g., identical images) consumes unnecessary memory.
  • std::shared_ptr enables shared ownership, deleting the resource only when the last owner is destroyed.
  • SDL resources like SDL_Surface require custom cleanup - SDL_FreeSurface() - which can be handled by custom deleters in smart pointers.
  • An asset manager acts as a central repository and cache for shared resources, typically using a map from an identifier (like a file path) to the resource pointer.
  • By delegating resource management to smart pointers and manager classes, components can often avoid needing any custom destructor/copy/move logic (Rule of Zero).
Free and Unlimited Access

Professional C++

Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development

Screenshot from Warhammer: Total War
Screenshot from Tomb Raider
Screenshot from Jedi: Fallen Order
Ryan McCombe
Ryan McCombe
Posted
sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Get Started for Free
Creating Components
Free and Unlimited Access

Professional C++

Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development

Screenshot from Warhammer: Total War
Screenshot from Tomb Raider
Screenshot from Jedi: Fallen Order
Contact|Privacy Policy|Terms of Use
Copyright © 2025 - All Rights Reserved