Почему мобильная игра не держит 60 FPS?
На Samsung Galaxy A52 (Adreno 618) игра выдаёт 28–32 FPS при целевых 60. На Xiaomi Redmi Note 11 с Helio G96 и Mali-G57 — стабильные 55–60 FPS. Разная производительность на похожих по цене устройствах — типичная ситуация в мобильном геймдеве. Причина почти всегда в том, что оптимизация проводилась на флагмане, а mid-range GPU с другой архитектурой даёт совсем другую картину. Наша оптимизация рендеринга мобильной игры начинается с анализа bottleneck на конкретных чипах. Мы помогли заказчикам сократить затраты на GPU вдвое, избежав дорогих переделок.
Мы специализируемся на кросс-платформенной оптимизации: работаем с Unity, Unreal Engine и собственных движках. За плечами — более 15 проектов, где мы доводили FPS до стабильных 60 на целевых устройствах. В результате оптимизации рендеринга мобильной игры мы добивались прироста в 20–30% без потери качества. Ключ к стабильной производительности — понимание архитектуры мобильных GPU. В отличие от настольных, они используют tile-based rendering, что накладывает особые требования к шейдерам и батчингу.
Как определить bottleneck на мобильном GPU?
Прежде чем оптимизировать — понять что является bottleneck. В Unity: Frame Debugger + Profiler. Включаем Profile GPU в Android Player Settings и смотрим Profiler → GPU. Если GPU-время на кадр близко к 16ms (60 FPS) а CPU-время значительно меньше — GPU-bound. Если наоборот — CPU-bound.
На Unreal Engine: stat GPU в консоли, ProfileGPU команда, RenderDoc для захвата кадра. r.ScreenPercentage 50 — быстрый тест: если FPS резко вырос при снижении разрешения вдвое — точно GPU-bound. Команды stat unit, stat drawcalls, r.ShowFlag.Rendering 1 дают breakdown по CPU/GPU и количеству draw calls.
Почему draw calls критичны для mid-range устройств?
На мобильных GPU draw call overhead выше чем на консолях/PC. 500+ draw calls в кадре — красная зона для mid-range Android. Каждый уникальный материал = отдельный draw call. Каждый MeshRenderer с уникальным материалом — ещё один.
Static batching (Unity): объекты с одинаковым материалом объединяются в один mesh. Требование — одинаковый Material asset (не просто одинаковые настройки). Mark as Static в Inspector. Работает автоматически при сборке.
GPU Instancing: для повторяющихся объектов (трава, деревья, враги одного типа):
// Материал должен поддерживать instancing
material.enableInstancing = true;
// Рисуем 1000 экземпляров одним draw call
Graphics.DrawMeshInstanced(mesh, 0, material, matrices, 1000);
SRP Batcher (Unity URP/HDRP): автоматически батчит объекты с разными материалами но одинаковым шейдером. Включается в URP Asset → SRP Batcher = enabled. Самый простой способ сократить draw calls без ручного batching.
Как мы оптимизируем рендеринг
Шейдеры под tile-based GPU
Мобильные GPU (Adreno, Mali, PowerVR, Apple) используют Tile-Based Immediate Mode Rendering. Экран делится на тайлы, каждый рендерится целиком в быстром on-chip памяти. Это означает:
- Framebuffer fetch — чтение из текущего framebuffer внутри тайла — практически бесплатно. Используем для deferred lighting:
gl_LastFragData в GLSL (GLES extension EXT_shader_framebuffer_fetch).
- Depth pre-pass на мобиле — часто лишний overhead, TBIMR и так эффективно работает с depth test внутри тайла.
- Discard в фрагментном шейдере (alpha-test, clip) — на tile-based GPU убивает ранний depth test для целого тайла. Заменяем alpha-blend или alpha-to-coverage там где возможно.
Precision qualifiers в GLSL/Metal
// МЕДЛЕННО — highp везде по умолчанию
uniform highp mat4 ModelMatrix;
varying highp vec2 TexCoord;
// БЫСТРО — минимально необходимая точность
uniform highp mat4 ModelMatrix; // матрицы — highp обязательно
varying mediump vec2 TexCoord; // UV-координаты — mediump достаточно
varying lowp vec4 VertexColor; // цвет — lowp
На Mali GPU переход с highp на mediump для texture samplers — от 10 до 25% прирост производительности фрагментного шейдера.
ALU vs Texture Fetch
На большинстве мобильных GPU texture fetch дешевле чем тяжёлые ALU-вычисления (sin, pow, sqrt). Предварительно запечённые lookup таблицы в текстуре быстрее чем вычисление в шейдере:
// Медленно: вычисляем fresnel в шейдере
float fresnel = pow(1.0 - dot(viewDir, normal), 5.0);
// Быстро: lookup texture
float fresnel = texture2D(fresnelLUT, vec2(dot(viewDir, normal), roughness)).r;
Совет по профилированию на Mali
Для Mali GPU используйте инструмент Streamline Performance Analyzer (ARM DS-5) или AGI (Android GPU Inspector). Обращайте внимание на контроллеры: Fragment ALU cycles, Fragment texture cycles, и Memory bandwidth. Это поможет точно определить, узкое место — ALU, текстуры или пропускная способность.
Dynamic resolution и adaptive performance
Рендерить в нативном разрешении iPhone 15 Pro (2556×1179) — избыточно для мобильной игры с интенсивным рендерингом. Стандартная практика — render scale 0.7–0.85 от нативного с последующим upscale.
Unity URP Dynamic Resolution:
ScalableBufferManager.ResizeBuffers(0.75f, 0.75f); // 75% от нативного
Unreal Mobile Super Resolution (MSR) — встроенный temporal upscaler для мобильных платформ с Unreal 5.1+. r.Mobile.TemporalAA 1. Даёт качество близкое к нативному при значительно меньшей GPU-нагрузке.
Adaptive Performance (Samsung Game SDK + Unity): автоматически снижает нагрузку при перегреве. Доступны метрики теплового статуса и производительности через UnityEngine.AdaptivePerformance.
Кейс: 40 → 58 FPS на Adreno 618
Из нашей практики: runner-игра на Galaxy A52 — 40 FPS. Профилирование через AGI показало: Fragment ALU 87%, fragment bandwidth — перегружен. Три изменения:
- Шейдер воды: заменили
pow(fresnel, 5.0) на LUT-текстуру → -8ms GPU
-
highp → mediump для всех texture samplers → -4ms GPU
- Dynamic resolution 0.80 вместо нативного → -6ms GPU
Итого: с 40 до 58 FPS без изменения визуального стиля. На Pro-устройствах — без изменений, они держали 60 FPS с запасом.
| Метрика |
До |
После |
Снижение |
| FPS |
40 |
58 |
+18 (45%) |
| GPU time (ms) |
25 |
16.5 |
34% |
| Draw calls |
780 |
210 |
73% |
Процесс работы
- Аналитика: сбор логов, профилирование на целевых устройствах, определение bottleneck.
- Проектирование: выбор методов оптимизации (batching, шейдеры, dynamic resolution).
- Реализация: внедрение изменений в код и ассеты.
- Тестирование: прогон на 5+ различных устройствах, сравнение FPS и качества.
- Деплой: подготовка релизной сборки, настройка Adaptive Performance.
Сроки: профилирование и анализ занимают 2–3 дня. Оптимизация шейдеров, batching, dynamic resolution — от 1 до 3 недель в зависимости от состояния проекта.
Что вы получите
- Подробный отчёт с анализом рендеринга и узкими местами
- Оптимизированные шейдеры с минимальной точностью (mediump/lowp)
- Настроенный automatic batching (static batching, SRP Batcher, instancing)
- Конфигурацию Dynamic Resolution с учётом целевых устройств
- Инструкцию по сборке и деплою
- Гарантию стабильности результата на согласованном пуле устройств
| Bottleneck |
Симптомы |
Инструменты диагностики |
Типичные решения |
| CPU-bound |
CPU time > 16ms, много физики, скриптов |
Unity Profiler, Unreal stat unit |
Оптимизация кода, сжатие ассетов |
| GPU-bound |
GPU time > 16ms, высокий fill rate |
GPU Profiler, RenderDoc |
Снижение разрешения, шейдеры, LOD |
| Draw calls |
>500 draw calls, высокая batch count |
Frame Debugger, stat drawcalls |
Static batching, GPU instancing, SRP |
| Bandwidth |
Высокое использование memory bandwidth |
GPU counters (Mali, Adreno) |
Сжатие текстур, mipmaps, alpha |
Свяжитесь с нами для консультации по вашему проекту. Закажите аудит рендеринга и получите план оптимизации под ваше целевое железо. Узнайте, как сэкономить ресурсы на ранних этапах разработки.
Оптимизация мобильных приложений: cold start, память, батарея, FPS, профилирование
Приложение с временем холодного старта 4+ секунды теряет пользователей ещё до первого экрана. Android Vitals в Google Play Console прямо влияют на ранжирование в поиске: приложения с плохими метриками получают меньший organic reach. Apple аналогично мониторит crash rate и время запуска через MetricKit. Оптимизация — это не «сделать быстрее», а понять где именно теряется время и что с этим делать.
Cold Start: где убивается время до первого кадра
Cold start — запуск приложения, когда процесс не существует в памяти. На Android это время от нажатия на иконку до Activity.onWindowFocusChanged(hasFocus = true). На iOS — от tap до viewDidAppear первого экрана.
Android: main thread перегружен при инициализации
Application.onCreate() — главный враг быстрого старта на Android. Разработчики инициализируют здесь всё подряд: Firebase, Analytics, базу данных, HTTP-клиент, DI-контейнер. Каждый SDK добавляет 20–200 мс на main thread.
Инструмент для диагностики: Android Studio Profiler → App Startup. Показывает граф инициализации с временем каждого компонента. Альтернатива — Tracing.beginSection("MyInitTag") в коде + systrace.
Решение: App Startup Library (Jetpack) с явным графом зависимостей инициализаторов. Компоненты, нужные только в конкретных сценариях, инициализируются лениво — by lazy {} или initializer с флагом lazyInit. Firebase Analytics, например, не нужен до первого пользовательского действия — его инициализацию можно отложить.
ContentProvider-ы, автоматически добавляемые SDK через AndroidManifest merge, тоже запускаются при старте. tools:node="remove" в манифесте позволяет отключить конкретный провайдер и инициализировать SDK вручную в нужный момент.
Ещё одна грабля: Room.databaseBuilder().build() на main thread. Это синхронная операция создания/открытия файла БД — на медленных устройствах занимает 50–300 мс. Переносим в coroutine с Dispatchers.IO, в ViewModel через viewModelScope.launch.
iOS: Dyld linking и +load
На iOS cold start делится на pre-main (до вызова main()) и post-main. Pre-main — время загрузки dylib, rebase/binding, Objective-C runtime initialization и выполнения +load методов.
Xcode Instruments → App Launch template показывает время pre-main и post-main раздельно. DYLD_PRINT_STATISTICS=1 в схеме запуска выводит детальное время загрузки в консоль.
Что убивает pre-main:
- Много динамических библиотек (каждая dylib — накладные расходы на линковку). CocoaPods добавляет отдельную dylib на каждый pod. Решение: Swift Package Manager со статической линковкой (
type: .static) или use_frameworks! :linkage => :static в CocoaPods.
-
+load методы в Objective-C — выполняются синхронно при загрузке класса, до main(). Сторонние SDK могут злоупотреблять этим. +initialize — ленивый аналог, вызывается при первом обращении к классу.
Post-main — application(_:didFinishLaunchingWithOptions:). Та же история что на Android: синхронная инициализация всего. lazy var для сервисов, которые не нужны немедленно. SwiftUI @StateObject инициализирует объект только когда View появляется — это уже встроенная ленивость.
Целевые метрики (App Store рекомендации): cold start < 400 мс для простых приложений, < 2 секунды для сложных. Warm start (процесс в памяти, но Activity/Scene пересоздаётся) — < 1 секунда.
Память: утечки, OOM, excessive pressure
Утечка памяти в iOS — retention cycle: объект A держит ссылку на B, B держит на A, ни один не освобождается. Классика: Timer с self в замыкании без [weak self]. Timer удерживает замыкание, замыкание удерживает self (ViewController), ViewController не освобождается при закрытии. Instruments → Leaks или Memory Graph Debugger в Xcode — находит живые объекты, которых не должно быть.
На Android garbage collector управляет памятью, но утечки всё равно случаются. Activity или Fragment, удерживаемые через статическую ссылку, singleton, или Handler/Runnable после onDestroy — классика. LeakCanary — обязательный инструмент в debug-сборке. Добавляется одной зависимостью debugImplementation "com.squareup.leakcanary:leakcanary-android" и автоматически детектирует утечки с полным стектрейсом.
OutOfMemoryError чаще всего происходит из-за загрузки изображений. Bitmap в памяти занимает ширина × высота × 4 байта. Изображение 4000×3000 px — 48 МБ в памяти, независимо от размера файла на диске. Glide / Coil правильно обрабатывают это: загружают с даунсемплингом под размер View, кешируют в LRU-кеш. Загружать в ImageView без Glide/Coil через BitmapFactory.decodeFile — путь к OOM на устройствах с 2 ГБ RAM.
На Flutter Dart VM имеет свой GC, но нативные ресурсы (изображения, текстуры) не управляются Dart GC. Image.network кеширует изображения в памяти без автоматического освобождения при выходе из дерева виджетов — при длинных списках с картинками используем cached_network_image с правильным memCacheWidth/memCacheHeight.
FPS и UI Performance
60 FPS — 16.67 мс на кадр. 120 FPS (ProMotion) — 8.33 мс. Всё что занимает больше на main thread — джанк.
Типичные причины просадок FPS:
На iOS: синхронная декодировка изображений в cellForRowAt. Когда ячейка таблицы появляется, UIImage(contentsOfFile:) декодирует JPEG/PNG на main thread — видно как заторможенный скролл на длинных списках. Решение: UIImage.preparingForDisplay() (iOS 15+) или ImageIO с kCGImageSourceCreateThumbnailWithTransform в background queue, результат через DispatchQueue.main.async.
На Android: RecyclerView.Adapter.onBindViewHolder с синхронными операциями. Базы данных, файловая система, синхронные сетевые запросы на main thread — StrictMode.ThreadPolicy с detectAll().penaltyLog() в debug-сборке покажет все нарушения.
На Flutter: build() метод вызывается часто, он должен быть дешёвым. setState() на верхнем виджете пересобирует всё дерево. const конструкторы, RepaintBoundary, разбиение на мелкие виджеты с локальным стейтом — основные инструменты. Flutter DevTools → Performance показывает janky frames (красные) с причинами.
Профилирование Compose: Recomposition Highlighter и трассировка через Trace.beginSection в @Composable. remember для дорогих вычислений, derivedStateOf для computed values, LazyColumn вместо Column + forEach для длинных списков.
Батарея: Wake locks, WorkManager, сетевые запросы
Приложение в топе по расходу батареи — пользователь видит это в настройках и удаляет. Android Battery Historian (из ADB bug report) показывает детальный timeline: wake locks, wakeups, network activity, sensor usage.
Основные потребители энергии:
- Постоянный GPS (разбираем в maps-geo)
- Polling сети каждые N секунд вместо push
- Holding wake lock дольше необходимого
- Excessive
AlarmManager wakeups
WorkManager с Constraints — правильный способ планировать фоновые задачи: setRequiredNetworkType, setRequiresBatteryNotLow, setRequiresCharging. ОС батчирует задачи и выполняет в удобное время.
На iOS BGTaskScheduler с BGProcessingTaskRequest (для тяжёлых задач при зарядке) и BGAppRefreshTaskRequest (для лёгких обновлений) — система решает когда выполнять, разработчик только регистрирует и реализует логику.
Батчинг сетевых запросов: вместо 10 отдельных запросов в течение минуты — один батч запрос. Меньше радио-активностей (LTE radio потребляет много при инициализации соединения), меньше wakeups.
Инструменты профилирования
| Платформа |
Инструмент |
Что показывает |
| iOS |
Xcode Instruments (Time Profiler) |
CPU, call stack, горячие методы |
| iOS |
Allocations |
Живые объекты, пики памяти |
| iOS |
Leaks |
Retention cycles |
| iOS |
MetricKit |
Производственные метрики (crash rate, hang rate, launch time) |
| Android |
Android Profiler |
CPU, Memory, Network, Energy |
| Android |
Systrace / Perfetto |
System-level трейсы |
| Android |
LeakCanary |
Утечки памяти |
| Android |
Battery Historian |
Энергопотребление |
| Flutter |
Flutter DevTools |
Recomposition, frame rendering, memory |
| Flutter |
Dart Observatory |
Dart VM profiling |
MetricKit на iOS — особенно ценен: реальные данные с устройств пользователей, а не симулятора. MXMetricManager получает агрегированные метрики раз в сутки: MXAppLaunchMetric, MXHangDiagnostic, MXCPUExceptionDiagnostic. Диагностики по hang и CPU-exceptions содержат стектрейс с реального устройства — золото для диагностики production-проблем.
Процесс оптимизации
Начинаем с измерения, не с предположений. Инструменты выше дают цифры: конкретное время cold start, конкретный объём памяти, конкретные кадры с просадкой. Потом — приоритизация по impact: что больше всего влияет на пользовательский опыт именно в этом приложении.
Аудит производительности существующего приложения: 3–5 рабочих дней. Реализация оптимизаций — от недели до двух месяцев в зависимости от запущенности проблем и архитектуры кода.