paint-brush
ObjectPool Desenini Kullanarak Oyun Projenizi Nasıl Geliştirirsiniz (Birlik Kılavuzu)ile@jokeresdeu
906 okumalar
906 okumalar

ObjectPool Desenini Kullanarak Oyun Projenizi Nasıl Geliştirirsiniz (Birlik Kılavuzu)

ile Les Dibrivniy16m2023/05/10
Read on Terminal Reader
Read this story w/o Javascript

Çok uzun; Okumak

Nesne Havuzu, oyun projelerinin optimizasyonunun %99'unun temelini oluşturur. Temel bir prensibe dayanmaktadır: Görevi tamamlandıktan sonra nesne silinmez, ayrı bir ortama taşınır ve geri alınıp yeniden kullanılabilir. Les Dibrivniy bu modelin neden gerekli olduğunu açıklıyor.
featured image - ObjectPool Desenini Kullanarak Oyun Projenizi Nasıl Geliştirirsiniz (Birlik Kılavuzu)
Les Dibrivniy HackerNoon profile picture
0-item
1-item

Merhaba, adım Oles Dibrivniy ve Keiki'de Unity Geliştiricisiyim. EdTech ve GameDev'in kesiştiği noktada, çocukların gelişimine yönelik uygulamalar ve web ürünleri yaratıyoruz. Artık ürünlerimizin dört milyondan fazla kullanıcısı var.

Birisi size iş görüşmesi sırasında oyun geliştirmede kullandığınız programlama kalıplarını sorarsa bahsetmeniz gereken ilk şeylerden biri ObjectPool'dur. Temel bir prensibe dayanmaktadır: Görevi tamamlandıktan sonra nesne silinmez, ayrı bir ortama taşınır ve geri alınıp yeniden kullanılabilir.

Bu kalıp, kullanıcının uygulamaya ilişkin algısını doğrudan etkiler, bu nedenle çok önemlidir. Oyun projelerinin optimizasyonunun %99'unun temeli bu olmalıdır.

Bu makale programlama guruları için geçerli olmayabilir ancak yeni başlayanlar için tam tersi geçerli olabilir. Burada bu modelin neden gerekli olduğunu açıklamak için örnekler kullanacağım.


ObjectPool olmadan işler nasıl gidiyor?

İlk olarak, ayarlardan başlayarak ObjectPool olmadan proje durumunu analiz edeceğiz. Kahramanın ve iki düşmanın ateş topu fırlattığı nispeten basit bir sahnemiz var:

Ateş topunun neye benzeyeceğini düşünelim. Alanlar

 Rigidbody2D
Ve
 _speed
ateş toplarının hareketinden sorumlu olacak; the
 OnTriggerEnter2D
yöntem bir çarpışmadan sonra çalışacaktır - FireBall bileşenine sahip nesne yok edilecektir:

 public class FireBall : MonoBehaviour 2 { 3 [SerializeField] private Rigidbody2D _rigidbody; 4 [SerializeField] private float _speed; 5   
6 public void FlyInDirection(Vector2 flyDirection) 7 { 8 _rigidbody.velocity = flyDirection * _speed; 9 } 10   
11 private void OnTriggerEnter2D(Collider2D other) 12 { 13 Destroy(gameObject); 14 } 15 }

Düşman da oldukça basit görünecek:

 public class Enemy : MonoBehaviour 2 { 3 [SerializeField] private FireBall _fireBall; 4 [SerializeField] private Transform _castPoint; 5   
6 public void Cast() 7 { 8 FireBall fireBall = Instantiate(_fireBall, _castPoint.position, Quaternion.identity); 9 fireBall.FlyInDirection(transform.right); 10 } 11 }

Şunu arayabilirsiniz:

 Cast
projenizin veya animasyon etkinliklerinizin herhangi bir noktasından yöntem. Şöyle bir akış var: Düşman bir ateş topu yarattı ve onu kendi bakış açısına doğru fırlattı. Ateş topu yoldaki ilk engele ulaşarak yok edilir.

Bu yaklaşım optimal gibi görünse de en kötü seçeneklerden biridir. Her şeyi nokta nokta analiz edelim:

1. Cihazın CPU ve belleğinin verimsiz kullanımı.

 Instantiate
Ve
 Destroy
pahalı işlemlerdir: bunları birkaç saniyede bir çağırmak gecikmelere ve gecikmelere neden olur. Bu, özellikle hafızanın her baytının kaydedilmesinin gerekli olduğu akıllı telefonlar gibi gadget'larla çalışırken fark edilir. Bu işlemlerin oyun için ne kadar "acı verici" olduğunu görmek için Unity Profiler'ı açabilir ve Hiyerarşi penceresinin arama alanına Örnekleme yazabilirsiniz. Aşağıdakiler olacaktır:

Elbette, dramatik etkiyi en üst düzeye çıkarmak için her düşmanın aynı anda oluşturduğu ateş toplarının sayısını 500'e çıkardım. Bununla birlikte, özellikle kullanıcı arayüzü öğeleri veya parçacıklarıyla uğraşırken, daha sürekli olarak oluşturulan ve silinen nesnelere sahip projeler hayal etmek kolaydır.

Bu işlemler çalışma zamanında sürekli olarak gerçekleşir. Oyuncu, sahnede yüzlerce nesne oluşturduktan sonra sabit bellek tahsisi nedeniyle karelerde gözle görülür bir düşüş yaşayabilir.

2. Yarattığınız her şey yok edilmeli ve temizlenmelidir.

 Destroy
yöntem, oyun nesnesini sahneden kaldırarak onu çöp toplayıcıya gönderir. Toplayıcının onu ne zaman ve nasıl işleyeceği üzerinde hiçbir kontrolünüz yok. Bu arada,
 Destroy
çok sinsidir. Oyun nesnesinin kendisini siler ancak bileşenleri ayrı ayrı yaşamaya devam edebilir. Bu bileşene başka bir nesne bağlıysa bu durum meydana gelebilir. Örneğin belirli bir etkinliğe abonelik olabilir.

3. Kod kontrolü. Bir nesnenin yaratılmasından ve yok edilmesinden farklı sınıflar sorumludur ve projede onlarcası bulunabilir. Neyin nerede oluşturulduğunu veya silindiğini bulmak bazen önemsiz bir iş olmuyor - ve artık hiyerarşideki nesnelerin kontrol edilmesi konusunda sessiz kalıyorum.

ObjectPool'u projeye entegre edelim!

Sorunu tanımladıktan sonra çözümüne geçelim. Daha önce de belirttiğim gibi, ObjectPool modelinin çalışma prensibi basittir: nesneyle çalışmayı bitirdikten sonra silinmez ancak "havuzda" saklanır. Nesne ondan alınabilir ve yeniden kullanılabilir:

Bir nesnenin yaratılmasından, yeniden kullanılmasından ve yok edilmesinden tek bir varlık sorumlu olacak - buna biz diyeceğiz

 ObjectPool
. Bir ateş topuyla çalışmayı şöyle gösterebiliriz:

 public class ObjectPool : MonoBehaviour 2 { 3 [SerializeField] private FireBall _fireBallPrefab; 4   
5 private readonly List <FireBall> _freeFireBalls = new List <FireBall>(); 6   
7 public FireBall GetFireBall() 8 { 9 FireBall fireBall; 10       if (_freeFireBalls.Count > 0 ) 11 { 12 fireBall = _freeFireBalls[ 0 ]; 13 _freeFireBalls. Remove(fireBall);
14 } 15       else
16 { 17 fireBall = Instantiate(_fireBallPrefab, transform); 18 } 19       return fireBall; 20 } 21   
22 private void ReturnFireBall(FireBall fireBall) 23 { 24 _freeFireBalls.Add(fireBall); 25 } 26 }

 _freeFireBalls
liste bu kodda görünür. İşini yapmış olan oluşturulan ateş toplarını orada depolayacağız. Düşman artık şöyle görünecek:

 public class Enemy : MonoBehaviour 2 { 3 [SerializeField] private ObjectPool _objectPool; 4 [SerializeField] private Transform _castPoint; 5   
6 public void Cast() 7 { 8 FireBall fireBall = _objectPool.GetFireBall(); 9 fireBall.transform.position = _castPoint.position; 10 fireBall.FlyInDirection(transform.right); 11 } 12 }

Sorunumuzun kökü: Ateş topunu havuza nasıl geri gönderebiliriz? Düşmana güvenemeyiz; ateş topunun ne zaman yok edileceğini bilmiyor. Ayrıca şu konularda ateş topu bilgisi vermek de istemiyoruz:

 ObjectPool
çünkü gereksiz bağlantılar yaratacaktır.

Umarım bunu yaptığımı fark etmişsindir

 ReturnFireBall
yöntem özel. Bu nedenle, temel C# modellerinden biri olan gözlemci ve bunun uygulanması olaylarını kullanacağız. Ateş topu artık şöyle görünecek:

 public class FireBall : MonoBehaviour 2 { 3 [SerializeField] private Rigidbody2D _rigidbody; 4 [SerializeField] private float _speed; 5   
6 public event Action<FireBall> Destroyed; 7   
8 public void FlyInDirection(Vector2 flyDirection) 9 { 10 _rigidbody.velocity = flyDirection * _speed; 11 } 12   
13 private void OnTriggerEnter2D(Collider2D other) 14 { 15 Destroyed?.Invoke(this); 16 } 17 }

 ObjectPool
abone olacak
 Destroyed
nesneyi dünyaya aktardıktan sonraki olay:

 public class ObjectPool : MonoBehaviour 2 { 3 [SerializeField] private FireBall _fireBallPrefab; 4 private readonly List <FireBall> _freeFireBalls = new List <FireBall>(); 5   
6 public FireBall GetFireBall() 7 { 8 FireBall fireBall; 9       if (_freeFireBalls.Count > 0 ) 10 { 11 fireBall = _freeFireBalls[ 0 ]; 12 _freeFireBalls. Remove(fireBall);
13 } 14       else
15 { 16 fireBall = Instantiate(_fireBallPrefab, transform); 17 } 18 fireBall.Destroyed += ReturnFireBall; 19       return fireBall; 20 } 21   
22 private void ReturnFireBall(FireBall fireBall) 23 { 24 fireBall.Destroyed -= ReturnFireBall; 25 _freeFireBalls.Add(fireBall); 26 } 27 }

Geriye Nesne Havuzunu nesnenin üzerine asmak kalır.

 Enemy
bileşen — tebrikler! Çöp toplayıcı siz başka bir sahneye geçene veya oyunu kapatana kadar dinlenecektir. Temel uygulamasında ObjectPool'umuz var, ancak onu geliştirebiliriz.

Arayüzler ve jenerikler: ObjectPool ile mükemmel uyum

 FireBall
nesne havuzunda çalıştıracağımız tek nesne türü bu değil. Ayrı yazmalısınız
 ObjectPool
her birinin diğer türlerle çalışması için. Bu, kod tabanını genişletecek ve kodu daha az okunabilir hale getirecektir. Öyleyse taklitleri ve jenerikleri kullanalım.

İçinden geçtiğimiz her nesne

 ObjectPool
belirli bir türe bağlı olmalıdır. Belirli bir uygulamadan bağımsız olarak işlenmeleri gerekir. Havuzun işleyeceği nesnelerin temel miras hiyerarşisini korumak önemlidir. Miras alacaklar
 MonoBehaviour
, en azından. IPolable arayüzünü kullanalım:

 public interface IPoolable 2 { 3 GameObject GameObject { get ; } 4 event Action<IPoolable> Destroyed; 5 void Reset (); 6 }

Biz miras alıyoruz

 FireBall
ondan:

 public class FireBall : MonoBehaviour, IPoolable 2 { 3 [SerializeField] private Rigidbody2D _rigidbody; 4 [SerializeField] private float _speed; 5 public GameObject GameObject => gameObject; 6   
7 public event Action<IPoolable> Destroyed; 8   
9 private void OnTriggerEnter2D(Collider2D other) 10 { 11       Reset (); 12 } 13 public void Reset () 14 { 15 Destroyed?.Invoke(this); 16 } 17   
18 public void FlyInDirection(Vector2 flyDirection) 19 { 20 _rigidbody.velocity = flyDirection * _speed; 21 } 22 }

Son görev öğretmektir

 ObjectPool
herhangi bir nesneyle çalışmak. IPolable yeterli olmayacaktır çünkü Instantiate nesnenin yalnızca bir kopyasını oluşturabilir.
 gameObject
mülk. Kullanacağız
 Component
; bu tür nesnelerin her biri bu sınıftan miras alır.
 MonoBehaviour
da ondan miras kalmıştır. Sonuç olarak aşağıdakileri elde edeceğiz
 ObjectPool
:

 public class ObjectPool<T> where T : Component, IPoolable 2 { 3 private readonly List <IPoolable> _freeObjects; 4 private readonly Transform _container; 5 private readonly T _prefab; 6   
7 public ObjectPool(T prefab) 8 { 9 _freeObjects = new List <IPoolable>(); 10 _container = new GameObject().transform; 11 _container. name = prefab.GameObject. name ; 12 _prefab = prefab; 13 } 14   
15 public IPoolable GetFreeObject() 16 { 17 IPoolable poolable; 18       if (_freeObjects.Count > 0 ) 19 { 20 poolable = _freeObjects[ 0 ] as T; 21 _freeObjects. RemoveAt(0);
22 } 23       else
24 { 25 poolable = Object.Instantiate(_prefab, _container); 26 } 27 poolable.GameObject.SetActive(true); 28 poolable.Destroyed += ReturnToPool; 29       return poolable; 30 } 31   
32 private void ReturnToPool(IPoolable poolable) 33 { 34 _freeObjects.Add(poolable); 35 poolable.Destroyed -= ReturnToPool; 36 poolable.GameObject.SetActive(false); 37 poolable.GameObject.transform.SetParent(_container); 38 } 39 }

Artık bir tane oluşturabiliriz

 ObjectPool
miras koşullarını karşılayan herhangi bir nesne için
 Component
ve IP'de havuzlanabilir. kaldırdım
 ObjectPool
miras
 MonoBehaviour
böylece nesnelere asacağımız bileşenlerin sayısı azalır.

Çözülmesi gereken bir sorun var. Aynı anda birden fazla düşman sahnede olabilir ve her biri aynı ateş toplarını doğurur. Hepsi aynı süreçten geçse harika olurdu

 ObjectPool
— asla çok fazla kaynak tasarrufu olamaz! Bir sınıfın belirli bir nesne türünü istemesi de oldukça kullanışlıdır. Böyle bir sınıf, nesnenin oluşturulmasını, kontrolünü ve nihai olarak elden çıkarılmasını devralacaktır. Bu seçenek her şeyi yönetmek için en iyi seçimdir.
 ObjectPool
Projede bireysel olarak zorludur.

Temel gereksinimleri tamamlamak için

 ObjectPool
sahne yüklendiğinde belirli sayıda nesne oluşturmamız gerekir. Herhangi bir kaynak tüketimi sorununu önlemek için bir yükleme penceresi ekleyeceğiz. Şimdi bunu uygulamaya başlayalım.

Ek bir varlık oluşturacağız

 PoolTask
nihai uygulanmasında
 ObjectPool
. Bu sınıf, bir prefabrik yapıdan oluşturulan nesnelerle çalışmayı kontrol edecektir:

 public class PoolTask 2 { 3 private readonly List <IPoolable> _freeObjects; 4 private readonly List <IPoolable> _objectsInUse; 5 private readonly Transform _container; 6   
7 public PoolTask(Transform container) 8 { 9 _container = container; 10 _objectsInUse = new List <IPoolable>(); 11 _freeObjects = new List <IPoolable>(); 12 } 13   
14 public void CreateFreeObjects<T>(T prefab, int count) where T : Component, IPoolable 15 { 16       for (var i = 0 ; i < count; i++) 17 { 18 var poolable = Object.Instantiate(prefab, _container); 19 _freeObjects.Add(poolable); 20 } 21 } 22   
23 public T GetFreeObject<T>(T prefab) where T : Component, IPoolable 24 { 25 T poolable; 26       if (_freeObjects.Count > 0 ) 27 { 28 poolable = _freeObjects[ 0 ] as T; 29 _freeObjects. RemoveAt(0);
30 } 31       else
32 { 33 poolable = Object.Instantiate(prefab, _container); 34 } 35 poolable.Destroyed += ReturnToPool; 36 poolable.GameObject.SetActive(true); 37 _objectsInUse.Add(poolable); 38       return poolable; 39 } 40   
41 public void ReturnAllObjectsToPool() 42 { 43 foreach (var poolable in _objectsInUse) 44 poolable. Reset (); 45 } 46   
47 public void Dispose() 48 { 49 foreach (var poolable in _objectsInUse) 50 Object.Destroy(poolable.GameObject); 51       
52 foreach (var poolable in _freeObjects) 53 Object.Destroy(poolable.GameObject); 54 } 55   
56 private void ReturnToPool(IPoolable poolable) 57 { 58 _objectsInUse. Remove(poolable);
59 _freeObjects.Add(poolable); 60 poolable.Destroyed -= ReturnToPool; 61 poolable.GameObject.SetActive(false); 62 poolable.GameObject.transform.SetParent(_container); 63 } 64 }

PoolTask'ın ek özellikleri olacaktır:

1. Dünyaya bıraktığımız nesnelerin, gerektiğinde yok edilebilmesi veya belirli bir anda havuza geri gönderilebilmesi için takip edilmesi;

2. Önceden belirlenmiş miktarda serbest nesne oluşturmak.

Son olarak tüm ihtiyaçlarımızı karşılayacak, nesnelerin kontrolünü ve üretimini tamamen devralacak bir ObjectPool oluşturalım:

 public class ObjectPool 2 { 3 private static ObjectPool _instance; 4 public static ObjectPool Instance => _instance ??= new ObjectPool(); 5   
6 private readonly Dictionary<Component, PoolTask> _activePoolTasks; 7 private readonly Transform _container; 8  
9 private ObjectPool() 10 { 11 _activePoolTasks = new Dictionary<Component, PoolTask>(); 12 _container = new GameObject().transform; 13 _container. name = nameof(ObjectPool); 14 } 15   
16 public void CreateFreeObjects<T>(T prefab, int count) where T : Component, IPoolable 17 { 18       if (!_activePoolTasks.TryGetValue(prefab, out var poolTask)) 19 AddTaskToPool(prefab, out poolTask); 20       
21 poolTask.CreateFreeObjects(prefab, count); 22 } 23   
24 public T GetObject<T>(T prefab) where T : Component, IPoolable 25 { 26       if (!_activePoolTasks.TryGetValue(prefab, out var poolTask)) 27 AddTaskToPool(prefab, out poolTask); 28       return poolTask.GetFreeObject(prefab); 29 } 30   
31 public void Dispose() 32 { 33 foreach (var poolTask in _activePoolTasks.Values) 34 poolTask.Dispose(); 35 } 36   
37 private void AddTaskToPool<T>(T prefab, out PoolTask poolTask) where T : Component, IPoolable 38 { 39 var taskContainer = new GameObject 40 { 41           name = $ "{prefab.name}_pool" , 42 transform = 43 { 44 parent = _container 45 } 46 }; 47 poolTask = new PoolTask(taskContainer.transform); 48 _activePoolTasks.Add(prefab, poolTask); 49 } 50 }

Singleton kullanımı dikkatinizi çekebilir; bu yalnızca bir örnektir. Projenizdeki kullanımı özelleştirebilirsiniz; çalışıyor olabilir

 ObjectPool
yapıcılar aracılığıyla veya Zenject aracılığıyla enjeksiyon yoluyla.

Son versiyonumuz var

 Cast
yöntem
 Enemy
:

 public class Enemy : MonoBehaviour 2 { 3 [SerializeField] private FireBall _prefab; 4 [SerializeField] private Transform _castPoint; 5   
6 public void Cast() 7 { 8 FireBall fireBall = ObjectPool.Instance.GetObject(_prefab); 9 fireBall.transform.position = _castPoint.position; 10 fireBall.FlyInDirection(transform.right); 11 } 12 }

Jenerik kullandığımız için hemen işlememiz gereken türde bir nesne elde ediyoruz.

 ObjectPool
Dahili görevleri prefabrik yapıya göre gruplandıracak - eğer birden fazla varsa
 FireBall
havuz bunları doğru bir şekilde işleyecek ve size doğru olanı verecektir. Bu yaklaşım oyun sahnesi için herhangi bir nesnenin oluşturulmasına yardımcı olacaktır.

Ancak kullanıcı arayüzü öğeleriyle çalışırken dikkatli olun: bir nesneyi farklı dönüşümlere sahip üst dönüşümler arasında taşırken.

 localScale
,
 localScale
nesnenin kendisi değişecektir. Projenizde uyarlanabilir bir kullanıcı arayüzü varsa, tuval bileşeniyle yapılan dönüşümler bunların
 localScale
Uzantıya bağlı olarak. Bu basit işlemi yapmanızı tavsiye ederim:

 poolable.GameObject.transform.localScale = Vector2. on e ;

Diğer seçeneklerde 3 komut dosyası kullanabilirsiniz:

 ObjectPool
,
 PoolTask
, Ve
 IPoolable
. Bu yüzden bunları projenize eklemekten ve Nesne Havuzu modelini %100 kullanmaktan çekinmeyin!