После того как я закончил мой первый проект, пришла мысль о портировании его на мобильные устройства или хотя бы запуске на встроенном GPU. Во всех гайдах по оптимизации, в одном из первых советов вам сообщат, что не стоит переживать о производительности заранее, начинайте оптимизировать, после того как все закончите, и вы постепенно приведете все в порядок. Так и я, выпустив изначально игру на десктоп, решил, что никогда не будет поздно оптимизировать ее под мобильные устройства. К сожалению, мне не удалось в полной мере достичь поставленной цели, потому как, похоже, что мобильные игры следует с самого начала разрабатывать с прицелом на слабое железо. На данный момент, для дальнейшей оптимизации под мобильные платформы я вижу только необходимость серьезно переделать геймплей и дизайн игрового мира. Однако и в текущем варианте получен ценный опыт оптимизации под Unity3d и результирующий прирост производительности более чем в 300% на интегрированном GPU.
Давайте начнем с CPU
Достаточно очевидный список сформировавшийся во время оптимизации:
- Не используйте Свойства! Поля и методы — ваши лучшие друзья.
- Кешируйте все, что вы получаете через GetComponent<>, сюда же входят transforms, rigidbodies и др.
- Старайтесь никогда не обращаться к объектам дважды для получения одних и тех же данных. Почти всегда это доступ через Свойства, от которых необходимо отказываться. Зачастую можно увидеть как в разных скриптах запрашивается позиция одного и того же объекта, что лучше заменить на кеширование той самой позиции, обновляя ее один раз внутри Update или даже FixedUpdate в этом объекте.
- Кешируйте всю математику. Каждый вызов Vector.Up будет под капотом вызывать конструктор, что не очень быстро. Я создал статический CachedMath класс, в который были сложены все направления, часто используемые векторы и кватернионы.
- Попробуйте обходиться без использования типа String. Каждая строка требует выделения памяти, и при бесконтрольном использовании строк, вы увидите, как GC остановит все потоки для своего вызова. В моем случае основными источниками строк были индикатор FPS и таймер во время гонки. Решением стало создать пул строковых литералов для всех цифр от 1 до 100. Это полностью исключило выделение строк в каждом кадре.
- Никогда не используйте foreach, просто замените на for, если хотите сберечь GC и драгоценное время CPU. К тем же последствиям зачастую приводит и использование шаблонных методов(generics).
- LINQ является еще одним источником нагрузки на GC. Старайтесь упрощать ваши LINQ выражения, или еще лучше, полностью заменять их на простые конструкции.
- Все строки используемые в Animator-объектах следует сконвертировать в целочисленные идентификаторы через Animator.StringToHash()
- Инстанциирование объектов является очень тяжелой операций, поэтому стоит для частых созданий использовать пул объектов и затем их переиспользовать.
- Удаляйте все пустые методы Update и FixedUpdate. Также, если ваш скрипт использует оба или только фиксированный, то стоит подумать о переносе любой возможной логики из фиксированного в обычный Update.
Конечно, любые оптимизации стоит проводить только, если вы видите задержки в окне профилировщика. Главное, уничтожайте на корню постоянные выделения памяти.
Также никогда не поздно упростить некоторую логику в ваших скриптах, или количество обрабатываемых данных в разумных пределах. Однако еще раз повторюсь, что вам нужны веские основания полученные с помощью профилировщика, что конкретный метод слишком медленен. После изменений обязательно проследите, чтобы профилировщик отображал меньшие цифры, чем до начала оптимизаций.
Самым плохим моментом оптимизаций является то, что ваш структурированный и "идеальный" код растекается в местами не очень читабельное нечто. К сожалению, это неизбежно. Главное помнить о том, что это жертва в угоду производительности.
Теперь GPU
С CPU советы были достаточно универсальны и они применимы в любом проекте. Чего не скажешь о GPU-оптимизациях, которые зачастую сильно зависят от конкретной сцены. Однако, если вы не используете сильной магии в своих шейдерах, то явный индикатор — это количество проходов GPU(pass-calls).
Моя игра содержит открытый мир с океаном как основой для передвижений и несколькими островами в качестве декораций. В моем случае проходов было больше 2000, и мне удалось снизить это значение до примерно 300.
Материалы. Уменьшайте количество используемых материалов насколько это возможно. Каждая смена материала это новый проход, также как и каждый текстурный слой внутри материала это тоже новый проход. Конечно, я несколько упрощаю и проходы формируются не так просто, но факт остается — слишком много проходов будут непомерно нагружать слабый GPU. Для мобильных устройств рекомендуют что-то в районе 40-60 проходов. Более продвинутые устройства могут обрабатывать и в районе сотни. Так что вам есть куда стремиться!
Видимые объекты. В моей сцене слишком много объектов, которые постоянно присутствуют на экране. Проблема лишь в том, что они и должны быть видимы! Конечно, издалека нам не нужна такая же детализация как и вблизи, поэтому очевидным решением было использовать LOD-объекты.
Импостеры. Я предпочел заменить мои объекты с помощью импостеров (в целом это очень похоже на биллборды, но это множество текстур полученных пререндером объекта со всех сторон). Во встроенном Asset-Store от Unity3d множество готовых платных решений для LOD и импостеров. Однако я решил воспроизвести базовый алгоритм самостоятельно. Я создал скрипт-расширение редактора, который создавал копию необходимого объекта, менял его слой, затем создавал камеру которая была ограничена только этим специальным слоем, и производил отрисовку объекта в текстуры со всех сторон. Были добавлены основные параметры, как название результирующей папки с текстурами, разрешение получаемых текстур, расстояние до объекта, смещение по высоте, количество сторон и флаг для сохранения или отключения освещения во время создания импостера. После того как все действия завершены, скрипт удалял уже ненужную копию объекта.
Спрайты. Теперь почти все объекты заменяются спрайтами на определенном удалении от камеры. Но количество проходов было все еще огромным. Тогда я обнаружил, что спрайты это далеко не всегда легковесная форма для отображения. Каждый спрайт по умолчанию триангулирует картинку, создавая множество вершин. На каждые 900 или около того(по официальной документации) вершин, создается очередной проход (официально группировка|пакетирование|batching — сохранение данных множества объектов в одну инструкцию для GPU — вообще неприменим к SpriteRenderer объектам). В то же время нельзя заменить все спрайты на полные квадратные регионы с прозрачностью, т. к. все прозрачные пиксели все еще требуют отрисовки, и GPU их не пропускает. Также прозрачность ведет к проблемам во время отрисовки всех спрайтов из-за проверки на глубину отрисовки. GPU все еще будет создавать дополнительный проход для одного или двух спрайтов, между отрисовкой множества уже сгруппированных только потому, что этого требует проверка по глубине. Единственное, что удалось сделать — это изменить тип спрайта на Multiple, что меняет внутренний механизм триангуляции, который создает намного меньше вершин.
Упаковщик спрайтов(SpritePacker). Это последнее о чем вы должны помнить при работе со спрайтами. Чтобы явно указать для спрайта необходимость упаковки в карту атлас, нужно указать его Tag. В момент отрисовки спрайтов из одного атласа GPU не создает дополнительных проходов, даже если порядок отрисовки по глубине не оптимален для неупакованных спрайтов. Размер результирующего атласа также важен. По умолчанию он ограничен значением в 2048х2048. Это максимальный размер атласа, и он динамически подстраивается под оптимальный, в зависимости от заполнения. В моем случае этого было недостаточно для упаковки всех необходимых мне спрайтов на одной странице. Замена алгоритма упаковки на собственный, который основан на базовом, но с измененным значением размера на 4096x2048 значительно улучшило производительность.
Дальнейшее увеличение до 4096x4096 почти не отразилось на количестве проходов, но при этом даже несколько ухудшило производительность. Стоит помнить, что некоторые спрайты не могут быть размещены на одном атласе вместе — для этого они должны иметь одинаковые настройки компрессии, и часть других параметров, иначе они будут автоматически разделены по разным группам. Поэтому старайтесь группировать спрайты по атласам логически и визуально, чтобы в один момент на экране отображалось как можно меньше атласов, ведь каждое переключение между ними, включая неоптимальное расположение по глубине будут стоить вам проходов.
В моем случае я разделил атласы на UI-спрайты, затем все объекты, которые расположены очень далеко — пришлось использовать несколько атласов, но они были разделены на диаметрально противоположные по расположению в мире группы, и одновременно на экране увидеть их достаточно сложно, и все оставшиеся промежуточные объекты.
После всех изменений, производительность улучшилась настолько, что отключение всех объектов импостеров практически не влияет на результирующий FPS.
Вода. В моем случае, мне необходимо было получить более производительную воду. Изначально в сцене использовалась waterProDaytime с включенным преломлением, которая подверглась минимальным изменениям для поддержки пены вдоль береговой линии. Была убрана камера преломлений и заменена на вызов grabpass. Все дело в том, что для корректного отображения преломлений, камере пришлось отключать матрицу отсечения, т. к. в противном случае — все объекты, выше уровня воды, просто не отбрасывали теней. Из-за этого ограничения, камера дополнительно отрисовывала сцену целиком, и вызов grabpass оказался в этом случае быстрее. Также был изменен параметр LOD множителя на время отрисовки отражений. Таким образом импостеры чуть дольше отображаются в воде, что дополнительно снижает нагрузку.
Все изменения повысили производительность на интегрированном GPU с 6-8 до 22-24 кадров в секунду. По-прежнему низкий показатель, но лучшего добиться пока не удалось. Все еще рекомендую запуск своей игры на дискретной графике.
Полировка
Новый релиз не хотелось выпускать только с изменением в производительности, поэтому было решено закрыть несколько достаточно важных моментов в том, как игра выглядит, а именно — UI.
Все, кто видел мою игру в первом релизе говорили, что UI плох. Он просто мертв, и даже если я не видел этого раньше, сейчас я понимаю что же с ним не так.
В нем отсутствовали звуки, не было движения, жизни. Запустив один из проектов, я начал замечать детали главного меню, которые раньше для меня были просто невидимы. Так что я добавил все недостающее в первом приближении. Теперь выбранная кнопка анимирована, перевод фокуса и нажатие кнопок озвучены из бесплатных ресурсов в Asset-Store.
Все элементы в самой игре анимировать было бы слишком отвлекающим и раздражающим игрока эффектом, поэтому я добавил внутреннее переливающееся свечение, которое очень мягко напоминает о UI элементах.
И последним стал экран медалей. Он был просто статичен, в нем было ужасно скучно находиться. А ведь это должно быть местом в котором игрок с наслаждением наблюдает за своим прогрессом. Так что я добавил немного жизни. Теперь его украшают три системы частиц, создающих мягкие переливающиеся шары света похожие на светлячков. Частицы, к сожалению по умолчанию не доступны на UI, поэтому пришлось создавать дополнительную камеру, которая отрисовывает только эти частицы в текстуру, затем накладывающуюся на UI.
Что было оставлено позади
Мне не удалось запустить игру на мобильных устройствах. Даже самое мощное железо не воспроизводит игру с приемлемой производительностью. Возможно, с будущими версиями Unity3d что-то изменится, но на данный момент, как я уже упоминал, разработка игры с прицелом на мобильные устройства должна проводиться иначе с самого начала.
Также, в моем случае проявлялись очень странные артефакты отрисовки на мобильных устройствах со светящимися в некоторых случаях, буквально как лазеры материалами, которые выглядят вполне нормально на десктопе.
Другая цель, появившаяся во время оптимизаций в виде быстрого запуска приложения, или даже плавного запуска, также не была достигнута. Холодный старт в моем случае длится больше минуты. Причем каждый последующий запуск сокращает это время почти вдвое. Так что, похоже, это какое-то внутреннее необходимое требование Unity-плеера. Самым главным же недостатком является зависание UI потока при активации сцены. Я уже использую асинхронный вариант загрузки сцены, даже переключил его в Additive режим, однако UI просто останавливается после 90% загрузки, когда необходимо переключить флаг allowSceneActivation. Было бы здорово, если кто-то подскажет обходной путь для Unity5.x, или нечто вроде вызова события которое может потокобезопасно и с главным приоритетом изменять Ui-объекты с их перерисовкой, чтобы была хоть какая-то индикация процесса приложения.
P.S.
Конечно, это только моя история и она не решит магическим образом все ваши проблемы. Местами она слишком субъективна, но все же надеюсь, что кто-то найдет эти советы полезными.
Если кому-то интересно посмотреть все в действии, к сожалению, видео еще нет, однако триал теперь бесконечен.
Автор: wowaaa