Gdy spojrzymy na nowoczesne silniki gier, zazwyczaj spotkamy się z koncepcją komponentów. Są to najczęściej klasy dołączane do gameobjectów, dzięki którym obiekt zyskuje pewne nowe funkcje i cechy. Dziś koncepcja ta, rozwinęła się do poziomu, w którym obiekty gry to tylko prymitywne identyfikatory, komponenty stały się strukturami bez funkcji, a cały kod znajduje się w tzw. systemach (ECS). Jednak, w większości przypadków zwykła architektura komponentowa będzie wystarczająca - i na pewno dużo lepsza niż drabinka dziedziczenia.

W tym wpisie przedstawię koncepcję architektury komponentowej i powody dlaczego jest lepsza od zwykłego dziedziczenia. Zacznijmy od początku - od drabinki dziedziczenia.

Drabinka dziedziczenia - podejście naiwne

Na świat gry może składać się bardzo dużo różnych gameobjectów, które współdzielą między sobą pewien podzbiór funkcji. Początkujący programista gier prawdopodobnie będzie chciał utworzyć osobną klasę dla każdego typu aktora i następnie będzie starał się te klasy między sobą dziedziczyć.

Problem z dziedziczeniem

Podejście to może wydawać się słuszne, ponieważ jest w pełni zgodne z paradygmatem programowania obiektowego. Niestety niesie za sobą szereg konsekwencji.

Wyobraźmy sobie, że mamy klasę bazową Orc, oraz dwie klasy pochodne Mage i Warrior. Klasy pochodne nadpisują funkcję render() aby wyrenderować swoją wersję postaci. Dodają również dodatkowe funkcje specyficzne dla nich samych (mag czaruje, warrior atakuje).

Jeżeli teraz chcielibyśmy utworzyć klasę Orc Mage Warrior, napotykamy pierwsze trudności:

  • Nie wszystkie języki programowania dostarczają dziedziczenia wielobazowego.
  • Dodatkowo pojawia się problem z nadpisaną funkcją render(), ponieważ pojawia się ona w dwóch klasach bazowych.
  • Kolejnym problemem jest to, że klasy tracą niezależność - gdybyśmy chcieli dodać coś specyficznego dla maga, musimy się liczyć z tym, że wszystkie klasy bazowe również to otrzymają...

Różnorodność gameobjectów, może być tak duża, że organizacja kodu przy tym podejściu staje się nie lada wyzwaniem. Dodatkowo warto wspomnieć, że z polimorficznymi wywołaniami funkcji wiąże się pewien narzut czasowy. Nawet jeżeli zdecydujemy się dziedziczyć jedynie po cechach (np. Spellable, Attackable), problemy z elastycznością nadal będą występować.

Przy programowaniu gier, szczególnie ważna staje się ogólna zasada, aby przedkładać kompozycje klas ponad ich dziedziczenie.

Wydzielmy, zatem kod odpowiedzialny za poszczególne funkcje gameobjectu do osobnych klas. W taki sposób uzyskaliśmy komponenty...

Architektura komponentowa

Komponent posiada funkcje i dane potrzebne do realizacji swoich zadań. Na przykład komponent graficzny RenderComponent może posiadać dane o modelu trójwymiarowym oraz implementację funkcji renderującej. Gameobjecty, nazywane również jednostkami (ang. entities), implementowane są zazwyczaj jako proste klasy przechowujące instancje komponentów.

Przykład klasy Entity w języku C++ został przedstawiony na listingu poniżej. Wspomniana implementacja oparta została o szablony. Jest to rozwiązanie bardzo wydajne. Dodatkowo tworzenie nowych komponentów odbywa się in-place, przez co nie ma tu żadnego, niepotrzebnego kopiowania.

class Entity {
    using AnyComponentPtr = std::unique_ptr<std::any>;
    using AnyComponentsMap = 
    	std::unordered_map<std::type_index, AnyComponentPtr>;
    	
public:
    template <typename Component, typename ... Args>
    void createComponent(Args&& ... args);

    template <typename Component>
    void deleteComponent();

    template <typename Component>
    bool hasComponent();

    template <typename Component>
    Component& getComponent();
    
private:
    AnyComponentsMap m_components;
};

Aktualizacja obiektów

Przyjrzyjmy się zatem, w jaki sposób możemy aktualizować stan gry (czyli nasze komponenty).

for(auto& entity : entities)
{
    if(entity->hasComponent<RenderComponent>())
    {
        auto& component = entity->getComponent<RenderComponent>();
        component.render();
    }
}

Aktualizację stanu gry dokonujemy iterując po obiektach klasy Entity. Za pomocą funkcji szablonowej getComponent uzyskujemy dostęp do odpowiedniej instancji komponentu, a następnie możemy np. wywołać jej metodę. Warto zwrócić uwagę, że podczas każdego obiegu pętli sprawdzany jest warunek hasComponent. To rozwiązanie można znacznie zoptymalizować, iterując po kontenerze przechowującym jedynie jednostki zawierające interesujący nas komponent.

// Physics
for(auto& component : physicsComponents)
{
    component.update(/* dt */);
}

// Rendering
for(auto& component : renderComponents)
{
    component.render();
}

Na zakończenie

Architektura komponentowa jest wystarczająco elastyczna i wydajna dla większości gier. Co jednak z produkcjami, które wymagają jeszcze większej wydajności? Co takiego można zmienić w standardowej architekturze komponentowej, aby wycisnąć z naszej gry więcej fpsów?

Wydajniejszym rozwinięciem architektury komponentowej jest architektura Entity Component System. Więcej o niej wkrótce w kolejnych wpisach.