什么!FPS难道不是越高越好吗?

一、背景


作为手机游戏开发者,我们的工作中有很多时间都在尝试优化自己的代码。比如让某一段逻辑执行的更快速,或降低一些迭代的频率,减轻CPU负担,抑或通过各种骚操作在不降低画面质量的情况下,减轻GPU负担
最终的目的都是想让使用不同性能设备的玩家都可以流畅的体验游戏。“卡不卡”也是玩家对游戏产生的第一印象之一,因此,我们的目的就是让游戏以最快的速度运行。
通常,评价一款游戏是否流畅的最直观的指标就是FPS(帧/秒)。那么,FPS真的是越高越好吗?
什么!FPS难道不是越高越好吗?
市面上评测硬件性能都是对比不同硬件下相同画质游戏的FPS,且越高越好(Higher is better)。
对于桌面平台来讲,它有持续稳定的供电与强劲的散热方案,不需要考虑发热与耗电量的问题,可以让硬件*发挥。其次我们还要考虑显示设备(手机屏幕、电脑屏幕)的刷新率(RefreshRate)。
为了了解帧率(单位是FPS)与刷新率的关系,我们先来看看他们的定义:


1. 帧率(FrameRate)


帧率是GPU和CPU合作在游戏运行时,可产生的图像的数量,计量单位是帧/秒(FramePerSecond),通常是评估硬件性能与游戏体验流畅度的指标。


2. 刷新率(RefreshRate)


刷新率(垂直刷新率、垂直扫描率)表示显示设备一秒内可显示的新图像的数量,计量单位是赫兹(Hz)。
刷新率与帧率是两个独立的概念,帧率表示驱动显示器的设备每秒可产生新图像的数量。

可简单理解为:

  • 游戏引擎与驱动是生产者,工作效率用帧率来评价;

  • 显示设备是消费者,工作效率用刷新率来评价。



简而言之,我们真正感受到的流畅度会被刷新率限制,当帧率高过刷新率时,显示器每秒所能显示的图像数量仍然是不变的。

3. 画面撕裂(ScreenTearing)



假设你的显示设备刷新率为60Hz,当帧率高过刷新率或游戏运行时的帧时间不是1/60的倍数(2/60、3/60),即其FPS不是:…/120/60/30/20/… 时,就会存在显示器正在刷新图像的同时,新的数据也正由显卡传过来的情况,导致屏幕中有多帧数据同时出现的情况。

如上图所示,B帧渲染较快,在A帧的数据仍在显示器中刷新时,提交了新数据,造成画面撕裂,这种现象就叫做画面撕裂。这种瑕疵最简单的解决方案是垂直同步(VSync)。

4. 垂直同步(Vertical Synchronization)


垂直同步会同步显卡与显示设备的工作:

当显示器在刷新数据时,会让GPU等待,直到完全刷新数据后,让GPU提交新的数据,并在下一个刷新周期刷新。
垂直同步会将游戏的FPS限制为显示设备的刷新率,其最大的问题是会导致玩家输入延迟,因为它会要求显卡在渲染完毕后等待显示设备去刷新数据。
什么!FPS难道不是越高越好吗?
显而易见,这个问题对于竞技游戏的影响是很大的。


(1)NVIDIA G-Sync


如果你是NVIDIA显卡,且你的显卡和显示器均支持G-Sync[1],则显示器中的特殊芯片会与显卡进行交流,使显示器调整其刷新率以匹配显卡的帧率。
什么!FPS难道不是越高越好吗?
其缺点是需要硬件(显示设备硬件)支持。


(2)AMD FreeSync


FreeSync[2]是基于DP接口(DisplayPort)所支持的自动同步技术[3](Adaptive-Sync)实现的。
什么!FPS难道不是越高越好吗?
这是一个开放的技术标准,因此FreeSync不需要授权费用,通常支持FreeSync的显示设备会比G-Sync便宜。
了解到影响用户体验的这些因素后,我们也了解到相应的解决方案。那么,移动平台的具体情况是什么?与桌面平台又有什么不同呢?


二、移动平台


以高通为例,其Adreno[4] GPU的性能数据如下表所示:


高通骁龙865所配置的Adreno 650与Intel 11代CPU所附带的Iris Plus集显对比:


可以看到目前的旗舰移动GPU的浮点数算力已经赶超Intel的集显,性能已经非常可观,移动端硬件也有能力去输出非常高的帧率。


1. Android


移动平台的显示设备在很长一段时间里,都是60Hz。

我们从上文了解到,在游戏图像展示在显示屏的过程中,有一个比较影响用户体验的同步过程。
游戏逻辑和渲染循环与安卓系统和显示屏硬件之间有一个同步的关系,这个同步过程我们称为帧节奏(Frame Pacing),也即引擎与CPU、GPU配合产生图像的帧率 与显示屏刷新率之间的同步关系。
安卓的显示系统可避免画面撕裂(ScreenTearing)的问题,即当显示器正在刷新数据时,新的数据被Push到显示设备时的情况。其通过以下措施避免撕裂(Tearing):

  • 将历史帧数据缓存住;

  • 自动检测有延迟的帧数据提交;

  • 当提交有延迟时,重复渲染历史帧数据。


通过Buffer缓存帧数据,当显示器刷新时,如果有新数据传输,直接将其缓存即可。如此设计,就不会有VSYNC的阻塞式等待的问题,也不会增大影响游戏逻辑的输入延迟。
虽然带来了一定的画面延迟,但可以避免画面撕裂问题。具体的数据提交流程如下:
首先引擎通过 eglSwapBuffers 告知显示系统 SurfaceFlinger[5],其次SurfaceFlinger 会将数据缓存到Buffer中。
如果当前是刷新的窗口期,SF会等待硬件的VSYNC信号。收到信号后,SF将从Buffer中找到最新的Buffer;如果没有找到,就用上一次的Buffer;如果正在刷新中,SF则是处在睡眠状态(设备实现相关)。
假设同步(Frame Pacing)频率为30Hz时,正确的同步关系如下图:

NB即为No Buffer,Latch即表示为Buffer传输中。图中的Display刷新率为60Hz,渲染的频率为30Hz。

(1)短帧卡顿


当某帧的渲染时间变小,会出现卡顿现象(Stuttering):

如上图所示,C帧的渲染因为一些原因所花费的时间很短,在下一个刷新窗口期就渲染完毕了,因此曾经的NB位置存储了C帧的图像数据,最终导致刷新出的帧序列变为:AABCCC,C帧的FrameTime短了,反而让玩家感受到游戏不流畅了。

(2)解决短帧卡顿


安卓提供了Swappy Frame Pacing库(Android Game SDK[6]的一部分),UE4.25[7]与Unity2019.2[8]已合入Swappy库。

通过 EGL_ANDROID_presentation_time[9]设置显示器Present的时间。在我们的例子中,更新频率是30Hz,通过设置PresentTime为30Hz,即可避免短帧卡顿的情况。

(3)长帧卡顿/延迟


什么!FPS难道不是越高越好吗?
如上图,B帧因为一些原因占用了超过33.3ms的帧时间,导致NB的帧重复了两次,造成AAABCC的帧序列,从而导致卡顿:

  • A延续了三帧;

  • B只展示了一帧。


(4)解决长帧卡顿


Swappy会通过添加同步锁,使显示系统有充足的等待空间,从而不至于将影响扩散。
什么!FPS难道不是越高越好吗?
通过同步锁 EGL_KHR_fence_sync[10] ,虽然帧A的问题无法解决,但帧A之后的B、C都不会受到帧A的影响。

2. Frame Pacing Library


Android Game SDK 中的 Swappy 库,不仅可以解决长短帧的问题,也可以支持动态调整设备的刷新率,以提供给玩家最流畅的视觉体验。不同刷新率的设备支持不同的FPS:

  • 60Hz:60FPS/30FPS/20FPS

  • 60 + 90Hz:90FPS/60FPS/45FPS/30FPS

  • 60 + 90 + 120Hz:120FPS/90FPS/60FPS/45FPS/40FPS/30FPS


Swappy可根据渲染器的具体帧时间,选择最符合的刷新率,提供给玩家一个更流畅的视觉体验,通过systrace[11]可根据SurfaceView的数据验证Frame Pacing库的改进。

至此我们了解到安卓平台的Frame Pacing改进方案Swappy库,其实就是一个简化版的G-Sync或Free-Sync,都可以通过动态调整显示器的刷新率(支持动态刷新率的设备)来输出更流畅的效果


3. iOS



苹果在2018年的WWDC上分享过一个演讲[12],其中介绍了苹果在Frame Pacing上所做的改进。

上面的动图中虽然左侧是40FPS,高于右侧的30FPS,但用户体验明显是30FPS侧更友好。

40FPS的执行时序情况如上图所示,VSYNC的最小间隔即刷新率为60Hz。当我们以尽可能快的速度去渲染新的帧时,0/1刷新点Display的缓存中没有数据,因此均使用历史数据。
即A展示了2帧。第2帧时,B帧的GPU计算完毕,可直接展示B。第3帧时,C帧的GPU计算完毕,直接展示C,且因为A的GPU错过了刷新点4,因此C也展示了两帧。依次循环往复,造成了:AABCCABBCAA 的长短帧问题,最终导致卡顿的表现。

(1)设置固定帧率


iOS 10.3以上支持新的API:





MTLDrawable addPresentedHandlerMTLCommandBuffer presentDrawable afterMinimumDurationMTLCommandBuffer presentDrawable atTime

可设置最小帧间隔,从而解决长短帧的问题:










// Render Scene...// Get drawable and present at 30 FPSlet drawable = view.currentDrawable {    // Render Final Pass     ...    let duration = 33.0 / 1000.0  // Duration of 33 ms    commandBuffer.present(drawable, afterMinimumDuration: duration)}commandBuffer.commit()


通过设置帧渲染最小间隔,可让帧以固定的频率渲染新的帧,从而为CPU、GPU留下了足够长的时间去渲染场景。
假设刷新率为60Hz,只要CPU与GPU完成协作输出数据的时间在3*(1/60)ms之内,即第1帧GPU的工作C 保证在 第3帧的工作A开启之前完成,iOS设备就可以输出连续的30Hz的图像。

4. Unreal Engine 4


从4.25版本开始,UE4整合了安卓的Swappy库:










// Runtime/OpenGLDrv/Private/Android/AndroidOpenGLFramePacer.cppvoid FAndroidOpenGLFramePacer::Init() {    InitSwappy();}void FAndroidOpenGLFramePacer::InitSwappy() {    JNIEnv* Env = FAndroidApplication::GetJavaEnv();    SwappyGL_init(Env, FJavaWrapper::GameActivityThis);}

开启Swappy时直接使用其API对Frame Pacing进行控制:












// r.setframepace 30// a.UseSwappyForFramePacing=1bool FAndroidOpenGLFramePacer::SwapBuffers(bool bLockToVsync) {#if USE_ANDROID_OPENGL_SWAPPY    int64 DesiredFrameNS = (1000000000L) /        (int64)FAndroidPlatformRHIFramePacer::GetFramePace();    SwappyGL_setSwapIntervalNS(DesiredFrameNS);    SwappyGL_setAutoSwapInterval(false);    SwappyGL_swap(eglDisplay, eglSurface);#endif

根据UE4的代码,UE4并未使用Swappy的默认模式[13],而是根据配置,通过Swappy设置了正确的同步节奏。
Swappy比UE4默认的FramePacer更了解安卓系统。根据UE4的文档,其真实表现也比默认的Pacer更稳定,未来的版本也将会在安卓平台把Swappy作为默认的FramePacer。
4.25以下版本使用UE4的Leagcy Frame Pacer:通过eglSwapInterval[14]控制Swap Buffer时所需等待的VBLANK[15]次数。
VBLANK指一帧数据最后一行显示完毕到下一帧第一行数据开始显示的过程,eglSwapInterval 实际上是无法精确了解显示屏(硬件)刷新的时间的,因此其真实效果不如更了解硬件的Swappy好。








// rhi.SyncInterval, 60Hz设置为1int32 SyncInterval = GetLegacySyncInterval();// 当配置的同步间隔改变时,使用eglSwapInterval配置VBLANK等待次数if (DesiredSyncIntervalRelativeTo60Hz != SyncInterval) {    eglSwapInterval(eglDisplay, DriverSyncIntervalRelativeToDevice);}

若设备支持 ANDROID_get_frame_timestamps[16] 扩展,可通过API拿到驱动层的一些时间数据,计算出更精确的SwapInterval:










EGLint Item = EGL_COMPOSITE_INTERVAL_ANDROID;// The time delta between subsequent composition events.eglGetCompositorTimingANDROID_p(eglDisplay, eglSurface, 1, &Item,    &COMPOSITE_INTERVAL);if (COMPOSITE_INTERVAL >= 4000000 && COMPOSITE_INTERVAL <= 41666666) {    DriverRefreshRate = float(1000000000.0 / double(COMPOSITE_INTERVAL));    DriverRefreshNanos = COMPOSITE_INTERVAL;}

并且可以解决短帧卡顿的问题。即通过查询历史帧的数据,控制Compositor的工作时机,当短帧发生时,根据刷新率计算出正确的工作时间。
什么!FPS难道不是越高越好吗?
保证短帧的数据B在显示器刷新两次,以保持体验的流畅性:
















EGLint TimestampList = EGL_FIRST_COMPOSITION_START_TIME_ANDROID;// The first time at which// the compositor began preparing composition for this frame.EGLnsecsANDROID Result = 0;eglGetFrameTimestampsANDROID_p(eglDisplay, eglSurface,    FrameIDs[Index % NUM_FRAMES_TO_MONITOR], 1, &TimestampList, &Result);
// 设置下一帧的起始时间EGLnsecsANDROID DeltaNanos =    EGLnsecsANDROID(DesiredSyncIntervalRelativeToDevice) *    EGLnsecsANDROID(DeltaFrameIndex) *    DriverRefreshNanos;EGLnsecsANDROID PresentationTime = Result + DeltaNanos;eglPresentationTimeANDROID_p(eglDisplay, eglSurface, PresentationTime);


(1)iOS


iOS通过控制CADisplayLink[17]的参数来控制FramePacing的表现:









FIOSPlatformRHIFramePacer::FrameInterval = NewFrameInterval;uint32 MaxRefreshRate = FIOSPlatformRHIFramePacer::GetMaxRefreshRate();CADisplayLink* displayLinkParam = (CADisplayLink*)param;// iOS 10displayLinkParam.preferredFramesPerSecond = MaxRefreshRate / FIOSPlatformRHIFramePacer::FrameInterval;// pre iOS 10displayLinkParam.frameInterval = FIOSPlatformRHIFramePacer::FrameInterval;

即可通过历史帧的数据动态调整FrameInterval或期望FPS,以达到更流畅的视觉体验。


5. Unity


Unity2019.2之后在安卓平台整合了Swappy作为FramePacer。
什么!FPS难道不是越高越好吗?
Unity2018版本仅设置了glSwapInterval,即通过不是很精确的timestamp模式控制FramePacing:









// Runtime/GfxDevice/egl/WindowContextEGL.cppEGLint WindowContextEGL::SetVSyncInterval(EGLint interval) {    interval = clamp(interval, m_VSyncIntervalMin, m_VSyncIntervalMax);    if (eglSwapInterval(m_EGLDisplay, interval))        return interval;    return -1;}


而iOS上FramePacing的实现与UE4基本一致。


上一篇:Vue+SpringMVC---前后端分离极简尝试


下一篇:FPS游戏方框透视的实现原理