移动端游戏性能优化——渲染管线

1,442 阅读14分钟

为什么要做性能优化?

每个手游的厂商都希望支持更多的移动设备,有更多的玩家喜欢自家的游戏,这样才能给游戏厂商提供丰厚的回报。所以研发厂商需要要支持更多的设备,降低游戏过程中的卡顿,提高流畅性,保持电量和网络流量消耗是一个合理的范围。

我们从玩家的角度看一下什么是性能优化。

玩家从应用商店下载游戏,这时需要有更小的包体,更快完成从商店的下载。

启动游戏后,能让玩家很快游玩,要求进入游戏后不能有太多的资源下载。

进入游戏后,需要吸引玩家的眼球,要求游戏提供精美的画面。

在游玩游戏过程中,需要保证画面的前提下,保持流畅,不掉帧,不发烫,并保持网络的稳定。

当然应该避免崩溃使玩家突然被中断游戏,这种中断体验非常影响玩家的游玩感受。

简而言之,性能优化就是让想办法让玩家有更好的游戏游玩体验。

如何做性能优化?

当我们要解决一个问题,需要先要了解这个问题的具体情况,找到它的主要原因,解决主要原因后,再回头看这个问题是否已经彻底解决,如果没有,那么继续分析问题的现状,循环往复,直到达到预期。

性能优化也是一样的,需要:

  1. 采集性能数据
  2. 分析性能数据,猜想并验证主要瓶颈
  3. 针对主要瓶颈做优化
  4. 检查优化的性能指标是否达标,如果不达标,需要继续一轮的分析和优化

在一次性能优化后,需要再重新分析瓶颈在哪儿,因为优化后的瓶颈可能跟之前不同了,再继续在之前的瓶颈上优化下去可能是徒劳的。

游戏的性能指标一般有帧间隔(FrameTime,1000/FPS)、内存、包体大小、带宽、耗电量等数据。需要强调的一点是做帧率的优化,要以帧间隔为指标,而非FPS为指标,因为FPS的非线性的,从40帧优化到50帧和从50帧优化到60帧,虽然FPS都变化了10,但是它们的优化幅度是不一样的,40帧对应的帧间隔是25ms,50帧对应的是20ms,60帧对应的是16.67ms。

分析工具

工欲善其事必先利其器,想要做好性能优化一定要找到合适的工具。

笔者常用的Profiler工具有Nsight、AndroidStudio、Xcode、SnapDragon、Visual Studio等。这些工具能给我们提供分析性能的依据。除此之外,还需要开发自己的Profiler工具,满足项目内部的定制需求。除了通过采集性能数据之外,还可以根据制定的性能规范,并开发一套检查系统,在工序的早期能暴露出可能会有性能问题的地方,降低开发成本。

通过分析工具拿到数据之后,需要猜想并验证瓶颈在哪儿,找到瓶颈才能知道往哪个方向优化。在这个文章中,笔者写的是针对渲染管线的分析和优化,而不侧重于在游戏中的具体优化手段。因为渲染管线的瓶颈分析和优化方法是内功,上层的具体优化策略是招式。掌握了基础原理,搞优化时面对各种复杂情况才能有的放矢。而游戏中具体的优化策略,下篇文章再做介绍。

渲染管线

这篇文章需要有一定的渲染管线的知识,下图是Nvidia提供的一个简要的渲染管线图。

CPU处理的是游戏的运行逻辑、场景的组织和剔除,物理模拟、提交光照、贴图、模型和渲染命令给GPU等,这个也称为应用阶段(Application State)。

GPU接受到命令后,先处理顶点变换,将顶点从模型空间变换到NDC空间(Vertex Shading),再经过视口变换后,投影后的点在屏幕上的坐标。这个阶段称为几何阶段(Geometry Stage)。

变换到屏幕上的点会被管线组装成三角形,之后对三角形包围的区域就行着色(Pixel Shading),再经过混合,将混合后的结果保存到FrameBuffer中。这个阶段称为光栅化阶段(Rasterizer Stage)。

定位瓶颈

上面说到了可以用Profiler工具采集数据,帮助我们分析可能是哪个部分的瓶颈,但我们还需要进行验证。

由于CPU与GPU是并行的,GPU之间的流水线也在一定程度上是并行的。如果CPU是瓶颈,无论我们如何优化GPU的渲染,也是无法取得有效的优化成果的。

所以需要用到控制变量法进行一系列测试来验证是某个瓶颈的猜想是否正确。通过增加或减少某个瓶颈阶段对应的工作量,看性能指标是否发生变化。如果发生了显著的变化就证实了是这个地方的瓶颈。

GPU Gems的Graphics Pipeline Performance文章中给出了一种定位瓶颈的思路,从流程图上可以看出是从渲染管线的最底层向上直到CPU端查找瓶颈的。这篇文章也从下往上行文光栅化阶段、几何阶段、应用阶段的瓶颈定位方法。

光栅化阶段

从Nvidia给的管线图中可以看到,光栅化阶段包含Triangle Setup、Pixel Shading、Blending这三个步骤。

Triangle Setup只是把投影到屏幕上的点组装成三角形,没什么开销。所以我们要关注Pixel Shading和Blending这两个步骤。

Blending阶段的瓶颈定位

Blending的操作是将Pixel Shadering阶段的输出,如颜色、深度、模板值,经过alpha混合、比较测试等操作后保存到当前使用的FrameBuffer中。这个阶段需要读取FrameBuffer,所以它的瓶颈主要是FrameBuffer的带宽。通过减少FrameBuffer(颜色、深度模板缓存)的位占用,如32位改为16位,可以快速测试是否是此阶段的瓶颈。

Pixel Shading的瓶颈定位

Pixel Shading阶段是将光栅化三角形区域内的每个像素完成着色的计算,所以光栅化三角形的总面积决定了shader的执行次数。

shader里需要采样多个贴图,并进行复杂的光照计算,它与贴图的带宽以及shader指令的复杂度有关系。

可以通过降低分辨率来减少shader的执行次数,快速判断是否是PixelShading阶段的瓶颈。

执行一次shader的消耗与shader复杂度,采样纹理的大小,以及shader执行次数有关。通过减小shader分辨率,以减少shader执行次数,这时帧率如果有明显上升,说明瓶颈在Pixel Shading。之后通过控制变量法,进一步细化瓶颈的位置。减少shader的复杂度,判断是否是shader指令的瓶颈;通过减少纹理的带宽,判断是否是纹理带宽的瓶颈,而硬件API是支持指定采样某一级mipmap的,通过这种方法可以在不改变贴图原尺寸的情况下,快速验证纹理带宽的瓶颈。

几何阶段

几何阶段主要做的是顶点的投影变换,还有一部分其他计算,如顶点光照、或者像素光照需要的位置、TBN等。所以我们需要判断读取顶点和索引的带宽,以及Vertex Shader的执行是否存在瓶颈。

顶点和索引带宽的瓶颈定位

读取顶点和索引的GPU渲染管线的第一步,而它的带宽除了跟顶点和索引buffer的大小有关外,还与顶点和索引buffer存放的位置有关。如果存放在系统内存中,它们传输到GPU是通过AGP接口或PCI Express接口进行的,传输速度有限。另外一种方式是存储在GPU的独立内存中,对GPU来说,这种访问方式比较快。所以后续优化点里会有一项合理指定buffer的存储位置。

想要验证是否是这个阶段的瓶颈,可以增加或减少顶点buffer的长度。使用更高或更低精度的顶点信息,如64位或16位的int值,能比较符合控制变量测试这一要点。而如果使用其他的方式,可能会引起其他变量发生变化。增加或减少顶点数会影响到物体的形状,进而影响到光栅化的Pixel Shading阶段。需要注意,增加无用的顶点信息,GPU可能会在传输过程中忽略这个多余的顶点信息,造成测试失效。

Vertex Shader的瓶颈定位

Vertex Shader里执行了顶点的投影变换,以及可能会有的TBN和光照计算。这个过程还会包含顶点信息如纹理坐标、法线、颜色的插值。可以从改变vertex shader的执行次数,或改变每次vertex shader的执行消耗入手。

改变顶点的数量就能改变Vertex Shader的执行次数,但是这种方法可能会引起其他变量的变化。

改变shader的执行消耗也就是控制Vertex Shader的的指令,通过使计算复杂化或者简化,测量帧率的变化,就可以判断是否是shader执行的瓶颈。其中需要注意的一点,不要使用不会被用到的计算,以防被GPU优化掉干扰测试。

应用程序阶段的瓶颈

如果在GPU管线流程中没有找到性能瓶颈,那说明瓶颈在CPU上,也就是应用程序阶段。

除了用排除GPU瓶颈作为判断应用程序阶段的瓶颈外,可以利用增加或减少一些对GPU没有影响的计算量看帧率是否发生变化;也可以把将GPU的消耗降到最低,比如发送空的GPU命令,看帧率是否发生明显变化。

CPU端的性能数据很容易采集,所以这时候用Profiler能直接看出来有异常的地方,XCode、AndroidStudio的都可以。

渲染管线的优化策略

对不同阶段的瓶颈,优化策略不同,下面对从管线自上而下的顺序介绍优化策略。

应用程序阶段

应用程序优化的重点在于减少CPU的时间消耗。可以从以下多个方面入手:

  • 优化算法,降低时间复杂度

  • 提高并发,将高消耗的运算放到异步线程,比如相交检测,动画更新等

  • 减少CPU提交命令的等待时间。CPU提交命令有一定的时间消耗,可以从多角度优化

    • 通过空间加速结构(四叉树等)、视锥体裁剪等提不需要显示的物体
    • 通过静态或动态合批技术,减少DrawCall的次数
    • 把提交渲染命令的操作交由异步线程处理,UE4的RHI线程专门负责与GPU的交互
    • 对渲染状态做排序,比如依据材质、采样方式等,可以有效减少GPU切换状态的消耗,有利于减少CPU的等待时间
  • 减少资源锁定,由于CPU与GPU是并行的,CPU读取某个资源的时候可能GPU正在处理这个资源,这样CPU就只能等待,造成CPU周期的浪费

    • 尽量避免在渲染期间读写资源
    • 在创建资源时指定合适的存储位置,存储在系统内存还是GPU内存,以免因为存储位置不合适造成CPU/GPU使用资源的消耗过高
  • 让GPU承担部分CPU的运算

    • 将CPU蒙皮改为GPU蒙皮
    • 利用GPU求物体的可见性,如Hierarchical Z-Buffer Occlusion
  • 合理组织数据的存储结构,保证连续访问的内容也要保持连续存储,提高cache命中率。

  • 使用SIMD,矩阵运算,视锥裁剪都可以用SIMD完成。

  • 尝试不同的编译器,选择性能最优的编译选项。

几何阶段

几何阶段的消耗主要由两部分组成:顶点和索引buffer读取,顶点变换。笔者没遇到过瓶颈出在顶点变换阶段的。所以只讲下如何优化顶点和索引buffer的读取瓶颈。

  • 优化顶点buffer的存储布局方式,提高cache命中率

  • 减小顶点和索引buffer的长度

    • 优化模型,减少模型的面数
    • 某些属性,如顶点颜色、顶点UV、索引buffer都可以用低精度的数据存储
  • 能推导出来的变量就不需要存储到顶点中了,比如TBN矩阵,只需要其中的两个向量就可以计算出第三个向量。

  • 顶点数据压缩

光栅化阶段

光栅化阶段是最容易出问题,因为光栅化的结果是引擎程序、客户端程序、美术甚至还有策划的配置共同的结果。

Pixel Shading阶段

  • 降低分辨率,这个Pixel Shader的消耗与分辨率直接的关系,降分辨率通常是最快速有效的方法

  • 减小贴图尺寸,使用GPU支持的压缩格式,如ETC2、ASTC等,场景中的贴图使用MipMap,慎重使用三线性过滤,降低采样贴图的带宽消耗

  • 降低Pixel Shader的计算量

    • 将复杂函数,如Color Grading算法用Color Look Up(LUT)代替,这样Pixel Shader里就只需要花费采样贴图和查表的开销
    • Shader中的静态光照和阴影,使用烘焙系统烘焙出光照贴图(LightMap)和阴影贴图(ShadowMap)
    • 尝试将实时光照中的部分计算移动到Vertex Shading阶段
    • 利用LOD技术,低等级细节用更简单的shader
  • 合理使用shader中支持的低精度类型,如颜色、UV这种对精度要求没有特别高,而位置、法线等相关的变量对精度的要求就比较高

  • 利用管线的性质,减少Pixel Shader的执行,如通过对物体排序从前往后渲染,利用Early-Z剔除被遮挡的像素。非常重要的一点,慎重使用discard指令,因为它在Pixel Shader里决定是否丢弃像素,影响了深度值,会使Early-Z失效

  • 合理使用消耗比较高的函数,如pow、exp、sin、log等

  • 控制Alpha Blend的使用,因为alpha透明会导致像素重绘(OverDraw)

  • 合理组织不透明、半透明和alpha-test三种类型物体的绘制,一般是先画alpha-test,再画不透明,最后画透明物体

Blending阶段

这个流程里主要跟FrameBuffer的读写有关系,从减少带宽的角度优化

  • 尝试用更低精度的FrameBuffer,如16位的深度和颜色
  • 尽量不要使用浮点类型的FrameBuffer,它会比整型消耗更多的带宽
  • 对物体从前往后排序,执行Depth-Pre Pass先完成深度的写入
  • 关闭不必要的深度写入

结尾

这篇文章里从渲染管线的角度分析性能和优化的方法,偏基础原理。下篇文章写一些笔者在工作中用到的一些优化方法。

参考

  1. 【《Real-Time Rendering 3rd》 提炼总结】(十二) 渲染管线优化方法论:从瓶颈定位到优化策略_浅墨_毛星云的博客-CSDN博客_渲染管线优化
  2. GPU GEMS. Graphics Pipeline Performance