网易首页 > 网易号 > 正文 申请入驻

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

0
分享至

Unity 2020.2 已经开发下载,新版本中,我们修复了许多开发平台的通病:不连贯的Time.deltaTime。它造成了游戏中的运动会出现颤动、抖动的现象。在本文中,我们将介绍背后原因,以及新版Unity中推出的解决方案。

自电子游戏出现以来,对流畅运动的追求从未停止。而要实现独立于帧的运动,则要用到时间增量(Delta Time)

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 beta已经开始解决这个问题了。

那为什么会出现这种情况?为什么当帧率锁定在144 fps时,Time.deltaTime每次更新不是1⁄144秒(6.94毫秒)?本文将介绍该现象背后的原因及最终的解决方法。

什么是时间增量(Delta Time)?

它有何重要性?

通俗地讲,时间增量是上一帧完成所需的时间。听起来很简单,但实际远非如此。在大部分游戏开发教材中,一个游戏循环的定义通常为以下样式:

while (true){ProcessInput();Update();Render();}

在此类游戏循环中,时间增量的计算非常简单:


var time = GetTime();while (true){var lastTime = time;time = GetTime();var deltaTime = time – lastTime;ProcessInput();Update(deltaTime);Render(deltaTime);}

这个模型虽然简单、好理解,但它并不能满足当今的游戏引擎。为了满足对高性能的需求,目前的引擎会使用称为“管线化”的技术,让引擎在任意时间内能处理多张帧。

试比较以下两张图:


在两张图中,游戏循环的每个部分耗费的时间都相同,但第二张图会并行处理各部分,在同样时间下最多可以产出两倍数量的帧。管线化的引擎中,帧处理时间不再是所有阶段时间的总和,而是最长的阶段时间。

然而,这个解释依旧是帧处理的简述:

每一帧的处理阶段会耗费不同的时间。这一帧上的对象可能要比上一帧多,因而渲染的时间也更长。又或者玩家用脸滚了一遍键盘,这样处理输入的时间就会更长。

由于管线不同的阶段耗时也不同,我们需要人为停下过快的部分,防止管线超前处理。最常见的方法是让引擎等待前一帧贴(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讯息或输入数据处理三个阶段的任何变动都会影响结果。

简化后的主线程循环可为如下定义:

while (!ShouldQuit()){PumpOSMessages();UpdateInput();SampleTime(); // We sample time here!Update();WaitForRenderThread();IssueRenderingCommands();}

右滑查看完整代码

这下问题的解决方案似乎就很明显了:只要把时间采样放到等待阶段之后就行。这时游戏的循环将变成如下样式:

while (!ShouldQuit()){PumpOSMessages();UpdateInput();Update();WaitForRenderThread();SampleTime();IssueRenderingCommands();}

然而,这样改并不能正确解决问题:渲染的时间读数与Update()并不相同,导致出现更多问题。另一个解决方法是将当前采样的时间储存起来,仅在下一帧开始时更新引擎时间。可是,这又会让引擎更新的时间成为上一帧渲染之前的时间。

那将SampleTime()挪到the Update()之后并没有效果,可如果将等待阶段放到帧的开头呢:

while (!ShouldQuit()){PumpOSMessages();UpdateInput();WaitForRenderThread();SampleTime();Update();IssueRenderingCommands();}

不幸的是,这会造成另一个问题:由于渲染线程需要在请求的那一刻完成,使得渲染线程从并行处理的收益很小。

我们看回帧处理时间线:


Unity会等待帧的渲染线程完成来强制达成同步,防止主线程的数据处理超过屏幕当前帧太多。渲染线程在完成渲染、等待帧显示到屏幕上时会被视作“处理已完成”,成为前部缓存并等待下一缓存的出现。然而渲染线程其实并不在意上一帧显示的时间,只有需要自我约束的主线程在意。所以我们可以将渲染线程等待帧显示在屏幕上的阶段放入主线程中,称作WaitForLastPresentation()。主线程循环就可写作:

while (!ShouldQuit()){PumpOSMessages();UpdateInput();WaitForLastPresentation();SampleTime();Update();WaitForRenderThread();IssueRenderingCommands();}

时间采样现在会在循环的等待阶段后执行,采样时机与显示器刷新率相匹配。而时间采样在帧的开头执行又意味着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()两步得合并为一步:

while (!ShouldQuit()){PumpOSMessages();UpdateInput();WaitForLastPresentationAndGetTimestamp();Update();WaitForRenderThread();IssueRenderingCommands();}

右滑查看完整代码

这下,颤动现象就解决了。

输入延迟因素

输入延迟是一个比较困难的问题。延迟测量精确度较低,且受多种因素影响:硬件、操作系统、硬盘、游戏引擎、游戏逻辑和显示设备。Unity无法影响其他因素,所以我将着重分析游戏引擎。

引擎输入延迟是指出现OS输入讯息到图像传输至显示器的间隔。在主线程循环中,我们可以将输入延迟用代码显示出来(假定QualitySettings.maxQueuedFrames设为了2):

PumpOSMessages(); // Pump input OS messages for frame 0UpdateInput(); // 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 screenUpdate(); // Update game state for frame 0WaitForRenderThread(); // Wait until all commands from frame -1 are submitted to the GPUIssueRenderingCommands(); // Send rendering commands for frame 0 to the rendering threadPumpOSMessages(); // Pump input OS messages for frame 1UpdateInput(); // Process input for frame 1WaitForLastPresentationAndGetTimestamp(); // Wait for frame -1 to appear on the screenUpdate(); // Update game state for frame 1, finally seeing the input event that arrivedWaitForRenderThread(); // Wait until all commands from frame 0 are submitted to the GPUIssueRenderingCommands(); // Send rendering commands for frame 1 to the rendering threadPumpOSMessages(); // Pump input OS messages for frame 2UpdateInput(); // Process input for frame 2WaitForLastPresentationAndGetTimestamp(); // Wait for frame 0 to appear on the screenUpdate(); // Update game state for frame 2WaitForRenderThread(); // Wait until all commands from frame 1 are submitted to the GPUIssueRenderingCommands(); // Send rendering commands for frame 2 to the rendering threadPumpOSMessages(); // Pump input OS messages for frame 3UpdateInput(); // Process input for frame 3WaitForLastPresentationAndGetTimestamp(); // Wait for frame 1 to appear on the screen. This is where the changes from our input event appear.

右滑查看完整代码

就是这样!从OS输入讯息的发出到结果显示到屏幕这个间隔内有许多的处理步骤。如果Unity出现掉帧,游戏循环的大部分时间都在等待,则输入延迟在144hz刷新率下最差可达4 * 6.94 = 27.76毫秒,因为引擎会等待四次(即四次刷新间隔)。

而要改善延迟效果,我们可以在等待前一帧显示之后发出OS讯息、更新输入:

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 screenPumpOSMessages(); // Pump input OS messages for frame 0UpdateInput(); // Process input for frame 0Update(); // Update game state for frame 0 with the input event that we are measuringWaitForRenderThread(); // Wait until all commands from frame -1 are submitted to the GPUIssueRenderingCommands(); // Send rendering commands for frame 0 to the rendering threadWaitForLastPresentationAndGetTimestamp(); // 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对输入延迟的影响

禁用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

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

The Elusive Frame Timing(难以捉摸的帧计时),作者Alen Ladavac。

https://medium.com/@alen.ladavac/the-elusive-frame-timing-168f899aec92

欢迎您通过以下链接下载使用Unity 2020.2:

https://unity.cn/releases

点击关键词

获取更多信息

Unity 技术精讲

[1]

[2]

[3]

[4]

[5]

Unity 官方微信

第一时间了解Unity引擎动向,学习最新开发技巧

特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。

Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.

相关推荐
热点推荐
王者荣耀崩了!1小时热度破10亿,话题遭玩家霸屏:一颗水晶失忆

王者荣耀崩了!1小时热度破10亿,话题遭玩家霸屏:一颗水晶失忆

请叫我游戏天才
2021-11-26 15:54:29
微信、支付宝被限!不能用于经营性收款,友商表态:重大利好

微信、支付宝被限!不能用于经营性收款,友商表态:重大利好

和讯网
2021-11-26 15:30:07
当杨元庆表态不做芯片和系统之后,联想的研发精神就已经死掉了

当杨元庆表态不做芯片和系统之后,联想的研发精神就已经死掉了

区块科技
2021-11-26 13:20:02
特斯拉上海工厂再扩产:新增员工4000人,无新增生产设备

特斯拉上海工厂再扩产:新增员工4000人,无新增生产设备

澎湃新闻
2021-11-26 20:34:20
小雀斑Eddie Redmayne 沉重道歉!

小雀斑Eddie Redmayne 沉重道歉!

下水道男孩
2021-11-27 01:17:51
1090例!惊现"比德尔塔还可怕"的毒株!疫情刚好转的新加坡慌了,紧急限制7国入境!

1090例!惊现"比德尔塔还可怕"的毒株!疫情刚好转的新加坡慌了,紧急限制7国入境!

新加坡万事通
2021-11-27 01:58:33
女大学生何成慧:被深山老汉120元买下,17年后被找到,父母落泪

女大学生何成慧:被深山老汉120元买下,17年后被找到,父母落泪

忆丹说文史
2021-11-26 12:08:13
南非出现新冠病毒新变种 英国暂停接收6国航班

南非出现新冠病毒新变种 英国暂停接收6国航班

看看新闻Knews
2021-11-26 15:05:03
数万磅火鸡和馅饼!美国供应链危机,但全球美军将收到感恩节大餐

数万磅火鸡和馅饼!美国供应链危机,但全球美军将收到感恩节大餐

环球时报评论
2021-11-26 15:11:47
性伴侣数量>7个和<3个的男性,到底有什么区别?

性伴侣数量>7个和<3个的男性,到底有什么区别?

四川名医
2021-11-27 00:42:12
世欧预附加赛各队身价:意大利6.635亿欧vs北马其顿4900万欧

世欧预附加赛各队身价:意大利6.635亿欧vs北马其顿4900万欧

直播吧
2021-11-27 01:45:05
明明拿底薪 却干着千万年薪的活,本赛季低薪高能 当属这5人

明明拿底薪 却干着千万年薪的活,本赛季低薪高能 当属这5人

老邓侃球
2021-11-26 16:28:27
开心一刻:借领导的手机给老婆打电话,刚输入号码,只见手机上…

开心一刻:借领导的手机给老婆打电话,刚输入号码,只见手机上…

资讯沸点
2021-11-24 13:07:51
若葡、意在附加赛决赛相遇,葡萄牙将享有主场优势

若葡、意在附加赛决赛相遇,葡萄牙将享有主场优势

虎扑足球
2021-11-27 01:20:07
有钱夫妻床上的炮灰,我当了2次

有钱夫妻床上的炮灰,我当了2次

小镇诗人
2021-11-26 13:53:05
江西赣州公安回应“见习辅警玩游戏辱骂队友”:不予聘用

江西赣州公安回应“见习辅警玩游戏辱骂队友”:不予聘用

澎湃新闻
2021-11-26 22:14:21
人被拿掉哪些器官不会死?5个没用器官,医生一般不会告诉你

人被拿掉哪些器官不会死?5个没用器官,医生一般不会告诉你

39健康网
2021-11-26 10:37:34
总统被毒死,首都丢失,中方老朋友面临亡国,急盼老大哥主持公道

总统被毒死,首都丢失,中方老朋友面临亡国,急盼老大哥主持公道

空降雄兵
2021-11-26 17:08:16
杜妈去九江找许敏,铩羽而归后,她说:别阻挠查真相,会遗臭万年

杜妈去九江找许敏,铩羽而归后,她说:别阻挠查真相,会遗臭万年

今天很开心呢
2021-11-26 22:04:50
世界杯附加赛抽签出炉!意大利、葡萄牙同组,两强最多一队晋级

世界杯附加赛抽签出炉!意大利、葡萄牙同组,两强最多一队晋级

切尔西的饮水机
2021-11-27 00:38:00
2021-11-27 07:44:49
Unity
Unity
Unity引擎官方帐户
1899文章数 6230关注度
往期回顾 全部

游戏要闻

控制怪兽的幕后黑手!新作《怪兽指挥官》上架Steam

头条要闻

学生营养餐后呕吐腹泻 招标项目曾屡遭投诉均被驳回

头条要闻

学生营养餐后呕吐腹泻 招标项目曾屡遭投诉均被驳回

体育要闻

他来了,曼联真的有救了吗?

娱乐要闻

刘真出任央视2022年春晚总导演?

财经要闻

科技要闻

恒大汽车退地266万平,涉及金额12.84亿元

汽车要闻

续航里程达518公里 新款别克微蓝6售15.99万起

态度原创

艺术
旅游
游戏
时尚
公开课

艺术要闻

洛可可名作《秋千》完成修缮保护,露出清晰细节

旅游要闻

上海外滩罗斯福公馆外墙爬满圣诞老人

玩加xDES独家专访LNG Tarzan:正在努力不丢失游戏的乐趣

没想到这3款“奶奶毛衣”让姑娘们这么上头!

公开课

福建的省会是哪里?大部分人都答错