最近学习了Unity的图形渲染和UI的优化部分,感觉还是有挺多东西的。在此做一个简单的总结和记录。
如果把计算机绘制想象成画画,想要加快画画速度,我们可以从几个方面来进行优化:
1、先画背景,再画物体;先画物体,再画背景。(Overdraw)
2、一次知道要画什么东西没,减少画笔换颜料的次数。(Batch / Draw Call)
3、用一个颜料就尽量一次把要画的都画完,免得之后还得再换回来。(提交顺序)
4、减少修改。(Rebuild)
Batch可以看作是为了绘制物体从而生成对应的渲染命令发送给GPU的一个过程。
Draw Call可以看作是调用渲染命令的一个过程。
Batch在引擎中一般认为是经过打包后的Draw Call,也可以认为就是Draw Call的另一种称呼。
Batch里装的是一堆顶点数据,这一堆顶点数据实际上就是一个物体的Mesh。这一堆数据提交给GPU的过程就是一个批次,即一个Mesh一个批次。(可以看作每个批次只提交一个vertex数组和index数组)
所以我们需要尽可能地合并批次。策略如下:
对于使用同一Material的静态/不动/背景物体,可以勾上Static。打包时Unity会自动提取这些静态物体的顶点数据,构建成一个大的顶点数据缓存(形成一个新的大Vertex Buffer/数组和Index Buffer/数组),从而可以看作形成了一个大的Mesh。最终会在一个批次提交这些顶点数据。
之后Unity会根据自己的场景管理系统判断各个子物体的可见性,并调用多次DC分别绘制每个需要绘制的子物体。由于这些物体不再变化,所以绘制命令也不会变化,于是这些绘制命令也会被缓存起来。
也就是说,静态合批可以有效减少Batch次数,但不会减少DC。但是由于这些子物体共享Material,所以渲染状态/绘制命令并没有切换,调用DC时会缓存绘制命令到Command Buffer,还是起到了优化的目的。
由于静态合批的Buffer既增加运行时内存又增加包体,所以还可以采取运行时进行静态合批的策略,用第一次加载的内存和时间换包体大小。
对于动态物体,Unity动态合批策略是针对同一材质的物体。通过一次DC来绘制多个物体,从而实现合批。
动态合批是在每帧中进行的,所以是会增加CPU开销的。并且Unity限制动态合批的模型最多只能有900个顶点属性。(顶点属性非顶点)
和静态合批相比,由于不需要预先复制顶点数据,所以内存消耗会小一些。虽然减少了DC,但也增加了CPU开销。
对于UGUI来说,Unity动态合批的底层源码是这样的:
1、对Canvas下所有UI进行深度优先排序。
2、遍历这些深度优先排序的UI:如果该UI不渲染,则depth = -1;如果该UI下面没有其他UI与其重叠,则depth = 0;如果该UI下面只有一个UI与其重叠,并且该UI与这个下面的UI可以合批,则depth = 下面的UI的depth;如果该UI下面只有一个UI与其重叠,并且该UI与这个下面的UI不能合批,depth = 下面的UI的depth + 1;如果该UI下面有多个UI与其重叠,则depth = max(与其重叠的UI的depth) + 1。
3、计算完UI的depth之后,剔除depth == -1的UI,根据depth -> Material ID -> Texture ID -> UI渲染层级高低(这是条件优先级,相同的情况下比较下一个条件)的条件进行排序。depth越小越先渲。
4、根据这个顺序进行渲染,相邻且相同Material的UI可以合批。
(简单概括来说,就是减少重叠,实在是有重叠的话,需要合批的物体重叠高度也尽量一样。高度不一样的时候可以通过垫一个UI来完成合批。)
本质上来说,就是计算出来同depth就可以合批。
实际上,静态合批和动态合批在某些方面还是有很大弊端的。大场景中的大量静态物体合批后,内存增加会非常大;动态合批的要求苛刻,且有些情况下消耗可能过大,得不偿失。
于是Unity提供了一种强大的功能,GPU Instancing。
即首先静态物体的信息缓存(位置、缩放、光照、UV偏移…),然后将静态物体从场景中剔除。当需要渲染这些静态物体的时候,通过Instance来进行渲染。跟静态合批相比,减少了巨大物体静态合批产生的内存。
节省的原理是:如果使用GPU Instance,就会缓存实例的偏移量,在每次绘制的时候,不再需要传顶点数据给GPU,GPU会根据实例偏移量自行计算并绘制。简而言之,就是规避了合并Mesh导致的内存与性能问题。
优先级:静态合批 > GPU Instancing > 动态合批。只要前一个成功了,后一个就不会再进行了。
Unity 2018的可编程渲染管线中包含的批处理器可以大幅提升渲染时的速度。
1、Mask导致无法合批。
2、不同材质无法合批。
3、有lightmap的物体含有额外/隐藏的Material属性,除非lightmap一样,否则无法合批。
4、提交顺序很关键,提交顺序不合理会导致合批被打断。
一个像素被多次重复Rasterization、Shading,导致性能浪费。
对于在UI上优化Overdraw,首先就是注意少用点特殊效果、尽可能合并UI。
UI字体也是按照矩形的方式来渲染的,单纯的文本还好,但加了Outline、Shadow等特效的时候,Overdraw就会大幅增加了。
除此之外,一些透明响应区域对Overdraw影响巨大。把Image转化为Empty4Raycast,使其不参与渲染即可。
对于Sprite网格,可以采用多边形网格,增加顶点数量来减少Overdraw。
还有一种高级策略,即自定义渲染管线,使得UI可以按需以不透明物体的模式绘制,即使其存入Z-Buffer。
全屏界面——可关闭后面的场景相机。
半屏界面——可降低场景的分辨率、降低场景相机的更新频率、改成Blur静态背景。这些做法通常需要把场景绘制到RT上。
透明点击区域——可改用empty4raycast、CullTransparentMesh。
Mask组件(有两层Overdraw)——可改用RectMask2D(这个不知道,需要再做研究)。
UI——Sprite使用Tight模式(Quad Mesh 变成了多边形Mesh),Image使用Use Sprite Mesh,配合来完成UI Overdraw减少。
九宫UI——边框图片的九宫拉伸可以去掉 FillCenter。
Unity等成熟的商业引擎对于不透明物体几乎都是从前往后提交的。这样的话,被遮挡掉的像素,无需渲染。我们看Frame Debugger的时候,可以发现引擎就是这样做的。
Z-Buffer(在着色阶段做遮挡剔除)
HSR(Hidden Surface Removal,在几何阶段做遮挡剔除,节省了光栅化的开销)
透明像素的渲染是需要进行颜色叠加算法计算的。也就是说,透明像素需要后面层次的颜色信息。
所以透明物体都是从后往前绘制的。
1、不同相机,按照相机Depth来定,其中Depth数值越小,越先渲染。
2、相同相机,场景透明物体比UI先渲染。
3、相同相机,相同场景当中的物体,Sorting Order越小,越先渲染(看起来距离自己也是越远)。
4、相同相机,相同场景的物体,相同Sorting Order,则Shader或者材质上面设置的Render Queue越小越先渲染。
5、以上条件均相同,则按照物体中心点距离相机的距离来渲染,越远越先渲染。其中这个距离有两种计算方式,如果按照透视方式计算,则按照中心点的深度来计算;如果按照正交方式计算,则按照中心点与相机近裁剪平面垂直距离计算。另外自己也可以自定义计算方式,可以自己在Project Settings - Graphics Settings - Camera Settings - Transparency Sort Mode选择。
UI其实是个很特殊的组件,你可以理解为,一个Canvas就是一个铺满相机的四方片(Screen Space - Overlay / Camera)或者场景当中的一个透明的面片(World Space)。所以其渲染有点类似于透明物体渲染。
UI提交顺序通常按照它在Hierarchy当中Canvas下的节点顺序来定。一般来说,放在上面的先提交,下面的后提交(具体提交顺序见上文动态合批部分)。这和放到场景当中的透明物体有所差异。所以为了实现UI合批,提交顺序/UI层级很关键。
当然也可以添加Canvas组件来人为地打乱这种顺序。
部分物体,可以通过手动修改Render Queue来调整Unity渲染顺序。
一个2*2的像素Quad被多次重复Rasterization、Shading,导致性能浪费。
GPU为了节省效率,一般会使用2*2的像素Quad来进行渲染,而非一个一个像素渲染。当出现一个Quad中不止一个物体时,才需要进行额外处理。
这个额外处理就是Quad Overdraw出现的原因。
假如一个Quad中出现了物体A和物体B,那么在渲染Quad的时候先按照只有A的时候渲染,之后抛弃那些不属于A的像素;然后按照只有B的时候渲染,之后抛弃那些不属于B的像素。这就导致了一个Quad渲染了两次。
因此即使这些物体没有进行遮挡/重合,只要这些物体产生的三角形足够小、足够多,仍会产生Quad Overdraw的问题。
复杂物体使用LOD来规范,避免因为大量三角形占据面积小于一个像素带来的Quad Overdraw。
Rebuild指的是Canvas上所有Graphic组件的网络重新被计算。
Canvas负责将它包含的几何体组合成Batch,生成合适的渲染命令发送给Unity图形系统。这个过程在底层的C++代码中完成,这个过程被称为一次rebatch或者一次batch build。当一个Canvas被标记为dirty时,这个Canvas被认为是需要进行一次Rebuild的。
一个子Canvas仅仅是一个嵌套在父Canvas中的组件,子Canvas将它的子物体和它的父Canvas隔离,一个子Canvas下dirty的子物体不会触发父Canvas的Rebuild,反之亦然。
1、Canvas动静分离,但需要注意的是不同Canvas上的UI不能合批。
2、需要改颜色时,通过修改Material颜色代替修改UI组件颜色。
归根结底就是防止Canvas变脏。
Unity的射线检测这一操作是非常费的,不仅仅是遍历所有接收射线的物体,而且还会进行射线穿透/传递的判断等等一系列操作。
UI上的射线检测会遍历所有开启Raycast Target选项的UI,所以不需要射线检测的UI就最好给它关了。
子Canvas中的OverrideSorting属性将会造成Graphic Raycast测试停止遍历Transform层级。如果启动它不会带来排序或者射线检测的问题,那么就应该使用它来降低射线进行层级遍历的性能成本。(这个没测试,不确定)
UGUI的Text也是一个性能坑。
注意Text在Canvas层级的位置,很多时候Text是导致合批被打断的罪魁祸首。
使用静态字体虽然包大,但是不会重建图集;使用动态字体虽然包小,但是可能会重建图集。
尽量不要使用Best Fit,因为Unity会把每个要显示的独立的字形的每个单独的尺寸渲染进字体图集,所以使用Best Fit的话字体图集将会被迅速的被不同的尺寸的字形所填满。频繁的字体图集重建会迅速降低运行时性能,并导致内存碎片。
UGUI的各种Layout组件也是非常费的。
因为它们必须在每次被标记为dirty的时候重新计算其子元素的大小和位置。
如果可以的话,可以使用RectTransform的锚点来进行布局,以代替Layout组件。
图形渲染的优化方法有很多很多,想要做好优化,关键就在于得深知底层原理。只有知道它是这么做的,才能够对它进行合适的优化。
学习的道路永无止境。
https://learn.unity.com/tutorial/optimizing-unity-ui#
https://zhuanlan.zhihu.com/p/76562327
https://zhuanlan.zhihu.com/p/76562370
https://zhuanlan.zhihu.com/p/76562384
https://zhuanlan.zhihu.com/p/68530142