Реализация Object Pooling для мобильной игры
Каждый раз, когда в игре спавнится пуля, монета или частица взрыва — Unity вызывает Instantiate, а при уничтожении — Destroy. На десктопе это незаметно. На мобильном GPU с 4 ГБ общей памяти и Garbage Collector, который работает в том же потоке, что и рендер, это выражается в характерном фризе на 30–80 мс в момент массового спавна врагов или взрыва с партиклами.
Где именно ломается без пула
Проблема не в самом выделении памяти — она в том, что GC в Mono (а Unity на мобильных до сих пор часто работает именно на нём) останавливает мир для сборки. Instantiate создаёт управляемые объекты, которые рано или поздно попадают под сборку. При частом спавне/уничтожении объектов сборка срабатывает именно в самые нагруженные моменты — взрыв, большое количество врагов на экране, переход между уровнями.
Типичная симптоматика: Profiler в Unity показывает периодические пики GC.Collect по 40–120 мс на устройствах уровня Samsung Galaxy A53 или iPhone 12. На флагманах эти пики меньше, но на целевых устройствах с Android 10–12 и 3–4 ГБ RAM они воспроизводятся стабильно.
Второй аспект — фрагментация памяти. Частые аллокации и освобождения приводят к тому, что у приложения много мелких «дырок» в куче, и при очередной крупной аллокации система запрашивает новый блок у ОС. На Android это иногда триггерит LMK (Low Memory Killer) раньше, чем ожидается.
Как реализуем пул
Базовый паттерн — ObjectPool<T> с двумя стеками: active и inactive. При запросе объекта берём из inactive, активируем (SetActive(true)), добавляем в active. При возврате — наоборот. Никакого Instantiate в hot path.
Для Unity начиная с версии 2021 есть встроенный UnityEngine.Pool.ObjectPool<T>, который избавляет от велосипеда. Он thread-safe через ConcurrentStack под капотом и поддерживает колбэки actionOnGet / actionOnRelease / actionOnDestroy. Пишем тонкую обёртку над ним для каждого типа пула — пули, враги, частицы — с явным maxSize чтобы пул не рос бесконтрольно.
var pool = new ObjectPool<Bullet>(
createFunc: () => Instantiate(bulletPrefab),
actionOnGet: b => b.gameObject.SetActive(true),
actionOnRelease: b => b.gameObject.SetActive(false),
actionOnDestroy: b => Destroy(b.gameObject),
maxSize: 100
);
Для частиц (ParticleSystem) — отдельная логика. Возвращать партикл в пул нужно не по таймеру, а по событию OnParticleSystemStopped. Иначе возврат произойдёт пока ещё летят частицы, и визуально эффект обрежется.
Прогрев пула (warm-up)
Пул бесполезен, если он начинает заполняться прямо во время геймплея. Прогреваем на экране загрузки: создаём нужное количество объектов, немедленно возвращаем в пул. Это переносит аллокации туда, где фриз незаметен пользователю.
Количество объектов для прогрева — не «с запасом», а на основе анализа максимального одновременного количества объектов на самом тяжёлом уровне. Если в пике на экране 60 пуль — прогреваем 80.
Пулы на Addressables
Если проект использует Addressables, пул усложняется: Instantiate работает через Addressables.InstantiateAsync, результат — AsyncOperationHandle. При возврате в пул нельзя просто вызвать Destroy — нужно Addressables.ReleaseInstance. Пишем пул-менеджер с учётом этого, иначе получим утечку нативной памяти в IL2CPP-билде.
Профилирование до и после
До внедрения пула: в Unity Profiler видим GC.Alloc на 2–8 КБ при каждом спавне пули + периодические GC.Collect на 50+ мс. После: в hot path нет аллокаций вообще, GC.Collect срабатывает только при переходах между сценами.
На Pixel 6a с 20 одновременными врагами и активным пулом — стабильные 60 FPS против 45–55 без пула при тех же условиях.
Процесс
Аудит существующего кода → выявление горячих путей с Instantiate/Destroy → реализация пулов с прогревом → интеграция в SpawnManager → профилирование в Editor Profiler и на девайсе через Android GPU Inspector или Xcode Instruments. Тестирование на Low-end устройстве (что-то уровня Samsung Galaxy A32) обязательно — именно там разница наиболее заметна.
Сроки: два-пять рабочих дней в зависимости от количества типов объектов и архитектуры существующего кода.







