Developedia
Developedia
Pooling

Pooling

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űnteObjektumok létrejötte és megszűnte, Objektumok ki- és bekapcsolásaObjektumok 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);                   // 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.

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.

‣
A fenti megoldás teljes kódja:

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:

Teljesítmény Pool-olás nélkül: ~50FPS     ~10KB GC Allocation / Frame
Teljesítmény Pool-olás nélkül: ~50FPS ~10KB GC Allocation / Frame
Teljesítmény Pool-olással:  ~75FPS        0 GC Allocation / Frame
Teljesítmény Pool-olással: ~75FPS 0 GC Allocation / Frame

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.

Logo

Főoldal

Blog

Elmélet

3D Studio

Adatvédelmi nyilatkozat

GY.I.K.

Házirend

Szerző: Marosi Csaba / marosi.csaba@3d-studio.hu

DiscordGitHubLinkedIn
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
	}
}
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.
	}
}
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.
	}
}
// 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.
	}
}