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ása
Bő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:
using System.Collections.Generic;
using UnityEngine;
class Pool: MonoBehaviour
{
[SerializeField] GameObject prefab; // Mely prototípus alapján készülnek a példányok
readonly List<GameObject> _pool = new(); // Kikapcsolt elemek listája
// Kérek egy példányt!
public GameObject Get()
{
GameObject item;
if (_pool.Count > 0) // Ha van szabad elem talomban,
{
item = _pool[0]; // kiveszük a pool-ból,
_pool.RemoveAt(0);
}
else // egyébként
item = Instantiate(prefab); // létrehozzuk egyet újonnan.
item.gameObject.SetActive(true); // Bekapcsoljuk az objektumot
return item; // és visszaadjuk a kérőnek.
}
// Visszaadok egy példányt!
public void PutBack(GameObject item)
{
item.gameObject.SetActive(false); // Kikapcsoljuk a nem kellő példányt
_pool.Add(item); // és visszatesszük a pool-ba
}
}
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); // Visszatesz
A 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.
static class Pool
{
// Prefab alapján tárolja a kikapcsolt példányok listáját:
static readonly Dictionary<GameObject, List<GameObject>> pool = new();
// Kérek egy példányt!
public static GameObject Get(GameObject prefab)
{
// Ha nem létezik a lista a megfelelő prefab-hoz, akkor létrehozzuk:
if (!pool.TryGetValue(prefab, out List<GameObject> items))
{
items = new List<GameObject>();
pool.Add(prefab, items);
}
GameObject instance;
// Ha üres a lista akkor létrehozunk egy új példányt:
if(items.Count == 0)
instance = Object.Instantiate(prefab);
// Egyébként pedig kiveszünk egyet a listából:
else
{
instance = items[0];
items.RemoveAt(0);
}
// Aktiváljuk a példányt, meghívjuk a OnGetFromPool metódust, majd visszaadjuk:
instance.SetActive(true);
return instance;
}
// Visszaadok egy példányt!
public static void PutBack(GameObject instance, GameObject prefab) //⚠️ Hmmm...
{
instance.SetActive(false); // Kikapcsoljuk a példányt.
pool[prefab].Add(instance); // Visszatesszük az instance-t a pool-ba.
}
}
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.
using System.Collections.Generic;
using UnityEngine;
static class Pool
{
// Prefab alapján tárolja a kikapcsolt példányok listáját:
static readonly Dictionary<GameObject, List<GameObject>> pool = new();
// Minden példányra eltároljuk, hogy melyik prefab-ból lett létrehozva:
static readonly Dictionary<GameObject, GameObject> instanceToPrefab = new();
// Kérek egy példányt!
public static GameObject Get(GameObject prefab)
{
// Ha nem létezik a lista a megfelelő prefab-hoz, akkor létrehozzuk:
if (!pool.TryGetValue(prefab, out List<GameObject> items))
{
items = new List<GameObject>();
pool.Add(prefab, items);
}
GameObject instance;
if (items.Count == 0) // Ha üres a lista akkor,
{
instance = Object.Instantiate(prefab); // létrehozunk egy új példányt
instanceToPrefab.Add(instance, prefab); // és eltároljuk a példány prefab-ját.
}
else // Egyébként pedig
{
instance = items[0]; // kiveszünk egy nem használt példányt a listából.
items.RemoveAt(0);
}
instance.SetActive(true); // Aktiváljuk a példányt,
return instance; // majd visszaadjuk.
}
// Visszaadok egy példányt!
public static void PutBack(GameObject instance)
{
instance.SetActive(false); // Kikapcsoljuk a példányt.
GameObject prefab = instanceToPrefab[instance]; // Példányhoz tartozó prefab.
pool[prefab].Add(instance); // Visszatesszük az instance-t a pool-ba.
}
}
Ez a megoldás is kiegészíthető egy automatikus kezdeti csoportos létrehozó metódussal.
// Előre hozzáadunk néhány elemet a pool-hoz.
public static void Expand(GameObject prefab, int count)
{
// Ha nem létezik a lista a megfelelő prefab-hoz, akkor létrehozzuk:
if (!pool.TryGetValue(prefab, out List<GameObject> items))
{
items = new List<GameObject>();
pool.Add(prefab, items);
}
for (int i = 0; i < count; i++)
{
GameObject instance = Object.Instantiate(prefab); // Létrehozunk egy új példányt,
instanceToPrefab.Add(instance, prefab); // eltároljuk a hozzá tartozó prefab-ot,
instance.SetActive(false); // kikapcsoljuk,
items.Add(instance); // és eltároljuk a pool-ban.
}
}
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.