Реалізація 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 під капотом та підтримує callback'и 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) обов'язково — саме там різниця найпомітніша.
Терміни: два-п'ять робочих днів залежно від кількості типів об'єктів та архітектури існуючого коду.







