After finding my self writing the same code over and over again, I’ve decided to just throw all the reusable stuff into a library. Now I can just import this library and pretend Unity comes with those features out-of-the-box.
Overview
NekoLib is a library that encapsulates commonly-used tools and features to help with game development. This article introduces two of the features, probably the only ones that are remotely useful.
GameServices – Lightweight Service Locator
NekoLib’s GameServices
provides an easy way to manage global access for objects. This is useful for managing project-specific services that you want to have global access to.
Why?
This was initially designed as a way to centrally manage singletons. Singletons are useful because they provide global access and lazy initialization without the restrictions of static methods. However, using singletons too much can make the project hard to maintain.
- As the size of the project grows, it would become increasingly difficult to identify where a singleton is initialized.
- Singleton implementations become harder to replace as there can be lots of instance calls scattered across the codebase.
Some alternatives include dependency injection frameworks like Zenject and Extenject. These frameworks are often quite heavyweight and could introduce problems with il2cpp code stripping if not handled correctly. They’re usually fit for larger-scale projects.
Another easy option is to just have a gameobject act as a global service locator and leverage Unity’s GetComponent()
(i.e. Attach MonoBehaviours as services, then just GetComponent()
to get the service you want). This would be easy to set up and can work pretty well, so it may be a feasible option for some projects. There can still be some drawbacks:
- It would be a hassle to manage initialization order
- Is is generally not recommended to GetComponent frequently during runtime due to the performance cost so make sure you cache your stuff.
GetComponent()
iterates over all components on the gameobject (though still faster than Dictionary when there are few elements).
If these drawbacks won’t matter for your project then this might actually be a better way.
GameServices
provides an alternative to this approach. Instead of relying on GetComponent()
and MonoBehaviours, it maintains its own type-safe service mapping and provides an API for global access.
Usage
GameServices
is a global service locator that makes it easy to centrally manage global services. At the start of the application, you can register game services using GameServices.Register<MyServiceType>(myServiceObj)
. You can then access the service from elsewhere by GameServices.Get<MyServiceType>()
.
Polymorphism Support
You can register any type that you want global access to, it works as long as
inherits myServiceObj
.Type()
. This means we can register a service that implements an interface MyServiceType
MyInterface
, then we’re able to obtain that service by calling
. In this case, the code which wants to get this service won’t be dependant on the specific implementation. This makes it easier to replace globally accessible instances.GameServices
.Get<MyInterface>()
Unity Component Shorthand Support
If you register a Unity component or gameobject instance, it will be set to DontDestroyOnLoad. If you register a Unity component but don’t provide an instance, a gameobject will be automatically instantiated with the component attatched and set to DontDestroyOnLoad.
Example
This example showcases different ways to register a service. GameEngine initializes the application and registers necessary services into GameServices
.
// Main game manager.
[DefaultExecutionOrder(-1)]
public class GameEngine : MonoBehaviour
{
[SerializeField] private UIRoot _uiRoot;
[SerializeField] private Rewired.InputManager _inputManagerPrefab;
protected override void Awake()
{
base.Awake();
// Registers an existing MonoBehaviour instance.
GameServices.Register<UIRoot>(_uiRoot);
// Registers a MonoBehaviour instance, instantiated on the spot.
GameServices.Register<Rewired.InputManager>(
GameObject.Instantiate(_inputManagerPrefab)
);
// Registers a MonoBehaviour to an interface.
// Because no instance is provided,
// this automatically instantiates a new gameobject with a new component attached.
GameServices.Register<IObjectPoolManager, ObjectPoolManager>();
// Registers self.
GameServices.Register<GameEngine>(this);
}
Here GameEngine is a DontDestroyOnLoad gameobject that is initialized when the game starts. You could still use this approach alongside a conventional singleton pattern. For example, if you’re using Behaviour Designer, you could attach a BehaviorManager component onto the GameEngine gameobject, and call BehaviorManager.instance
to access it.
Pool – Dynamically optimized type-safe pooling
Garbage collection is becoming less of a problem since incremental GC was introduced. However, there is still overhead with frequently destroying large gameobjects. In order to mitigate this overhead and further relieve gc pressure, people often use object pools to collect and reuse transient gameobjects. NekoLibs provides a robust object pooling solution with the following features:
- Generics support – We often need to access some MonoBehaviour attached to a gameobject once it has been spawed from a pool. Since
GetComponent
calls incur a performance cost, to avoidGetComponent
calls, we could directly pool the MonoBehaviours. - Dynamic size – Pools can automatically expand or shrink. Pools that keep redundant copies of pooled objects will be periodically cleared to save memory.
- Non-invasive integration – Pooled objects don’t have to implement some specific interface or specific methods. It is usually sufficient for a MonoBehaviour to keep a reference to an
IObjectPool
instance, and return itself to the pool when required.
ObjectPoolManager
keeps track of registered pools, and periodically optimizes pool size. Internally, ObjectPoolManager
maintains a dictionary of reference pools and a dictionary of prefab pools.
- A reference pool provides instances of a class
- A prefab pool provides gameobjects instantiated from a prefab.
Usage
Ususally, you can have a global instance of ObjectPoolManager
, and access it to get pools you want to use.
The main benefit of using ObjectPoolManager
over Unity’s new built-in object pooling is you could easily have all your object pools centrally managed and optimized. It also provides an inspector interface that displays information about active pools. Use Unity’s object pooling if you wish to customize pooling behaviour via delegates.
Getting a Pool
You can obtain a pool by:
GetPool<T>()
to Get or create a pool for a specific type
GetPool<T>(T obj)
to get or create a pool for a specific prefab, whereT
is a Unity component type
Both methods will return a IObjectPool<T>
.
Creating a Pool
When you try to get a pool for a type or prefab for the first time, the corresponding pool will be automatically created and registered.
Alternatively, you could manually create and register pools by RegisterPool<T>()
and RegisterPool<T>(T obj)
.
Using a Pool
With a IObjectPool pool
, you could do the following:
pool.Get()
to obtain a pooled objectpool.Push(T obj)
to return an object back into the pool.
Example
In this example, a global object pool manager has already been created by GameServices.Register<IObjectPoolManager, ObjectPoolManager>()
.
Here, Bullet
is a MonoBehaviour component attached to the root gameobject of a bullet prefab. BulletFactory
provides a function that uses an object pool manager to create pooled bullet instances from a prefab.
public static class BulletFactory
{
// Gets the global object pool manager instance.
// You could cache this for better performance.
ObjectPoolManager PoolManager => GameServices.Get<IObjectPoolManager>();
public static Bullet Instantiate(Bullet prefab, BulletConfig cfg,
Vector3 origin, Vector3 direction, LayerMask layerMask = default)
{
IObjectPool<Bullet> pool = PoolManager.GetPool(prefab);
Bullet bullet = pool.Get();
bullet.Init(cfg, origin, direction, layerMask, pool);
return bullet;
}
}