第六章 渲染优化

  • Post author:
  • Post category:其他


这里起个头,后续有时间,根据划分出的小的点一个个补充。



1.实时渲染部分概念

CPU通过图形API向GPU设备发送渲染指令,再通过硬件驱动程序发送给GPU设备,渲染指令列表会累积在一个称为“命令缓冲区”的Queue结构中。这些命令由GPU逐一处理直到为空。只要GPU能在下一帧开始之前跟上指令的速度和复杂度,帧速就保持不变。然而,如果GPU跟不上,或者CPU花费太多时间生成命令,帧速率将开始下降。



填充率

填充率指的是GPU绘制片元的速度(只包含在给定的片元着色器中通过各种条件测试的片元)。



过度绘制Overdraw

在这里插入图片描述

过度绘制的越多,浪费的填充率越多。

不同的渲染队列大致为两种类型:不透明队列和透明队列。在不透明队列中渲染的对象可以通过Z-测试剔除片元。然而,在透明队列中渲染的对象不能这样做,这将导致大量的过度绘制。

所有的Unity UI对象通常都在透明队列中渲染,这也是过度绘制的主要来源。



内存带宽

只要从GPU 显存VRAM的某个部位将纹理拉入更低级别的内存中,就会消耗内存带宽。这通常发生在对纹理采样时。

GPU包含多个内核,每个内核都可访问VRAM的相同区域,还都有一个小得多的本地纹理缓存,来存储GPU最近使用的纹理。(这种设计与CPU的多级内存缓存结构类似,允许数据向上或向下传输到不同级别的内存上)。这是硬件设计方法,因为内存越快,意味着制造越困难,成本越高。因此与其拥有一个庞大、昂贵的VRAM块,还不如采用大容量、便宜的VRAM块,使用更小、更快、更低级别的纹理缓存来采样,从而两全其美,即快速采样,成本更低。

如果需要的纹理已经存在于内核的本地纹理缓存中,那么采样通常如闪电般快速,几乎感觉不到。否则,需要从VRAM中提取纹理信息,才能进行采样。这实际上是纹理的有效缓存数据丢失,因为现在需要花些时间从VRAM中寻找并提取需要的纹理。这种传输会消耗一定数量的可用内存带宽,这个量相当于VRAM中存储的纹理文件的总大小(原始文件会压缩)。

如果在内存带宽方面遇到瓶颈,GPU无法及时将数据推回到帧缓冲区,以渲染到屏幕上,导致帧率降低。

内存带宽进行合理使用需要进行估算,例如:每个内核的内存带宽为每秒96GB,目标帧速率为每秒60帧,在到达内存带宽的瓶颈之前,GPU每秒可提取1.6GB(90/60)的纹理数据。



光照和阴影

阴影:首先为场景设置阴影投射器和阴影接收器,分别用来创建和接收阴影。然后,每次渲染阴影接收器时,GPU都会从光源的角度将任何阴影投射器对象渲染成纹理,目标是收集每个片元的距离信息。对阴影接收器进行同样的动作,除了阴影投射器和光源重叠的片元外,GPU可将片元渲染得更暗,因为这类片元位于阴影投射器产生的阴影下。这些信息变成附加的纹理,称为纹理阴影(Shadowmap)。

由于片元着色器需要多次传递信息来完成最终的渲染,因此在填充率和内存带宽(Lightmap和Shadowmap纹理)代价很大。这就是为什么和大多数其他渲染特性相比,实时阴影异常昂贵,在启用后会显著增加Draw Call数的原因。

游戏内为阴影有很多优化手段和成熟方案,这值得另开文章详细说。



全局光照GI

全局照明(Global Illumination,GI),是烘焙Lightmapping的一种实现。Lightmapping类似于阴影映射技术创建的Shadowmap,其为每个表示额外照明信息的对象生成一个或多个纹理,然后在片元着色器的光照过程中采样,以模拟静态光照效果。

这些Lightmap和其他形式的光照的最大区别是,Lightmap是在编辑器中预先烘焙的,并打包到游戏的构建版本中。这确保在游戏运行时不需要重新生成,从而节省大量的Draw Call和GPU。且可以烘焙出高质量的Lightmap。

Lightmap只应用于场景中的静态对象。但是,Light Probe可以生成一组额外的Lightmap纹理,这些纹理可以应用到附近的动态对象,使这些对象能够从预生成的光照中受益。

Unity有两种不同的解决方案。全局照明是在Lightmapping后最新一代的数字技术,它不仅计算光照如何影响给定对象,还计算附近物体受光后反射结果,用来影响它周围的光照贴图信息。它就是Enlighten,该系统既可创建静态Lightmap,也可创建预计算的实时全局照明,它是实时和静态阴影的混合,支持在没有昂贵的实时照明效果下模拟一天中的时间效果(太阳光的方向随时间变化)。



多线程渲染

Android系统可以选中Edit | Project Settings | Player | Other Settings | Multithreaded Rendering复选框来开启。

iOS系统可通过使用Edit | Project Settings | Player | Other Settings | Graphics API下面的Apple’s Metal API开启。

多线程渲染特性一旦启用,将影响到CPU的瓶颈。在未启用该特性时,主线程将执行为命令缓冲区生成指令所需的所有工作,这意味着在其他地方提升的性能可以释放出来,让CPU生成指令。但是,当多线程渲染启动后,大部分的工作都被推送到独立的线程中,这意味着通过CPU提升主线程对渲染性能的影响很小。



2.性能检测问题

由于渲染涉及的过程很多,因此GPU的瓶颈可能出现在很多方面。接下来探讨如何检测这些问题。



方式一:性能分析器分析问题:


第一步,先找到CPU还是GPU哪个是性能瓶颈。


性能分析器可以把渲染中的瓶颈定位到CPU或者GPU。使用性能分析器窗口中的CPU使用率和GPU使用率来检查问题,这样可以知道哪个负荷重。

举栗子1:在不采用批处理和阴影技术的情况下,创建上千个简单的立方体对象。

在这里插入图片描述

上图显示了结果。性能分析CPU受限。这个测试是为了让CPU生成指令而产生非常多的Draw Call(大约32 000次),由于渲染的对象很简单,因此GPU的工作很少。

从图中可以看出,CPU的渲染消耗了大量的循环(每帧约25毫秒),而GPU的处理小于4毫秒,这表明瓶颈在CPU中。

请注意,该性能分析测试是针对独立的程序完成的,而不是在编辑器中测试。

现在我们知道,渲染是受CPU限制的,因此可以开展一些节省CPU的性能改进工作。


举栗子2:只创建一个简单对象,但使用昂贵的着色器对纹理进行数千次采样。

在这里插入图片描述

如上图所示:CPU Usage区域的渲染和GPU Usage区域的总渲染成本很接近。还可看见画面底部的CPU和GPU时间成本也相当类似(均为29毫秒)。似乎CPU、GUP都存在瓶颈,但其实GPU的负荷比CPU大。

因为采用层级模式深入查看CPU Usage区域的分解视图,会发现CPU的大部分时间都花在标记为Gfx.WaitForPresent的任务上。这是

CPU等待GPU完成当前帧时浪费的时间

。因此,尽管看起来瓶颈受两者的约束,但实际上瓶颈还是受GPU影响更多。即使启用了多线程渲染,CPU还是需要等管线渲染完成,才能开始下一帧的处理工作。

Gfx.WaitForPresent通常用来表示CPU正在等待垂直同步完成,因此在本测试中需要禁用。



方式二:暴力测试分析问题

暴力测试方法:在场景中去除指定的活动,并检查性能是否有大幅提升。如果一个小的调整导致速度大幅提升,就说明找到了瓶颈所在的重要线索。

  1. CPU受限:CPU受限的暴力测试就是降低Draw Call来检查性能是否有突然的提升。然而这种方式通常不太可能实现,因为我们已经通过静态批处理、动态批处理、混淆等技术将Draw Call降到最低限度。这说明可降低的Draw Call范围非常有限。

    但是,可以引入更多对象或禁用节省Draw Call的功能(如静态和动态批处理),故意将Draw Call计数增加一小部分,并观察情况是否比以前更糟糕。
  2. GPU受限:有两种好的暴力测试方法,用来确定是填充率受限还是内存带宽受限,这两种方法分别是

    降低屏幕分辨率和降低纹理分辨率



    降低屏幕分辨率:可以让光栅器生成的片元少许多。减少填充率的消耗。所以,如果屏幕分辨率降低后性能突然提高,那么填充率应该就是瓶颈。

    降低纹理分辨率:它可以测试内存带宽是否遇到瓶颈。降低全局纹理质量可进入Edit | Project Settings | Quality | Texture Quality,设置的值为Half Res、Quarter Res或Eighth Res。



3.渲染性能增强



1.GPU Skinning

可以通过牺牲GPU Skinning来降低CPU或GPU的负载。该功能可以在Edit | Project Settings | Player Settings | Other Settings | GPU Skinning下切换。

原理:Skinning是基于动画骨骼的当前位置变换网格顶点的任务。在CPU上工作的动画系统会转换对象的骨骼,确定其当前的姿势,但动画过程中的下一个重要步骤是围绕这些骨骼包裹网格顶点,以将网格放在最终的姿势中。为此,需要迭代每个顶点,并对连接到这些顶点的骨骼执行加权平均。该顶点处理任务可以在CPU上执行,也可以在GPU的前端执行,具体取决于是否启用了GPU Skinning选项。



2.降低模型复杂度

  1. 让美术团队手动调整,生成多边形数更少的网格,或使用网格抽取工具来简化网格。
  2. 实现网格的自动剔除特性 LOD。
  3. 导入时对资源进行“修剪”,比如剔除多余的顶点属性。



3.GPU实例化

其他地方已经讲了。略。



4.LOD

LOD(Level Of Detail,LOD)指的是根据对象与相机的距离和/或对象在相机视图中占用的空间,动态地替换对象。这里不单单指网格,Shader贴图材质等等都可以。

剔除组:

剔除组(Culling Groups)是Unity API的一部分,允许创建自定义的LOD系统,作为动态替换某些游戏或渲染行为的方法。希望应用LOD的示例包括用较少骨骼的版本替换动画角色,应用更简单的着色器,在很远的距离上跳过粒子系统生成过程,简化AI行为等。

在其最基本的层次上,剔除组系统仅仅指出,物体对相机是否可见,它们有多大,但它在游戏中也有其他用途,比如确定玩家当前是否可以看到某些敌人的据点,或者玩家是否正在接近某些区域。剔除组系统还有更广泛的用途,因此值得关注。


https://blog.csdn.net/u010019717/article/details/97690363



5.遮挡剔除


遮挡剔除可以剔除不可见的对象,减少过度绘制和Draw Call数,来节省填充率。


该系统的工作原理是将世界分割成一系列的小单元,并在场景中运行一个虚拟摄像机,根据对象的大小和位置,记录哪些单元对其他单元是不可见的(被遮挡)。

请注意,这与视锥剔除技术不同,视锥剔除的是当前相机视图之外的对象。视锥剔除总是主动和自动进行的。因此遮挡剔除将自动忽略视锥剔除的对象。

只有在StaticFlags下拉列表下正确标记为Occluder Static和/或Occludee Static的对象才能生成遮挡剔除数据。Occluder Static是静态物体的一般设置,它们既能遮挡其他物体,也能被其他物体遮挡,例如摩天大楼或山脉,这些物体可以隐藏其他物体,也能隐藏在其他物体后面。Occludee Static是一种特殊的情况,例如透明对象总是需要利用它们后面的其他对象才能呈现出来,但如果有大的对象遮挡了它们,则需要隐藏它们本身。



6.优化粒子系统

降低粒子系统密度和复杂性非常简单:使用更少的粒子系统,生成更少的粒子,使用更少的特殊效果。图集也是另一种降低粒子系统性能成本的常用技术。对于粒子系统,还有一个重要的性能考虑因素,那就是粒子系统的自动剔除过程。

  1. 粒子系统的自动剔除:基本思想是根据不同的设置,所有粒子系统都是可预测的或不可预测的(确定性与非确定性)。当粒子系统是可预测的,且对主视图是不可见的时,可以自动删除整个粒子系统,以提升性能。一旦可预测的粒子系统重新出现在视野中,Unity就能精确地计算出粒子系统在那个时刻的样子,就好像它生成了一直不可见的粒子一样。只要粒子系统以程序化的方式产生粒子,状态就可以立即计算出来。

    但是,如果有一些设置强制粒子系统变得不可预知或者非程序化,就不知道粒子系统的当前状态必须是什么,即使它以前是隐藏的,也可能需要进行全帧渲染,而不管它可见或可不见。破坏粒子系统可预测性的设置包括(但不限于)使粒子系统在世界空间中渲染,应用外力、碰撞和轨迹,或使用复杂的动画曲线。

    注意,当粒子系统的自动剔除功能被中断后,Unity会提供一种很有用的警告,如图所示。

    在这里插入图片描述
  2. 避免粒子系统的递归调用

    ParticleSystem组件中的很多方法都是递归调用。这些方法的调用需要遍历粒子系统的每个子节点,并调用子节点的GetComponent()方法获得组件信息。如果组件存在,则调用组件中对应的方法。以此类推,对该子粒子系统的父节点、子节点将重复此操作。有几个粒子系统API会受到递归调用的影响,例如Start()、Stop()、Pause()、Clear()、 Simulate()和isAlive()。这些方法都有一个默认为true的withChildren参数。给这个参数传递false值(例如:调用Clear(false))可以禁用递归行为和子节点的调用。实在需要遍历,最好缓存粒子系统组件,并手动迭代它们.



7.查看Unity UI源代码

Unity在bitbucket库中提供了UI系统的源代码,具体网址为:https://bitbucket.org/ Unity-Technologies/ ui。

如果UI的性能上有重大问题,就可以查看源代码来确定问题的原因,并希望找到解决问题的方法。

有一个更极端的情况,但也是一个可能的选择,就是实际修改UI代码并编译它,并手动将其添加到项目中。



4.着色器优化技术



1.考虑使用针对移动平台的着色器

项目中最好使用针对该平台的Shader。



2.使用小的数据类型

特别是在移动平台上,更小的数据类型比更大的往往更快。

能使用16甚至12位的,最好不要使用32位浮点float



3.重排时避免修改精度

重排(Swizzling)是一种着色器编程技术,它将组件按照所需的顺序列出并复制到新的结构中,从现有向量中创建一个新的向量(一组数值)。

float4 input=float4(1.0, 2.0, 3.0, 4.0); // initial test value (x, y, z,w)
 
// 重排2个组件
float2 val1=input.yz; // val1=(2.0, 3.0)
 
// 用不同的顺序重排3个组件
float3 val2=input.zyx; // val2=(3.0, 2.0, 1.0)
 
// 多次重排相同的组件
float4 val3=input.yyy; // val3=(2.0, 2.0, 2.0)
 
// 多次重排一个标量
float sclr=input.w; // sclr=(4.0)
float3 val4=sclr.xxx; // val4=(4.0, 4.0, 4.0)

可以使用xyzw和rgba表示法依次引用相同的组件。不管是代表颜色还是向量,它们只是为了让着色器代码容易阅读。

在着色器中将一种精度类型转换为另一种精度类型是一项很耗时的操作,在重排时转换精度类型会更加困难。如果有使用重排的数学运算,请确保它们不会转换精度类型。



4.使用GPU优化后的辅助函数

着色器编译器通常能很好地将数学计算简化为GPU的优化版本,但自定义代码的编译不太可能像CG库的内置辅助函数和Unity CG包含文件提供的其他辅助函数那样有效。



5.删除不必要的数据

冗余数据会花费GPU时间,它们必须从内存中获取。



6.只公开所需的变量

着色器中不必要的变量公开会给材质带来较大消耗,因为GPU不能假设这些值是常量,这意味着编译器不能编译这些值。这些数据必须在每个过程中从CPU推送到GPU,因为它们会通过诸如SetColor()和SetFloat()等材质对象的方法在运行时发生修改。

最好在项目后期,关闭这些公开的参数。



7.减少数学计算的复杂度


可以提前计算复杂的数学函数,并将其输出作为浮点数据存储在纹理文件中,作为复杂数学函数的映射图。



8.减少纹理采样

内存带宽开销的核心消耗就是纹理采样。

尤其注意!!!:

不按顺序进行纹理采样会给GPU带来非常昂贵的缓存丢失。

如果必须这样做,纹理最好重新排序,以便按顺序进行采样。

举个栗子:如果通过反转x和y坐标进行采样(例如,用tex2D(y, x)替代tex2D(x, y)),那么纹理查找操作将垂直遍历纹理,然后水平遍历纹理,几乎每次迭代都会造成缓存丢失。而简单地旋转纹理文件数据,并按正确的顺序执行采样(tex2d(x,y)),可以节省大量性能损耗。



9.避免条件语句

时间消耗不稳定,记得似乎是在1/8 – ?。总之最好不要使用条件语句。



10.减少数据依赖

编译器尽力将着色器代码优化为更友好的GPU底层语言,这样,在处理其他任务时,就不需要等待获取数据。

坏例子:

float sum=input.color1.r;
sum=sum + input.color2.g;
sum=sum + input.color3.b;
sum=sum + input.color4.a;
float result=calculateSomething(sum);

这段代码有一个数据依赖关系,每个计算都需要等上一个计算结束才能开始。但是,着色器编译器经常检测到这种情况,并将其优化为使用指令级并行的版本。以下代码是编译前一段代码后生成的和机器代码等效的高级代码:

float sum1, sum2, sum3, sum4;
sum1=input.color1.r;
sum2=input.color2.g;
sum3=input.color3.b;
sum4=input.color4.a;
float sum=sum1 + sum2 + sum3 + sum4;
float result=CalculateSomething(sum);

下面的数据依赖关系会对性能造成很大的影响,因为对每个纹理进行采样需要事先对另一个纹理进行采样。

float4 val1=tex2D(_tex1, input.texcoord.xy);
float4 val2=tex2D(_tex2, val1.yz); // requires data from _tex1
float4 val3=tex2D(_tex3, val2.zw); // requires data from _tex2

在任何时候,都应该避免这样的强数据依赖关系。



11.使用基于着色器的LOD

可以强制Unity使用更简单的着色器来渲染远端对象,这是一种节省填充率的有效方法。LOD关键字可以在着色器中用来设置着色器支持的屏幕尺寸参数。如果当前LOD级别不匹配此参数值,它将转到下一个回退的着色器,以此类推,直到找到支持给定尺寸参数的着色器。

还可以在运行时使用

maximumLOD

属性更改给定着色器对象的LOD值。

该特性类似于前面介绍的基于网格的LOD,并使用相同的LOD值来确定对象的形式参数,因此应该采用这样的配置。



12.使用更少的纹理数据Mip Maps

场景窗口有一个Mipmaps着色模式,它根据当前的纹理比例是否适合当前场景窗口的摄像机位置和方向,来决定是将场景中的纹理突出显示为蓝色还是红色。这将有助于识别哪些纹理适合于进一步优化。



13.实时阴影优化技术

阴影很容易成为Draw Call和填充率的最大消耗者之一。

Edit | Project Settings | Quality | Shadows下有一些重要的阴影设置。对Shadows选项而言,Soft Shadows所需代价最大,Hard Shadows所需代价最小,No Shadows不需要产生代价。

Shadow Resolution、Shadow Projection、Shadow Distance和Shadow Cascades也是影响阴影性能的重要设置。

Shadow Distance(阴影距离)是运行时阴影渲染的全局乘数。

较高的Shadow Resolution(阴影分辨率)和Shadow Cascades(阴影级联)值将增加内存带宽和填充率的消耗。这两种设置都有助于抑制阴影渲染中生成伪影的影响,但代价是Shadowmap纹理尺寸会大得多,并将增加内存带宽和VRAM的消耗。

软/硬阴影的唯一区别是着色器的复杂度,因此相对于硬阴影,软阴影并不会消耗更多的内存或CPU。



14.剔除遮罩

灯光组件Culling Mask属性是基于层的遮罩,可用于限制受给定灯光影响的对象。

//TODO 以后详细讲



15.烘焙的光照纹理

光照纹理器为场景中所有标记为Lightmap Static的对象生成纹理,对象越多,则生成的纹理数据越多。这可以利用加法或减法进行场景加载,以最小化每帧需要处理的对象数。当然,在加载多个场景时,这将引入更多的Lightmap数据,所以每次出现这种情况时,内存消耗都会大幅增加,只有在卸载旧场景后才会释放内存。



版权声明:本文为Bilibili_Cheers原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。