Egy GameObject-et létrehozni egy prototípus alapján nem bonyolult, ahogy azt ki- és bekapcsolni vagy törölni sem:
GameObject newInstance = Instantiate(); // GameObject Létrehozás
Destroy(newInstance); // GameObject Törlés
gameObject.SetActive(true); // GameObject bekapcsolása
gameObject.SetActive(false); // GameObject kikapcsolásaBővebben: Objektumok létrejötte és megszűnte,
Objektumok ki- és bekapcsolása
Ezek közül a törlés és létrehozás arányosan sokkal időigényesebb folyamat, mint a ki és bekapcsolás. Még fontosabb, hogy a létrehozás mindig allokál memóriát, amit aztán a Garbage Collector-nak fel kell szabadítania. Ez nincsen így a ki- és bekapcsolás esetén. Mindezért ha nagyon gyakran hozunk létre és törlünk objektumokat, az akár jelentős hatással is lehet a játék futásának teljesítményére.
Ilyen gyakran példányosított és törölt objektumok lehetnek például lövedékek vagy vizuális effektek. Ekkor hasznos lehet más stratégiát alkalmazni. Törlés helyett akár ideglenesen ki is kapcsolhatjuk a már nem kellő elemeket és szükség esetén újra használhatjuk őket. Ezt a technikát nevezik pooling-nak.
Ehhez szükséges valamilyen rendszert írnunk ami menedzseli a ki és bekapcsolt objektumokat. Ezt a menedzser objektumot hívjuk pool-nak. Hogyan is zajlik mindez!
Hogyan írhatunk pool-t?
- A pool számon tartja a kikapcsolt objektumokat. Erre általában egy listát használunk.
- Ha szükségünk van egy új GameObject-re, akkor
Instantiate()helyett, kérünk egy példányt a pool-tól. - Ha a pool-nak van talomban kikapcsolt példánya, akkor odaadja azt.
- Ha nincs, akkor ő
Instantiate()-el egy példányt nekünk. - Ha már nincs szükség egy objektumra, akkor
Destroy()helyett visszatesszük a pool-ba.
Mindez egy MonoBehaviour osztályként megvalósítva:
A fentiek kiegészíthetők azzal, hogy a pool létrejöttekor automatikusan létrehoz pár példányt előre egy beállítható érték alapján és eltárolja őket:
[SerializeField, Min(0)] int initialSize = 10;
void Awake()
{
for (int i = 0; i < initialSize; i++)
{
GameObject item = Instantiate(prefab);
item.gameObject.SetActive(false);
_pool.Add(item);
}
}Ez csak egy lehetséges megvalósítása egy pool-nak. Sok egyéb is elképzelhető…
Statikus Pool
A fenti megoldás esetén előre létre kell hozni minden olyan objektumra egy pool-t, amit menedzselni szeretnénk és a lekérő objektumnak ismerni kell ezt a pool-t, tehát kell legyen rá egy referenciája. Mindez igen kényelmetlen lehet használat szempontjából.
Helyette készíthetünk egy statikus pool osztályt is, ami menedzseli az összes lehetséges pool-olt elemtípust, nem csak egy fajtát. Ekkor nem kell a Pool-t egy GameObject-en tárolni és bárhonnan hozzá fogunk férni statikusan.
// Elvárt használat:
GameObject instance = Pool.Get(prefab); // Kivesz
Pool.PutBack(instance); // VisszateszA trükk, hogy ekkor egy Dictionary-ban kell eltárolni az összes elemet, aminek kulcsa a prefab, ami alapján létrehoztuk az elemet
Ebben az esetben lekéréskor kell megadni a pool-nak, hogy mely prefab-ból szeretnénk egy példányt megkapni és visszatétel esetén is meg kell adni, melyik prefab-hoz tartozott a példány.
A fenti megoldás kényelmetlensége, hogy a lekérő objektumnak el kell tárolni a példány mellett azt is, hogy mely prefab-ból lett létrehozva, ahhoz, hogy vissza tudja azt tenni a pool-ba (⚠️ jel a kódban). Ez megint csak nagyon kényelmetlen használatot eredményez. Ezt a feladatot is átveheti a Pool, de ehhez számon kell tartania minden példányhoz a hozzá tartozó prefabot-t. Erre használhatunk egy újabb Dictionary-t.
Ez a megoldás is kiegészíthető egy automatikus kezdeti csoportos létrehozó metódussal.
Automatikus Reset
A fenti megoldásokban mindig a lekérő objektum felelőssége, alapállapotba hozni a használt elemet, nem pedig a pool-é. Akár ezt is aztomatizálhatjuk például úgy hogy létrehozunk egy interface-t, ami az alapállapotba hozható komponensek valósítanak meg. Egyetlen metódusa pedig a Reset;
interface IPooled
{
void Reset();
}Ekkor minden alkalommal, amikor egy elemet előveszünk a pool-ból, meg kell néznünk, hogy vannak e Reset-elhető komponensei és ha igen meg kell hívnunk a Reset függvényt rajtuk.
// Ha az instance-ban van IPooled komponens, akkor meghívjuk rajta a Reset metódust:
instance.GetComponentInChildren<IPooled>()?.Reset();
☝️
// ?. operátor
// null teszt és függvényhívás egy sorban.Vegyük észre, hogy minden egyes kényelmi funkció lassító hatással lesz a pool működésére, márpedig annak egyetlen feladat az optimalizáció. Ez nem feltétlenül probléma, hiszen a teljes megoldásunk még mindig jóval gyorsabb lesz, mint ha újra és újra hoznánk létre az objektumokat.
Azért lépésről lépésre néztük végig egy pool felépítését, hogy tetszőlegesen válogathassunk a számunkra kellő feature-ök közül. És persze mindemellett sokféle egyéb módon bővíthető vagy módosítható még egy pooling rendszer.
Teszt
Az alábbi kísérletben másodpercenként 2000 Rigidbody-t spawn-olok és törlök.
Íme a teljesítmény spwn-olással és anélkül:
A fő tanulság a kísérletből, hogy sokat lehet nyerni a pooling-gal, viszont az is, hogy jóóó sok objektum kell hozzá, hogy a különbség szembetűnő legyen. Általános tanácsom az optimalizációval kapcsolatban, hogy akkor próbáljuk megoldani a problémákat, mikor jelentkeznek és ne előre, mert lehet, hogy fölösleges munkát végzünk. Ez főleg igaz, ha valaki kezdő. Egy tapasztaltabb fejlesztő néha előre meg tudja becsülni, milyen optimalizációkra lesz szüksége, ám méz ez sem biztos.