Искать

Развитие наилучших практик

Последнее обновление: декабрь 2018 г.

Что вы узнаете на этого доклада: советы по производительности и оптимизации скриптов от Иэна Дандора, актуализированные в соответствии переходом на информационно-ориентированную архитектуру Unity.

Unity развивается, и поэтому старые трюки могут не дать того эффектного прироста производительности, как раньше. В этой статье анализируются отдельные изменения Unity (от Unity 5.x до Unity 2018.x) и рассматриваются возможности по их использованию. Источник приведенных методов — доклад Иэна Дандора, так что можете сначала изучить его.

Производительность скриптов

Одна из самых сложных задач оптимизации заключается в выборе метода оптимизации кода после выявления проблемной области. Здесь нужно учитывать самые разные факторы, включая особенности операционной системы, процессора и видеоадаптера, разбиение на потоки, доступ к памяти и другие. Очень сложно заранее понять, какой метод оптимизации даст в реальном мире лучший результат.

Как правило, проверять влияние оптимизации лучше на небольших тестовых проектах — их итерация происходит намного быстрее. Но выделение кода в тестовый проект само по себе непросто, потому что простая изоляция кода меняет окружение, в котором этот код выполняется. Возможны отличия в таймингах потоков; управляемая динамическая память может быть менее фрагментирована или иметь меньший объем. Следовательно, тесты лучше проектировать осторожнее.

Начните с учета источников данных и поведения кода при изменении этих источников.

  • Как код реагирует на непосредственно связанные данные, последовательно расположенные в памяти?
  • Как он обрабатывает несвязные в кэши данные?
  • Как много кода вы удалили из цикла, в котором работает ваш код? Изменили ли вы использование кэша команд процессора?
  • На каком аппаратном обеспечении запускается код? Насколько хорошо это аппаратное обеспечение реализует предсказание ветвления? Насколько хорошо оно исполняет микрооперации с изменением очередности? Поддерживает ли SIMD?
  • Если у вас проходит массовая многопоточная обработка и вы переходите на систему с меньшим числом ядер, как реагирует система?
  • Каковы параметры масштабирования вашего кода? Происходит ли масштабирование линейно по мере роста набора данных или сильнее?

В сущности, вам надо поразмыслить над тем, что вы измеряете в своих тестах.

В качество примера рассмотрим следующий тест простой операции: сравнение двух строк.

Когда API в C# сравнивают две строки, они проводят преобразования, специфические для региона, чтобы гарантировать, что различные символы могут соответствовать различным символам с учетом языка и региональных параметров, и вы отметите, что этот процесс идет довольно медленно.

Хотя большинство строковых API в C# являются культурно-зависимыми, один таковым не является: String.Equals.

Если вы откроете String.CS из GitHub и взгляните на String.Equals, вот что вы увидите: очень простой метод, который делает пару проверок перед передачей управления методу под названием EqualsHelper, скрытому методу, который нельзя вызвать без рефлексии.

lp MonoString CS1

Простой метод — EqualsHelper. Он анализирует строки по 4 байта, сравнивая исходные байты вводимых строк. При обнаружении различий он останавливается и возвращает значение "false".

Но есть и другие с способы проверить равенство строк. Самый невинно выглядящий из них — перегрузка String.Equals, принимающая два параметра: эталонная строка и параметр Enum под названием StringComparison.

Мы уже определили, что одно-параметрическая перегрузка метода String.Equals выполняет малую работу перед передачей управления на EqualsHelper. А что делает двух-параметрическая перегрузка?

Если вы взгляните на код двух-параметрической перегрузки, то увидите, что она выполняет несколько дополнительных проверок перед вводом крупной инструкции switch. Эта инструкция проверяет значение ввода перечисления StringComparison. Поскольку мы сравниваем с одно-параметрической перегрузкой, нам следует провести порядковое сравнение – побайтовое сравнение. В этом случае пройдет 4 проверки, прежде чем управление перейдет на StringComparison.Ordinal, где код выглядит очень похоже тому, как в одно-параметрической перегрузке String.Equals. Это значит, что при использовании двух-параметрической перегрузки String.Equalsвместо одно-параметрической перегрузки процессор выполнит несколько дополнительных операций сравнения. Можно было бы ожидать, что при использовании двух-параметрической перегрузки код будет выполняться медленнее, но стоит протестировать это.

Вы не хотите останавливаться на тестировании только String.Equals, если интересуетесь всеми способами проверки строк на тождественность. Есть еще перегрузка String.Compare, способная выполнять порядковое сравнение, а также метод String.CompareOrdinal, который сам имеет две различные нагрузки.

В качестве возможной реализации здесь приводится простой пример с написанным вручную кодом. Это всего лишь небольшая функция с проверкой длины, которая последовательно берет по символу из двух строк и проверяет их.

После исследования кода всех вариантов на ум приходят четыре разных полезных теста:

  • Две идентичные строки, чтобы протестировать производительность в наихудшем случае.
  • Две строки с произвольными символами, но одинаковой длины, чтобы обойти проверку длины.
  • Две строки одинаковой длины с произвольными символами, но с идентичным первым символом, чтобы обойти оптимизацию, обнаруженную только в String.CompareOrdinal.
  • Две строки произвольной длины с произвольными символами, чтобы протестировать производительность в лучшем случае.

После проведения нескольких тестов String.Equals выходит явным победителем независимо от платформы, версии среды исполнения скриптов и использования Mono или IL2CPP.

Стоит заметить, что метод String.Equals используется в операции сравнения ==, так что не поленитесь и исправьте a == b на a.Equals(b) во всем своем программном коде!

В действительности, при анализе результатов тестирования обнаруживается, что вручную написанный пример кода значительно проигрывает всем остальным вариантом, что может показаться странным. Исследуя код IL2CPP, можно увидеть, что Unity проверяет границы массива и проводит проверки на null, когда наш код проходит кросс-компиляцию.

Эти проверки можно отключить. В установочной папке Unity найдите подпапку IL2CPP. Внутри этой подпапки IL2CPP вы найдете файл IL2CPPSetOptionAttributes.cs. Перетащите его в свой проект, и вы получите доступ к Il2CppSetOptionAttribute.

Вы можете декорировать типы и методы этим атрибутом. В его настройках вы можете отключить автоматические проверки на null и/или границы массивов. Это может ускорить выполнение кода – порой значительно. В данном случае с вручную написанным кодом для сравнения строк ускорение составило 20%!

lp совет по null check

Компонент Transform

Вы не узнаете этого, просто посмотрев на Hierarchy в редакторе Unity, но компонент Transform претерпел множество изменений с Unity 5 до Unity 2018. Эти изменения также представляют некоторые новые интересные возможности в плане улучшения производительности.

В версиях Unity 4 и Unity 5.0 для объекта выделяется место где-то в куче нативной памяти Unity всякий раз, когда вы создаете Transform. Этот объект мог оказаться где угодно в куче нативной памяти – не было гарантии, что два компонента Transform, созданные один за другим, окажутся в памяти рядом друг с другом, и не было гарантии, что дочерний компонент Transform окажется рядом со своим родителем.

Это означает, что проходя линейно через иерархию Transform, мы не проходили последовательно через смежные области памяти. Это вынуждало процессор часто задерживаться в ожидании извлечения данных Transform из кэш-памяти второго уровня или из основной памяти.

Всякий раз, когда менялось положение, поворот или масштаб Transform, этот компонент Transform отправлял сообщение OnTransformChanged. Это сообщение должны были получать все дочерние Transform, чтобы обновлять свои данные, и так они могли уведомлять все другие компоненты, заинтересованные в изменениях в Transform. Например, дочерний Transform с прикрепленным к нему Collider должен обновлять систему физики всякий раз, когда меняется дочерний или родительский Transform.

Это неизбежное сообщение вызывало массу проблем, связанных с производительностью, особенно из-за того, что не было встроенного способа избегать побочных сообщений. Если вы собирались изменить Transform, и вы знали, что вы также измените и его дочерний компонент, не было способа воспрепятствовать тому, чтобы Unity не посылал сообщение OnTransformChanged после каждого изменения. Из-за этого много времени ЦП уходило впустую.

Из-за этого одна из наиболее общих рекомендаций в старых версиях Unity состояла в том, чтобы накапливать изменения компонентов Transform. То есть, фиксировать положение и поворот Transform в начале кадра и использовать и обновлять эти кэшироанные значение на протяжении всего кадра. А применять изменения положения и поворота только один раз, в конце кадра. Это хорошая рекомендация вплоть до версии Unity 2017.2.

К счастью, версии Unity 2017.4 и 2018.1 избавлены от OnTransformChanged. Ее заменила новая система, TransformChangeDispatch.

TransformChangeDispatch была впервые введена в Unity 5.4. В этой версии компоненты Transform больше не являлись одинокими объектами, которые могут быть размещены где угодно в куче нативной памяти Unity. Вместо этого каждый корневой компонент Transform в сцене стал иметь непрерывный буфер памяти. Этот буфер, называемый структурой TransformHierarchy, содержит все данные для всех компонентов Transform под корневым компонентом Transform.

Кроме того, структура TransformHierarchy также хранит метаданные о каждом компоненте Transform в ней. Эти метаданные включают в себя битовую маску с указанием, является ли данный компонент Transform "грязным" – изменилась ли его позиция, поворот или масштаб с последнего раза, когда он был помечен как “чистый”. Они также включают в себя битовую маску для слежения за тем, какие другие системы Unity заинтересованы в изменениях конкретного компонента Transform.

Располагая этими данными, Unity может теперь создать список "грязных" компонентов Transform для каждой из своих внутренних систем. Например, система Physics может запросить TransformChangeDispatch передать список компонентов Transform, данные которых были изменены с последнего раза, когда система Physics запускала FixedUpdate.

Однако, чтобы составить список измененных компонентов Transform, система TransformChangeDispatch не должна опрашивать все компоненты Transform в сцене. На это ушло бы слишком много времени, если сцена содержит массу компонентов Transform – и при этом, в большинстве случаев, менялось лишь несколько компонентов Transform.

Чтобы избежать этого, TransformChangeDispatch следит за списком "грязных" компонентов в структурах TransformHierarchy. Всякий раз, когда компонент Transform меняется, он помечает себя и свои дочерние компоненты как "грязные" и регистрируется в структуре TransformHierarchy, в которой он хранится с системой TransformChangeDispatch. Когда другая система в Unity запрашивает список измененных компонентов Transform, система TransformChangeDispatch просматривает все компоненты Transform, хранящиеся в каждой из "грязных" структур TransformHierarchy. Компоненты Transform с "грязными" битами, представляющие интерес для системы, сделавшей запрос, добавляются к списку, и этот список возвращается в эту систему.

Вследствие этой архитектуры, чем больше вы дробите иерархию, тем легче Unity следить за изменениями на детальном уровне. Чем больше компонентов Transform имеется в корне сцене, тем меньше компонентов Transform мы должны проверить, когда мы ищем изменения.

Однако есть и еще одно следствие. TransformChangeDispatch использует внутреннюю многопоточную систему Unity, чтобы разделять работу, необходимую при проверке структур TransformHierarchy. Это разделение, а затем объединение результатов, добавляет небольшую нагрузку всякий раз, когда системе необходимо запросить список изменений у TransformChangeDispatch.

Большинство внутренних систем Unity запрашивают обновления один раз за кадр, непосредственно перед своим запуском. Например, система анимации запрашивает обновления непосредственное перед подсчетом всех активных аниматоров в сцене. Аналогично, система рендеринга запрашивает обновления для всех активных рендеров в сцене перед составлением списка видимых объектов.

Только одна система ведет себя немного по-другому: система физики.

В Unity 2017.1 (и более старых версиях), обновления физики были синхронными. Когда вы перемещали или поворачивали компонент Transform с прикрепленным к нему коллайдером, мы сразу же обновляли сцену физики. Это гарантировало, что измененное положение или поворот коллайдера отображалось в мире физики, так чтоб рейкастеры и прочие компоненты физики получали точные данные.

Когда произошла миграция физики на использование TransformChangeDispatch в Unity 2017.2, хотя это было необходимое изменение, оно также может порождать проблемы. Всякий раз, когда происходит рейкастинг, нам нужно было бы запрашивать у TransformChangeDispatch список измененных компонентов Transform и применять полученные изменения в мире физики. Это может быть расточительным, в зависимости от того, насколько велики ваши TransformHierarchies и как ваш код вызывает API из физики.

Это поведение стало управляться новой настройкой, Physics.autoSyncTransforms. В версиях с Unity 2017.2 по Unity 2018.2 эта настройка по умолчанию имела значение "истина", так что движок Unity должен автоматически синхронизировать мир физики с обновлениями компонентов Transform всякий раз, когда идут запросы типа Raycast и Spherecast.

Эту настройку можно изменить будь то в настройках физики в редакторе Unity или во время выполнения с помощью свойства Physics.autoSyncTransforms. Если вы установите на "ложь", то тем самым отключите автоматическую синхронизацию физики, и тогда система физики будет запрашивать систему TransformChangeDispatch на предмет изменений только в конкретное время: непосредственно перед запуском FixedUpdate.

Так что, если вы заметите проблемы производительности при вызове API с запросами физики, мы можем предложить вам два способа справиться с ними.

Первый способ состоит в том, что вы можете установить Physics.autoSyncTransforms на "ложь". Это устранит провалы производительности из-за TransformChangeDispatch и запросов со стороны системы физики.

Однако, если вы сделаете это, изменения в коллайдерах не будут синхронизироваться со сценой физики до следующего запуска FixedUpdate. Это означает, что если вы отключите autoSyncTransforms, переместите коллайдер и вызовите Raycast с лучом, направленным в сторону нового положения коллайдера, Raycast может не попасть в коллайдер из-за того, что Raycast работает на последнем обновлении версии сцены физики, а сцена физики еще не обновилась с новым положением коллайдера.

Это может приводить к странным багам, и вам следует тщательно тестировать свою игру, чтобы убедиться, что отключение автоматической синхронизации компонентов Transform не вызывает проблем. Если вам нужно заставить систему физики принудительно обновить сцену физики с изменениями Transform, вы можете вызвать Physics.SyncTransforms. Этот API медленный, так что не злоупотребляйте им, вызывая его по несколько раз за один кадр!

Заметьте, что с версии Unity 2018.3 значением Physics.autoSyncTransforms по умолчанию будет "ложь".

Второй способ оптимизации времени на запросы в TransformChangeDispatch состоит в том, чтобы перестроить порядок запросов и обновлений сцены физики таким образом, чтобы он больше подходил под новую систему.

Помните: если для Physics.autoSyncTransforms установить "истина", то при каждом запросе физики будут проверяться изменения в TransformChangeDispatch. Однако, если TransformChangeDispatch не имеет "грязных" структур TransformHierarchy для проверки, и система физики не имеет обновленных компонентов Transform для применения в сцене физики, тогда почти не будет дополнительной нагрузки.

Поэтому вы можете выполнять все запросы физики в пакете, а затем применять все изменения Transform в пакете. Но не смешивайте при этом изменения в Transforms с вызовами API с запросами физики.

Данный пример иллюстрирует разницу:

lp примеры компонента Transform

Разница в производительности между этими двумя примерами поражает, и она становится еще более ярко выраженной, когда сцена содержит лишь немного иерархий Transform.

lp результаты примеров компонента Transform

Аудиосистема

Unity использует внутреннюю систему FMOD для воспроизведения AudioClips. FMOD работает на собственных потоках вычислений, и эти потоки ответственны за декодирование и микширование аудио. Однако, воспроизведение аудио не обходится полностью даром. Для каждого источника Audio Source, активного в сцене, выполняется некоторая работа и в основном потоке. Так что на платформах с малым числом процессорных ядер (например, на старых смартфонах) возможна конкуренция аудиопотоков FMOD за процессорные ядра с основным потоком и потоками рендеринга Unity.

В каждом кадре Unity просматривает все активные источники Audio Sources. Для каждого Audio Source, Unity вычисляет расстояние между источником звука и активным слушателем, а также ряд других параметров. Эти данные используются для расчета ослабления звука, доплеровского смещения и других эффектов, которые могут влиять на индивидуальные Audio Sources.

Общераспространенная проблема возникает из-за поля "Mute" (Без звука) на панели Audio Source. Можно было бы подумать, что установка флажка в поле Mute избавляет от всех вычислений, связанных с заглушенным источником звука – но это не так!

lp переключатель звука

На самом деле настройка “Mute” просто обнуляет параметр громкости звука после того, как выполнены все другие вычисления, связанные с громкостью, включая расчет затухания в зависимости от расстояния. Unity также передаст заглушенный Audio Source в систему FMOD, которая проигнорирует его. Расчет параметров Audio Source и передача Audio Sources в FMOD отображается как AudiosSystem.Update в Unity Profiler.

Если вы увидите в Profiler много времени, потраченного на аудио, проверьте, нет ли у вас множества заглушенных, но активных Audio Sources. Если это так, посмотрите, нельзя ли отключить компоненты Audio Source вместо того, чтобы заглушать их, или отключить их GameObject. Вы также можете вызвать AudioSource.Stop для остановки воспроизведения.

Вы также можете ограничить количество голосов в аудионастройках Unity. С этой целью вы можете вызвать функцию AudioSettings.GetConfiguration, которая возвращает структуру, содержащую две интересующие нас величины: число виртуальных голосов и число реальных голосов.

Сокращение числа виртуальных голосов уменьшит число источников Audio Source, которые FMOD опрашивает при определении того, какие Audio Sources надо воспроизводить. Сокращение числа реальных голосов уменьшит число источников Audio Source, которые FMOD микширует для создания звукового сопровождения игры.

Чтобы изменить число виртуальных или реальных голосов, используемых системой FMOD, вам необходимо изменить соответствующие значения в структуре AudioConfiguration, возвращаемой функцией AudioSettings.GetConfiguration, а затем перезапустить аудиосистему с новой конфигурацией, передав структуру AudioConfiguration в качестве параметра в AudioSettings.Reset. Учтите, что это прерывает воспроизведение аудио, так что рекомендуется делать это, когда игроки не заметят изменения, например во время загрузки экрана.

Анимации

В Unity есть две различные системы для воспроизведения анимаций: система аниматора (Animator) и система анимации (Animation).

Под "системой аниматора" имеется в виду система, включающая в себя компонент Animator, прикрепляемый к объектам GameObjects с целью их анимации, а также ассет AnimatorController, на которой ссылаются один или более компонентов Animator. Эта система получила название Mecanim, и она очень богата на функции и возможности.

В контроллере аниматора (Animator Controller) вы определяете состояния. Этими состояниями могут быть либо анимационный клип (Animation Clip), либо дерево смешения (Blend Tree). Состояния могут быть организованы в слои (Layers). В каждом кадре оценивается активное состояние на каждом слое, и результаты с каждого слоя смешиваются и применяются к анимационной модели. При переходе между двумя состояниями оцениваются оба состояния.

Вторая система называется “системой анимации”. Она представлена компонентом Animation и очень проста. В каждом кадре каждый активный компонент Animation проходит через все кривые в прикрепленном к нему анимационном клипе, проводит расчеты по этим кривым и применяет результаты.

Разница между этими двумя система заключается не только в функциях и возможностях, но и в самих основах.

Система Animator очень сильно распаралелена на множество потоков. Ее производительность значительно меняется в зависимости от центральных процессоров и числа их ядер. Вообще говоря, она масштабируется менее, чем линейно с ростом числа кривых в ее анимационных клипах. Поэтому она очень хорошо работает при расчете сложных анимаций с большим числом кривых. Однако для нее характерны довольно высокие издержки.

Система Animation, будучи простой, почти не имеет издержек. Ее производительность масштабируется линейно с ростом числа кривых в воспроизводимых клипах анимации.

Эта разница наиболее впечатляющая, когда две системы сравниваются при воспроизведении идентичных анимационных клипов.

lp тест анимационных клипов

При воспроизведении анимационных клипов старайтесь выбрать систему, наиболее соответствующую сложности вашего контента и аппаратному обеспечению, на котором будет запускаться игра.

Другая общераспространенная проблема заключается в злоупотреблении слоями в контроллерах аниматора (Animator Controller). Когда Animator работает, в каждом кадре он просчитывает все слои в своем контроллере, включая слои, чей вес установлен на ноль, что означает, что эти слои не вносят визуального вклада в конечные результаты анимации.

Каждый дополнительный слой добавляет дополнительные вычисления в каждый контроллер аниматора в каждом кадре. Так что, вообще говоря, старайтесь использовать поменьше слоев. Если у вас есть отладочные, демонстративные или кинематографические слои в контроллере аниматора, постарайтесь реорганизовать их. Объедините их с существующими слоями или вообще устраните перед выпуском игры.

Общий и гуманоидный скелет

По умолчанию Unity импортирует анимационные модели с общим скелетом (Generic rig), но обычно разработчики переключаются на гуманоидный скелет (Humanoid rig), когда начинают заниматься анимацией персонажа. Но это не обходится бесплатно с точки зрения потребления ресурсов ЦП.

Гуманоидный скелет вносит два добавления в систему Animator: инверсную кинематику и перенос анимации. Перенос анимации - великолепная возможность, позволяющая повторно использовать анимацию на других аватарах.

Однако, даже если вы не используете инверсную кинематику и перенос анимации, Animator персонажа с гуманоидным скелетом все равно будет рассчитывать инверсную кинематику и перенос анимации в каждом кадре. Это приводит к на 30-50% большему потреблению времени ЦП по сравнению с обычным скелетом, при использовании которого не происходит этих расчетов.

Если вы не используете эти возможности гуманоидных скелетов, вам лучше использовать общие скелеты.

Пул объектов в аниматоре

Использование пула объектов (Object Pooling) является ключевой стратегией для избавления от пикового потребления ресурсов во время игрового процесса. Однако исторические сложилось так, что систему Animator трудно использовать с Object Pooling. Всякий раз, когда объект GameObject в Animator включен, необходимо восстанавливать буферы промежуточных данных для использования в контроллере аниматора. Этот процесс называется Animator Rebind и отображается в Unity Profiler как Animator.Rebind.

До версии Unity 2018 единственный обходной путь состоял в том, чтобы отключать Animator Component, а не GameObject. Это имело побочные эффекты: если были MonoBehaviors, Mesh Colliders или Mesh Renderers на вашем персонаже, вам хотелось отключить и их. Таким способом вы могли экономить время ЦП, расходуемое вашим персонажем. Но это добавляло сложности в ваш программный код и могло легко приводить к ошибкам.

В Unity 2018.1 мы ввели Animator.KeepControllerStateOnEnable API. По умолчанию это свойство установлено на "ложь", что означает, что Animator будет вести себя как обычно – освобождая буферы промежуточных данных при выключении и снова занимая их при включении.

Однако, если вы установите это свойство на "истина", аниматоры будут сохранять свои буферы даже при выключении. Это означает, что не будет Animator.Rebind, когда аниматор будет включен снова. Наконец-то можно создавать пулы аниматоров!

Дополнительные ресурсы

Мы очень хотим знать, нравится ли вам наш контент.

Да, хочу больше Нет, могло быть и лучше