Egy ECS-ben fontos, hogy hol és hogyan tároljuk a komponenseket, főleg, ha játékokról van szó, ahol fontos a sebesség.
Az első megközelítés
Az entity-component kapcsolat nagy vonalakban nézve annyit mond, hogy egy entitást a komponensei határoznak meg, azaz maga az entity egy buta objektum, ami a komponenseit "tárolja". Gondoljunk a Unity GameObject osztályára: adott egy objektum, le tudjuk kérni a hozzá tartozó komponenseket - többek között - a GetComponent függvényével.
Tekintsük általánosságban a következő, nagyon egyszerű komponens osztály(oka)t:
class Component { public: Component(GameObject* obj) : object(obj) { } virtual ~Component() { } protected: GameObject* const object; }; class Transform : public Component { /* ... */ } class Renderer : public Component { /* ... */ } // ...
Ekkor, ha azt mondjuk, hogy az entity tárolja a komponenseket, akkor az valahogy így néz ki:
class GameObject { public: template <class T, typename... Args> T* Add(Args&&... args) { // static_assert: base_of<Component, T> T* result = new T(this, std::forward<Args>(args)...); components.push_back(result); return result; } private: typedef std::vector<Component*> Components; Components components; };
Mi is a probléma ezzel? Az objektumtól le tudjuk kérni a hozzá tartozó komponenseket, szóval mindenki boldog. Pontosabban csak egy valaki boldog, mégpedig a komponenst lekérdező fél, sajnos senki más nem igazán.
A komponenseken való végigiterálás egy igen gyakori jelenség futás közben: a mozgó objektumoknak ki kell számolni a world mátrixát, ütközést kell számolni a fizikai komponensekkel, stb. Azonban, ha az entitás tárolja a komponenseket (főleg ilyen általános módon), akkor az iterálás igen problémás. Minden objektumon végig kell iterálni, le kell kérdezni az adott típusú komponensét (pl. Transform), és azon elvégezni a műveleteket.
Egy kicsivel jobb, ha az objektumok néhány előre definiált, jól ismert komponenst külön tárolnak, és csak a user-defined komponenseket tárolják egy ilyen általános tárolóban, valahogy így:
Transform* transform; Renderer* renderer; Components otherComponents;
Ekkor az adott típusú built-in komponens elérése már gyorsabb, de továbbra is végig kell iterálni az összes objektumon, és azon keresztül lekérni az adott komponenst. Magyarul a memóriában oda kell ugrani az objektum címére, kiolvasni a komponens címét, majd odaugrani a memóriában. Ráadásul le kell ellenőriznünk, hogy van-e ilyen komponense az adott objektumnak. Ez úgy nem tűnik annyira hatékonynak.
Egy kicsit jobb
Tegyük félre, hogy az entitás hozza létre a komponenseket, szimplán csak tároljon egy pointert. De akkor ki hozza létre és tárolja magát a komponenst? Egyszerű a válasz: az adott system, vagy akár még egy szinttel feljebb, a systemeket vezérlő manager/system.
A system egy olyan osztály, amely egy adott tevékenységet végez, általában egy típusú komponenshez tartozik. Például a TransformSystem az objektumok transzformálásáért (térbeli elhelyezéséért) felel, és a Transform típusú komponenseket használja. A PhysicSystem a fizikához használatos komponens(eke)t, és így tovább. Természetesen vannak olyan esetek is, amikor egy systemnek többféle komponensre is szüksége van, például a RendererSystem: szüksége van az objektumok térbeli elhelyezkedésére (world mátrix) és magára a Renderer komponensre, amely a kirajzolandó dolgokat tárolja.
Tehát a komponensek létrehozása egy másik szintre kerül. Személy szerint azt a megközelítést választottam, hogy a Scene tárolja a komponenseket, a System pedig csak egy referenciát kap a megfelelő komponens listára/listákra.
class GameObject { public: void StoreComponent(Component* component) { components.push_back(component); } private: typedef std::vector<Component*> Components; Components components; }; class Scene { public: template <class T, typename... Args> T* AddComponent(GameObject* object, Args&&... args) { // static_assert... T* result = new T(object, std::forward<Args>(args)...); object->StoreComponent(result); AddToPool<T>(result); return result; } void Update() { transformSystem->DoJob(transforms); // ... } private: template <class T> using ComponentPool = std::vector<T>; ComponentPool<Transform*> transforms; ComponentPool<Renderer*> renderers; ComponentPool<Component*> otherComponents; // ... TransformSystem* transformSystem; RendererSystem* rendererSystem; // ... template <class T> void AddToPool(T* component) { otherComponents.push_back(component); } // specializations.... template <> void AddToPool<Transform>(Transform* component) { transforms.push_back(component); } // ... };
Mit is nyertünk ezzel? Komponensenként eggyel kevesebb memória-ugrást, nomeg ellenőriznünk sem kell, hogy van-e ilyen komponens vagy sem, hiszen ott van a listában, és kész. Mindezt azzal értük el, hogy nem az objektumokon kell végigiterálni, hanem az adott komponens listán.
Most már örülhetnénk, mert minden jó. Alapvetően igen, kivéve a szegény memóriát. Mivel heap alloc történik, így nem tudjuk pontosan, hogy a memóriában hol kapnak helyet a létrehozott komponensek. Ráadásul alapvetően a komponensek viszonylag kicsik, így könnyen előfordulhat, hogy a memóriában össze-vissza helyeken lesznek a komponenseink (ahol éppen helyet talált nekik az oprendszer). Miután a komponenseket felszabadítjuk, az általuk lefoglalt memória szabaddá válik. Azonban, ha sok ilyen kicsi memóriaterületet foglaltunk le "össze-vissza", akkor előfordulhat, hogy egy nagyobb allokáció nem fog sikerülni, mert nem maradt egyetlen egybefüggő memóriaterület.
Érdemes utánanézni a "memory fragmentation" fogalomnak. A linken található ábrán látható az előbbiekben tárgyalt probléma, amikor egy "közbezárt" memóriaterület felszabadul, a helyére nem foglalható egy annál nagyobb objektum.
Freed block B. Notice that the memory that B used cannot be included for an allocation larger than B's size.
PC-n ez nem okoz túl nagy gondot, ott a virtuális memória, meg eleve viszonylag nagy az elérhető RAM, de gondoljunk egy zárt platformra, bármely konzol, telefon vagy tablet. Ezeknek a memóriája véges, ráadásul abban sem vagyok biztos, hogy lenne (például) az xbox-nak virtuális memória-kezelése, hiszen - tudtommal - a HDD nem szükséges tartozéka. No persze PC-n sem elhanyagolható a probléma, az erőforrás-pazarlás sosem jó ötlet.
Mi a megoldás?
Saját memóriakezelés: foglaljunk le egy nagyobb egybefüggő területet előre, és placement new segítségével hozzuk létre az objektumokat. Bővebben később... :)