главная / блог / производительность
Опубликовано: 16 апреля 2026 г. 10 мин чтения производительность
Оптимизация мобильных игр: draw calls, overdraw и +17 FPS

Оптимизация мобильных игр: draw calls, overdraw и +17 FPS

Сцена tower defense держала всего 41 FPS на среднем Android. Рассказываю, как замеры draw calls, материалов и overdraw подняли ту же сцену до 58 FPS.

Сцена tower defense, застрявшая на 41 FPS

Прошлой зимой мне достался финальный проход по оптимизации проекта в жанре tower defense. На Redmi Note 10 — Snapdragon 678, Adreno 612 — к двенадцатой волне время кадра поднималось до 24,3 мс, в среднем 41 FPS. Первое предположение было самым обычным: врагов на экране слишком много, значит, дорого обходятся ИИ и физика. Два дня я профилировал планировщик задач и не нашёл ничего: игровой поток укладывался в 6,1 мс.

Картина прояснилась, когда Unity Profiler показал 14,8 мс на render thread и 26 мс на GPU. Frame Debugger насчитал больше 780 draw calls, и около 400 из них приходилось на кольца дальности башен, числа урона, decals на земле и частицы дыма. Почти весь экран был закрыт наложенными друг на друга прозрачными слоями, и каждый заново закрашивал одну и ту же область. Сокращение числа врагов вдвое вернуло всего 1,2 мс — виновник был не здесь.

Настоящая ошибка сидела в моём собственном определении оптимизации мобильных игр: годами я понимал её как «уменьшить количество полигонов». Всего в сцене было 210 тысяч треугольников, и Adreno 612 рисовала эту геометрию без малейшего труда. Нагрузка шла с двух разных сторон: затраты CPU на подготовку каждого draw call и объём данных, записываемых в основную память на каждый пиксель. Дальше — запись того, как я развёл эти две стороны и сколько миллисекунд принесло каждое изменение.

Когда какой batching действительно включается

Механизмов три, и делают они разную работу. SRP Batcher не уменьшает количество draw calls: он держит константы материалов в постоянном буфере GPU для вызовов, использующих один и тот же вариант шейдера, и тем самым удешевляет подготовку на стороне CPU. То есть 780 вызовов так и остаются 780, но каждый становится заметно легче. Важно число вариантов шейдера, а не материалов; из-за того что я это упустил, я долго объединял совсем не то.

Реально сокращает счёт именно GPU instancing: один и тот же mesh, один материал, до 1023 экземпляров за вызов. Ловушка вот в чём: в URP, если шейдер совместим с SRP Batcher, путь с instancing не запускается вообще, потому что приоритет остаётся за SRP Batcher. Я заметил это, только когда нарисовал основания башен вручную через Graphics.DrawMeshInstanced: до этого я видел в Frame Debugger строки «SRP Batch» и считал, что instancing работает.

Static batching на этапе сборки склеивает меши в один большой vertex buffer. Выигрыш настоящий, но за него платят: в нашей сцене это 38 МБ дополнительной памяти под меши и потеря гранулярности отсечения, ведь склеенная группа либо рисуется целиком, либо не рисуется вовсе. Я оставил его включённым только для кусков земли, которые никогда не двигаются и всё равно всегда попадают в кадр вместе. Отключение для деревьев и камней снизило и память, и число реально отправляемых треугольников.

Есть ещё тихий убийца батчинга — MaterialPropertyBlock. Мы вешали его на каждый renderer, чтобы подкрашивать уровни башен, и одного этого хватало, чтобы такие объекты выпали из совместимости с SRP Batcher. После переноса вариаций цвета в данные экземпляра и в vertex color render thread опустился с 14,8 мс до 11,0 мс — без единой изменённой строки шейдера.

BatchingSetup.cs
using UnityEngine; using UnityEngine.Rendering; // Tower bases share one atlas material, so they can be submitted as one // instanced call. URP prefers the SRP Batcher over instancing here. public sealed class BatchingSetup : MonoBehaviour { private const int MaxPerBatch = 1023; [SerializeField] private Mesh _baseMesh; [SerializeField] private Material _atlasMaterial; [SerializeField] private Transform[] _slots; private readonly Matrix4x4[] _matrices = new Matrix4x4[MaxPerBatch]; private int _count; private void Awake() { _count = Mathf.Min(_slots.Length, MaxPerBatch); for (int i = 0; i < _count; i++) _matrices[i] = _slots[i].localToWorldMatrix; } private void Update() { // One draw call for up to 1023 bases instead of one per renderer. Graphics.DrawMeshInstanced(_baseMesh, 0, _atlasMaterial, _matrices, _count, null, ShadowCastingMode.Off, receiveShadows: false); } }
csharpBatchingSetup.cs

Количество материалов и раскладка texture atlas

На число draw calls на самом деле влияло количество материалов. В сцене их было 34, и большинство отличалось лишь другой текстурой 512x512. Я собрал их в три texture atlas по 2048x2048: окружение, башни, а также эффекты вместе с UI. Чем больше объектов делят один материал, тем шире пул, доступный для instancing и батчинга.

Сборка атласа менее механическая, чем кажется. Переупаковывая UV, я вынужден был исключить каждый mesh, который опирается на тайлинг, потому что режим wrap mode repeat внутри атласа не работает: подмешиваются пиксели соседнего острова. Для плит пола я оставил отдельный материал и рисовал их через instancing. Каждому острову я дал 8 пикселей padding, чтобы не текли мипмапы: при 4 пикселях на расстоянии появлялись тонкие цветные швы.

По сжатию я перешёл с ETC2 на ASTC 6x6 — при том же бюджете памяти заметно чище, особенно на градиентах внутри атласа. После атласирования материалов стало 6 вместо 34, а draw calls — 210 вместо 780. Render thread упал с 11,0 мс до 7,4 мс. Это был самый крупный отдельный выигрыш за весь проход, и ни строчки шейдерного кода я для него не написал.

Во что обходится overdraw от прозрачных слоёв

Overdraw — это количество записей в один и тот же пиксель за кадр. У прозрачных объектов ZWrite выключен, поэтому тест глубины никого не отбрасывает: то, что сзади, шейдится ровно так же, как то, что спереди. В режиме overdraw в Rendering Debugger центр сцены показывал 11x — некоторые пиксели закрашивались одиннадцать раз за кадр. Большая часть тех 26 мс на GPU уходила именно сюда.

Я пересчитал слои по одному: кольцо дальности, decal на земле, дым, искры, вспышка урона и поверх всего полноэкранный quad виньетки. Виньетку я перенёс в финальный шейдер пост-обработки, а отдельный quad удалил — сам по себе это целый полноэкранный слой в 1080p. Кольцо дальности стал рисовать только тогда, когда башня выделена. Частицы дыма сократил с 240 до 90, увеличив каждую: та же визуальная плотность при трети затрат на заливку.

Не считайте alpha test решением. Использование clip() в материалах листвы и заборов ломает early-z и отсечение скрытых поверхностей на tile-based GPU; в обоих замеренных случаях возврат к alpha blend дал 0,6 мс. По той же причине сортировка прозрачных объектов спереди назад не даёт ничего, ведь глубину не пишет ни один из них. Выигрыш приходит только от уменьшения числа слоёв и площади экрана, которую они занимают.

На tile-based GPU настоящий предел — пропускная способность

Мобильные GPU делят кадр на тайлы, обрабатывают каждый тайл в маленькой быстрой памяти на кристалле и выписывают результат в основную память. Дорого обходится не шейдерная математика, а именно этот трафик записи и чтения. В 1080p одна цель RGBA32 — это около 8 МБ на кадр, или 500 МБ/с при 60 FPS, и это всего один проход. Когда устройство греется, первой урезается частота памяти, так что пропускная способность — ещё и тепловая проблема.

Поэтому смена render target посреди кадра стоит дорого: каждое переключение заставляет выгружать тайловую память в основную. В нашей цепочке пост-обработки было три отдельных blit; объединение downsample для bloom с цветокоррекцией в один pass сняло 1,7 мс времени GPU. По той же причине передавать RenderBufferLoadAction.DontCare для целей, прежнее содержимое которых не важно, — бесплатный выигрыш: лишний load означает чтение всей цели из основной памяти обратно в тайлы.

Depth prepass на мобильных обычно бьёт по своим. Приём, который срезает overdraw на десктопе, здесь стоил нам 0,9 мс, потому что геометрия обрабатывается дважды и на тайловой архитектуре появляется дополнительный трафик глубины. Собственное низкоразрешающее Z-отсечение Adreno и так делает похожую работу бесплатно. Попробовал, замерил, откатил — это как раз тот случай, когда десктопные рефлексы не переносятся.

Настоящим решением для прозрачных слоёв стал рендер частиц в половинном разрешении. Я рисовал частицы в render target вдвое меньшего размера и возвращал их в кадр через depth-aware upsample: число шейдящихся пикселей падает вчетверо, композит стоит 0,4 мс, чистый выигрыш — 3,1 мс. По краям есть лёгкая лесенка, но на низкочастотных эффектах вроде дыма и пыли она не видна. Резкие тонкие эффекты — искры и трассы пуль — остались в полном разрешении.

Измеренный результат на среднем Android-смартфоне

На той же 60-секундной записи двенадцатой волны и том же Redmi Note 10: время кадра снизилось с 24,3 мс до 16,4 мс, а средний показатель вырос с 41 FPS до 58 FPS. Draw calls упали с 780 до 190, материалы — с 34 до 6, пиковый замеренный overdraw — с 11x до 4x. За 15-минутную сессию температура батареи держалась на 39 °C вместо 44 °C, так что устройство ни разу не ушло в throttling, а падение FPS на последних пяти минутах исчезло.

Мне важно, как этот выигрыш распределён, потому что от этого зависит, с чего я начну в следующем проекте: около 40% дала работа с материалами и атласом, 35% — снижение overdraw вместе с частицами в половинном разрешении, 15% — правки по пропускной способности, остальное — возвращение совместимости с SRP Batcher. Сокращения полигонов в этом списке нет нигде. Я не тронул ни одного меша и оставил настройки LOD ровно такими, какими они были.

В своём проекте я бы держался такого порядка: сначала выпишите из Frame Debugger количество материалов и причины разрыва батчей, затем найдите в режиме overdraw три худших слоя, и только после этого беритесь за шейдер. Замеряйте каждый шаг на реальном устройстве, на одной и той же записанной сцене и при одинаковой температуре; у меня было немало правок, которые показывали 200 FPS в редакторе и теряли 0,2 мс на телефоне. И не смотрите на одну цифру: draw calls, overdraw и пропускная способность — разные ограничения, и починив одно, легко сломать другое.

← Все статьи