Basic Resource Management in a Custom Game Engine
- Mike Galinski
- Game programming
- October 4, 2024
Resource management in game engines is a wide term. It ranges from handling freshly exported assets to managing assets loaded into memory, in a specific form for particular engine modules. In this article I describe the most basic form of runtime resource management – based on sharing a resource loaded only once into memory.
Background
I present this topic in the context of a simple game engine I coworked on, written in 4 months, in modern C++ and DirectX 11 for courses conducted by Lodz University of Technology. Aimed at students and beginners, it’s a guide based on our approach, working within limited time and resources to create a simple, yet fully functional engine. I don’t claim to be an expert, and my coding style may differ from what you’d find in AAA studios, but I hope our experience helps those taking their first steps in engine programming. And my team and I are very proud of that.
Why Basic Resource Management Is Important
It’s the memory cost, CPU efficiency, and loading times. Let’s recall your first graphics programming project. Maybe it was a graphics programming course at your university or you were just playing with learnopengl.com, or something else. Did you pay much attention to how often are the assets loaded?
Let’s investigate a scene having 20 identical models (with multiple meshes) and 20 identical textures of the “level completed” panel. How many times are these assets loaded?
Info
Without runtime resource manager, the same model and texture will be loaded into memory 20 times – for each instance.
With runtime resource manager, the same model and texture will be loaded into memory only once – with the first instance.
❌ without runtime resource manager | ✅ with runtime resource manager | |
---|---|---|
memory cost |
1.6 GB |
678 MB |
peak CPU % while loading |
21% |
14% |
load time | 2.280 s | 0.645 s |
This is one of the first things we wanted to pay attention to when transistioning our project from a small graphics programming application to a simple framework/engine. Such improvement was easy to implement for us, and it can be good enough in terms of optimizing memory and CPU efficiency in your first game engine too.
Implementation
Firstly, let’s create a ResourceManager
class and make it a Meyers’ Singleton. It’s not the devil here. Singletons are often used in game engines, and having a global access to this only instance is really helpful. You also probably don’t bother about thread-safety in you first, single-threaded, game engine. But keep in mind that you should know implications of using particular patterns if you want to use them.
In our case, we list the following types of resources: shaders, textures, and meshes.
Note
– What about sounds?
– They are handled differently via miniaudio library which takes care of reference counting.
Let’s declare methods for loading our resources. We’re trying to make sort of a unified interface here, but you will see how those loader methods reference other subsystems and delegate loading resources to particular factory methods in different modules.
// ResourceManager.h:
std::shared_ptr<Texture> load_texture(std::string const& path, TextureType const type, TextureSettings const& settings = {});
std::shared_ptr<Texture> load_cubemap(std::vector<std::string> const& paths, TextureType const type,
TextureSettings const& settings = {});
std::shared_ptr<Texture> load_cubemap(std::string const& path, TextureType const type, TextureSettings const& settings = {});
std::shared_ptr<Shader> load_shader(std::string const& compute_path);
std::shared_ptr<Shader> load_shader(std::string const& vertex_path, std::string const& pixel_path);
std::shared_ptr<Shader> load_shader(std::string const& vertex_path, std::string const& pixel_path, std::string const& geometry_path);
std::shared_ptr<Shader> load_shader(std::string const& vertex_path, std::string const& tessellation_control_path,
std::string const& tessellation_evaluation_path, std::string const& pixel_path);
std::shared_ptr<Mesh> load_mesh(u32 const array_id, std::string const& name, std::vector<Vertex> const& vertices,
std::vector<u32> const& indices, std::vector<std::shared_ptr<Texture>> const& textures,
DrawType const draw_type, std::shared_ptr<Material> const& material,
DrawFunctionType const draw_function = DrawFunctionType::Indexed);
Each of these methods is responsible for loading a resource. As you can see e.g. load_shader()
methods are overloaded, as different shaders can be loaded. Next, let’s declare vectors that store our resources:
std::vector<std::shared_ptr<Texture>> m_textures = {};
std::vector<std::shared_ptr<Mesh>> m_meshes = {};
std::vector<std::shared_ptr<Shader>> m_shaders = {};
Here are the unordered maps storing keys and IDs in the respective m_textures
/m_meshes
/m_shaders
vectors.
std::unordered_map<std::string, u16> names_to_textures = {};
std::unordered_map<std::string, u16> names_to_meshes = {};
std::unordered_map<std::string, u16> names_to_shaders = {};
Generating keys for a resource manager is convenient when using strings, and std::unordered_map
is a hashmap, actually. Because of working with hashes, complexity of hashmap operations (inserting, deletions, lookup…) is O(1). So std::unordered_map
fits well here.
Let’s also declare a simple method that just converts a stringstream to string, this is for convenience, and this is, in fact, generating a string key:
[[nodiscard]] std::string generate_key(std::stringstream const& stream) const;
…and a method for retrieving an element from a vector. This is a template method (compile time goes brrr!) but in this simple case it could be just three similar methods for each type of resource. It might look a bit ugly for you, but handling it like that is not necessarily bad. The resources are vastly different, and the more types of them you have, the harder it gets to prepare a unified approach. The nature of a resource manager itself often results in methods like this:
template<typename T>
std::shared_ptr<T> get_from_vector(std::string const& key)
{
i32 id = -1;
if constexpr (std::is_same_v<T, Texture>)
{
auto const it = names_to_textures.find(key);
if (it != names_to_textures.end())
{
id = it->second;
return m_textures[id];
}
}
else if constexpr (std::is_same_v<T, Mesh>)
{
auto const it = names_to_meshes.find(key);
if (it != names_to_meshes.end())
{
id = it->second;
return m_meshes[id];
}
}
else if constexpr (std::is_same_v<T, Shader>)
{
auto const it = names_to_shaders.find(key);
if (it != names_to_shaders.end())
{
id = it->second;
return m_shaders[id];
}
}
return nullptr;
}
It could be automated with some autogenerated code (we have an “Engine Header Tool” in our project used for e.g. generating (de)serialization code for components). Have a better idea for implementing it? Post it in a comment below!
The above function, firstly, checks which type of resource are we dealing with (e.g. if constexpr (std::is_same_v<T, Texture>)
is true if the given type is Texture
) – this decides in which unordered map are we going to look for the key, and in which vector – for the resource. Then we’re using std::unordered_map::find()
to find the key in our unordered map. If this key is found, we return the ID of the resource paired with the key. If not, we return nullptr
.
We’re lacking the last peace of the puzzle. How do the loading methods work? And maybe at this point the idea of “keys” representing assets is still murky? Let me clarify:
// ResourceManager.cpp:
std::shared_ptr<Shader> ResourceManager::load_shader(std::string const& vertex_path, std::string const& pixel_path)
{
std::stringstream stream;
stream << vertex_path << pixel_path;
std::string const& key = generate_key(stream);
auto resource_ptr = get_from_vector<Shader>(key);
if (resource_ptr != nullptr)
return resource_ptr;
resource_ptr = ShaderFactory::create(vertex_path, pixel_path);
m_shaders.emplace_back(resource_ptr);
names_to_shaders.insert(std::make_pair(key, m_shaders.size() - 1));
return resource_ptr;
}
This is a load_shader()
function for shader programs in which we specify just a vertex shader and a pixel (fragment) shader. Again, shader file A is different from shader file B. So we generate a key of this shader asset from the paths of vertex and pixel shader!
We declare a stringstream and fill it with vertex_path
and pixel_path
. Then we use generate_key()
method that will convert the stringstream to string and return it:
std::string ResourceManager::generate_key(std::stringstream const& stream) const
{
return stream.str();
}
Then, in load_shader()
, we obtain a resource from the respective vector (auto resource_ptr = get_from_vector<Shader>(key);
) We tell what type of resource it is as a template parameter. And we will have either a proper resource (so we can return it and save memory, yay!) or we have a nullptr
if nothing was found in the vector.
Look again what happens when no resource was found:
// (...)
resource_ptr = ShaderFactory::create(vertex_path, pixel_path);
m_shaders.emplace_back(resource_ptr);
names_to_shaders.insert(std::make_pair(key, m_shaders.size() - 1));
return resource_ptr;
When resource was not found in the vector, it needs to be loaded. And it might vary from resource to resource. Here we call a factory method that will load and create a valid shader program, emplace_back()
it into the vector, and insert()
a key-ID pair into the unordered map. From now on, the same resource (with this key) will no longer be loaded again, resource manager will just return already loaded asset from the vector. ShaderFactory::create()
can be replaced with your method that’s used for loading and compiling shaders, of course.
Let’s see another example. Meshes can be either parts of some models (loaded using libraries such as tinygltf or Assimp) or they can be generated from code (e.g. cubes and spheres). How to properly distinguish meshes to load them only when needed?
Here’s the trick: we used a mesh ID within the model, and paths to all textures in addition to a model name to generate a key.
std::shared_ptr<Mesh> ResourceManager::load_mesh(u32 const array_id, std::string const& model_name, std::vector<Vertex> const& vertices,
std::vector<u32> const& indices, std::vector<std::shared_ptr<Texture>> const& textures,
DrawType const draw_type, std::shared_ptr<Material> const& material,
DrawFunctionType const draw_function)
{
std::stringstream stream;
stream << model_name << array_id;
for (auto const& texture : textures)
{
stream << texture->path;
}
std::string const& key = generate_key(stream);
auto resource_ptr = get_from_vector<Mesh>(key);
if (resource_ptr != nullptr)
return resource_ptr;
resource_ptr = MeshFactory::create(vertices, indices, textures, draw_type, material, draw_function);
m_meshes.emplace_back(resource_ptr);
names_to_meshes.insert(std::make_pair(key, m_meshes.size() - 1));
return resource_ptr;
}
Processing all meshes within one model, in a Model
class, looks like this now:
// Model.cpp:
void Model::proccess_node(aiNode const* node, aiScene const* scene)
{
for (u32 i = 0; i < node->mNumMeshes; ++i)
{
aiMesh const* mesh = scene->mMeshes[node->mMeshes[i]];
m_meshes.emplace_back(proccess_mesh(mesh, scene));
}
// (...)
}
std::shared_ptr<Mesh> Model::proccess_mesh(aiMesh const* mesh, aiScene const* scene)
{
// (...)
// m_meshes is being filled, so m_meshes.size() gets incremented and it's unique each iteration
return ResourceManager::get_instance().load_mesh(m_meshes.size(), model_path, vertices,
indices, textures, m_draw_type, material); // array_id for generating key is an index in m_meshes
}
Passing a size of m_meshes
to ResourceManager
when filling m_meshes
to generate a key is smart and easy! But what if a mesh is generated, e.g. it’s a simple code-generated cube or a quad? Take a look:
// "Button", a class that needs to create a mesh from code. Button.cpp:
std::shared_ptr<Mesh> Button::create_sprite() const
{
std::vector<Vertex> const vertices = {
{glm::vec3(-1.0f, -1.0f, 0.0f), {}, {0.0f, 0.0f}}, // bottom left
{glm::vec3(1.0f, -1.0f, 0.0f), {}, {1.0f, 0.0f}}, // bottom right
{glm::vec3(1.0f, 1.0f, 0.0f), {}, {1.0f, 1.0f}}, // top right
{glm::vec3(-1.0f, 1.0f, 0.0f), {}, {0.0f, 1.0f}}, // top left
};
std::vector<u32> const indices = {0, 1, 2, 0, 2, 3};
std::vector<std::shared_ptr<Texture>> textures;
std::vector<std::shared_ptr<Texture>> diffuse_maps = {};
TextureSettings texture_settings = {};
texture_settings.wrap_mode_x = TextureWrapMode::ClampToEdge;
texture_settings.wrap_mode_y = TextureWrapMode::ClampToEdge;
if (!m_texture_path.empty())
diffuse_maps.emplace_back(ResourceManager::get_instance().load_texture(m_texture_path, TextureType::Diffuse, texture_settings));
textures.insert(textures.end(), diffuse_maps.begin(), diffuse_maps.end());
return ResourceManager::get_instance().load_mesh(0, m_texture_path, vertices, indices, textures, DrawType::Triangles, material);
}
You can see two usages of ResourceManager
here. The first one is for a texture, and the second one is for handling a code-generated quad. In this case, we only need info about the texture for the quad to uniquely identify it. So we simply pass “0” as an array_id
, because there’s no mesh array in Button
class, it’s just one mesh with four vertices declared by hand.
In more complex cases you might need to come up with more sophisticated ways of generating keys to identify the assets.
Summary
That’s it! I hope now you can be happy with your saved memory, smaller CPU usage, and shorter loading times. 😄 Even if the performance of your engine was improved just by a little, this was an important step in building a sensible engine architecture. And you will thank yourself for writing runtime resource manager when you will approach programming systems that load plenty of entities, such as particle systems. Here’s a recap of the post:
Conclusion summary
How basic runtime resource manager works when you request (load) a resource:
- it generates a key (each loading function may have slightly different implementation).
- calls a template method
get_from_vector()
specifying desired resource type and providing the key. It will return eithernullptr
or a valid resource.- if a valid resource is returned by
get_from_vector()
, you’ve got your resource! - if a
nullptr
is returned byget_from_vector()
, a custom loading function (e.g. from aShaderLoader
) is called and the returned value is added to a vector. A key and ID pair is added to an unordered map. The freshly loaded resource is returned.
- if a valid resource is returned by
Special thanks to:
- Mariusz Sielicki, an experienced developer who has guided me and my team through struggles with resource management and broadened my grasp of how vast this topic is.
- Activision, for appreciating our project and awarding it at ZTGK 2024 Gamedev Contest.
Sources & Additional Materials
- Game Engine Architecture, 3rd Edition – a Bible of engine programming, serving as a great introductory text. Has a solid chapter on resource management. Great!
- A Resource Manager for Game Assets – an article on gamedev.net showing a similar approach with a use of an unordered map.