修复 Unity 2020.2 中的 Time.deltaTime,使游戏更流畅:需要什么?

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 ms)?在这篇博文中,我将带您踏上调查并最终解决这一现象的旅程。
通俗地说,delta 时间就是上一帧完成所需的时间。这听起来很简单,但并不像你想象的那么直观。在大多数游戏开发书籍中,你都能找到关于游戏循环的经典定义:
while (true)
{
ProcessInput();
Update();
Render();
}
有了这样一个游戏循环,就很容易计算出 delta 时间:
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 Hz 显示器上运行时不会掉帧。点击图片查看全尺寸:

Unity 的帧流水线并不是从零开始实现的。相反,它在过去十年中不断发展,才有了今天的面貌。如果回溯 Unity 过去的版本,你会发现它每隔几个版本就会发生变化。
您可能会立即注意到它的一些特点:
- 一旦所有工作都提交给 GPU,Unity 不会等待该帧被翻转到屏幕上:相反,它会等待上一帧。这由 QualitySettings.maxQueuedFrames API 控制。该设置描述了当前显示的帧与当前渲染的帧之间的距离。可能的最小值是 1,因为在屏幕上显示 frameen 时,只能呈现 frameen+1。由于在本例中将其设置为 2(这是默认值),Unity 会在开始渲染 frameen+2 之前确保 frameen 显示在屏幕上(例如,Unity 在开始渲染 frame5 之前,会等待 frame3 出现在屏幕上)。
- 第 5 帧在 GPU 上的渲染时间比显示器的单次刷新间隔时间要长(7.22 毫秒对 6.94 毫秒),但没有丢帧。出现这种情况的原因是,QualitySettings.maxQueuedFrames 的值为 2 时,实际帧出现在屏幕上的时间会延迟,这样就会产生一个缓冲时间,只要 "尖峰 "不成为常态,就能防止丢帧。如果设置为 1,Unity 肯定会放弃该帧,因为它将不再与作品重叠。
尽管屏幕每 6.94 毫秒刷新一次,Unity 的时间采样还是呈现出不同的画面:

在这种情况下,delta 时间平均值((7.27 + 6.64 + 7.03)/3 = 6.98 ms)非常接近实际显示器刷新率(6.94 ms),如果测量时间较长,最终平均值将正好为 6.94 ms。遗憾的是,如果使用这个 delta 时间来计算可见物体的移动,就会产生非常微妙的抖动。为了说明这一点,我创建了一个简单的Unity 项目。它包含三个在世界空间中移动的绿色方块:
- 最上面的正方形每帧移动的距离相同--这个正方形代表完美的移动,是我们的参照点。它的周围有两条红色竖线,可以更容易地看到其他方块是否与它对齐。
- 中间方块的移动距离是顶部方块在一秒钟内移动的距离乘以 Time.deltaTime。
- 底部方块使用Rigidbody 移动(启用插值),其速度设置为顶部方块在一秒钟内移动的距离。
摄像头连接在顶端的立方体上,因此在屏幕上看起来完全静止不动。如果 Time.deltaTime 是准确的,那么中间和底部的立方体看起来也是静止的。立方体每秒移动的宽度是显示屏宽度的两倍:速度越快,抖动越明显。为了说明移动情况,我在背景中的固定位置放置了紫色和粉色的不动方块,这样你就能知道方块的实际移动速度。
在 Unity 2020.1 中,中间和底部的立方体与顶部立方体的移动不太一致,它们会轻微抖动。下面是一段用慢动作摄像机拍摄的视频(放慢了 20 倍):
那么,这些三角洲时间的不一致性从何而来?显示屏显示每帧画面的时间固定,每 6.94 毫秒更换一次画面。这就是真正的 delta 时间,因为这就是一帧画面出现在屏幕上所需的时间,也是游戏玩家观察每一帧画面所需的时间。
每个 6.94 毫秒的时间间隔由两部分组成:处理和睡眠。从示例帧 Timeline 可以看出,Delta 时间是在主线程上计算的,因此它将是我们的主要关注点。主线程的处理部分包括抽取操作系统信息、处理输入、调用 Update 和发出渲染命令。"等待渲染线程 "是睡眠部分。这两个时间间隔的总和等于实际帧时间:

由于各种原因,这两个时序在每一帧都会波动,但它们的总和保持不变。如果处理时间增加,等待时间就会减少,反之亦然,因此它们总是正好相等于 6.94 毫秒。事实上,导致等待的所有部分的总和始终等于 6.94 毫秒:

不过,Unity 在Update开始时会查询时间。因此,发出渲染命令、抽取操作系统信息或处理输入事件所需的任何时间变化都会影响结果。
一个简化的 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 出现在屏幕上。如果是这样,就根本不需要流水线了。相反,它会等待 frameen - QualitySettings.maxQueuedFrames 出现在屏幕上,这使得主线程无需等待最后一帧完成即可继续运行(除非 maxQueuedFrames 设置为 1,在这种情况下,每一帧都必须在新帧开始前完成)。
实施这一解决方案后,三角洲时间变得比以前稳定得多,但仍会出现一些抖动和偶尔的偏差。我们依赖于操作系统按时将引擎从睡眠中唤醒。这可能需要多微秒的时间,因此会给 delta 时间带来抖动,尤其是在同时运行多个程序的桌面平台上。
为了改进计时,您可以使用显示到屏幕上的帧的精确时间戳(或屏幕外的缓冲区),大多数图形 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 没有丢帧,而且游戏循环所花费的时间与处理时间相比主要是等待时间,那么在 144hz 刷新率下,引擎输入延迟的最坏情况是 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 传输到显示器需要大量数据。计算一下:每秒向显示器发送 144 次 2560x1440 非 HDR 图像,需要每秒传输 12.7 千兆比特(每个像素 24 比特 * 2560 * 1440 * 144)。这些数据无法在瞬间传输:GPU 不断向显示器传输像素。每传输完一帧后,会有短暂的间歇,然后开始传输下一帧。这段中断时间称为VBLANK。启用 VSync 后,操作系统基本上只能在 VBLANK 时翻转帧缓冲区:

关闭 VSync 后,后置缓冲区会在渲染完成后立即翻转到前置缓冲区,这意味着显示器会在刷新周期的中间突然开始从新图像中获取数据,导致画面的上半部分来自旧画面,而下半部分来自新画面:

这种现象被称为 "撕裂"。撕裂技术允许我们减少帧下半部分的tdisplay,以牺牲视觉质量和动画流畅性来换取输入延迟。当游戏帧率低于 VSync 时间间隔时,这种方法尤其有效,可以部分恢复因错过 VSync 而造成的延迟。在屏幕上部被用户界面或天空盒占据的游戏中,它的效果也更好,因为这样就更难察觉到撕裂。
禁用 VSync 有助于减少输入延迟的另一种方法是增加tinput。如果游戏能够以比刷新率高得多的帧率进行渲染(例如,在 60 Hz 显示器上以 150 fps 的帧率进行渲染),那么禁用 VSync 将使游戏在每次刷新间隔期间多次抽取操作系统事件,从而减少它们在操作系统输入队列中等待引擎处理的平均时间。
请记住,禁用 VSync 最终应由游戏玩家决定,因为它会影响视觉质量,如果最终出现明显的撕裂现象,还有可能导致恶心。最佳做法是在游戏中提供一个设置选项,以便在平台支持的情况下启用/禁用它。
执行此修复后,Unity 的帧 Timeline 看起来是这样的:

但它真的能提高物体运动的流畅性吗?当然了!
我们在 Unity 2020.2.0b1 中运行了本篇文章开头展示的 Unity 2020.1 演示。下面是拍摄的慢动作视频:
此修复在2020.2 测试版中提供,适用于这些平台和图形 API:
- Windows、Xbox One、通用 Windows 平台(D3D11 和 D3D12)
- macOS、iOS、tvOS(Metal)
- PlayStation 4
- Switch
我们计划在不久的将来在其余受支持的平台上实现这一功能。
请关注本论坛的更新,并让我们了解您对我们目前工作的看法。
- 难以捉摸的帧定时,阿伦-拉达瓦茨的文章
- 控制器在《使命召唤》中显示延迟,Akimitsu Hogge 的 GDC 2019 演讲

如果您有兴趣了解有关 2020.2 中可用功能的更多信息,请查看测试版博文并注册参加 Unity 2020.2 测试版网络研讨会。我们最近还分享了2021 年的路线图计划。