Javier Sandoval

2020 - 2021
|
PC/Windows/Linux
|
Made with Custom C++ Engine

GigaEngine

The goal for this game's engine (dubbed "GigaEngine") architecture was to create an ECS engine that was useful for the game team while still being performant for all the game's needs. While the engine was first being built, there was a lot of speculation still on what the game will be and what features are necessary to make the game work. So, I planned a more general approach when building the engine.

Entity

An entity was a class that acted as an ID that associates a set of components to a single "object" in the game. Systems can use these entities to get components and update them (more on that later). Entities are stored in custom structure called an EntityList. It acts similarly to an std::set with a twist. The EntityList will contain a set of functions that will help filter out entities based on a certain criteria. For example, an EntityList will contain a OfType<T>() function that will create a new EntityList containing all the entities that have a component of type T. Creating a new EntityList on every filter allows for stacking filters on top of each other for more advanced queries. If you want to go through all entities in a EntityList, you can use the ForEach() function with a function or lambda applied on each entity or use the GetEntities() function and go through them yourself.

A common design that that got used for Entities, Components, and Systems is that they all have a manager that will represent the "global" use case for that type. In this case, the EntityManager will contain all the entities in the game.

Code Example

//to keep things simple, entities are just id #'s
using Entity = uint64_t;

//stores all the entities
EntityManager ents(...);

//create unique entities
Entity newEnt = ents.CreateEntity();

//delete entities
ents.DeleteEntity(newEnt);

//grab all entities
EntityList& mainList = ents.Entities();

//filter them and go through them all
mainList.OfType<Enemy>().ForEach([](Entity ent) {
    Debug::Logf("%d is an enemy!", ent);
});

//you can also filter and go through at the same time
mainList.ForEach<Velocity>([](Entity ent, Velocity& vel){
    vel.value *= dt;
});

Components

Components represent the data of an Entity. Components will contain data related to a specific use case and can be removed from an entity at any time. For example, if an entity needs a position in space so it can be rendered onto a screen, then it would need the Position component to make it work. Inside the Position component, there will be 2 float values representing x and y with nothing else. Unlike "true" ECS, components can have functions to have less boilerplate when it comes to the systems.

Components are very similar to entities on how they are stored but on a larger level. Like the rest of the systems, all components are stored in a ComponentManager and they will be stored based on their type. With using the RTTR library, I can get an id that can be used for a hash table and associate a ComponentList with the type id. Each ComponentList will associate an entity with a Component of type T. The component manager will communicate with the EntityManager to get the data necessary when an EntityList requires a component based on the ForEach() functions. It will also communicate with the EntityManager for any DeleteEntity() calls to also remove all the components linked to the entity. When it comes to polymorphism/inheritance, the ComponentManager will link derived class lists with their base classes so EntityList.ForEach() loops can support inheritance components. The ComponentManager also contains general functions such as AddComponent(), GetComponent and DeleteComponent().

Code Example

//stores all the components
ComponentManager comps(...);

//create a component
struct Position : public Component{
    vec3 value;
};

//add it to the list (if it already exists, return the current one)
Position& p = comps.AddComponent<Position>(ent);

//get the component(if it doesn't exist, send nullptr)
Position* p = comps.GetComponent<Position>(ent);

//deleting components
comps.DeleteComponent<Position>(ent);

//can be used in Entities.ForEach
EntityManager.Entities().ForEach<Position>([](Entity ent, Position& pos) {
    pos.value.x += 3.f * dt;
});

//polymorphism
EntityManager.Entities().ForEach<Component>([](Entity ent, Component* comp) {
    Position* pos = dynamic_cast<Position*>(comp));
    if(pos) { /*...*/}
});

Systems

Systems represent all the logic required to modify all the components in a level. Anything ranging from physics to graphics to behaviors will be done through systems. Systems will contain a reference to the respective managers so systems will have full control on adding or removing entities and components throughout the level's lifetime.

The setup for creating a system is very similar to the DOTS System put in place by Unity. You can create a class that is derived by the System class and you can override functions that get executed at diffrent stages. This engine only has 3 possible types: Init(), Update(), Exit(). Init() will only get called when the level will start or the System gets added to a world at runtime. Update() gets called every frame while system is active. Exit() gets called when the World is destroyed or the System will get removed from the world.

In order to keep track of the execution order for each system, the System constructor will have a parameter that will allow the developer to ensure exeucution happens before a given event (e.g. Pre-Graphics, Pre-Physics, etc.).

Code Example

class PlayerSystem: public System {
    public:
        //calls update during the gameplay phase of the frame loop.
        PlayerSystem() : System(System::Order::Gameplay) {} 

        //gets called every frame
        void Update() override{
            //System class has a number of helper references and helper functions to the world variables
            
            //In this case you can call Entities directly from the EnityManager.
            Entities.ForEach<Player>([](Entity ent, Player& player){
                if(Input.GetKeyDown(' '))
                {
                    player.state = PlayerState::Jump;
                    
                    //implicit access to world functions
                    Entity particles = EntityCreate("JumpParticles");
                    CreateComponent<JumpParticles>(particles);

                    //...
                }
            })
        }
};

World

Worlds are the "glue" for all these managers. You can also think of of the world as a level. The World class contains an EntityManager, ComponentManager, and SystemManager. Worlds also has a few helper functions that will allow better communication in between each managers.

Something special about the World class is the use of the Cleanup() functions. Each manager will get have a Cleanup() function where all the DeleteXXX() functions will actually apply those operations at the end of the frame.

The SystemManager will have a reference to the World so all System classes can access to the entire level in a more efficient manner.

Code Example

//create world
World level = World::Create();

//with the world you can do all the things that the ECS managers can do!

//Create entity
level.CreateEntity();

//Create Component
level.CreateComponent<Velocity>();

//Create System
level.CreateSystem<RenderSystem>();

while(true)
{
    //update level and cleanup all delete calls after the end of this call
    level.Update(dt);
}

Serialization

Serialization of Dimlight works mostly through the Cereal library and the RTTR library. Each World will contain its own level and will contain all the data that is used in said world including graphics related data like textures and meshes.

The serialization of the World starts off by saving the respective managers individually. The EntityManager is rarely saved, only when there is specific data regarding an entity like a custom name given to said entity.

The ComponentManager contains the bulk of the data stored for the world. It will go through each ComponentList and try to save each individual component with an entity id attached to it. In C++, its extremely difficult to store the type of object so this is where the RTTR library will come in. It started off with just using the rttr::type class to get the name of the class that needs to be save and query types but once I realized that RTTR has a more robust rttr::variant that abstracts the class it self made the serialization process a lot easier.

With rttr::variant, you can sort through all the data of a component regardless of type. When saving a rttr::variant in Dimlight, it will see if the type of variant is a basic data type (int, float, long, etc.) and if it isn't a basic datatype you can find the underlying members in a variant and recursively find and store the value.

Code Example


//Cereal requires to layout archive members as a template
template <typename Archive>
void archive(Archive& ar, rttr::variant& comp)
{
    //check if current variant is a data type
    if(comp.is_type<float>())
    {
        ar(comp.get_value<float>());
        return;
    }
    else if(comp.is_type<int>())
    {
        ar(comp.get_value<int>());
        return;
    }
    //... continue for all data types

    //if we reach here, then there are data members inside this variant
    std::vector<rttr::property> properties = comp.get_type().get_properties();

    //go through all the entities 
    for(const rttr::property& prop: properties)
    {
        //call this function again and save the data inside this component.
        ar(prop.get_value(comp));
    }
    
}

This setup will make sure that even if a component contains classes or structs inside of them, the classes or structs will also contain the data members inside of itself (and the data members inside of those and so on).

For the SystemManager, it is a lot more straightforward. Since Systems are not designed to contain data, only saving the type of the system itself is good enough for serialization purposes.

For graphics, the serializer will only save the meshes and texture locations. Meshes will store the Vertex data and their respective indices. Texture Data was not stored in the world file but gave references to local file location to each Texture instead. Due to the large size of textures, these textures are preloaded asynchronously.