UGUI底层

article/2025/10/6 16:33:19

关于UGUI底层的小知识---上 (转雨松momo)

1 | UGUI原理简述

1.1 原理

首先得生成显示UI用的Mesh,如图1-1所示,一个矩形的Mesh,由4个顶点,2个三角形组成,每个顶点都包含UV坐标,如果需要调整颜色,还需要提供顶点色。就像UGUI中调节图片和文本的颜色,其实就是设置它们的顶点色而已。

  

然后将网格和纹理信息送入GPU中渲染,如图1-2所示一个最简单的UI元素就渲染出来了。如果再继续排列组合其他的UI元素,那么一个游戏界面就诞生了。所谓的UI其实就是用一个正交摄像机端端地看着若干个平面网格。

以上只是UI的最基本的显示原理,虽然这样是可以拼出来UI的,但是我们是无法应用在游戏中的。比如,DrawCall需要合并,UI的点击操作事件、UI基础组件等等,所以就诞生了伟大的UGUI。带着界面打开慢和界面操作慢的两个问题,我们开始分析UGUI的源码来寻找一些答案。

 

 

2丨WillRenderCanvases源码解读

使用UGUI都知道只要添加Canvas将会打断和之前元素DrawCall的合并,每个Canvas都会开始一个全新的DrawCall,遗憾的是UGUI并没有公开Canvas的源码,通过反编译DLL我们看到了Canvas中的部分C#源码,如下代码所示,当Canvas需要重绘的时候会调用SendWillRenderCanvases()方法。 

Canvas.cs (部分代码):

在CanvasUpdateRegistry的构建函数中可以看到Canvas.willRenderCanvases事件添加到this.PerformUpdate()方法中,UI发生变化一般分两种情况,一种是修改了宽高这样会影响到顶点位置需要重建Mesh,还有一种仅仅只修改了显示元素,这样并不会影响顶点位置,此时unity会在代码中区别对待。

CanvasUpdateRegistry.cs(部分代码): 

复制代码

public class CanvasUpdateRegistry
{//...略protected CanvasUpdateRegistry(){//构造函数处委托函数到PerformUpdate()方法中//每次Canvas.willRenderCanvases就会执行PerformUpdate()方法Canvas.willRenderCanvases += PerformUpdate;}private void PerformUpdate(){//开始BeginSample()//在Profiler中看到的标志性函数Canvas.willRenderCanvases耗时就在这里了//EndSample()UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Layout);CleanInvalidItems();m_PerformingLayoutUpdate = true;//需要重建的布局元素(RectTransform发生变化),首先需要根据子对象的数量对它进行排序。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;// 布局构建结束,开始进行Mask2D裁切(详细内容下面会介绍)ClipperRegistry.instance.Cull();m_PerformingGraphicUpdate = true;//需要重建的Graphics元素(Image Text RawImage 发生变化)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);//重建UI元素}catch (Exception e){Debug.LogException(e, instance.m_GraphicRebuildQueue[k].transform);}}}//这里需要思考的是,有可能一个Image对象,RectTransform和Graphics同时发生了修改,它们的更新含义不同需要区分对待//1.修改了Image的宽高,这样Mesh的顶点会发生变化,此时该对象会加入m_LayoutRebuildQueue队列//2.修改了Image的Sprite,它并不会影响顶点位置信息,此时该对象会加入m_GraphicRebuildQueue队列//所以上面代码在遍历的时候会分层//for (int i = 0; i <= (int)CanvasUpdate.PostLayout; i++)//for (var i = (int)CanvasUpdate.PreRender; i < (int)CanvasUpdate.MaxUpdateValue; i++)//Rebuild的时候会把层传进去,保证Image知道现在是要更新布局,还是只更新渲染。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);}
}

复制代码

 

3丨UI重建触发事件提取

如图3-1所示,在Profiler中看到的UGUI标志性耗时函数,其实就是在PerformUpdate方法中加的检测。

通常UGUI界面操作卡大概率都是Canvas.SendWillRenderCanvases()方法耗时,需要检查界面是否存在多余或者无用的重建情况。由于界面很多我们无法定位到到底是哪个界面下的哪个元素引起了网格重建。通过观察CanvasUpdateRegistry.cs源代码,我们发现需要网格重建的元素都被缓存在这两个对象中。 
 

CanvasUpdateRegistry.cs(部分代码):

复制代码

public class CanvasUpdateRegistry
{//...略//保存待重建布局元素(如:RectTransform变化)private readonly IndexedSet<ICanvasElement> m_LayoutRebuildQueue = new IndexedSet<ICanvasElement>();//保存待重建渲染元素(如:Image变化)private readonly IndexedSet<ICanvasElement> m_GraphicRebuildQueue = new IndexedSet<ICanvasElement>();
}

复制代码

接着我们来看看待重建布局元素和待重建渲染元素是如何被缓存起来的。如果某个Graphic发生布局位置或者渲染变化会分别加入这两个不同的渲染队列,等待下一次UI的重建。 

Graphic cs(部分代码)

复制代码

public abstract class Graphic: UIBehaviour,ICanvasElement
{//...略protected override void OnBeforeTransformParentChanged(){GraphicRegistry.UnregisterGraphicForCanvas(canvas, this);//布局发生变化LayoutRebuilder.MarkLayoutForRebuild(rectTransform);//LayoutRebuilder.MarkLayoutForRebuild方法内部实现//private static void MarkLayoutRootForRebuild(RectTransform controller)//{//    if (controller == null)//        return;//    var rebuilder = s_Rebuilders.Get();//    rebuilder.Initialize(controller);//    局部发生变化,会通过TryRegisterCanvasElementForLayoutRebuild()将自己加入待布局重建队列//    if (!CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild(rebuilder))//        s_Rebuilders.Release(rebuilder);//}}public virtual void SetMaterialDirty(){if (!IsActive())return;m_MaterialDirty = true;//渲染发生变化,会通过RegisterCanvasElementForGraphicRebuild()将自己加入待渲染队列CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);if (m_OnDirtyMaterialCallback != null)m_OnDirtyMaterialCallback();}
}

复制代码

 

 

复制代码

public abstract class Graphic: UIBehaviour,ICanvasElement
{//...略protected override void OnBeforeTransformParentChanged(){GraphicRegistry.UnregisterGraphicForCanvas(canvas, this);//布局发生变化LayoutRebuilder.MarkLayoutForRebuild(rectTransform);//LayoutRebuilder.MarkLayoutForRebuild方法内部实现//private static void MarkLayoutRootForRebuild(RectTransform controller)//{//    if (controller == null)//        return;//    var rebuilder = s_Rebuilders.Get();//    rebuilder.Initialize(controller);//    局部发生变化,会通过TryRegisterCanvasElementForLayoutRebuild()将自己加入待布局重建队列//    if (!CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild(rebuilder))//        s_Rebuilders.Release(rebuilder);//}}public virtual void SetMaterialDirty(){if (!IsActive())return;m_MaterialDirty = true;//渲染发生变化,会通过RegisterCanvasElementForGraphicRebuild()将自己加入待渲染队列CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);if (m_OnDirtyMaterialCallback != null)m_OnDirtyMaterialCallback();}
}

复制代码

所以我们只需要在外面将这两个对象捞出来遍历一下就能知道到底是哪个界面下的哪个元素引起了网格重建。 

 

复制代码

using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using UnityEngine.UI;public class NewBehaviourScript : MonoBehaviour {IList<ICanvasElement> m_LayoutRebuildQueue;IList<ICanvasElement> m_GraphicRebuildQueue;private void Awake(){System.Type type = typeof(CanvasUpdateRegistry);FieldInfo field = type.GetField("m_LayoutRebuildQueue", BindingFlags.NonPublic | BindingFlags.Instance);m_LayoutRebuildQueue = (IList<ICanvasElement>)field.GetValue(CanvasUpdateRegistry.instance);field = type.GetField("m_GraphicRebuildQueue", BindingFlags.NonPublic | BindingFlags.Instance);m_GraphicRebuildQueue = (IList<ICanvasElement>)field.GetValue(CanvasUpdateRegistry.instance);}private void Update(){for (int j = 0; j < m_LayoutRebuildQueue.Count; j++){var rebuild = m_LayoutRebuildQueue[j];if (ObjectValidForUpdate(rebuild)){//Debug.LogFormat("{0}引起网格重建", rebuild.transform.name,);}}for (int j = 0; j < m_GraphicRebuildQueue.Count; j++){var element = m_GraphicRebuildQueue[j];if (ObjectValidForUpdate(element)){Debug.LogFormat("{0}引起{1}网格重建", element.transform.name, element.transform.GetComponent<Graphic>().canvas.name);}}}private bool ObjectValidForUpdate(ICanvasElement element){var valid = element != null;var isUnityObject = element is Object;//Here we make use of the overloaded UnityEngine.Object == null, that checks if the native object is alive.if (isUnityObject)valid = (element as Object) != null; return valid;}
}

复制代码

如下图所示,当Canvas下某个元素引起了网格重建,我们可以知道具体是哪个UI元素。

 

 

4丨网格重建源码解读

Canvas.SendWillRenderCanvases()方法到底干了些什么?到底卡在那里?观察代码可以发现它需要调用每个ICanvasElement接口下的Rebuild()方法。UGUI的Image和Text组件都派生自Graphics类,并且都实现了ICanvasElement接口。 

如下代码所示,Rebuild()方法就是 UpdateGeometry(更新几何网格)和 UpdateMaterial (更新材质),看来这和我们文章一开始讲的UI绘制原理是一模一样的。 
Graphic.cs(部分代码):

 

UpdateGeometry(更新几何网格),就是确定每一个UI元素Mesh的信息,包括顶点数据、三角形数据、UV数据、顶点色数据。如下代码所示,无论Image还是Text数据都会在OnPopulateMesh函数中进行收集,它是一个虚函数会在各自的类中实现。

顶点数据准备完毕后会调用canvasRenderer.SetMesh()方法来提交。很遗憾CanvasRenderer.cs并没有开源,我们只能继续反编译看它的实现了,如下代码所示,SetMesh()方法最终在C++中实现,毕竟由于UI的元素很多,同时参与合并顶点的信息也会很多,在C++中实现效率会更好。看到这里,我相信大家应该能明白UGUI为什么效率会被NGUI要高一些了,因为NGUI的网格Mesh合并都是在C#中完成的,而UGUI网格合并都是在C++中底层中完成的。 

CanvasRenderer.cs(部分代码)

总的来说 Profiler中看到Canvas.SendWillRenderCanvases()效率过低就是因为参数Rebuild()的元素过多,底层的代码我们是无法修改,但是却可以从策略上避免,文章后面我会讲讲我是如何避免它的。 

再回到Canvas.SendWillRenderCanvases()方法,当网格需要重建时Unity底层会自行调用,在UGUI中只需要准备好需要参与Rebuild()的元素即可。 

如果某个UI需要重建,首先需要将它加入“待重建队列”,等到下一次Unity系统回调Canvas.SendWillRenderCanvases()方法时一起Rebuild()。如下代码所示,只需要调用LayoutRebuilder.MarkLayoutForRebuild(rectTransform)方法就可以将该UI元素加入“待重建队列”等待重建。

复制代码

//Graphic.cs(部分代码)
public abstract class Graphic : UIBehaviour,ICanvasElement
{//...略//更新全部public virtual void SetAllDirty(){//更新布局SetLayoutDirty();//更新顶点SetVerticesDirty();//更新材质SetMaterialDirty();}public virtual void SetLayoutDirty(){if (!IsActive())return;//加入待布局队列LayoutRebuilder.MarkLayoutForRebuild(rectTransform);if (m_OnDirtyLayoutCallback != null)m_OnDirtyLayoutCallback();}public virtual void SetVerticesDirty(){if (!IsActive())return;m_VertsDirty = true;//加入待渲染列CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);if (m_OnDirtyVertsCallback != null)m_OnDirtyVertsCallback();}public virtual void SetMaterialDirty(){if (!IsActive())return;m_MaterialDirty = true;//加入待渲染列CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);if (m_OnDirtyMaterialCallback != null)m_OnDirtyMaterialCallback();}
}

复制代码

由于元素的改变可分为布局变化、顶点变化、材质变化,所以分别提供了三个方法SetLayoutDirty();SetVerticesDirty();SetMaterialDirty();供选择。举个例子,在UI中如果调整了元素在Hierarchy中的父节点,如下代码所示,在OnTransformParentChanged()方法中监听,通过SetAllDirty();方法将该UI加入“待重建队列”。
为什么UI发生变化一定要加入待重建队列中呢?其实这个不难想象,一个UI界面同一帧可能有N个对象发生变化,任意一个发生变化都需要重建UI那么肯定会卡死。所以我们先把需要重建的UI加入队列,等待一个统一的时机来合并。 

Graphic.cs(部分代码):

再比如需要修改Text文本的字体,由于字体大小的变化只会影响布局信息和顶点信息,那么就调用SetVerticesDirty();SetLayoutDirty();方法即可。 

UI的网格我们都已经合并到了相同Mesh中,还需要保证贴图、材质、Shader相同才能真正合并成一个DrawCall。UGUI开发时使用的是Sprite对象,其实Sprite对象只是在Texture上又封装的一层数据结构,它记录的是Sprite大小以及九宫格的区域,还有Sprite保存在哪个Atals中。如果很多界面Prefab引用了这个Sprite,它只是通过GUID进行了关联,它并不会影响到已经在Prefab中保存的Sprite,这对后期调整图集和DrawCall方便太多了,这也是UGUI比NGUI更方便的一点。


http://chatgpt.dhexx.cn/article/qh19uTq6.shtml

相关文章

UGUI组件详解

什么是 UGUI UGUI 是 Unity 自带的一套 GUI 系统&#xff0c;含有基本的一些 UI 控件。 UGUI 控件有哪些&#xff1f; 我们常用的有 Canvas&#xff0c;Text&#xff0c;Image&#xff0c;Button&#xff0c;Toggle&#xff0c;Slider&#xff0c;Scroll Bar&#xff0c;Scroll…

【Unity基础】ugui的基础知识篇

文章目录 前言一、常用用可视化控件1、Image2、RawImageI.和Image的区别 3、Text组件4、画布是怎么渲染出可视化UI的&#xff1f;II.这里整理一下渲染相关的关系图&#xff0c;如下: 5、关于画布的布局重构 二、Button交互组件1、Button组件的源码以及使用方式2、Button组件是如…

Unity之UGUI详解

UGUI 文章目录 UGUI六大基础组件概述Canvas对象上依附的&#xff1a;CanvasCanvas ScalerGraphic RaycasterRectTransform EventSystem对象上依附的&#xff1a;EventSystemStandalone Input Module Canvas画布组件Screen Space overlayScreen Space CameraWorld Space CanvasS…

using namespace std

整体认识 —— 解决命名冲突 一个简单的C程序&#xff1a; #include<iostream>using namespace std;int main(int argc,char **argv) {cout<<"hello world !"<<endl;system("pause"); // 让程序暂停,按任意键继续 注意&#xff0c;…

K8S:Namespace详解

Namespace概念 Kubernetes 支持多个虚拟集群&#xff0c;它们底层依赖于同一个物理集群&#xff0c;这些虚拟集群被称为命名空间。 命名空间 namespace 是 k8s 集群级别的资源&#xff0c;可以给不同的用户、租户、环境或项目创建对应的命名空间。 在创建pod的时候可以指定p…

TypeScript中的命名空间—namespace

TypeScript中的命名空间—namespace 什么是命名空间&#xff1f;在很多语言中都有这个概念。 命名空间是为了解决命名冲突。比如你在代码的不同地方&#xff0c;都定义了同名但是不同含义的函数、变量等&#xff0c;虽然不提倡这么做&#xff0c;但是有时候需要这么做。那怎么…

c++ 中的 namespace 用法

多人代码的整合&#xff0c;namespace 还是非常有用的。可以很轻松的避免变量与函数一样的命名 1. namespace 在 单个头文件 中使用 下面给一个简单示例演示命名空间和自定义头文件的使用&#xff0c;代码如下&#xff1a; compare.h&#xff1a; namespace compare{double…

【C++】命名空间(namespace) 以及理解using namespace std

命名空间 1.命名空间使用的背景1.背景 2.命名空间的定义&#xff08;namespace&#xff09;2.1正常的定义2.2 命名空间可以嵌套定义2.3允许命名空间相同 3.域作用限定符&#xff08;&#xff1a;&#xff1a;&#xff09;和命名空间的使用3.1域作用限定符&#xff08;&#xff…

C++ namespace

C namespace 一、什么是 namespace1.1 C语言标识符作用域的缺陷1.2 namespace作用 二、namespace的定义2.1 命名空间可以嵌套定义小技巧 2.2 可定义重复的命名空间 三、namespace使用3.1 命名空间名称作用域限定符使用3.2 using将命名空间中成员引入3.3 使用using namespace将整…

Linux namespace概述

操作系统通过虚拟内存技术&#xff0c;使得每个用户进程都认为自己拥有所有的物理内存&#xff0c;这是操作系统对内存的虚拟化。操作系统通过分时调度系统&#xff0c;每个进程都能被【公平地】调度执行&#xff0c;即每个进程都能获取到CPU&#xff0c;使得每个进程都认为自己…

C++ | 你真的了解namespace吗?

文章目录 一、前言二、命名冲突三、命名空间1、域作用限定符2、命名空间的概念&#x1f449;示例1&#x1f449;示例2 3、命名空间的定义4、命名空间的使用① 指定命名空间访问【做项目】② 使用using部分展开【做项目】③ 使用using namespace全局展开【日常练习】 5、小结 解…

using namespace std 介绍

using namespace std&#xff1b; 首先我们要知道&#xff0c;这句代码的意思是&#xff1a;打开标准命名空间&#xff0c;即告诉编辑器我们将要使用名字空间std中的函数或者对象。 using 意思就是正在使用的意思。namespace 的引用是为了解决不同space中命名相同导致命名冲突…

Namespace基本知识

published: true tags: C author: persuez Namespace C中namespace简单来说就是用来控制标志符&#xff08;如变量&#xff0c;函数&#xff0c;类等&#xff09;的名字冲突的。 简单术语 declarative region: 指标志符声明的区域。具体见图一。 potential scope: 指从该标…

【C++】命名空间namespace详解

一、命名空间的引入 C中&#xff0c;名称(name)可以是符号常量、变量、宏、函数、结构体、枚举、类和对象等等。而在大型工程中&#xff0c;难免会有重名的现象&#xff0c;命名空间namespace&#xff0c;就是C引入的一种解决名称冲突的机制 1.1 如何解决命名冲突 C语言中 …

namespace介绍

命名空间 命名空间 namespace 1、::作用域运算符&#xff08;表明 数据、方法 的归属性问题&#xff09; 2、命名空间 namespace 解决命名冲突 2.1&#xff1a;namespace命名空间的定义 2.2:命名空间只能全局范围内定义&#xff08;以下错误写法&#xff09; 2.3:命名空间可嵌套…

【C++】命名空间(namespace)详解

一、为什么使用命名空间 考虑一种情况&#xff0c;当我们有两个同名的人&#xff0c;Zara&#xff0c;在同一个班里。当我们需要对它们进行区分我们必须使用一些额外的信息和它们的名字&#xff0c;比如这个区域&#xff0c;如果它们生活在不同的区域或者它们的母亲或父亲的名…

Python中,线程threading详解

Python中最常用的同步有&#xff1a;锁/互斥&#xff0c;以及信号量。其中锁是最简单最低级的机制&#xff0c;信号量用于多线程竞争有限资源的情况。但是锁被释放&#xff0c;线程不一定被释放。 threading.Lock同步锁&#xff08;原语锁&#xff09; 通常使用获得锁&#xff…

【Python】多线程及threading模块介绍

​目录 1. 多线程简单介绍 2. threading模块介绍 2.1 threading模块常用方法 2.2 Thread类使用 2.2.1 使用构造函数传递可调用对象的方法创建线程 2.2.2 继承threading.Thread类 3. 多线程程序中使用(共享)全局变量 4. 多线程共享全局变量遇到的问题 5. 线程同步 5.1 …

threading库:Python线程的基础知识

目录 前言Thread对象区分线程守护线程自定义线程定时器线程线程间传送信号 前言 前面的subprocess库主要讲解的是进程知识与进程间的交互。而进程有可以拥有多个线程&#xff0c;所以threading库提供了管理多个线程执行的API&#xff0c;允许程序在同一个进程空间并发地运行多…

Python Threading 线程模块用法

一、什么是 Threading Threading用于提供线程相关的操作&#xff0c;线程是应用程序中工作的最小单元。python当前版本的多线程库没有实现优先级、线程组&#xff0c;线程也不能被停止、暂停、恢复、中断。 1.1、线程池图解 二、创建线程 导入模块threading&#xff0c;通过…