Sponsored By
Radu Danciu, Blogger

December 16, 2014

7 Min Read

A lot has been written about the downsides of memory-managed environments in gaming, and though today resources are plentiful by yesterday's standards, the best-looking and best-performing games have always relied on tightly optimized code to wring out more of a platform's performance than previously thought possible.

Using Unity's C# environment clearly forfeits some memory optimization techniques. There are plenty of guides dealing with how to avoid common mistakes, but the closest thing to consensus on a good policy is memory pooling.

With that in mind we set out to build a framework for comprehensive and flexible pooling under Unity. Aimed at low-to-mid-level experienced developers, the main goal was to offer as much control for tailoring and customization as possible all the while delivering streamlined performance.

There are several primers out there on what memory pooling is and tons of info on how to build a bare-bones system from scratch, so we won't cover that here. If you need a refresher on the topic check out this excellent series on C# memory management for Unity by Wendelin Reich.

What we will cover instead are a few feature highlights as implemented in the package "Pooling Toolkit".

Pooling Toolkit on the Unity Asset Store

1. Eliminating trial and error by learning from use-case data

Manually setting the size for a pool of objects of a given type is usually guesswork. So the first thing on our 'to do' list was getting pools to learn their own size from use.

The data had to be stored in XML files per-scene. This has several benefits - each level has its own memory profile  allowing tailored trade-offs and splitting up the data this way results in easier collaboration when dealing with source control. Also, plain-text is easier to merge and debug in case anything breaks.

To get this working, the pools' underlying stack size is monitored using reflection.


internal void Recover(GameObject target)
{
	lock (syncRoot)
	{
	#if (MEM_DEBUG || MEM_POOL_SIZE_LEARNING)
		bool reallocatingStackThisFrame = false;
		
		//[...]
		
		if (pooledObjects.Count == GetCapacity())
		{
			reallocatingStackThisFrame = true;
		}
		
		//[...]
		
		if (reallocatingStackThisFrame)
		{
		#if MEM_POOL_SIZE_LEARNING
			if (KGameObjectPoolManager.GameObjectPoolLearningEnabled)
			{
				var eh = OnPoolSizeChanged;

				if (eh != null)
				{
					eh(GetCapacity(), resourcePath, false);
				}
			}
		#endif

			KDebug.Log(KDebug.LogLevel.Info, KDebug.LogType.Instantaneous, "KGameObjectPool[{0}]: reallocated, new size is {1}.", resourcePath, GetCapacity());
		}
	#endif
	}
}

#if MEM_DEBUG || MEM_POOL_SIZE_LEARNING
private int GetCapacity ( )
{
	int ret = 0;
	lock (syncRoot)
	{
		ret = ((GameObject[])arrayField.GetValue (pooledObjects)).Length;
		
		//arrayField is initialized on pool creation
		//arrayField = pooledObjects.GetType().GetField("_array", BindingFlags.NonPublic | BindingFlags.Instance);
	}
	
	return ret;
}
#endif

 

Note that this extraneous bit of code can be toggled off at runtime by disabling learning using the provided config editor UI or it can even be stripped from the code using the provided conditional compilation defines for maximum performance when shipping a build.

The main benefit of this approach is that over normal, regular playtesting and QA processes the system adjusts itself to usage. Over time, studying how a level's profile changes (by examining changes to the xml data) conclusions can be drawn correlating gameplay trends with memory usage.

2. Automating initialization and reset for GameObjects and all MonoBehaviours

Frameworks, tutorials and examples available all have varying approaches to initializing and resetting objects, but they mostly revolve around leaving it entirely to the user to re-initialize an instance after obtaining it.

Initialization is the one-time setup performed when new objects get created (typically when the pool is initially filled), and reset is called whenever an object is 'instantiated' from the pool. When objects get returned into the pool, they only get disabled - this makes it so that resetting is 'lazy', done only if required and stale objects in the pool won't waste any CPU cycles.

In an effort to keep things neat, we thought of providing a base class for poolable MonoBehaviours that mandates defining initialization and reset methods. This makes it so that the resolution of the concern is implemented along with the component itself, keeping a cleaner codebase.

Obviously the approach is not sufficient as there are cases where you can't extend from KPoolableMonoBehaviour (built-in components, or those from another library and so on). For those cases we introduced the HandledComponentTypeResetHandlers dictionary which lists reset methods associated to their respective types. The list of handled components is compiled once during construction and cached for use at runtime to maximize performance.

The caching of both handled components and KPoolableMonoBehaviours per object makes for a speedy runtime as GetComponent won't be called several times every frame as it would be under naive use. If for some reason you can't generalize resets, you can of course approach it that way as well, but you should first off consider why that is along with re-architecting the system for better separation of concerns.

3. Pooling most any C# object

Everything available for GameObjects (like pool size monitoring and learning) is available for any other type using KObjectPoolManager. In this case, there are two types of types to deal with - those inheriting from IPoolable and the rest.

Pools of IPoolable objects are referred to as 'loosely coupled' and are similar to KPoolableMonoBehaviours in providing their own methods for initialization and reset. Any other types are 'tightly coupled' and require you to specify delegate methods for initializing and resetting them.

So concluding this point, you can pool various things you may need - like threaded job workers or pathfinding nodes. Oh, and yes, the whole shabang is thread-safe.

4. Fault tolerance & rich debugging

On development builds, asking KGameObjectPoolManager to instantiate an object that is not pooled will lead to a warning being logged and an instance being provided for you using GameObject.Instantiate instead. Similarly, trying to recover an object that didn't come out of a pool will result in a warning and the object being destroyed.

Tracking down the warnings will allow you to correct the problem before you ship.

This makes it so that, on release builds, misuse of pooling won't show up as missing objects to the user, reverting to non-pooled behaviour using GameObject.Instantiate and GameObject.Destroy. This will inevitably lead to slower performance, but at least it won't leave your game crashing or looking unfinished.

Going even further, during development builds objects instantiated from pools are tracked, and if you happen to leak them (either destroying them or forgetting to return them to the pool) you will be warned and provided information about where in your code the object was instantiated from.

Setting out to build this package we had a few ideas of what would really make a difference to a user, from our own and our colleagues' experience. By now, I've used it on a number of projects, every time tweaking and tunning and adding features required. With that background, I'm confident in saying that it has been battle-tested and developed to face real challenges that cropped up with using such a system, and over time developed towards being a comprehensive and elegant solution.

But you don't have to take our word for it. Evaluation licenses are available, and we encourage everybody to try it before committing to spend on it. If you are a student or teacher interested in using this for educational purposes you can get an educational license as well. For both cases, get in touch at [email protected] and we'll get you set up in no time.

Read more about:

Featured Blogs

About the Author(s)

Daily news, dev blogs, and stories from Game Developer straight to your inbox

You May Also Like