The main data objects for this design are resources. These resources will have common fields such as an ID, type, loading state, memory size, and source path.
A generic Resource abstraction should be created, with Texture, Model, Audio, and Shader resources extending or implementing it.
However, game objects should not access resources directly. They should receive a ResourceHandle, which represents active usage of a resource.
Internally, each resource should be owned by a ResourceControlBlock. This object contains the actual resource, its loading state, memory size, synchronization data, and an atomic active reference count.
This separation is important because cache residency and resource lifetime are not the same thing. A resource can be removed from the cache, but it must remain alive while any game object still holds a handle to it.
Resources need to be loaded from different sources, with a specific pipeline for each resource type. This leads to creating ResourceSource and ResourceLoader interfaces.
DiskSource and NetworkSource should implement the ResourceSource interface. Each resource type should have a corresponding loader, such as TextureLoader, ModelLoader, AudioLoader, and ShaderLoader.
The ResourceSource is responsible for retrieving raw data. The ResourceLoader is responsible for converting that raw data into a typed resource.
The loaders should not directly manage the cache. Instead, a ResourceManager should coordinate loading, cache access, in-flight requests, and eviction. This keeps loading logic separate from cache policy.
Resources need to be reference counted. This can be implemented through a ResourceHandle class.
Each ResourceHandle points to a ResourceControlBlock. When a handle is created, the control block’s active reference count is incremented. When the handle is released, the count is decremented.
The ResourceControlBlock should own the actual resource and should be able to outlive the cache entry. This prevents dangling references if the cache evicts an unused entry due to memory pressure.
A resource is considered evictable when its active reference count reaches zero. However, reaching zero does not mean it must be destroyed immediately. It only means the cache may evict it if memory needs to be freed.
A ResourceCache should be created to hold cache entries, mapped by resource ID.
Each cache entry should reference a ResourceControlBlock, rather than owning the resource directly. The cache represents whether a resource is currently available for reuse, not whether the resource is alive.
The cache should expose methods such as:
isCached
getById
insert
remove
The ResourceManager should use the cache as follows:
Check if the requested resource is cached.
If it is cached, return a ResourceHandle.
If it is not cached, check whether it is already loading.
If it is already loading, attach the caller to the existing load operation.
If it is not loading, start a new load operation.
This avoids race conditions and prevents the same resource from being loaded multiple times concurrently.
The cache map should be thread-safe. A concurrent hash map or a normal map protected by appropriate locking can be used. Expensive loading work should happen outside cache locks.
ResourceControlBlock memory should be managed independently from cache entries.
The control block should remain alive while any of the following exist:
An active ResourceHandle
A cache entry
An in-flight load operation
A pending completion callback
When the cache evicts an entry, it only removes the cache’s reference to the control block. If no handles or load operations still reference it, the control block and the actual resource can be destroyed. If something still references it, the resource remains valid.
This means cache eviction does not invalidate resources that are still in use.
To evict the least recently used item, the cache can use a map plus a doubly linked list.
The map stores resource IDs and points to cache entries. The linked list tracks usage order.
When a resource is accessed, its entry is moved to the head of the list. The least recently used resource is found at the tail.
When loading a new resource would exceed the memory budget, the cache checks entries from the tail and evicts the first resource whose active reference count is zero.
If a resource still has active references, it must be skipped.
If no unreferenced resources can be evicted, the system should apply a configurable policy, such as allowing temporary memory overflow or failing the load.
Asynchronous loading should be represented by a LoadOperation and a LoadTicket.
A LoadOperation represents the actual in-progress load for one unique resource ID. It stores the current state, latest progress value, subscribers, and final result.
A LoadTicket is returned to callers. It allows the caller to subscribe to progress updates and receive the final ResourceHandle when loading completes.
Progress should not be modeled as a simple field inside a promise. A promise represents completion, while progress is a sequence of updates over time.
Instead, loaders and sources should report progress to the LoadOperation through a progress reporting interface. The LoadOperation then notifies subscribed LoadTickets.
The progress data should include:
resource ID
resource type
current stage
normalized progress
For cache hits, loadAsync can return a completed LoadTicket with progress set to 100%.
Callbacks should be delivered through a safe callback queue, preferably on the main thread, so background loading threads do not directly modify game state.
Resource access must be thread-safe because resources can be requested, loaded, cached, and released concurrently.
The main shared structures that require synchronization are:
ResourceCache
LRU list
In-flight load table
ResourceControlBlock state
progress subscriber list
The system should lock only around shared state updates. Disk access, network requests, decoding, and resource creation should happen outside global locks.
The ResourceManager should also maintain an in-flight load table, keyed by resource ID, so concurrent requests for the same uncached resource attach to the same LoadOperation instead of starting duplicate loads.