Struktura danych bez ECS w silniku gry

0

Obmyślam strukturę zarządzania danymi w silniku gry, pisanym w C++. Z jednej strony chciałbym, aby całość była dosyć elastyczna i nadawała się do różnych projektów (nie chcę silnika zaprojektowanego z myślą o jednej grze, czy jednym typie gier), ale też nie porywam się z motyką na słońce i chcę go używać do małych, hobbystycznych gierek, więc wolę trzymać się zwykłej obiektówki, bez systemów w stylu ECS.

Tyle tytułem wstępu. Obecnie zamysł na całość mam taki. Jest główna klasa abstrakcyjna ObjectBase:

class ObjectBase
{
protected:
	virtual void Update() = 0;
 	virtual void Render() = 0;
};

Każdy taki obiekt może istnieć samodzielnie, jako Object, stanowiący wrapper na ObjectBase:

template<typename T> requires std::is_base_of_v<ObjectBase, T>
class Object
{
private:
	T* Data = nullptr;
	ObjectBase** Link = nullptr;
public:
	T* operator->();
	void Allocate();
	void Destroy();
};

Albo też jako część grupy obiektów ObjectList:

template<typename T> requires std::is_base_of_v<ObjectBase, T>
class ObjectList
{
private:
	T* List = nullptr;
	ObjectBase** Link = nullptr;
public:
	T& operator[](const uint16_t& uiIndex);
	void Allocate(const uint16_t& uiIndex);
	void Destroy(const uint16_t& uiIndex);
	void AllocateAll();
	void DestroyAll();
};

Z tych komponentów można potem zbudować szkielet poziomu gry, np.:

class LevelBase
{
private:
	ObjectBase** List;
public:
	void AllocateAll();
	void DestroyAll();
};

class Level : public LevelBase
{
public:
	Object<Player> Player;
	struct NPC
	{
		ObjectList<Merchant> Merchant;
	} NPC;
	struct Enemies
	{
		ObjectList<Orc> Orc;
		ObjectList<Skeleton> Skeleton;
	} Enemies;
	struct Weapons
	{
		ObjectList<Sword> Sword;
		ObjectList<Dagger> Dagger;
	} Weapons;
};

Dzięki temu obiekty są dosyć wygodnie posortowane i można uzyskać łatwy dostęp do rzeczywistych typów bez konieczności rzutowania, np.:

Level level[4];
level[0].Enemies.Skeleton[24].Damage = 10;

Dodatkowo wszystkie obiekty są spięte w jedną listę wskaźników ObjectBase** List, do której łatwo może podpiąć się globalny dla silnika ObjectBase** World, z którego dane o obiektach świata gry czerpią wątki odpowiedzialne za odświeżanie logiki i renderowanie. Z tego względu oba wrappery, tj. Object i ObjectList mają wskaźnik ObjectBase** Link, który muszą pozyskać z klasy Level. Dzięki temu wywołując metodę Allocate() i jej pochodne, nie tylko następuje alokacja pamięci dla obiektu, ale zostaje też on podpięty pod ObjectBase** List i tym samym staje się częścią świata (inaczej silnik go nie "zobaczy").

Minusy są takie, że każdy Level wymaga wielgaśniego konstruktora, bo każdy wrapper musi jakoś uzyskać dostęp do ObjectBase** List, żeby móc podpinać do niej nowe obiekty. Po drugie jest to dosyć nieelastyczne, bo jak chcę dodać nowy element do poziomu albo nową grupę elementów, to muszę przerabiać klasę i konstruktor (zwłaszcza gdybym chciał dla każdego poziomu mieć indywidualną strukturę).

Mógłbym mieć po prostu jedną, wielką listę obiektów, a potem uzyskiwać do nich dostęp przez rzutowanie, np.:

class World
{
private:
	ObjectBase** List = nullptr;
public:
	template<typename T>
	T& Get(const uint16_t uiIndex)
	{
		return *static_cast<T*>(List[uiIndex]);
	}
};

World world;
world.Get<Orc>(12).Health = 76;

Tylko wtedy musiałbym wiedzieć, że np. obiekt o indeksie 1276 jest np. dokładnie tym mieczem, którego akurat szukam, co byłoby raczej mało praktyczne. Z tego względu najbardziej by mi odpowiadało, gdyby dało się to zrobić dynamiczne i modularnie. Na przykład tworzę sobie obiekt od szablonu:

World<Orc, Sword, Dragon> world;

A potem mogę się odwoływać do poszczególnych typów obiektów, gdzie każdy typ ma własną listę, z własnymi indeksami:

world.Get<Orc>(0).Health = 11; //Pierwszy Orc z listy Orców
world.Get<Dragon>(0).AoEDamage = 76.21f; //Pierwszy smok z listy smoków.

To też nie idealne rozwiązanie, ale już w miarę sensowne do ogarnięcia. No ale to wymagałoby (?) specjalizacji szablonów, a tego chyba nie da się (?) zrobić modularnie, przez dziedziczenie.

Ktoś ma jakieś pomysły albo sugestie jak taki system zbudować, żeby był wygodny i w miarę elastyczny?

0

więc wolę trzymać się zwykłej obiektówki, bez systemów w stylu ECS.

Wszystkie gierki które dotychczas tworzyłem były oparte o ECS (i żadnej z tych gierek w życiu nie przepisałbym na obiektówkę), więc jeśli szukasz czegoś wygodnego, elastycznego i sound, to jest to imo jedyna sensowna opcja.

Choć oczywiście nie wynajdywałbym koła na nowo - w samym Ruście jest z dwadzieścia różnych bibliotek implementujących ten pattern, więc domyślam się, że dla C++ powinny ich być setki.

0

Oczywiście masz rację, że tak to się robi obecnie, natomiast mnie zastanawia, jak sobie radzono z tym kiedyś, gdy do dyspozycji była tylko obektówka i to bez bajerów z C++11 i w górę. To co wymyśliłem jest jakkolwiek zbliżone do dawniej stosowanych rozwiązań, czy zupełnie pobłądziłem? :).

0

Z tego co napisałeś, mam wrażenie, że chcesz świat podłączyć pod zewnętrzną listę/y obiektów w nim się znajdujących, zamiast stworzyć obiekt świata, wyposażonego w listę/y obiektów, z których się składa. Czemu tak?

0

ale ty chyba i tak robisz coś w stylu systemow z ECS, gdzie masz osobną tablicę na każdy typ jednostki?

	struct Enemies
	{
		ObjectList<Orc> Orc;
		ObjectList<Skeleton> Skeleton;
	} Enemies;
	struct Weapons
	{
		ObjectList<Sword> Sword;
		ObjectList<Dagger> Dagger;
	} Weapons;
private:
	T* Data = nullptr;
	ObjectBase** Link = nullptr;

No i czym jest ObjectBase?
Czy tu emulujesz dziedziczenie za pomocą wskaźników? Coś na zasadzie "tu mamy obiekt, a to jest wskaźnik do mojego przodka"? (jak w dziedziczeniu prototypowym). Czy coś źle zrozumiałem?

level[0].Enemies.Skeleton[24].Damage = 10;

Dobra, ale w realnym kodzie chyba nie będzie tego na sztywno i tak ustawione. Dlaczego w ogóle istniałaby potrzeba ustawić damage = 10 dla skeletonu o indeksie 24?
albo tu:

world.Get<Orc>(12).Health = 76;

tak jakby strasznie na sztywno to ustalane. A co za różnica dla gry, czy zaatakował gracza Ork czy Smok? Ktoś zaatakował, ale to jakoś powinno być uogólnione przecież.

0
furious programming napisał(a):

Z tego co napisałeś, mam wrażenie, że chcesz świat podłączyć pod zewnętrzną listę/y obiektów w nim się znajdujących, zamiast stworzyć obiekt świata, wyposażonego w listę/y obiektów, z których się składa. Czemu tak?

Bo u mnie level jest całym światem w danym momencie i dzięki temu mogę zrobić coś takiego:

World.Level[0].Destroy(); //Niszczy wszystkie obiekty starego poziomu i zwalnia pamięć.
world.Level[1].Load(); //Alokuje pamięć i tworzy wszystkie obiekty nowego poziomu.
World.GetLevel(1); //Zczytuje listę obiektów (po prostu swapując wskaźnik) przez co silnik może na nich pracować.
//Przy czym World widzi wszystkie obiekty jako ObjectBase**, więc ma dostęp tylko to metod klasy bazowej.
LukeJL napisał(a):

No i czym jest ObjectBase?

Wkleiłem kod w pierwszym poście. To "pusta" klasa, zawierająca metody abstrakcyjne związane z obróbką danego obiektu przez wątek odświeżający logikę i wątek renderujący, które odpowiednio wywołują sobie Update() i Render(), bez wchodzenia w szczegóły obiektu.

Czy tu emulujesz dziedziczenie za pomocą wskaźników? Coś na zasadzie "tu mamy obiekt, a to jest wskaźnik do mojego przodka"? (jak w dziedziczeniu prototypowym). Czy coś źle zrozumiałem?

Chyba nie...? Chociaż nie jestem pewien co masz na myśli, pisząc o "emulowaniu dziedziczenia".

level[0].Enemies.Skeleton[24].Damage = 10;

Dobra, ale w realnym kodzie chyba nie będzie tego na sztywno i tak ustawione. Dlaczego w ogóle istniałaby potrzeba ustawić damage = 10 dla skeletonu o indeksie 24?
albo tu:

world.Get<Orc>(12).Health = 76;

tak jakby strasznie na sztywno to ustalane. A co za różnica dla gry, czy zaatakował gracza Ork czy Smok? Ktoś zaatakował, ale to jakoś powinno być uogólnione przecież.

To tylko przykład, generalnie chodzi mi o to, żeby mieć w miarę łatwy dostęp do poszczególnych typów obiektów, bez konieczności rzutowania. Wiadomo, że w praktyce nie będę ręcznie ustawiał danych np. dla jednego wroga czy NPC, ale już dla całej grupy? Czemu nie. Mógłbym np. mieć zaklęcie, które zabija wszystkie szkielety na planszy i wtedy mógłbym zrobić tak:

for (uint32_t I = 0; I < Enemies.Skeleton.Count; I++)
{
	Enemies.Skeleton[I].Health = 0;
}

Alternatywą byłoby:

  1. Oblecieć w pętli wszystkie obiekty przetrzymywane przez kontener World, a więc dosłownie wszystkie elementy poziomu, w tym też np. ściany, skały, chmury, drzewa, przedmioty itd.

  2. Sprawdzić który z obiektów jest szkieletem (np. poprzez wewnętrzny identyfikator nadany każdemu obiektowi albo dynamic_cast).

  3. Rzutować każdy wykryty w powyższy sposób obiekt na Skeleton, a następnie ustawić mu Health na 0.

    Wolę pierwszy sposób :).

0

@Crow: u mnie będzie to wyglądało tak, że świat jest w 3D (jedna wielka mapa), mapa będzie podzielona na sektory (małe sześciany), a każdy z nich będzie zawierał listę obiektów, które się w danym sześcianie znajdują. Obiekty, z których będzie zbudowany teren (wszystko co statyczne), będą umieszczone tylko w tych listach, natomiast obiekty wymagające spawnowania i ogólnie szybkiego dostępu (w tym gracze, potworki, NPC-e itp), będą przechowywane w dodatkowych listach, natomiast kopie ich referencji będą trzymane również w listach sektorów (obiekty będą refcountowane).

Głównie chodzi o to, że raytracer musi śmigać po sektorach mapy i tak szybko jak się da, testować kolizje z obiektami — dlatego każdy sześcian musi posiadać referencje wszystkich obiektów, które się w nim znajdują. Natomiast dodatkowe listy są potrzebne do tego, aby mieć błyskawiczny dostęp do obiektów, które w każdej klatce wymagają spawnu, despawnu i aktualizacji. Szukanie tych obiektów w gigantycznej mapie trwałoby milion lat, w małej liście mam do nich dostęp praktycznie od razu. Takich dodatkowych list będzie wiele, w tym na pewno jedna dla bohaterów (graczy) i jedna dla wszystkich zespawnowanych potworków. Przy czym raczej skuszę się na cache'owanie większej liczby typów obiektów, ale to wyjdzie w praniu.

0
furious programming napisał(a):

mapa będzie podzielona na sektory (małe sześciany), a każdy z nich będzie zawierał listę obiektów, które się w danym sześcianie znajdują.

No, czyli coś jak moje levele.

Obiekty, z których będzie zbudowany teren (wszystko co statyczne), będą umieszczone tylko w tych listach, natomiast obiekty wymagające spawnowania i ogólnie szybkiego dostępu (w tym gracze, potworki, NPC-e itp), będą przechowywane w dodatkowych listach, natomiast kopie ich referencji będą trzymane również w listach sektorów (obiekty będą refcountowane).

Ale czym są te listy? To coś w stylu kontenerów polimorficznych?

struct Object
{

}

struct Sword : public Object
{

}

struct Gun : public Object
{

}

Object* List[20];
List[0] = new Sword;
List[1] = new Gun;
//itd.

Czyli że lista składa się ze wskaźników na klasę bazową, w które ty upychasz potem różne polimorficzne klasy potomne. Czy może osobne listy konkretnych obiektów, z odgórnie znanym typem:

Sword* sword[10];
Gun* gun[10];

sword[0] = new Sword;
gun[0] = new Gun;
//itd.

???

0
Crow napisał(a):

Ale czym są te listy? To coś w stylu kontenerów polimorficznych?

Raczej w stylu kontenerów generycznych. Różnica polega na tym, że ani nie używam wbudowanych w język generyków, ani OOP — dla mnie wszystko jest buforami bajtów, a generyczność uzyskuję zwykłymi, nietypowanymi wskaźnikami. Lista obiektów, to po prostu spójny ciąg pointerów na cokolwiek, na co może wskazywać wskaźnik (głównie na ”obiekty”, czyli instacje zwykłych struktur alokowanych dynamicznie za pomocą GetMem). To tak w skrócie.

Czyli że lista składa się ze wskaźników na klasę bazową, w które ty upychasz potem różne polimorficzne klasy potomne. Czy może osobne listy konkretnych obiektów, z odgórnie znanym typem:

Nie wiem czy powinienem o tym pisać, żeby nie odciągać Cię od kodu, jaki sam piszesz. Ty używasz klas, pewnie też interfejsów, więc spokojnie możesz sobie zrobić drzewo dziedziczenia (co będzie kłopotem/redundancją, dlatego wymyślono ECS-a). U mnie wszystko jest strukturalno-proceduralne, w stylu surowego C.

Jeśli chodzi o te listy, to cała mapa będzie reprezentowana jako trójwymiarowa macierz list, w której jedna lista opisuje jeden sześcienny sektor (o rozmiarze potęgi dwójki, albo 64×64×64, albo 128×128×128 pikseli). Każda z tych list będzie przechowywać wskaźniki na obiekty dowolnego typu — od obiektów terenu po aktorów. Każdy obiekt będzie dziedziczyć ze struktury bazowej, więc będę mógł je łatwo rozróżniać (to samo możesz robić używając pascalowego operatora is w przypadku klas, ale nie wiem jak się to w C++ testuje). Zewnętrzne (dodatkowe) listy, te których chcę użyć do szybkiego dostępu do kluczowych obiektów, będą już wyspecjalizowane — np. lista z potworkami będzie zawierać wyłącznie wskaźniki na potworki, ale dowolnego typu.

1
LukeJL napisał(a):

No i czym jest ObjectBase?

Mnie też razi ta nazwa, w sensie za szeroka.
Jak zawsze wtedy, intuicja podpowiada ze projektant nie bardzo wiedział co chce.

0

A czy dałoby się przynajmniej zmusić jakoś do działania poniższą strukturę?


//KLASA Z LISTĄ INT

class Type_Int
{
private:
	int* List = nullptr;
public:
	template<typename>
	auto& Get(const uint16_t& uiID); //<- Bazowe Get()

	template<>
	auto& Get<int>(const uint16_t& uiID) //<- Specjalizacja dla int
	{
		return List[uiID];
	}
};

//KLASA Z LISTĄ FLOAT

class Type_Float
{
private:
	float* List = nullptr;
public:
	template<typename>
	auto& Get(const uint16_t& uiID); //<- Bazowe Get()

	template<>
	auto& Get<float>(const uint16_t& uiID) //<- Specjalizacja dla float
	{
		return List[uiID];
	}
};

//KLASA ZBIORCZA

template<typename... T>
struct Collector : public T...
{};

//TEST

int main()
{
	Collector<Type_Int, Type_Float> collector;
 	collector.Get<int>(0) = 23;
  	collector.Get<float>(0) = 65.03f;
}

Kompilator oczywiście krzyczy o "ambigious", bo zarówno Type_Int jak i Type_Float korzystają z takiego samego, bazowego Get(). Da się to jakoś obejść?

1

Double Dispatch wydaje się być jakąś obiektową alternatywą dla ECS. Nie użyłem tego w praktyce w projekcie gry, ale czysto hipotetycznie może to wprowadzić jakiś porządek i być otwartym na rozszerzenia jednocześnie, podałem przykład w innym wątku https://4programmers.net/Forum/Gamedev/366660-sposob_implementacji_systemow_wplywajacych_na_wszystko?p=1896166#id1896166

Nie mogę jednak zagwarantować, że ten mechanizm Ci się sprawdzi. Widziałem użycie tego w różnych projektach (nie grach) tworzonych w językach, które nie wspierały double dispatch "out of the box" i wyglądało to kiepsko. Wyglądało to tak, jakby ludzie dopiero co przeczytali o "visitor pattern" i strasznie chcieli się nim pobranzlować. Tym nie mniej wydaje mi się, że jeśli nie będziesz się sugerował nazwą wzorca i nie będziesz myślał ani chciał tworzyć żadnych visitorów tylko po prostu zaimplementować double dispatch to może to zadziałać jeśli koniecznie chcesz wypróbować OOP.

(edit)
A tak offtopując, to wydaje mi się, że tworząc grę to struktura danych to najmniej ciekawy i jeden z łatwiejszych problemów do rozwiązania. IMHO nie powinieneś sobie tym zawracać głowy aż do momentu, gdy bałagan zaczyna przeszkadzać w renderowaniu i w tym momencie wybrać coś co rozwiąże Twój problem. A najlepiej podpatrzeć jak inni rozwiązali ten problem przed Tobą np. w takim https://github.com/MrFrenik/Enjon/tree/master/Source by jak najszybciej wrócić do ciekawszych problemów.

1

Dzięki za sugestię. Właśnie opracowuję system trochę przypominający w użyciu ECS, tzn. tablice tworzone dynamicznie, sparowane z typami (coś jak już tu opisywałem, tylko teraz jeszcze spięte w std::tuple). Na dniach powinienem mieć funkcjonalny prototyp i go tu wrzucę, może komuś się przyda :).
A co do "ciekawszych problemów", to pewnie masz rację, ale to mój pierwszy "poważny" silnik i myślę o nim w taki sposób, że nie mogę mieć X bez Y, a Y bez Z i tak po nitce do kłębka... Żeby dobrze opracować buforowanie danych, muszę wiedzieć, jak te dane są przechowywane itd.

Zarejestruj się i dołącz do największej społeczności programistów w Polsce.

Otrzymaj wsparcie, dziel się wiedzą i rozwijaj swoje umiejętności z najlepszymi.