Оптимизация 2d-приложений для мобильных устройств в Unity3d
Недавно наша студия завершила разработку большого обновления — Captain Antarctica: Endless Run — для устройств на iOs. Кропотливая работа над обновлением затронула производительность, которая оказалась очень низкой на слабых устройствах. Я боролся с этим целую неделю и добился как минимум 30 FPS, а также значительного сокращения размера приложения. Хочу рассказать, как я это сделал, ну и как делать не стоит.
Статья пригодится любым разработчикам на Unity (причем не только менеджерам проектов и техническим специалистам, но и просто программистам, художникам и дизайнерам), потому что она затрагивает как оптимизацию на Unity в целом, так и конкретно оптимизацию 2d-приложений для мобильных устройств.
Возможно все!
Начну с того, что каждый раз, когда я приступаю к оптимизации, я поначалу не верю, что можно что-то еще оптимизировать, особенно если проект уже прошел несколько циклов оптимизации до этого. Но просмотр официальной документации Unity, тем на форумах, статей в Интернете наводит меня на мысль о новых возможных улучшениях. Таким образом я веду специальный список, в котором записаны основные идеи по тому, что можно оптимизировать в проекте на Unity, постоянно обновляю его и первым делом обращаюсь к нему, когда речь заходит об оптимизации. Этими идеями я хочу с Вами поделиться. Надеюсь, статья поможет Вам сделать Ваш проект намного более шустрым.
Сразу обозначу, что разработка велась на Unity 3.5.6, целевая платформа — устройства Apple от iPhone 3GS и новее.
Базовые правила
Для начала приведу несколько правил, которыми я пользуюсь при разработке и оптимизации.
1. Не оптимизируйте заранее.
Это золотое правило должно быть знакомо всем, кто когда-либо занимался оптимизацией. Вспомним правило 80/20: 80% пользы получается от 20% работы.
Цифры довольно условны, но я имел ввиду вот что: наиболее вероятно, что большая часть оптимизаций, которые Вы собираетесь сделать на начальном этапе проекта, скорее всего вообще никак не повлияет на конечный проект в целом.
Однако, есть пара исключений из этого правила, которые особенно важны при разработке для мобильных устройств, потому что это правило больше подходит для PC-проектов. А PC намного производительнее мобильных платформ и менее ограничены в ресурсах. Так вот, исключения:
- Допустим, есть код, или конструкция, которую вы уже сейчас знаете, как написать лучше, с меньшей затратой по памяти/процессора и тд. Вы об этом знаете по своему опыту, потому что не раз оптимизировали компоненты подобного рода, и знаете, что это приводит к увеличению производительности. Так почему бы уже сейчас не написать ее правильно? Обычно такие вещи складываются в свод правил типа «как правильно писать код, и как писать не надо», и грамотный программист постоянно им пользуется во избежание ошибок в будущем. Нечто подобное имеет место для художников, дизайнеров и тд.
- Если есть действие, которое будет повторяться много раз, и с самого начала можно его оптимизировать, почему бы это не сделать сразу. Дальше все будет идти автоматом, и не нужно будет исправлять одну и ту же вещь несколько раз. Сюда относятся, например, вспомогательные скрипты, помогающие дизайнерам ускорить однообразную работу с группой объектов.
2. Найдите то, что нужно оптимизировать.
Несколько лет назад я имел такую ситуацию: проходишься по коду, сценам и тд и оптимизируешь все, что только можно, а потом смотришь на производительность — это ошибка начинающего оптимизатора.
Для начала нужно найти то, что тормозит систему, на чем бывают скачки производительности. В этом очень сильно помогает профайлер. Конечно, он довольно условен и сам немного нагружает систему, но польза от него неоспорима! В Unity Pro есть встроенный пройфалер, довольно удобный. Но если у Вас обычный Unity, можно использовать профайлер xCode, или любой другой подходящий. Профайлер помогает находить наиболее нагружающий код, показывает используемую память, насколько звуки грузят систему, кол-во DrawCall на конечном устройстве и тд. Таким образом, прежде чем оптимизировать, прогоните приложение через профайлер. Думаю, Вы много чего нового узнаете о своем проекте)
Что мне помогло решить проблему с производительностью?
Еще до прогона через профайлер было очевидно, что слабое место — в кол-ве Draw Calls. В среднем сцена выдавала порядка 70 DrawCalls, что для устройств уровня iPad1 и ниже является фатальным. Нормально для них — 30-40 Draw Calls. Посмотреть кол-во Draw Calls можно прямо в редакторе в окне Game->Stats:.
Кол-во Draw Calls, показываемых в редакторе, совпадает с таковым на конечных устройствах. Профайлер это подтвердил. Вообще, очень полезно смотреть эту статистику, и не только программистам, но и дизайнерам, для нахождения «тугих» мест в игре.
В наших сценах плохо работало группирование нескольких Draw Calls для одного и того же материала в один Draw Call. Это называется Dynamic Batching. Я начал рыть на тему «как понизить кол-во Draw Calls и улучшить их группирование». Ниже перечислены основные правила, придерживаясь которых, можно получить приемлемое количество Draw Calls. Вот те, которые мне очень сильно помогли:
1. Использовать атласы для комбинирования нескольких текстур в одну большую.
На самом деле важнее даже, чтобы спрайты/модели использовали не то что бы одну общую текстуру, а скорее один общий материал. Именно в кол-ве различных материалов измеряется кол-во Draw Calls (в идеальном случае). Поэтому у нас в проекте используемые изображения всегда объединены в атласы, разбитые на категории: объекты, используемые на всех сценах, объекты GUI, задний фон и тд. Вот пример такого атласа:
Такое разбиение так же будет полезно в будущем для применения к текстурам различных настроек. Но об этом позже.
2. Не стоит изменять Transform->Scale.
Объекты с измененным Scale попадают в отдельную категорию, увеличивающую кол-во Draw Calls. Я заметил это, когда еще раз проходился по документу Draw Call Batching. Вот что значит перечитывать;) Пройдясь по сцене, я обнаружил огромное количество таких объектов:
- Оказалось, что дизайнеры уже давно увеличили некоторые часто используемые объекты в 1.2 раза через Scale прямо в прифабе объекта. В итоге, мы пришли к решению увеличить их размер прямо в текстуре. Это к тому же соблюдало условие пиксель-в-пиксель, что очень важно для 2d-игры.
- Были объекты, которые имели одно и то же изображение, но разный Scale. Для таких объектов был написан специальный скрипт, который переводил нужный Scale с Transform прямо на меш, используемый для спрайта, т.е. менял размер меша и оставлял Scale = (1, 1, 1).
- Также Scale часто использовался у нас для отражения объекта, например, Scale.x = -1 отражает объект слева направо. Все такие скейлы были заменены на соответствующие им повороты.
- Еще у некоторых объектов Scale был изменен в анимации, пару раз неоправданно. Не забывайте проверять анимации, часто изменения в них — неявные, и могут быть обнаружены только после запуска.
В результате устранения практически всех изменений Scale удалось снизить кол-во Draw Call практически вдвое! Правда, эти улучшения заняли у нас порядочное время, поэтому стоит помнить о Scale уже на начальном этапе дизайна уровней. И теперь в Памятке дизайнерам у нас жирным шрифтом красным цветом написано: Старайтесь избегать изменения Scale.
Еще несколько подсказок (взятых в том числе из документа Unity), как сократить количество Draw Calls:
- Статические объекты могут быть помечены как Static. Тогда будет использоваться Static Batching (только в Pro-версии), который тоже поможет сократить кол-во Draw Calls.
- Старайтесь использовать объекты с одним и тем же материалом на одном и том же расстоянии от камеры. Пример: у нас различия по расстоянию в 10 юнитов уже давали 1-2 дополнительных Draw Call. При этом какая-то особая закономерность выявлена не была, но я подозреваю, что есть связь между размерами камеры, размерами объектов, их расстоянием до камеры и количеством Draw Calls. Экспериментируйте!
- Старайтесь, чтобы объекты с разными материалами не перекрывали друг друга. Это тоже увеличивает кол-во Draw Call, особенно для полупрозрачных объектов.
- Многопроходные (multi-pass) шейдеры увеличивают количество Draw Call. У нас таких не было, но полезно будет учесть это в будущем.
- Каждая система частиц дает 1 Draw Call. (Имеется ввиду старая система частиц Unity 3.5.6, используемая нами по сей день. Как обстоят дела в Unity 4, я не знаю). Поэтому если на экране одновременно N систем частиц — это автоматически как минимум N Draw Calls. Обычно, одного и того же эффекта можно достигнуть разными способами, в том числе меньшим числом как систем частиц, так и частиц в системе. Часто видел, как начинающие дизайнеры эффектов используют огромное кол-во частиц (и огромный размер самих частиц), чтобы создать вау-эффект. При этом они не думают о производительности (особенно учитывая, что все это делается в редакторе на PC) и обычно достаточно меньшего количества частиц, чтобы достичь того же эффекта.
Скачки производительности
Второй фактор, влияющий на производительность — так называемые скачки производительности. Порою в игре были «зависания» на 0,5-1 секунду, что конечно же было неприемлемо и напрямую влияло на геймплей. Причем такое наблюдалось даже на самых последних устройствах.
И в этом случае помог профайлер! Вот список правил для уменьшения скачков производительности:
1. Старайтесь не использовать Instantiate(), особенно для сложных объектов.
Скачки производительности приходились в основном на вызовы Instantiate(), которые создавали новые объекты из прифабов, или клонировали существующие. Причем некоторые объекты были очень громоздкими, что и повлияло на время их создания. Вместо этого пришлось переписать систему так, чтобы объекты использовались заново. Т.е. состояние объекта после окончания использования (или перед использованием) приводилось к начальному. Это также помогло сократить объем используемой памяти (так как на новые объекты больше не нужно было новой памяти) и количество вызовов Destroy().
2. Минимизируйте количество вызовов Destroy().
Destroy (особенно для больших объектов) почти всегда приводит к манипуляциям с памятью. А это обычно плачевно сказывается на производительности. Это правило напрямую связано с правилом выше, ибо вызовы Instantiate()/Destroy()обычно связаны. Таким образом, использование объектов заново лишило необходимости уничтожать их.
3. Минимизируйте вызовы gameObject.SetActiveRecursively().
Для сложных объектов вызов может быть очень долгим, потому что он предполагает не просто активацию объектов и их компонентов, но в некоторых случаях и загрузку необходимых ресурсов.
4. Минимизируйте вызовы Object.Find().
Думаю, не стоит объяснять, что время этой операции зависит от кол-ва объектов на сцене. Сюда же относятся функции типа GetComponent().
5. Минимизируйте вызовы Resources.UnloadUnusedAssets() и GC.Collect().
Unity иногда сама прибегает к ним, если недостаточно памяти для загрузки нового ресурса или пришел запрос от ОС освободить неиспользуемую память. Таким образом, первые 2 правила автоматически сокращают кол-во таких вызовов. Лучшее место для вызова Resources.UnloadUnusedAssets вручную — перед загрузкой сцены или непосредственно сразу после ее запуска. Это также поможет освободить дополнительную память для сцены, что иногда бывает критично. Соответствующий скачок производительности можно скрыть, например, экраном загрузки;)
Использование правил выше привело к устранению скачков производительности и намного более плавным геймплею и картинке.
Другие оптимизации
Далее привожу другие правила, которые могут помочь Вам. Большинством из них я сам пользовался на предыдущих этапах оптимизации.
Скрипты
- Не используйте GetComponent<>() в Update, FixedUpdate и других подобных функциях. Вместо этого лучше кэшировать компонент в Awake() или Start(). Если объектов, использующих скрипт, очень много, такое кэширование может значительно сократить время работы скрипта.
- Кэшируйте встроенные компоненты типа transform, renderer и тд. Особенно если они используются в функциях типаUpdate(). Ибо каждый такой вызов делается через GetComponent(). См. правило выше. Это можно сделать, например, так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/// <summary> /// Cached transform /// </summary> protected new Transform transform { get { if(cachedTransform == null) cachedTransform = base.transform; return cachedTransform; } } private Transform cachedTransform = null; |
- спользуйте Vector2(3,4).sqrMagnitude вместо magnitude.
- Используйте Color32 и Mesh.colors32 вместо Color и Mesh.colors при доступе к цветам меша.
- Можно использовать OnBecameVisible/OnBecameInvisible для скриптов, которые могут быть отключены, когда камера больше не видит объект.
- Используйте встроенные массивы. Имеются ввиду массивы типа T[]. Они намного быстрее всех остальных коллекций.
- Отключите логи при работе приложения на устройстве. Обычно печать лога требует доступа к файловой системе, а если логов много, это может привести к печальным последствиям для производительности.
- Отключите исключения. Как следствие — старайтесь не использовать их в коде. Отключение исключений поможет сохранить до 30% производительности. Но не забывайте, что если ошибка произойдет, и она не сможет быть обработана исключением — это падение приложения на устройстве. Поэтому приходится выбирать — либо хорошее тестирование и прирост производительности, либо надежность, но производительность чуть хуже. В случае, когда производительность удовлетворяет, преимущество лучше отдать второму варианту.
Физика
- Чем меньше одновременно активных Rigidbody, тем лучше. Деактивируйте неиспользуемые.
- Сокращайте количество FixedUpdate в единицу времени. Fixed Time = 0.03333 гарантирует физику со скоростью 30 вычислений в секунду, что обычно очень неплохо. Даже 20 может быть приемлемым, что соответствует Fixed Time = 0.05.
- Минимизируйте использование Continuous или Dynamic collision detection. Они очень ресурсозатратны. Для 2d-игр они обычно не нужны.
Анимации
- Следите за количеством анимированных объектов на экране. Чем меньше — тем лучше.
- Вместо нескольких простых анимаций на сложном объекте, можно сделать одну сложную, делающую то же самое.
- Можно попытаться уменьшить Sample Rate у клипа. Особенно если анимаций очень много.
- Используйте Culling Type = Based On Renderers или Based On User Bounds. Тогда анимация будет проигрываться только тогда, когда объект виден на экране.
Если это, конечно, устраивает.
Система частиц
- Используйте Vertical Billboard вместо Billboardв Particle Renderer.
- Используйте на мобильных устройства шейдеры из Mobile->Particles. Это касается не только системы частиц.
- Используйте как можно меньше систем частиц и самих частиц в системе для достижения нужного эффекта.
- Для слабых систем можно отключить некоторые малозаметные или несущественные системы частиц. Что позволит сохранить несколько Draw Calls и поднять на них производительность в ущерб эффектности. Уверяю, зачастую приятнее получать удовольствие от процесса игры, нежели от мегакрутых эффектов.
GUI
- Не используйте OnGUI(). Каждый такой вызов — несколько дополнительных Draw Calls. Тоже самое относится кGUILayout. И вообще, поддержка GUI в Unity сделана очень плохо. У нас, например, своя система GUI, основанная на спрайтах. В Asset Store есть несколько других очень полезных плагинов для GUI.
- Размер текстуры, генерируемой для шрифта, можно уменьшить, добавив только используемые символы. В Font Settings установить Character = Custom Set, а в Custom Chars включить используемые символы:
- Можно попробовать использовать отдельные камеры для объектов сцены и GUI. Это позволит сделать GUI zoom-независимым, что освобождает от использования на нем Scale при увеличении/уменьшении. Иногда это может увеличить производительность.
Другое
- Отключить акселерометр, если он не используется. Можно также понизить частоту измерений в Player Settings.
- Можно ограничить максимальный FPS на старых устройствах. Например, установив его в:
Application.targetFrameRate = 30
. Обычно это приводит к более гладкой картинке. Также, это уменьшает просадку аккумулятора, т.к. за то же время требуется меньше процессорной мощности. Вообще, на устройствах Apple FPS > 60 не имеет смысла, т.к. частота обновления экрана у них — 60. - Иногда периодическая частая сборка мусора сглаживает производительность. Потому что сама система делает это редко и когда уже все совсем плохо, и может накопиться большое кол-во объектов для уничтожения, что приводит к скачку производительности. Если делать это чаще, объектов для уничтожения будет меньше, и освобождение памяти будет более гладким. С другой стороны, за все время работы сцены сборка мусора может и не понадобиться.
Уменьшение размера приложения
Для чего это может понадобиться? Раньше это делалось потому, что приложения размером <20Mb можно было загружать на iOS через 3g-сеть. Что в принципе должно увеличить кол-во закачек, хотя конкретной статистики я не видел. В связи с выпуском iPad3 приложения стали «жирнее», и порог был поднят до 50Mb. Не стоит также забывать, что после заливки приложения в AppStore оно будет увеличено в размере в среднем на 4Mb. Для проверки, сколько приложение будет весить после заливки в AppStore в xCode в Organizer->Archives даже появилась специальная кнопочка Estimate Size:
Все необходимое по уменьшению размера билда описано в документах Unity:
Я же опишу здесь то, что использовал сам. Начнем с того, что влияет на производительность:
1. Используйте правильный формат текстуры.
Это также позволит уменьшить объем используемой текстурной памяти, а соответственно и повысить производительность. Иногда достаточно использовать формат текстуры 16 bits, особенно если вся графика нарисована всего в нескольких цветах. Сравните:
Для монотонных текстур можно использовать только ее серую и альфа-компоненту и лепить из них готовый объект, используя специально написанный для этого шейдер и умножение на цвет:
Для задников и нечетких объектов можно использовать компрессию. Сейчас PVRTC-компрессия на iOS довольно продвинутая. Стоит помнить, что чем больше текстура — тем лучше ее качество после компрессии. На маленьких текстурах использование компрессии может быть неприемлемым.
Чтобы это все имело смысл, нужно разделять объекты на группы типа «задний фон», GUI, игровые объекты, о чем я уже писал. Тогда на каждый тип можно завести свой атлас и использовать различные настройки формата текстуры.
2. Используйте правильный формат звуков.
Раньше я не задумывался об этом. Использование компрессии на звуках позволило мне не только сократить размер приложения, но и объем используемой им памяти. Сам я пользуюсь следующими правилами:
- Используйте Audio Format = Native для очень коротких и маленьких по размеру звуков (< 100 Kb).
- Для остальных используйте компрессию. Compression = 96 Kbps — это уже очень приемлемо. Ниже — заметны искажения.
- Используйте Load Type = Compressed in Memory для большинства ужатых звуков. Это позволит уменьшить объем используемой памяти, но может влиять на производительность, так как требует распаковки во время воспроизведения.
- Для фоновой музыки используйте Load Type = Stream from disk, особенно на системах с быстрым HDD. Это позволит сохранить очень много памяти.
- Используйте Decompress on Load во всех остальных случаях. Звук будет занимать больше памяти, но практически не будет «сажать» CPU, что поможет иногда избавиться от скачков производительности.
- Используйте Hardware Decoding для фоновой музыки. Встроенный декодер в устройствах iOS позволяет сократить использование CPU на проигрывании фоновой музыки, но это может быть сделано только для одного трека одновременно.
- Используйте Force to Mono, если стерео не нужно. Или если в файле оно присутствует, но не различимо. Это в 2 раза уменьшит объем используемого места (как в памяти, так и на диске).
Далее следуют другие шаги по уменьшению размера билда. Большинство настроек делается в Player Settings:
- Установите в настройках проекта Stripping Level = Use micro mscorlib. Это только для владельцев Pro. Не используйте без надобности единицы из System.dll и System.Xml.dll. Они не совместимы с Use micro mscolib.
- Установите API Compatibility Level в .Net 2.0 subset. Но иногда после этого код может не работать, если соответствующие классы/функции и тд не входят в .Net 2.0 subset. Что однажды случилось в моем случае.
- Также следует избавиться от зависимостей от ненужных библиотек.
- Установите Script Call Optimization Level в Fast but no exceptions, чтобы отключить исключения. Это также уменьшит размер билда.
- Установите Target Platform в armv6 (OpenGL ES1.1). Если вам не нужен armv7. Или наоборот, в armv7, но не оба одновременно. Учитывая, что Apple все меньше поддерживает устройства с armv6, имеет смысл оставить лишь armv7.
- Не используйте массивы JS. Лучше не использовать JS вообще, используйте C#. Обычно, после переписывания кода скриптов с JS на C# приложение весит меньше.
Использованные источники вдохновения
В первую очередь использовались документы Unity — самые полезные ресурсы от самих разработчиков Unity. Их читать нужно в первую очередь, желательно по несколько раз, а через некоторое время еще раз, потому что они постоянно обновляются.
- Оптимизация производительности скриптов.
- Оптимизация для мобильных устройств.
- Чеклист по оптимизации для мобильных устройств.
- Оптимизация производительности в iOS.
- Оптимизация графики.
- Профилирование на мобильных устройствах.
- Полезная информация о параметрах устройств Apple.
Отдельно выношу уже указанные документы по сокращению размера приложения:
Другие источники:
- 46 Tips & Tricks for 2d mobile Performance in Unity. Полезная статья, сильно повлиявшая на мой список.
- Optimizing with Unity for iOS.
- Code optimization in Unity.
Заключение
Проведенная мною оптимизация позволила существенно повысить производительность игры и играбельность в целом. В качестве дополнительного бонуса был уменьшен размер приложения;) Надеюсь, моя статья поможет Вам сделать ваше приложение на Unity еще лучше.