Architecture - mtalyat/Minty GitHub Wiki

This is an overview of the architecture for the Minty game engine. All of the code is documented with XML style comments, so you are welcome to read those directly for more specific information on the functions, etc. This document provides a high level overview of the classes and how they interact with one another.

Application

Application diagram.

The Application is a high level class that handles running a "game loop" for a single application. The Application class is completely optional. It's only responsibility is organizing the game loop- all of its actions are done by calling functions from the Context that it is given.

This includes the following step, in this order:

  1. Setup
  2. Loop
    1. Finalize
    2. Render
    3. Process Events
    4. Record Time
    5. Update
  3. Sync
  4. Tear Down

Setup

Setup consists of initializing render loop objects, such as the time object.

Loop

This is the main loop. It will run until the Application is quit, or the Window closes.

Finalize

This will call the finalize() function for the Context the Application is using. Finalize is called first, to complete any operations that need to be completed before rendering. For example, when the (initial) Scene is loaded, all objects with a Transform have their local data set, but need to have their global data updated. This is done in the finalize stage. Otherwise, all objects would be out of place on the first frame rendered.

Render

This will call the render() function for the Context the Application is using.

Process Events

This will call the process_events() function for the Context the Application is using. Any pending events will be processed by the engine here. This can include, but is not limited to:

  • Window events
  • Key input events
  • Mouse input events
  • Controller (Gamepad) input events

Record Time

The time used for the engine will be updated here. The total time and the elapsed time are updated separately. The elapsed time will record the total time passed since the last time the time was recorded. That will either be since the Setup phase of the Application, or since the last frame Record Time. The total time will be the amount of time that has passed since the Setup phase. All times are recorded in nanoseconds, but are converted to seconds for convenience.

Update

This will call the update(Time) function for the Context the Application is using. This will also pass in the newly recorded time. By performing update at the end of the loop, it will almost certainly ensure that your elapsed time is above zero (unless you have a quantum computer).

Sync

This will call the sync() function for the Context the Application is using. This will sync all asynchronous operations, so there are no errors that come from accessing resources being used by other threads. Examples of asynchronous operations include jobs within the JobManager, and rendering tasks within the RenderManager.

Cleanup

This will clean up any resources used by the Application loop objects.

Context

Context diagram.

The Context holds all of the data needed to run a Minty program. A Context consists of a Window, all necessary managers, and a list of registered components and a list of registered systems.

When a Context is created, it will create a Window, register the Components and Systems, and initialize the Managers in a specific order. Some Managers depend on one another to function. Below are a list of relationships to help determine the order:

  • MemoryManager must be initialized before all else, as anything else in the engine may use it to allocate/deallocate data.
  • JobManager must be initialized before AssetManager and SceneManager, as those two Managers use Jobs to asynchronously load data.
  • RenderManager must be initialized before SceneManager, as many Assets require data and resources from the RenderManager when loaded.
  • AudioManager must be initialized before SceneManager, for the same reason as the RenderManager.
  • AssetManager must be initialized after MemoryManager, JobManager, RenderManager, and AudioManager, as all of those have components or functionality that is used by the AssetManager.
  • InputManager has no dependencies.
  • SceneManager must come last, as it is the most likely to depend on another Manager.

When a Context is destroyed, it will dispose of the managers in the reverse order they were initialized in. This, again, is due to the same dependencies as above, but in reverse.

Manager

A Manager is a class that is used to manage resources or functionality within the engine. For example, the RenderManager handles all rendering, the AssetManager handles loading and unloading assets, etc. Each Manager will have a set of virtual functions that other Managers can optionally override. Managers are expected to call their parent's copy of each respective function. This is especially important for initialize() and dispose(), which do perform some actions in the base Manager class.

initialize()

This function is called to initialize the Manager. This is supposed to be used in replacement of initializing things in the constructor for the object. Constructors are only meant to set default/empty values, and those values should be populated here.

dispose()

This function is called to de-initialize the Manager. It should reset or clear any resources used, and should be ready to initialize later.

update(Time)

This function is called to update the Manager. Any operations that must be done once a frame should be done here.

finalize()

This function is used to finalize the Manager. This is also called once a frame, but after the update call. It can be thought of as a "late update." Finalize does not provide the time.

render()

This function is used to render the Manager. Most managers will not use this. This is called once a frame, after the finalize call. It, of course, could do non-rendering things, but the built in Minty objects only use it for rendering or render-related operations.

sync()

This function is used to wait, or sync any asynchronous operations.

handle_event(Event)

This function is used to handle an Event that has occurred. After an Event has been canceled or marked as handled, it will stop being passed to other Managers. Of course, this could be avoided by not changing the state of the Event.

MemoryManager

MemoryManager diagram.

The Memory Manager handles allocating and deallocating memory. By default, C++ allows you to allocate memory on the stack or the heap. The Memory Manager pre-allocates a bunch of data on the heap, in an effort to reduce the number of new calls, and to speed up some operations.

By default, all objects within Minty will default to the Default allocator (Dynamic Memory), but they can be specified using a constructor argument what type of allocator to use.

Allocations and deallocations can be done easily with the allocate() and deallocate() functions, as well as construct() and destruct(). allocate() and deallocate() act more like malloc() and free(), whereas construct() and destruct() act more like new() and delete().

Temporary Memory Stack

The Temporary Memory Stack is stack-styled memory. As data is allocated, it will allocate at the top of the stack and return that data. This is very fast, and is intended for data that will only be allocated for 1 frame or less. The data does not need to be deallocated manually, as it is deallocated at the end of every frame automatically. No destructors are called on this data, as it is not needed, since everything is “wiped.”

Task Memory Stack

The Task Memory Stack is a set of 4 stack-styled memories. Each frame, the data can be allocated on the stack, similar to the Temporary Memory Stack. The only difference is that this data lasts for 4 frames before it is overwritten. Therefore, this data can be used for operations or tasks, such as loading assets, that may take a couple frames to complete. The 4 stacks are cycled through and reused to reduce allocations. Data does not need to be deallocated manually.

Persistent Memory Pool

The Persistent Memory Pool is a collection of pools of various sizes. Several pools are allocated, with varying sizes of slots. Anywhere from 16B to 1MB. When data is allocated with this, a slot of the memory pool is consumed, and will remain in use until the memory is manually freed, or until the MemoryManager is disposed of. If the requested size does not have any pools available, the data will be allocated using dynamic memory.

Dynamic Memory

Dynamic Memory is any memory that is allocated on the heap using malloc or new. In this case, this is an option (Allocator::Default), but can also sometimes be used from Persistent Memory if the pool(s) run out of memory.

JobManager

JobManager diagram.

The JobManager handles batching and running tasks, spread across multiple threads on the system. A task, also known as a Job, can be queued up to be ran. As soon as a thread is available, it will take ownership of the Job and run it.

Jobs can specify 1 or more dependencies on other jobs when it is scheduled. If it has a dependency, it will not run until the other jobs have been completed. Do note that this does not wait for the onComplete() function to be called, but rather, it will run as soon as the dependency Job completed.

After a Job has completed, it has an opportunity to run a callback function, known as an onComplete() function. These functions are queued up, and will be ran on the main thread within the update() loop.

RenderManager

RenderManager diagram.

The RenderManager does not store a lot of data by itself. It mostly serves as the focal point for rendering tasks, such as start/end frames, start/end render passes, bind resources, and draw commands. Most of the complicated code is abstracted away.

The RenderManager holds default Assets for a couple of types for a few specific situations. It will keep track of the default mesh types, such as Cube or Quad. It will also keep track of custom materials generated for Sprites and for Text Assets, so they can be reused.

AudioManager

AudioManager diagram.

The AudioManager handles all of the audio output within the engine. It will stop all sounds when it is disposed of.

The AudioManager has 4 important features to it.

Audio Listener

The Audio Listener is the object receiving sound in a 3D environment. Currently, there can only be one of these at once. Audio Sources will have their audio affected by their relation to the Audio Listener.

Audio Source

An Audio Source is an emitter of sound within a 3D environment. There can be any number of these. They can have various values modified on them, such as their attenuation, and their min and max distance to the Audio Listener.

Attenuation determines the relationship of how the sound changes based on the distance to the Audio Listener, such as: Inverse Distance, Linear Distance, or Exponential Distance. The default is Linear Distance.

Min Distance determines at what distance the max volume is played, from the Audio Listener. Any distance less than the minimum distance will play at full volume.

Max Distance determines at what distance the sound no longer plays, from the Audio Listener. Any distance greater than the maximum distance will be silent.

Regular Sound

The Audio Manager can also play clips in 2D space. These do not require a listener, and can be played more to the left or right, based on the given inputs.

Background Sound

The Audio Manager can also easily handle background sound, like music. This has its own dedicated channel, and will not be interfered with by other sounds.

Asset Manager

AssetManager diagram.

The Asset Manager handles the loading and unloading of Assets. Loading and unloading can either be done from a Wrap file (custom archive file), or by loading the asset directly from the disk. Typically, you will want to read from a Wrap file with a published game, but you could easily read from the file system, if you want to be able to edit the assets easily, or expose them to the user for some reason.

The Wrap files are loaded into a Wrapper. The Wrapper handles virtually combining all loaded Wrap files into one, allowing for easy integration with the file system. A Wrapper, therefore, acts as a virtual file system.

Assets can also be loaded or unloaded synchronously or asynchronously. If a asset is loaded synchronously, it will have to wait for the file reading to be completed before the code moves on. If done asynchronously, it will call a callback function afterwards with the ID of the new Asset.

The Asset Manager is particular about the order in which assets are loaded. If another asset has a dependency on another Asset, it will often not load that Asset. For example, if you load a Material, that has not yet had its corresponding Shader loaded, it will fail to load the Material.

Input Manager

This currently has no special purpose. It will either have some special purpose added to it later, or it will be removed. All input is currently handled through events directly.

It will likely be used for connected controllers, so that and other data is persistent across Scenes.

Scene Manager

SceneManager diagram.

The Scene Manager handles the active Scene, and loading new Scenes. The active Scene will receive the same function calls that the Manager itself receives, such as update(), render(), handle_event(), etc. It can also be used to load Scenes, although this uses the Context's AssetManager in the background.

When a Scene is set as active, it will not immediately make it the next Scene. This is to allow the current Scene to wrap up operations, such as updating. Instead, the Scene is marked as the "next Scene," and it will be made the active Scene on the next finalize() call.

System Manager

SystemManager diagram.

A System Manager controls Systems. This Manager does not live within a Context, but instead, within a Scene. It will pass all of its events onto each System that lives within the System Manager.

Entity Manager

EntityManager diagram.

An Entity Manager controls Entities and Components. The Entity Manager uses EnTT under the hood. On top of that, it provides other functionality, such as complex hierarchical management, updating dirty Transform components, and other ECS management.

Additionally, the Entity Manager keeps tracks of any UUIDs used for each Entity. These are to be used to look up Entities, as names are not guaranteed to be unique.

Scene

Scene diagram.

The Scene is a collection of entities, components, and systems. Each Scene consists of an EntityManager, which controls all Entities and Components within the Scene, and a SystemManager, which controls all of the Systems within the Scene. It also has a list of Assets which are "owned" by the Scene.

Loading

When a Scene is loaded from the disk, it will load any Scene Assets, create the Systems, create the Entities, and finally, create the Components. This order is very particular.

The Assets are loaded first in case any Systems or Components depend on them. These Assets will be unloaded when the Scene is unloaded, unless they are "unregistered" from the Scene.

The Systems are then created. Most systems should not depend on Assets, but if they do, they can rely on the Scene Assets.

The Entities are then created, without any components. This is done intentionally, in case any Components have a dependency on another Entity that has not yet been loaded.

Lastly, the Components for each Entity are created.

Once the Scene is made active/inactive, it will call the formal on_load()/on_unload() functions. The Scene will pass all of its function calls it receives, such as update(), to the System Manager and then the Entity Manager.