Unity wprowadza do swojego silnika tajemnicze Entity Component System (ECS). Jest to bardzo potężne narzędzie, z którego warto korzystać. Dlatego dziś sobie o nim opowiemy.

Czym jest ECS?

Zanim zaczniemy jakąkolwiek zabawę, warto sobie wyjaśnić czym jest wspomniane Entity Component System. W zasadzie nie jest to żadna technologia, czy super biblioteką, a wzorzec architektoniczny. Do tej pory z powodzeniem wykorzystywał go CryEngine, a teraz zawita do Unity.

Żeby to dobrze zrozumieć, warto to zestawić z bardziej klasycznym terminem, jakim jest OOP, czyli Object Oriented Programming. W przypadku programowania obiektowego (bo tym jest OPP) skupiamy się na obiektach. Najczęściej obrazujemy rzeczywistość przez jakiś model. Czyli tworzymy np. obiekt pojazd, który opisany jest danymi typu: “typ pojazdu”, “napęd”. Potem uszczegóławiamy, tworząc dziedziczącą po klasie pojazd, klasę “samochód”. Tam dopisujemy kolejne pola, takie jak “liczba kół”, “typ nadwozia”. Operujemy na obiektach. W przypadku OPP stawialiśmy na zamknięte obiekty. To znaczy, że zarówno dane, jak i logika zawierały się wewnątrz obiektu. Nawiązując do obiektu auto, w naszej klasie będziemy mieli zarówno stan baku, jak i funkcję “zatankuj”.

W przypadku ECS odchodzimy od tego schematu i rozdzielamy dane od logiki. Nie trzymamy ich w jednej klasie, o czym zaraz sobie dokładniej opowiemy.

W pewnym momencie OPP dla gier stało się niewystarczające. Mnogość komponentów, zależności w hierarchiach (przez dziedziczenie) zaczęło sprawiać, że ten system stał się kompletnie niepraktyczny.

Dlaczego nie OPP?

W klasycznym modelu pracy z Unity kolejność działań wygląda następująco:

  • Tworzymy obiekt
  • Przypisujemy mu komponenty
  • Dodajemy skrypty

Potencjalnie nie widać tu problemu, ale są dwa i to bardzo istotne, ukryte pod maską. Dokładniej na pamięci komputera. To, że podanie danej z dysku twardego jest wolniejsze od sięgnięcia po Cache, nie muszę nikogo tutaj uczyć. Zdarza się tak, że niektóre managery pamięci potrafią przewidzieć pobieranie z pamięci i je nieco przyspieszyć. Jeśli pobieramy np. tablicę mającą 300 elementów, to taki manager może przewidzieć, że jak pobraliśmy element 50, to zaraz będziemy chcieli 51 i przerzuca nam te dane do pamięci cache, skąd są pobierane szybciej. Może to zrobić, bo tablica, będąc alokowana w pamięci, zajmuje spójny obszar. Natomiast w przypadku alokowania pamięci dla GameObjectu z komponentami, dane będą całkowicie poszatkowane i rozrzucone po całej pamięci. Sięgnięcie więc po jeden obiekt może zająć sporo czasu.

Alokowanie pamięci dla GameObject [2]
Drugi problem jest nieco mniej oczywisty. Załóżmy, że chcemy dokonać przesunięcia obiektu. Żeby to zrobić poprawnie, potrzebujemy odwołać się do komponentu transform, a dokładniej do zmiennej position, może jeszcze rotation. Tymczasem, przy klasycznym modelu pracy z Unity musimy pobrać cały komponent, ze wszystkimi pozostałymi danymi, których prawdopodobnie nie wykorzystamy, a pamięć się marnuje.

No to jak działa to ECS?

Jak już powiedziałem, Entity Component System to wzorzec architektoniczny, więc wymusza na nas zmianę nie tylko pisanego kodu, co zmianę podejścia do pisania kodu, projektowania gry, ale też cały sposób myślenia.

ECS zmienia logikę. Przestajemy myśleć o obiektach, a zaczynamy myśleć o Jednostkach (entities), Komponentach (components) oraz Systemach (system). Co jest tutaj czym?

Jednostki to obiekty użytkowe, które posiadają identyfikator. To dzięki indetyfikatorowi, można odróżnić od siebie konkretne obiekty.

Komponenty zawierają w sobie jedynie informację. Najczęściej wykorzystuje się tu klasy, struktury albo tablice.

Systemy wykonują operację na jednostkach, które dysponują komponentami zależnymi od systemu. Tutaj małe wyjaśnienie czym jest komponent zależny od systemu. Zakłada się dwa podejścia do problemu. Pierwsze mówi, że każdy komponent musi mieć swój system. Przykładowo tworzymy ColliderComponent, to musi istnieć ColliderSystem. Jednak to rozwiązanie nas nieco ogranicza i moim zdaniem drugie, które dopuszcza, że jeden System operuje na kilku komponentach, jest lepsze. Dlaczego? Bo mając ColliderComponent i RigidbodyComponent, możemy sobie zrobić PhysicsSystem i zarządzać całą fizyką z jednego miejsca, zamiast niepotrzebnie się rozdrabniać.

Uproszczony obiekt wykorzystujący ECS. Uproszczony, bo można by go pozbawić jeszcze kilku elementów i zastąpić je komponentami.

Zasadniczo można to rozumieć tak. Mamy komponenty, które przechowują jakieś dane. Np. Komponent życia, który trzyma punkty życia postaci. Komponent walki wręcz, który dysponuje informacją, ile obrażeń zadajemy etc. Te komponenty będą najczęściej skryptami, bardzo prostymi skryptami. Do tego mamy nasze Systemy, które będą odpowiednim komponentem umiały zarządzać, czyli HealthSystem będzie umiał dodawać i odbierać życie. To również będzie jakiś skrypt. Natomiast Jednostka, to jakiś GameObject, wyposażony w odpowiednie komponenty. Wyraźnie tutaj widać rozdzielenie logiki, która znajduje się teraz w systemach od danych, które przebywają w komponentach.

Czy to ma jakieś wady?

Zastanówmy się teraz jakie mamy wady i zalety tego rozwiązania? Zalety nasuwają się w dość oczywisty sposób. Dane są przechowywane w lepszy sposób w pamięci, a pobierając daną, nie zbieramy zbędnych informacji. Więc mamy spory plus optymalizacyjny. Dodatkowo zyskujemy na możliwości ponownego użycia gotowego kodu. Mniejsze komponenty możemy spokojnie wykorzystywać w innych projektach czy innych obiektach w jednej grze. Również sporo zyskujemy w przypadku rozwoju projektu. W przypadku OPP zarządzanie hierarchami (dziedziczeniem) może sprawiać, że dopisywanie nowych rzeczy z czasem będzie trudne, a dodatkowo dany obiekt będzie nadawał się tylko do tego, do czego został napisany i nie wykorzystamy tego kodu już w innym projekcie.

Wada jest w sumie jedna. Zarządzanie całością. Kiedy mamy dużo komponentów i faktycznie oddzielny system do każdego, to może nam wyjść niezła siatka poplątanych ze sobą systemów. Trzeba to wszystko ze sobą skomunikować. Szczególnie dla początkujących programistów to może być trudny orzech do zgryzienia. Jednak jeśli dobrze sobie przemyślimy projekt, to powinno pójść gładko.

Jazda testowa z ECS

No to, czas sobie przetestować nasze Entity Component System. Zobaczyć jak tworzy się kod pod ECS oraz czy faktycznie jest jakaś poprawa prędkości. Do naszych testów stworzymy bardzo prostą “grę”, która będzie spawnować obiekty w losowej lokacji, po czym będzie je przesuwać w dół, aż do osiągnięcia współrzędnej Y = 0, po czym taki obiekt będzie przenoszony na górę ekranu. Nie chodzi nam teraz o sens, czy logikę. Chcemy zobaczyć, jak to działa i szybko namnożyć obiektów, żeby przetestować szybkość Entity Component System.

Kod kontrolny

Zacznijmy od jakiegoś programu kontrolnego, czyli prostego skryptu napisanego w sposób klasyczny, którego wyniki działania zestawimy ze zoptymalizowanym kodem za pomocą ECS.

Potrzebujemy przede wszystkim jakiś model. Ja sobie pozwoliłem pobrać ten pakiet:

https://assetstore.unity.com/packages/3d/vehicles/space/star-sparrow-modular-spaceship-73167

Teraz przygotowujemy scenę testową. Potrzebujemy 2 skrypty. Pierwszy nazywam Movement_C.cs. Jego kod wygląda tak:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Movement_C : MonoBehaviour
{

    public float speed = 5.0f;

    void Update()
    {
        Vector3 pos = transform.position;
        pos += transform.forward * speed * Time.deltaTime;

        if (pos.y < -14)
            pos.y = 16;

        transform.position = pos;
    }

}

Jak widać, nie ma tutaj cudów. W każdej klatce pobieramy lokację obiektu, zwiększamy pozycję o pewną wartość, po czym, jeśli pozycja w osi Y jest poniżej -14, to ustalamy ją na 16 i ustawiamy pozycję dla obiektu. Wartości -14 i 16 wziąłem sobie z testów ustawienia obiektu. Po prostu przy wartości 16 mam obiekt u samej góry ekranu, a przy -14 znika na dole.

Ten skrypt przypisujemy do dowolnego prefabu z pobranego projektu. Tworzymy drugi skrypt: GameManager.cs Ten jest nieco bardziej złożony, ale tylko pozornie.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameManager : MonoBehaviour {

    public GameObject enemyShipPrefab = null;
    public int enemyShipIncremement = 100;

    float deltaTime = 0.0f;
    int objects = 0;

    void Update() {

        if ( Input.GetKeyDown( "space" ) ) { 
            AddShips(enemyShipIncremement);
        }

        deltaTime += (Time.unscaledDeltaTime - deltaTime) * 0.1f;
    }

    void AddShips(int amount) {

        objects += amount;

        for (int i = 0; i < amount; i++) {
            float xVal = Random.Range(-28, 29);
            float zVal = Random.Range(15f, 25f);
            Vector3 pos = new Vector3(xVal, 16f, zVal);
            Quaternion rot = Quaternion.Euler(90f, 180f, 0f);
            var obj = Instantiate(enemyShipPrefab, pos, rot) as GameObject;
        }
    }

    void OnGUI()
    {
        int w = Screen.width, h = Screen.height;

        GUIStyle style = new GUIStyle();

        Rect rect = new Rect(0, 0, w, h * 2 / 100);
        style.alignment = TextAnchor.UpperLeft;
        style.fontSize = h * 2 / 100;
        style.normal.textColor = new Color(0.0f, 0.0f, 0.5f, 1.0f);
        float msec = deltaTime * 1000.0f;
        float fps = 1.0f / deltaTime;
        string text = string.Format("{0:0.0} ms ({1:0.} fps)", msec, fps);
        GUI.Label(rect, text, style);

        rect = new Rect(0, (h * 2 / 100) * 2, w, h * 2 / 100);
        text = string.Format( "Objects: {0})", objects );
        GUI.Label( rect, text, style );
    }

}

Na początek deklarujemy sobie parę zmiennych. Nie będę ich omawiał, bo wyjaśnią się w reszcie skryptu, który omówię funkcja po funkcji.

void Update() {

    if ( Input.GetKeyDown( "space" ) ) { 
        AddShips(enemyShipIncremement);
    }

    deltaTime += (Time.unscaledDeltaTime - deltaTime) * 0.1f;
}

Pierwsze linijki to wywołanie funkcji AddShips na naciśnięcie spacji, z parametrem “enemyShipIncremement”, zmienna ta określa ile statków, stworzymy po jednym kliknięciu.

Ostatnia linijka to liczenie czasu, które potrzebne nam będzie do ustalenia ile klatek na sekundę wykonuje nasz program, co da nam pewien pogląd na optymalizację.

void AddShips(int amount) {

    objects += amount;

    for (int i = 0; i < amount; i++) {
        float xVal = Random.Range(-28, 29);
        float zVal = Random.Range(15f, 25f);
        Vector3 pos = new Vector3(xVal, 16f, zVal);
        Quaternion rot = Quaternion.Euler(90f, 180f, 0f);
        var obj = Instantiate(enemyShipPrefab, pos, rot) as GameObject;
    }
}

Tutaj mamy funkcję AddShip. Pierwsze co robimy to zwiększamy zmienną objects, która również istnieje w celach informacyjnych, żebyśmy wiedzieli, ile obiektów już stworzyliśmy.

Później mamy pętle for, aby faktycznie stworzyć obiekty. Pierwsze dwie zmienne to początkowa pozycja obiektu w osiach X i Z. Wartości wybrałem empirycznie, testując, kiedy obiekt mi się mieści w ekranie. Wektor pos to startowa pozycja statku, z losowymi pozycjami w osiach X i Z, oraz ze stałym 16 (aby mieściło się w ekranie) w osi Y. Następnie mamy ustawienie, rotacji, która technicznie nie ma znaczenia, dopóki “przód statku” skierowany jest w dół, żeby nasz skrypt Movement przesuwał statek w dół. Jeśli korzystacie z tej paczki co ja, te ustawienia rotacji będą idealne. Na koniec tworzymy obiekt.

Ostatnią funkcję pominę w omawianiu, bo jest to proste wypisanie liczby obiektów i klatek na sekundę w lewym górnym rogu ekranu.

Kiedy to wszystko mamy, dodajemy sobie GameManager do obiektu kamery i przypisujemy do publicznej zmiennej prefab, do którego dodaliśmy skrypt Movement. I teraz możemy się bawić. Po odpaleniu gry w lewym górnym rogu powinniśmy widzieć napis podający liczbę obiektów oraz liczbę klatek na sekundę. Naciśnięcie spacji wytworzy nam nowe 100 obiektów.

Wyniki dla klasycznej metody pisania kodu w Unity.

Co na to Entity Component System?

Zobaczmy, na co stać to całe Entity Component System. Aby skorzystać z ECS po pierwsze musimy wejść sobie w menu: Edit -> Project Settings -> Player Settings. Następnie z menu rozwinąć zakładkę Other Settings i ustawić Scripting Runtime Version na .NET 4.5, będzie to wymagało ponownego uruchomienia Unity.

Uruchamiamy .NET 4.5

Po tym otwieramy folder Unity na dysku za pomocą przeglądarki systemowej (albo klikając prawym klawiszem myszki w panelu Project i z menu kontekstowego wybieramy “Show in Explorer”. Wchodzimy do folderu “Packages”, który będzie w głównym folderze z projektem i otwieramy plik o nazwie manifest.json.

Podmieniamy sobie zawartość tego pliku na to:

{
    "dependencies": {
        "com.unity.modules.ui": "1.0.0", 
        "com.unity.modules.tilemap": "1.0.0", 
        "com.unity.modules.physics2d": "1.0.0", 
        "com.unity.modules.assetbundle": "1.0.0", 
        "com.unity.modules.unitywebrequestassetbundle": "1.0.0", 
        "com.unity.test-framework.performance": "0.1.34-preview", 
        "com.unity.modules.unityanalytics": "1.0.0", 
        "com.unity.modules.umbra": "1.0.0", 
        "com.unity.modules.vehicles": "1.0.0", 
        "com.unity.modules.imageconversion": "1.0.0", 
        "com.unity.modules.director": "1.0.0", 
        "com.unity.modules.video": "1.0.0", 
        "com.unity.modules.audio": "1.0.0", 
        "com.unity.modules.unitywebrequest": "1.0.0", 
        "com.unity.modules.ai": "1.0.0", 
        "com.unity.modules.unitywebrequestwww": "1.0.0", 
        "com.unity.modules.particlesystem": "1.0.0", 
        "com.unity.modules.imgui": "1.0.0", 
        "com.unity.modules.physics": "1.0.0", 
        "com.unity.modules.screencapture": "1.0.0", 
        "com.unity.modules.xr": "1.0.0", 
        "com.unity.modules.terrain": "1.0.0", 
        "com.unity.modules.unitywebrequestaudio": "1.0.0", 
        "com.unity.modules.jsonserialize": "1.0.0", 
        "com.unity.modules.terrainphysics": "1.0.0", 
        "com.unity.entities": "0.0.12-preview.19", 
        "com.unity.modules.animation": "1.0.0", 
        "com.unity.package-manager-ui": "2.0.0-preview.3", 
        "com.unity.modules.cloth": "1.0.0", 
        "com.unity.modules.uielements": "1.0.0", 
        "com.unity.modules.vr": "1.0.0", 
        "com.unity.modules.unitywebrequesttexture": "1.0.0", 
        "com.unity.modules.wind": "1.0.0"
    }, 
    "registry": "https://packages.unity.com", 
    "testables": [
        "com.unity.collections", 
        "com.unity.entities", 
        "com.unity.jobs", 
        "com.unity.test-framework.performance"
    ]
}

Zapisujemy plik. Unity będzie musiało sobie chwilę pomyśleć. Teraz jak wybierzecie z menu Window -> Package Manager, powinniście na liście znaleźć pozycję Entity. Jeśli tak jest, wszystko poszło gładko.

[stextbox id=’alert’ defcaption=”true”]To, co wykorzystamy teraz, to nie będzie czyste Entity Component System, a hybrydowe ECS zaproponowane przez Unity, dla ułatwienia wejścia w nowy sposób tworzenia oprogramowania, oraz dla ułatwienia podmiany starego kodu na nowy. Wprowadza kilka ułatwień, dzięki którym nie musimy o nich sami pamiętać. [/stextbox]

To pora na nieco kodu. Żeby sobie uzmysłowić co się w ogóle dzieje, otworzymy sobie Entity Debugger, co można zrobić z menu Window -> Analysis -> Entity Debugger. W tym oknie będą wyświetlały się wszystkie jednostki (Entities) jakie utworzymy.

Debugowanie Entities w Entity Component System w Unity

Ja teraz utworzyłem sobie nową scenę i wybrałem drugi prefab statku – podmieniłem w nim Mesh i Materiał na te ze statku 1, żeby operować na jednym modelu. Pierwsze co musimy zrobić, to uczynić statek Jednostką (Entity), aby to zrobić, dodajemy mu skrypt Game Object Entity. Co najprościej zrobić przez zaznaczenie obiektu, kliknięcie “Add Component” w oknie Inspector i wpisanie “Entity” w wyszukiwarce. Poszukiwany skrypt powinien wyskoczyć na samej górze.

No to teraz tworzymy nowy skrypt, który ja nazwałem Movement_ECS.cs, jego budowa wygląda tak:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Movement_ECS : MonoBehaviour {

    public float speed = 5.0f;

}

Jak wspominaliśmy komponenty w ECS mają zawierać tylko dane. Nasze Movement_ECS jest komponentem, więc jedyne co w nim trzymamy to zmienną informującą o prędkości poruszania się statku. Tutaj widać różnicę między ECS a klasycznym Unity. Wywaliliśmy ze skryptu logikę, czyli klasę Update. Ta powędruje do Systemu (Systems), który będzie zarządzać wszystkim, co związane z naszym komponentem. Dlatego tworzymy nowy skrypt MovementSystem.cs. Jego budowa wyglądała będzie tak:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;

public class MovementSystem : ComponentSystem {

	struct Components
    {
        public Movement_ECS movement;
        public Transform transform;
    }
	
    protected override void OnUpdate()
    {
        float deltaTime = Time.deltaTime;

        foreach ( var e in GetEntities<Components>() )
        {
            Vector3 pos = e.transform.position;
            pos += e.transform.forward * e.movement.speed * deltaTime;

            if (pos.y < -14)
                pos.y = 16;

            e.transform.position = pos;
        }
    }
}

Zmian jest sporo. Pierwsza istotna zmiana jest w linii 4. Żeby korzystać z dobrodziejstw ECS musimy załączyć bibliotekę Entities.

Druga widoczna zmiana, to fakt, że nie dziedziczymy po MonoBehaviour, a po ComponentSystem.

Na uwagę zasługuje ten fragment kodu:

struct Components
{
    public Movement_ECS movement;
    public Transform transform;
}

O co tutaj chodzi? Otóż jak wspomniałem systemy, obsługują tylko konkretne komponenty. W tej strukturze informujemy nasz system, jakie obiekty ma obsłużyć. W tym wypadku zajmie się jedynie obiektami, które dysponują komponentem Transform, oraz naszym skryptem Movement_ECS. Jest to kluczowe, bo skryptu systemu NIE dodajemy do żadnego obiektu w grze. Ta informacja musi my wystarczyć, żeby znalazł odpowiednie obiekty.

Została funkcja OnUpdate. W Entity Component System nadpisujemy funkcję Update z klasy, po której dziedziczymy, stąd słówko override i stąd protected.

W pętli foreach GetEntites jest funkcją wbudowaną w Unity i wyszukuje wszystkie obiekty (Jednostki), które spełniają założenia zbudowanej wcześniej struktury, podanej jako parametr. Wewnątrz pętli odwołujemy się do komponentów przez e.  bo tak nazwaliśmy zmienną dla pętli (e jak Entity). Odwoływać możemy się jedynie do komponentów podanych w strukturze.

DeltaTime jest wyrzucone za pętle, bo jest identyczne dla każdego obiektu, więc jest to dodatkowy trik optymalizacji.

Powtarzamy teraz sekwencję z wcześniej, czyli Movement_ECS.cs przyczepiamy do prefabu statku, GameManager do kamery, przypisujemy prefab i uruchamiamy projekt.

Wyniki dla Hybrid Entity Component System w Unity

Pure ECS

Jak wspomniałem to, co omówiliśmy przed chwilą to Hybrid Entity Component System, które jest ukłonem w stronę użytkowników Unity. Hybrydowe ECS od czystego różni się tym, że w przypadku czystego ECS nie możemy korzystać z MonoBehaviour (które my sobie wykorzystaliśmy) jako komponentu, oraz z GameObjectów. System Unity dzięki hybrydowemu ECS zmienia MonoBahaviour i GameObjecty w Entities i Components. Tym samym nie musimy się tym martwić i zajmować. Pisanie kodu jest przyjemniejsze i wymaga mniej nakładu pracy, żeby przerobić istniejący kod na Entity Component System. Jednak pewnie większość się zastanawia, czy napisanie kodu w czystym ECS da jeszcze lepsze efekty wydajnościowe? Po co się zastanawiać? Sprawdźmy to!

Nieco inne będzie nasze podejście do budowania nowej sceny, ponieważ czyste Entity Component System nie obsługuje GameObjectów, dlatego nie możemy skorzystać z gotowego prefabu. Tym razem, niemal całość “gry” zamknie się w kodzie.

Przy okazji przyznam, że ze wszystkich 3 rozdziałów tego kursu, ten zajął mi najwięcej czasu pod względem pisania kodu. Przykładów i dokumentacji na temat czystego Entity Component System w Unity jest bardzo mało i często słabo opisane. To na marginesie, a teraz wracamy do pracy.

Zaczynamy sobie od przygotowania skryptu Movement_PureECS.cs, czyli znów naszego komponentu.

using System.Collections;
using System.Collections.Generic;
using Unity.Entities;
using UnityEngine;

public struct Movement_PureECS : IComponentData
{
    public float speed;
}

Tak wygląda całość i jeśli zestawicie sobie go z Movement_ECS.cs to zobaczycie jedną szczególną różnicę. Nie dziedziczymy po MonoBehaviour, a po IComponentData. Druga różnica to fakt, że aby mieć dostęp do tej klasy musimy sobie dodać Unity.Entites. Ostatnia różnica jest taka, że w przypadku Hybrydowego ECS mieliśmy klasę, tutaj mamy strukturę. To właśnie Hybrydowe ECS w Unity zmienia naszą klasę z poprzedniego przykładu w mniej więcej coś takiego. Tym razem komponentu nigdzie nie przypisujemy, zrobimy to w kodzie w trakcie tworzenia entity.

Teraz zajmiemy się game managerem, który u mnie nazywa się PureGameManager.cs, a jego kod wygląda tak:

using System.Collections;
using System.Collections.Generic;
using Unity.Mathematics;
using Unity.Entities;
using Unity.Rendering;
using Unity.Transforms;
using UnityEngine;


public class GameManager_PureECS : MonoBehaviour {

    private static EntityManager entityManager;
    private static MeshInstanceRenderer meshRenderer;
    private static EntityArchetype shipArchetype;

    public Mesh mesh;
    public Material material;

    float deltaTime = 0.0f;
    int objects = 0;

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    public static void Initalize()
    {
        entityManager = World.Active.GetOrCreateManager<EntityManager>();

        shipArchetype = entityManager.CreateArchetype(
            typeof(Position),
            typeof(Movement_PureECS),
            typeof(MeshInstanceRenderer)
        );
    }
    
    public void Update()
    {

        int amount = 100;

        if (Input.GetKeyDown("space"))
        {
            objects += amount;

            for (int i = 0; i < amount; i++) {
                AddShip();
            }
        }

        deltaTime += (Time.unscaledDeltaTime - deltaTime) * 0.1f;
    }


    private void AddShip()
    {
        Entity shipEntity = entityManager.CreateEntity(shipArchetype);

        float xVal = UnityEngine.Random.Range(-28, 29);
        float zVal = UnityEngine.Random.Range(15f, 25f);
        Quaternion rot = Quaternion.Euler(90f, 180f, 0f);

        entityManager.SetComponentData(shipEntity, new Position { Value = new Vector3(xVal, 16, zVal) });
        entityManager.SetComponentData(shipEntity, new Movement_PureECS { speed = 10f });
        entityManager.SetSharedComponentData(shipEntity, new MeshInstanceRenderer
        {
            mesh = mesh,
            material = material
        });
    }

    void OnGUI()
    {
        int w = Screen.width, h = Screen.height;

        GUIStyle style = new GUIStyle();

        Rect rect = new Rect(0, 0, w, h * 2 / 100);
        style.alignment = TextAnchor.UpperLeft;
        style.fontSize = h * 2 / 100;
        style.normal.textColor = new Color(0.0f, 0.0f, 0.5f, 1.0f);
        float msec = deltaTime * 1000.0f;
        float fps = 1.0f / deltaTime;
        string text = string.Format("{0:0.0} ms ({1:0.} fps)", msec, fps);
        GUI.Label(rect, text, style);

        rect = new Rect(0, (h * 2 / 100) * 2, w, h * 2 / 100);
        text = string.Format("Objects: {0})", objects);
        GUI.Label(rect, text, style);
    }
}

Funkcję Update i OnGUI zostały niezmienione, więc je omijam. Pojawiły się za to dwie nowe funkcje, które sobie dokładniej omówimy:

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
public static void Initalize()
{
    entityManager = World.Active.GetOrCreateManager<EntityManager>();

    shipArchetype = entityManager.CreateArchetype(
        typeof(Position),
        typeof(Movement_PureECS),
        typeof(MeshInstanceRenderer)
    );
}

Na początek dyrektywa. Mówi ona, że ta funkcja ma zostać wywołana zaraz, zanim scena się załaduje. (BeforeSceneLoad). Żeby było to możliwe, funkcja jest statyczna.

Teraz to, co się dzieje w środku. Pierwsza linijka to stworzenie za pomocą globalnej zmiennej World, Entity Managera, czyli zmiennej, która będzie zarządzała Jednostkami (Entities). W Hybrid ECS nie mieliśmy tego, bo zajęło się tym samo Unity. Druga linijka to tworzenie archetypu jednostki. W trakcie tworzenia Entity, możemy podawać komponenty pojedynczo, albo stworzyć wcześniej Archetyp. Można uznać, że jest to matryca dla jednostek. Później, gdy będziemy tworzyć nowe jednostki, to każda korzystająca z tego archetypu, będzie miała dokładnie taki zestaw komponentów, jak podano w archetypie.

Czas na czary, czyli tworzenie jednostek.

private void AddShip()
{
    Entity shipEntity = entityManager.CreateEntity(shipArchetype);

    float xVal = UnityEngine.Random.Range(-28, 29);
    float zVal = UnityEngine.Random.Range(15f, 25f);

    entityManager.SetComponentData(shipEntity, new Position { Value = new Vector3(xVal, 16, zVal) });
    entityManager.SetComponentData(shipEntity, new Movement_PureECS { speed = 10f });
    entityManager.SetSharedComponentData(shipEntity, new MeshInstanceRenderer
    {
        mesh = mesh,
        material = material
    });
}

Pierwsza linijka to przygotowanie Entity, za pomocą funkcji CreateEntity. Jak widać, podajemy tutaj archetyp, który wcześniej przygotowaliśmy, ale równie dobrze można podawać komponenty tutaj, na żywo. Wygląda to jednak nieco inaczej. Ten sam kod, bez archetypu wyglądałby tak:

Entity shipEntity = entityManager.CreateEntity(
    ComponentType.Create<Position>(),
    ComponentType.Create<Movement_PureECS>(),
    ComponentType.Create<MeshInstanceRenderer>()
);

Kolejne linijki to znane już losowanie pozycji. Na koniec ustalamy wartości dla komponentów. Ponieważ komponenty to struktury, to nie mogą mieć domyślnych wartości – w Hybrydowym ECS mogliśmy podać domyślną szybkość w naszym komponencie, tym razem nie ma takiej możliwości – musimy zadeklarować wartość dla każdego z komponentów.

Budowa funkcji SetComponentData jest nieco mało atrakcyjna. Najpierw podajemy Entity, dla którego ustalamy wartość, potem tworzymy nowy obiekt typu danego komponentu, stąd new Position lub new Movement_PureECS. Następnie są nawiasy klamrowe i ustalenie wartości dla parametrów struktury. Domyślny komponent Position ma jedną wartość typu Vector3 (lub float3) o nazwie Value, stąd takie, a nie inne przypisanie. Oczywiście można to zrobić poza funkcją i podać jako parametr dodatkową zmienną, ale w takich przypadkach szkoda pamięci.

Ostatnia linijka korzysta z funkcji SetSharedComponentData i szczerze powiem, że nie wiem, jaka jest różnica między nią, a SetComponentData. Dokumentacja Unity i znaleziona wiedza nie odpowiada mi na to pytanie. Jednak w przypadku MeshInstanceRenderer trzeba użyć tej pierwszej, aby nie otrzymać błędu kompilacji. Jak ktoś zna odpowiedź, to dajcie znać w komentarzu, a z radością dopiszę wyjaśnienie.

Na koniec został do napisania nowy system u mnie PureMovementSystem.cs:

using System.Collections;
using System.Collections.Generic;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;

public class PureMovementSystem : ComponentSystem
{

    public struct Data
    {
        public readonly int Length;
        public ComponentDataArray<Position> Position;
        public ComponentDataArray<Movement_PureECS> Movement;
    }

    [Inject]
    private Data m_Data;

    protected override void OnUpdate()
    {
        float deltaTime = Time.deltaTime;

        for (int index = 0; index < m_Data.Length; ++index)
        {
            var position = m_Data.Position[index].Value;

            position += new float3(0, deltaTime * -1 * m_Data.Movement[index].speed, 0);

            if( position.y < -14)
            {
                float xVal = UnityEngine.Random.Range(-28, 29);
                float zVal = UnityEngine.Random.Range(15f, 25f);
                position = new float3(xVal, 16, zVal);
            }

            m_Data.Position[index] = new Position { Value = position };
        }
    }
}

Zmieniła się ponownie struktura. Tym razem nie jest ona dowolna, jak było w przypadku Hybrydy. Każda ze zmiennych (które dalej reprezentują komponenty) musi być typu ComponentDataArray. Dodatkowo potrzebna jest zmienna długości tablicy, które jest tylko do odczytu.

Dzieje się tak, ponieważ nie korzystamy sobie teraz z wygodnego GetEntities. Tablice wypełnia nam ten kod:

[Inject]
private Data m_Data;

Ten kod automatycznie wypełnia nasze tablice na podstawie podanych komponentów. Wywoływany jest raz przed każdym wywołaniem OnCreateManager, OnDestroyManager i OnUpdate. Nie ma sensu wchodzić w jego technikalia.

No i ponownie nasza funkcja OnUpdate. Zmiany są kosmetyczne. Po pierwsze, pętle foreach zastępujemy klasyczną pętlą for. Co za tym idzie, nie odwołujemy się do obiektów za pomocą e.  za to za pomocą nieco bardziej skomplikowanego: m_Data.Position[index]  oczywiście w miejsce Position można podać inny komponent.

Zostaje przygotować scenę. Jak już mówiłem, nie robimy nic z samym komponentem czy z systemem. Dodajemy sobie tylko ponownie PureGameManager do naszej kamery i uzupełniamy zmienne.

Jest jeszcze jedna uwaga. Może się pojawić błąd przy renderowaniu materiału. Należy wtedy znaleźć materiał, zaznaczyć go, a w oknie inspektor wybrać “Enable GPU Instancing”.

Można odpalać i się bawić.

Uważny czytelnik zauważy, że jedna rzecz się zmieniła, a mianowicie nie dodałem komponentu Rotate. Nie zrobiłem tego, ponieważ przy każdej próbie dodania samego komponentu do Archetypu Unity się crashowało, a logi nie były zbyt pomocne w odkryciu, czemu się tak dzieje. Dlatego pozwoliłem sobie rotację porzucić, licząc, że to jakiś problem samego Unity i zostanie z czasem naprawiony.

Wyniki dla Pure Entity Component System w Unity

Ile to wszystko warte?

Test przeprowadzałem następująco. Zwiększam liczbę statków o 100 i odczekiwałem chwilę. Robiłem tak do momentu odnotowania pierwszego spadku liczby klatek poniżej 30, czyli gdy pierwszy raz zobaczyłem 29 klatek. Wyniki wyglądają następująco:

Wykorzystana Metoda – Liczba obiektów gdy pojawiło się 29 klatek, czas poświęcony na wykonanie MovementSystem.

Klasyczne Unity: 3800 obiektów, 7.26ms

Hybrid ECS: 4000 obiektów, 5.0ms

Pure ECS: 8500, 2.81ms

Zaznaczę od wyjaśnienia “~” w Pure ECS. W przypadku pozostałych systemów czas był raczej stabliny, w przypadku Pure skakał dość znacząco i raz wskazywał 2.8ms, a raz nawe 12ms. Najczęściej jednak wskazywał liczbę w okolicy 5.2ms, stąd uznałem taki wynik.

Jak widać wykorzystanie ECS daje spore zyski, jeśli chodzi o wykorzystanie obiektów oraz szybkość obliczeń. Jeszcze lepsze wyniki otrzymujemy dla Pure ECS, jednak jest dużo bardziej problematyczny w implementacji i stanowczo odradzam przesiadkę na niego początkującym programistom.

To nie wszystko! Wspominałem, że ECS najczęściej pojawia się z Job System i z Burst Compilerem. W kolejnym wpisie przysiądziemy sobie właśnie do Job System i sprawdzimy, jak jego wykorzystanie wpłynie na prędkość renderowania tej samej sceny.

Źródła:

[1] https://docs.unity3d.com/Manual/JobSystem.html
[2] https://software.intel.com/en-us/articles/get-started-with-the-unity-entity-component-system-ecs-c-sharp-job-system-and-burst-compiler
[3] https://digitalcommons.calpoly.edu/cgi/viewcontent.cgi?referer=https://pl.wikipedia.org/&httpsredir=1&article=1138&context=cpesp
[4] https://hackernoon.com/when-not-to-use-ecs-48362c71cf47
[5] https://www.youtube.com/watch?v=_U9wRgQyy6s
[6] http://infalliblecode.com/unity-ecs-survival-shooter-part-2/
[7] https://www.youtube.com/watch?v=Q-52mBy2mow
[8] https://www.youtube.com/watch?v=lDTyCYAtQyQ
[9] https://gametorrahod.com/all-of-the-unitys-ecs-job-system-gotchas-so-far-6ca80d82d19f
[10] https://forum.unity.com/threads/i-get-the-impression-that-ecs-means-i-have-to-write-way-more-code-to-do-the-same-thing.535114/
[11] https://github.com/Unity-Technologies/EntityComponentSystemSamples

Leave a Reply

Your email address will not be published. Required fields are marked *