Sunday, March 18, 2018

Game Engine Design 101: Modularity

Hey all,

As promised, this month's (Quarter's? I don't know.) article will be another Game Engine Design 101. This time, we will be going over the topic of modularity.

This topic is a bit "in the weeds", and if you're not familiar with some of the topics discussed here, feel free to comment on this article.

How can you make sure that your engine can be ported to multiple environments / systems? You could use multiplatform libraries, but what if that library isn't compatible with a platform that you would like to port to?

What would we like to do?
  • Separate out the platform specific code (ex. OpenGL) from the core engine code, to keep the engine code small.
  • Maximize modularity.
This is where inheritance is actually useful.

SIDE NOTE: In general, favor composition over inheritance, consider which objects in your engine are a "has a" relationship versus an "is a" relationship. Example: A car IS A vehicle, a car HAS passengers.

The code examples from JFramework I'll be using are here:
The general idea is to create a layer of abstraction away from the actual platform specific implementation of libraries, i.e. abstract calls to OpenGL away from the core of the engine.

GraphicsManager HAS A Screen to render to, but if you look at the implementation of Screen, it has the most barebones implementation, in fact, there are no calls to OpenGL at all! Also, you can't even make a Screen in its own right, because it's an abstract class! The lines from Screen.h shown below are why it's an abstract class.

virtual void            ResetObjectTexture(Surface* aSurface, TextureData* aOldData, TextureData* aNewData) = 0;
virtual void            ResetObjectShader(Surface* aSurface, ShaderData* aOldData, ShaderData* aNewData) = 0;
virtual void            SetClearColor(Vector4 const &aClearColor) = 0;
virtual void            ChangeSize(int aW, int aH, bool aFullScreen) = 0;
virtual void            PreDraw() = 0;
virtual void            Draw(std::vector<Surface*> const &aObjects, std::vector<Surface*> const &aUIObjects, std::set<Camera*> const &aCameras) = 0;
virtual void            DebugDraw(std::vector<Surface*> const &aObjects) = 0; 
virtual void            SwapBuffers() = 0;

By doing the above, we're telling the reader / compiler: "You need to derive from this class, and implement these methods, all Screens need to have these implemented." Whenever a derived class calls one of these methods, the derived version will ALWAYS be called.

From there, we make a PCShaderScreen class, which derives from Screen. This class will implement the actual drawing to the screen via OpenGL. Displaying the code would be too much so just check PCShaderScreen.cpp. In this type of relationship, a PCShaderScreen IS A type of Screen, thus we use inheritance.

And now, the final piece of the puzzle: When GraphicsManager creates a Screen to display to, assuming we're on PC, it will create our PCShaderScreen:

GraphicsManager::GraphicsManager(GameApp *aApp, int aWidth, int aHeight, bool aFullScreen) : Manager(aApp, "GraphicsManager", GraphicsManager::sUID),
                                                                           mSurfaces(), mUIElements(), mTextures(), mShaders(), mCameras(),
                                                                           mScreen(nullptr), mPrimaryCamera(nullptr)
  // Add Default Texture
  AddTexturePairing(DEFAULT_TEXTURE_NAME, new TextureData(DEFAULT_TEXTURE_NAME, -1, 0, 0, "", "", Vector4(), Vector4(), 0, 0));
  mScreen = new PCShaderScreen(this, aWidth, aHeight, aFullScreen);
  assert(!"Needs screen for this device.");

And now any calls to Screen will call the derived version from PCShaderScreen, which will make direct calls to OpenGL.

NOTE:  The SHADER_COMPATIBLE macro is defined from the compiler, you can look up ways to do that, it's not hard.

So, what have we done?
  • We separated the platform-specific implementation (PCShaderScreen) away from the core of the engine. (GraphicsManager)
  • We used inheritance to extend a class (Screen) to be able to have a platform specific implementation (PCShaderScreen)
What advantages does this bring?
  • When we move to a new platform, we can just not include our PCShaderScreen code, implement a new Screen for the platform, and move on.
  • The code in GraphicsManager stays the same no matter the platform, and stays relatively small.
There, now our code is more modular, easy!
There are other cases that you can use this for, but graphics is the best example, relative to JFramework.

Note: I used to render the code.