MS и FPS
В этой статье:
- Использование статистики игрового окна, отладчика кадров и профайлера
- Сравнение динамической обработки, GPU инстансирования и SRP batcher
- Отображение счетчика частоты кадров
- Автоматическое переключение между функциями
- Плавный переход между функциями.
Это четвертое руководство из серии, посвященной изучению основ работы с Unity, введение в измерение производительности. Мы также добавим возможность морфинга функций в нашу библиотеку функций.
Это руководство создано с использованием Unity 2020.3.6f1.
1. Unity профилирование
Unity постоянно отрисовывает новые кадры. Чтобы все выглядело плавно, он должен делать это очень быстро, чтобы мы воспринимали последовательность изображений как непрерывное движение. Обычно 30 fps — для краткости — это минимум, к которому нужно стремиться, 60 fps - идеальный вариант. Эти цифры часто встречаются из-за того, что во многих устройствах частота обновления экрана составляет 60 Гц. Вы не сможете выводить больше кадров, не отключив функцию VSync, что приведет к разрывам изображения. Если не удается достичь постоянных 60 fps, то следующая оптимальная частота - 30 fps, которая выполняется один раз за два обновления дисплея. На один шаг ниже будет 15 fps, что недостаточно для плавного движения.
Будет ли достигнута целевая частота кадров, зависит от того, сколько времени потребуется для обработки отдельных кадров. Чтобы достичь 60 fps, мы должны обновлять и рендерить каждый кадр менее чем за 16,67 миллисекунды. Временной интервал для 30 fps вдвое больше, то есть 33,33мс на кадр.
Когда наш график запущен, мы можем оценить, насколько плавно он движется, просто наблюдая за ним, но это очень неточный способ измерения его производительности. Если движение кажется плавным, то оно, вероятно, превышает 30 fps, если оно кажется прерывистым, то, вероятно, оно меньше этого значения. Кроме того, в один момент он будет работать плавно, а в следующий - тормозить из-за нестабильной работы. Это может быть вызвано изменениями в нашем приложении, а также другими приложениями, запущенными на этом же устройстве. Редактор Unity также может работать нестабильно в зависимости от того, что он делает. Если бы у нас было около 60 кадров, то они бы скакали между 30 и 60 fps, что вызвало бы дискомфорт, несмотря на высокий средний fps. Поэтому, чтобы получить представление о том, что происходит, нам нужно более точно измерить производительность. В Unity есть несколько инструментов, которые помогут нам в этом.
1.1 Статистика игрового окна
В игровом окне есть панель наложения Statistics, которую можно активировать с помощью кнопки на панели инструментов Stats. Она отображает измерения, сделанные для последнего отрендеренного кадра. Это не говорит нам о многом, но это самый простой инструмент, который мы можем использовать, чтобы получить представление о происходящем. Окна игры в режиме редактирования обычно обновляются только время от времени, после того, как что-то изменилось. В режиме воспроизведения обновляется каждый кадр.
Следующая статистика приведена для нашего графика с функцией torus и resolution 100, использующего встроенный пайплайн рендеринга по умолчанию (Built-in Render Pipeline), который с этого момента я буду называть BRP. У меня включена функция VSync для игрового окна, поэтому обновления синхронизируются с моим 60-герцовым монитором.
Статистика показывает кадр, в течение которого основной поток процессора работал 31,7 мс, а поток рендеринга - 29,2 мс. Скорее всего, вы получите другие результаты в зависимости от вашего оборудования и размера игрового окна. В моем случае это говорит о том, что для рендеринга всего кадра потребовалось 60,9 мс, но панель статистики показала 31,5 кадров в секунду, что соответствует времени процессора. Индикатор fps, похоже, показывает худшее время и предполагает, что оно соответствует частоте кадров. Это чрезмерное упрощение, которое учитывает только работу CPU, игнорируя GPU и монитор. Реальная частота кадров, скорее всего, ниже.
Помимо индикации времени и частоты кадров в секунду, на панели статистики также отображаются различные сведения о том, что было отрендерено. Всего было обработано 30003 пакета, и, по-видимому, при объединении не было сохранено ни одного. Это команды отрисовки, отправленные на GPU. Наш график содержит 10000 точек, поэтому кажется, что каждая точка рендерилась три раза. Это один раз для прохода глубины, один раз для теневых кастеров (указанных отдельно) и один раз для конечного куба на каждую точку. Другие три пакета предназначены для дополнительной работы которая не зависит от нашего графика, такой как sky box и обработка теней. Было также шесть вызовов set-pass, Также было шесть вызовов set-pass, которые можно рассматривать как переконфигурацию GPU для отрисовки другим способом, например, с другим материалом.
Если мы используем URP, статистика будет другой. Рендеринг выполняется быстрее. Легко понять почему: всего 20002 пакета, что на 10001 меньше, чем для BRP. Это связано с тем, что URP не использует отдельный проход по глубине для создания направленных теней.
Несмотря на то, что Saved by batching сообщает об отсутствии объединения, URP по умолчанию использует SRP batcher, но панель статистики этого не понимает. SRP batcher не устраняет отдельные команды отрисовки, но может сделать их намного эффективнее. Чтобы продемонстрировать это, выберите наш URP ассет и отключите SRP Batcher в разделе Advanced в нижней части инспектора. Убедитесь, что Dynamic Batching также отключен.
При отключенном SRP batcher производительность URP значительно снижается.
1.2 Динамическое объединение
Помимо SRP batcher, в URP есть еще один переключатель для динамического объединения. Это старый метод, который динамически объединяет небольшие меши в один большой, который затем отрисовывается вместо них. При включении этого параметра для URP количество пакетов сокращается до 10024, и панель статистики показывает, что 9978 отрисовок были исключены.
В моем случае SRP Batcher и динамического объединение имеют сопоставимую производительность, поскольку кубические меши точек нашего графика являются идеальными кандидатами для динамического объединения.
SRP недоступны для BRP, но мы можем включить для него динамическое объединение. Для этого мы можем найти переключатель в разделе Other Settings в настройках проекта Player, чуть ниже того места, где мы изменяли цветовое пространство на линейное. Он отображается только в том случае, если не используются настройки scriptable render pipeline.
Динамическое объединение намного эффективнее для BRP, поскольку он позволяет исключить 29964 пакетов, сократив их количество всего до 39, но, похоже, это не сильно помогает.
1.3 Создание экземпляров на графическом процессоре
Еще один способ повысить производительность рендеринга - включить GPU инстансирование. Это позволит использовать одну команду рендеринга, чтобы заставить GPU рендерить множество экземпляров одного меша из одного и того же материала, предоставляя массив матриц преобразования и, при необходимости, другие данные экземпляров. В этом случае мы должны включить эту функцию для каждого материала. Для этого у нас есть переключатель Enable GPU Instancing.
URP предпочитает SRP batcher созданию экземпляров на GPU, поэтому, чтобы он работал для наших точек, SRP batcher нужно отключить. После этого мы видим, что количество пакетов сократилось до 46, что намного лучше, чем при динамическом объединении. Позже мы выясним причину этой разницы.
Из этих данных можно сделать вывод, что для URP лучше всего подходит GPU инстансирование, за которым следует динамическое объединение, а затем SRP batcher. Но разница невелика, поэтому они кажутся практически эквивалентными для нашего графика. Единственный очевидный вывод заключается в том, что следует использовать либо GPU инстансирование, либо SRP batcher.
Для BRP GPU инстансирование приводит к большему количеству пакетов, чем при динамическом объединении, но частота кадров при этом немного выше.
1.4 Отладчик фреймов
Панель статистики может сообщить нам, что использование динамического объединения отличается от использования GPU инстансирования, но не объясняет, почему. Чтобы лучше понять, что происходит, мы можем использовать отладчик кадров, который открывается через Window / Analysis / Frame Debugger. При включении с помощью кнопки на панели инструментов он отображает список всех команд отрисовки, отправленных на GPU для последнего кадра игрового окна, сгруппированых по образцам профилирования. Этот список отображается в левой части. Справа показаны подробные сведения о конкретной выбранной команде отрисовки. Кроме того, в игровом окне отображается прогрессивное состояние отрисовки непосредственно после выбранной команды.
Мы видим в общей сложности 30007 вызовов отрисовки, что больше, чем указано в панели статистики, поскольку есть команды, которые не учитываются как пакеты, например, очистка целевого буфера. 30000 отрисовок для наших точек указаны отдельно как Draw Mesh Point(Clone) в разделах DepthPass.Job, Shadows.RenderDirJob и RenderForward.RenderLoopJob.
Если мы повторим попытку с включенным динамическим объединением, структура команд останется прежней, за исключением того, что каждая группа из 10000 отрисовок будет сокращена до двенадцати вызовов Draw Dynamic. Это значительное улучшение с точки зрения затрат на взаимодействие между CPU и GPU.
Если мы используем GPU инстансирование, то вместо этого каждая группа сокращается до 20 вызовов Draw Mesh (Instanced) Point(Clone). Снова значительное улучшение, но уже другой подход.
Мы видим, что то же самое происходит с URP, но с другой иерархией команд. В этом случае точки рисуются дважды, сначала в Shadows.Draw, а затем в RenderLoop.Draw. Существенное отличие заключается в том, что динамическое объединение, по-видимому, не работает для карты теней, что объясняет, почему она менее эффективна для URP. В итоге мы также получаем 22 пакета вместо 12, что указывает на то, что URP материал использует больше данных о вершинах сетки, чем стандартный BRP, поэтому в один пакет помещается меньше точек. В отличие от динамического объединения, GPU инстансирование работает с тенями, поэтому в данном случае оно лучше.
Наконец, при включенном SRP batcher 10000 точек отрисовки отображаются в виде 11 SRP batcher команд, имейте в виду, что это все еще отдельные вызовы отрисовки, но очень эффективных.
1.5 Дополнительный источник света
Результаты, которые мы получили на данный момент, относятся к нашему графику с одиночным направленным источником света и другими используемыми нами настройками проекта. Давайте посмотрим, что произойдет, когда мы добавим второй источник света к сцене, а именно точечный свет с помощью GameObject / Light / Point Light. Установим его положение на ноль и убедимся, что он не отбрасывает тени, что и должно быть по умолчанию. BRP поддерживает тени для точечных источников света, но URP нет.
Благодаря дополнительному освещению BRP теперь прорисовывает все точки дополнительный раз. Отладчик кадров показывает, что RenderForward.RenderLoopJob отрисовывает в два раза больше, чем раньше. Что ещё хуже, динамическое пакетирование теперь работает только для проходов глубины и теней, но не для forward-проходов.
Это происходит потому, что BRP отрисовывает каждый объект один раз для каждого источника света. В нем есть основной проход, который работает с одним направленным источником света, за которым следуют дополнительные проходы, рендерящиеся поверх него. Это происходит потому, что используется устаревший forward-additive рендеринг. Динамическая пакетная обработка не может обрабатывать эти разные проходы, поэтому не используется.
То же самое верно и для GPU инстансирования, за исключением того, что она по-прежнему работает для основного прохода. Только дополнительные проходы света от этого не выигрывают.
Второй источник света, по-видимому, не имеет значения для URP, поскольку это современный forward отрисовыватель, который принимает все освещение за один проход. Таким образом, список команд остается прежним, хотя GPU требуется выполнять больше вычислений освещения для каждой отрисовки.
Эти выводы касаются для одного дополнительного источника света, который влияет на все точки. Если добавить больше источников света и перемещать их так, что разные точки освещаются разными источниками, ситуация усложняется, и пакеты могут разделяться при использовании GPU инстансирования. То, что верно для простой сцены, может оказаться неверным для сложной.
1.6 Профайлер
Чтобы лучше понять, что происходит на стороне CPU, мы можем открыть окно профайлера. Отключите точечный свет и откройте окно через Window / Analysis / Profiler. Оно будет записывать данные о производительности во время игрового режима и сохранять их для последующего анализа.
Профайлер разделен на две секции. В его верхней части содержится список модулей, которые отображают различные графики производительности. Верхний из них - CPU Usage, на котором мы сосредоточимся. При выборе этого модуля в нижней части окна отображается подробная разбивка кадра, который мы можем выбрать на графике.
Окно по умолчанию для использования CPU — это временная шкала. Она отображает, сколько времени было потрачено на то или иное действие в течение кадра. Временная шкала показывает, что каждый кадр начинается с PlayerLoop, который тратит большую часть своего времени на вызов RunBehaviourUpdate. Двумя шагами ниже мы видим, что в основном это вызов нашего метода Graph.Update. Вы можете выбрать блок временной шкалы, чтобы увидеть его полное название и длительность в миллисекундах.
После начальных сегментов цикла воспроизведения следует короткая часть EditorLoop, после которой следует другой сегмент воспроизведения для рендеринга части кадра, где CPU сообщает GPU, что делать. Работа распределена между основным потоком, потоком рендеринга и несколькими рабочими потоками, но для BRP и URP используется другой подход. Эти потоки выполняются параллельно, но также имеют точки синхронизации, когда одному из них приходится ждать результатов другого.
После рендеринга — пока поток рендеринга все еще занят, если используется URP — следует еще один сегмент редактора, после которого начинается следующий кадр. Потоки также могут пересекать границы кадра. Это происходит потому, что Unity может начать цикл обновления следующего кадра в основном потоке до завершения потока рендеринга, используя параллелизм. Мы вернемся к этому позже в следующем разделе.
Если вас не интересует точное время работы потоков, вы можете заменить вид Timeline на Hierarchy через выпадающее меню слева. Иерархия отображает те же данные в одном сортируемом списке. Это представление упрощает понимание того, что занимает больше всего времени и где происходит выделение памяти.
1.7 Профилирование сборки
Профайлер наглядно показывает, что редактор увеличивает нагрузку на наше приложение. Таким образом, гораздо полезнее профилировать наше приложение, когда оно работает самостоятельно. Для этого нам нужно собрать приложение, специально для отладки. Мы можем настроить способ сборки нашего приложения в окне Build Settings, которое открывается через File / Build Settings.... Если вы еще не настраивали это, раздел Scenes in Build будет пустым. Это нормально, потому что по умолчанию будет использоваться текущая открытая сцена.
Вы можете выбрать целевую платформу, для которой ваша текущая машина наиболее удобна. Затем включите опции Development Build и Autoconnect Profiler.
Чтобы наконец создать автономное приложение, которое часто называют сборкой, нажмите кнопку Build или Build and Run, чтобы сразу открыть приложение после завершения процесса сборки. Вы также можете запустить другую сборку через File / Build and Run.
Как только сборка запустится самостоятельно, закройте ее через некоторое время и вернитесь в Unity. Теперь профайлер должен содержать информацию о том, как она работала. Это не всегда происходит после первой сборки, если это так, попробуйте снова. Также имейте в виду, что профайлер не очищает старые данные при подключении к сборке, даже если включена функция Clear on Play, поэтому убедитесь, что вы смотрите на соответствующие кадры, если вы запускали приложение всего несколько секунд.
Поскольку в редакторе Unity нет дополнительных затрат, сборка должна выполняться лучше, чем в режиме воспроизведения. Профайлер больше не будет отображать секции цикла редактора.
2. Отображение частоты кадров
Нам не всегда нужна подробная информация о профилировании, часто бывает достаточно приблизительной индикации частоты кадров. Кроме того, мы или кто-то другой, можем запускать наше приложение где-то без доступа к редактору Unity. В таких случаях мы могли бы измерять и отображать частоту кадров непосредственно в приложении, в небольшом наложенном окне. По умолчанию такая функциональность недоступна, поэтому мы создадим ее сами.
2.1 Панель пользовательского интерфейса
Небольшую оверлейную панель можно создать с помощью внутриигрового пользовательского интерфейса Unity. Мы также будем использовать TextMeshPro для создания текста, отображающего частоту кадров. TextMeshPro - это отдельный пакет, содержащий расширенные функции отображения текста, превосходящие стандартный компонент пользовательского интерфейса. Если у вас еще не установлен этот пакет, добавьте его через менеджер пакетов. При этом также автоматически устанавливается пакет Unity UI, поскольку TextMeshPro зависит от него.
После того как пакет UI станет частью проекта, создайте панель через GameObject / UI / Panel. Это создаст полупрозрачную панель, которая будет закрывать весь UI холст. Размер холста соответствует размеру окна игры, но в окне сцены он намного больше. Проще всего увидеть его, включив 2D-режим на панели инструментов окна сцены и затем уменьшив масштаб.
Каждая UI имеет корневой объект холста, который автоматически создается при добавлении панели. Панель является дочерним объектом холста. Также был создан объект EventSystem, который отвечает за обработку событий ввода UI. Мы не будем его использовать, поэтому можем игнорировать или даже удалить его.
На холсте есть компонент масштабирования, который можно использовать для настройки масштаба UI. Настройки по умолчанию предполагают постоянный размер в пикселях. Если вы работаете с дисплеем с высоким разрешением или retina, вам придется увеличить коэффициент масштабирования, иначе UI будет слишком маленьким. Существуют также другие режимы масштабирования, с которыми вы можете поэкспериментировать.
Игровые объекты UI имеют специализированный компонент RectTransform, который заменяет обычный компонент Transform. Помимо обычных свойств положения, вращения и масштаба, он предоставляет дополнительные свойства, основанные на привязках. Привязки управляют относительным положением и изменением размера объекта относительно его родительского элемента. Самый простой способ изменить его - через всплывающее окно, которое открывается щелчком по квадратному изображению привязки.
Мы разместим панель счетчика частоты кадров в правом верхнем углу окна, поэтому установите привязку панели в верхний правый угол и ось поворота XY на 1. Затем установите ширину на 38, высоту на 70 и позицию на ноль. После этого установите цвет компонента изображения на черный, сохранив его альфа-канал без изменений.
2.2 Текст
Чтобы поместить текст на панель, создайте текстовый элемент TextMeshPro UI через GameObject / UI / Text - TextMeshPro. Если вы впервые создаете объект TextMeshPro, появится всплывающее окно Import TMP Essentials. Импортируйте необходимые файлы, как было предложено. В результате будет создана папка TextMesh Pro с некоторыми ассетами, с которыми нам не придется работать напрямую.
Как только текстовый игровой объект будет создан, сделайте его дочерним объектом панели, установите для его привязки режим растягивания в обоих измерениях. Сделайте так, чтобы он перекрывал всю панель, что можно сделать, установив значения left, top, right и bottom на ноль. Также дайте ему описательное название, например, Frame Rate Text.
Затем внесите несколько изменений в компонент TextMeshPro - Text (UI). Установите Font Size на 14 и Alignment на center middle. Затем заполните область Text Input текстом-заполнителем (placeholder text), в частности, FPS, за которым следуют три строки с тремя нулями в каждой.
Теперь мы можем видеть, как будет выглядеть наш счетчик частоты кадров. Три строки с нулями являются заполнителями для статистики, которую мы вскоре отобразим.
2.3 Обновление экрана
Для обновления счетчика нам нужен пользовательский компонент. Создайте новый C# скрипт для компонента FrameRateCounter. Присвойте ему сериализуемое поле TMPro.TextMeshProUGUI, которое будет содержать ссылку на текстовый компонент, используемый для отображения его данных.
using UnityEngine;
using TMPro;
public class FrameRateCounter : MonoBehaviour {
[SerializeField]
TextMeshProUGUI display;
}
Добавьте этот компонент к текстовому объекту и свяжите его с отображением.
Чтобы отобразить частоту кадров, нам нужно знать, сколько времени прошло между предыдущим и текущим кадром. Эта информация доступна через Time.deltaTime. Однако это значение зависит от шкалы времени, которую можно использовать для замедленной съемки, быстрой перемотки или полной остановки времени. Вместо этого нам нужно использовать Time.unscaledDeltaTime. Получите его при запуске нового метода Update в FrameRateCounter.
void Update () {
float frameDuration = Time.unscaledDeltaTime;
}
Следующим шагом будет настройка отображаемого текста. Мы можем сделать это, вызвав метод SetText с аргументом text string. Давайте начнем с ввода того же текста-заполнителя, который у нас уже есть. Строка пишется в двойных кавычках, а новый абзац - со специальной последовательностью символов \n.
float frameDuration = Time.unscaledDeltaTime;
display.SetText("FPS\n000\n000\n000");
В TextMeshProUGUI есть варианты методов SetText, которые принимают дополнительные аргументы типа float. Добавьте длительность кадра в качестве второго аргумента, а затем замените первую строку с тремя нулями в нашей строке на один ноль в фигурных скобках. Это указывает, где в строке должен быть вставлен аргумент типа float.
display.SetText("FPS\n{0}\n000\n000", frameDuration);
Длительность кадра показывает, сколько времени прошло. Чтобы показать частоту кадров в секунду, мы должны отобразить обратное значение, то есть единицу, деленную на длительность кадра.
display.SetText("FPS\n{0}\n000\n000", 1f / frameDuration);
Это покажет значимое значение, но с большим количеством цифр, например, 59,823424. Мы можем указать, что текст следует округлить до определенного количества цифр после запятой, поставив после нуля двоеточие и нужное число. Мы округлим до целого числа, поэтому добавим ноль.
display.SetText("FPS\n{0:0}\n000\n000", 1f / frameDuration);
2.4 Средняя частота кадров
Отображаемая частота кадров меняется очень быстро, поскольку время между последовательными кадрами почти никогда не бывает одинаковым. Мы можем сделать ее менее изменчивой, показав среднюю частоту кадров, а не только частоту для последнего кадра. Для этого мы будем отслеживать количество отрендеренных кадров и общую длительность, затем показывать количество кадров, деленное на их общую длительность.
int frames;
float duration;
void Update () {
float frameDuration = Time.unscaledDeltaTime;
frames += 1;
duration += frameDuration;
display.SetText("FPS\n{0:0}\n000\n000", frames / duration);
}
Это сделает наш счетчик более стабильным по мере его работы, но это среднее значение будет для всего времени работы нашего приложения. Поскольку нам нужна свежая информация, нам приходится часто сбрасывать и начинать сначала, получая новое среднее значение. Мы можем сделать это настраиваемым, добавив сериализуемое поле длительности выборки, установленное по умолчанию на одну секунду. Задайте разумный диапазон, например, от 0.1 до 2 секунд. Чем короче длительность, тем точнее результат, но его будет сложнее прочитать, так как он меняется быстрее.
[SerializeField]
TextMeshProUGUI display;
[SerializeField, Range(0.1f, 2f)]
float sampleDuration = 1f;
Отныне мы будем настраивать отображение только в том случае, если накопленная длительность равна или превышает заданную длительность выборки. Мы можем проверить это с помощью оператора >= (больше или равно). После обновления отображения сбросьте накопленные кадры и длительность обратно до нуля.
void Update () {
float frameDuration = Time.unscaledDeltaTime;
frames += 1;
duration += frameDuration;
if (duration >= sampleDuration) {
display.SetText("FPS\n{0:0}\n000\n000", frames / duration);
frames = 0;
duration = 0f;
}
}
2.5 Лучшее и худшее
Средняя частота кадров колеблется, потому что производительность нашего приложения непостоянна. Иногда оно замедляется либо из-за того, что у него временно появляется больше работы, либо из-за того, что другие процессы, запущенные на том же компьютере, мешают. Чтобы получить представление о том, насколько велики эти колебания, мы также запишем и отобразим наилучшую и наихудшую длительность кадра, которые произошли в течение периода выборки. Установите наилучшую длительность на float.MaxValue по умолчанию, что является худшей возможной лучшей длительностью.
float duration, bestDuration = float.MaxValue, worstDuration;
При каждом обновлении проверяйте, не меньше ли текущая длительность кадра, чем наилучшая на данный момент. Если да, укажите её новой наилучшей длительностью. Также проверьте, не превышает ли текущая длительность кадра наихудшую на данный момент. Если да, сделайте её новой наихудшей длительностью.
void Update () {
float frameDuration = Time.unscaledDeltaTime;
frames += 1;
duration += frameDuration;
if (frameDuration < bestDuration) {
bestDuration = frameDuration;
}
if (frameDuration > worstDuration) {
worstDuration = frameDuration;
}
…
}
Теперь мы поместим наилучшую частоту кадров на первую строку, среднюю на вторую и наихудшую на последнюю. Мы можем сделать это, добавив еще два аргумента в SetText и добавив больше заполнителей в строку. Это индексы, поэтому первое число обозначается как 0, второе 1, а третье 2. После этого также сбросьте значения наилучшей и наихудшей длительности.
if (duration >= sampleDuration) {
display.SetText(
"FPS\n{0:0}\n{1:0}\n{2:0}",
1f / bestDuration,
frames / duration,
1f / worstDuration
);
frames = 0;
duration = 0f;
bestDuration = float.MaxValue;
worstDuration = 0f;
}
Обратите внимание, что наилучшая частота кадров может превышать частоту обновления экрана, даже если включена функция VSync. Аналогично, наихудшая частота кадров не обязательно должна быть кратной частоте обновления экрана. Это возможно, потому что мы не измеряем длительность между отображаемыми кадрами. Мы измеряем длительность между кадрами Unity, которые являются итерациями его цикла обновления. Цикл обновления Unity не совсем синхронизирован с экраном. Мы уже видели намек на это, когда профилировщик показал, что цикл обновления следующего кадра запущен, в то время как поток рендеринга текущего кадра все еще занят. И после завершения потока рендеринга, GPU все еще предстоит проделать некоторую работу, и после этого все равно потребуется некоторое время, прежде чем дисплей обновится. Таким образом, отображаемый нами FPS - это не реальная частота кадров, а то, что нам сообщает Unity. В идеале они должны совпадать, но сделать это правильно сложно.
2.6 Длительность кадров
Частота кадров в секунду (FPS) является хорошей единицей измерения воспринимаемой производительности, но при попытке достичь целевой частоты кадров полезнее отображать длительность кадра. Например, при попытке добиться стабильных 60FPS на мобильном устройстве важна каждая миллисекунда. Поэтому добавим опцию конфигурации режима отображения к нашему счётчику частоты кадров.
Определите перечисление DisplayMode для FPS и MS внутри FrameRateCounter, а затем добавьте сериализуемое поле этого типа, установленное на FPS по умолчанию.
[SerializeField]
TextMeshProUGUI display;
public enum DisplayMode { FPS, MS }
[SerializeField]
DisplayMode displayMode = DisplayMode.FPS;
Затем, когда при обновлении отображения в Update, проверьте, установлен ли режим на FPS. Если да, сделайте то же, что мы делали. В противном случае замените заголовок FPS на MS и используйте обратные аргументы. Умножьте их также на 1000, чтобы перевести секунды в миллисекунды.
if (duration >= sampleDuration) {
if (displayMode == DisplayMode.FPS) {
display.SetText(
"FPS\n{0:0}\n{1:0}\n{2:0}",
1f / bestDuration,
frames / duration,
1f / worstDuration
);
}
else {
display.SetText(
"MS\n{0:0}\n{1:0}\n{2:0}",
1000f * bestDuration,
1000f * duration / frames,
1000f * worstDuration
);
}
frames = 0;
duration = 0f;
bestDuration = float.MaxValue;
worstDuration = 0f;
}
Длительность кадров часто измеряется в десятых долях миллисекунды. Мы можем повысить точность нашего отображения на один шаг, увеличив округление цифр с нуля до 1.
display.SetText(
"MS\n{0:1}\n{1:1}\n{2:1}",
1000f * bestDuration,
1000f * duration / frames,
1000f * worstDuration
);
2.7 Распределение памяти
Наш счетчик частоты кадров завершен, но прежде чем двигаться дальше, давайте проверим, насколько он влияет на производительность. Отображение UI требует больше вызовов отрисовки на кадр, но это не имеет большого значения. Используем профилировщик в режиме воспроизведения, а затем ищем кадр, во время которого мы обновляем текст. Оказывается, он не занимает много времени, но при этом выделяется память. Это проще всего обнаружить через представление иерархии, отсортировав по столбцу GC Alloc.
Строки текста являются объектами. Когда мы создаем новый объект с помощью SetText, создается новый объект string, который отвечает за выделение 106 байт. Затем обновление UI Unity увеличивает это значение до 4,5 килобайт. Хотя этого немного, оно будет накапливаться, вызывая в какой-то момент процесс сбора мусора в памяти, что приведет к нежелательному увеличению длительности кадра.
Важно помнить о выделении памяти для временных объектов и по возможности устранять повторяющиеся. К счастью, SetText и обновление UI Unity выполняют такое распределение памяти только в редакторе по разным причинам, например, по обновлению поля ввода текста. Если мы профилируем сборку, то мы обнаружим некоторые начальные выделения, но не более того. Поэтому важно профилировать сборки.
3. Автоматическое переключение функций
Теперь, когда мы знаем, как профилировать наше приложение, мы можем сравнить его производительность при отображении различных функций. Если функция требует дополнительных вычислений, процессор должен выполнять больше работы, что может снизить частоту кадров. Однако то, как рассчитываются точки, не имеет значения для GPU. Если разрешение одинаковое, GPU будет выполнять тот же объем работы.
Самая большая разница - между функциями wave и torus. Можно сравнить их загрузку процессора с помощью профилировщика. Мы можем либо сравнить два отдельных запуска с разными настроенными функциями, либо профилировать в режиме воспроизведения и переключаться во время воспроизведения.
График работы процессора показывает, что после переключения с torus на wave нагрузка действительно снижается. При переключении также наблюдается значительный скачок длительности кадра. Это произошло потому, что режим воспроизведения временно приостанавливается при изменении через редактор. Позже также произошли небольшие скачки из-за деселекции и изменения фокуса редактора.
Скачки относятся к категории Other. График процессора можно отфильтровать, переключив метки категорий слева, чтобы мы могли видеть только релевантные данные. При отключенной категории Other изменение объема вычислений становится более очевидным.
Переключение функций через инспектор неудобно для профилирования из-за пауз. Еще хуже то, что нам приходится создавать новую сборку для профилирования другой функции. Мы могли бы улучшить это, добавив возможность переключения функций в наш график, помимо использования инспектора, автоматически или с помощью пользовательского ввода. В этом руководстве мы рассмотрим первый вариант.
3.1 Циклический переход по функциям
Мы будем автоматически переключаться между всеми функциями. Каждая функция будет отображаться в течение фиксированного времени, после чего будет показана следующая. Чтобы настроить длительность функции, добавьте к Graph сериализуемое поле для этого, со значением по умолчанию одна секунда. Также установите его минимальное значение равным нулю, присвоив ему атрибут Min. Нулевая длительность приведет к переключению на другую функцию в каждом кадре.
[SerializeField]
FunctionLibrary.FunctionName function;
[SerializeField, Min(0f)]
float functionDuration = 1f;
Теперь нам нужно будет отслеживать, как долго активна текущая функция и переключаться на следующую, когда это необходимо. Это усложнит наш метод Update. Его текущий код обрабатывает исключительно обновление текущей функции, поэтому давайте перенесем его в отдельный метод UpdateFunction и вызовем его из Update. Это упорядочит наш код.
void Update () {
UpdateFunction();
}
void UpdateFunction () {
FunctionLibrary.Function f = FunctionLibrary.GetFunction(function);
float time = Time.time;
float step = 2f / resolution;
float v = 0.5f * step - 1f;
for (int i = 0, x = 0, z = 0; i < points.Length; i++, x++) { … }
}
Теперь добавьте поле duration и увеличьте его на значение delta time (возможно масштабированное) в начале Update. Затем, если длительность равна или превышает заданное значение, сбросьте ее обратно на ноль. После этого вызывайте UpdateFunction.
Transform[] points;
float duration;
…
void Update () {
duration += Time.deltaTime;
if (duration >= functionDuration) {
duration = 0f;
}
UpdateFunction();
}
Скорее всего, мы никогда не достигнем длительности функции, а немного превысим её. Мы могли бы проигнорировать это, но для того, чтобы обеспечить разумную синхронизацию с исключенным временем переключения функций, нам следует вычесть лишнее время из длительности следующей функции. Мы делаем это, вычитая желаемую продолжительность из текущей вместо того, чтобы устанавливать ее равной нулю.
if (duration >= functionDuration) {
duration -= functionDuration;
}
Чтобы переключаться между функциями, добавим метод GetNextFunctionName в FunctionLibrary, который принимает имя функции и возвращает следующую. Поскольку перечисления (enums) являются целыми числами, мы можем просто добавить единицу к его параметру и вернуть это значение.
public static FunctionName GetNextFunctionName (FunctionName name) {
return name + 1;
}
Но мы также должны вернуться к первой функции, а не переходить к последней, иначе мы получим недопустимое имя. Таким образом, только если указанное имя меньше torus, мы можем увеличить его. В противном случае мы возвращаем первую функцию, wave. Мы можем сделать это с помощью блоков if-else, каждый из которых возвращает соответствующий результат.
if (name < FunctionName.Torus) {
return name + 1;
}
else {
return FunctionName.Wave;
}
Можно сделать этот метод независимым от имени функции, сравнив имя (как int) с длиной массива функций минус единица, которая соответствует индексу последней функции. Если мы находимся в конце, мы также можем вернуть ноль, который является первым индексом. Преимущество этого подхода заключается в том, что нам не придется корректировать метод, если мы позже изменим имена функций.
if ((int)name < functions.Length - 1) {
return name + 1;
}
else {
return 0;
}
Также возможно сократить тело метода до одного выражения, используя тернарный условный оператор ?:. Это выражение if-then-else с ? и : разделяющими его части. Оба варианта должны выдавать значение одного и того же типа.
public static FunctionName GetNextFunctionName (FunctionName name) {
return (int)name < functions.Length - 1 ? name + 1 : 0;
}
Используйте новый метод в Graph.Update, чтобы переключаться на следующую функцию, когда это необходимо.
if (duration >= functionDuration) {
duration -= functionDuration;
function = FunctionLibrary.GetNextFunctionName(function);
}
Теперь мы можем видеть производительность всех функций последовательно, профилируя сборку.
В моем случае частота кадров одинакова для всех функций, потому что она никогда не опускалась ниже 60 кадров в секунду. Профилирование сборки с включенной функцией VSync делает разницу более очевидной. В качестве альтернативы можно отображать только скрипты в профилировщике.
Оказывается, что быстрее всего работает Wave, за ним следует Ripple, затем Multi Wave, после этого Sphere, а медленнее всего работает Torus. Это соответствует тому, что мы ожидали, зная код.
3.2 Случайные функции
Давайте сделаем наш график немного интереснее, добавив возможность произвольного переключения между функциями вместо циклического прохождения фиксированной последовательности. Добавьте метод GetRandomFunctionName в FunctionLibrary для этого. Он может выбрать случайный индекс, вызвав Random.Range с нулем и длиной массива функций в качестве аргумента. Выбранный индекс будет допустимым, поскольку это целочисленная версия метода, для которой предоставленный диапазон является включающим-исключающим.
public static FunctionName GetRandomFunctionName () {
var choice = (FunctionName)Random.Range(0, functions.Length);
return choice;
}
Мы можем пойти еще дальше и убедиться, что никогда не получим одну и ту же функцию дважды подряд. Для этого переименуйте наш новый метод в GetRandomFunctionNameOtherThan и добавьте параметр function name. Увеличьте первый аргумент Random.Range до 1, чтобы нулевой индекс никогда не выбирался случайным образом. Затем проверьте, совпадает ли выбранное значение с именем, которого следует избегать. Если это так, верните первое имя, в противном случае - выбранное. Таким образом, мы заменяем нулем запрещенный индекс, не вводя предвзятость выбора.
public static FunctionName GetRandomFunctionNameOtherThan (FunctionName name) {
var choice = (FunctionName)Random.Range(1, functions.Length);
return choice == name ? 0 : choice;
}
Вернувшись к Graph, добавьте параметр конфигурации для режима перехода: циклический или случайный. Снова сделайте это с помощью пользовательского поля enum.
[SerializeField]
FunctionLibrary.FunctionName function;
public enum TransitionMode { Cycle, Random }
[SerializeField]
TransitionMode transitionMode;
При выборе следующей функции проверьте, установлен ли режим перехода на циклический. Если это так, вызовите GetNextFunctionName, иначе GetRandomFunctionName. Поскольку это усложняет выбор следующей функции, давайте вынесем этот код в отдельный метод, чтобы упростить Update.
void Update () {
duration += Time.deltaTime;
if (duration >= functionDuration) {
duration -= functionDuration;
PickNextFunction();
}
UpdateFunction();
}
void PickNextFunction () {
function = transitionMode == TransitionMode.Cycle ? FunctionLibrary.GetNextFunctionName(function) : FunctionLibrary.GetRandomFunctionNameOtherThan(function);
}
3.3 Интерполирующие функции
В завершение этого руководства мы сделаем переход между функциями ещё интереснее. Вместо того, чтобы внезапно переключаться на другую функцию, мы плавно преобразуем наш график в следующий. Нас также интересует профилирование производительности, поскольку требуется одновременного вычисления двух функций во время перехода.
Начните с добавления функции Morph в FunctionLibrary, которая будет отвечать за переход. Задайте ей те же параметры, что и методам функции, плюс два параметра Function и параметр float для управления прогрессом морфинга.
public static Vector3 Morph (
float u, float v, float t, Function from, Function to, float progress
) {}
Мы используем параметры Function вместо параметров FunctionName, потому что таким образом Graph может получать функции по имени один раз за обновление, поэтому нам не нужно будет дважды обращаться к массиву функций для каждой точки.
Прогресс - это значение от 0 до 1, которое мы будем использовать для интерполяции от первой предоставленной функции ко второй. Для этого мы можем использовать функцию Vector3.Lerp, передавая ей результат выполнения обеих функций и значение прогресса.
public static Vector3 Morph (
float u, float v, float t, Function from, Function to, float progress
) {
return Vector3.Lerp(from(u, v, t), to(u, v, t), progress);
}
Lerp - это сокращение от линейной интерполяции. Она обеспечит прямой переход с постоянной скоростью между функциями. Мы можем сделать его более плавным, замедлив выполнение ближе к началу и концу. Это делается путем замены исходного выполнения вызовом Mathf.Smoothstep с аргументами 0, 1 и прогресс. Применяется функция 3x^2−2x^3, широко известная как smoothstep. Первые два параметра Smoothstep являются смещением и масштабом для этой функции, которые нам не нужны, поэтому используем 0 и 1.
return Vector3.Lerp(from(u, v, t), to(u, v, t), SmoothStep(0f, 1f, progress));
Метод Lerp ограничивает свой третий аргумент так, чтобы он находился в диапазоне от 0 до 1. Метод Smoothstep делает то же самое. Мы настроили последний на вывод значения от 0 до 1, поэтому дополнительное ограничение Lerp не требуется. Для подобных случаев существует альтернативный метод LerpUnclamped, так что давайте воспользуемся им вместо этого.
return Vector3.LerpUnclamped(
from(u, v, t), to(u, v, t), SmoothStep(0f, 1f, progress)
);
3.4 Переход
Период перехода между функциями требует определенной длительности, поэтому добавьте опцию конфигурации для этого в Graph с теми же минимальными и стандартными значениями, что и для длительности функции.
[SerializeField, Min(0f)]
float functionDuration = 1f, transitionDuration = 1f;
Теперь наш график может работать в двух режимах: либо в режиме перехода, либо нет. Мы будем отслеживать это с помощью логического поля, которое имеет тип bool. Нам также нужно отслеживать название функции, из которой мы выполняем переход.
float duration;
bool transitioning;
FunctionLibrary.FunctionName transitionFunction;
Метод UpdateFunction работает для отображения одной функции. Дублируйте его и переименуйте новый в UpdateFunctionTransition. Измените его так, чтобы он получал обе функции и вычислял прогресс, который представляет собой текущую длительность, деленную на длительность перехода. Затем он должен вызывать Morph вместо одной функции в своем цикле.
void UpdateFunctionTransition () {
FunctionLibrary.Function
from = FunctionLibrary.GetFunction(transitionFunction),
to = FunctionLibrary.GetFunction(function);
float progress = duration / transitionDuration;
float time = Time.time;
float step = 2f / resolution;
float v = 0.5f * step - 1f;
for (int i = 0, x = 0, z = 0; i < points.Length; i++, x++) {
…
points[i].localPosition = FunctionLibrary.Morph(
u, v, time, from, to, progress
);
}
}
В конце Update проверьте, находимся ли мы в состоянии перехода. Если это так, вызовите UpdateFunctionTransition, иначе UpdateFuction.
void Update () {
duration += Time.deltaTime;
if (duration >= functionDuration) {
duration -= functionDuration;
PickNextFunction();
}
if (transitioning) {
UpdateFunctionTransition();
}
else {
UpdateFunction();
}
}
Как только длительность превышает длительность функции, мы переходим к следующей. Прежде чем выбрать следующую функцию, укажите, что мы находимся в состоянии перехода, и сделайте функцию перехода равной текущей функции.
if (duration >= functionDuration) {
duration -= functionDuration;
transitioning = true;
transitionFunction = function;
PickNextFunction();
}
Но если мы уже находимся в состоянии перехода, нам нужно сделать что-то ещё. Поэтому сначала проверьте, находимся ли мы в состоянии перехода. Если это не так, мы должны проверить, не превысили ли мы длительность функции.
duration += Time.deltaTime;
if (transitioning) {}
else if (duration >= functionDuration) {
duration -= functionDuration;
transitioning = true;
transitionFunction = function;
PickNextFunction();
}
Если мы находимся в состоянии перехода, то мы должны проверить, не превысили ли мы длительность перехода. вычтите длительность перехода из текущей длительности и переключитесь обратно в режим одной функции.
if (transitioning) {
if (duration >= transitionDuration) {
duration -= transitionDuration;
transitioning = false;
}
}
else if (duration >= functionDuration) { … }
Если теперь провести профилирование, можно увидеть, что действительно во время переходов Graph.Update занимает значительно больше времени. Точное время, которое оно занимает, зависит от того, между какими функциями оно выполняется.
Стоит повторить, что результаты профилирования, которые вы получаете, зависят от вашего оборудования и могут сильно отличаться от примеров, которые я показал в этом руководстве. При разработке собственного приложения определите, какие минимальные требования оборудования вы поддерживаете, и тестируйте на них. Ваша машина для разработки предназначена только для предварительного тестирования.