Исправление Time.deltaTime в Unity 2020.2 для более плавного игрового процесса: Что для этого потребовалось?

В бета-версии Unity 2020.2 представлено исправление проблемы, от которой страдают многие платформы разработки: непоследовательные значения Time.deltaTime, которые приводят к рывкам и заиканиям. Прочитайте эту статью в блоге, чтобы понять, что происходило, и как новая версия Unity поможет Вам создать немного более плавный игровой процесс.
С момента появления игр достижение независимого от кадров движения в видеоиграх означало учет времени дельты кадра:
void Update()
{
transform.position += m_Velocity * Time.deltaTime;
}
Это позволяет добиться желаемого эффекта, когда объект движется с постоянной средней скоростью, независимо от частоты кадров, на которой работает игра. Теоретически, он также должен перемещать объект в постоянном темпе, если Ваша частота кадров стабильна. На практике картина совершенно иная. Если бы Вы посмотрели на реальные значения Time.deltaTime, Вы могли бы увидеть следующее:
6.854 ms
7.423 ms
6.691 ms
6.707 ms
7.045 ms
7.346 ms
6.513 ms
Эта проблема затрагивает многие игровые движки, включая Unity, и мы благодарны нашим пользователям за то, что они обратили на нее наше внимание. К счастью, бета-версия Unity 2020.2 начинает решать эту проблему.
Так почему же это происходит? Почему, когда частота кадров зафиксирована на постоянном значении 144 fps, Time.deltaTime не равна 1⁄144 секунды (~6,94 мс) каждый раз? В этой статье я расскажу Вам о том, как исследовать и, в конечном счете, устранить это явление.
Говоря простым языком, дельта-время - это количество времени, которое потребовалось Вашему последнему кадру для завершения. Звучит просто, но это не так интуитивно понятно, как Вам может показаться. В большинстве книг по разработке игр Вы найдете это каноническое определение игрового цикла:
while (true)
{
ProcessInput();
Update();
Render();
}
С помощью такого игрового цикла легко вычислить дельта-время:
var time = GetTime();
while (true)
{
var lastTime = time;
time = GetTime();
var deltaTime = time - lastTime;
ProcessInput();
Update(deltaTime);
Render(deltaTime);
}
Хотя эта модель проста и понятна, она крайне неадекватна для современных игровых движков. Чтобы достичь высокой производительности, сегодня в процессорах используется техника, называемая "конвейеризацией", которая позволяет процессору работать над несколькими кадрами в каждый момент времени.
Сравните с этим:

К этому:

В обоих случаях отдельные части игрового цикла занимают одинаковое количество времени, но во втором случае они выполняются параллельно, что позволяет выдать более чем в два раза больше кадров за то же время. При конвейеризации двигателя время кадра меняется с равного сумме всех этапов конвейера на равное самому длинному из них.
Однако даже это является упрощением того, что на самом деле происходит каждый кадр в движке:
- Каждый этап конвейера занимает разное количество времени в каждом кадре. Возможно, в этом кадре на экране больше объектов, чем в предыдущем, из-за чего рендеринг занимает больше времени. Или, может быть, игрок нажимал на клавиатуру лицом, из-за чего обработка ввода занимала больше времени.
- Поскольку разные этапы конвейера занимают разное количество времени, нам нужно искусственно приостановить более быстрые, чтобы они не слишком сильно опережали события. Чаще всего это реализуется путем ожидания, пока какой-либо предыдущий кадр не будет переброшен в передний буфер (также известный как буфер экрана). Если включена функция VSync, она дополнительно синхронизируется с началом периода VBLANK дисплея. Я подробнее остановлюсь на этом позже.
Учитывая эти знания, давайте посмотрим на типичную временную шкалу кадров в Unity 2020.1. Поскольку выбор платформы и различные настройки существенно влияют на него, в этой статье предполагается, что автономный плеер Windows с включенным многопоточным рендерингом, отключенными графическими заданиями, включенным vsync и QualitySettings.maxQueuedFrames, установленным на 2, работает на мониторе 144 Гц без выпадения кадров. Нажмите на изображение, чтобы увидеть его в полном размере:

Конвейер кадров Unity не был реализован с нуля. Вместо этого он развивался в течение последнего десятилетия, чтобы стать тем, чем он является сегодня. Если Вы обратитесь к прошлым версиям Unity, то обнаружите, что она меняется каждые несколько выпусков.
Вы можете сразу же заметить в нем несколько вещей:
- Как только вся работа передана на GPU, Unity не ждет, пока этот кадр будет переведен на экран: вместо этого он ждет предыдущего. Это контролируется API QualitySettings.maxQueuedFrames. Этот параметр описывает, насколько далеко отображаемый кадр может находиться за кадром, который в данный момент рендерится. Минимально возможное значение - 1, поскольку лучшее, что Вы можете сделать, это отрисовать кадр+1, когда кадр отображается на экране. Поскольку в данном случае оно установлено на 2 (что является значением по умолчанию), Unity убедится, что кадрen отобразится на экране до того, как начнется рендеринг кадра framen+2 (например, прежде чем Unity начнет рендеринг кадра5, он подождет, пока на экране появится кадр3).
- Для рендеринга кадра5 на GPU требуется больше времени, чем для одного интервала обновления монитора (7,22 мс против 6,94 мс); однако ни один из кадров не выпадает. Это происходит потому, что QualitySettings.maxQueuedFrames со значением 2 задерживает момент появления реального кадра на экране, что создает буфер времени, который защищает от выпадения кадров, пока "всплеск" не становится нормой. Если бы этот параметр был равен 1, Unity, несомненно, отбросила бы кадр, поскольку он больше не перекрывал бы работу.
Несмотря на то, что обновление экрана происходит каждые 6,94 мс, временная выборка Unity представляет другую картину:

Среднее значение дельта-времени в данном случае ((7,27 + 6,64 + 7,03)/3 = 6,98 мс) очень близко к реальной частоте обновления монитора (6,94 мс), и если бы Вы измеряли его в течение более длительного периода времени, то в конечном итоге оно составило бы ровно 6,94 мс. К сожалению, если Вы используете это дельта-время для расчета видимого движения объекта, Вы внесете очень тонкий флуктуационный эффект. Чтобы проиллюстрировать это, я создал простой проект Unity. Он содержит три зеленых квадрата, перемещающихся по мировому пространству:
- Верхний квадрат перемещается на одинаковое расстояние каждый кадр - этот квадрат представляет собой идеальное движение и является нашей точкой отсчета. Он окружен двумя красными вертикальными линиями, чтобы было легче определить, выровнены ли другие кубики относительно него.
- Средний квадрат перемещается на расстояние, на которое верхний куб перемещается в течение секунды, умноженное на Time.deltaTime.
- Нижний квадрат использует Rigidbody для перемещения (с включенной интерполяцией), а его скорость установлена на расстояние, которое верхний квадрат проходит за секунду.
Камера прикреплена к верхнему кубику, поэтому на экране она выглядит совершенно неподвижной. Если Time.deltaTime точен, средний и нижний кубики будут выглядеть неподвижными. Каждую секунду кубики перемещаются на расстояние, вдвое превышающее ширину дисплея: чем выше скорость, тем более заметным становится дрожание. Чтобы проиллюстрировать движение, я поместил фиолетовые и розовые неподвижные кубики в фиксированные позиции на заднем плане, чтобы Вы могли определить, как быстро кубики на самом деле движутся.
В Unity 2020.1 средний и нижний кубы не совсем соответствуют движению верхнего куба - они слегка дрожат. Ниже представлено видео, снятое с помощью камеры замедленной съемки (замедление в 20 раз):
Так откуда же берутся эти несоответствия дельта-времени? Дисплей показывает каждый кадр в течение фиксированного количества времени, меняя изображение каждые 6,94 мс. Это реальное дельта-время, потому что именно столько времени требуется кадру, чтобы появиться на экране, и именно столько времени игрок Вашей игры будет наблюдать за каждым кадром.
Каждый интервал 6,94 мс состоит из двух частей: обработка и сон. Пример Timeline показывает, что время дельты вычисляется в главном потоке, поэтому на нем мы и сосредоточимся. Обработка главного потока состоит из перекачки сообщений ОС, обработки ввода, вызова Update и выдачи команд рендеринга. "Ждать потока рендеринга" - это спящая часть. Сумма этих двух интервалов равна реальному времени кадра:

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

Однако Unity запрашивает время в начале обновления. Поэтому любые колебания во времени, которое требуется для подачи команд рендеринга, перекачки сообщений ОС или обработки событий ввода, будут искажать результат.
Упрощенный цикл главного потока Unity можно определить следующим образом:
while (!ShouldQuit())
{
PumpOSMessages();
UpdateInput();
SampleTime(); // We sample time here!
Update();
WaitForRenderThread();
IssueRenderingCommands();
}
Решение этой проблемы кажется простым: просто переместите выборку времени после ожидания, чтобы игровой цикл стал таким:
while (!ShouldQuit())
{
PumpOSMessages();
UpdateInput();
Update();
WaitForRenderThread();
SampleTime();
IssueRenderingCommands();
}
Однако это изменение работает некорректно: рендеринг имеет другие показания времени, чем Update(), что негативно сказывается на всевозможных вещах. Один из вариантов - сохранить время выборки в этот момент и обновить время работы двигателя только в начале следующего кадра. Однако это означает, что движок будет использовать время, прошедшее до рендеринга последнего кадра.
Поскольку перемещение SampleTime() после Update() неэффективно, возможно, перемещение ожидания в начало кадра будет более успешным:
while (!ShouldQuit())
{
PumpOSMessages();
UpdateInput();
WaitForRenderThread();
SampleTime();
Update();
IssueRenderingCommands();
}
К сожалению, это вызывает другую проблему: теперь поток рендеринга должен завершить рендеринг почти сразу после запроса, а это значит, что поток рендеринга получит лишь минимальную выгоду от параллельной работы.
Давайте снова посмотрим на временную шкалу кадров:

Unity обеспечивает синхронизацию конвейера, ожидая поток рендеринга каждый кадр. Это необходимо для того, чтобы основной поток не убегал слишком далеко вперед от того, что отображается на экране. Поток рендеринга считается "закончившим работу", когда он завершает рендеринг и ожидает появления кадра на экране. Другими словами, он ждет, пока задний буфер перевернется и станет передним буфером. Однако поток рендеринга не заботится о том, когда предыдущий кадр был выведен на экран - это волнует только основной поток, потому что ему нужно дросселировать себя. Поэтому вместо того, чтобы поток рендеринга ждал, пока кадр появится на экране, это ожидание можно перенести в основной поток. Давайте назовем его WaitForLastPresentation(). Главный цикл потока становится:
while (!ShouldQuit())
{
PumpOSMessages();
UpdateInput();
WaitForLastPresentation();
SampleTime();
Update();
WaitForRenderThread();
IssueRenderingCommands();
}
Теперь время отбирается сразу после завершения цикла ожидания, поэтому время будет соответствовать частоте обновления монитора. Время также отсчитывается в начале кадра, поэтому Update() и Render() видят одинаковые тайминги.
Очень важно отметить, что WaitForLastPresention() не ждет, пока на экране появится кадр - 1. Если бы это было так, то конвейеризация вообще не проводилась бы. Вместо этого он ожидает появления на экране кадра framen - QualitySettings.maxQueuedFrames, что позволяет главному потоку продолжить работу, не дожидаясь завершения последнего кадра (если только maxQueuedFrames не установлено в 1, в этом случае каждый кадр должен быть завершен перед началом нового).
После внедрения этого решения дельта-время стало гораздо более стабильным, чем раньше, но некоторое дрожание и случайные отклонения все же имели место. Мы зависим от того, что операционная система вовремя пробуждает двигатель ото сна. Это может занять несколько микросекунд и, следовательно, внести дрожание в дельта-время, особенно на настольных платформах, где одновременно запущено несколько программ.
Чтобы улучшить синхронизацию, Вы можете использовать точную временную метку кадра, выводимого на экран (или внеэкранный буфер), которую большинство графических API/платформ позволяют Вам извлекать. Например, в Direct3D 11 и 12 есть IDXGISwapChain::GetFrameStatistics, а в macOS - CVDisplayLink. Однако у такого подхода есть несколько минусов:
- Вам нужно написать отдельный код извлечения для каждого поддерживаемого графического API, а это значит, что код измерения времени теперь зависит от платформы, и для каждой платформы есть своя отдельная реализация. Поскольку каждая платформа ведет себя по-разному, подобное изменение рискует привести к катастрофическим последствиям.
- В некоторых графических API, чтобы получить эту временную метку, необходимо включить VSync. Это означает, что если VSync отключен, время все равно придется рассчитывать вручную.
Однако я считаю, что такой подход стоит риска и усилий. Результат, полученный с помощью этого метода, очень надежен и дает тайминги, которые прямо соответствуют тому, что видно на дисплее.
Поскольку нам больше не нужно самостоятельно определять время, шаги WaitForLastPresention() и SampleTime() объединяются в новый шаг:
while (!ShouldQuit())
{
PumpOSMessages();
UpdateInput();
WaitForLastPresentationAndGetTimestamp();
Update();
WaitForRenderThread();
IssueRenderingCommands();
}
Таким образом, проблема резких движений решена.
Входная задержка - сложный вопрос. Его не так просто точно измерить, и он может быть вызван различными факторами: аппаратным обеспечением ввода, операционной системой, драйверами, игровым движком, игровой логикой и дисплеем. Здесь я сосредоточусь на факторе игрового движка, связанном с задержкой ввода, поскольку Unity не может повлиять на другие факторы.
Задержка ввода движка - это время между появлением входного сообщения ОС и отправкой изображения на дисплей. Учитывая цикл основного потока, Вы можете визуализировать входную задержку как часть кода (при условии, что QualitySettings.maxQueuedFrames установлено на 2):
PumpOSMessages(); // Pump input OS messages for frame 0
UpdateInput(); // Process input for frame 0
--------------------- // Earliest input event from the OS that didn't become part of frame 0 arrives here!
WaitForLastPresentationAndGetTimestamp(); // Wait for frame -2 to appear on the screen
Update(); // Update game state for frame 0
WaitForRenderThread(); // Wait until all commands from frame -1 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 0 to the rendering thread
PumpOSMessages(); // Pump input OS messages for frame 1
UpdateInput(); // Process input for frame 1
WaitForLastPresentationAndGetTimestamp(); // Wait for frame -1 to appear on the screen
Update(); // Update game state for frame 1, finally seeing the input event that arrived
WaitForRenderThread(); // Wait until all commands from frame 0 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 1 to the rendering thread
PumpOSMessages(); // Pump input OS messages for frame 2
UpdateInput(); // Process input for frame 2
WaitForLastPresentationAndGetTimestamp(); // Wait for frame 0 to appear on the screen
Update(); // Update game state for frame 2
WaitForRenderThread(); // Wait until all commands from frame 1 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 2 to the rendering thread
PumpOSMessages(); // Pump input OS messages for frame 3
UpdateInput(); // Process input for frame 3
WaitForLastPresentationAndGetTimestamp(); // Wait for frame 1 to appear on the screen. This is where the changes from our input event appear.
Фух, вот и все! Между тем, как ввод стал доступен в виде сообщения ОС, и тем, как его результаты стали видны на экране, происходит довольно много событий. Если Unity не сбрасывает кадры, а время, затрачиваемое игровым циклом, в основном связано с ожиданием, а не с обработкой, то наихудший сценарий задержки ввода от движка для частоты обновления 144 Гц составляет 4 * 6,94 = 27,76 мс, поскольку мы ждем, пока предыдущие кадры появятся на экране четыре раза (это означает четыре интервала частоты обновления).
Вы можете улучшить задержку, прокачивая события ОС и обновляя ввод после ожидания отображения предыдущего кадра:
while (!ShouldQuit())
{
WaitForLastPresentationAndGetTimestamp();
PumpOSMessages();
UpdateInput();
Update();
WaitForRenderThread();
IssueRenderingCommands();
}
Это исключает одно ожидание из уравнения, и теперь наихудшая задержка ввода составляет 3 * 6,94 = 20,82 мс.
Можно еще больше уменьшить входную задержку, уменьшив QualitySettings.maxQueuedFrames до 1 на платформах, которые это поддерживают. Затем цепочка обработки входных данных выглядит следующим образом:
--------------------- // Input event arrives from the OS!
WaitForLastPresentationAndGetTimestamp(); // Wait for frame -2 to appear on the screen
PumpOSMessages(); // Pump input OS messages for frame 0
UpdateInput(); // Process input for frame 0
Update(); // Update game state for frame 0 with the input event that we are measuring
WaitForRenderThread(); // Wait until all commands from frame -1 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 0 to the rendering thread
WaitForLastPresentationAndGetTimestamp(); // Wait for frame 0 to appear on the screen. This is where the changes from our input event appear.
Теперь наихудшая задержка на входе составляет 2 * 6,94 = 13,88 мс. Это настолько низкий уровень, насколько мы можем достичь при использовании VSync.
Внимание: Установка значения QualitySettings.maxQueuedFrames в 1, по сути, отключит конвейеризацию в движке, что значительно усложнит достижение целевой частоты кадров. Имейте в виду, что если Вы в конечном итоге будете работать с более низкой частотой кадров, Ваша входная задержка, скорее всего, будет хуже, чем если бы Вы сохранили QualitySettings.maxQueuedFrames равным 2. Например, если из-за этого Вы снизите скорость до 72 кадров в секунду, Ваша входная задержка составит 2 * 1⁄72 = 27,8 мс, что хуже, чем предыдущая задержка в 20,82 мс. Если Вы хотите использовать эту настройку, мы советуем Вам добавить ее в качестве опции в меню настроек игры, чтобы геймеры с быстрым оборудованием могли уменьшить QualitySettings.maxQueuedFrames, а геймеры с медленным оборудованием могли оставить настройку по умолчанию.
Отключение VSync также может помочь уменьшить задержку ввода в определенных ситуациях. Напомним, что задержка ввода - это количество времени, которое проходит между тем, как ввод становится доступным для ОС, и тем, как кадр, обработавший ввод, отображается на экране, или, в виде математического уравнения:
Латентность = tdisplay - tinput
Учитывая это уравнение, есть два способа уменьшить задержку ввода: либо сделать tdisplay меньше (выводить изображение на дисплей раньше), либо сделать tinput больше (запрашивать события ввода позже).
Передача данных изображения с GPU на дисплей требует огромного объема данных. Просто посчитайте: чтобы передать изображение 2560x1440 без HDR на дисплей 144 раза в секунду, необходимо передавать 12,7 гигабита каждую секунду (24 бита на пиксель * 2560 * 1440 * 144). Эти данные не могут быть переданы мгновенно: графический процессор постоянно передает пиксели на дисплей. После передачи каждого кадра наступает короткая пауза, и начинается передача следующего кадра. Этот период перерыва называется VBLANK. Когда VSync включен, Вы, по сути, говорите ОС перевернуть кадровый буфер только во время VBLANK:

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

Это явление известно как "разрыв". Разрыв позволяет нам уменьшить tdisplay для нижней части кадра, жертвуя визуальным качеством и плавностью анимации ради задержки ввода. Это особенно эффективно, когда частота кадров в игре ниже, чем интервал VSync, что позволяет частично восстановить задержку, вызванную пропуском VSync. Он также более эффективен в играх, где верхняя часть экрана занята пользовательским интерфейсом или скайбоксом, что затрудняет обнаружение разрывов.
Еще один способ отключения VSync может помочь уменьшить задержку ввода - это увеличение tinput. Если игра способна рендерить с частотой кадров, значительно превышающей частоту обновления (например, 150 fps на дисплее 60 Гц), то отключение VSync заставит игру перекачивать события ОС несколько раз за каждый интервал обновления, что сократит среднее время их нахождения в очереди ввода ОС в ожидании обработки движком.
Помните, что отключение VSync должно в конечном итоге зависеть от игрока в Вашей игре, поскольку оно влияет на качество изображения и может вызвать тошноту, если разрыв будет заметен. Лучшей практикой является предоставление опции настроек в Вашей игре, чтобы включить/выключить ее, если она поддерживается платформой.
С этим исправлением временная шкала кадров Unity выглядит следующим образом:

Но действительно ли это улучшает плавность перемещения объектов? Еще бы!
Мы запустили демонстрацию Unity 2020.1, которую мы показали в начале этого поста, в Unity 2020.2.0b1. Вот получившееся замедленное видео:
Это исправление доступно в бета-версии 2020.2 для этих платформ и графических API:
- Windows, Xbox One, Универсальная платформа Windows (D3D11 и D3D12)
- macOS, iOS, tvOS (Metal)
- PlayStation 4
- Switch
В ближайшем будущем мы планируем внедрить это для остальных поддерживаемых нами платформ.
Следите за обновлениями в этой ветке форума и дайте нам знать, что Вы думаете о нашей работе на данный момент.
- Неуловимая синхронизация кадров, статья Алена Ладавака
- Контроллер для отображения задержки в Call of Duty, презентация Акимицу Хогге на GDC 2019

Если Вам интересно узнать больше о том, что будет доступно в версии 2020.2, ознакомьтесь с бета-версией в блоге и зарегистрируйтесь для участия в вебинаре по бета-версии Unity 2020.2. Недавно мы также рассказали о наших планах на 2021 год.