从图形渲染管线谈性能优化
最近做一个3D场景类项目,其中涉及到各种建筑模型、植物模型、地形等等。画面效果还不错;可是运行起来,那性能啊,没得玩。索性尝试了各种优化手段,这里简单和大家分享一下。当然,在讲如何优化性能之前,先谈谈图形渲染管线。
图形渲染管线
我们都知道,我们开发游戏或者是开发三维项目,其开发目的,都是将三维场景中的一些模型、纹理渲染到二维的显示屏上,以像素的形式展现给人的眼睛。看似一个简单的过程,其中要经历的步骤可没有那么简单。它需要CPU、GPU、内存的相互配合和深度参与;这一系列过程就是渲染流水线。通常渲染流水线将其渲染流程分为三个阶段:应用阶段、几何阶段、光栅化阶段。其每个阶段由特定的硬件参与。如下图:
应用阶段
首先确定,应用阶段是由CPU主导的,也就是应用阶段的一切操作都是由CPU来执行的。那么,在应用阶段,CPU通常会执行哪些操作呢?假如,我们有一个3D场景,场景里有一些建筑;建筑有一些纹理贴图;场景中还包含一些灯光、摄像机等等。我们的目的是要把这个三维场景渲染到显示器上,显示器显示的是一个一个的像素。学过光照模型的同学都知道,我们要对一个模型进行光照计算,需要直到模型的顶点坐标、法线、视角坐标以及光照颜色等。这些数据统统都是由CPU来收集,CPU收集这些需要参与计算的数据(将其加载到内存中(RAM)),由于大多数现代GPU不能访问RAM,所以CPU还需要把顶点、网格、纹理等数据加载先显存中(VRAM),并设置当前的渲染状态。当把数据加载到显存之后,就可以释放RAM中的一些数据了。通常,这个过程,CPU会去调用图形底层API(DirectX或者OpenGL),我们将这个过程称之为DrawCall.
几何阶段
首先确定,几何阶段是由GPU主导的,即几何阶段中发生的一切操作均通过GPU来执行。在应用阶段,CPU收集的一些数据被传递到了当前阶段。几何阶段主要包含如下操作:
众所周知,一个简单三角形的基本构成为点、线、面。应用阶段输出的模型顶点将作为几何阶段的输入,进行逐顶点、逐多边形操作;且模型顶点位于模型空间中,为了最终将其渲染到屏幕空间;需要将模型坐标进行如下变换,将顶点坐标由模型空间变换到屏幕空间中:
通过如上一系列变换将模型顶点变换到屏幕空间中,并将不在摄像机视野内的顶点裁剪掉,剔除一些不需要渲染的面片。
光栅化阶段
通过几何阶段的计算,得到了一些三角形片元,在光栅化阶段,我们就需要对这些三角形片元进行设置和遍历,并对每个片元进行着色,逐片元操作,其主要包含以下流程:
其中,三角形设置、三角形遍历、逐片元操作均为不可编程配置阶段。片元着色器中用于实现逐片元的着色操,可编程实现;而逐片元操作阶段则执行一些如:颜色修改、深度缓冲、透明度混合等,其不可编程实现,但具有较高的可配置性。
如何针对渲染流水线进行优化
说了那么多渲染流水线的相关知识,那么我们针对场景的优化应该从那里下手呢?因为渲染管线主要涉及的硬件包括CPU
、内存(RAM)、GPU(显卡、显存),那么,我们就从CPU端的优化开始吧。
CPU端性能优化
盘点一下,CPU端会进行哪些操作:读取模型顶点信息、材质信息、光照信息、纹理信息等等,然后调用底层API(DirectX或者OpenGL)进行渲染,这个过程我们称之为DrawCall。
DrawCall优化———static(静态标记优化)
1.先看一个图,如下,场景中包含四个模型(立方体、球体、胶囊体,平面j均使用同一个材质球)、一个主摄像机、一个主光源,我们不做任何处理,其DrawCall如下:
其渲染过程如下:
2.我们将四个模型都将其标记为静态的(static),其DrawCall如下:
其渲染过程如下:
总结一:通过如上对比,可发现,在多个模型使用相同材质的情况下,将共用相同材质的多个网格标记为static(静态的),可以大大降低DrawCall。Unity会将阴影绘制、和模型渲染进行合并批处理,从而降低CPU调用。
3.如果多个网格使用的材质不同,且不标记static,其渲染DrawCall如何,看下图:
其渲染过程如下:
4.如果多个网格使用不同材质,并将网格标记为static,其渲染DrawCall如何,看下图:
其渲染过程如下:
总结二:通过如上对比,可发现,在多个模型使用不同材质的情况下,将不同模型的网格标记为static(静态的),会对DrawCall产生一定的影响,但影响并不是很大。由于各个模型使用的材质不同,标记为静态并不会很大程度降低DrawCall。但是,Unity会对所有静态模型的阴影绘制进行合并批处理,一定程度上降低DrawCall。
DrawCall优化———static(视锥体剔除(Frustum Culling)、遮挡剔除(Occlusion Areas))
说到视锥体剔除(Frustum Culling)和遮挡剔除(Occlusion Areas),需要注意的一点是,我们这里说的视锥体剔除,并不是发生在几何阶段的片元裁剪。视锥体剔除是由CPU执行的一个对场景对象进行粗粒度剔除的一种操作,这种操作针对的是对象,并不是片元。通常,视锥体剔除在Unity中,游戏运行时是默认进行的,并不需要我们额外配置。但是,我们也可以根据视锥体剔除的工作原理,对场景模型做一些优化。
视锥体剔除(Frustum Culling)
1.先看个图,场景中为三个独立的模型,且分别为三个不同的材质,其DrawCall如下:
2.下图,将上图三个模型合并为一个独立的模型,且将材质也合并为一个独立的材质,其DrawCall如下:
总结三:1中当对象逐个被移除视锥体之后,DrawCall不断减小。2中针对一些场景,把多个多边形网格合并为一整个大的多边形网格。就拿图1中这个场景来说,我们有三个对象,绘制网格的DrawCall可能就会占用3个;我们把这三个网格合并为一个,如图2,那么绘制网格的DrawCall可能就会只用1个;虽然这种方式可以减少DrawCall的占用数量。但是,这里需要强调一点,视锥体剔除针对的是对象,并不是片元。当三个小网格合并为一个大网格之后,移动摄像机,当模型的一半在视锥体外时,视锥体并不会对这个大网格进行裁剪,而是把网格的全部顶点传递给GPU.这时,如果合并之后的网格过大,就会造成CPU瓶颈。所以,针对视锥体剔除的特性,对网格进行合并时,我们需要根据需求进行衡量是否合并多个模型为一个网格。
遮挡剔除(Occlusion Areas)
遮挡剔除,顾名思义,就是把被遮挡住的部分剔除,不参与渲染,此过程发生在CPU阶段,即使用此技术将完全被遮挡的对象在渲染管线的应用阶段剔除,几何阶段和光栅化阶段均不会参与。如下图,存在如下一个场景从,场景中物体中存在大量的遮挡关系。
1.未进行遮挡剔除优化,其DrawCall如下:
2.同一场景,同一视角。对场景进行遮挡剔除优化,其DrawCall如下:
3.遮挡剔除的工作原理:
总结四:遮挡剔除技术可以将场景中将被遮挡的对象剔除渲染管线,从而提升渲染管线的处理能力。通常这个过程由CPU主导。注意区别几何阶段的裁剪阶段,遮挡剔除并不是对几何图元进行裁剪。而是通过对当前摄像机视角内的一些对象执行计算,如果对象在视角内完全被其它对象遮挡住,则将其剔除渲染管线。如果对象只有一半或部分在视角内,也不会对其进行剔除。
DrawCall优化————网格合并
渲染管线的最终目的就是将场景中的三维模型、纹理等经过一系列变化、计算,最终以像素的形式将其呈现在二维屏幕上。之前提到过,每需要渲染一个对象,CPU都会调用底层API(DirectX或者OpenGL)进行绘制,且CPU会将材质、纹理等信息从内存(RAM)拷贝到显存(VRAM)中,跨硬件的调用是一个耗时的过程。如果我要渲染10个三维模型,意味着我就需要调用10次底层API。那么我们可不可以在绘制过程中通过什么方式来减少对底层API的调用呢?答案是可以的。我们可以将多个使用相同材质的三维模型合并为一个模型来达到这个目的。注意,这里提到的相同材质的多个三维模型。有同学可能就会问了,那不同材质的多个三维模型合并可不可以呢?答案是可以的,如果把多个材质也合并为一个材质(即把多个模型用到的纹理都打包为一张图集),这种方式也会显著的降低DrawCall。但是,如果合并后的网格仍然具有多个材质,那么,这种方式对DrawCall的减少作用是比较小的。我们之前的静态部分标记部分有说到原因。(Tips:合并工具推荐:MeshBake),下面,我们简单看两张对比图:
1.具有相同材质,但未对网格进行合并的场景:
2.将具有相同材质的网格进行合并:
总结五: 对多个具有相同材质类型的网格进行,可以降低DrawCall消耗。通过合并多个网格为一个网格,降低了CPU与GPU通信的次数,从而降低DrawCall的消耗。
GPU端优化
从图形渲染管线可知,GPU主要执行几何阶段和光栅化阶段。几何阶段涉及的过程主要包含:模型顶点变换->顶点着色阶段->曲面细分着色->投影->裁剪->屏幕映射。光栅化阶段涉及的过程主要包含:三角形设置->三角形遍历->像素着色阶段->逐片元操作->合并输出。其实,GPU端的优化很简单,只需要保证应用阶段传递到GPU的顶点数量、纹理数量、材质数量合理,就不会造成CPU渲染的压力。
多细节层次(Level Of Details)
这种处理方式很简单,根据相机视角距离对象的远近距离来控制GPU渲染高精度模型还是低精度模型。一般相机距离对象较近时,渲染高精度模型;距离对象较远时,渲染低精度模型或者不渲染。简单看个图:
总结六:使用LOD技术可以降低CPU端的渲染压力,当尽头距离目标物体较远时,渲染较低精度的模型或者剔除渲染;当镜头距离目标物体较近时渲染高精度的模型,可以保证模型的精度不丢失。LOD技术是典型的概念是使用空间换时间。使用多种不同精度的模型(高模、中模、低模)依据镜头的远景有选择性的渲染。虽然多个模型占据一定的内存空间,但是相对于GPU的渲染负载,牺牲内存占用显然是较明智的。
正向渲染(Forawrd Rendering)和延迟渲染(Deferred Rendering)
根据物理学的定理:人为什么能看到物体的颜色?人眼可以看见可见光,波长从红光到紫光。白光是复合光,白光是由多种颜色的光复合而成,如我们所见的太阳光;由赤、橙、黄、绿、青、蓝、紫等构成。我们肉眼之所以能够看到世界中各式各样的颜色,并不是由于那个物体发出了对应颜色的光。而是由于物体反射了对应颜色的光。同样,在计算机图形学中。对物体光照的计算是非常重要的。在Unity中可对物体执行逐顶点、逐像素光照计算。所谓逐顶点光照计算,即在顶点着色器中进行光照的计算,通过为每个顶点计算一次光照颜色,然后再通过顶点所在多边形覆盖的区域对像素颜色进行插值,其光照值取决于光线角度,表面法线和观察位置。逐像素光照计算,即再片元着色器中进行光照计算。对每一个像素执行光照计算。无论是逐顶点光照还是逐像素光照,都会造成GPU的计算负载。
正向渲染特性(Forward Rendering Attribute)
-
正向渲染(Forward Rendering),先执行着色计算,再执行深度测试。Unity中,最亮的几个光源会进行逐像素计算,较次的后四个会进行逐顶点计算,剩下的则使用球谐计算。
-
正向渲染的物体需要使用两个Shader Pass进行光照计算。一个BasePass渲染第一个逐像素的平行光,以及所有逐顶光和球谐光。另一个Additional Pass 用于渲染除BasePass中渲染的平行光外,其余每个需要逐像素渲染的光源都会使用这个Pass进行光照计算,并最终将颜色叠加到BasePass渲染出的颜色上。
-
使用正向渲染的物体,其光照计算复杂度与光源数量成正比,渲染m个物体在n个光源下的着色,其复杂度为O(m*n)
-
正向渲染适用于光源较少的场景。
正向渲染过程如下:
图形渲染管线中,深度测试是用来消除场景中不可见的面,比如图元的遮挡,由于三维场景的Z轴层次关系,一个物体可能覆盖再另外一个物体之上,那么对被覆盖住的物体执行计算是没有任何必要的,因为它根本不会再屏幕上显示出来。由于正向渲染的特性,先执行着色计算,再进行深度测试,会导致一些本不需要显示的片元也参与了着色计算,导致性能浪费。
延迟渲染特性(Deferred Rendering Attribute)
- 延迟渲染(Deferred Rendering),先执行深度测试,再执行着色计算。
- 顾名思义,延迟渲染,就是将着色过程延后,先将场景被遮挡且不需要显示的片元丢弃。最后再对有效的片元执行着色计算。
- 使用延迟渲染,其光照计算复杂度受光源数量影响并不大,渲染m个物体在n个光源下的着色,其复杂度为O(m+n)
延迟渲染过程如下:
延迟渲染过程,在对场景的渲染过程中与正向渲染不同,延迟渲染首先将简单的几何信息(顶点坐标、法线向量、纹理坐标等等)存储到中间缓冲区中,即G-Buffer中(Geometry Buffer 几何缓冲中)。着色阶段只需要渲染出一个屏幕大小的矩形,使用G-Buffer中的几何数据与光照模型进行光照计算。
总结七:正向渲染和延迟渲染各自有各自的优势;两者最大的差别允许在于参与计算的光源数目。同样,有光源意味着,光源照射物体会产生阴影,实时阴影的计算同样是非常消耗性能的,不仅会对CPU造成过多负载,同时也会加大GPU的渲染压力。因此,合理的光源数量、阴影数量、质量对提升游戏的FPS有很大的帮助,同时也可以应用光照贴图技术来减少替换实时照明和阴影绘制。
对游戏的优化,有很多种方式、手段。从硬件、图片、纹理、模型、光照、批处理以及渲染管线的各个阶段下手。以上,就是总结的一些优化性能的方式。
参考
- 《Unity Shader 入门精要》——冯乐乐
- 《Real-Time Rendering 3rd》 提炼总结——浅墨_毛星云
- 《Real-Time Rendering 3rd》
更多内容,欢迎关注: