Alain Galvan ·10/26/2018 8:36 PM · Updated 1 year ago
A review of modern game engine architectures, discussing different approaches to modeling high and low level systems, actors, entity component systems, data oriented design, and current best practices.
Tags: bloggame engine
The definition of an Engine can be ambiguous. It could be an application engine driven by views such as Apple's UIKit, React Native or the modern web platform (though one might want to call those application engines), a rendering engine without complex logic for input devices and built for real time rendering research such as NVIDIA Falcor or Microsoft's MiniEngine, a shadertoy, demoscene app, Cook raytracer written onto a business card [Sanglard 2013], or a massive framework [Gregory 2018] made of a variety of components such as Unreal, Unity, Godot, etc.
Overall however, a Game Engine is a platform by which a team of individuals can all contribute and build a product. We'll be reviewing Game Engine Architectures, their design, best practices when designing engines, and current state of the art tools that you can build off of.
Game engines are made of top level systems built as:
An Application Hierarchy, with a Scene (or view) of Actors (or collection of view components), and components of those actors such as logic controllers, meshes, image textures, audio, etc. This is often called the Actor Design Pattern, where independent actors execute their own tasks.
Singletons objects accessible throughout an application that store state or control overarching systems like input, application state, UIs, etc.
Middleware that can subscribe to other middleware (eg. Renderer middleware subscribing to Windowing middleware), and low level systems such as actors to subscribe to them to access application state.
Or any combination of those designs.
Actors can be built with:
Classical Hierarchies where components such as transformations, Meshes, etc. are described through inheritance.
An Entity Component System where declarative components hold the state of their data and have external systems such as middleware or singletons to process that state.
Or even a combination of these designs (though using two different schools of thought may end up making code more complex and difficult to understand). In the end these constraints and methods of organization only serve as a way of keeping concerns separate and making your application easier to understand as a whole.
Let's review each approach along with their strengths and weaknesses:
Singletons are incredibly useful when architecting applications, as any system can immediately access data from a singleton. Unity for instance makes heavy use of singletons, which can be seen for instance, in the way they handle their Input singleton:
// Source: https://docs.unity3d.com/ScriptReference/Input.GetKeyDown.html
using UnityEngine;
using System.Collections;
public class ExampleClass : MonoBehaviour
{
void Update()
{
if (Input.GetKeyDown((KeyCode.Space))
{
print("space key was pressed");
}
}
}Dear ImGui uses a singleton to draw immediate mode graphics user interfaces:
// 💬 Inside the ImGui namespace, all state is maintained in `imgui.cpp`.
ImGui::Text("Hello, world %d", 123);
if (ImGui::Button("Save"))
{
// 💾 Save the application
}
const char* buf[1024];
ImGui::InputText("string", buf, IM_ARRAYSIZE(buf));
ImGui::SliderFloat("float", &f, 0.0f, 1.0f);There's a variety of ways to approach writing a singleton, each offers a different level of control of when a singleton is created, in exchange for slightly more lines of code. Most of the time you will want to decide, since some systems may depend on other systems.
class Singleton
{
public:
static int getData();
static int setData(int data);
private:
int mData;
};
// ♦️ In your .cpp file:
namespace
{
MyCreatedSingleton* gSingleton = nullptr;
}
void createSingleton()
{
if(!gSingleton)
{
gSingleton = new MyCreatedSingleton();
}
}
int MyCreatedSingleton::getData()
{
return gSingleton->mData;
}
void MyCreatedSingleton::setData(int data)
{
gSingleton->mData = data;
}
void useSingleton()
{
int data = Singleton::getData();
data++;
Singleton::setData(data);
}
Singleton architectures make it very difficult to know where dependencies are, which can make it difficult to unit test your code or draw dependency graphs. They are easier to use as for the most part, you would only need to #include "YourSingleton.h" and you're good to go), but you as an application architect will need to decide if having a single controlling instance fits the needs of your application.
One approach to modeling high level systems is the use of middleware, the idea is to keep the core of an engine small and allow middleware to fill in the gaps in state that would be missing by default. Actors can subscribe to middleware if it exists, and from there read data from OS windows, the GUI state, or anything developers would feel need to be shared across Actors.
#include "Engine.h"
void Engine::start(const EngineDesc& desc)
{
// 🌟 Initialize middleware
for (auto itr = mMiddleware.begin(); itr != mMiddleware.end(); ++itr)
{
std::vector<IMiddlewarePtr> createdMiddleware(mMiddleware.begin(), itr);
IMiddleware& m = **itr;
if (!m.create(desc, createdMiddleware))
{
mMiddleware.erase(itr);
itr = mMiddleware.begin();
continue;
}
}
// 🏎️ Run engine loop
while (mRunning)
{
mRunning = false;
for (IMiddlewarePtr& ware : mMiddleware)
{
bool shouldUpdate = ware->shouldUpdate();
if (shouldUpdate)
{
mRunning |= shouldUpdate;
ware->update(this);
}
}
}
}Middleware would be subscribed to by actors which need access to the state of the various systems running the application. The benefits of this are that the global namespace is no longer used, leading to more isolated code, but this comes at the cost of subscription overhead on the part of actors, in addition to local pointers to subscribed data. There's also a level of "technical overhead" on the part of an engineer, as more isolated code means being aware of what middleware they would need to subscribe to in order to solve their problems.
void Player::setupState(const std::vector<MiddlewarePtr>& middleware)
{
input = findMiddleware<InputMiddleware>(middleware);
}
void Player::update(float deltaTime)
{
if (input->checkKey(Key::Right).active())
{
//move right
}
else if (input->checkKey(Key::Left).active())
{
//move left
}
else
{
//slow down
}
}Middleware follows the subscriber design pattern, and as such, there's an initial setup cost for each actor that wants to subscribe to middleware outside the scene hierarchy (on the order of ( O(N) ) ). While this cost can be mitigated (by constexpr based pointer resolving at compile time), it's still code that must be included by subscribers.
Actors have the option of being built with an Inheritance Hierarchy. This makes it very easy to design actors that share the same functionality instantly. Issues arise with this approach such as the Diamond Problem, what if an actor needs to inherit multiple objects, each of which inherits from the same source object? That's not to mention an endless chain of inheritance that you see in APIs like Java, Inheritance Soup if you will.
Then there's the idea of compile time inheritance though the use of magic methods, compile time virtual function calls, alongside mixins, a concept popularized by languages like Ruby.
The Entity Component Architecture lets entities hold components, which typically just contain data. That data is then processed with high level systems managing rendering, collisions, path-finding, physics, etc.
This fits very well with the idea of Data Oriented Design. Basically, it's in an application's best interest to group similar data together to help the CPU's cache miss less often.
AMD's Cauldron Engine is a real time PBR renderer that uses the GLTF specification, ImGui, and a backend powered by either Vulkan or DirectX 12.
NVIDIA's GameWorks team released Falcor, a realtime research renderer used in a variety of research papers.
János Turánszki's (@wickedengine) is an incredibly well written game engine with tons of examples.
Professor Morgan McGuire (@CasualEffects)
released G3D Engine, his open
source rendering engine.
Sebastian Merry of Microsoft and Microsoft's DirectX 12 Examples team released MiniEngine, a sample engine for rendering DirectX 12 applications.
Epic Games' Unreal Engine is open source and available on GitHub here, though this requires you to register an Epic Games account and link it to your GitHub.
Wolfgang Engel (@wolfgangengel) released
a cross platform renderer called The Forge,
his GitHub is also full of samples and examples
of rendering engines.
Ray and a team of open source contributors built the raylib series of libraries, cross platform game libraries inspired by the XNA framework.
There's also a variety of individual engineers that have open sourced their personal engines:
Finally, there's a variety of articles and books on game engine architecture, including:
Game Engine Architecture by Jason Gregory is the de-facto standard in the subject of game engines, along with similar books on rendering architectures like Real Time Rendering.
Blizzard Entertainment's Overwatch game released an article discussing their editor, which appears (but don't quote me on this) to be a a Qt powered scene editor called TED.
Godot Engine has a blog where they discuss rendering and architecture changes they've been working on, such as their Vulkan renderer.
Sean Middleditch (@seanmiddleditch) released a blog post that focused on never forgetting that game engines are for games, not magical structures in and of themselves, so engineering them in the context of an underlying purpose rather than for the sake of engineering is incredibly important.
| [Sanglard 2013] |
| [Gregory 2018] |