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... :)
