图形引擎实战:游戏GPU性能优化

前言

最近项目场景进入优化阶段,之前我对优化的认识只在一些通用方法上,比如:减面,LOD等。对其他的优化方法完全不了解。这些手段确实是有作用的,但是如果不深入进去根据项目和硬件平台特点进行优化,很难实现性能和效果的最大性价比,就很有可能会出现“效果也不好,性能也不好“这种情况。

随着这段时间的学习和第一阶段优化结束,我对优化这件事有了更多的认识。我希望把我这段时间实践和学习得到的知识分享出来。学习时间不太长,经验不足难免会有疏漏。希望能对大家有帮助。

因为优化其实是个很大的话题,而且篇幅有限,我不能让文章太长。所以我只会讲解大概的思路和实际在项目中遇到的问题,比较复杂的内容我会贴上链接,大家可以去详细阅读。

目录

基本优化思路和工具

渲染管线和不同渲染管线

CPU与GPU架构

常见瓶颈和优化手段

实际项目中优化

基本优化思路和工具

其实很多的美术同学会有一个误解,就是减面,缩纹理就一定会得到性能提升。实际上不是,因为现代硬件设别都会有cpu和gpu两个重要的部分。也许目前的瓶颈不是gpu,是cpu。即使缩减了纹理,gpu也是在等cpu,并不会提高整体设备性能。所以做优化先要确定当前的渲染瓶颈在哪。

上面图表示了优化性能的循环过程,分析应用——确定瓶颈——优化瓶颈——测试瓶颈。如果从开发过程的一开始就遵循这些建议,就可以节省大量的时间和精力。根据项目场景的特点进行优化。

Profiling工具一般是不同的平台会有不同的工具,常用的就是Unity自带的profiler,framedebugger,Renderdoc,snapdragon profiler,xcode等。可以根据不同的硬件不同的平台选择不同的工具进行Profile。

下面我们可以用Unity的Profiler举个例子:

可以看到,上图中的2号框就是CPU主线程逻辑已经处理完,但是gpu还在渲染,cpu就是在等待gpu结束。这个时候瓶颈就在gpu上,对gpu优化就是有效果的。如果对cpu进行优化,即使cpu再快,也需要等待gpu渲染结束,性能不会有显著提升。

反过来,如果gpu在等待cpu,那瓶颈就在cpu上,优化gpu就不会有性能提升。

关于Unity Profiler工具的具体讲解,可以浏览Unity官网的博客。

Unity博客 用Unity Frame Timing Manager检测性能瓶颈

渲染管线和不同的渲染管线

传统的渲染管线也许你已经看厌了,刚接触渲染第一个学的就是渲染管线。在优化中,对渲染管线各个阶段的了解显得更重要。

基本就是从CPU传数据到GPU,到顶点着色器,然后做裁剪,屏幕映射。然后进行光栅化、片元着色器。最后逐片元操作,输出到framebuffer显示。

具体每个阶段做什么可以仔细阅读一下下面的链接。

知乎 一篇搞定Unity Shader入门精要(更新中)

CSDN 计算机图形学笔记(一)渲染管线概述

但是很多步骤是硬件或者引擎已经定好的,我们其实可以先不用管太多细节的内容。只需要知道大概的管线流程,在整个管线流程中,最重要的开销就是取决于数据传输数据计算。例如:如果模型顶点增多,数据传输量变大,顶点着色器因为是逐顶点执行的,所以数据计算量也会变大。

不过上面的管线只是抽象出来的逻辑管线,实际上当前的GPU根据设备的特性会使用三种不同的渲染管线:IMR(Immediate Mode Rendering)TBR(Tile Based Rendering)TBDR(Tile Based Deferred Rendering)

IMR:Immediate Mode Rendering

IMR是非常经典的IMR渲染管线,是桌面端最常见的GPU架构。图中管线的流程和上面提到的抽象逻辑管线的步骤差不多。不太一样的地方是在Raster和Texture&Shade阶段中间还有一个Early Visibility Test阶段。这个阶段是用来做Early Z Test的,相比之下,在逐片元操作进行的Z Test就被成为Late Visibility Test。关于Early Z 可以读下面的文章。

CSDN 图形学进阶——Early-Z和Z-prepass

IMR的优势就是渲染管线没有中断,有利于提高GPU的最大吞吐量,最大化的利用GPU性能。同时从vertex到raster的处理都是在GPU内部的on-chip buffer进行的,这意味着只需要很少的带宽,就可以存取处理过程中的图元数据。

所以桌面GPU天然就可以处理大量的DrawCall和海量的顶点。而移动端GPU则对这两者异常敏感。这不仅仅是GPU性能差异,架构差异也很重要。

但是IMR是全屏绘制的,这意味着我们需要一个全屏的framebuffer。这就导致这个framebuffer的内存很大。所以在光栅化之后的步骤,都会大量的和系统内存交互,有大量的带宽开销,对于移动端,这是不可接受的。

TBR:Tile-Based Rendering

对于移动端设备,控制功耗非常重要,功耗高意味着耗电、发热、降频。带宽是功耗的第一杀手,大量的带宽开销会带来明显的耗电和发热。

移动端GPU的带宽本来就跟桌面端GPU不是一个量级,又无法像独立显卡一样独占大量带宽,所以减少带宽开销变得异常重要。因此移动端GPU普遍使用得是TBR/TBDR架构。

TBR和IMR不同之处在于,TBR多了一个Deffered操作。在图中的Tiling阶段,gpu会等所有的顶点都处理裁剪好,然后把屏幕分成一个一个小Tile,GPU一次只绘制一个Tile。绘制完毕再将绘制结果写入FrameBuffer中。

TBR最大的优势就是减少了对主存的访问,减少了带宽开销。但是GPU要处理所有的顶点才会生成Tile List,然后再光栅化,跟IMR比就会有明显的延迟感。而且Tile List也同样有带宽开销。顶点越多,带宽消耗越多。所以移动端游戏对顶点数量更加敏感。

TBDR:Tile-Based Deferred Rendering

TBDR相比于TBR,又多了一个Deferred操作,就是HSR(Hidden Surface Removal)的过程,这个过程会让被遮挡的片元直接对丢弃掉。

不同架构的详细内容可以看以下文章

GPU Architectures——Maurizo Cerrato

Samsung Developers GPU Framebuffer Memory: Understanding Tiling

CPU与GPU架构

这块内容其实挺细节的,我们只做大概了解。

上图表示了CPU和GPU的硬件差异。

  • CPU核心数量少(计算单元少),每个核心都有控制单元。内存设计上是大缓存,低延迟。
  • GPU相反,计算单元非常多,多个计算单元共享一个控制单元。内存设计上追求高带宽,可以接受较高延迟。

所以CPU比较擅长分支控制,逻辑运算,但是GPU不擅长。GPU更擅长对海量数据并发计算,比CPU要快很多。

CPU和GPU的缓存体系

在cpu和gpu架构中,缓存是非常重要的一部分。上图中其实就能看出来区别。

CPU的缓存有L1/L2/L3三级缓存。L1缓存和L2缓存是在CPU核心内部的(每个核心都配有独立的L1/L2缓存),L3缓存是所有核心共享的,缓存是SRAM,速度比系统内存(DRAM)要快非常多。

多级缓存是为了减少延迟,L1缓存离核心最近,存取速度最快,但是存储空间最小;主存离核心最远,存取速度最慢,但是存储空间最大。

CPU查找数据的时候按照L1->L2->L3->DRAM的顺序进行。当数据不在缓存中时,需要从主存中加载,就会有很大的延迟。

GPU的缓存和CPU的缓存结构时相似的,但是GPU做不到CPU那样每个计算核心一两个缓存,它是一个SM(流多处理器)一个L1缓存,然后所有的SM共享一个L2缓存,最后是DRAM,显卡里的叫显存。

GPU查找数据的时候也是按照L1->L2->DRAM的顺序存取的,距离越远存取速度越慢。

详细的内容可以看下面链接的文章。

CSDN 深入GPU硬件架构及运行机制

NVIDIA DEVELOPER Life of a triangle – NVIDIA’s logical pipeline

常见瓶颈和优化手段

解决完必要的基础内容之后我们就可以接着谈优化了。Imagination有两篇关于自家PowerVR系列显卡的性能优化建议,其中列举了一些常见的性能优化场景。

Imagination PowerVR Performance Recommendations: The Golden Rules

Imagination PowerVR Optimisations and Recommendations

我结合实际项目遇到的问题来梳理一下相关的优化。

几何优化

减少几何复杂程度

相当于减少了模型的顶点数,更少的顶点就会减少顶点的传输(带宽压力),也会减少Vertex Shader的执行次数(计算压力)。对于移动设备就意味着Tiling阶段更少的等待,和更小的Tile List(带宽压力)。

模型减面、LOD包括凹凸,高度,法线,细节贴图去代替高模体现细节都是同样类型的优换化。这些基本都是最基本的优化,一般的项目肯定会用的,我就不多说了。值得一说的是,更多的面数并不会显著提升画面效果,反而会增加带宽和计算压力。针对模型复杂度,摄像机距离。使用足够的顶点数就够了。

我们在实际优化测试的过程中,用到了一个比较好用的减面工具:Polygon Cruncher。用于测试正好,但是想要效果更好的减面还是需要手动减面。

减少每个顶点数据量

顶点数据是指输入到顶点着色器中的顶点数据,或者是存储Mesh的顶点数据。我们通过压缩顶点数据类型减少读取顶点时的读写开销。在Unity中,我们可以通过设置Mesh的顶点数据类型来达到压缩顶点数据的目的。

我们可以在Unity官方文档中查看可以使用的数据类型:Unity Documentation VertexAttributeFormat

我们可以根据项目实际情况来选择足够精度的数据类型,上面是优化前后的顶点数据,可以看到其实是有不小的优化的,数据量已经砍半了。

在Shader中,我们也需要注意使用的数据类型,目前hlsl中有float和half两种浮点数类型,如果能用half类型就尽量不要用float类型。

甚至我们可以在Vertex Shader里使用一些快速的顶点数据压缩/解码方案。(少量的计算换取更少的带宽)。

KlayGE游戏引擎 完整的顶点压缩

KlayGE游戏引擎 压缩tangent frame

物体优化

基于摄像机距离的排序和剔除

这个前面我们提到过,EarlyZ是在Fragment Shader之前进行剔除,目的就是让不需要绘制的像素提前剔除,这样就能避免overdraw。但是EarlyZ有个缺点:如果手动写入了深度值、开启alpha test或者丢弃像素等操作,gpu就会关闭EarlyZ,不过现代GPU不存在这个问题。AlphaTest物体不能做EarlyZ write,但是可以做EarlyZ test。不过会因为深度回读导致卡管线,这个不可避免。

PreZ是在正式渲染物体之前,先对不透明物体渲一遍深度,然后在EarlyZ test阶段根据这个深度进行像素剔除。这样就可以避免多余的计算。所以PreZ一般是配合EarlyZ进行使用。

在实际测试当中,项目的草模型为面片草,我们发现AlphaTest对性能影响很大。这个时候其实瓶颈就不在顶点数量上,减顶点的提升微乎其微。所以最后我们选择PreZ+EarlyZ。(如果是模型草,就不存在AlphaTest的困扰了,但是模型草很难达到片草的精度,如果精度不是很高,推荐模型草)。

Unity已经给我们准备了现成的PreZ Pass,我们在我们的Shader上加上DepthOnly Pass就可以了。

然后在管线设置中更改为Forced,这样就可以看到PreZ的效果了。但是因为Auto选项在安卓和IOS平台默认不开启PreZ,所以只能改为Forced才会开。

如果相对单独Shader打开PreZ,可以自定义RenderFeature实现。

另外除了PreZ,EarlyZ还有Hi-Z。Hi-Z是视口内的遮挡剔除,它会剔除掉视口内被遮挡的物体。直接阻止这些物体提交到GPU。因为项目是小的固定场景,场景并不大,所以Hi-Z我们觉得可能提升效果不大,目前没有使用。感兴趣的可以看看下面的文章:

知乎 Compute Shader 进阶应用:结合Hi-Z剔除海量草渲染

基于材质/RenderState的排序

RenderState是个比较统一的称呼,像是buffer/texture绑定、framebuffer切换、shader切换、depth/stencil/culling mode/blend mode等都属于状态切换,并且会有性能开销。NVIDIA_OpenGL_beyond_porting中给出了一张图大概量化了各类状态切换的开销:

上图其实可以看到,有些状态的切换还是比较慢的。所以我们要尽量避免这些状态的切换:一种方式就是从根本上减少,减少使用的效果。另外一种方式就是把相同材质/RenderState的物体可以合并为一个batch提交,就是我们常说的合批,减少drawcall。

Unity中常见的合批手段有:Static Batching、Dynamic Batching、SRP Batcher和GPU instancing

Dynamic Batching使用条件比较苛刻,目前我们项目中使用非常少。基本上都是用另外三个。

Static Batching是比较传统的合批,它会把勾选static的Mesh合并成一个大Mesh一起提交。性能提升比较大,但是合成的大Mesh会占用额外的内存。不会减少DrawCall,但是会让CPU在“设置渲染状态-提交Draw Call”上更高效。

SRP Batcher是SRP管线带有的合批,要求不同的物体不同材质使用相同的Shader才能够合批。不会减少DrawCall,而是在DrawCall与DrawCall之间减少CPU的工作量。

Static Batching和SRP Batcher各有利弊,它们可以同时开,但是因为Static Batching是用空间换时间,所以在一些比较大场景的时候,Static Batching会让内存开销显著增大,而且一些小场景用Static Batching就比较合适。在项目中一些小型的战斗场景,就比较适合使用Static Batching。

GPU Instancing适用于大量相同的物体(同Mesh,同Material),比前两个效果都好。适合用在建筑、草、树等重复出现的物体。在项目中,我们也对草和树使用了GPU Instancing。但是由于Unity本身的限制。GPU Instancing不能和其他三者同时使用,若同时开启,程序只会执行优先级更高的一个。也就是说开了srp或static就不能开instancing。

优先级顺序:SRP Batcher | Static Batching > GPU Instancing > Dynamic Batching

但是我们只想让草和树开instancing,其他还是正常static或srp。方法也很简单,让草和树的shader不支持SRP Batcher就好了。打开了instancing它们就会自动走instancing。

关于合批更详细的讲解可以看下面的文章:

知乎 关于静态批处理/动态批处理/GPU Instancing/SRP Batcher的详细剖析

知乎 Unity渲染优化的4种批处理:静态批处理,动态批处理,动态批处理,SRP Batcher与GPU Instancing

除了合批减少状态切换,还有最直接的方式减少切换。上面的消耗排序的图可以看到,RenderTarget的切换是非常慢的。在渲染中,要尽可能避免频繁RT切换。

在实际项目测试中,有些后处理效果的RT切换的非常频繁,比如上面看到的Bloom效果,如果是默认状态,会有20多次渲染绘制,每次绘制都会切换RT。会产生大量额外的带宽开销。

我们可以勾选上Skip Iterations 选项,调节步数,减少渲染的次数以减少RT切换。当然越少的步数效果越差,实际参数要进行美术和性能之间的权衡。

其他的后处理效果也需要注意这个问题有没有过多的RT切换。并且要严格控制RT的大小。

还有一个在项目中遇到的问题,背包或者角色展示界面,会将场景或者角色绘制到一个RT上。然后再将这个RT绘制到UI上。一些UI框架会做优化,将静态的UI绘制到一个RT上减少DrawCall,但是如果不能保证UI真的完全静止不动(当然不能交互),在移动平台通常是负优化,增加了内存消耗和RT切换开销。

贴图优化

贴图优化在优化里也非常重要,在项目优化过程中,贴图优化做的最多的两件事就是:减少贴图数量压缩贴图大小。贴图最重要的就是内存和带宽开销。

减少贴图数量

因为有些Shader,美术同学为了保证好看,加了很多效果。贴图的数量多的飞起。有些效果或者贴图的数量并没有那么明显。在优化的过程中可能就要考虑把这些效果砍掉。或者是把多张单通道的贴图合在一起,减少采样次数。

减少贴图尺寸

减少贴图尺寸带来的最大好处就是提高缓存命中率,贴图尺寸越小,每条cache line覆盖的被采样的像素就越多。尽量保证贴图大小和效果之间的权衡,如果效果已经足够好或者已经看不到明显的提升了,可以缩小贴图尺寸。

使用压缩贴图

这个思路和顶点压缩是类似,就是牺牲一些计算量用于即时的贴图解压缩,来换取更少的带宽消耗。DXT/PVRTC/ASTC都是这样的思路。

可以在贴图设置中调整贴图的压缩格式,具体格式的说明可以查看Unity官方文档的解释。

Unity Documentation Recommended, default, and supported texture formats, by platform

也可以在脚本中设置Texture的具体格式。

Unity Documentation TextureFormat

使用Mipmap

MipMap基本上是标配,基本上都要用的,可以用少量的内存换带宽开销和异常效果。缓存命中率更高。

Shader优化

Shader优化也是占了大部分的时间,因为很多Shader在写的时候为了保证效果,没有考虑到性能,导致性能很差。

简化Shader

首先要对Shader进行简化,有些Shader可能会有很多实验性或者是效果不明显的功能。要和美术进行沟通然后该删减删减。对于不能删减的功能,就可以进行简化。

在删减或简化的过程中,一般看Shader编译后的指令数就能大概看出Shader优化前后相对的消耗。我习惯用RenderDoc截帧把Shader编译成DXBC看,反复注释代码用来定位哪些功能效果的消耗比较大。具体RenderDoc怎么使用和怎么看DXBC可以看下面的文章。

知乎 RenderDoc工具分析和使用

知乎 如何阅读和还原分析器中的DXBC?

在简化的过程中也可以遵循“足够好”原则。如果简单的效果已经足够好了,就没必要用更复杂的模型了。

在优化项目角色Shader中,因为战斗场景的角色数量比较多,所以角色的消耗是比较大的。不过好在相机视角不会太近,所以很多在展示阶段实现的功能都可以进行简化。比如:多光源中,额外光源使用的光照模型就可以使用blinn phong。

Shade中的分支

GPU和CPU不一样,同一个warp执行的是相同的指令,如果出现分支的时候,为了保证所有线程是同步的。GPU会把两个分支都走一遍,然后通过掩码丢弃不要的结果,这就带来很多额外的开销。

可以使用常量或者Uniform做为判断的条件,多数时候不会出现无意义的开销。

Unity中有UNITY_BRANCH和UNITY_FLATTEN两个关键字可以控制分支的类型。Flatten就是把两个分支都走一遍。Branch就是只走满足条件的分支,可以节省一些性能,但是也会增加一些开销。所以我在实际写Shader的过程中很少使用。

如果不使用if-else,另外的选择就是multi_compile。在我们项目中比较常用的是这个,不过很遗憾,它也同样会有副作用:它会增加变体数量,不同的变体就是不同的Shader,会导致SetPassCalls增加,影响运行时性能。变体增多也会产生更多的内存占用。

所以实际还是以测试结果为主,如果变体太多的话,还是选择if-else,并使用const或者uniform作为判断条件比较好。

Shader中的计算

  • DXBC中mad是一条指令,把计算转换成(a*b+c),可以节省指令。
  • saturate, negation, abs是免费的,clamp, min, max不是。不要负优化。
  • sin, cos, log, sqrt, pow, atan, atan2使用SFU进行计算,通常需要花费几个ALU甚至几十个ALU,尽可能避免。比如pow(x, 5),就可以写成 x2 = x*x; x5 = x2*x2*x这样,如果4次方的结果也能接受,甚至两个乘法就能搞定了。
  • 类型转化不一定是免费的(half->float, vec3->vec4),减少无意义的类型转换。
  • 尽量先计算标量再计算向量,性能会更好。比如下面这个例子。
  • 尽量使用“足够用”的数据类型,如果使用half足够的话,就没有必要用float了。
  • 减少Uniform变量、临时变量的数量(就是尽可能精简代码),可以减少寄存器的使用,避免寄存器超过限制写入主存。

经过这一通优化,项目的Shader效率就提高很多了。

拿项目的角色Shader举例,DXBC指令数基本上可以砍半了。

通用的优化

其实还有一些绝招可以用,基本上就是通用的方案:

减少模型数量

这个很容易理解,就是减少渲染内容,带宽和计算都会的到提升。

减低渲染分辨率

这个也是最后没有办法的办法,降低分辨率之后帧数也会有非常明显的提升,带宽和计算都会得到一定程度的提升。

可以在脚本中通过这个方法降低分辨率。

但是这些方法一般对效果影响很大,性能虽然有提升,但是会很大程度上降低效果。所以建议最后的最后,没办法的时候再用。

实际项目中优化

讲完上面一些常见的优化手段之后,可以来具体讲一下在项目中具体的优化细节。我们拿到工程场景的时候可以按照上面的方式查看一下场景内有没有非常夸张的资源。比如同屏面数,drawcall,模型面数,贴图尺寸,shader指令数等。如果有很夸张的是一定要调整的。

查看完之后就可以用性能测试工具进行分析。这里我们用的是Snapdragon Profiler。它可以用来分析安卓高通芯片的设备。具体使用方法我就不详述了。不过说实话这玩意很难用,经常莫名其妙崩溃。操作也不方便。

知乎 使用snapdragon profiler调试Unity安卓app

在截帧模式下把GPU General下的Clocks选中再截帧就能够看到渲染中每批渲染所耗费的clocks数了。这个基本上是准确的。然后再到Runtime模式下查看Clocks/Second(频率)这个数值。用clocks/频率,就可以大概估算出渲染项占用的时间了。一般在同一台设备上每个渲染项的clocks数相差不大,不会随着时间的变化而变化。一般变化的是频率。移动设备在运行游戏一段时间后温度会升高,会被降频。所以想要计算不同温度下或者不同游玩时间下渲染项的消耗,就可以只测量实际频率。然后计算就可以了。

下面是优化过程中,记录的各个渲染项的时间消耗。再根据场景本身的特点,判断哪些渲染项占用的时间比较多,还有优化空间。频率取值为600million,是实际测试出的大概数值。

我们在最后把分辨率改到原分辨率的70%,这样最后GPU总花费的时间为10.6ms左右,其实如果按这个时间来看,稳60帧其实没有问题。但是因为会发热降频,所以最后的帧数大概稳定50帧。

除了截帧分析渲染项消耗时间,我们还通过Runtime模式记录了一些带宽的相关参数,其中VertexMemory相关参数就是用来分析顶点数据带宽的。TextureMemory就是用来分析纹理数据带宽的。

每个数值的下面都给出了建议值。具体建议可以查看Android developers上面的文章。

Android developers Analyze memory efficiency

上面的数据其实都没有达到建议值,因为项目的优化还没有结束。

总结一下,目前项目做的效果比较明显的优化有:

  • 根据摄像机距离控制比较夸张的面数,贴图大小。LOD
  • 对草树叶等比较大量的模型使用instancing,其余的场景能用static的用static,不能用static用SRP batcher。
  • 对草树叶等需要大量alpha test的shader增加PreZ,PreZ+earlyZ效果不错。
  • 把远景等看不太清楚的模型材质都替换为比较简单的。比如远景的树和房子做成billboard。地形分块,近处地形比较精细,远处比较简单。
  • 检查并简化所有的Shader,首先检查贴图采样数,如果采样数过多一定要削减,能合并的进行合并,然后检查所有功能,简化比较费的效果。
  • 检查后处理,削减不必要的效果(后处理在移动平台上很费)。减少后处理效果的迭代次数(比如Bloom,模糊迭代10次以内比较好)。
  • 缩减顶点数据类型。
  • 控制特效面片的叠加,避免过多的overdraw。
  • 缩减的输出分辨率。

结语

通过最近优化这部分的工作,学到了很多优化相关的知识。最终优化方案对整体性能提升还是很大的,也非常感谢同事的帮助和指导。不过因为自己水平有限,篇幅有限,内容也很多,很难用一篇文章讲清楚。希望能让大家对优化有一个整体的了解,如果写的有问题欢迎大家来评论指正。

欢迎加入我们!

感兴趣的同学可以投递简历至:CYouEngine@cyou-inc.com

#搜狐畅游##游戏引擎##引擎开发工程师##我的成功项目解析##校招#
全部评论

相关推荐

已老实求offer😫:有点像徐坤(没有冒犯的意思哈)
点赞 评论 收藏
分享
某牛奶:一觉醒来全球程序员能力下降200%,小伙成功scanf惊呆在座个人。
点赞 评论 收藏
分享
5 17 评论
分享
牛客网
牛客企业服务