Искать на сайте Unity

Компонент Unity Messaging позволяет вам описывать «волшебные» методы, которые вызываются во время игры при определенных условиях. Эта функциональность понятна и проста в применении, в том числе и для новых пользователей!

Например, если описать метод Update(), как показано ниже, он будет вызываться каждый раз при смене кадра

void Update() {
    transform.Translate(0, 0, Time.deltaTime);
}

Этот метод может показаться странным опытному разработчику по трем причинам:

  1. Неясно, как именно вызывается метод.
  2. Неясно, в каком порядке вызываются методы, если сцена содержит несколько объектов.
  3. Стиль кода не работает с intellisense.

Как вызывается update() ?

Unity не использует System.Reflection для поиска «волшебных» методов.

При первом обращении к определенному типу MonoBehaviour происходит инспекция соответствующего ему скрипта с помощью Mono или IL2CPP. Собирается и кэшируется информация об описываемых в MonoBehaviour «волшебных» методах. Например, если описан метод Update(), то скрипт, содержащий описание, добавляется в список скриптов, которые обновляются при каждой смене кадра.

Пока идет игра, Unity просматривает кэшированные списки и вызывает найденные методы. При этом не имеет значения, какой модификатор доступа используется для Update.

Порядок вызова

Порядок определяется настройками Script Execution Order Settings (Edit > Project Settings > Script Execution Order). В этом меню можно выстроить порядок вручную — хотя это, конечно, неудобно при наличии большого количества скриптов. В будущем мы рассмотрим более удобные методы определения порядка — например, с помощью свойств внутри кода.

Проблемы с Intellisense

Большинство интегрированных сред разработки, используемых для редактирования скриптов C#, не умеют работать с «волшебными» методами и не могут определить, когда их нужно вызывать. При сборке выдаются предупреждения; в целом, ориентирование в коде затрудняется.

Иногда разработчики добавляют абстрактный класс, расширяющий MonoBehaviour, и для каждого скрипта в проекте расширяют новый класс. Обычно в него помещают набор виртуальных «волшебных» методов. Например, таким образом (класс назван «BaseMonoBehaviour»):

public abstract class BaseMonobehaviour : MonoBehaviour {
    protected virtual void Awake() {}
    protected virtual void Start() {}
    protected virtual void OnEnable() {}
    protected virtual void OnDisable() {}
    protected virtual void Update() {}
    protected virtual void LateUpdate() {}
    protected virtual void FixedUpdate() {}
}

В таком случае использование MonoBehaviour в коде C# становится более логичным. Однако у этого метода есть проблема.

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

На первый взгляд это несерьезно, так как вызываться вхолостую будут пустые методы. Проблема в том, что вызовы будут производиться из нативной среды C++ в среду C#, что создаст дополнительную нагрузку.

Вызываем Updates 10000 раз

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

(1) В первой сцене создается 10 тыс. MonoBehaviour, содержащих следующий код:

private void Update() {
    i++;
}

(2) Во второй сцене создается столько же MonoBehaviour, но в них используется пользовательский метод UpdateMe, который вызывается управляющим скриптом при каждой смене кадра:

private void Update() {
    var count = list.Count;
    for (var i = 0; i < count; i++) list[i].UpdateMe();
}

В качестве теста мы запускали проект на двух устройствах iOS после сборки в Mono и IL2CPP в режиме Non-Development и конфигурации Release. Мы использовали таймер для оценки производительности таким образом:

  1. Таймер включался при первом вызове Update (с помощью Script Execution Order).
  2. Таймер останавливался при вызове LateUpdate.
  3. После нескольких минут работы высчитывалось среднее время.

В тесте использовались следующие версии ПО: Unity 5.2.2f1, iOS 9.0

Mono

WOW! Это много! Должно быть, с этим тестом что-то не так!

На самом деле, я просто забыл установить Script Call Optimization в значение Fast but no Exceptions. И теперь мы видим, какое влияние на производительность оказывает это значение... о котором многие в IL2CPP не беспокоятся.

Mono (fast but no exceptions)

OK, это значительно лучше. Давайте переключимся в IL2CPP.

IL2CPP

Здесь мы видим две вещи:

  1. Эта оптимизация все еще имеет значение в IL2CPP.
  2. В IL2CPP по-прежнему есть то, что требует улучшения. И пока я пишу этот пост, команды Scripting и IL2CPP работают над повышением производительности. Например, новейшая ветка Scripting включает оптимизацию, благодаря которой тесты проходят на 35% быстрее.

Я расскажу, что происходит у Unity "под капотом" ниже. Сейчас давайте изменим наш код Manager, чтобы он работал в 5 раз быстрее!

Вызовы интерфейса, виртуальные визовы и доступ к массивам

Оказывается, что для повышения скорости перебора нашего списка из 10000 элементов лучше заменить List<> на обычный массив, так как сгенерированный код С++ работает быстрее. При замене List<ManagedUpdateBehavior> на ManagedUpdateBehavior[] действительно происходит заметное ускорение.

Выглядит значительно лучше!

Апдейт: Я запускал этот тест с массивом в Mono и получил 0.23мс

Инструменты спасения!

Мы выяснили, что вызов функций из C++ в C# не очень быстрый, но давайте посмотрим, что именно происходит, когда Unity вызывает Update по отношению к различным объектам. Для этого мы воспользовались компонентом Time Profiler из набора Apple Instruments

Обратите внимание, что это не тест Mono vs. IL2CPP test — большинство вещей, которые будут описаны ниже, верны и для билдов Mono iOS.

Для исследования использовался iPhone 6. Тест с Time Profiler был запущен на несколько минут, после чего был рассмотрен минутный интервал, начинающийся с этой строки:
void BaseBehaviourManager::CommonUpdate<BehaviourManager>()

В правой колонке представлены функции, отсортированные по времени исполнения, и вызываемые ими другие функции. Левая колонка — общая занятость процессора по времени (мс) и по процентам для каждой функции и всех вызываемых ею функций. Средняя колонка — время исполнения функции без учета вызываемых ею функций.

Так как Unity не загружал систему полностью, вызовы Update происходили только 10 секунд из 60. Рассмотрим поближе те функции, которые заняли больше всего времени.

UpdateBehavior.Update()

В центре виден сам метод Update: IL2CPP называет его UpdateBehavior_Update_m18. До того, как происходит вызов Update, Unity проходит достаточно долгий путь.

Перебор Behaviour

Unity выполняет обновления для всех Behaviour. Класс-итератор SafeIterator перебирает все зарегистрированные объекты этого типа (что занимает 1517 мс из 9979), чтобы исключить ошибку — например, в том случае, если следующий объект в списке окажется удален.

Проверка корректности вызова

Unity проверяет, относится ли каждый производимый вызов к существующему методу активного GameObject, который был инициализирован и чей метод Start был вызван ранее. Это занимает 2188 мс из 9979.

Подготовка к вызову метода

Unity создает ScriptingInvocationNoArgs (вызов из нативной среды к управляемой среде) и ScriptingArguments, после чего дает команду виртуальной машине IL2CPP на задействование метода (функция scripting_method_invoke). Это занимает 2061 мс из 9979.

Вызов метода

Функция scripting_method_invoke проверяет правильность передаваемых аргументов (900 мс), после чего вызывает метод Runtime::Invoke виртуальной машины IL2CPP (1520 мс). Runtime::Invoke сначала проверяет, существует ли вызываемый метод (1018 мс), а потом вызывает сгенерированную функцию RuntimeInvoker для сигнатуры метода (283 мс). RuntimeInvoker вызывает функцию Update (42 мс).

И прекрасная цветная табличка.

Управляемые обновления

Видно, что большую часть времени исполнения занимает функция IL2CPP ManagedUpdateBehavior_UpdateMe_m14; также вызываются те же методы, что и в предыдущем тесте, хотя некоторые из них не представлены в таблице из-за того, что занимают менее 1 мс. IL2CPP также проводит проверку на NULL по отношению к перебираемому массиву.

На изображении ниже используются те же цвета.

В целом, исполнение происходит гораздо быстрее.

Несколько слов о тесте

Если быть честным, этот тест не полностью корректен. Unity использует множество проверок, чтобы обеспечить правильное функционирование игры. В отличие от написанного вручную скрипта управления обновлениями из второй сцены, Unity каждый раз проверяет наличие и активность GameObject, наличие нужного метода, последовательность действий MonoBehaviour и т.д.

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

Как лучше поступить?

Конечно, правильная последовательность действий при разработке зависит от самого проекта. Однако достаточно часто в играх используется большое количество GameObject в одной сцене, и каждый из них выполняет некоторые действия при каждой смене кадра. Даже если каждый объект исполняет лишь небольшой фрагмент кода, вычислительные затраты на тысячи вызовов Update при большом количестве объектов могут оказаться неприемлемыми — и в таком случае придется переделывать архитектуру игры под использование управляемых обновлений и проводить рефакторинг всех объектов.

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

23 декабря 2015 г. через Технологии | 9 мин. читать
Партнеры
Unity, логотипы Unity и другие торговые знаки Unity являются зарегистрированными торговыми знаками компании Unity Technologies или ее партнеров в США и других странах (подробнее здесь). Остальные наименования и бренды являются торговыми знаками соответствующих владельцев.