Basic Resource Management in a Custom Game Engine

Basic Resource Management in a Custom Game Engine

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 = {};

Info – Generating Keys

To load resources only once we need to keep track of what has already been loaded, and return a pointer to a resource instead of loading it again. We track it by generating a unique key for each resource. This “key” is a string in the following std::unordered_map. The key is tied to an index of the resource in its respective vector and it uniquely identifies where the asset comes from. We need to distinguish that a model loaded from a file “model1.gltf”  is different from a model loaded from “model2.gltf”, and that when we load “model1.gltf” again, it’s the same file that was loaded earlier, so we need to reference what’s already loaded instead of loading it again. The key is a bridge between an asset on disk and it’s different form in memory.

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 either nullptr or a valid resource.
    • if a valid resource is returned by get_from_vector(), you’ve got your resource!
    • if a nullptr is returned by get_from_vector(), a custom loading function (e.g. from a ShaderLoader) 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.

Special thanks to:

Sources & Additional Materials

Related Posts

Holistic Game Production. How We Made "Heartbreaker"

Holistic Game Production. How We Made "Heartbreaker"

“Heartbreaker” started as a job interview task I built in one weekend. 18 months later, my teammates and I won an award with “Heartbreaker” during a global gamedev conference, surpassing over a 100 professional indie teams.

Read More
Designing a Seamless Tutorial. Case Study of "Guiding Light"

Designing a Seamless Tutorial. Case Study of "Guiding Light"

Games take different approaches to teaching players how to play. Some provide explicit tutorials before the game starts, some offer no guidance, leaving players to figure out the mechanics on their own (or check a wiki), and others teach players seamlessly as they play.

Read More