TankEngine In-Depth
Tank Engine is my 2D and 3D capable game engine made in C++. It hinges on the heavy use of a custom-built Entity Component System with memory pools per system. For this version of the engine, I remade Bubble Bobble with both a 3D element and the 2D game.
My main goal with this project, from the beginning, was to learn how to build lite and fast Game Engines. Focusing on smart use of memory as well as implementing techniques such as Batching to accelerate rendering:
Bubble Bobble
To put the engine to use, I also made a clone of Bubble Bobble. This is available for download along with the source code of the engine in the TankEngine Repository
Gameplay Video
ECS
The main structural element of the engine is the ECS(Entity Component System). It's a memory focused implementation that allows for very fast manipulation of information both synchronously and asynchronously.
// Creating the world were all our game information will live
m_pWorld = Universe::GetInstance()->PushWorld();
// Upon creation of the World, you push on to it the Systems and their respective configurations
m_pWorld->PushSystems<
WorldSystem<TransformComponent2D, 256, 0, ExecutionStyle::SYNCHRONOUS>,
WorldSystem<SpriteRenderComponent, 256, 1, ExecutionStyle::SYNCHRONOUS>,
WorldSystem<LifeSpan, 256, 2, ExecutionStyle::SYNCHRONOUS>,
WorldSystem<ProjectileComponent, 256, 3, ExecutionStyle::SYNCHRONOUS>,
WorldSystem<PlayerController, 8, 4, ExecutionStyle::SYNCHRONOUS>,
WorldSystem<ParticleEmitter, 16, 5, ExecutionStyle::SYNCHRONOUS>,
WorldSystem<Particle, 4096, 6, ExecutionStyle::ASYNCHRONOUS>,
WorldSystem<TransformComponent, 8, 7, ExecutionStyle::SYNCHRONOUS>,
WorldSystem<CameraComponent, 8, 8, ExecutionStyle::SYNCHRONOUS>,
WorldSystem<ModelRenderComponent, 8, 9, ExecutionStyle::SYNCHRONOUS>
>();
WorldSystems
World Systems are the containers that store components. Each is responsible for managing one component type inside of a Memory Pool. The use of memory pools greatly accelerates the speed with which we can process information as it is neatly packed together.
Execution Style
Execution Style dictates if the System is going to update inline on the main thread or in parallel on a separate thread.
As seen in the previous snippet of code, this ECS implementation follows a Universe->World->System->Component Scene Graph.
- A Universe can have multiple Worlds
- A World can have multiple World(Component) Systems
- A World can have multiple Entities
- A Component System can have multiple components of one type
- An Entity can be made up of multiple components
With this format, it is quite easy for a user to quickly build the components necessary to create a lot of extensive gameplay. The next example showcases the creation of a player entity with all the necessary components.
PushComponents
, in this case, returns a std::tuple<ComponentType *...>
.
auto pEntity = m_pWorld->CreateEntity();
auto [pMovement, pRenderer, pTransform] = pEntity->PushComponents<PlayerController, SpriteRenderComponent, TransformComponent2D>();
// Component setup
pRenderer->SetSpriteBatch(pSpriteBatch);
pRenderer->SetAtlasTransform({ 0, 0, 16, 16 });
pTransform->position = { pos.x, pos.y, 0.f };
pTransform->scale = { 4.f, 4.f };
pMovement->SetInputController(player);
Graphics - DirectX 11
3D Graphics
For this version of TankEngine, the focus was mainly on 2D so the 3D pipeline is fairly basic. To render in 3D, you can load FBX 3D models with the Resource Manager and then use them in an entity with a ModelRenderComponent
and TransformComponent
.
const auto pModel = RESOURCES->Get<Model>("arcade");
const auto pTexture = RESOURCES->Get<Texture>("arcade_diffuse_pog");
auto pWorldModel = m_pWorld->CreateEntity();
auto [pModelRenderer, pTransform] = pWorldModel->PushComponents<ModelRenderComponent, TransformComponent>();
pModelRenderer->Initialize(pModel, pTexture);
pTransform->Rotate(0.f, XM_PIDIV2, 0.f);
pTransform->scale = { 0.01f, 0.01f, 0.01f };
2D Graphics
The 2D graphics in this version of TankEngine are implemented using Sprite Batches. This way, we can reduce the number of Draw Calls to one per sprite batch with one Texture Atlas. These sprite batches fed the Graphics Card a buffer of vertices with all the information required for a Geometry Shader to generate the correct primitives and continue with the next stages of Rendering.
After creating a sprite batch and registering it to the renderer, every frame during drawing, any pushed sprite will be rendered!
m_pDynamic_SB = new (Memory::New<SpriteBatch>()) SpriteBatch("Dynamic");
m_pDynamic_SB->InitializeBatch(RESOURCES->Get<Texture>("atlas_0"));
pEngine->RegisterBatch(m_pDynamic_SB);
m_pDynamic_SB->PushSprite(rect, { position.x, position.y, 0 }, 0.f, { 4.f, 4.f }, { 0.f, 0.f }, { 1.f, 1.f, 1.f, 1.f });
Profiling and Debug tools
With the help of ImGui, I created some tools to help visualize what the engine is doing both in memory usage and processing time.
World System Debugger
World System Debugger keeps track of the status of the ECS as a global as well as the individual systems.
Render Settings
The Render settings keep track of all of the sprite batches, how many sprites are being rendered as well as the frame time.
Memory Tracker
The Memory Tracker keeps track of the total memory allocated using the engines Memory tracking features.
Logger
The Logger keeps track of the debug log of the engine. It can also display filtered logs according to the chosen type.
Profiler
The Profiler is a custom-built tool that allows the user to easily Profile code and get a visual representation of their code hierarchy and what might be causing slowdowns
// Populate sprite batches
PROFILE(SESSION_RENDER_ECS,
pRenderer->SpriteBatchRender(m_pWorld->GetSystemByComponent<SpriteRenderComponent>())
);
PROFILE(SESSION_RENDER_ECS,
pRenderer->ModelRender(m_pWorld->GetSystemByComponent<ModelRenderComponent>())
);
// Render your sprite batches
Profiler::GetInstance()->BeginSubSession<SESSION_BATCH_RENDERING>();
PROFILE(SESSION_BATCH_DYNAMIC, m_pDynamic_SB->Render());
PROFILE(SESSION_BATCH_DYNAMIC, m_pDynamicNumbers_SB->Render());
PROFILE(SESSION_BATCH_STATIC, m_pStatic_SB->Render());
Profiler::GetInstance()->EndSubSession();
Resource Management
The resource manager was made to be as effortless to use as possible. At startup, all files of a registered and supported format, get loaded into the asset library. At any time, the user can ask the ResourceManager for an existing asset or to load a new one.
Supported formats:
- fx (HLSL effects file)
- wav
- temd (Tank Engine Model Descriptor for .fbx) *
- jpg
- png
* The Tank Engine Model descriptor contains information on the model FBX file, the texture to use, and which UV Channel
Input Management
The Input manager supports Mouse, Keyboard, and Controller input. For Controller connections, it also features callbacks for connection events. Controller Rumble is also supported!
InputManager::GetInstance()->RumbleController(35000, 35000, 0.2f, m_PlayerController);
// Registering a callback to be called when a controller is connected
// ConnectionType::CONNECTED -> Player created for a specific controller
// ConnectionType::DISCONNECTED -> Player for specific controller is removed
InputManager::GetInstance()->RegisterControllerConnectedCallback(
[this](uint32_t controller, ConnectionType connection)
{
if (connection == ConnectionType::CONNECTED)
m_pPlayers[controller] = Prefabs::CreatePlayer(m_pWorld, m_pDynamic_SB, { float(rand() % 1000 + 300), 0 }, (Player)controller);
else
m_pWorld->DestroyEntity(m_pPlayers[controller]->GetId());
});
// Runs a check for connected controllers, this shouldn't be called every frame. XINPUT is expensive xD
InputManager::GetInstance()->CheckControllerConnection();
// Action mapping for toggling a particle system when R is pressed
InputManager::GetInstance()->RegisterActionMappin(
ActionMapping(SDL_SCANCODE_R, ActionType::PRESSED,
[pParticleSystem]()
{
pParticleSystem->ToggleSpawning();
}));
// Direct input
if (pInputMananager->IsPressed(ControllerButton::DPAD_LEFT, m_PlayerController))
movement.x -= dt * movementSpeed;
if (pInputMananager->IsPressed(ControllerButton::DPAD_RIGHT, m_PlayerController))
movement.x += dt * movementSpeed;