Resource Pooling for Games
Presenting a concept for managing shared resources.
Today I want to share a simple, but versatile way of resource management for games with you!
I discovered the approach in one of my first Zig projects, a small football game where you could kick a ball around. Also GLES 2.0. So, nothing special for an experienced graphics/game coder, but it was a nice way to learn a new language and try its capabilities in bit manipulations and resource management and such.
This technique allows the following things without having too much code:
- Background resource loading
- Unloading of non-needed resources to save memory
- Resource management for streamed levels instead of static ones
- Progressive resource loading
When writing a game, you usually don't want to care too much about *what resource do I need when?*, but you notice quickly: The only real simple way to do resource management is: Load everything and never forget the resources until the program ends.
This method isn't really suitable for games with larger resource pools, multiple levels and everything. One quickly reaches the point where the VRAM isn't enough anymore and also normal RAM quickly jumps up into gigabytes of memory usage. So resource management is a must, but I had no idea on how to solve that. My first approach was a resource pool that only had one job: For a given resource handle (in my case, it was a string), return me a pointer to that resource and load it from disk, if not already done.
This prevented long initial loading times and allowed me to load only the stuff I need in the current game session. But: We introduced stuttering every time a resource was loaded, which isn't a thing we want to have in our games. My solution to that was using multithreaded background loading with shared OpenGL contexts. It's not as nice as it could be, but it works.
Now the solution on how to get resources into RAM was solved, but they still remained there forever. So the longer you play, the more resources get load and at one point, everything is loaded and we have the same problems we tried to avoid.
The solution to that problem isn't obvious and there are several approaches to it. In my situation back then, I didn't need a sophisticated heuristic or something, but only resource management for "menu" and "one level at a time". My optimization goal back then was this:
- Keep files between levels loaded that are used in both levels, but unload all resources not required in the current scene.
My solution to that was dead-simple. Mark every resource with a bit mask when querying the resource. Free all resources that were not requested between the last two garbage collection steps. When rendering, query the resources with the current level as a bit mask.
After some toying around with this solution I noticed that it's actually really versatile and allows a lot of different features I considered complex to be solved with it.
But enough backstory, let's jump right into application design and code!
The resource pool has two methods:
- get(id: ID, usage: BitMask) -> Handle
- collectGarbage() -> void
`Handle` is a thing that allows you access the resource that was loaded by the resource pool. `ID` is a unique identifier of any kind. `BitMask` is a set of boolean variables, usually implemented with an unsigned integer and bit values.
When calling `get`, the pool will either allocate a new handle or returned an already allocated one for the resource `id`.
`id` is a unique identifier for that resource, it may be the file name, a system-internal integer, a URI, whatever. Just make sure every resource is uniquely addressed with that identifier.
`usage` is a bitmask with one bit set for each group the resource is requested with this call. The pool will then set a flag that this group was requested in the internal resource structure. A group is just some semantic thing you define as a programmer, it has no internal meaning. But please note that a group is always a bit, not a number. So Group 0 has value 1, Group 1 has value 2, Group 2 has value 4 and so on.
A call to collectGarbage() will remove all resources from the pool that have no usage groups activated. It then resets all resource usage groups to *unused*.
And this is all the logic we need for the basic implementation of resource pooling. When requesting a resource between two calls of collectGarbage(), the resource pool will not release the resource, otherwise it will be released. Super-simple, very effective.
Use Case: Keeping shared resources load between two levels
This is my original use case for which I created this system. There are two options on how this can be solved:
- (1) Have one group per level
- (2) Have two groups: next_level and current_level
Both approaches share the same schematic:
1. Load a level and obtain resource handles with level mask
This marks all resources that are shared in the current and the level to be loaded as used and prevents freeing them when doing the collectGarbage() call, but releases all resources only used in the (now) previous level.
The approach (1) is a tad easier for low level count and gets unusable as soon as you have too many levels. You just allocate one bit per level in your game and use it to request your level resources with that.
Approach (2) is smarter: It uses the fact that only the current and the next level are actually relevant, resources from all previous levels are already unloaded. So when loading a new level, we do the following: Mark all loaded resources with group next_level, then collectGarbage(), then swap the values of next_level and current_level. This can be repeated ad nauseum and will allow us to load as many levels as we want and always keep resources from the last level loaded (as they were marked previously with next_level which is now current_level.
As approach (2) isn't obvious, here a small example:
We have four resources A, B, C and D, and three levels L1, L2, L3. The levels consist of the following resources:
L1 = (A, B) L2 = (B, C) L3 = (B, C, D) L4 = (C, A)
We now load those levels in order, the matrix lists which resources are currently loaded (x), will be loaded (l):
┏━━━┳━━━┳━━━┳━━━┓ ┃ A ┃ B ┃ C ┃ D ┃ ┏━━━━╋━━━╇━━━╇━━━╇━━━┩ ┃ L1 ┃ l │ l │ │ │ ┣━━━━╉───┼───┼───┼───┤ ┃ L2 ┃ │ x │ l │ │ ┣━━━━╉───┼───┼───┼───┤ ┃ L3 ┃ │ x │ x │ l │ ┣━━━━╉───┼───┼───┼───┤ ┃ L4 ┃ l │ │ x │ │ ┗━━━━┹───┴───┴───┴───┘
As you can see, loading both L2 and L3 will only load a single additional resource, even if L3 uses 3 resouces.
So the goal of keeping already loaded resources in-memory was met. Nice!
Use Case: Streaming levels
This method also allows us to stream resources in and out of memory but using 16 distinct zones arranged in a square:
┌───┬───┬───┬───┐ │ 0 │ 1 │ 2 │ 3 │ ├───┼───┼───┼───┤ │ 4 │ 5 │ 6 │ 7 │ ├───┼───┼───┼───┤ │ 8 │ 9 │ A │ B │ ├───┼───┼───┼───┤ │ C │ D │ E │ F │ └───┴───┴───┴───┘
The following things apply:
- each cell has a mask bit assigned
- each cell has 8 neighbours, wrapping over the edge of the grid
- the player/camera/center-of-scene always is located in a single cell
Now when the center of scene moves and changes from one cell into another, do the following:
1. Reference all contents from the surrounding cells
This will keep resources in memory that were previously already loaded and will only load the resources that were not in any previous cell. It will also unload all resources that aren't required anymore.
As you can see, this is the same pattern as for the levels, only managing the mask bits got a bit more complex. The rest of the API can stay the same and handle levels exactly the same as streaming resources.
This method allows an easy way to manage resources for a lot of game projects, when a lot of resources must be handled and not every required resource is known before-hand (dynamic loading is also covered when calling collectGarbage() more often).
It's simple to implement and requires in the best case only a single layer of indirection (id → handle → resource pointer) instead of (id → resource pointer) look up and can be implement efficiently with an array of resources.