Singletons in game engine architecture

Started by
61 comments, last by LorenzoGatti 6 years, 1 month ago

How do game engine architectures (not in the game code) organize their different subsystems? Currently, I use a "hybrid" of the Singleton and Service Locator design pattern.

  • Engine
    • InputManager
    • ...
    • RenderingManager
      • RenderingStateManager (encapsulates common blend, depth-stencil, rasterizer and sampler states)
      • RenderingOutputManager (encapsulates the main output resources and their views)
      • Renderer (encapsulates the rendering passes)
      • SwapChain (could be a manager of SwapChains as well in case of multiple windows)

My Engine class can only be instantiated once. Upon instantiation, it constructs all managers responsible for the different subsystems (input, rendering, resources, etc.). These managers can be accessed by invoking getters on the Engine object. To facilitate accessing the managers further, I add a class method to each manager class which internally will invoke the corresponding getter on the singleton Engine object. That way, I can just write code as RenderingManager::Get()->GetDisplayConfiguration() for example, and I do not need to include the engine header every time. Unfortunately, this seems a bit dual: you can create as many managers as you want (not that I create more than one), but there is one manager that has a privileged way of accessing it.

Furthermore, I am not really happy with this approach since you eventually always fall back to the engine itself by including the engine header inside the managers' .cpp files. So I rather want to decouple the first-layer of managers completely from the engine itself into a separate static library each and make each manager a singleton (just like I did with the engine).

The benefit of making these managers singletons is not having to pass pointers around (note that it will never be the case that you have to pass m pointers n levels deep). It allows me to just create zero-argument factory functions such as CreateWhiteTexture(). On the one hand less there is less boiler plate code. On the other hand this makes it harder to spot dependencies or know whether a function is pure.

Any thoughts and suggestions on how it is commonly done?

🧙

Advertisement
2 hours ago, matt77hias said:

On the other hand this makes it harder to spot dependencies or know whether a function is pure.

If you ever want to make a multi-threaded engine, the dependencies and side effects of functions have to be crystal clear at all times. The very existence of any global state makes every function suspicious.

Globals are justifiable for features that belong to the EXE file itself. The command line parameters that it was launched with. The std-out/std-err streams, the debug output log, the crash handler, etc...

For anything else, they're wrong.
They're still commonly used due to the reasons you already know -- they're comfortable!
(but still objectively wrong)

47 minutes ago, Hodgman said:

If you ever want to make a multi-threaded engine, the dependencies and side effects of functions have to be crystal clear at all times. The very existence of any global state makes every function suspicious.

The interfaces of the managers support multiple-threads and indirections such as CreateWhiteTexture as well.

But what does the alternative look like:

member methods

ResourceManager::CreateWhiteTexture(ID3D11Device *, GUID, string)

This is obviously wrong since objects should only have a bare minimum interface. (cfr. Non-Member Functions Improve Encapsulation)

non-member methods

CreateWhiteTexture(ResourceManager *, ID3D11Device *, GUID, string)

This makes everything extremely transparent, but it results in lots of boiler plate. If I would have a method GetResourceManager() and GetID3D11Device(), I could have skipped the first two arguments for a start. And multi-threading will still work since I guarantee that both ResourceManager and ID3D11Device can be used by multiple threads at the same time?

 

 

🧙

26 minutes ago, matt77hias said:

member methods

ResourceManager::CreateWhiteTexture(ID3D11Device *, GUID, string)

This is obviously wrong since objects should only have a bare minimum interface. (cfr. Non-Member Functions Improve Encapsulation


ResourceManager::ResourceManager(ID3D11Device *)
ResourceManager::CreateWhiteTexture(GUID, string)

Problem solved...

 

29 minutes ago, Kylotan said:

ResourceManager::CreateWhiteTexture(GUID, string)

Still the same problem

Non-Member Functions Improve Encapsulation, Member Functions Don't.

Why would you add CreateWhiteTexture to the ResourceManager itself? The ResourceManager needs a method to create general Textures, not 100 methods for all special variants of textures you frequently use. You do not add 100 constructor methods to the Texture class either. So you move those special variants outside the class to improve encapsulation, but now you need to pass the ResourceManager as an explicit argument itself.

This manifests itself upstream. For example: each render pass uses some shaders and maybe some predefined textures, so all render passes need a pointer/reference to a ResourceManager (and this will only be the start).

Empty constructors of materials having textures will disappear or not construct at all, since an Initialize member method will need to be added to take over the initialization of member variables (to non-nullptr values).

 

🧙

59 minutes ago, matt77hias said:

CreateWhiteTexture(ResourceManager *, ID3D11Device *, GUID, string)

This makes everything extremely transparent, but it results in lots of boiler plate. If I would have a method GetResourceManager() and GetID3D11Device(), I could have skipped the first two arguments for a start. And multi-threading will still work since I guarantee that both ResourceManager and ID3D11Device can be used by multiple threads at the same time?

Actually the question here is why are you not using the service locator you mention in the OP?  I.e. you should rewrite that function as:

CreateWhiteTexture(ServiceLocator*, GUID, string);

There are quite a few benefits to this approach.  Primary among the benefits is that you can change everything about how to create a texture, update this function to match and not have to touch any of the uses of the function afterwards.  This is the pattern I tend to use all the way from the main function on up.  If you centralize everything around a combined Factory/Locator abstraction there is a little bit of extra setup boilerplate 'within' wrapper functions such as CreateWhiteTexture but very little when using the function.  I.e. in my codebase it would be something like the following:

TextureBase* CreateWhiteTexture(ServiceLocator* locator, GUID guid, string name)
{
  auto driver = locator->Get<RenderDriver>();
  auto resMan = locator->Get<ResourceManager>();
  ... make a texture etc ...
}

That's not *too* much boilerplate to deal with once in a while when writing these helpers is it?  I admit, I tend to take this to a bit of an extreme level given the choice simply because I've been bitten by singletons so many times over the years that I never want to see such things in the future if I can help it.  Going back to hodg's reply, I don't even believe in singletons for executable resources for the most part.  For instance, the command line passed into main, my "Application::Main" never see's that, it can get the 'environment' object from the locator passed to it after the per platform main/WinMain/DllMain/whatever has setup the process.  It's a bit extreme but can be really useful for instance if you ever worked on an MMO and needed to startup 100 headless clients to stress test servers.  Starting 100 processes is typically bad, but if you have abstracted away the entire environment from main on up, you can just call "Application::Main" 100 times even passing in custom command lines, different cin/cout abstractions (i.e. feed to logging instead of console) etc etc.

24 minutes ago, All8Up said:

Actually the question here is why are you not using the service locator you mention in the OP?

The main reason why I currently didn't use it, is its tree like structure:

  • Engine (which is the service locator itself)
    • InputManager
      • Mouse
      • ...
      • Keyboard
    • ...
    • RenderingManager
      • RenderingStateManager
      • RenderingOutputManager
      • Renderer
      • SwapChain

To bind a point sampler, I will need to write something like:

engine->GetRenderingManager()->GetRenderingStateManager()->BindPointSampler< Pipeline::PS>(slot);

Pretty long in my opinion, though I see the benefits.

I also will need to split the tree like structure if I want every system in a separate lib. For the rendering subsystem, I need a representation of the scene with only rendering related components, I need a resource factory with only rendering related resources and I need a service locator with only rendering related managers/factories/caches. Though, I am not really sure if you can cut and (disjointly) partition an engine like this.

 

 

🧙

10 minutes ago, matt77hias said:

Engine (which is the service locator itself)

Ah, you are breaking SRP at multiple levels then.  But, even without that, the suggested referential chain is another case of writing a helper function rather than typing that over and over.  So, you could rewrite that chain as:

BindPointSampler<PipelinePS>* GetBindPointSampler(engine, slot) { return ... chain of references ... ; }

This goes back to the cohesion problem.  By writing the chain of dereferences you are exposing the ownership into your code.  If you encapsulate the chain of dereferences as above, if and when you decide to change the referential chain you don't have to go hunt down every use case, just change this helper function.  Additionally, you can write a couple variations such that if you already have the state manager, it will automatically start at that point rather than going all the way up to engine.

Obviously though you should be keeping your reference chains as short as possible.  Anytime I see a reference chain longer than two hops, I tend to think there is something wrong.  GetSwapChain(engine) is probably a bad chain, GetSwapChain(window) or GetSwapChain(renderer) are both shorter and imply more context around what you are doing and as such, have less of a cohesion problem exposed.  Or, even better, hide the swap chain completely and only have GetCurrentImageView(renderer).  Utilizing helpers in this way means you don't expose the ownership outwards from the things that actually need to know such things.  When done well your high level code is extremely simple and linear and independent of how you might want to restructure things later.

 

2 hours ago, matt77hias said:

This makes everything extremely transparent, but it results in lots of boiler plate. If I would have a method GetResourceManager() and GetID3D11Device(), I could have skipped the first two arguments for a start.

As Kylotan suggests, this indicates that maybe a resource manager that tracks the device will be more useful than one that doesn't. You don't need to have a CreateWhiteTexture on the resource manager itself - you can write a CreatureWhiteTexture function that just takes a resource manager and creation data, and then have the resource manager use its internal D3D device to create the texture.

Or, you could put stuff needed for texture creation into a texture factory (which would track the device), and put the texture creation method on that, then pass the created texture to the resource manager when it has already been created. For example, something like:


resourceManager.RegisterTexture(CreateWhiteTexture(textureFactory), guid, name);
44 minutes ago, All8Up said:

Actually the question here is why are you not using the service locator you mention in the OP?

I can think of one reason, which OP doesn't cite - the service locator pattern is just a "better" way to have singletons. Being able to switch out objects from the service locator at runtime is nifty, but it still has the problem that globals and singletons have where it hides dependencies within the methods that use it, rather than making them explicit,. The service locator still looks like global state to client code and most implementations of it still only allow one of each type of object to be located from it - both of which are problems. I see it as a crutch for dealing with globals-heavy legacy code; in new code, it'd call it an anti-pattern.

1 minute ago, Oberon_Command said:
57 minutes ago, All8Up said:

Actually the question here is why are you not using the service locator you mention in the OP?

I can think of one reason, which OP doesn't cite - the service locator pattern is just a "better" way to have singletons. Being able to switch out objects from the service locator at runtime is nifty, but it still has the problem that globals and singletons have where it hides dependencies within the methods that use it, rather than making them explicit,. The service locator still looks like global state to client code and most implementations of it still only allow one of each type of object to be located from it - both of which are problems. I see it as a crutch for dealing with globals-heavy legacy code; in new code, it'd call it an anti-pattern.

Well...  I don't promote the idea of using the locator to hide such details, I only mention it as "you could" do so, not should.  And I agree that such things should be avoided as a better practice.  I tend to get into that a bit more when I mention minimizing the referential chains, those are just evil.  I'm unsure of the OP's desires though, so when you have a hammer, everything looks like a nail kinda applies. :P

 

This topic is closed to new replies.

Advertisement