重建 是UGUI优化的关键 -- Unite2017嘉宾杨怀忠分享《UGUI深度优化》
关于Unite
Unite大会是由Unity举办的全球开发者大会,至今已有10年的历史。Unite现已成为游戏行业,VR/AR行业中最具有权威性和影响力的活动。
杨怀忠:前面几位同事有点拖堂了,所以我可能今天讲的速度会快一点。我今天跟大家分享的是UI方面的经验。我先介绍一下UI的基础知识,会稍微提一下UI常用的几款优化工具,这个优化工具使用起来比较简单,我会简单说一下。重点介绍一下UI-Canvas使用原理的问题。中间会穿插一些UI的控件,其实用Unity的开发者,都比较了解了,比较多。因为时间的关系我这边会筛选两个来说。最后会介绍一下其他的经验。
UI的基础我这里分了这样几个环节,首先我先介绍这个术语,这个术语在开发功能是不太常见的。然后会说到一些渲染的细节,平时在用UI渲染的时候经常会出现的一些误区。然后比较重要的一点概念就是重新合批。接下来在合批的过程当中,我们有一步很重要的也是对性能消耗非常大的一步就是重建。具体的内容后面会详细介绍一下。然后就是Layout,就是重建,它对性能的消耗都是非常非常大的。这些都是我今天介绍的重点。然后术语我们做Unity UI,最多接触的就是UI-Canvas。我们平时的工作过程当中,会接触到很多企业,包括很大型的企业他们在做UI的时候,可能一个游戏下来里面只有一个canvas。我们使用一个canvas还是使用多个canvas,这个是很有讲究的。然后dirty,这是什么意思呢?什么样的UI我们认为是dirty,如果是一个dirty的UI,我们会做什么呢?接下来是合批,然后Sub-canvas,如果我们在一个游戏里面不使用一个canvas,使用多个canvas。我们会有怎么样的影响。接下来的Graphic跟图形学那个单词长的是一样的,但是意思会有一点区别。我这里的区别是Unity UI的基类,通过这个基类,我们在一款游戏当中我们希望创建自己的UI,你必须要从这个Graphic里面继承出来。然后我们美术在设计的时候,我们本身都会去设置它的一个布局,但是在我们引擎这一步也有组建。这个Layout的使用以及需要注意的事项后面会有详细的介绍。最后介绍一下什么叫rebuild,一定要区别开来,它与合批是两个意思。它主要是针对UI mesh重新编译的过程,所以它跟合批是有很大的区别。
这边看一下渲染的细节,它是很关键的,也是我们经常忽略的。首先在Unity UI基础里面,所有渲染细节都是透明队列的。我们可能碰到很多都是不透明的场景,但透明的性能和不透明的区别非常地大。底下都是针对透明队列做了一个解释,比如说我们在透明队列里面,我们是从后往前画的。后面还有一个混合的问题,它的性能的影响是非常大的。我们的建议是什么样呢?我们在做UI的时候尽量保证每一个像素不重合,当然这是很难做到的。就是关于Overdraw的话题,我们会发现UI的问题可能会有80%以上的情况都是Overdraw太高。我们希望每个像素只画一次,但是因为Overdraw的存在,我们可能画了十次甚至更多。这对它的填充率就要求很高了。
再介绍一下合批,我这里加了一个重合批,就是对于我们canvas来说,我们每个canvas在画之前都要进行一个合批的过程。如果我们这个canvas底下所有的UI元素每一帧都始终保持不变,我们只需要合批一次。它的意思就是说第一次合批之后把这个结果保存起来,如果下面再画第二帧的时候,如果没有变化,就继续采用第一帧的。如果发生变化了,就重用Batching。如果有任何一个UI组件发生变化了,它发生dirty之后就会触发重新合批。重新合批的过程是非常复杂的过程,我这边简单地列了一下,在重新合批的过程当中会做哪些事情。首先第一步要根据深度关系进行排序,如果一个canvas底下的层级关系非常复杂,它的排序的性能消耗呈非线性的增长。另外一个就是覆盖关系,在UI里面,其实像我刚才提到的,我们需要尽量避免UI的重叠。举个简单的例子,如果我们有两个Button,它们重叠的话,它是多少的消耗呢?这就是一个覆盖关系。然后就是一个材质,材质在一开始接触Unity引擎的时候都会听说过静态合批和动态合批。其实原理是一样的,我们能够合到一个批次里面,他们的材质必须是一样的,就是UI组件。这就是rebatching大概的过程。我们是根据节点从上往下的顺序。它要有一个排序的过程,如果UI组件布局不合理,它的这个算法的复杂度也会成倍地增长。什么样的排序是合理的呢?
关于rebatching的过程当中还有一点需要注意,它是多线程的。我们会发现,同样的一个游戏在不同的手机上测试出来的性能差距会很大。这个跟手机性能,还有CPU多核有关系。如果你单核、四核跑起来的性能在游戏里面用到了引擎的多线程的处理算法,区别会非常明显。所以重新合批就用到了多线程。所以在不同核的手机说,性能的差距会比较明显。刚才介绍了一个合批,这里介绍一个re-build,它有一定的联系,但是有一定的区别。在合批这样的管线或者流程完成的时候,在这个过程当中要完成这样一个re-build的过程。比如说它的位置发生了变化,我们都知道要触发同一合批。那个re-build我这边重新分了一下,主要是布局的重新build还有图形学的Graphic 的rebuild。底下的函数再重新提一下,我们在进行性能优化的时候会经常看到这个函数,这个函数性能消耗一般在UI里面比重比较大。它干的事情就是完成这样rebuild的过程,如果布局是dirty,它也会触发。什么情况下会产生Graphic rebuilds呢?这里有一个demo,里面会列出几个属性,比如说我们常用的属性,有位置,颜色,都会影响到Graphic的重建。这边就是对布局rebuild进行一个重新说明,它就是一个位置、大小发生了改变。当它如果发生变化,我们的布局在这个底层实现的时候,相当于一个函数模块。这个模块的函数都要重新计算一遍,显示该UI的区域。我们在计算的过程当中也是先计算根节点,按照越靠上的结点会优先结算。这样做的过程我们是布局,如果根接点先发生变化,也会影响指节点。在这个算法中,在Layout的rebuild当中,你执行这样的一个过程,性能的消耗非常大。消耗在什么地方,就是我列出来的这个位置。当你这个列表发生变化的时候,这个列表里面的所有元素都要重新排序和显示新的区域。
这里就是提到Graphic rebuild,因为在UI里面,顶点数据就是存储一些位置、颜色。等Graphic rebuild的时候,我们要重建mesh。如果这个顶点数很多,它的性能消耗是非常非常严重的。还有一个非常容易被大家忽略的,当我们材质发生变化的时候,比如说在UI里面,它可能想改变这个UI的材质,这材质的改变也会导致canvas Render的改变。上面介绍了一些UI优化当中经常用到的一些基础概念和常用的一些术语,对我们理解UI底层优化工具会有帮助。我这里推荐几款UI优化领域的工具,比如说最常用的Profiler,只要做过优化的人都对这一块非常熟悉。还有 Frame Debugger,这两款都是在Unity上面的。底下两款是Xcode里面的。相对于前面两款工具来说,它们的级别更高一点,相当于重量级的选手。一般在项目的某一些阶段的时候,还是建议大家用下面的两个看一下它的性能。前面两个是比较方便,是在Unity的编辑器里面,随时可以用这两款工具。然后在里面会经常出现关于性能的函数,这些函数我们可能有的时候在手册里面是找不到的。所以我这边简单地介绍一下这些函数是做什么用的。首先第一个,它主要是计算batch的过程,在这里都要完成。所以我们会经常发现纳占用的性能消耗的比重会非常高。底下这一块,canvas的Render是每一帧里面都出现。刚才提到的是C++底层出现的,虽然消耗比较大,但是计算速度是有保障的。底下这个函数稍微有一点不太有把握的是它包含了一些脚本调用的脚本。
前面提到的dirty这个关键词在这里也提到了,这两个函数的消耗也非常高。关于Graphic的rebuild,这个是我们Unity内建的一个小工具。一个用汉语来表示,一个叫描边,一个叫字体阴影。很多游戏厂商都用了这两个其中的功能,字体描边和阴影虽然经过开发团队进行优化过,但是性能消耗还是非常高。在我们项目当中是否能够接受这个损耗是要考虑的。当我们看到这两个标题的时候,我们应该知道它在做什么事情了。如果想把它去掉,应该要知道它应该怎么来做。rebuild的过程是去修改mesh的过程。然后关于Frame Debugger,我也简单提一下。它有三个模式,一个是screenspace overlay一个是screen space camera,一个是world space。会发现canvas的下面的性能消耗中找不到它们的位置了。用Frame Debugger的时候,怎么去找到canvas的消耗。
下面介绍一下今天的着重点,就是canvas。在这个UI canvas里面,性能消耗比较大的一块也是canvas的重建。在我前面那个同事在介绍性能优化的时候,多少提到了一些。然后在UI重建canvas的过程当中,在canvas下面肯定会包含很多UI的组件。我们怎么去排这个次序,这一点我相信大家都应该有这个经验。这个经验会打断我们已经重建的批次。比如说我们下面有三个组件,第一个和第三个它们的材质是一样的,可能会分到同一个批里面,如果中间有一个材质或者其他的因素影响了这两个合批,它们三个是单独划出来。如果做得好的话,材质是一样的话,可能是同一个批。不要出现打断这个合批的过程,因为如果不打断合批,在最理想的情况下,每次合批的过程是可以重用的。而且一旦打断了这个合批,所有的都不能重用的。这只是我们目前表面上看到的东西。包括VBO数据,包括带宽都会受到这一块的影响。然后就是多级canvas,对应于我们前面提到Graphic的时候,我们到底用一个canvas还是用很多个canvas。如果我们选择用很多个canvas,我们是建平级的canvas还是用多级的canvas。他们是有一些区别的,后面会详细地讲一下。会介绍一下我们使用canvas的时候最基本的一些准则。
canvas重建的过程其实就是生成UI组件的过程。这边插一句,在之前很多开发者不明白我们UI canvas是干什么用的。我这边解释一下,这个canvas其实在UGUI里面重要的作用就是生成UI组件,然后生成command命令,然后传递到GPU,最后由GPU把它们画出来,完成的是这么一个过程。在生成UI组件的过程当中,也包括了布局,就是哪些UI显示在哪个位置,包括它们的大小。这边有一个字体多边形,我这边简单提一下,后面会有一个字体的简单介绍,每一个字体都是一个单独的多边形,这一点很重要。每一个字体都是一个单独的多边形。然后canvas的重建过程当中也会包含我们前面说的一个合批。底下我这边写了一个重建会不会成为我们游戏的瓶颈呢?有可能会。什么情况下会呢?如果一个canvas下面,比如说一个游戏就一个canvas,所有的UI组件都放在这个canvas下面,你游戏足够大,这个canvas就会有可能成为你游戏的瓶颈。有些情况下canvas会分成很多个,但是它们分布非常不均匀,有的canvas下面UI,比如说我一个canvas下面有10个canvas,虽然看起来不是很多,但是我每一帧都要dirty,都要重建,这样就会形成瓶颈。它会影响我们合批的结果,既然影响我们合批的结果,我们怎么避免出现这种情况呢?就是避免出现中间层,中间层的意思就是说我某一个UI组件和它周围的UI组件都不在一个批次里,我是单独的UI组件,这会导致我们之前做的batch会被打断。如何避免这种情况出现呢?我们要重新排序,把这种被称为中间层的UI组件移开,移到哪里去呢?我们可以把它移到最下面的位置,做所有操作的时候都是从根接点往上做的,这样对我们性能的影响会减少。
前面也提到了这一点,我们在canvas底下放UI组件的时候,我们要保证不必要重叠的UI组件千万不能出现重叠区域。前面这句话我说了两遍,每一个字体都是单独的多边形,为什么这句话说了很多呢?因为根据我们的经验,很多游戏会因为字体打断UI的合批。就像我们看一个字体,看起来它的区域很小,但是因为每个字体都是一个多边形,这个区域我们是看不见的。如果覆盖了其他的UI,这个UI就会被打断,每一帧都会被重新合批。
然后提一下多级canvas,我们需要考虑一下是使用多个同级的canvas还是就是sub-canvas。这里需要有一个小的知识点,前面提到了这么多的合批,这么多的重建,都是针对单独的canvas来讲的。内嵌的canvas和前面覆类的canvas是没有关系的在合批过程当中。我们在这边提到的是性能优化,然后性能优化我们要找到的一个平衡点就是最后这一句话,就是最少的重建消耗和最少的drawCall消耗。如果都放在同一个canvas下面,也是不现实的。
然后这边就是我们canvas使用的一般准则,其实这是比较简单的准则,在真正应用当中要比这个复杂很多。一般的准则是至少会存在一个canvas。我这个UI组件我只需要游戏启动的时候合一次批,所有的结果都会保存,直到我退出这个APP。底下这个canvas,我们放动态的UI组件,这个动态UI组件已经提到过很多次了,每一帧都要重新合批,这个性能消耗非常大。还有一点,我们把所有的UI组件都放在同一个canvas下面是不现实的。如果都放在动态canvas下面也是不现实的。我们在动态canvas下面可以继续划分,至于怎么划分,多少个canvas,这个跟我们具体项目有关系,不太好给出一个统一的标准答案。这边提下canvas的一个组件处理输入,我们只需要知道它是怎么样的输入处理的,比如说canvas Raycaster。我们每一个添加UI组件的时候可能都会勾选了Raycster,这样每一帧都要去检测所有勾选的这个UI组件。这个性能消耗是非常非常严重的。还要注意一下,我这边列出了一个版本信息,Raycaster的检测在5.4之前都会去做检测的,不管是手机平台还是其他平台,不管有没有鼠标都会去检测。但是5.4之后优化了这个问题,大家有兴趣的可以回去检测一下。开发者可以定义自己的InputManger类。如何定义呢?方法也比较简单。我们可以从网上下载下来,继承该类,然后添加功能。
这边简单说一个优化,如果所有的UI组件都包含了射线优化的话,性能消耗非常高。下面就说到了UI的性能消耗为什么会这么高。比如说我们因为会有一些组合的UI控件,比如说一个button下面有很多层,如果是挂在了button下面的最后一级,一级一级往下,去检测。对于复杂的控件,如果你需要对它进行检测,尽量把这个Raycast放在根节。因为碰撞检测这一块性能消耗非常高,我们有一个小技巧,overridesorting属性会打断射线,可以降低层级遍历的成本。下面说一下优化UI控件,因为UI控件非常多,这边讲两个。一个是字体,还有一个就是滚动条。字体会分成这几个模块来讲,一个是字体网格重建还有动态字体,字体集,还有关于性能方面的。
一开始提到了每一个字体都是独立的四边形,这一点很重要。你再复杂的一个字体都是一个四边形。我们对一个字体要预留一定的空间,避免它们之间进行覆盖。然后还有字体UI Text重建,如果这个字体挂在其他的UI上面也会导致它的重建。底下这个就是当我们需要隐藏UI的时候,最常用的就是Disabled和re-enabled,不仅仅是针对字体也包括其他的UI组件。你看底下这边又说了一下,会导致我们掉帧。这里仅仅简单的是先把一个组件Disabled掉然后又re-enabled掉。后面再详细地解释一下。然后是动态字体和字体集。动态字体在开发的过程中,我们使用的字体大部分都是动态字体,但现在也有静态字体。静态字体我们会把它提前渲染到贴图里面,然后直接用,这样比较简单,不再提了。主要讲动态字体和字体集。动态字体最常用的就是游戏里面都有聊天环节,我们不知道用户会输入什么,我们会放很多字体。我们每一个字体都会维护自己的一个字体集。字体的大小比如说我一个A字,我有一个大写的,有一个小写的,有一个8号字体,有一个10号字体,在字体集里面都会保留4份A。我申请一个字体集,它的空间是有限的。它里面存的是我当前活动的字体。如果根据这个去写,很快就会把字体集合撑满。后面会讲,这个后果很严重。如果这个时候还需要新加字体,空间就不够了。就是要重建这张贴图,如果重建了这张贴图,会有一个很复杂的过程。对我们性能的影响也会非常大。如果当前这个字体集太小了,比如说256×256的,我已经满了,但是我在当前的字体里面找不到新加的字体,也会导致字体集的重建。
这里说一下字体集如何重建,以及对我们性能的影响。第一步我们会使用当前的一个大小。比如说我当前用的字体集就是512×512的。如果这个界面上用了100个字体,我会把这100个字体全部加入到字体集里面,如果OK就OK,如果不OK就继续。那么就扩充,扩充的过程,首先是512×512会捡最小分辨率的一个进行扩,如果还不够会继续往下扩。这个扩充的过程,你想再回到过去,已经回不去了。底下会有一个函数,这个函数的功能会申请一些字体,把我们需要的字体申请这些字体加入到当前的字体集里面,引出底下这句话,当我们发生重建的时候,即使我们用了Font.RequestCharacter,也没用了。
再说一下备用字体。里面会让大家选一些备用字体。备用字体其实很好,它会让我们避免一些字体找不到。但是它带来的问题是什么呢?为了让我们找到我们需要的字体,它带来的问题就是内存会爆掉。我们给出的应对方案是对字体库进行裁减。我们要明确哪些字是我们需要用到的,如果不用到,可以裁减掉。然后就是敢于Best Fit,它会将当前字体自适应到适合当前文本框最适合的整数字体大小。比如我设置的是14号字体,但是因为文本框比较小,会往下面调整一点。它带来的一个缺点是什么呢?如果一个界面里面所有的字体都勾选了这个选项,同一个字体它的大小会发生变化,可能变成12号,可能变成13号。后续的影响是,比如说字母A在屏幕上出现了五次,但是大小是不一样的。它在我们出现的字体集里面,每一个字号都会出现一次。这个字体集很快就会撑爆了。
接下来介绍一下滚动制图。它也是会出现问题的控件。在滚动制图里面经常会出现问题。第一个是把所有需要显示的东西都显示出来,在滚动的时候就把它实例化。在滚动的过程当中,有些东西是看得到的,有些东西是看不到的。它会导致根节下面的所有东西都进行重建。这只适用于滚动制图比较小的环境下,有可能我们是可以接受的,但是在我们UI组件非常高的情况下,肯定是不可以接受的。第二种方式我们要用内存池,缓存池我们可以把UI组件里面用到的这些UI组件先缓存起来,在使用到的时候通过改变UI的transform去显示。transform对应的是布局,布局改变了,它也会导致一些东西重建。而且它的重建会随着canvas Render的数量的增加而增加。
最后介绍一下我们用到的一些小经验。首先是Layout组件,很多游戏为了自适应分辨率,它会把Layout组件挂在上面,这是我们最不推荐的,因为性能消耗非常大。我们推荐的是在不同的时间节点可以去手动地切换,切换不要让它自动去选择。有一些UI组件暂时不需要,就把它隐藏掉。隐藏的方式有很多种,最好的是哪种方式呢?就是Disabling G component,不会重新组建,不会重新合批,所有的性能消耗都不会触发。唯一做的就是数据保存在那里,合批的结果也会保存在那里,只是不发出来。如果把它移到屏幕外边,如果把它当前UI组件Disable掉,前面说过的雷你都会踩。第三条就是大家比较忽略的,里面有一个camera的选项,很多开发者会把这一条忽略掉。这一条空的值可能对功能没有影响,但是对性能是有影响的。如果不指定一个camera,到底会触发哪一个camera传递过来的事件呢?它会传递所有的事件。
然后重要的话就避免覆盖,在UI组件没覆盖的情况下,我们可以推测我这个UI的。最后一条,就是Mask,这个也是大家最容易忽略的。当我们使用滚动条的时候,要用Mask。它的功能是可以保证让当前没有显示出来的UI组件不把它划出来。我今天的分享就到这里,谢谢大家!