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:
std::shared_ptr
manages shared resources.AssetManager
for SDL_Surface
data.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;
}
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.
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.
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.
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};
};
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:
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:
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
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,
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:
SDL_Surface
pointer loaded with data from an image fileImageComponent
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:
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>
instancesLet’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:
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.
AssetManager
Now that we can create asset managers, let’s add one for our scene and give our components access to it.
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;
// ...
};
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.
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:
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.IMG_Load()
with a call to our asset manager’s LoadSurface()
function. We are currently calling IMG_Load()
in the ImageComponent
constructor.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
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.
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();
}
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:
std::shared_ptr
enables shared ownership, deleting the resource only when the last owner is destroyed.SDL_Surface
require custom cleanup - SDL_FreeSurface()
- which can be handled by custom deleters in smart pointers.Optimize image loading using shared pointers and an asset manager for better memory use and simpler code.
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games