Как использовать пул объектов в Unity для спавна врагов без лишних Instantiate/Destroy

Пул врагов меняет жизненный цикл: экземпляр создаётся при необходимости, используется, возвращается и снова получает начальное состояние. Это сокращает повторное создание и уничтожение, когда объекты действительно часто появляются и исчезают. Но пул не гарантирует рост FPS в любой игре и не делает бесплатным хранение сотен неиспользуемых объектов.
Ниже — авторский пример для API Unity 6.0. Он сверён с документацией, но не запускался в редакторе Unity и не является результатом измеренного ускорения. Противник движется через Transform, без физического контроллера. Все экземпляры принадлежат GameObject спавнера и остаются его дочерними объектами.
Договоритесь, кто управляет жизненным циклом
Используем UnityEngine.Pool.ObjectPool<Enemy>. Get выдаёт свободный объект или вызывает createFunc, если свободных нет. Release возвращает объект; при переполнении хранимой части вызывается actionOnDestroy. Сам игровой код не должен параллельно уничтожать врага через Destroy или просто выключать его вместо возврата.
В примере состояние rented защищает от повторного возврата. Смерть, выход за границу и тайм-аут вызывают один Return. OnDisable не используется как автоматический возврат: отключение может иметь другую причину, а сам Release в нашей схеме как раз выключает GameObject.
Создайте компонент противника
Сохраните следующий код как Enemy.cs. В префабе Enemy отключите сам GameObject до запуска сцены. Instantiate сохраняет активное состояние оригинала; неактивный префаб позволяет связать владельца и подготовить параметры до активации экземпляра.
using UnityEngine;
using UnityEngine.Pool;
public sealed class Enemy : MonoBehaviour
{
private IObjectPool<Enemy> owner;
private bool rented;
private int hp;
private float remaining;
public void Attach(IObjectPool<Enemy> pool) => owner = pool;
public void Wake(Vector3 position)
{
transform.position = position;
transform.rotation = Quaternion.identity;
hp = 3;
remaining = 5f;
rented = true;
gameObject.SetActive(true);
}
public void Hit(int damage)
{
if (!rented || damage <= 0) return;
hp -= damage;
if (hp <= 0) Return();
}
private void Update()
{
if (!rented) return;
remaining -= Time.deltaTime;
transform.position += Vector3.left * 2f * Time.deltaTime;
if (remaining <= 0f || transform.position.x < -20f) Return();
}
public void Return()
{
if (!rented) return;
rented = false;
owner.Release(this);
}
public void Sleep()
{
rented = false;
StopAllCoroutines();
CancelInvoke();
gameObject.SetActive(false);
}
}
Wake сначала задаёт положение, поворот, здоровье и срок жизни, затем активирует объект. SetActive может вызвать OnEnable, поэтому нужное состояние должно быть готово заранее. Внешняя система урона вызывает Hit; повторный удар после возврата игнорируется.
Sleep явно отменяет отложенные вызовы и корутины этого компонента. Это не очищает автоматически подписки, эффекты или состояние других компонентов. Если ваш враг использует анимацию, след, физическое движение, цель или навигацию, добавьте соответствующий сброс в его контракт повторного использования.
Добавьте спавнер и настоящий прогрев
Создайте EnemySpawner.cs на активном отдельном GameObject и назначьте неактивный префаб в поле enemyPrefab. Здесь прогреваются 20 объектов, хранится до 40, а отдельное правило игры запрещает более 40 одновременно выданных врагов.
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Pool;
public sealed class EnemySpawner : MonoBehaviour
{
[SerializeField] private Enemy enemyPrefab;
private ObjectPool<Enemy> pool;
private void Awake()
{
pool = new ObjectPool<Enemy>(
createFunc: Create,
actionOnGet: null,
actionOnRelease: e => e.Sleep(),
actionOnDestroy: e => Destroy(e.gameObject),
collectionCheck: true,
defaultCapacity: 20,
maxSize: 40);
var warming = new List<Enemy>(20);
for (int i = 0; i < 20; i++) warming.Add(pool.Get());
foreach (Enemy enemy in warming) pool.Release(enemy);
}
private Enemy Create()
{
Enemy enemy = Instantiate(enemyPrefab, transform);
enemy.Attach(pool);
return enemy;
}
public bool TrySpawn(Vector3 position)
{
if (pool == null || pool.CountActive >= 40) return false;
Enemy enemy = pool.Get();
enemy.Wake(position);
return true;
}
private void OnDestroy() => pool?.Clear();
}
Вызывайте TrySpawn из своего игрового контроллера после инициализации, передавая точку появления. false означает, что спавн сейчас не выполнен. Это метод спавна, а не готовый таймер волн: периодичность и выбор точек остаются задачей игрового сценария.
Прогрев удерживает сразу все 20 полученных экземпляров, а затем возвращает их. Если сделать двадцать повторов Get → Release по одному, пул сможет выдавать один и тот же объект: двадцать разных врагов так не создаются.
Не путайте три разных ограничения
| Параметр | Значение в примере | Смысл |
|---|---|---|
| defaultCapacity | 20 | Стартовая вместимость внутренней структуры, не число созданных врагов |
| maxSize | 40 | Предел хранимых возвращённых экземпляров |
| Проверка CountActive | 40 | Авторский предел одновременно выданных врагов |
При двадцать первом одновременном спавне после прогрева свободный пул может исчерпаться и создать новый экземпляр. Это ожидаемое поведение. Если нужна иная политика, заранее прогрейте обоснованный объём либо откажитесь от спавна при нехватке свободных объектов.
collectionCheck помогает обнаруживать двойной Release, но документация конструктора указывает, что эти проверки выполняются в Editor, а не Player. Поэтому игровая защита rented нужна отдельно. ObjectPool также не предназначен для вызовов из фоновых потоков.
Проверьте сброс состояния и завершение сцены
Авторская последовательность проверки: выдать врага, нанести урон, вернуть, снова выдать и убедиться, что здоровье и срок жизни начались заново. Затем одновременно довести разные причины возврата до одного кадра: объект не должен оказаться в пуле дважды.
Проверьте не только видимость: прежняя цель, подписка на события или незавершённый эффект способны проявиться после повторного спавна. Деактивация GameObject отключает компоненты и останавливает связанные с ним корутины, но не заменяет продуманного прикладного сброса всех внешних связей.
Clear очищает ожидающие экземпляры пула, а не является универсальным поиском всех активных врагов. Учебная схема предполагает уничтожение всего GameObject спавнера: Unity уничтожает его дочерние объекты, включая активных противников. Если вы переносите врагов в другую иерархию или уничтожаете только компонент спавнера, нужен отдельный учёт активных экземпляров и иной порядок завершения.
Сравните результат в Profiler
Сначала снимите исходную сцену с тем же количеством врагов и теми же волнами. Затем сравните вариант с пулом: время CPU, вызовы Instantiate и Destroy, выделения GC.Alloc и поведение после заполнения. Период прогрева выделите отдельно от устойчивого игрового участка.
В CPU Usage Profiler изучайте кадры и иерархию вызовов, а не только средний FPS. Сравнение проводите в сопоставимой сборке и на подходящем устройстве; не смешивайте результаты разных условий. Если выделения остались, ищите их конкретные источники: пул не устраняет создание строк, коллекций и других данных игрового кода.
Размер пула выбирают по наблюдаемому пику и доступной памяти. Для редко создаваемого врага постоянный запас может оказаться неоправданным. Полезный результат — понятный жизненный цикл и измеренное изменение нагрузки, а не максимальное число заранее созданных объектов.




