本系列为作者学习UnityShader入门精要而作的笔记,内容将包括:
- 书本中句子照抄 + 个人批注
- 项目源码
- 一堆新手会犯的错误
- 潜在的太监断更,有始无终
总之适用于同样开始学习Shader的同学们进行有取舍的参考。
移动平台上的优化
对游戏的优化,一开始就应当视为游戏设计的一部分,特别是当游戏可能在一些低配设备上运行的时候,例如移动设备,在移动设备上的GPU与PC的GPU设计完全不同,它能使用的带宽,功能和其他资源特别有限。这要求我们时刻把优化谨记在心。才可以避免等到项目完成时才发现游戏根本无法在移动设备上运行。
在本章中,我们将会学习到一些关于渲染的优化技术:
影响性能的因素
一个游戏主要使用两种计算资源:CPU和GPU。其中CPU主要负责保证帧率,GPU主要负责分辨率渲染等相关的一些处理
我们可以把造成性能瓶颈的主要原因分成以下几个方面:
(1) CPU
- 过多的DrawCall
- 复杂的脚本或者物理模拟
(2) GPU
- 顶点处理
-
- 过多的顶点
-
- 过多的逐顶点计算
- 片元处理
-
- 过多的片元(可能是由于分辨率造成的,也可能是由于DrawCall造成的)
-
- 过多的逐片元计算
(3) 带宽
- 使用了尺寸很大且未压缩的纹理
- 分辨率过高的帧缓存
对于CPU来说,限制他的主要是每一帧中DrawCall的数目。我们曾介绍过DrawCall的相关概念和原理,简单来说,就是CPU在每次通知GPU进行渲染之前,都需要提前准备好顶点数据(如位置、法线、颜色、坐标纹理等),然后调用一系列API把他们放到GPU可以访问到的指定位置。
最后调用一个DrawCall绘制指令通知GPU将准备好的数据取走并进行计算。
但是过多的DrawCall会导致CPU性能瓶颈,因为每次调用DrawCall时CPU往往都需要改变很多的渲染状态的设置,这些操作都是很耗时的。如果一帧中需要的DrawCall数目过多的话,就会导致CPU大部分时间都花在提交DrawCall上了。
其他的一些因素,例如物理、布料模拟、蒙皮、粒子模拟等,这些都是计算量很大的操作,也会导致CPU效率低下。
而对于GPU来说,它负责整个渲染流水线,从处理CPU传递过来的模型数据开始,进行顶点着色器、片元着色器等一系列的工作,最后输出到屏幕上的每个像素。因此,GPU的性能瓶颈和需要处理的顶点数目、屏幕分辨率、显存等因素有关。而相关的优化策略可以从减少处理的数据规模(包括绘制的顶点数量和片元数量,避免overDraw)、减少运算复杂度等方面入手
后续本章会涉及的优化技术有:
CPU优化:
- 使用批处理技术减少DrawCall数量
GPU优化
-
减少需要处理的顶点数量
-
- 优化几何体
-
- 使用模型的LOD技术
-
- 使用遮挡剔除(Occlusion Culling)技术
-
减少需要处理的片元数量
-
- 控制绘制顺序
-
- 警惕透明物体
-
- 减少实时光照
-
减少计算复杂度
-
- 使用Shader的LOD技术
-
- 代码方面的优化
(3) 节省内存带宽
-
- 减少纹理大小
-
- 利用分辨率缩放
渲染统计窗口
在游戏画面的右上角,我们可以通过states来查看渲染统计窗口,该窗口显示了3个方面的信息:音频、图像和网络(似乎后面的版本不显示网络了)。
渲染统计窗口显示了很多重要的渲染数据,例如FPS、批处理数目、顶点和三角形网格的数目等。
这些较为基础的数据指示了我们该从哪些方面进行优化,但是需要更多分析的话则需要性能分析器:
性能分析器
性能分析器指示了程序运行时的大部分信息。例如在Rendering一栏中,绿线代表批处理数量,蓝线代表了PassCall的数量,还有一些其他的信息,例如顶点和三角形面的信息等。
然而性能分析器给出的DrawCall数量和批处理数量、Pass数量等等不一定准确,往往会大于我们估算的数量。这是由于Unity需要进行很多其他的工作,例如初始化各个缓存,为阴影更新深度纹理和阴影映射纹理等,因此需要花费比预期更多的DrawCall。
帧调试器
使用帧调试器,我们可以清楚的看到每一个DrawCall的工作结果,看到渲染该帧时发生的所有的DrawCall渲染事件以及当前渲染事件使用的Pass,每一步实现了什么样的效果。
在移动平台上进行优化时,由于上述的内置分析器往往是基于PC的分析结果,有时我们还需要使用移动平台专用的性能分析工具来进行分析。
减少DrawCall的数量
为了将一个物体渲染到屏幕上,CPU需要检查哪些光源影响了该物体,绑定Shader并设置它的参数(包括材质,网格,各类贴图等等,这些都由drawcall传递),再把渲染命令发送到GPU,当场景中包含了大量对象时,这些操作就会非常耗时。
例如我们想要渲染一千个三角形,如果按照一千个单独的网格进行渲染,所花费的时间要远远大于渲染一个带有一千个三角形的网格。因为为一千个物体准备DrawCall和为一个物体准备DrawCall,显然前者的耗时更多,而在GPU上二者的计算却基本没有区别。
因此CPU的DrawCall会成为优化瓶颈,一个优化思想就是尽可能的减少DrawCall的数量。
关于渲染相关数据结构的说明
顶点缓冲区对象(VBO):主要用于存储顶点以及顶点附带的各种属性信息,比如顶点位置、法线、颜色、UV等
顶点数组对象(VAO):规定VBO中数据的格式。比如多少空间存储顶点坐标,多少空间存储顶点法线等等
索引缓冲区对象(EBO):负责缓存VBO中顶点的索引,用来解决顶点数据重复使用的问题,避免顶点数据被重复存储。举个例子,绘制一个长方形需要四个顶点、两个三角形,在没有EBO的情况下需要在VBO中存储6个顶点的数据(其中两个是重复的)。存在EBO时,VBO中存储四个顶点的数据,通过EBO中的索引顺序重复调用VBO中相应顶点数据绘制三角形
批处理
什么样的物体可以一起处理呢?答案是使用了同一个材质的物体,对于使用了同一材质的物体,他们的区别仅仅在于使用的顶点数据的差别,我们可以将他们的顶点数据在一次DrawCall中合并,再一起发给GPU,从而完成一次批处理
Unity中支持两种批处理方式,一种是动态批处理,另一种是静态批处理,对于动态批处理来说,优点是一切处理都是Unity自动完成的,不需要我们自己完成任何操作,且物体是可以移动的。但缺点是限制很多,可能一不小心就劈坏了这种机制,导致Unity无法动态批处理使用了相同材质的物体
静态批处理的优点是自由度很高,限制很少;但缺点是可能会占用更多的内存,并且经过静态批处理后的所有物体都不可以再移动了。(即使在脚本中尝试改变物体的位置也是无效的)
动态批处理
如果一些模型共享了同一个材质并满足一些条件,则Unity会自动为其进行动态批处理,将这些网格合并一个DrawCall。
动态批处理的原理是对可批处理的模型网格进行一次合并,在把合并后的模型数据传递给GPU,并用同一个材质进行渲染。且进行了批处理之后的模型仍然可以移动,这是由于处理每帧时Unity都会重新合并一次网格。
虽然动态批处理不需要我们进行任何操作,但是注意只有满足条件的模型和材质才会被动态批处理:
转自Unity性能优化之动态合批
动态合批条件:
- 使用相同的材质球
- 正在屏幕视野中
动态合批的适用范围:
- 未勾选Static的网格模型
- 粒子系统、线条或轨迹渲染器
勾选动态批处理前,三个方块需要4个DrawCall
在Project Setting中勾选动态批处理后同样材质的物体将节省两个DrawCall。如果勾选静态批处理也是节省两个DrawCall,但是静态批处理后物体位置不可移动。
动态合批的缺点:
- 由于模型顶点变换的操作,计算的模型顶点数量不宜太多,否则CPU串行计算耗费的时间太长会造成场景渲染卡顿,所以动态合批只能处理一些小的模型
动态合批失败的情况:
-
物体Mesh大于等于900个面
-
改变Renderer.material将会造成一份材质的拷贝,导致不满足"使用相同材质球"的合批条件
-
如果你的着色器使用顶点位置,法线和UV值三种属性,那么你只能批处理300顶点以下的物体;如果你的着色器需要使用顶点位置,法线,UV0,UV1和切向量,那你只能批处理180顶点以下的物体,否则都无法参与合批
随后我们试试不同模型的动态合批:
可以看到即使是不同的模型,也成功进行了动态合批,这是由于这些模型的顶点数小于300
在增加了一个顶点数大于300的球体后,drawcall也随之增加了,说明该网格并没有被动态合批。
动态合批中断的情况:
- 位置不相邻且中间夹杂着不同材质的其他物体
在中间穿插了一个不同材质的物体,此时是有合批的
将左边物体移出合批范围,尽管左边网格仍然在屏幕内,但此时两个相同材质的网格不合批了
在隐藏中间的物体之后,两个相同材质的网格又合批了
-
物体如果都符合条件会优先参与静态合批,然后是GPU Instancing,最后才是动态合批
-
物体之间如果有镜像变换则不能进行合批
将其中一个物体的一个轴进行翻转,则不合批
若两个轴进行翻转,则还是合批
若三个轴进行翻转,则不合批
-
拥有lightmap的物体和没有lightmap的物体
-
使用Multi-pass Shader的物体会禁用动态合批
-
Unity的Forward Rendering Path(前向渲染路径)中如果一个物体接受多个光照会为每一个per_pixel light产生多余的模型提交和绘制,从而附加了多个Pass导致无法合批
在场景中新增了一个点光源后,动态合批失效了,这是由于渲染了多个Pass的Shader在应用多个光照下破坏了动态合批的机制。需要处理的pass由2个变为了2*2=4个。当然不在点光源内的物体们依旧会被合批。
静态合批
静态批处理是另一种合批方式,它可以适用于任何大小的几何模型。其原理是:在程序开始运行的阶段,把需要进行静态批处理的模型合并到一个新的网格结构中,这意味着这些模型不可以在运行时刻被移动。但由于它只需要进行一次合并操作,因此比动态批处理更高效。
静态批处理算是一个典型的用内存换时间的策略。要做优化无非就是从两个资源下手:内存优化和计算时间优化。而除了减少内存消耗和计算时间消耗外,我们也可以使用内存换时间,或是用时间换内存。
静态批处理的缺点在于:往往需要占用更多的内存来存储合并后的几何结构。如果合并前的物体共享了相同的网格,那么合批时内存中的每一个该物体都会复制一个该共享网格。例如有1000棵树模型共享了一个树的网格模型,当我们对这1000棵树进行静态批处理,那么每棵树都会在内存中复制一个树的网格模型,那么所消耗的内存就是原来的1000倍!
在上述情况下虽然合批后性能是提高了,但是内存消耗太大了,反而得不偿失。那么如果这些树的网格恰好超过了动态合批限制的顶点数量,那么动态合批也用不了了。这种情况下,要么自行编写合批代码,要么我们可以使用GPU实例化来解决。
在静态合批下,可以看到节省了2个DrawCall
在面板中查看此时的模型网格,会发现所有静态合批的模型都合并为了同一种VBO网格:
可以看到包含了4个submeshes,对于合并后的网格,Unity会判断其中使用同一个材质的子网格,然后对它们进行批处理。
在内部实现上,unity会将这些静态物体变换到世界空间下,然后为它们构建一个更大的顶点和索引缓存。而对于使用了同一材质的物体,Unity只需要调用一个DrawCall就可以绘制全部的物体。而对于使用不同材质的物体,静态批处理则同样可以提升渲染性能——尽管使用材质不同,但是静态批处理减少了这些DrawCall之间的状态切换(上下文切换)。
同时我们发现,尽管有三个茶壶,但是子物体依旧是四个,因此尽管每个茶壶使用的网格是相同的,但是在内存中会缓存一个该网格的复制,因此是三个网格,而非三个茶壶直接共用一个网格。
现在我们在场景中增加一个点光源,会发现DrawCall数量显然增加了。但是由于处理平行光的BasePass部分仍然会被静态批处理,因此依然为我们节省了两个DrawCall。
共享材质
无论是动态批处理还是静态批处理,都要求模型使用同一种材质(同一材质,而非同一Shader)。但有时我们希望使用同一种材质,但材质中的部分数据有变化,例如颜色,某些属性等,例如我们想要给茶壶换换色,使得场景中同时存在两种颜色的茶壶,那么在编辑器中就需要创建两种材质,即使它们是同一个shader。
为了使用一个材质实现不同模型的微调效果,一种常用的方法就是使用网格的顶点数据(最常见的就是顶点颜色数据)来存储这些参数。
经过合批的物体会合成一个更大的VBO发送给GPU,VBO中的数据作为输入传递给顶点着色器,因此,我们可以巧妙地对VBO中的数据进行控制,例如森林场景中所有的树使用了同一种材质,我们希望它们可以通过批处理减少DrawCall,又希望不同树使用不同颜色,此时我们可以使用网格的顶点颜色来调整。
如果我们需要访问合批后的共享材质,应当使用Renderer.shadedMaterial
来保证修改的是和其他物体共享的材质,但这意味着材质修改会应用到所有使用该材质的物体上。另一个类似的API是Renderer.material
,如果使用Renderer.material
来修改材质,Unity会创建一个该材质的复制品,从而破坏批处理在该物体上的应用。
批处理的注意事项
在选择使用动态批处理还是静态批处理时,有一些小小的建议:
- 尽可能使用静态批处理,但得时刻小心对内存的消耗,并且记住被静态批处理的物体不可以再移动
- 如果无法使用静态批处理,那么使用动态批处理时要小心上述的条件,尽量为顶点小于300的小物体使用动态批处理
- 对于一些重复的小道具,尽可能使用动态批处理
- 对于包含动画的部分,我们无法对其进行静态批处理,但是可以将不动的部分设置为静态
在使用批处理时还需要注意,由于批处理需要把模型变换到世界空间下再合并,因此,如果Shader中存在一些基于模型空间下的坐标的运算,那么往往会得到错误的结果。要么把坐标运算变换到世界空间下进行,要么再Shader中使用DisableBatching标签来强制使用该shader的材质不会被批处理。
另一个注意事项是,使用半透明材质的物体通常需要使用严格的从后往前的绘制顺序来保证透明混合的正确性。对于这些物体,unity会先保证绘制顺序,再应用批处理,若合批的绘制顺序不能满足则无法应用批处理。
GPU实例化
这里要拓展一些优化DrawCall的小技巧,一个是GPU实例化。假设我们要生成很多士兵,这些士兵使用相同的模型网格和相同的材质,使用相同的动画和骨骼。那么我们能不能用一个DrawCall来生成所有的士兵?如果使用之前讲的静态合批的话,未免也太耗内存了,有一个更好的方法——GPU Instancing
通过在材质面板上勾选该选项开启GPU实例化,Unity的表面着色器自带GPU实例化选项,而顶点片元着色器则需要在代码中使用开启GPU实例化的宏。
GPU Instancing(GPU实例化)有许多优点,首先,我们可以通过GPU示例化用一个DrawCall就能完成一批相同材质相同模型物体的渲染——因为一次DrawCall能把材质和模型缓存保存在GPU中,接着GPU直接调用缓存即可。
除此之外,我们可以用GPU Instancing对每个重复生成的物体单独进行材质修改,只需要在shader中设置属性块变量,并在C#中使用MaterialPropertyBlock
类赋值该变量即可,例如下面代码:
Shader:
UNITY_INSTANCING_BUFFER_START(prop)
UNITY_DEFINE_INSTANCED_PROP(fixed4, _Color)
UNITY_INSTANCING_BUFFER_END(prop)
C#:
GameObject chair = Instantiate(Prefab,new Vector3(pos.x,0,pos.y),Quaternion.identity);
MaterialPropertyBlock prop = new MaterialPropertyBlock();
prop.SetColor("_Color",color);
chair.GetComponentInChildren<MeshRenderer>().SetPropertyBlock(prop);
开启GPU实例化后,所有相同模型网格和材质的实例渲染时只调用一个DrawCall,我们可以Instantiate直接生成实例物体(当然生成实例时需要消耗性能),生成实例方便我们直接去访问它们并对其进行各类操作。
或者有时我们并不需要操作或者访问这些实例,此时我们可以直接将其渲染到屏幕上,如下述代码所示:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
public class GrassInstance : MonoBehaviour
{
public GameObject prefab;
public int InstanceCount = 10;
public float Scale = 4.0f;
private Mesh mesh;
private Material material;
private List<Matrix4x4[]> matrixList;
private Matrix4x4[] matrix;
private MeshFilter[] meshFilters;
private int matrixCount;
private Renderer[] renders;
private MaterialPropertyBlock materialPropertyBlock;
void Awake() {
if(prefab == null)
return;
matrixList = new List<Matrix4x4[]>();
var meshFilter = prefab.GetComponent<MeshFilter>();
if(meshFilter) {
mesh = prefab.GetComponent<MeshFilter>().sharedMesh;
material = prefab.GetComponent<Renderer>().sharedMaterial;
}
materialPropertyBlock = new MaterialPropertyBlock();
// 每组GPUInstance指令最多生成1023个物体
matrixCount = InstanceCount / 1024 + 1;
int RemainCount = InstanceCount;
for (int i = 0; i < matrixCount; i++)
{
int LoopTime = RemainCount - (matrixCount - i - 1) * 1023;
matrix = new Matrix4x4[LoopTime];
for (int j = 0; j < LoopTime; j++)
{
float x = Random.Range(2.0f, 6.0f);
float y = 0;
float z = Random.Range(-10.0f, 10.0f);
matrix[j] = Matrix4x4.identity;
//设置位置
matrix[j].SetColumn(3, new Vector4(x, y, z, 1));
//设置缩放,矩阵缩放
matrix[j].m00 = Scale;
matrix[j].m11 = Scale;
matrix[j].m22 = Scale;
}
matrixList.Add((matrix));
RemainCount -= LoopTime;
}
}
void Update() {
for (int i = 0; i < matrixList.Count; i++)
{
Graphics.DrawMeshInstanced(mesh, 0, material, matrixList[i], matrixList[i].Length,(MaterialPropertyBlock) null, ShadowCastingMode.On, true, 5);
}
}
}
我们可以使用DrawMeshInstanced
直接将物体渲染到屏幕上,这样就不用生成实例了,更节省性能。当然这些直接绘制的物体由于没有实例,就无法访问预制体上的一些组件了,不过一些shader中的交互还是可以实现的。
(使用GPU实例化生成的4096棵草)
使用GPU实例化,我们就可以生成许多相同模型相同材质的士兵,它们使用相同的骨骼动画,并且我们可以分别设置它们的属性块。而且竟然只使用一个DrawCall,对于性能是巨大的提升。
享元模式
享元模式严格来说并不属于渲染领域的内容,而是一种设计模式。还是以上述的士兵为例,每个士兵单位的基础属性应该都是相同的,而通常士兵的血量、身高这些属性是独立的。假如每个士兵预制体类用一个脚本SoliderManager
来管理士兵的属性的话。那么每个Manager上都带有重复的基础属性,1000个士兵的属性就要在内存中重复保存1000次,且不同预制体上的相同属性的地址各不相同,显然浪费了内存和CPU资源。
因此如果对于同个士兵,我们能够让所有士兵都引用同一个属性的话,就不需要再创建一个属性了。即使有一千个,一万个士兵,它们引用的基础属性也始终只有一个。
public class FlyweightAttr
{
public int maxHp { get; set; }
public float moveSpeed { get; set; }
public string name { get; set; }
public FlyweightAttr(string name, int maxHp, float moveSpeed)
{
this.name = name;
this.maxHp = maxHp;
this.moveSpeed = moveSpeed;
}
}
public class SoldierAttr
{
public int hp { get; set; }
public float height { get; set; }
public FlyweightAttr flyweightAttr { get; }
// 构造函数
public SoldierAttr(FlyweightAttr flyweightAttr, int hp, float height)
{
this.flyweightAttr = flyweightAttr;
this.hp = hp;
this.height = height;
}
}
可以看到士兵属性类的构造函数中定义了一个共享属性类,只需在实例化士兵属性类的时候为构造函数传入共享属性类的引用就可以使所有的士兵都引用同一个共享属性类。
之所以提到享元模式,是因为在模型情况下,例如我们要使用相同材质网格模型时,材质,网格实际上也可以看作共享属性。利用享元模式,我们可以将共享的属性只发给GPU一次,不就是相当于将多个DrawCall节省为了一个DrawCall?
使用享元模式使用相同材质:
using UnityEngine;
using System.Collections;
using System;
public class flyweightTerrain : MonoBehaviour {
public Material redMat;
public Material greenMat;
flyweightTile redTile;
flyweightTile greenTile;
flyweightTile[,] tiles;
int width = 5;
int height = 5;
int[,] terrain = {
{ 0,1,0,0,0},
{ 0,0,0,1,0},
{ 1,0,0,1,0},
{ 1,0,0,0,0},
{ 0,0,1,0,0}
};
void Start () {
redTile = new flyweightTile(redMat, true);
greenTile = new flyweightTile(greenMat, false);
drawTerrain();
}
void drawTerrain() {
tiles = new flyweightTile[width, height];
for (int i = 0; i < width; i++)
for (int j = 0; j < height; j++)
{
if (terrain[i, j] == 0)
tiles[i, j] = greenTile;
else
tiles[i, j] = redTile;
}
for (int i = 0; i < width; i++)
for (int j = 0; j < height; j++)
{
GameObject obj = GameObject.CreatePrimitive(PrimitiveType.Cube);
obj.transform.position = new Vector3(i - 2, 0, j);
obj.GetComponent<MeshRenderer>().material = tiles[i, j].mat;
}
}
}
class flyweightTile {
public flyweightTile(Material mat, bool isHard=false){
this.mat = mat;
_ishard = ishard;
}
public Material mat;
bool _ishard = false;
public bool ishard {
get { return _ishard; }
}
}
使用享元模式,我们生成了两种不同的立方体。其中相同材质的立方体共享了性能消耗。
假设这是个生成地形的代码,两种材质代表了两种地形,我们就可以用享元模式共享性能消耗,生成两种不同地形。