003 | Management is the Key

A ResourceManager (RM) is an essential component in any game engine. It allows resources such as images, audio files, scripts or 3D models to be loaded, managed and shared efficiently.

Think of the RM as the orchestra conductor in a data theater. He makes sure that everything is in place when it is needed and releases what is no longer needed.


Pooling and Reference Counting

Pooling and reference counting are common techniques in software development that focus on the efficient management of resources, especially memory. They can be used individually or in combination, with each approach offering different benefits depending on the specific requirements of the application.

What is Pooling?

Pooling refers to the technique of providing and holding a “pool” of already initialized objects that can be reused. Instead of creating and destroying a new object each time it is needed, you get it out of the pool and return it when it is no longer needed.

  • What: Pooling allocates and reuses resources in advance instead of constantly creating and destroying them.
  • Why: It minimizes resource initialization overhead and can help reduce memory fragmentation.
  • When: Especially useful when resource initialization is expensive in terms of time, CPU or memory consumption.

What is Reference Counting?

Reference Counting is a method for tracking how many references exist to a given object. When an object is created, its reference count starts at one. Each time another reference to that object is created, the counter is increased. When a reference is removed, the counter is decreased. When the counter reaches zero, the object is released.

  • What: The reference count keeps track of how many references exist to a given resource. Once there are no more references, the resource is released.
  • Why: To ensure that resources are not released while they are still needed and that they are automatically released once they are no longer in use.
  • When: Useful in environments where the duration for when a resource will be needed is unpredictable, or where there are multiple instances of the same resource being owned or referenced.

Combining Pooling and Referece Couting

Merging these two strategies can amplify their individual benefits:

  • How: A pool of objects is created. In addition to regular data, each object has a reference counter. When an object is borrowed, its counter is incremented; when it is released, it is decremented. When the counter reaches 0, the object is returned to the pool.
  • Why: This synergy allows resources to be loaned out quickly (through pooling) and ensures that resources that are no longer in use are released safely (through Reference Counting).
  • When: Most valuable when objects are distributed across different parts of a system or when system load varies.

Both pooling and reference counting offer unique advantages in resource management. Bringing them together allows you to take advantage of both without forgetting the added complexity that comes from having these techniques together.


The Code in Detail

Now that we have a basic understanding of resource management theories and strategies, it’s time to see how these concepts translate into practical code. Through the following code snippets, we’ll see a real-world implementation of pooling and reference counting, as well as some best practices, and gain a deeper understanding of how these methods optimize our applications.

Get Method

/// <summary>
/// Retrieves an asset by its name from the manager's collection.
/// </summary>
/// <param name="name">The unique name/key of the asset.</param>
/// <returns>
/// Returns the asset if it exists; otherwise, returns null.
/// If the asset's lifecycle is set to "Counting", the reference counter is incremented.
/// </returns>
public IAsset? Get(string name)
{
    // Checks if the asset exists within the manager's internal dictionary.
    if (!_assets.ContainsKey(name))
        return null;

    // Retrieves the asset from the dictionary.
    var asset = _assets[name];

    // Increments the reference counter if the asset's lifecycle is set to 'Counting'.
    if (asset.LifeCycle == LifeCycle.Counting)
        asset.ReferenceCounter++;

    return asset;
}

Understanding the Get Method:

  • Asset Existence Check: Before any other operation, it’s essential to confirm the asset is in the manager’s collection. If it isn’t, the method immediately returns null, ensuring no further operations occur on non-existent data.
  • Asset Retrieval: If the asset does exist, it’s promptly fetched and ready for further checks.
  • RefCounting Mechanism: We have already talked in detail about the importance of reference counting. When the asset’s lifecycle is referred to as “counting,” the method increments its reference counter. This counter helps to efficiently manage the asset’s memory and determine when it is safe to unload or reuse the asset.

Load Method

/// <summary>
/// Loads an asset from the specified path and adds it to the manager.
/// </summary>
/// <param name="name">The unique name to identify the asset within the manager.</param>
/// <param name="path">The file path to load the asset from.</param>
/// <param name="lifeCycle">The lifecycle mode for the asset (default is Persistent).</param>
/// <exception cref="FileNotFoundException">Thrown when the file is not found at the given path.</exception>
/// <exception cref="InvalidOperationException">Thrown when an asset with the given name already exists in the manager.</exception>
public void Load(string name, string path, LifeCycle lifeCycle = LifeCycle.Persistent)
{
    // Check if the file exists at the given path.
    if (!File.Exists(path))
        throw new FileNotFoundException($"File not found: '{path}'");

    // Check if the asset is already loaded in the manager.
    if (_assets.ContainsKey(name))
        throw new InvalidOperationException($"Asset with name '{name}' already exists.");

    // Load the asset using the loader.
    var asset = _loader.Load(path);
    
    // Set the lifecycle for the asset.
    asset.LifeCycle = lifeCycle;
    
    // Add the asset to the internal dictionary.
    _assets.Add(name, asset);
}

Understanding the Load Method:

  • Asset Existence Verification: The method starts by checking the asset’s existence in the manager’s collection using the ContainsKey function. If the asset isn’t present, the function returns null.
  • Asset Retrieval: For existing assets, the method fetches the asset based on the name identifier.
  • Reference Counting Logic: The mechanism of reference counting becomes clear in the next step. When an asset is in “Count” lifecycle mode, its reference count is incremented. As mentioned earlier, this mechanism is critical for effective memory management and deciding the right time to unload or reallocate assets.

Release Methode

/// <summary>
/// Decrements the reference counter of an asset.
/// </summary>
/// <param name="asset">The asset whose reference counter needs to be decremented.</param>
/// <remarks>
/// If the asset's lifecycle is set to "Counting" and its reference counter is greater than zero, the method will decrement its reference counter.
/// This mechanism assists in efficiently managing the asset's memory, determining when it can be unloaded or recycled.
/// </remarks>
public void Release(IAsset asset)
{
    // Check if the asset's lifecycle is set to "Counting" and its reference counter is greater than zero.
    if (asset.ReferenceCounter > 0 && asset.LifeCycle == LifeCycle.Counting)
        // Decrement the reference counter.
        asset.ReferenceCounter--;
}

Understanding the Release Methode:

  • Asset LifeCycle & Reference Counter Verification: Before decrementing the asset’s reference counter, the method checks two conditions: The asset’s lifecycle should be set to count, and its reference counter should be greater than zero. Both conditions must be met to ensure that the asset is managed with the reference counting mechanism and to avoid decrementing the counter to a negative value.
  • Decrementing the Counter: When the above conditions are met, the asset’s reference counter is decremented. This step decrements the asset’s inventory in storage and signals that its demand has decreased. In combination with other mechanisms, this can indicate when it is appropriate to unload or reuse an asset.

UnloadUnusedResources Methode

/// <summary>
/// Removes unused assets from the manager.
/// </summary>
/// <remarks>
/// Scans through all assets and identifies those with a "Counting" lifecycle and a zero reference counter. Such assets are considered unused and are removed from the manager.
/// </remarks>
public void UnloadUnusedResources()
{
    // Create a list to hold keys of assets that need to be removed.
    var keysToRemove = new List<string>();

    // Loop through all assets in the manager.
    foreach (var asset in _assets)
    {
        // If an asset's lifecycle is set to "Counting" and its reference counter is zero, mark it for removal.
        if (asset.Value.LifeCycle == LifeCycle.Counting && asset.Value.ReferenceCounter == 0)
            keysToRemove.Add(asset.Key);
    }

    // Remove the marked assets from the manager's collection.
    foreach (var key in keysToRemove)
    {
        _assets.Remove(key);
    }
}

Understanding the UnloadUnusedResources Methode:

  • Identifying Unused Assets: Within the method, a list called keysToRemove is created to collect the keys of assets that are identified as unused. The method loops through all assets in the manager. If an asset’s lifecycle is “Counting” and its reference counter is zero, this implies the asset is not being used and is thus added to the keysToRemove list.
  • Removing Unused Assets: After identifying all unused assets, a separate loop runs through the keysToRemove list and removes these assets from the manager’s collection. This two-loop strategy ensures that the list of assets isn’t modified during enumeration, preventing potential runtime errors.

Loader Method

/// <summary>
/// Loader for 2D textures.
/// </summary>
public class Texture2DLoader : ILoader
{
    /// <summary>
    /// Loads a 2D texture from the specified path.
    /// </summary>
    /// <param name="path">The path to the texture file.</param>
    /// <returns>A new Texture2D object.</returns>
    /// <exception cref="TypeLoadException">Thrown when the file type is not supported.</exception>
    public IAsset Load(string path)
    {
        // Check if the file is a .png type.
        if (path.EndsWith(".png"))
        {
            // Create and return a new Texture2D object.
            return new Texture2D(path);
        }
        else
        {
            // Throw an exception if the file type is not supported.
            throw new TypeLoadException($"Type not supported '{path}'");
        }
    }
}

Understanding the Loader Method:

The Texture2DLoader class is dedicated to loading 2D texture assets. Implementing the ILoader interface, it provides functionality to read .png files and transform them into Texture2D objects for use in applications.

  • Loading Logic: Within the Load method, the loader checks the extension of the provided path. If the file ends with .png, it assumes the file is a valid texture and proceeds to create a new Texture2D object using the path. The newly created object is then returned.
  • Handling Unsupported Types: If the provided path does not end in .png, the loader throws a TypeLoadException to indicate the unsupported file type. This exception handling ensures that only valid textures are processed, and it promptly alerts developers or systems about any incompatible file types.

Reflecting on the Code:

As we have addressed the above code segments, it is important to highlight the various code techniques and best practices. In particular, adherence to SOLID principles ensures that the code remains maintainable, understandable, and extensible. The Never Nesting principle integrated into the design contributes to a clearer structure and better readability, effectively reducing the complexity of the code.

Disclaimer: The code samples provided above are under active development. Future versions will undergo further refinements to ensure quality and effectiveness.

Final thoughts and future plans for ResourceManager.

ResourceManager is an important part of our software architecture and serves as a flexible and extensible foundation for managing different types of assets. As we have seen, it provides efficient and optimized resource management through mechanisms such as pooling and reference counting.

However, the true power of the ResourceManager concept will become apparent in the future implementation of other specialized managers such as the AudioManager or SpriteManager. These managers will exist as singletons in the code, each using their own specialized implementation of the ResourceManager.

The modular design allows us to provide a custom loader for each type of asset. This makes the system incredibly flexible: each manager can have its own loader, specific to the needs of that asset type. These loaders are then made available to the central ResourceManager, which processes them accordingly.

The approach is designed to scale easily, both in terms of the number of resources managed and the different types of assets. It is an approach that respects SOLID principles and enables high maintainability and extensibility.

In the future, we see enormous potential for further development here, especially in the expansion and specialization of the various managers and loaders. It is a living system that can be constantly adapted and optimized to meet the increasing requirements and possibilities in software development

I hope this post provides a general overview of how a ResourceManager works. If you have any questions or would like to address further topics, please don’t hesitate to get in touch.

Let’s grow and learn together!

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top