Unity官方的UGUI优化指南读后总结

Unity官方的UGUI优化指南读后总结

Unity官方的UGUI优化指南: Optimizing Unity UI

Unity UI的C#是开源的,可以从Unity’s Bitbucket repository里的 UI文件夹下找到。

Unity UI居然还有单独的Document,惊了, Unity UI Document 1.0.0

Unity官方出的Unity在5.2版本的时候对UGUI的优化方案和方向:"Unity UGUI 的整个改进过程_(5.2启用)"

优化指南的Unity版本:2017.1 +

参考的UGUI源码的版本:2019.1


1、优化UGUI可以从哪些方面入手


优化指南里提出了4个方向

  1. 避免GPU进行重复的像素点渲染

    • 原文的描述是说 Fragment Shader 的利用率,因为我觉得不怎么容易理解,所以会翻译成我自己觉得比较理解的文字,后续很多内容都会做这样的处理,因此我这篇文章是不权威的,仅仅作为帮你找到一些方向的交流使用。

    • 这里简单地理解就是要 尽可能地避免UI上出现过多的像素叠加, 比如说Text下方有一张Image,就是属于这种情况。

    • 这个方向能优化的东西不是很多,主要做的是在摆UI预设体的时候,Recttransform的大小控制在【够用就好】的情况。

  2. 避免触发 Canvas 的 Rebuild。

    在Rebuild这一块上,已经不是老版本那种理解了,Unity做了很大的优化,参考"Unity UGUI 的整个改进过程_(5.2启用)"。目前触发Rebuild问题最大的,我个人经验,常见的有三个。

    • 大量的Graphic组件的Enable同时进行开关

    • Hierarchy上的父子层级的变化

    • LayoutGroup之类的组件的设置变更

  3. Excessive numbers of rebuilds of Canvas batches (over-dirtying)

    • Canvas batches 重建次数过多。

    • [个人理解] Canvas的数量过多,并且一次性地引发多个Canvas的Rebuild。这个我想来想去,没想到匹配的情况。只能结合后续提到的,Canvas batches 的过程是在一个叫Renderer thread线程里进行的,如果一次性进行过多的Canvas batches,会导致这个线程“迟到”,从而延迟了给主线程回传数据时机,会引发卡帧的情况。

  4. 减少CPU处理顶点的时间。

    • 这个主要是针对Text的,因为Text不像Image,它需要根据具体的文字生成网格,并且受Text的各种属性影响这个过程,比如说字体的Size。
    • 由于我对Text的优化没有任何经验,因此本文不会提及Text的优化。但原文有提到,有兴趣的可以移步过去看 ,Optimizing Unity UI。

2、详细解读UGUI的重要(基础)组件


Unity UI的C#是开源的,可以从Unity’s Bitbucket repository里的 UI文件夹下找到。

如果你要看下去,最好配合着源码看看,看得到的类文件,比一串类名要让你更投入和更容易理解一些。

Canvas组件

一个原生代码的组件,就是用C++写的 ,一般渠道看不到源码,我就不懂看。不过理解它的作用就行。

Canvas,(我的理解的UI的最大渲染单位)。如果你将它理解Mesh的话,可以假设它是一张大的Mesh,由其子物体的Mesh组合而成。子物体的MeshCanvas Renderer提供。

拟物版的理解,Canvas是一个1米*1米的空白画布,画布区域里有12生肖的贴纸(表示12个Graphic的子物体),摆的位置乱七八糟的,每个图案都带着一个组件Canvas Renderer,可以理解Canvas Renderer就是贴纸的胶水,是Canvas Renderer把12生肖的图案和画布关联起来的,把画布挂在墙上就类比GPU将Canvas内容渲染到屏幕上。

问:我要Canvas来干嘛?直接把12生肖贴纸贴墙上它不香吗?

答:

简单的回答:就是减少Draw Call,如果 DC 都不知道的话可以先去查下资料,这个知识点很重要的。

长一点的回答:减少子物体互相打断批处理的情况。大家都知道,UI的渲染顺序,是TOP -> BUTTOM,从上到下的,那如果遇到 IMAGE->TEXT->IMAGE-TEXT-IMAGE这种情况,一个一个按顺序来渲染的话,岂不是要5次DC?然后你试试,在工程里按这个顺序摆放5个物体。【重点,5个物体不能重叠!】。摆放好后打开Game视图右上角的Statas,你会看到UGUI产生了2个Batches(这里暂且把Batches等价DC,实际上两者大部分时候是不等的)。Canvas的作用,就是节省这3个DC,也是为了节省这3个DC,导致了各种UGUI的优化的出现。(看不懂就跳过,以后会理解的)

【兴趣,5个物体重叠你再看看?嘿嘿,理解了并且感兴趣的话,细节可以看"Unity UGUI 的整个改进过程_(5.2启用)"】

问:Canvas Renderer 就是个胶水吗?怎么理解啊?

答:我不知道。Canvas Renderer就像一个工具人,搜集Graphic的各种信息,然后将信息传递给Canvas,最终Canvas将自身的信息传递给GPU去渲染出来。也希望各位看官,能理解这点的话,在评论里说一下。

子Canvas

在Canvas的节点下,创建一个物体,在那个物体上挂一个Canvas的组件,这一个Canvas组件就被称为子Canvas,就是子Canvas。

上面我提了一句,Canvas是最大的渲染单位,为什么这样说呢?常规的情况,父子节点,父节点是包含/拥有子节点的,但是Canvas不一样,子Canvas和Canvas是互相独立的,两者是渲染层面上是平等关系,互不影响互不干扰。【Canvas之间互不影响互不干扰】

子Canvas一般是用来做优化用的,Unity优化指南里,也非常推荐用子Canvas去分离一些UI元素从而达到减少Canvas遍历子节点进行的Rebuild次数以及减少Canvas触发Rebuild是影响的UI节点数量。

问:什么时候用子Canvas?

答:这个没有一个固定的用法,也不应该有固定的用法,一切都要根据项目来。文章后面会有这方面的的一些讲解的。

Graphic 组件

你看的到的UI元素,都是继承自Graphic的,当然继承链中间穿插了一个叫做MaskableGraphic的子类,这个子类实现了IMaskable的接口,目的是为了提供遮罩的功能,就是Mask的功能(和RectMask2D无关)。RawImage,Image和Text之类的可以看得到的东西,都是继承自MaskableGraphic的,可以从UI源码上看的到。

Graphic就是我们的12生肖的图案,如果图案的某些性质发生了改变,就会触发Graphic自身的Rebuild函数的调用。你看源码的话,在文件里搜索下“Dirty”和“Rebuild”,你会看到很多相关的东西,这些都和Graphic的Rebuild有关。

Layout组件

每一个Reacttransform都是一个Layout,你虽然找不到Layout这个类,但万恶之源就是它。因为它是可以嵌套的,当一个东西可以嵌套的时候,就代表它的影响范围不仅仅是自身。

另外LayoutGroup的相关类、ContentSizeFitter 都是Layout的性能痛点的帮凶。很多人在ScrollView里用了LayoutGroup,就很难受,痛上加痛,后面有专门讲这个的优化。

CanvasUpdateRegistry类

Graphic 和 Layout的组件,都依赖CanvasUpdateRegistry来实现Rebuil。这个类是一个很关键的类,记录(Track)着被标记为Dirty的Layout和Graphic组件的集合,在其(Layout和Graphic组件)关联的Canvas调用 willRenderCanvases事件时,根据需要触发更新。根据需要的解释可以看源代码。

Graphic和Layout组件的更新过程称为Rebuild。就是你觉得12生肖的图案要改一下,你就得把画布从墙上拿下来,拿回工作室,重新花时间修改,然后贴到画布上(这是Canvas的事情),再贴回墙上(贴墙是GPU的事)。这个过程被称为Rebuild。

Graphic和Layout组件的产生了修改后,就会被设置为Dirty,之前看源码的话应该很容易理解,Dirty之后就会被CanvasUpdateRegistry跟踪。

Dirty标记,平时写代码的时候,也会也经常用。比如有10件衣服,我弄脏了衣服的一小块地方,那我就需要洗一下脏了的那些衣服,其他的衣服我可以不洗,保留下来一直用。(一件一直干净的衣服能穿一辈子!战术后仰!)。

Canvas就是把包含Mesh(还有很多其他信息)的专用的一系列数据缓存下来,一直用。

Canvas 渲染细节

这部分懂的就懂,不懂的以后会懂。这里我直接用原文翻译了,每一句都是干货。

顺便推一名博主的UGUI优化指南翻译原文,就是翻译的时候是分段的,看起来小难受。下面一段话就是从他那里复制过来的,我英文很一般,谷歌翻译也不算很准。

在拼UI的时候,请记住所有由Canvas绘制的图形都将绘制在透明队列中。也就是说,由Unity UI生成的图形将总是从后向前绘制并启用alpha blend。从性能的角度来看,需要记住的重要一点是,我们绘制的多边形图形的所有像素都将被采样,即使这个像素完全被其他不透明的多边形覆盖了(就是实际上这图片被其他Graphic遮住了,你看不见它,它也仍然会被GPU渲染的,仍然消耗着GPU资源,这就是透明队列必然出现的情况)。在移动设备上,这种过度的绘制很快便会引起GPU的Fragment填充率的问题。

Unity UI的图形渲染是在Transparent队列(透明队列)的,因此要注意Overdraw,避免同一个像素的渲染次数过多引起GPU的填充率的性能问题。

这里就是提醒你,完全不可见的UI界面,应该把它关掉,但是!最好别乱关,用Canvas的Enable来打开和关闭界面。不过这方面在最后面会详细说。


3、Canvas的渲染过程 ( The Batch building process)


Canvas的批处理过程(The Batch building process)

关于The Batch building process,我没能找到很详细的过程,只是从"Unity UGUI 的整个改进过程_(5.2启用)"找到了一点相关的内容,这个批处理过程,实现了我们[Canvas组件]这一章里提到的,在无重叠的情况降低DC数量的功能,这个功能官方的名称叫做批处理排序。而在改进之后,这部分的性能消耗被大大地优化了,并且放到了多线程去处理,对主线程的影响相当的小。

顺着往下说,我们其实很容易发现一个事情,我们在运行时移动Image的位置时,应该是打乱的旧的批处理排序,但是Profile上看性能并没有大的波动。移动位置会将Canvas设置为Dirty,但这个Dirty是会触发The Batch building process,而不会触发Rebuild的,因为Rebuild是从下而上的。

聊天的时候,会把Canvas的Batch building process这一个过程称为Rebatch,跟Rebuild区分开来。

所以对于UGUI的性能分析,要分开两点

  • Canvas的批处理过程 Rebatch (The Batch building process)

  • GraphicLayoutRebuild

这两点,都会影响性能。但是Rebatch是有多线程的加持的,而Rebuild是在主线程的。

  • Rebuild自身会有性能消耗,同时Rebuild会触发Rebatch。

  • Rebatch除了被Rebuild触发,还会被其他情况触发。

  • Rebatch的性能上的问题,在电脑上比较难看的出来,因为有多线程加持。

(原文)计算批次需要按深度对网格进行分类,并检查它们是否存在重叠,共享的材料等。此操作是多线程的,因此在不同的CPU架构之间,尤其是在移动SoC(通常具有很少的CPU内核)和现代台式机CPU(通常具有4个或更多内核)之间,其性能通常会有很大差异。

感慨:我以前一直以为,不管是Rebuild还是Rebatch,最终影响性能的地方都是Canvas的Rebatch,因为以前很多人都说XXX操作会引起Canvas的Rebuild(当初还是5.x的时代)。现在面对 Rebatch + Rebuild,有点懵,想了好久,然后又做了测试才想清楚了。

回到正题:

批处理构建过程,不仅有批处理排序,还有网格合并之类的。

批处理构建过程,其中Canvas组合了其包含的UI元素的网格并生成适当的渲染命令以发送到Unity的图形管道。此过程的结果将被缓存并重复使用,直到将Canvas标记为脏的为止(每当对其组成网格之一进行更改时都会发生)。

Canvas使用的网格取自其子节里的Graphic组件的一组Canvas Renderer组件,但不包含在任何一组Sub-Canvas的子节点下的Graphic组件。

根据上述的信息,有以下优化方案

  • 减少Rebuild,尤其是Layout的Rebuild,影响范围大。

  • 减少由于Rebuild触发大规模Canvas的Rebatch。优化方法使用子Canvas的动静分离。

  • 减少大规模Canvas的Rebatch,比如部分节点有各种循环动画引起大规模Canvas的Rebatch。优化方法使用子Canvas的动静分离。

CanvasUpdateRegistry 引导Rebuild(The rebuild process)

CanvasUpdateRegistry 前文也有提到,应该都比较眼熟吧。打开UI的源码,这次来仔细看它里面的一个函数,叫做PerformUpdate,查找这个函数的引用,可以看到下方这行代码

        protected CanvasUpdateRegistry()
        {
            Canvas.willRenderCanvases += PerformUpdate;
        }

PerformUpdate函数会在WillRenderCanvases事件触发时调用,这就是为什么我们看Profile的时候,出现性能高峰的总是会看到WillRenderCanvases的原因了。

PerformUpdate的运行过程分3步

  • 顺序遍历调用Dirty的Layout组件的Rebuild函数

  • 要求任何已注册的剪切组件(例如蒙版)剔除所有剪切的组件。这是通过ClippingRegistry.Cull完成的。

  • 顺序遍历调用Dirty的Graphic组件的Rebuild函数

这里再次强调一下,Rebuild指的是LayoutGraphic的Rebuild,并不是Canvas的Rebatch。

private void PerformUpdate()
        {
            UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Layout);
            CleanInvalidItems();

            m_PerformingLayoutUpdate = true;
            //Layout Rebuild
            m_LayoutRebuildQueue.Sort(s_SortLayoutFunction);
            for (int i = 0; i <= (int)CanvasUpdate.PostLayout; i++)
            {
                for (int j = 0; j < m_LayoutRebuildQueue.Count; j++)
                {
                    var rebuild = instance.m_LayoutRebuildQueue[j];
                    try
                    {
                        if (ObjectValidForUpdate(rebuild))
                            rebuild.Rebuild((CanvasUpdate)i);
                    }
                    catch (Exception e)
                    {
                        Debug.LogException(e, rebuild.transform);
                    }
                }
            }

            for (int i = 0; i < m_LayoutRebuildQueue.Count; ++i)
                m_LayoutRebuildQueue[i].LayoutComplete();

            instance.m_LayoutRebuildQueue.Clear();
            m_PerformingLayoutUpdate = false;

            // now layout is complete do culling...
            ClipperRegistry.instance.Cull();
            //Graphic Rebuild
            m_PerformingGraphicUpdate = true;
            for (var i = (int)CanvasUpdate.PreRender; i < (int)CanvasUpdate.MaxUpdateValue; i++)
            {
                for (var k = 0; k < instance.m_GraphicRebuildQueue.Count; k++)
                {
                    try
                    {
                        var element = instance.m_GraphicRebuildQueue[k];
                        if (ObjectValidForUpdate(element))
                            element.Rebuild((CanvasUpdate)i);
                    }
                    catch (Exception e)
                    {
                        Debug.LogException(e, instance.m_GraphicRebuildQueue[k].transform);
                    }
                }
            }

            for (int i = 0; i < m_GraphicRebuildQueue.Count; ++i)
                m_GraphicRebuildQueue[i].GraphicUpdateComplete();

            instance.m_GraphicRebuildQueue.Clear();
            m_PerformingGraphicUpdate = false;
            UISystemProfilerApi.EndSample(UISystemProfilerApi.SampleType.Layout);
        }

问:为什么要Rebuild?

答:原本画布上是12生肖从左到右按顺序排的,有一天策划让你倒过来排,你是不是得先拿回工作室重新排下顺序?这就类比Layout的Rebuild。有一天策划让你把12生肖换成12星座,你是不是同样得拿回工作室重新换贴纸?这就类比Graphic的Rebuild。

问:Rebuild消耗的是什么性能资源?

答:消耗的是主线程的CPU资源。但之前也说了,Rebuild是会触发Rebatch的,在多线程不友好的低端机器上,Rebatch的消耗并不低的。

Layout的Rebuild

要重新计算一个或多个Layout组件中包含的组件的适当位置(和可能的大小),必须按其适当的层次结构顺序应用Layouts。在GameObject层次结构中靠近根部的布局可能会更改可能嵌套在其中的任何布局的位置和大小,因此必须首先进行计算。

为此,Unity UI将脏布局组件的列表按其在层次结构中的深度进行排序。层次结构中较高的项(即,父节点较少)被移到列表的前面。

然后,请求布局组件的排序列表以重建其布局;这是实际更改由布局组件控制的UI元素的位置和大小的地方。有关各个元素的位置如何受布局影响的更多详细信息,请参见《 Unity手册》的“ UI自动布局”部分。

这里原文讲的很好很细了,也比较容易理解。

问:ContentSizeFitter是子物体反向影响父物体的,和Rebuild的顺序反过来,不会冲突吗?

答:ContentSizeFitter的变化,是在Rebuild操作之前。并且ContentSizeFitter是从子节点影响父节点的Layout大小的,就是可以理解为在Rebuild之前,受影响的父子节点的Layout的大小都已经确定了,和没有ContentSizeFitter组件的物体没什么区别。

Graphic Rebuild

重建图形组件后,Unity UI会将控制权传递给ICanvasElement接口的Rebuild方法。图形实现了这一点,并在“重建”过程的“预渲染”阶段运行两个不同的重建步骤。

  • 如果顶点数据已标记为脏数据(例如,当组件的RectTransform的大小更改时),则将重新构建网格。
  • 如果将材质数据标记为脏(例如,当更改组件的材质或纹理时),则将更新附加的Canvas Renderer的材质。

图形重建不会以任何特定顺序遍历图形组件列表,并且不需要任何排序操作。

问:单个物体的Rubuild好像消耗并不大?

答:确实,我觉得还是得益于CPU多核的发展,让Rubuild引发的Canvas的Rebatch在多线程下得到非常好性能优化。但如果触发Rebuild的物体个数数量多的时候,其自身的CPU资源消耗累加起来是很大的,尤其是Layout这种影响范围很广的Rebuild。

4、性能优化工具

Unity Profiler

没用过的可以百度搜索下,很简单的。

这里我照搬原文了,工具流没啥好说的。

img

Canvas.SendWillRenderCanvases 包含对Canvas组件的willRenderCanvases 事件订阅的C#脚本的调用。Unity UI的CanvasUpdateRegistry 类接收到此事件,并使用它来运行重建过程

注意:为了更轻松地查看UI性能的差异,通常建议禁用除“渲染”,“脚本”和“ UI”之外的所有跟踪类别。这可以通过单击CPU使用情况分析器左侧跟踪类别名称旁边的彩色框来完成。通过单击并向上或向下拖动类别名称,也可以在CPU事件探查器中对类别进行重新排序。

img

在2017.1及更高版本中,还有一个新的UI Profiler。默认情况下,此探查器是探查器窗口中的最后一个探查器。UI专用的视图,看性能和合批的时候挺有用的。

img

缺陷(重要):

不幸的是,部分UI更新过程未正确分类,因此在查看UI曲线时要小心,因为它可能不包含所有与UI相关的调用。例如,Canvas.SendWillRenderCanvases 归类为“ UI”,而Canvas.BuildBatch 归类为“其他”和“渲染”。

Unity FrameDebugger

帧调试器,谁用谁知道,简单得很,看得懂英文,会按键盘的上箭头和下箭头两个按键就行了。

Xcode的方法,可以去原文看Optimizing Unity UI


5、从像素叠加的方面来优化UI


修复 Fill-Rate(GPU 像素填充率[过高]) 的问题

可以采取两种措施来减轻GPU片段管道上的压力:

  • 降低片段着色器的复杂性。有关更多详细信息,请参见“ UI着色器和低规格设备”部分。
  • 减少必须采样的像素数。
    由于UI着色器通常是标准化的,所以最常见的问题就是过度使用填充率。这最常见是由于大量重叠的UI元素和/或具有占据屏幕重要部分的多个UI元素。这两个问题都可能导致透支水平过高。

简单得来说就是要降低像素叠加的情况。

关闭不可见的UI界面

如果你打开了一个新的UI界面,是全屏的且完全遮住的旧的UI界面,可以禁用置于全屏UI下方的全部UI元素。 最简单的方式就是把UI界面的根节点的物体设置为False,但注意不要在同一帧里处理打开和关闭,也不要在同一帧里一次性关闭,分帧处理是一个好的方法。

其次,一个另外的解决方式,会更好,使用要关闭的UI界面的最顶层的Canvas的Enable = false的方式来禁用,后文会详细讲解这个。

简化UI的结构(Simplify UI structure

it is important to keep the number of UI objects as low as possible

  • 精简你的UI结构,能用一张Image解决的事情,尽量不要用两张。

  • 尽量不要创建空节点。

  • 能用材质球和Shader解决的炫酷效果,尽量不要用PS里的图层叠加的方式来实现。

  • 因为无论是Rebuild还是Rebatch,都是UI的物体越少越好。

关闭渲染内容不可见的世界相机:

前文说了,UI是渲染在透明队列上的。即使你打开了一个全屏的UI,在这个UI的下方,你看不见的东西,你的游戏的世界空间相机仍将在UI后面渲染标准3D场景,它仍然被渲染,仍然消耗的CPU和GPU的资源。

当打开全屏UI的时候,分3种情况吧。

  • 完全全屏的UI:这个时候可以通过关闭世界相机来轻松的减轻GPU的压力,让它休息下。

  • 部分不可见之背景不会动:在打开UI之后,关闭世界之前,将场景渲染为一张Texture,然后可以用RawImage等方法显示这张Texture,制作一个假的背景。

  • 部分不可见之背景会动:许多“全屏”用户界面实际上并不会掩盖整个3D世界,而是使世界的一小部分可见。在这些情况下,最好只捕获渲染纹理中可见的世界部分。利用副本相机(世界相机的仿制品)实时渲染一张RenderTexture,然后把这个RenderTexture现在在界面的“可见区域”。和第二点不一样的是,第二点只需要渲染一次,而这个是实时渲染,没帧都会选染,好处是渲染的物体少了很多。

注意:如果一个Canvas被设置为“Screen Space – Overlay”,那么不管场景里有几个相机(即使是0个),Canvas都会被绘制。

分层式的UI组合

以下是原文翻译:

对于UI设计师来说,使用各种背景、元素组成一个最终UI是很常见的一件事。这样做相对简单,而且对迭代非常友好。但是由于UnityUI使用了透明渲染队列,所以它的性能很糟。

想想看一个具有背景、按钮和按钮上的一些文本的简单UI。由于透明队列中的对象是从后往前排序的,在一个像素落在一个文本字形内的情况下,GPU必须采样背景的纹理,然后是按钮的纹理,最后是文本图集的纹理,总共三个样本。随着UI复杂性的增加,更多的装饰元素被添加到背景中,样本的数量会迅速增加。

如果发现一个大型UI的填充率达到瓶颈,最好的办法是创建专门的UI sprites,将UI所有的装饰/不变元素合并到它的背景纹理中。这样就减少了必须层叠在一起才能实现设计的元素,但这个工作量很大,而且也增加了项目图集的大小。

这个做法也适用于子元素上。想想看一个带有滚动商品列表的商店UI。每个商品UI元素都有一个边框、一个背景和一些表示价格、名称和其他信息的图标。

商店UI需要一个背景,但是因为它的商品在背景上滚动,商品元素不能合并到商店UI的背景贴图上。但是,商品UI元素的边框、价格、名称和其他元素可以合并到商品的背景上。根据图标的大小和数量,可以节省相当多的填充率。

这种优化方式有几个缺点,这种定制的元素不能再被重用,并且需要美术同学帮忙修改。添加大的新贴图可能会显著增加保存UI贴图所需的内存数量,特别是在UI贴图没有按需加载和卸载的情况下。

我之前有个项目其实是利用了这一点做优化的,是一个ScrollView里面,Item的格式如下(灵魂画手,原图没空找了)


image-20200704120120883.png

有4种情况,无黄色星 => 3个黄色星,按照以往的做法,就是背景框一个Image,3个星星各自一个Image。但是通过上面的优化,可以转换为只需要一个Image组件,让美术把4种类型的效果都做成图片。

我这个ScrollView一次会显示50+个这样的Item,4个Image减少为1个,就相当于少了150+的UI物体,可以说是很大的优化了。

当然,在实际应用上,这种方法会额外增加的图集的大小,内存的大小,有时候性能优化提升了一小截,内存增大一大截,也是不好的。内存和性能经常是互相制衡的,需要自己摸索。

用简易的UI-Shader来优化

UI-Default定义了很多功能,如果不需要的话,可以修改一版Shader,用更简单的Shader来渲染。普通情况比较少用,深入抠性能的话可能会用上。不过我觉得会用上这种方式的人,本身肯定是会性能优化有比较多的了解的大牛了(我自己不会用)。不理解的也没关系,跳过就行。

//推荐的Shader
Shader "UI/Fast-Default" 
{
    Properties 
    { 
        [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} 
        _Color ("Tint", Color) = (1,1,1,1) 
    }
    SubShader
{
    Tags
    { 
        "Queue"="Transparent" 
        "IgnoreProjector"="True" 
        "RenderType"="Transparent" 
        "PreviewType"="Plane"
        "CanUseSpriteAtlas"="True"
    }

    Cull Off
    Lighting Off
    ZWrite Off
    ZTest [unity_GUIZTestMode]
    Blend SrcAlpha OneMinusSrcAlpha

    Pass
    {
    CGPROGRAM
        #pragma vertex vert
        #pragma fragment frag

        #include "UnityCG.cginc"
        #include "UnityUI.cginc"

        struct appdata_t
        {
            float4 vertex   : POSITION;
            float4 color    : COLOR;
            float2 texcoord : TEXCOORD0;
        };

        struct v2f
        {
            float4 vertex   : SV_POSITION;
            fixed4 color    : COLOR;
            half2 texcoord  : TEXCOORD0;
            float4 worldPosition : TEXCOORD1;
        };

        fixed4 _Color;
        fixed4 _TextureSampleAdd;
        v2f vert(appdata_t IN)
        {
            v2f OUT;
            OUT.worldPosition = IN.vertex;
            OUT.vertex = mul(UNITY_MATRIX_MVP, OUT.worldPosition);

            OUT.texcoord = IN.texcoord;

            #ifdef UNITY_HALF_TEXEL_OFFSET
            OUT.vertex.xy += (_ScreenParams.zw-1.0)*float2(-1,1);
            #endif

            OUT.color = IN.color * _Color;
            return OUT;
        }

        sampler2D _MainTex;
        fixed4 frag(v2f IN) : SV_Target
        {
            return (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;
        }
    ENDCG
    }
}

扩展问题:Stencil 到底做了什么?【等我学会Shader,没忘记的话再回头回答】


6、UI Canvas 的网格信息刷新优化


Canvas 的Rebatch

原文用的是 UI Canvas rebuilds 的标题,我觉得有点容易混淆,因为实际上它讲的概念还是Rebatch的这部分概念。

干货很多,我直接复制原文翻译

为了更好地显示UI,UI系统必须为屏幕上表示的每个UI组件构造图形。这这包括运行动态布局代码,生成多边形来表示UI文本字符串中的字符,并将尽可能多的图形合并到单个网格中以最小化draw calls。

Canvas重绘可以成为性能问题的两个主要原因:

  • 如果Canvas上可绘制UI元素的数量很大,那么计算合批本身就非常消耗性能。这是因为对元素进行排序和分析的成本与Canvas上可绘制UI元素的数量成正比。
  • 如果Canvas经常被标记为dirty(经常变化),那么可能会花费过多的时间因为一点点改动而刷新整个Canvas。
    随着Canvas上元素数量的增加,这两个问题都变得越来越严重。

重要提示:当Canvas上的任何可绘制UI元素发生更改时,画布必须重新进行一遍合批过程。这个过程重新分析Canvas上的每个可绘制的UI元素,不管它是否已经更改。注意,“更改”是影响UI对象外观的任何更改,包括替换sprite、修改位置和大小、修改文本网格中包含的文本等。

说实话我觉得,这部分的信息有点容易让人误会,容易让人觉得UI元素发生改变,都是由于Canvas的重绘引起的。但在Profiler上看,其实UI自身的Rebuild,尤其是Layout的Rebuild消耗挺大的。

看你们选择怎么理解吧,我还是保留观点,Rebatch+Rebuild。Rebatch在CPU的多核多线程的加持下,在UI数量合理的情况下,越来越不会拖后腿。而Rebuild在主线程上跑,也同样是会影响到性能的。

渲染排序优化

回想起文章的开头做的那个测试,5个IMAGE->TEXT的那个测试,可以看到通过Canvas的批处理顺序优化后,减少了3个DC。但前提是它们不重叠。因此这一小节就是提醒这么一件事。

两个Material不相同的Graphic组件们,如果它们在Hierarchy上的层级是互相穿插的,在搭建UI的时候,保证美术效果的前提下,尽可能地不要让他们出现叠加的情况,各自守好自己的三分两亩地,满足Canvas的优化条件,皆大欢喜。

这种情况,在结点层级很多的时候,需要更加注意,主要还是靠感觉和经验。

我觉得这个其实不能称为优化,而是UI搭建的基础知识,必须要知道的知识点,而且实际上也很少出现穿插的情况吧?

Canvas动静分离

以前老版本UGUI的时候,经常会提起的东西。现在其实还好,因为Rebatch被优化过了。

下面的原文谷歌翻译

由于Canvas会在其组成的可绘制组件中的任何一个发生更改的任何时候重新绘制,因此通常最好将任何不重要的Canvas分成至少两个部分。此外,如果希望元素同时更改,则最好尝试将元素共置在同一Canvas上。例如进度条和倒数计时器。它们都依赖于相同的基础数据,因此将需要同时进行更新,因此应将它们放置在同一Canvas上。

在一个画布上,放置所有静态且不变的元素,例如背景和标签。第一次显示“画布”时,它们将批处理一次,然后不再需要重新批处理。

在第二个Canvas上,放置所有“动态”元素-那些经常变化的元素。这将确保此Canvas主要重新绘制脏元素。如果动态元素的数量变得非常大,则可能有必要将动态元素进一步细分为一组不断变化的元素(例如,进度条,计时器读数,动画等)和一组仅偶尔变化的元素。

实际上,这实际上是相当困难的,尤其是在将UI控件封装到Prefab中时。相反,很多情况下,都是在同一个一个UI界面下,通过建立子Canvas的方式来进行动静分离。而不是像上面说的,整体分成两个Canvas。

我个人的感觉,如果说UI界面复杂度比较高的,用动静分离是很好的方式,我说的是用子Canvas动静分离。至于那个整体UI动静分离的,再见,打扰了,我这就走,3连送上好吗。

Graphic Raycaster

这章原文也是很多干货啊,讲了原理,

是的,是你了,熟悉的原文翻译

Graphic Raycaster是一个相对简单的实现,它遍历所有将' Raycast Target '设置为true的Graphic组件。每一个Raycast Target都会被进行测试。如果一个Raycast Target通过了所有的测试,那么它就会被添加到“被命中”列表中。

射线响应的要求:

  • Raycast Target处于激活状态(active )并且是启用了自身的(enabled)的,同时具备了Graphic组件
  • 输入的点击位置位于附加了Raycast Target的对象的RectTransform的Rect范围内(注意,这里不是图片或者Text的可视范围哦)
  • 如果Raycast Target对象或者它的子对象中包含ICanvasRaycastFilter组件,则该Raycast Filter组件允许进行Raycast。(这部分可以在源码找,可以看到有哪些ICanvasRaycastFilter组件)

被命中的Raycast Target列表根据深度排序,并进行过滤,以确保在相机后面的元素(即在屏幕上不可见)的被删除掉。

如果将Graphic Raycaster的“Blocking Objects”属性打开,Graphic Raycaster就可以将射线投射到3D或2D物理系统中。(在脚本中,属性名为blockingObjects)。意思就是打开这个选项之后,射线就会被3D或2D物体挡住,影响对UI的点击。

优化方向

  • 最佳做法是仅在必须接收指针事件的UI组件上启用“ Raycast Target”设置。射线广播目标的列表越小,必须遍历的层次越浅,则每个射线广播测试将更快。

  • 对于具有多个必须响应指针事件的可绘制UI对象的复合UI控件(例如希望其背景和文本都改变颜色的按钮),通常最好将 [单个(Single)] Raycast Target放置在复合UI的根部控制。当单个Raycast Target接收到指针事件时,它可以将事件转发到复合控件中的每个感兴趣的组件。

    第二点比较难理解,这里的转发功能,我感觉涉及到了自定义UI的方式,因为原Button组件都是只绑定了一个Graphic来接受点击的效果变化。但我们可以通过继承Button类来写自己的MyButton来扩展来实现。不过很多时候也没这个需求,所以我换一个不需要思考那么多的说法在下面。

  • 如果你的复合UI的 [最顶层] 的Graphic组件的Recttransform的Rect大小,已经满足了你的射线检测需要的Size大小,那么请只开启[最顶层] 的Graphic 的射线检测。

问:射线检测为什么消耗那么大?

答:原文谷歌翻译

搜索射线广播滤镜时,每个图形射线广播都将遍历 Transform层次结构一直到根。此操作的成本与层次结构的深度成比例线性增长。必须测试与层次结构中的每个Transform 关联的所有组件,以查看它们是否实现ICanvasRaycastFilter,因此这不是一个便宜的操作。

有几个使用ICanvasRaycastFilter的标准Unity UI组件,例如CanvasGroup,Image,Mask和RectMask2D,因此不能轻易消除这种遍历。

因此,不要把Raycast藏得太深,这个理论匹配上了优化方向的第二点和第三点。

子Canvas的OverrideSorting属性

子画布上的overrideSorting属性将导致Graphic Raycast测试停止爬升变换层次结构。如果可以启用它而不会引起排序或射线广播检测问题,则应使用它来减少射线广播层次结构遍历的成本。

嗯,又是一条提示你子Canvas的重要性的信息。如果你的Raycast的物体结点层次非常的深,可以用这种方式。但是我觉得改良一下节点层次可能会更好。

ScrollView 的优化

这里我直接讲优化的方式吧。

优化的原理

  • 避免Layout的Rebuild

  • 尽可能地减少onTransformParentchanged

最基础的工作,对象池 + 无限循环滚动

对象池都不做的,那没救了,别优化了,去搞一个对象池吧,很简单的。后续的优化都是基于对象池的。

使用LayoutGroup的ScrollView优化

这种方法比较常见,因为比较省事。

  • 核心原理:避免LayoutGroup的属性和其物体的Layout发生改变。因为LayoutGroup发生改变,它的Rebuild会引发其所有子物体的Layout的Rebuild。

占茅坑法:

预先在ScrollView的Content里放置好N个空物体,称为替身,替身的大小和原Item的大小一致,替身的数量和你预备要放的物体的个数一致(你要放2000个?这...下个方案见)。滚动的时候,只需要把可见的物体作为对应的替身的子物体即可,循环回收再利用。这里优化的地方在于,你原版的Item的加入和移除,并不会引起整个ScrollView的Rebuild,只会引发你当前的Item的Rebuild。

不使用LayoutGroup的ScrollView优化(推荐)

不使用LayoutGroup,通过代码计算每个Item的位置,自己计算并且摆放好Item所在的位置。这些操作需要自己去实际写代码,去感悟,我很难把难点说出来,而且难点也不难,试错几次就行了。这个的好处是不需要创建替身,替身数量多了之后也会到来麻烦的。

当然你熟悉了之后,还有很多种方法,只要效果好就行了。


7、其他优化


这部分照搬了,没啥感悟的,懂了就懂了。

基于RectTransform的布局

布局组件比较昂贵,因为布局组件每次标记为脏时必须重新计算其子元素的大小和位置。(有关详细信息,请参见基础步骤的“图形重建”部分。)如果给定布局中元素的数量相对较小且固定,并且布局具有相对简单的结构,则可以用RectTransform替换布局基于布局。

通过分配RectTransform的锚点,可以根据RectTransform的父级缩放其位置和大小。例如,可以使用两个RectTransforms实现简单的两列布局:

  • 左列的锚点应为X:(0,0.5)和Y:(0,1)

  • 右列的锚点应为X:(0.5,1)和Y:(0,1)

RectTransform的大小和位置的计算将由Transform系统本身以原生代码(C++)驱动。通常,这比依赖Layout系统的性能更高。

禁用画布

当显示或隐藏UI的离散部分时,通常在UI的根部启用或禁用GameObject。这样可以确保禁用的UI中没有任何组件接收输入或Unity回调。

但是,这也会导致Canvas放弃其VBO数据。重新启用画布将需要画布(和所有子画布)运行重建和重新批处理过程。如果这种情况经常发生,则CPU使用率增加会导致应用程序的帧速率停顿。

一种可能但很棘手的解决方法是将要显示/隐藏的UI放置在其自己的Canvas或Sub-canvas上,然后仅在此对象上启用/禁用Canvas组件。

这将导致不绘制UI的网格,但它们将保持驻留在内存中,并保留其原始批处理。此外,在UI的层次结构中不会调用OnEnable或OnDisable回调。

但是请注意,这不会禁用隐藏UI中的任何MonoBehaviour,因此这些MonoBehaviours仍将接收Unity生命周期回调,例如Update。

为避免此问题,将以这种方式禁用的UI上的MonoBehaviours不应直接实现Unity的生命周期回调,而应从UI根GameObject上的“回调管理器” MonoBehaviour接收其回调。每当显示/隐藏UI时,都可以通知此“回调管理器”,并且可以确保根据需要传播或不传播生命周期事件。此“回调管理器”模式的进一步说明不在本指南的范围之内。

分配事件摄像机

如果将Unity的内置输入管理器与 Canvas 的 Render Mode 设置为在 *World Space* or *Screen Space – Camera*模式中使用,则始终分别设置“事件摄像机”或“渲染摄像机”属性非常重要。Canvas的类属性上,统一叫做worldCamera

image-20200704133403795.png

如果未设置此属性,则Unity UI将通过使用Main Camera标签查找附加到GameObjects的Camera组件来搜索主摄像机。每个世界空间或摄影机空间画布将至少进行一次此查找。由于GameObject.FindWithTag的运行速度很慢,因此强烈建议所有World Space和Camera Space画布在设计时或初始化时分配其Camera属性。

对于 Overlay Canvases模式的 Canvas 不会发生这个问题。

UI源代码定制

UI系统已经开源啦。这种灵活性很棒,但这也意味着在不破坏其他功能的情况下就无法轻松进行某些优化。如果最终遇到的情况是可以通过更改C#UI源代码获得一些CPU周期,则可以重新编译UI DLL并覆盖Unity随附的UI DLL。此过程记录在Bitbucket存储库的自述文件中。确保获取与您的Unity版本相对应的源代码。

但是,由于存在一些重要的缺点,因此只能作为最后的手段。首先,您必须找到一种将新DLL分发给开发人员并构建计算机的方法。然后,每次升级Unity时,都必须将更改与新的UI源代码合并。确保您不能仅仅扩展现有的类或编写自己的组件版本,然后再朝该方向发展。

你可能感兴趣的:(Unity官方的UGUI优化指南读后总结)