Unity 2020.2 优化了 Time.deltaTime,以实现更流畅的游戏体验

Unity中国

2021年1月9日12:12 

关注

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

我们将在未来逐步在剩下的平台上应用该修复。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值