关注
Unity 2020.2 已经开发下载(点击回看),新版本中,我们修复了许多开发平台的通病:不连贯的Time.deltaTime。它造成了游戏中的运动会出现颤动、抖动的现象。在本文中,我们将介绍背后原因,以及新版Unity中推出的解决方案。
自电子游戏出现以来,对流畅运动的追求从未停止。而要实现独立于帧的运动,则要用到时间增量(Delta Time):
右滑查看完整代码
上方代码可以实现对象以不变的矢量向前运动的效果,且运动会无视游戏的帧率。从理论上说,如果帧率非常稳定,对象的运动也会非常平稳。然而,实际的表现并非如此,实际的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 beta已经开始解决这个问题了。
那为什么会出现这种情况?为什么当帧率锁定在144 fps时,Time.deltaTime每次更新不是1⁄144秒(6.94毫秒)?本文将介绍该现象背后的原因及最终的解决方法。
什么是时间增量(Delta Time)?
它有何重要性?
通俗地讲,时间增量是上一帧完成所需的时间。听起来很简单,但实际远非如此。在大部分游戏开发教材中,一个游戏循环的定义通常为以下样式:
在此类游戏循环中,时间增量的计算非常简单:
这个模型虽然简单、好理解,但它并不能满足当今的游戏引擎。为了满足对高性能的需求,目前的引擎会使用称为“管线化”的技术,让引擎在任意时间内能处理多张帧。
试比较以下两张图:
在两张图中,游戏循环的每个部分耗费的时间都相同,但第二张图会并行处理各部分,在同样时间下最多可以产出两倍数量的帧。管线化的引擎中,帧处理时间不再是所有阶段时间的总和,而是最长的阶段时间。
然而,这个解释依旧是帧处理的简述:
每一帧的处理阶段会耗费不同的时间。这一帧上的对象可能要比上一帧多,因而渲染的时间也更长。又或者玩家用脸滚了一遍键盘,这样处理输入的时间就会更长。
由于管线不同的阶段耗时也不同,我们需要人为停下过快的部分,防止管线超前处理。最常见的方法是让引擎等待前一帧贴(flip)到前部缓存(又称屏幕缓存)中。如果启用了VSync,这一步会额外同步到显示器的VBLANK阶段。这部分我将在随后细讲。
在了解了这些原理后,我们来看看Unity 2020.1中普通的帧时间线。鉴于平台和各类设定对结果影响极大,这里假定游戏为Windows Standalone运行版本,且启用了多线程渲染、VSync,禁用了图形Job,QualitySettings.maxQueuedFrames设为2,显示屏为144Hz,且画面未出现掉帧。点击下方图片可查看大图:
Unity的帧处理管线并不是从零建起的,而是在过去十年中逐渐成为现在的样子。每次Unity新版本的发布都会对其做出修改。
我们马上就能看出一些端倪:
当任务上传至GPU后,Unity并不会等帧被贴上屏幕,而是等待前一帧。该行为由QualitySettings.maxQueuedFrames API控制,它描述了当前显示帧与随后渲染帧之间的间隔。设定最小值为1,因为引擎最少需要渲染当前帧Fn的下一帧Fn+1。设定的默认值为2,则Unity会在开始渲染Fn+2之前先显示Fn(例如,在渲染F5之前,Unity会先等F3出现在屏幕上)。
帧在GPU上的渲染时间要比显示器单次刷新时间更长(7.22毫秒对6.94毫秒),但并未出现掉帧。这是因为设置为2的QualitySettings.maxQueuedFrames会推迟屏幕显示帧的时间,形成一个缓存来阻止掉帧的出现,前提是处理“高峰”没有一直出现。如果设为1,Unity一定会掉帧,并且管线会无法覆盖整个处理流程。
虽然屏幕以6.94毫秒一次的速率刷新,Unity的处理时间却并不相同:
此处的时间增量平均值((7.27 + 6.64 + 7.03)/3 = 6.98毫秒)十分贴近显示器刷新率(6.94毫秒),并且如果时间足够长,平均值可以准确达到6.94毫秒。可是,如果使用这个时间增量来计算对象的运动,对象会出现颤动。为了演示该现象,我创建了一个简单的Unity项目,其中有三个绿色方块会在空间中移动:
顶部方块的每一帧运动距离都相同——它是代表完美运动的参照物。两侧的红色平行线则是为了方便观察其他方块是否与其对齐。中间的方块会在一秒乘以Time.deltaTime的时间内移动到前一方块相同的距离。
底部方块使用了Rigidbody移动(启用了Interpolation插值),方块的矢量设为顶部方块一秒内的移动矢量。
摄像机放置在了顶部方块上,使得方块在屏幕中完全静止。如果Time.deltaTime是完全精确的,中间和底部的方块也会完全静止。方块每秒会经过两倍显示屏宽度的距离,这时速度越快,颤动越明显。为了表现出运动效果,我在背景的固定位置上放置了静止的紫色与粉色方块,用于与运动方块做对比。
在Unity 2020.1中,中间与底部的方块并不能很好地匹配方块运动,会出现轻微颤动。下方视频以慢速镜头(减缓了20倍)捕捉了运动:
时间增量不一致的源头
那时间增量不一致的原因是什么呢?显示器每帧的显示时间固定,每个6.94毫秒改变画面。这个时间是一帧出现在屏幕上的实际时间,是玩家会观察到的每一帧的时长,也是真正的时间增量。
每个6.94毫秒由两部分组成:帧处理和休眠。示例中的帧时间线其时间增量在主线程中计算完毕,因此也是我们的主要关注点。线程中的处理部分由发出OS讯息、处理输入数据、调用Update和发出渲染命令及部分组成。“等待渲染线程完成”属于休眠部分。两部分的总时长正等于实际的帧时长:
这两种时间都会因为各种原因出现波动,但总量会保持不变。如果处理时间较长,等待时间会减少,反之亦然,两者之和保持在6.94毫秒。实际上,所有处理阶段之和会让引擎的等待时间等于6.94毫秒:
然而,Unity会在Update更新阶段开始时查询时间。因此,发出渲染命令、输出OS讯息或输入数据处理三个阶段的任何变动都会影响结果。
简化后的主线程循环可为如下定义:
右滑查看完整代码
这下问题的解决方案似乎就很明显了:只要把时间采样放到等待阶段之后就行。这时游戏的循环将变成如下样式:
然而,这样改并不能正确解决问题:渲染的时间读数与Update()并不相同,导致出现更多问题。另一个解决方法是将当前采样的时间储存起来,仅在下一帧开始时更新引擎时间。可是,这又会让引擎更新的时间成为上一帧渲染之前的时间。
那将SampleTime()挪到the Update()之后并没有效果,可如果将等待阶段放到帧的开头呢:
不幸的是,这会造成另一个问题:由于渲染线程需要在请求的那一刻完成,使得渲染线程从并行处理的收益很小。
我们看回帧处理时间线:
Unity会等待帧的渲染线程完成来强制达成同步,防止主线程的数据处理超过屏幕当前帧太多。渲染线程在完成渲染、等待帧显示到屏幕上时会被视作“处理已完成”,成为前部缓存并等待下一缓存的出现。然而渲染线程其实并不在意上一帧显示的时间,只有需要自我约束的主线程在意。所以我们可以将渲染线程等待帧显示在屏幕上的阶段放入主线程中,称作WaitForLastPresentation()。主线程循环就可写作:
时间采样现在会在循环的等待阶段后执行,采样时机与显示器刷新率相匹配。而时间采样在帧的开头执行又意味着Update()和Render()的时间读数是相同的。
需要注意的一点是,WaitForLastPresention()并不会等待Fn-1显示到屏幕上,而管线化也就无从谈起。相应地,方法会等待帧Fn – QualitySettings.maxQueuedFrames出现在屏幕上,让主线程无需等待上一帧渲染完成(除非maxQueuedFrames设为了1,即新的帧开始前整个流程必须完成),继续执行流程。
深入发掘稳定性
在应用了上述方案后,时间增量变得远比原来稳定,但依旧有颤动、数值浮动现象。由于我们依赖于操作系统来及时唤醒引擎,唤醒过程需要几毫秒,造成时间增量的浮动。这一现象在桌面端有多个程序同时运行时更为明显。
那现在怎么办呢?大部分图形API或平台都允许用户提取帧出现于屏幕上(或幕后部缓存)时的时间戳。比如,Direct3D 11和12有IDXGISwapChain::GetFrameStatistics,而macOS有CVDisplayLink。但这种方法有一些缺点:
我们需要为每种图形API编写额外的提取代码,而每个平台都有独特的时间测量代码和应用方式。由于每个平台的行为不同,修改代码可能会造成灾难性后果。同时部分图形API要获取时间戳,必须先启用VSync。如果未启用VSync,时间必须手动计算。
但是,我认为这个方法仍旧值得一试。方法产出的结果可靠性高,且与显示器图像直接对应。
要使用图形API提取时间,WaitForLastPresention()和SampleTime()两步得合并为一步:
右滑查看完整代码
这下,颤动现象就解决了。
输入延迟因素
输入延迟是一个比较困难的问题。延迟测量精确度较低,且受多种因素影响:硬件、操作系统、硬盘、游戏引擎、游戏逻辑和显示设备。Unity无法影响其他因素,所以我将着重分析游戏引擎。
引擎输入延迟是指出现OS输入讯息到图像传输至显示器的间隔。在主线程循环中,我们可以将输入延迟用代码显示出来(假定QualitySettings.maxQueuedFrames设为了2):
右滑查看完整代码
就是这样!从OS输入讯息的发出到结果显示到屏幕这个间隔内有许多的处理步骤。如果Unity出现掉帧,游戏循环的大部分时间都在等待,则输入延迟在144hz刷新率下最差可达4 * 6.94 = 27.76毫秒,因为引擎会等待四次(即四次刷新间隔)。
而要改善延迟效果,我们可以在等待前一帧显示之后发出OS讯息、更新输入:
右滑查看完整代码
这会从等式中移除一次等待阶段,最差延迟便为3 * 6.94 = 20.82毫秒。
如果支持将QualitySettings.maxQueuedFrames降为1,则输入延迟还可进一步降低。这时,输入的处理流程将呈如下样式:
右滑查看完整代码
现在,最差的输入延迟为2 * 6.94 = 13.88毫秒,这已经是VSync下的最好结果了。
重要提示:
将QualitySettings.maxQueuedFrames设为1后引擎将无法管线化,使得高帧率的实现较为困难。如果真的出现掉帧,输入延迟可能会比QualitySettings.maxQueuedFrames设为2时更长。比如,当帧率掉到72帧每秒时,输入延迟为2 * 1⁄72 = 27.8毫秒,比之前的20.82毫秒更长。如果一定要使用该设定,我们建议将其加入游戏设定菜单,硬件更好的玩家可以降低QualitySettings.maxQueuedFrames数值,而较差的玩家则使用默认设定。
VSync对输入延迟的影响
禁用VSync可以在特定情况下降低输入延迟。而输入延迟是OS发出输入讯息到相应帧显示在屏幕上的间隔,以等式可表达为:
这时降低输入延迟就有了两种方法:降低tdisplay(让图像显示更快)或增加tinput(在随后查询输入事件)。
从GPU发送图像数据到显示器设计大量的数据。我们来算一算:要将一张2560x1440的非HDR图像以每秒144次的速率输送到显示器上需要每秒传输12.7GB的数据(每像素占24位*2560*1440*144).这么大的数据量无法立即传输完成,GPU会一直向显示器传输像素。在一帧传输完毕后,会出现一个短暂的间隔,接着下一帧的传输开始。这个间隔称为VBLANK。在启用VSync时,我们告诉OS仅在VBLANK期间将帧贴入帧缓存。
自上而下:渲染、后部缓存、前部缓存、显示器。
当关闭VSync时,后部缓存会在渲染完之后立即贴入前部缓存,意味着显示器会在刷新周期中突然开始抓取新图像的数据,造成帧的上半部为旧帧、下半部为新帧:
自上而下:渲染、后部缓存、前部缓存、显示器。
这个现象称为“撕裂(tearing)”。利用好撕裂,我们就能减少帧下半部分的tdisplay,通过牺牲图像质量和动画流畅度来降低输入延迟。如果游戏的帧率比VSync的间隔还低,则引擎可以补偿VSync缺失造成的部分延迟,此方法也就更加有效。同样的,如果游戏上半屏都是UI或天空盒,则撕裂更加难以察觉。
禁用VSync对减少输入延迟的另一种好处是增加tinput。如果游戏的帧渲染速率比刷新率(例如在60 Hz显示器上达到150 fps)高出很多,则游戏会在每次刷新间隔间发出几倍的OS输入事件,极大地减少OS输入在队列中的耗时。
注意是否禁用VSync应最终由玩家来决定,因为它会影响图像质量,在图像撕裂不明显的情况下,还有可能导致玩家产生恶心感。如果平台支持,我们建议在游戏中添加VSync的启用/禁用选项。
总结
在做出这些修复后,Unity的帧时间轴应该呈下图样式:
那对象运动的流畅度究竟有没有改善呢?当然了!
在本文中演示的Unity 2020.1演示项目在修改后其结果如下:
该2020.2 beta的修复支持如下平台与图形API:
Windows, Xbox One, Universal Windows Platform (D3D11 and D3D12)
macOS, iOS, tvOS (Metal)
Playstation 4
Switch
我们将在未来逐步在剩下的平台上应用该修复。