OOP ECS vs Pure ECS

11

Po pierwsze, wiem, że to pytanie wiąże się z tematem tworzenia gier, ale postanowiłem zadać je tutaj, ponieważ naprawdę sprowadza się ono do bardziej ogólnego problemu związanego z tworzeniem oprogramowania.

W ciągu ostatniego miesiąca dużo czytałem o systemach Entity-Component-Systems i teraz czuję się swobodnie z tą koncepcją. Jednak w jednym aspekcie brakuje wyraźnej „definicji”, a różne artykuły sugerują radykalnie różne rozwiązania:

To jest pytanie, czy ECS powinien przerwać enkapsulację, czy nie. Innymi słowy, jest to ECS w stylu OOP (komponenty są obiektami o stanie i zachowaniu, które zawierają specyficzne dla nich dane) w porównaniu z czystym ECS (komponenty są strukturami typu c, które zawierają tylko dane publiczne, a systemy zapewniają funkcjonalność).

Zauważ, że rozwijam Framework / API / Engine. Dlatego celem jest to, że może być łatwo przedłużony przez każdego, kto go używa. Obejmuje to między innymi dodawanie nowego typu komponentu renderowania lub kolizji.

Problemy z podejściem OOP

  • Komponenty muszą mieć dostęp do danych innych komponentów. Np. Metoda rysowania komponentu renderującego musi mieć dostęp do pozycji komponentu transformacji. To tworzy zależności w kodzie.

  • Składniki mogą być polimorficzne, co dodatkowo wprowadza pewną złożoność. Np. Może istnieć komponent renderowania sprite, który przesłania wirtualną metodę rysowania komponentu renderowania.

Problemy z czystym podejściem

  • Ponieważ zachowanie polimorficzne (np. Do renderowania) musi być gdzieś zaimplementowane, jest ono po prostu zlecane zewnętrznym systemom. (np. system renderowania duszków tworzy węzeł renderowania duszków, który dziedziczy węzeł renderowania i dodaje go do silnika renderowania)

  • Komunikacja między systemami może być trudna do uniknięcia. Np. System kolizji może potrzebować obwiedni, która jest obliczana na podstawie dowolnego betonowego elementu renderującego. Można to rozwiązać, umożliwiając im komunikację za pośrednictwem danych. Usuwa to jednak natychmiastowe aktualizacje, ponieważ system renderujący zaktualizowałby komponent ramki granicznej, a następnie system kolizji użyłby go. Może to prowadzić do problemów, jeśli kolejność wywoływania funkcji aktualizacji systemu nie jest określona. Istnieje system zdarzeń, który pozwala systemom zgłaszać zdarzenia, które inne systemy mogą subskrybować swoim modułom obsługi. Działa to jednak tylko w przypadku informowania systemów, co mają robić, tj. Nieważne funkcje.

  • Potrzebne są dodatkowe flagi. Weźmy na przykład komponent mapy kafelkowej. Będzie miał rozmiar, rozmiar płytki i pole listy indeksów. System map kafelków obsługiwałby odpowiednią tablicę wierzchołków i przypisywał współrzędne tekstury na podstawie danych komponentu. Ponowne obliczenie całej mapy tilemap każdej klatki jest jednak drogie. Dlatego potrzebna byłaby lista do śledzenia wszystkich wprowadzonych zmian, a następnie ich aktualizacji w systemie. W sposób OOP może to być zawarte w elemencie mapy kafelkowej. Np. Metoda SetTile () aktualizowałaby tablicę wierzchołków przy każdym jej wywołaniu.

Chociaż widzę piękno czystego podejścia, tak naprawdę nie rozumiem, jakie konkretne korzyści miałoby to w porównaniu z bardziej tradycyjnym OOP. Zależności między komponentami wciąż istnieją, chociaż są ukryte w systemach. Potrzebowałbym też znacznie więcej klas, aby osiągnąć ten sam cel. Wydaje mi się, że to nieco przesadnie zaprojektowane rozwiązanie, które nigdy nie jest dobre.

Co więcej, nie jestem tak bardzo zainteresowany wydajnością, więc cała idea projektowania zorientowanego na dane i braków kasowych nie ma dla mnie znaczenia. Chcę tylko ładną architekturę ^^

Mimo to większość artykułów i dyskusji, które czytam, sugeruje drugie podejście. DLACZEGO?

Animacja

Na koniec chcę zadać pytanie, jak poradziłbym sobie z animacją w czystym ECS. Obecnie zdefiniowałem animację jako funktor, który manipuluje bytem w oparciu o pewien postęp od 0 do 1. Komponent animacji ma listę animatorów, która ma listę animacji. W swojej funkcji aktualizacji stosuje następnie animacje, które są aktualnie aktywne dla encji.

Uwaga:

Właśnie przeczytałem ten post Czy obiekt architektury Entity Component System jest z definicji zorientowany? co wyjaśnia problem nieco lepiej niż ja. Chociaż zasadniczo dotyczy tego samego tematu, nadal nie daje odpowiedzi na pytanie, dlaczego podejście oparte na czystych danych jest lepsze.

Adrian Koch
źródło
1
Być może proste, ale poważne pytanie: czy znasz zalety / wady ECS? To głównie tłumaczy „dlaczego”.
Caramiriel,
Rozumiem zaletę używania składników, tj. Składu, a nie dziedziczenia, aby uniknąć diamentu śmierci poprzez wiele aspektów dziedziczenia. Korzystanie z komponentów pozwala również na manipulowanie zachowaniem w czasie wykonywania. I są modułowe. Nie rozumiem, dlaczego pożądane jest dzielenie danych i funkcji. Moja obecna implementacja jest na github github.com/AdrianKoch3010/MarsBaseProject
Adrian Koch
Cóż, nie miałem wystarczającego doświadczenia z ECS, aby dodać pełną odpowiedź. Ale kompozycja służy nie tylko unikaniu DoD; możesz także tworzyć (unikalne) byty w czasie wykonywania, które są trudniejsze do wygenerowania przy użyciu metody OO. To powiedziawszy, podział danych / procedur pozwala na łatwiejsze uzasadnienie danych. W prosty sposób możesz zaimplementować serializację, zapisywanie stanu, cofanie / ponawianie i takie rzeczy. Ponieważ łatwo jest uzasadnić dane, łatwiej je również zoptymalizować. Najprawdopodobniej możesz podzielić jednostki na partie (wielowątkowość) lub nawet odciążyć je na inny sprzęt, aby uzyskać pełny potencjał.
Caramiriel,
„Może istnieć komponent renderowania sprite, który przesłania wirtualną metodę rysowania komponentu renderowania”. Twierdziłbym, że nie jest to już ECS, jeśli tego wymagasz / wymagasz.
wondra

Odpowiedzi:

10

To jest trudne. Spróbuję rozwiązać niektóre pytania w oparciu o moje szczególne doświadczenia (YMMV):

Komponenty muszą mieć dostęp do danych innych komponentów. Np. Metoda rysowania komponentu renderującego musi mieć dostęp do pozycji komponentu transformacji. To tworzy zależności w kodzie.

Nie lekceważ tutaj ilości i złożoności (a nie stopnia) sprzężenia / zależności. Możesz patrzeć na różnicę między tym (a ten schemat jest już absurdalnie uproszczony do poziomów podobnych do zabawek, a prawdziwy przykład miałby między nimi interfejsy do poluzowania sprzężenia):

wprowadź opis zdjęcia tutaj

... i to:

wprowadź opis zdjęcia tutaj

... albo to:

wprowadź opis zdjęcia tutaj

Składniki mogą być polimorficzne, co dodatkowo wprowadza pewną złożoność. Np. Może istnieć komponent renderowania sprite, który przesłania wirtualną metodę rysowania komponentu renderowania.

Więc? Analogiczny (lub dosłowny) odpowiednik vtable i wirtualnej wysyłki można wywoływać za pośrednictwem systemu, a nie obiektu ukrywającego jego stan / dane. Polimorfizm jest nadal bardzo praktyczny i wykonalny dzięki „czystej” implementacji ECS, gdy analogiczny wskaźnik (wskaźniki) funkcji lub funkcji zmienia się w „dane”, które system może wywołać.

Ponieważ zachowanie polimorficzne (np. Do renderowania) musi być gdzieś zaimplementowane, jest ono po prostu zlecane zewnętrznym systemom. (np. system renderowania duszków tworzy węzeł renderowania duszków, który dziedziczy węzeł renderowania i dodaje go do silnika renderowania)

Więc? Mam nadzieję, że nie idzie to w parze z sarkazmem (nie moim zamiarem, chociaż często mnie o to oskarżano, ale chciałbym móc lepiej komunikować emocje za pomocą tekstu), ale zachowanie „polimorficzne” outsourcingu w tym przypadku niekoniecznie wiąże się z dodatkowym koszt wydajności.

Komunikacja między systemami może być trudna do uniknięcia. Np. System kolizji może potrzebować obwiedni, która jest obliczana na podstawie dowolnego betonowego elementu renderującego.

Ten przykład wydaje mi się szczególnie dziwny. Nie wiem, dlaczego mechanizm renderujący wyprowadza dane z powrotem na scenę (w tym kontekście uważam, że renderery są tylko do odczytu), lub żeby mechanizm renderujący wymyślił AABB zamiast jakiegoś innego systemu, który mógłby to zrobić zarówno dla mechanizmu renderującego, jak i kolizja / fizyka (może się tu rozłączać nazwa „komponentu renderowania” tutaj). Jednak nie chcę się zbytnio rozłączać na tym przykładzie, ponieważ zdaję sobie sprawę, że nie o to ci chodzi. Mimo to komunikacja między systemami (nawet w pośredniej formie odczytu / zapisu do centralnej bazy danych ECS z systemami zależnymi raczej bezpośrednio od transformacji dokonanych przez innych) nie powinna być częsta, jeśli to w ogóle konieczne. Że'

Może to prowadzić do problemów, jeśli kolejność wywoływania funkcji aktualizacji systemu nie jest określona.

To absolutnie powinno być zdefiniowane. ECS nie jest ostatecznym rozwiązaniem do zmiany kolejności przetwarzania oceny systemu dla każdego możliwego systemu w bazie kodu i uzyskania dokładnie tego samego rodzaju wyników dla użytkownika końcowego zajmującego się ramkami i FPS. Jest to jedna z rzeczy, przy projektowaniu ECS, którą przynajmniej zdecydowanie sugeruję, że należy się spodziewać nieco z góry (choć z dużą ilością wybaczającego oddechu, aby zmienić zdanie później, pod warunkiem, że nie zmienia to najbardziej krytycznych aspektów porządkowania wywołanie / ocena systemu).

Ponowne obliczenie całej mapy tilemap każdej klatki jest jednak drogie. Dlatego potrzebna byłaby lista do śledzenia wszystkich wprowadzonych zmian, a następnie ich aktualizacji w systemie. W sposób OOP może to być zawarte w elemencie mapy kafelkowej. Np. Metoda SetTile () aktualizowałaby tablicę wierzchołków przy każdym jej wywołaniu.

Nie do końca to zrozumiałem, poza tym, że dotyczy to danych. I nie ma żadnych pułapek dotyczących reprezentowania i przechowywania danych w ECS, w tym zapamiętywania, w celu uniknięcia takich pułapek wydajnościowych (największe z ECS mają tendencję do odwoływania się do systemów takich jak zapytania o dostępne wystąpienia poszczególnych typów komponentów, które są jednym z najtrudniejsze aspekty optymalizacji uogólnionego ECS). Fakt, że logika i dane są rozdzielone w „czystym” ECS, nie oznacza, że ​​musisz nagle ponownie obliczyć rzeczy, które w innym przypadku byłyby buforowane / zapamiętane w reprezentacji OOP. To kwestia dyskusyjna / nieistotna, chyba że pochyliłem się nad czymś bardzo ważnym.

Dzięki „czystemu” ECS możesz nadal przechowywać te dane w elemencie mapy kafelków. Jedyna istotna różnica polega na tym, że logika aktualizacji tej tablicy wierzchołków przenosi się gdzieś do systemu.

Możesz nawet polegać na ECS, aby uprościć unieważnianie i usuwanie tej pamięci podręcznej z encji, jeśli utworzysz oddzielny komponent, taki jak TileMapCache. W tym momencie, gdy pamięć podręczna jest pożądana, ale niedostępna w encji ze TileMapskładnikiem, możesz ją obliczyć i dodać. Kiedy jest unieważniony lub nie jest już potrzebny, możesz go usunąć za pomocą ECS bez konieczności pisania dodatkowego kodu specjalnie dla takiego unieważnienia i usunięcia.

Zależności między komponentami wciąż istnieją, chociaż są ukryte w systemach

Nie ma zależności między komponentami w „czystym” powtórzeniu (nie sądzę, że słuszne jest twierdzenie, że zależności są ukrywane przez systemy). Dane nie zależą od danych, że tak powiem. Logika zależy od logiki. A „czysty” ECS ma tendencję do promowania logiki, która ma być napisana w taki sposób, aby zależeć od absolutnie minimalnego podzbioru danych i logiki (często żadnej), której system wymaga do działania, w przeciwieństwie do wielu alternatyw, które często zachęcają w zależności od znacznie więcej funkcjonalności niż jest to wymagane w przypadku rzeczywistego zadania. Jeśli korzystasz z czystego prawa ECS, jedną z pierwszych rzeczy, które powinieneś docenić, są korzyści z oddzielania płatności, jednocześnie kwestionując wszystko, co nauczyłeś się doceniać w OOP na temat enkapsulacji, a zwłaszcza ukrywania informacji.

Odsprzęgając, mam na myśli w szczególności, jak mało informacji potrzebuje twój system. System ruchu nawet nie musi wiedzieć o coś znacznie bardziej skomplikowane jak Particlelub Character(deweloper systemu nie musi nawet wiedzieć takie pomysły podmiot w ogóle istnieją w systemie). Musi tylko wiedzieć o absolutnie minimalnych danych, takich jak element pozycji, który może być tak prosty, jak kilka liczb zmiennoprzecinkowych w strukturze. To jeszcze mniej informacji i mniej zależności zewnętrznych niż to, co zwykły mieć ze sobą czysty interfejs IMotion. Wynika to przede wszystkim z tej minimalnej wiedzy, którą każdy system wymaga do pracy, co sprawia, że ​​ECS często tak wybacza sobie radzenie sobie z bardzo nieoczekiwanymi zmianami projektowymi z perspektywy czasu, bez narażania się na kaskadowe uszkodzenia interfejsu w dowolnym miejscu.

Podejście „nieczyste”, które sugerujesz, nieco zmniejsza tę korzyść, ponieważ teraz twoja logika nie jest zlokalizowana ściśle w systemach, w których zmiany nie powodują kaskadowych awarii. Logika byłaby teraz do pewnego stopnia scentralizowana w komponentach, do których ma dostęp wiele systemów, które muszą teraz spełniać wymagania interfejsów wszystkich różnych systemów, które mogłyby z niej korzystać, a teraz jest tak, jakby każdy system musiał mieć wiedzę (zależną od) więcej informacje, które są ściśle potrzebne do pracy z tym komponentem.

Zależności od danych

Jedną z rzeczy, które budzą kontrowersje w ECS, jest to, że zwykle zastępuje on zależności od abstrakcyjnych interfejsów tylko surowymi danymi i jest to ogólnie uważane za mniej pożądaną i ściślejszą formę łączenia. Ale w takich domenach, jak gry, w których ECS może być bardzo korzystne, często łatwiej jest zaprojektować reprezentację danych z góry i utrzymać ją na stałym poziomie, niż zaprojektować, co można zrobić z tymi danymi na pewnym centralnym poziomie systemu. Jest to coś, co boleśnie zaobserwowałem nawet wśród doświadczonych weteranów w bazach kodowych, które wykorzystują bardziej czyste podejście oparte na interfejsie COM z takimi rzeczami IMotion.

Programiści wciąż szukali powodów, aby dodawać, usuwać lub zmieniać funkcje tego centralnego interfejsu, a każda zmiana była koszmarna i kosztowna, ponieważ miałaby tendencję do psucia każdej pojedynczej klasy, która zaimplementowała się IMotionwraz z każdym odtąd miejscem w używanym systemie IMotion. Tymczasem przez cały czas, z tak wieloma bolesnymi i kaskadowymi zmianami, wszystkie zaimplementowane obiekty IMotionpo prostu przechowywały macierz pływaków 4x4, a cały interfejs był po prostu zainteresowany tym, jak przekształcić i uzyskać dostęp do tych pływaków; reprezentacja danych była stabilna od samego początku i można było uniknąć wielu problemów, gdyby ten scentralizowany interfejs, tak podatny na zmiany przy nieprzewidzianych potrzebach projektowych, nawet nie istniał.

To wszystko może brzmieć niemal tak obrzydliwie jak zmienne globalne, ale natura organizacji ECS tych danych w komponenty pobierane jawnie według typów przez systemy sprawia, że ​​tak jest, podczas gdy kompilatory nie mogą wymuszać ukrywania informacji, miejsc, do których dostęp i mutowanie dane są ogólnie bardzo wyraźne i wystarczająco oczywiste, aby nadal skutecznie utrzymywać niezmienniki i przewidywać, jakie transformacje i skutki uboczne następują z jednego systemu do drugiego (w rzeczywistości w sposób, który może być prawdopodobnie prostszy i bardziej przewidywalny niż OOP w niektórych domenach, biorąc pod uwagę, w jaki sposób system zamienia się w płaski rurociąg).

wprowadź opis zdjęcia tutaj

Na koniec chcę zadać pytanie, jak poradziłbym sobie z animacją w czystym ECS. Obecnie zdefiniowałem animację jako funktor, który manipuluje bytem w oparciu o pewien postęp od 0 do 1. Komponent animacji ma listę animatorów, która ma listę animacji. W swojej funkcji aktualizacji stosuje następnie animacje, które są aktualnie aktywne dla encji.

Wszyscy tu jesteśmy pragmatykami. Nawet w gamedev prawdopodobnie otrzymasz sprzeczne pomysły / odpowiedzi. Nawet najczystszy ECS to stosunkowo nowe zjawisko, pionierskie terytorium, dla którego ludzie niekoniecznie sformułowali najsilniejsze opinie na temat skórowania kotów. Moja reakcja brzucha to system animacji, który zwiększa tego rodzaju postęp animacji w komponentach animowanych do wyświetlenia w systemie renderowania, ale ignoruje to tak wiele niuansów dla konkretnej aplikacji i kontekstu.

Dzięki ECS nie jest to srebrna kula i wciąż mam tendencję do wchodzenia i dodawania nowych systemów, usuwania niektórych, dodawania nowych komponentów, zmiany istniejącego systemu, aby wybrać ten nowy typ komponentu itp. Nie dostaję wszystko w porządku za pierwszym razem. Ale różnica w moim przypadku polega na tym, że nie zmieniam niczego centralnie, kiedy nie przewiduję z góry pewnych potrzeb projektowych. Nie dostaję efektu falowania kaskadowych awarii, które wymagają ode mnie wszędzie i zmieniania tak dużej ilości kodu, aby zaspokoić nowe potrzeby, które się pojawiają, i to jest dość oszczędność czasu. Ułatwia mi to również mózg, ponieważ kiedy siedzę z konkretnym systemem, nie muszę wiedzieć / pamiętać tyle o niczym innym niż odpowiednie komponenty (które są tylko danymi), aby nad nim pracować.

Dragon Energy
źródło