paint-brush
Làm cách nào để nâng cao dự án trò chơi của bạn bằng cách sử dụng mẫu ObjectPool? (Hướng dẫn thống nhất)từ tác giả@jokeresdeu
906 lượt đọc
906 lượt đọc

Làm cách nào để nâng cao dự án trò chơi của bạn bằng cách sử dụng mẫu ObjectPool? (Hướng dẫn thống nhất)

từ tác giả Les Dibrivniy16m2023/05/10
Read on Terminal Reader

dài quá đọc không nổi

Nhóm đối tượng là cơ sở để tối ưu hóa 99% các dự án trò chơi. Nó dựa trên một nguyên tắc cơ bản: sau khi hoàn thành nhiệm vụ của mình, đối tượng không bị xóa mà được chuyển đến một môi trường riêng biệt và có thể được truy xuất và sử dụng lại. Les Dibrivniy giải thích lý do tại sao mô hình này là cần thiết.
featured image - Làm cách nào để nâng cao dự án trò chơi của bạn bằng cách sử dụng mẫu ObjectPool? (Hướng dẫn thống nhất)
Les Dibrivniy HackerNoon profile picture
0-item
1-item

Xin chào, tên tôi là Oles Dibrivniy và tôi là Nhà phát triển Unity tại Keiki . Chúng tôi tạo ra các sản phẩm dựa trên sự giao thoa giữa EdTech và GameDev — các ứng dụng và sản phẩm web dành cho sự phát triển của trẻ em. Bây giờ các sản phẩm của chúng tôi có hơn bốn triệu người dùng.

Nếu ai đó hỏi bạn trong khi phỏng vấn xin việc về các mẫu lập trình bạn sử dụng trong phát triển trò chơi, thì một trong những điều đầu tiên bạn nên đề cập là ObjectPool. Nó dựa trên một nguyên tắc cơ bản: sau khi hoàn thành nhiệm vụ của mình, đối tượng không bị xóa mà được chuyển đến một môi trường riêng biệt và có thể được truy xuất và sử dụng lại.

Mẫu này ảnh hưởng trực tiếp đến nhận thức của người dùng về ứng dụng, đó là lý do tại sao nó rất quan trọng. Nó phải là cơ sở để tối ưu hóa 99% các dự án trò chơi.

Bài viết này có thể không phù hợp với các chuyên gia lập trình nhưng ngược lại dành cho người mới bắt đầu. Ở đây tôi sẽ sử dụng các ví dụ để giải thích tại sao mô hình này lại cần thiết.


Làm thế nào để mọi thứ diễn ra mà không có ObjectPool?

Đầu tiên, chúng ta sẽ phân tích trường hợp dự án không có ObjectPool, bắt đầu với cài đặt. Chúng ta có một cảnh tương đối đơn giản với nhân vật chính và hai kẻ thù ném quả cầu lửa:

Hãy xem quả cầu lửa sẽ trông như thế nào. Nhữn cánh đồng

 Rigidbody2D
 _speed
sẽ chịu trách nhiệm về chuyển động của các quả cầu lửa; các
 OnTriggerEnter2D
sẽ hoạt động sau khi va chạm — đối tượng có thành phần FireBall sẽ bị hủy:

 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 }

Kẻ thù cũng sẽ trông khá đơn giản:

 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 }

Bạn có thể gọi

 Cast
phương pháp từ bất kỳ điểm nào trong dự án hoặc sự kiện hoạt hình của bạn. Chúng tôi có dòng chảy sau: kẻ thù đã tạo ra một quả cầu lửa và phóng nó theo hướng nhìn của anh ta. Quả cầu lửa bị phá hủy khi gặp chướng ngại vật đầu tiên trên đường đi.

Cách tiếp cận này có vẻ tối ưu, nhưng nó là một trong những lựa chọn tồi tệ nhất. Hãy phân tích mọi thứ từng điểm một:

1. Sử dụng CPU và bộ nhớ của máy không hiệu quả.

 Instantiate
 Destroy
là những hoạt động tốn kém: gọi chúng cứ sau vài giây sẽ gây ra sự chậm trễ và chậm trễ. Điều này đặc biệt đáng chú ý khi làm việc với các thiết bị như điện thoại thông minh, nơi cần tiết kiệm từng byte bộ nhớ. Bạn có thể mở Unity Profiler và gõ Instantiate vào trường tìm kiếm của cửa sổ Hierarchy để xem các thao tác này gây "đau đớn" như thế nào đối với trò chơi. Sẽ có những điều sau đây:

Chắc chắn rồi, tôi đã tăng số quả cầu lửa do mỗi kẻ thù tạo ra cùng lúc lên 500 quả để tối đa hóa hiệu ứng ấn tượng. Tuy nhiên, thật dễ dàng để tưởng tượng các dự án có nhiều đối tượng được tạo và xóa liên tục hơn — đặc biệt là khi xử lý các thành phần hoặc hạt giao diện người dùng.

Các quá trình này xảy ra liên tục trong thời gian chạy. Người chơi có thể bị giảm khung hình đáng kể do phân bổ bộ nhớ liên tục sau khi bạn sinh ra hàng trăm đối tượng trên sân khấu.

2. Mọi thứ bạn đã tạo ra phải bị phá hủy và làm sạch. Các

 Destroy
phương thức loại bỏ đối tượng trò chơi khỏi hiện trường, gửi nó đến trình thu gom rác. Bạn không có quyền kiểm soát thời gian hoặc cách thức người thu thập xử lý nó. Nhân tiện,
 Destroy
là rất lén lút. Nó tự xóa đối tượng trò chơi, nhưng các thành phần của nó có thể tiếp tục tồn tại riêng biệt. Điều này có thể xảy ra nếu một đối tượng khác được liên kết với thành phần này. Ví dụ: nó có thể là một đăng ký cho một sự kiện nhất định.

3. Kiểm soát mã. Các lớp khác nhau chịu trách nhiệm tạo và hủy một đối tượng và hàng chục lớp trong số chúng có thể có trong dự án. Tìm cái gì và ở đâu được tạo hoặc xóa đôi khi không phải là một nhiệm vụ tầm thường — và bây giờ tôi im lặng về việc kiểm soát các đối tượng trong hệ thống phân cấp.

Hãy tích hợp ObjectPool vào dự án!

Sau khi xác định vấn đề, hãy chuyển sang giải pháp của nó. Như tôi đã đề cập trước đó, nguyên tắc hoạt động của mẫu ObjectPool rất đơn giản: sau khi hoàn thành công việc với đối tượng, nó không bị xóa mà ẩn trong "nhóm". Đối tượng có thể được lấy và sử dụng lại từ nó:

Một thực thể sẽ chịu trách nhiệm tạo, tái sử dụng và hủy một đối tượng — chúng tôi sẽ gọi nó là

 ObjectPool
. Chúng ta có thể làm cho nó trông như thế này để làm việc với một quả cầu lửa:

 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 }

Các

 _freeFireBalls
danh sách xuất hiện trong mã này. Chúng tôi sẽ lưu trữ những quả cầu lửa được tạo ra đã hoàn thành công việc của chúng ở đó. Kẻ thù bây giờ sẽ trông như thế này:

 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 }

Gốc rễ vấn đề của chúng ta: làm thế nào chúng ta có thể đưa quả cầu lửa trở lại hồ bơi? Chúng ta không thể dựa vào kẻ thù; anh ta không biết khi nào quả cầu lửa sẽ bị phá hủy. Chúng tôi cũng không muốn cung cấp kiến thức quả cầu lửa về

 ObjectPool
, vì nó sẽ tạo ra những kết nối không cần thiết.

Tôi hy vọng bạn đã nhận thấy rằng tôi đã thực hiện

 ReturnFireBall
phương pháp riêng tư. Do đó, chúng tôi sẽ sử dụng một trong các mẫu C# cơ bản - trình quan sát và triển khai của nó - các sự kiện . Quả cầu lửa sẽ trông như thế này bây giờ:

 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
sẽ đăng ký
 Destroyed
sự kiện sau khi chuyển đối tượng ra thế giới:

 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 }

Nó vẫn còn để treo Nhóm đối tượng trên đối tượng với

 Enemy
thành phần - xin chúc mừng! Bộ thu gom rác sẽ nghỉ ngơi cho đến khi bạn chuyển sang cảnh khác hoặc đóng trò chơi. Chúng tôi có ObjectPool trong quá trình triển khai cơ bản, nhưng chúng tôi có thể cải thiện nó.

Giao diện và khái quát: kết hợp hoàn hảo với ObjectPool

Các

 FireBall
không phải là loại đối tượng duy nhất chúng ta sẽ chạy qua nhóm đối tượng. Bạn phải viết riêng
 ObjectPool
cho mỗi để làm việc với các loại khác. Điều này sẽ mở rộng cơ sở mã và làm cho mã khó đọc hơn. Vì vậy, hãy sử dụng thuốc bắt chước và thuốc generic.

Mỗi đối tượng chúng ta chạy qua

 ObjectPool
phải được ràng buộc với một loại cụ thể. Chúng phải được xử lý độc lập với việc triển khai cụ thể. Giữ cấu trúc phân cấp thừa kế cơ bản của các đối tượng mà nhóm sẽ xử lý là điều cần thiết. Họ sẽ kế thừa từ
 MonoBehaviour
, ít nhất. Hãy sử dụng giao diện IPoolable:

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

Chúng tôi kế thừa

 FireBall
từ nó:

 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 }

Nhiệm vụ cuối cùng là dạy

 ObjectPool
để làm việc với bất kỳ đối tượng. IPoolable sẽ không đủ vì Instantiate chỉ có thể tạo một bản sao của đối tượng với
 gameObject
tài sản. Chúng tôi sẽ sử dụng
 Component
; mỗi đối tượng như vậy kế thừa từ lớp này.
 MonoBehaviour
cũng được kế thừa từ nó. Kết quả là, chúng ta sẽ nhận được như sau
 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 }

Bây giờ chúng ta có thể tạo một

 ObjectPool
cho bất kỳ đối tượng nào đáp ứng các điều kiện kế thừa từ
 Component
và IPoolable. tôi đã gỡ bỏ
 ObjectPool
thừa kế từ
 MonoBehaviour
, do đó giảm số lượng các thành phần chúng ta sẽ treo trên các đối tượng.

Có một vấn đề cần được giải quyết. Một số kẻ thù có thể ở trên sân khấu cùng một lúc và mỗi kẻ trong số chúng tạo ra những quả cầu lửa giống nhau. Sẽ thật tuyệt nếu tất cả họ đều trải qua như nhau

 ObjectPool
— không bao giờ có thể tiết kiệm quá nhiều tài nguyên! Có một lớp yêu cầu một loại đối tượng cụ thể cũng khá thuận tiện. Một lớp như vậy sẽ đảm nhận việc tạo, kiểm soát và xử lý cuối cùng của đối tượng. Tùy chọn này là lựa chọn tốt nhất vì quản lý mọi
 ObjectPool
cá nhân trong dự án là một thách thức.

Để hoàn thành các yêu cầu cơ bản của

 ObjectPool
, chúng ta cần tạo một số đối tượng đã đặt khi tải cảnh. Để tránh bất kỳ sự cố tiêu thụ tài nguyên nào, chúng tôi sẽ bao gồm một cửa sổ tải. Hãy bắt đầu thực hiện điều này ngay bây giờ.

Chúng tôi sẽ tạo một thực thể bổ sung

 PoolTask
trong việc thực hiện cuối cùng của
 ObjectPool
. Lớp này sẽ kiểm soát công việc với các đối tượng được tạo từ một prefab:

 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 sẽ có các tính năng bổ sung:

1. Theo dõi các đối tượng chúng tôi đã phát hành vào thế giới để nếu cần, chúng có thể bị phá hủy hoặc trả lại nhóm tại một thời điểm cụ thể;

2. Tạo ra một số lượng đối tượng tự do được xác định trước.

Cuối cùng, hãy tạo một ObjectPool đáp ứng mọi nhu cầu của chúng ta và hoàn toàn kiểm soát và tạo đối tượng:

 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 }

Việc sử dụng Singleton có thể khiến bạn chú ý — đây chỉ là một ví dụ. Bạn có thể tùy chỉnh cách sử dụng trong dự án của mình — nó có thể đang chạy

 ObjectPool
thông qua các nhà xây dựng hoặc tiêm thông qua Zenject.

Chúng tôi có phiên bản cuối cùng của chúng tôi

 Cast
phương pháp trong
 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 }

Chúng tôi nhận được một đối tượng thuộc loại chúng tôi cần xử lý ngay lập tức vì sử dụng chung.

 ObjectPool
sẽ nhóm các nhiệm vụ nội bộ theo nhà lắp ghép — nếu có một số nhiệm vụ với
 FireBall
thành phần, nhóm sẽ xử lý chúng một cách chính xác và cung cấp cho bạn thành phần phù hợp. Cách tiếp cận này sẽ giúp tạo ra bất kỳ đối tượng nào cho cảnh trò chơi.

Tuy nhiên, hãy cẩn thận khi làm việc với các thành phần giao diện người dùng: khi di chuyển một đối tượng giữa các biến đổi cha với các biến đổi khác

 localScale
, các
 localScale
của chính đối tượng sẽ thay đổi. Nếu bạn có giao diện người dùng thích ứng trong dự án của mình, các biến đổi với thành phần canvas sẽ thay đổi
 localScale
tùy thuộc vào phần mở rộng. Tôi khuyên bạn nên thực hiện thao tác đơn giản này:

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

Bạn có thể sử dụng 3 tập lệnh trong các tùy chọn khác:

 ObjectPool
,
 PoolTask
, Và
 IPoolable
. Vì vậy, vui lòng thêm chúng vào dự án của bạn và sử dụng mẫu Nhóm đối tượng cho 100%!