Jak poprawnie wdrożyć obsługę komunikatów w systemie encji opartej na komponentach?

30

Wdrażam wariant systemu encji, który ma:

  • Klasa Entity czyli niewiele więcej niż ID, który wiąże ze sobą składniki

  • Kilka klas komponentów , które nie mają „logiki komponentów”, tylko dane

  • Kilka klas systemowych (zwanych także „podsystemami”, „menedżerami”). Wykonują one wszystkie przetwarzanie logiki encji. W najbardziej podstawowych przypadkach systemy po prostu iterują listę podmiotów, którymi są zainteresowani, i wykonują akcję na każdym z nich

  • Obiekt klasy MessageChannel, który jest współużytkowany przez wszystkie systemy gier. Każdy system może subskrybować określony rodzaj wiadomości, których ma słuchać, a także może używać kanału do nadawania wiadomości do innych systemów

Początkowy wariant obsługi komunikatów systemowych wyglądał mniej więcej tak:

  1. Uruchom aktualizację dla każdego systemu gry sekwencyjnie
  2. Jeśli system robi coś ze składnikiem, a działanie to może zainteresować inne systemy, system wysyła odpowiedni komunikat (na przykład, wywołanie systemowe

    messageChannel.Broadcast(new EntityMovedMessage(entity, oldPosition, newPosition))

    za każdym razem, gdy jednostka jest przenoszona)

  3. Każdy system, który subskrybuje określoną wiadomość, otrzymuje swoją metodę obsługi wiadomości

  4. Jeśli system obsługuje zdarzenie, a logika przetwarzania zdarzenia wymaga wysłania innej wiadomości, wiadomość jest natychmiast wysyłana i wywoływany jest kolejny łańcuch metod przetwarzania wiadomości

Ten wariant był w porządku, dopóki nie zacząłem optymalizować systemu wykrywania kolizji (robiło się naprawdę powoli wraz ze wzrostem liczby jednostek). Na początku po prostu iterowałby każdą parę bytów za pomocą prostego algorytmu brutalnej siły. Następnie dodałem „indeks przestrzenny”, który ma siatkę komórek, która przechowuje jednostki znajdujące się w obszarze określonej komórki, umożliwiając w ten sposób sprawdzanie tylko jednostek w sąsiednich komórkach.

Za każdym razem, gdy jednostka się porusza, system kolizji sprawdza, czy jednostka koliduje z czymś na nowej pozycji. Jeśli tak, kolizja zostanie wykryta. A jeśli obie zderzające się jednostki są „obiektami fizycznymi” (oba mają komponent RigidBody i mają się odepchnąć, aby nie zajmować tej samej przestrzeni), dedykowany sztywny system separacji ciała prosi system ruchu o przeniesienie jednostek do niektórych konkretne pozycje, które je rozdzieliłyby. To z kolei powoduje, że system ruchu wysyła wiadomości powiadamiające o zmianie pozycji bytu. System wykrywania kolizji ma reagować, ponieważ musi zaktualizować swój indeks przestrzenny.

W niektórych przypadkach powoduje to problem, ponieważ zawartość komórki (ogólna lista obiektów encji w języku C #) jest modyfikowana podczas iteracji, co powoduje, że iterator zgłasza wyjątek.

Więc ... jak mogę zapobiec przerwaniu systemu kolizji podczas sprawdzania kolizji?

Oczywiście mógłbym dodać trochę „sprytnej” / „podstępnej” logiki, która zapewnia prawidłowe iterowanie zawartości komórki, ale myślę, że problem nie leży w samym systemie kolizji (miałem również podobne problemy w innych systemach), ale sposób wiadomości są obsługiwane podczas podróży z systemu do systemu. Potrzebuję jakiegoś sposobu, aby zapewnić, że określona metoda obsługi zdarzeń wykona swoje zadanie bez żadnych zakłóceń.

Co próbowałem:

  • Przychodzące kolejki wiadomości . Za każdym razem, gdy jakiś system rozgłasza komunikat, jest on dodawany do kolejek systemów, które są nim zainteresowane. Te komunikaty są przetwarzane, gdy aktualizacja systemu jest wywoływana dla każdej ramki. Problem : jeśli system A dodaje komunikat do kolejki systemu B, działa dobrze, jeśli system B ma zostać zaktualizowany później niż system A (w tej samej ramce gry); w przeciwnym razie wiadomość przetworzy następną ramkę gry (nie jest pożądane w niektórych systemach)
  • Wychodzące kolejki wiadomości . Podczas gdy system obsługuje zdarzenie, wszystkie wysyłane przez niego wiadomości są dodawane do kolejki wiadomości wychodzących. Wiadomości nie muszą czekać na przetworzenie aktualizacji systemu: są obsługiwane „od razu” po zakończeniu początkowej procedury obsługi komunikatów. Jeśli obsługa wiadomości powoduje emisję innych wiadomości, one również są dodawane do kolejki wychodzącej, więc wszystkie wiadomości są obsługiwane w tej samej ramce. Problem: jeśli system istnienia encji (wdrożyłem zarządzanie cyklem życia encji za pomocą systemu) tworzy encję, powiadamia o tym niektóre systemy A i B. Podczas gdy system A przetwarza komunikat, powoduje to łańcuch komunikatów, które ostatecznie powodują zniszczenie tworzonego bytu (na przykład istota pocisku została utworzona dokładnie tam, gdzie zderza się z jakąś przeszkodą, która powoduje samozniszczenie pocisku). Podczas rozwiązywania łańcucha komunikatów system B nie otrzymuje komunikatu o utworzeniu encji. Tak więc, jeśli system B jest również zainteresowany komunikatem o zniszczeniu bytu, otrzymuje go i dopiero po zakończeniu rozwiązywania „łańcucha” otrzymuje komunikat o początkowym utworzeniu bytu. To powoduje, że komunikat zniszczenia zostaje zignorowany, komunikat o stworzeniu zostaje „zaakceptowany”,

EDYCJA - ODPOWIEDZI NA PYTANIA, KOMENTARZE:

  • Kto modyfikuje zawartość komórki, podczas gdy system kolizji iteruje nad nimi?

Podczas gdy system kolizji sprawdza kolizje niektórych jednostek i ich sąsiadów, kolizja może zostać wykryta, a system jednostek wyśle ​​komunikat, na który zareagują natychmiast inne systemy. Reakcja na wiadomość może spowodować, że inne wiadomości zostaną utworzone i obsługiwane od razu. Tak więc inny system może utworzyć komunikat, który system kolizji musiałby natychmiast przetworzyć (na przykład jednostka została przeniesiona, więc system kolizji musi zaktualizować swój indeks przestrzenny), nawet jeśli wcześniejsze kontrole kolizji jeszcze się nie zakończyły.

  • Nie możesz pracować z globalną kolejką wiadomości wychodzących?

Ostatnio próbowałem jednej globalnej kolejki. To powoduje nowe problemy. Problem: Przenoszę element czołgu do elementu ściany (zbiornik jest kontrolowany za pomocą klawiatury). Potem postanawiam zmienić kierunek czołgu. Aby oddzielić zbiornik i ścianę od każdej ramki, CollidingRigidBodySeparationSystem odsunął zbiornik od ściany w możliwie najmniejszej ilości. Kierunek separacji powinien być przeciwny do kierunku ruchu czołgu (gdy rozpoczyna się losowanie gry, czołg powinien wyglądać tak, jakby nigdy nie poruszał się w ścianie). Ale kierunek staje się przeciwny do kierunku NOWEGO, co powoduje przesunięcie czołgu na inną stronę ściany niż początkowo. Dlaczego występuje problem: Oto jak teraz obsługiwane są wiadomości (kod uproszczony):

public void Update(int deltaTime)
{   
    m_messageQueue.Enqueue(new TimePassedMessage(deltaTime));
    while (m_messageQueue.Count > 0)
    {
        Message message = m_messageQueue.Dequeue();
        this.Broadcast(message);
    }
}

private void Broadcast(Message message)
{       
    if (m_messageListenersByMessageType.ContainsKey(message.GetType()))
    {
        // NOTE: all IMessageListener objects here are systems.
        List<IMessageListener> messageListeners = m_messageListenersByMessageType[message.GetType()];
        foreach (IMessageListener listener in messageListeners)
        {
            listener.ReceiveMessage(message);
        }
    }
}

Kod płynie w ten sposób (załóżmy, że nie jest to pierwsza ramka gry):

  1. Systemy zaczynają przetwarzać TimePassedMessage
  2. InputHandingSystem konwertuje naciśnięcia klawiszy na akcję encji (w tym przypadku lewa strzałka zmienia się w akcję MoveWest). Działanie encji jest przechowywane w komponencie ActionExecutor
  3. ActionExecutionSystem w reakcji na akcję encji dodaje MovementDirectionChangeRequestedMessage na końcu kolejki komunikatów
  4. MovementSystem przenosi pozycję encji na podstawie danych komponentu Velocity i dodaje komunikat PositionChangedMessage na końcu kolejki. Ruch odbywa się przy użyciu kierunku / prędkości ruchu poprzedniej klatki (powiedzmy północ)
  5. Systemy przestają przetwarzać TimePassedMessage
  6. Systemy zaczynają przetwarzać MovementDirectionChangeRequestedMessage
  7. MovementSystem zmienia prędkość / kierunek ruchu jednostki zgodnie z żądaniem
  8. Systemy przestają przetwarzać MovementDirectionChangeRequestedMessage
  9. Systemy zaczynają przetwarzać PositionChangedMessage
  10. CollisionDetectionSystem wykrywa, że ​​ponieważ obiekt się poruszył, wpadł na inny byt (zbiornik wszedł w ścianę). Dodaje do kolejki CollisionOccuredMessage
  11. Systemy przestają przetwarzać PositionChangedMessage
  12. Systemy zaczynają przetwarzać CollisionOccuredMessage
  13. CollidingRigidBodySeparationSystem reaguje na kolizję, oddzielając zbiornik i ścianę. Ponieważ ściana jest statyczna, poruszany jest tylko zbiornik. Kierunek ruchu czołgów jest wykorzystywany jako wskaźnik pochodzenia zbiornika. Jest przesunięty w przeciwnym kierunku

BŁĄD: Kiedy czołg poruszał się tą ramą, poruszał się zgodnie z kierunkiem ruchu z poprzedniej ramki, ale kiedy był oddzielany, stosowano kierunek ruchu z TEJ ramki, chociaż była już inna. To nie tak powinno działać!

Aby zapobiec temu błędowi, trzeba gdzieś zapisać stary kierunek ruchu. Mógłbym dodać go do jakiegoś komponentu, aby naprawić ten konkretny błąd, ale czy ten przypadek nie wskazuje na jakiś zasadniczo zły sposób obsługi wiadomości? Dlaczego system separacji powinien dbać o to, jakiego kierunku ruchu używa? Jak mogę elegancko rozwiązać ten problem?

  • Możesz przeczytać gamadu.com/artemis, aby zobaczyć, co zrobili z Aspectami, po której stronie znajdują się niektóre z problemów, które widzisz.

Właściwie od dłuższego czasu znam Artemis. Zbadałem jego kod źródłowy, czytałem fora itp. Ale widziałem, że „Aspekty” wymieniane są tylko w kilku miejscach i, o ile rozumiem, w zasadzie oznaczają „Systemy”. Ale nie widzę, jak Artemida rozwiązuje niektóre z moich problemów. Nawet nie używa wiadomości.

  • Zobacz także: „Komunikacja jednostek: kolejka komunikatów vs publikowanie / subskrybowanie vs sygnał / sloty”

Przeczytałem już wszystkie pytania gamedev.stackexchange dotyczące systemów jednostek. Ten wydaje się nie omawiać problemów, przed którymi stoję. Czy coś brakuje?

  • Obie sprawy traktuj inaczej, aktualizacja siatki nie musi polegać na komunikatach ruchowych, ponieważ jest to część systemu kolizji

Nie jestem pewny co masz na myśli. Starsze implementacje CollisionDetectionSystem po prostu sprawdzały kolizje podczas aktualizacji (gdy obsługiwano TimePassedMessage), ale musiałem zminimalizować kontrole tak bardzo, jak mogłem ze względu na wydajność. Więc przełączyłem się na sprawdzanie kolizji, gdy jednostka się porusza (większość jednostek w mojej grze jest statyczna).

Onlainas
źródło
Jest coś, co nie jest dla mnie jasne. Kto modyfikuje zawartość komórki, podczas gdy system kolizji iteruje nad nimi?
Paul Manta
Nie możesz pracować z globalną kolejką wiadomości wychodzących? Tak więc wszystkie tam wysyłane wiadomości są wysyłane za każdym razem po zakończeniu pracy systemu, w tym samozniszczenie systemu.
Roy T.
Jeśli chcesz zachować ten skomplikowany projekt, musisz postępować zgodnie z @RoyT. Rada to jedyny sposób (bez skomplikowanego przesyłania wiadomości w oparciu o czas), aby poradzić sobie z problemem związanym z sekwencjonowaniem. Możesz przeczytać gamadu.com/artemis, aby zobaczyć, co zrobili z Aspectami , po której stronie znajdują się niektóre z problemów, które widzisz.
Patrick Hughes,
2
Możesz dowiedzieć się, jak Axum to zrobił, pobierając CTP i kompilując trochę kodu - a następnie odwróć inżynierię wyniku do C # za pomocą ILSpy. Przekazywanie wiadomości jest ważną cechą języków modeli aktorskich i jestem pewien, że Microsoft wie, co robią - więc może się okazać, że mają najlepszą implementację.
Jonathan Dickinson

Odpowiedzi:

12

Prawdopodobnie słyszałeś o anty-wzorze obiektu God / Blob. Cóż, twoim problemem jest pętla Bóg / Kropelka. Majstrowanie przy systemie przekazywania wiadomości w najlepszym wypadku zapewni rozwiązanie Band-Aid, aw najgorszym stanie się całkowitą stratą czasu. W rzeczywistości twój problem w ogóle nie ma nic wspólnego z tworzeniem gier. Przyłapałem się na próbie modyfikacji kolekcji podczas iteracji kilka razy, a rozwiązanie jest zawsze takie samo: dzielenie, dzielenie, dzielenie.

Jak rozumiem sformułowanie twojego pytania, twoja metoda aktualizacji systemu kolizji wygląda obecnie zasadniczo następująco.

for each possible collision
    check for collision
    handle collision
    modify collision world to reflect change // exception happens here

Pisząc tak po prostu, możesz zobaczyć, że twoja pętla ma trzy obowiązki, kiedy powinna mieć tylko jeden. Aby rozwiązać problem, podziel bieżącą pętlę na trzy osobne pętle reprezentujące trzy różne przebiegi algorytmiczne .

for each possible collision
    check for collision, record it if a collision occurs

for each found collision
    handle collision, record the collision response (delete object, ignore, etc.)

for each collision response
    modify collision world according to response

Dzieląc oryginalną pętlę na trzy subloopy, nie będziesz już nigdy próbował modyfikować kolekcji, nad którą aktualnie iterujesz. Zauważ również, że nie wykonujesz więcej pracy niż w oryginalnej pętli, i w rzeczywistości możesz zyskać trochę wygranych w pamięci podręcznej, wykonując te same operacje wiele razy po kolei.

Istnieje również dodatkowa korzyść, polegająca na tym, że możesz teraz wprowadzić równoległość do swojego kodu. Twoje podejście do pętli kombinowanej jest z natury szeregowe (co jest zasadniczo tym, co mówi ci wyjątek dotyczący równoczesnej modyfikacji!), Ponieważ każda iteracja pętli potencjalnie zarówno odczytuje, jak i zapisuje w twoim świecie kolizji. Trzy subloopy, które przedstawiam powyżej, wszystkie albo czytają, albo piszą, ale nie oba. Przynajmniej pierwsze przejście, sprawdzanie wszystkich możliwych kolizji, stało się krępująco równoległe, a w zależności od tego, jak piszesz kod, drugie i trzecie przejście może być również.

Kaczor
źródło
Całkowicie się z tym zgadzam. Używam tego bardzo podobnego podejścia w mojej grze i wierzę, że to się opłaci na dłuższą metę. Tak powinien działać system kolizji (lub menedżer) (tak naprawdę uważam, że w ogóle nie można mieć systemu przesyłania wiadomości).
Emiliano
11

Jak poprawnie wdrożyć obsługę komunikatów w systemie encji opartej na komponentach?

Powiedziałbym, że chcesz dwa rodzaje wiadomości: synchroniczny i asynchoniczny. Wiadomości synchroniczne są obsługiwane natychmiast, podczas gdy asynchroniczne nie są obsługiwane w tej samej ramce stosu (ale mogą być obsługiwane w tej samej ramce gry). Decyzja, która jest zwykle podejmowana na podstawie „na klasę wiadomości”, np. „All EnemyDied wiadomości są asynchroniczne”.

Niektóre zdarzenia są obsługiwane o wiele łatwiej dzięki jednemu z tych sposobów. Na przykład z mojego doświadczenia wynika, że ​​obiekt ObjectGetsDeletedNow - jest znacznie mniej seksowny, a wywołania zwrotne są znacznie trudniejsze do wdrożenia niż ObjectWillBeDeletedAtEndOfFrame. Z drugiej strony, jakikolwiek program obsługi wiadomości podobny do „weto” (kod, który może anulować lub zmodyfikować niektóre akcje podczas ich wykonywania, podobnie jak efekt Osłony modyfikuje DamageEvent ) nie będzie łatwy w środowiskach asynchronicznych, ale bułka z masłem w połączenia synchroniczne.

W niektórych przypadkach asynchroniczny może być bardziej wydajny (np. Możesz pominąć niektóre procedury obsługi zdarzeń, gdy obiekt zostanie usunięty później). Czasami synchroniczny jest bardziej wydajny, szczególnie przy obliczaniu parametru dla zdarzenia jest kosztowne i wolisz przekazać funkcje zwrotne, aby pobrać określone parametry zamiast już obliczonych wartości (na wypadek, gdyby nikt nie był zainteresowany tym konkretnym parametrem).

Wspomniałeś już o innym ogólnym problemie z synchronicznymi systemami komunikatów: Według mojego doświadczenia z synchronicznymi systemami komunikatów, jednym z najczęstszych błędów i smutków jest zmiana list podczas iteracji po tych listach.

Pomyśl o tym: ma on charakter synchroniczny (natychmiast obsługuje wszystkie następstwa niektórych działań) i system komunikatów (oddzielenie odbiorcy od nadawcy, aby nadawca nie wiedział, kto reaguje na działania), że nie będziesz w stanie łatwo zauważ takie pętle. Mówię: bądź przygotowany na to, by często radzić sobie z tego rodzaju samodmodyfikującą iteracją. Jego rodzaj „z założenia”. ;-)

jak mogę zapobiec przerwaniu systemu kolizji podczas sprawdzania kolizji?

W przypadku konkretnego problemu z wykrywaniem kolizji może być wystarczające, aby zdarzenia kolizji były asynchroniczne, więc są one ustawiane w kolejce do momentu zakończenia menedżera kolizji i wykonywane jako jedna partia później (lub w późniejszym momencie w ramce). To jest twoje rozwiązanie „kolejka przychodząca”.

Problem: jeśli system A dodaje komunikat do kolejki systemu B, działa dobrze, jeśli system B ma zostać zaktualizowany później niż system A (w tej samej ramce gry); w przeciwnym razie wiadomość przetworzy następną ramkę gry (nie jest pożądane w niektórych systemach)

Łatwy:

while (! queue.empty ()) {queue.pop (). handle (); }

Po prostu uruchom kolejkę w kółko, aż nie pozostanie żadna wiadomość. (Jeśli wykrzykujesz teraz „niekończącą się pętlę”, pamiętaj, że najprawdopodobniej miałbyś ten problem jako „spamowanie wiadomości”, gdyby był opóźniony do następnej ramki. Możesz zapewnić () rozsądną liczbę iteracji w celu wykrycia niekończących się pętli, jeśli masz na to ochotę;))

Imi
źródło
Zauważ, że nie mówiłem dokładnie o tym, kiedy obsługiwane są wiadomości asynchroniczne. Moim zdaniem jest całkowicie w porządku, aby moduł detekcji kolizji mógł wyczyścić wiadomości po zakończeniu. Można to również traktować jako „wiadomości synchroniczne opóźnione do końca pętli” lub jakiś sprytny sposób „po prostu zaimplementuj iterację w taki sposób, aby można ją było modyfikować podczas iteracji”
Imi
5

Jeśli faktycznie próbujesz wykorzystać ECS zorientowany na dane, możesz pomyśleć o tym, jak to zrobić w najbardziej DOD.

Spójrz na blog BitSquid , w szczególności część o wydarzeniach. Przedstawiono system, który dobrze łączy się z ECS. Buforuj wszystkie zdarzenia w ładną, czystą kolejkę według komunikatu, w taki sam sposób, jak systemy w ECS są przypisane do poszczególnych komponentów. Systemy zaktualizowane później mogą skutecznie iterować w kolejce dla określonego typu komunikatu, aby je przetworzyć. Lub po prostu je zignoruj. Którykolwiek.

Na przykład CollisionSystem wygenerowałby bufor pełen zdarzeń kolizji. Każdy inny system działający po kolizji może następnie iterować listę i przetwarzać je w razie potrzeby.

Utrzymuje zorientowany na dane równoległy charakter projektu ECS bez całej złożoności rejestracji wiadomości itp. Tylko systemy, które faktycznie dbają o dany typ zdarzenia, iterują w kolejce dla tego typu, a wykonywanie prostej iteracji w jednym przejściu przez kolejkę komunikatów jest tak wydajne, jak to tylko możliwe.

Jeśli zachowasz konsekwentnie uporządkowane komponenty w każdym systemie (np. Zamów wszystkie komponenty według identyfikatora encji lub czegoś podobnego), zyskasz nawet miłą korzyść, że wiadomości będą generowane w najbardziej efektywnej kolejności do iteracji nad nimi i wyszukiwania odpowiednich komponentów w system przetwarzania. Oznacza to, że jeśli masz jednostki 1, 2 i 3, wiadomości są generowane w tej kolejności, a wyszukiwania komponentów wykonywane podczas przetwarzania wiadomości będą w ściśle rosnącej kolejności adresów (która jest najszybsza).

Sean Middleditch
źródło
1
+1, ale nie mogę uwierzyć, że to podejście nie ma wad. Czy to nie zmusza nas do sztywnego kodowania współzależności między systemami? A może te współzależności mają być zakodowane w taki czy inny sposób?
Patryk Czachurski
2
@ Daedalus: jeśli logika gry wymaga aktualizacji fizyki, aby wykonać prawidłową logikę, to dlaczego nie będziesz mieć tej zależności? Nawet w przypadku modelu pubsub musisz jawnie zasubskrybować taki i taki typ wiadomości, który jest generowany tylko przez inny system. Unikanie zależności jest trudne i polega głównie na znalezieniu odpowiednich warstw. Na przykład grafika i fizyka są niezależne, ale pojawi się warstwa kleju na wyższym poziomie, która zapewni interpolowane aktualizacje symulacji fizyki odzwierciedlone w grafice itp.
Sean Middleditch
To powinna być zaakceptowana odpowiedź. Prostym sposobem na zrobienie tego jest po prostu utworzenie nowego typu komponentu, na przykład CollisionResolvable, który zostanie przetworzony przez każdy system zainteresowany działaniem po kolizji. Co dobrze pasowałoby do propozycji Drake'a, jednak istnieje system dla każdej pętli podziału.
user8363