Frame Pacing是每个游戏都要遇到的问题,这里面有很多细节值得探讨。
为什么需要做Frame Pacing?
从我们的游戏线程渲染一帧到最终屏幕上绘制出一帧不是一个概念,这种间会经历CPU,GPU,屏幕合成器等多个角色的协同工作,我们追求三个目标,在给定的渲染帧率下
1. 尽量保证每个游戏线程渲染的帧都能成功在屏幕上展示,否则就是发生了弃帧,弃帧相当于对功耗的白白浪费
2. 尽量保证屏幕上每一帧持续的持续的时间一致,否则就是发生了帧率不稳或者长短帧,这导致用户感受到不顺滑
3. 尽可能缩短从CPU提交到屏幕呈现的时间差,即减少延迟。
通常我们无法保证游戏过程中上述三个目标达到100%,但是需要尽量达到。由于CPU的逻辑线程,渲染提交线程,GPU,屏幕合成器都处于异步工作的状态,我们需要协调好他们的工作步骤,这也让pacing这个单词很形象,即让每个独立工作的人在一起又能踩到合适的点上。
渲染、提交、展示的工作流程
以Android系统为例子
这里面有四个角色在工作:
1. 在第N帧,CPU渲染一帧出来,将图像绘制到系统给的其中一个buffer上,又称为back buffer,(这里还没有考虑CPU上本身还有多线程渲染,这里假设CPU在单线程上通过逻辑计算,渲染,并提交出来一帧,最后的一个API调用如glswapbackandfrontbuffer或者VKqueuepresentkhr)。
2. SurfaceFlinger在垂直同步周期N+1开始后,如果检测到在垂直同步周期N或更早有一个有效的提交,就会启动帧合成工作,所谓帧合成工作是将当前提交的back buffer同其他系统界面合成到一个准备用于呈现给最终屏幕的 Buffer上(这个 Buffer用于再下个垂直同步周期开始时被刷到屏幕上,即设置为Front Buffer)
3. GPU从CPU到提交开始就要工作,完成前面所有的buffer的绘制工作,通常要比CPU晚一个周期
4. 在垂直同步周期N+2开始的时候,如果这个Buffer被GPU处理完成,就可以被刷新到屏幕上。(我们知道系统会按照给定的频率刷新屏幕显示内容,这个刷新的间隔就是垂直同步时间,android系统可能同时支持多种刷新率,典型如60HZ,90HZ,120HZ,144HZ等,不能在屏幕使用front buffer刷新的时候去更改这个front buffer,surface flinger恰好为我们做了这个事情,在给定的垂直同步时间下,surface flinger会在固定的刷新时间点来临之前把某个完成的back buffer交换为front buffer,供屏幕刷新)
上图中,从CPU present到可以看到这一帧会经历2个垂直同步周期的延迟。
SurfaceFlinger的合成与弃帧
在上面这个流程中,有几个问题需要注意:
1. CPU必须要在垂直同步周期开始前至少t 纳秒前提交完成,才能被下个垂直同步的SurfaceFlinger合成,这个时间叫做PresentationDeadlineNanos,如下图,如果提交超过这个阈值,就得等到下下个周期才被合成。渲染上实际上就多延迟了一个同步周期
2. 如果在一个垂直同步周期内,SurfaceFlinger发现了大于1个back buffer被提交,那么他只会选择1个进行合成,而其他的被丢弃,因为合成更多的已经没有意义。并且Surface flinger合成的时机也是固定的,都是在每个垂直同步周期开始后的较短时间内。
如下图所示,Frame1实际上被丢弃,但是GPU还要处理Frame1,产生了功耗上的浪费。
back buffer的资源竞争和生命周期
关于Back Buffer的数量,系统的Back Buffer存在一定的数量限制,典型的系统有3个,也有2个,或更多。Buffer的生命周期如下
这是一个典型的生产者-消费者模式,应用程序APP生产(提交,如调用VKqueuepresentkhr)一个需要GPU填充的backbuffer。Surface Flinger发现了之后消费它,accquire这个buffer,调用它的present方法将它同其他系统界面合成到一个新的待呈现的buffer,cpu和gpu都使用完成后将其release,APP一侧下一帧通过dequeue buffer(如调用vkacquirenextimagekhr)找到一个released。
资源不足会导致某些工作受到阻塞:
最典型的例子是GPU瓶颈,导致CPU被阻塞,如下图
CPU的帧率可以达到120fps,而GPU只有60fps,假设系统有3个backbuffer,从第3个CPU帧开始,cpu就不能dequeue到一个可用的buffer了,因为1个buffer处于frame1 的 queued状态,1个buffer处于frame2 的acqured状态,另一个被用作front buffe。这会产生GPU对CPU的倒逼,将CPU也强行阻塞为60fps,在profile上我们将看到CPU的RHI线程大多数会卡顿在vkacquirenextimagekhr(对于gles来说,一般下一帧的acquire可以发生在前一帧的的present后,所以gles的app会看到卡顿在eglswapbuffer上)。无论是卡顿在vkacquirenextimagekhr或是eglswapbuffer都是典型的GPU瓶颈。
当然上面的这种倒逼情形是没有副作用的,因为它也满足我们最开始的两条目标,没有弃帧,帧呈现时间也是一致的,都是均匀的16.66ms。
渲染的提交时机和节奏
在Android系统上,Surface Flinger能够帮我们保证不出现因Front Buffer被同时读取刷新和修改而产生画面撕裂的问题,back buffer数量导致的倒逼机制可以使GPU瓶颈时CPU也不会过度提交,那我们是不是可以随意提交?显然不行。
1. 首先还是看上面的GPU瓶颈情形下,只要我们将初始提交发生一点时间上的位移,情况就会变得大不一样。这个从结果上看,刚好一半的帧被丢弃了,用户的实际接收帧率只有30fps,而不是预想的60fps。发生了什么?
我们不考虑第一帧,在第3,5,7帧都被丢弃了,第3帧被第3个垂直同步tick到进入surface flinger合成的时候,因为第二帧还在gpu处理中,所以不能进行,要推迟到下一个垂直同步,下一个垂直同步4开始时,又发现第4帧已经被提交了,那么这里有待合成的3和4,则帧3被丢弃掉了,开始合成frame4,依次类推下去。
这显然是个灾难,原因仅仅是因为我们提交的时机有所不同,出现这个问题的真实原因是我们不应该在前一帧的GPU还没处理完成的时候就进行下一帧的present。
所以提交时机很重要,如果我们能够保证等待前一阵GPU处理完才真正的提交,上面的情形就会被纠正过来,如图。可以看到除了前2帧有点问题外(第二帧持续了2个周期,弃掉了第一帧),从3帧开始就被纠正了过来。这个只是在最终发生prensent操作之前强行等待上一帧的GPU完成。这个例子表明CPU提交的时候要在乎gpu当前的状态。
2. 上面是典型的GPU瓶颈,如果是CPU瓶颈,又可能出现什么样的情况?假设在60hz下,GPU能跑满60,而CPU只有45fps,最终看上去的情况就可能是这个样子的,可以看到1,3帧在屏幕持续了1个周期,2,4..帧持续了两个周期,没有弃帧,但是帧呈现时间是不一致的,出现了long-shot frame现象,也就是说用户感受到的45帧实际是不平顺的45帧,这个问题的原因在于实际帧率不能被刷新率整除。
在这种情况下,对于很多游戏,可能平顺的30帧要比不平顺的45帧感受更好,并且节能,那不如将CPU的提交控制在30帧,当然对于平稳性和高帧率哪个更重要,是需要case-by-case的,还有一个折衷的方案是为游戏限制一个最小的阈值帧率如30帧,当帧率小于30时,帧率优先,档帧率高于30时,平稳优先。这个例子表明提交的时候还要在意实际帧率同刷新率的关系。
3. 如果是没有典型瓶颈,而CPU/GPU时间在给定垂直同步周期之内浮动情况(也就是我们在app侧认为游戏帧率能达到满帧)会有哪些情况发生呢?假设你的CPU和GPU帧率都能达到60,然后我们放任CPU随意提交。
下图是一种很有可能出现的情况,CPU\GPU都在60fps上下浮动,其中第4帧的CPU稍微高了一点点,它提交的时候超越了下帧垂直同步的deadline时刻,导致第4帧的surfaceflinger不能空闲,推迟到第5帧,直接导致了在最终用户屏幕上Frame3持续了2个周期,出现了长短帧。
又因为第4帧的影响导致第5帧提交也超出了dedaline,再加上第6帧的cpu跑的有点快,相对过短,这样6同5都在一个垂直同步周期内被提交,最终又导致frame 5被丢弃掉,没呈现到屏幕。
这样看来,如果放任cpu随意提交,即使理论上我们认为他能跑满60帧,也会导致各种丢帧,长短帧的事情发生,用户感受上不平滑,并且实际没有60帧。
对于上面这种情形,我们稍微改变下提交节奏,事情就会变好。
我们把帧5 和帧6这两个快帧的后面稍微延迟一点时间,延迟到这帧接近垂直同步结束时再提交,就会使帧4的影响不会传递到后面去,即除了第3帧的图像在屏幕多存续一个周期外,后面的帧都很正常。
正确的节奏
上面的一些例子说明,提交的时机和节奏的确影响最终的帧率,我们计算一个游戏的帧率不是简单的用1000/max(cpu,gpu),而是看最终屏幕上给了多少帧,还要关注他们是否均匀给出。
要实现文章开头所说的3个目标,我们需要在正确的时机进行提交,垂直同步是一个无情的准时发生的机器,我们需要自己去找准这个时间,为了找准这个时间, 在android系统上我们需要关注的还有当前系统的刷新率,vsync的发生时间,surfaceflinger的工作时间(包括它present的deadline,上次present的时间),APP自己的cpu时间和gpu时间。
当我们知道了这些信息后,我们就可以制定一个较好的提交策略,考虑到上述的一些例子,这个策略至少包含一下几点:
1. CPU发生present的时候要保证前一帧的GPU完成,不然会导致即将到来的surface flinger不工作。(这需要我们知道GPU完成的fence时间)
2. 充分考虑高帧率和平滑性的关系,追求帧率平顺,就应该尽量让垂直同步周期为CPU提交间隔的整数倍,在60刷新率下渲染45帧一定是长短帧的,除非游戏更加追求高帧率。(这需要我们知道当前的刷新率,以及短期内的CPU,GPU时间)
3. 最重要的是,如果前一帧在第N个垂直同步周期内提交完成,那么CPU发生下一个present的时候,延迟到N+1个垂直同步开始之前的一个时间点T(n+1)时进行,这个T(n+1) = 第N+1帧的垂直同步开始时间-系统的PresentationDeadlineNanos。(这需要我们知道surfaceflinger的工作时间)
保证这一点非常重要。
为什么提交的时间点要是这个T?
如果提交过早发生,极有可能导致当前帧挤到上一帧的垂直同步周期内,而把提交时间推到这个时间点,恰好保证它刚刚可以被下一个垂直同步内的surface flinger消化(因为如果再晚就错过了deadline,要再延迟一帧了)
Choreographer和Swappy
为了让我们获取到surface flinger和vsync发生的精确时间点,android的choreographer库为我们提供了这些机制,它会基于准确的vsync发生的时机,给APP定期的回掉,让我们知道一个新的垂直同步周期到来,以决定我们需要wait到什么时候进行present操作。
正如choreographer到英文翻译“编舞者”一样,我们利用它编排CPU的渲染提交步调,让CPU,GPU,Surface Flinger,Vsync能够和谐起舞。
不过使用choreographer库有点繁琐,android的game sdk为我们提供了另外一个库Swappy高度封装了choreographer,swappy同时支持gles和vulkan。从使用者的角度,swappy为我们提供了很多种提交策略,包括:
1.自动帧率和固定帧率,自动帧率将由swappy根据刷新率,你的短期历史CPU GPU时间,你允许的最大帧率,计算一个可以被刷新率整除的帧率
2. 它会计算GPU完成的时间,避免CPU提交时上一帧GPU没有处理完
3. 我们不用关心何时present,它会自动将prenset的时机对准到前面所说的垂直同步开始前减去deadline的那个时间
4. 他还允许我们设置一个最小帧率,当fps过低时,放弃稳帧率措施和等待present时间点对齐,而直接提交。
IOS系统和WIndows系统的Frame Pacing
这篇文章到此谈论的都是android系统的frame pacing,android 系统上这个也是最复杂的。ios和windows都有更简单的机制。
对于IOS系统,直接通过系统提供的MtlDrawable上的(void)presentAfterMinimumDuration函数实现。
当一帧结束时,我们用这个函数做prensent,presentAfterMinimumDuration即指的是我们期待的帧率,他会在一些情况下自动为我们阻塞住当前的cpu,控制我们的提交节奏,例如等待前一帧GPU处理完,或者对其到合适的提交点。
至于windows系统上的DX,也是依靠
IDXGISwapChain::Present( UINT SyncInterval,UINT Flags) 这个方法完成的。
这里面的syncinterval是垂直同步间隔,flags则是一些策略。DX上这个syncinterval的概念同移动端不太一样。首先这个syncinterval可以为0,意味着不管垂直同步,即随时都能提交,并交换给frontbuffer,哪怕frontbuffer正在被读取刷新,屏幕可能撕裂,但是手机上不会出现,例如android上,因为surface flinger的存在时一定不会存在所谓的不开垂直同步的。如果syncinterval为非0的值n,意味着至少距上次间隔n次垂直同步将画面刷新上去。
利用Systrace 工具找到frame pacing问题
各种系统都提供了录制system trace的工具,我们可以利用它随时找到游戏的帧率不稳或者丢帧问题。例如android 的systrace,里面可以看到每帧的提交,合成情况,以及是否发生了丢帧等,如果使用了swappy,还能看到swappy为我们做的wait prensent时间点的措施,以及cpu,gpu的时间。
如下图是一个例子,可以看到最上面的是ue4 rhi线程的提交,即CPU一侧的提交,它的标志性函数是vkqueuepresent,但是因为集成了swappy,所以调用的是swappyvk的prenset,这里可以看到swappy调用了很多wait函数来推迟我们的提交时间点。
下面是surface flinger在固定的垂直同步周期时间点上进行帧合成,在下面还能看到swappy统计到的cpu和gpu时间,最下面一行时垂直同步间隔。
其中很重要等一行PrevFrameMissed则表征了弃帧的发生,我们要关注frame missed的数据,以及时找到游戏帧率不稳定的原因。