UGUI 一口气全说完
UGUI 基础使用
常用的组件有 Button、Image、Text、TMP_Text、Canvas、CanvasGroup、
常用的布局组件有 Horizontal Layout Group、Vertical Layout Group、Grid Layout Group 几种。
具体使用方案参照文档官方即可。
UGUI 自适应
Canvas 设置
Canvas Scaler:控制 UI 随屏幕分辨率缩放。
UI Scale Mode(缩放模式):
- Constant Pixel Size(像素不变):UI 元素尺寸固定,不随分辨率变化,可能在高分辨率下看起来小。
- Scale With Screen Size(随屏幕尺寸缩放):常用模式,可根据分辨率自动缩放 UI。
- Reference Resolution(参考分辨率):设计 UI 时的目标分辨率(如 1920×1080)。
Screen Match Mode:
- Match Width Or Height:宽高比例匹配,可以通过 Match 参数控制是更倾向匹配宽度还是高度(0 = 宽度, 1 = 高度)。
- Expand:总是填满屏幕,可能会拉伸。
- Shrink:总是适应屏幕,但可能留空边。
- Constant Physical Size(物理尺寸不变):适用于固定尺寸的物理显示器或 VR 场景。
RectTransform
Anchor(锚点):决定 UI 相对父物体的定位方式。
固定四边锚点 → 拉伸适应屏幕尺寸
中心锚点 → UI 居中或固定比例
Pivot(中心点):旋转/缩放时的参考点。
Layout Group
Horizontal/Vertical/Grid Layout Group:自动排列子元素。
Content Size Fitter:根据内容自动调整尺寸。
UI 管理器代码实践方案
基础的 GameUI
UI 管理器通常需要解决以下几个问题:
- 简化 UI 界面的加载逻辑:使用管理器统一 UI 加载,初始化与显示,不要让 UI 相关的代码在其他业务代码中占据过多行数。
- 统一 UI 的接口:每个界面的打开和关闭都需要去处理一系列的业务逻辑,UI 管理器需要将这些业务逻辑封装好,方便后续的开发与维护。
- 控制 UI 的显示层级:避免 UI 之间的随意相互遮挡。
笔者目前使用的一套 UI 管理方案如下所示,核心组件是 BasePanel 和 GameUI。
将挂载了 GameUI 的 Canvas 预制体与挂载了 BasePanel 子类的 Panel 预制体放在事先制定好的位置,需要的时候按需加载。
classDiagram
class BasePanel {
+ ShowPanel(callback: UnityAction) BasePanel
+ HidePanel(callback: UnityAction) BasePanel
+ ClosePanel(callback: UnityAction) BasePanel
+ SetOrder(order: int) BasePanel
+ GetOrder() int
}
class GameUI {
- UI_PANEL_PATH: string
- Instance: GameUI
- PanelDict: Dictionary~string, BasePanel~
- CheckInit() void
+ GetPanel~T>(callback: UnityAction) BasePanel
+ PreloadPanel~T~(callback: UnityAction) BasePanel
+ ShowPanel~T~(callback: UnityAction) BasePanel
+ HidePanel~T~(callback: UnityAction) BasePanel
+ ClosePanel~T~(callb ack: UnityAction) BasePanel
+ RegisterPanel~T~(panel: T) void
+ UnregisterPanel~T~(panel: T) void
}
GameUI -- BasePanel
1[RequireComponent(typeof(Canvas))]
2[RequireComponent(typeof(CanvasGroup))]
3[RequireComponent(typeof(GraphicRaycaster))]
4public class BasePanel : MonoBehaviour
5{
6 protected Canvas _canvas;
7 protected CanvasGroup _canvasGroup;
8
9 protected virtual void Awake()
10 {
11 _canvas = GetComponent<Canvas>();
12 _canvasGroup = GetComponent<CanvasGroup>();
13 }
14
15 public virtual void ShowPanel(UnityAction callback = null)
16 {
17 GameUI.RegisterPanel(this);
18 gameObject.SetActive(true);
19 callback?.Invoke();
20 }
21
22 public virtual void HidePanel(UnityAction callback = null)
23 {
24 gameObject.SetActive(false);
25 callback?.Invoke();
26 }
27
28 public virtual void ClosePanel(UnityAction callback = null)
29 {
30 GameUI.UnregisterPanel(this);
31 Destroy(gameObject);
32 callback?.Invoke();
33 }
34
35 public virtual void SetOrder(int order, UnityAction callback = null)
36 {
37 _canvas.sortingOrder = order;
38 callback?.Invoke();
39 }
40
41 public virtual int GetOrder()
42 {
43 return _canvas.sortingOrder;
44 }
45}
46
47public class GameUI : NeoBehaviour
48{
49 private static readonly string GAMEUI_PATH = "UI/GameUI";
50 private static readonly string UI_PANEL_PATH = "UI/Panel/";
51
52 private static GameUI _instance;
53 public static GameUI Instance
54 {
55 get
56 {
57 CheckInit();
58
59 return _instance;
60 }
61 }
62
63 private static void CheckInit()
64 {
65 if (_instance != null) return;
66 var prefab = Resources.Load<GameObject>(GAMEUI_PATH);
67 if (prefab == null)
68 {
69 Debug.LogError($"[GameUI] Prefab not found at path: {GAMEUI_PATH}");
70 }
71
72 var go = Instantiate(prefab);
73 _instance = go.GetComponent<GameUI>();
74
75 if (_instance == null)
76 {
77 Debug.LogError("[GameUI] GameUI component not found on the instantiated prefab!");
78 }
79
80 DontDestroyOnLoad(go);
81 }
82
83 protected override void InitComponent()
84 {
85 base.InitComponent();
86
87 if (_instance == null)
88 {
89 _instance = this;
90 }
91 else if (_instance != this)
92 {
93 Debug.LogWarning("[GameUI] Another instance of GameUI already exists. Destroying this one.");
94 Destroy(gameObject);
95 }
96 }
97
98 private Dictionary<string, BasePanel> _panelDict = new();
99
100 private BasePanel SelfGetPanel<T>(UnityAction callback = null) where T : BasePanel
101 {
102 CheckInit();
103
104 var typeName = typeof(T).Name;
105 if (_panelDict.TryGetValue(typeName, out var panel))
106 {
107 callback?.Invoke();
108 return panel;
109 }
110
111 return null;
112 }
113
114 private BasePanel SelfShowPanel<T>(UnityAction callback = null) where T : BasePanel
115 {
116 CheckInit();
117 var typeName = typeof(T).Name;
118
119 if (_panelDict.TryGetValue(typeName, out var panel))
120 {
121 panel.ShowPanel(callback);
122 return panel;
123 }
124 else
125 {
126 var prefab = Resources.Load<GameObject>(UI_PANEL_PATH + typeName);
127 var tmpPanel = Instantiate(prefab, transform).GetComponent<T>();
128 tmpPanel.ShowPanel(callback);
129 }
130
131 return null;
132 }
133
134 private BasePanel SelfHidePanel<T>(UnityAction callback = null) where T : BasePanel
135 {
136 CheckInit();
137 var typeName = typeof(T).Name;
138 if (_panelDict.TryGetValue(typeName, out var panel))
139 {
140 panel.HidePanel(callback);
141 }
142
143 return null;
144 }
145
146 private BasePanel SelfClosePanel<T>(UnityAction callback = null) where T : BasePanel
147 {
148 CheckInit();
149 var typeName = typeof(T).Name;
150 if (_panelDict.TryGetValue(typeName, out var panel))
151 {
152 panel.ClosePanel(callback);
153 }
154
155 return null;
156 }
157
158 internal BasePanel SelfRegisterPanel<T>(T panel) where T : BasePanel
159 {
160 var typeName = typeof(T).Name;
161 if (!_panelDict.ContainsKey(typeName))
162 {
163 _panelDict[typeName] = panel;
164 return panel;
165 }
166 else
167 {
168 Debug.LogWarning($"[GameUI] Panel of type {typeName} is already registered.");
169 }
170
171 return null;
172 }
173
174 internal BasePanel SelfUnregisterPanel<T>(T panel) where T : BasePanel
175 {
176 var typeName = typeof(T).Name;
177 if (_panelDict.ContainsKey(typeName))
178 {
179 _panelDict.Remove(typeName);
180 return panel;
181 }
182 else
183 {
184 Debug.LogWarning($"[GameUI] Panel of type {typeName} is not registered.");
185 }
186
187 return null;
188 }
189
190 public static BasePanel GetPanel<T>(UnityAction callback = null) where T : BasePanel => Instance.SelfGetPanel<T>(callback);
191 public static BasePanel ShowPanel<T>(UnityAction callback = null) where T : BasePanel => Instance.SelfShowPanel<T>(callback);
192 public static BasePanel HidePanel<T>(UnityAction callback = null) where T : BasePanel => Instance.SelfHidePanel<T>(callback);
193 public static BasePanel ClosePanel<T>(UnityAction callback = null) where T : BasePanel => Instance.SelfClosePanel<T>(callback);
194 public static BasePanel RegisterPanel<T>(T panel) where T : BasePanel => Instance.SelfRegisterPanel(panel);
195 public static BasePanel UnregisterPanel<T>(T panel) where T : BasePanel => Instance.SelfUnregisterPanel(panel);
196}
由于笔者还没有在中大厂干过活,因此这是笔者在制作小游戏的时候逐渐摸索出来的一个比较灵活,适用范围比较广的一个解决方案,目前为止没有遇到什么问题。
无论是打开面板后的回调,还是针对 Panel 的一些其他操作进行拓展,都可以通过继承 BasePanel 后经过拓展简单的实现功能。
1GameUI.ShowPanel<MainMenuPanel>()
2 .SetOrder(10)
3 .SetContent("这是一段测试文本")
4 .SetAgreeBtn(() => {
5 GameUI.HidePanel();
6 });
设计缺陷与 MVC 架构方案
UI 经常需要显示游戏中的数据,例如玩家的金币数量,钻石数量,皮肤立绘。这些数据经常发生更改,并且有实时更新显示的需求。
我们能够想到的最简单的方法,就是在数据变更的时候,顺便在代码里去更改一下这些组件的值。
如下代码所示,我们在更改 Money 的值的时候,在代码中顺便同步更改主界面里 MoneyText 的值。
1public class SamplePanel : MonoBehaviour
2{
3 public Text _mainMenuMoneyText;
4 public int Money;
5
6 public void AddMoney(int count)
7 {
8 Money = Money + count;
9 _mainMenuMoneyText.text = Money.ToString();
10 }
11}
这样的做法看起来没有什么问题,但是当项目越来越大,需要修改的 MoneyText 越来越多时,就容易出现问题。
比如我们现在有十个 UI 面板,这些 UI 面板中都有 MoneyText。
如果我们需要将这十个 Text 组件都拖动到脚本中进行统一更改,那么代码结构将变得无比臃肿和丑陋。
1public class SamplePanel : MonoBehaviour
2{
3 public Text _mainMenuMoneyText;
4 public Text _configMoneyText;
5 // .....
6 public Text _storeMoneyText;
7 public Text _characterMoneyText;
8 public Text _buildMoneyText;
9 public Text _gameMoneyText;
10 public Text _packageMonetText;
11
12 public int Money;
13
14 public void AddMoney(int count)
15 {
16 Money = Money + count;
17 _mainMenuMoneyText.text = Money.ToString();
18 _configMoneyText.text = Money.ToString();
19 // ....
20 _storeMoneyText.text = Money.ToString();
21 _characterMoneyText.text = Money.ToString();
22 _buildMoneyText.text = Money.ToString();
23 _gameMoneyText.text = Money.ToString();
24 _packageMonetText.text = Money.ToString();
25 }
26}
在上面的演示代码中,笔者还非常克制的将所有的 Text 组件更改放在一个统一的方法中,看似还比较有维护性。
但是在实际的开发过程中笔者见过不止一个程序员对于这种需求,并不会将 Money 的数据更改封装成方法进行操作,而是在任何的地方修改 Money,然后将这些 Text 的脚本在修改处复制粘贴一遍。
代码引用混换不好维护是这样写法的一个显而易见的弊病,另一个弊病要更加隐蔽,需要项目到达某个体量之后才会显现出来,这个弊病就是不方便对 UI 进行拆分。
由于游戏对象和游戏对象之间使用了拖拽引用,因此这些互相依赖的对象往往需要在游戏一开始的时候就一直存在在游戏里面,一旦被销毁,或者是重新实例化,这些组件互相之间的引用就会丢失。
例如在上面的代码中,所有的 UI 面板都需要事先放在游戏里,不然管理器对这些 Text 组件的更改就会报空。
然而,将所有 UI 都放在场景里是一个非常不好的习惯,尤其是在稍微有一些规模,需要多人协作的项目。
在团队协作时,一旦有两个开发者对场景文件做了改动(可能是在某个时候不小心修改了 游戏物体的位置),Scene 文件就容易出现合并时的文件冲突。
这里需要说明,Unity 的 Scene 文件和 meta 文件使用的都是 Unity 自己拓展的 Yaml 格式文件,这些配置文件虽然以文本的形式存储,但对于大多数开发者来说其实是不可读的,因此虽然开发者能看到文件中的冲突内容,但你实际上不知道哪一处的 Yaml 更改对应着场景中新增或删除的哪个对象。
因此,如果团队需要多个 UI 程序开发 UI,最好是将他们正在制作的 UI 以预置体的形式从场景中拆分出来。
在明确了 UI 需要拆分后,如何让 UI 能够在不需要被游戏管理器拿着自己组件引用的情况下更新显示,就是 MVC 所需要解决的问题了。
MVC 的架构如下图所示。用户在 View(视图)中输入数据给 Controller(控制器),Controller 将输入的数据传递给 Model(数据管理器),Model 自己的数据发生更改后,发送数据更改事件通知 View,并让 View 对数据进行更新。
flowchart LR
View -->|Input Data| Controller
Controller -->|Update Data| Model
Model -.->|Data Changed Event| View
使用 MVC 的好处如下所示:
- Controller 自身不再需要同时管理业务,数据和界面,只需要安心的处理 View 传递过来的数据,经过业务代码的处理后交给 Model 即可,让自己从复杂性中挣脱了出来。
- View 不再需要和业务代码捆绑,安心的处理表现效果即可。
- Model 层的数据从 Controller 中抽出来单独存在,让关心该数据的其他 Controller 或者 System 更方便的获取和检索数据,而不需要获取整个 Controller,在一堆复杂的业务代码中找到数据相关的接口。
简单来说,MVC 所做的就是让 数据的归数据,展示的归展示,业务的归业务。
MVC 与 MVP 与 具体实现
我们在之前提到,MVC 是一个解决数据耦合的常用思路。但是在 Unity 开发过程中,我们其实没有办法直接在代码中套用 MVC 的思路。
这是因为在 Unity 中,我们这里的 View 层实际上是 UGUI 组件,组件没有办法接收 Model 层的数据更改事件,然后自动去获取对应 Model 里最新的数据数据。
因此,事件的获取与 View 视图的更新还是需要 Controller 来做。于是,MVC 的架构模型就转变成的更像是其变体 MVP 的样子。
当然,为了表达上的方便,MVC、MVP 和其他的一些变体有时候都会被叫成是 MVC,在这里,MVC 代表的是数据和表现分离的一种思维模式。
flowchart LR
View -->|Input Data| Presenter
Presenter -->|Update Data| Model
Model -.->|Data Changed Event| Presenter
Presenter -->|Update View| View
在具体的实现中,MVP 风格的代码如下所示。
1public class SamplePanel : MonoBehaviout
2{
3 private Text _moneyText;
4 private Button _addMoneyBtn;
5
6 private void Awake()
7 {
8 _moneyText = transform.Find("MoneyText").GetComponent<Text>();
9 _addMoneyBtn = transform.Find("AddMoneyBtn").GetComponent<Button>();
10 }
11
12 private void Start()
13 {
14 _addMoneyBtn.onClick.AddListener(() =>
15 {
16 XxxModel.Money += 100;
17 });
18 }
19
20 private void OnEnable()
21 {
22 // 在组件激活时开始监听 Model 中的数据更改
23 XxxModel.DataChanged.AddListener(UpdateView)
24
25 UpdateView();
26 }
27
28 private void OnDisable()
29 {
30 // 在组件失火时取消对 Model 数据更改的监听
31 XxxModel.DataChanged.RemoveListener(UpdateView);
32 }
33
34 private void UpdateView()
35 {
36 // 从 Model 中获取数据对组件的内容进行更改
37 _moneyText.text = XxxModel.Money.ToString();
38 }
39}
40
41public class XxxModel
42{
43 private UnityEvent DataChanged = new();
44
45 private int _money;
46 public int Money
47 {
48 get => _money;
49 set
50 {
51 _money = value;
52 // 在数据发生更改时,调用数据更改事件进行通知
53 DataChanged?.Invoke();
54 }
55 }
56}
用 UML 图来表示,则如下所示:
flowchart LR
UGUI -->|点击事件| SamplePanel
SamplePanel -->|更新数据| XxxModel
XxxModel -.->|数据更改事件| SamplePanel
SamplePanel -->|更新界面| UGUI
以上的代码只是一个用于说明 MVP 架构的简单示例,如果直接用在生产环境中会有许许多多的问题。在实际的项目开发中一般会使用比较方便的第三方框架(例如 QFramework)来帮助我们实现例如事件通知、事件订阅、Model 创建与初始化等内容。
UGUI 自适应问题
游戏中的坐标系有四种:本地坐标、世界坐标、屏幕坐标、视口坐标
摄像机的模式有三种:
Overlay:最简单的模式
ScreenSpace:如果 UI 需要跟着摄像机移动,可以使用
WorldSpace:不会跟着摄像机一起动,在人物上可以用来做血条
世界坐标、屏幕坐标、视口坐标相互转换
Camera.main.WorldToScreenPoint(worldPos)
:世界坐标转屏幕坐标
Camera.main.ScreenToWorldPoint(screenPos)
:屏幕坐标转世界坐标
Camera.main.ScreenToViewportPoint(screenPos)
:屏幕坐标转视口坐标
Camera.main.ViewporToScreenPoint(viewportPos)
:视口坐标转屏幕坐标
在以上四个方法的基础上,还有世界坐标转视口坐标,视口坐标转世界坐标等等,在这里就不详细说明了,更具体的可以阅读 Unity 文档中关于 Camera 类的说明。
Question:视口坐标是什么,和屏幕坐标有什么不同
视口坐标就是将屏幕像素的值,转化为 0 到 1 之间的值。
例如在 1920 x 1080 屏幕上,如果我们用屏幕坐标表述屏幕中心,则其值为 (960, 540)。如果我们用视口坐标来表述中心,则其值为 (0.5, 0.5)。
RectTransform 与 Position
有时候,我们希望去直接修改 UI 物体的 position
来实现精确控制,于是我们会写出如下代码。
1autoSizeTextBox.transform.position = screenPos
但这样进行计算,TextBox 并不会显示在 ScreenPos
的位置,因为 Text Box 是一个基于摄像机位置的物体,Inspector 面板中显示的只是 localPosition
,而不是 position
,因此如果我们修改物体的 position
,localPosition
就会变成某个极大或者极小值。
但我们即使修改物体的 localPosition
,会发现物体的位置显示还是不对。这是因为 localPosition
是相对于摄像机的位置,也就是下图一所示,而屏幕空间的值则如下图二所示。
图1:物体 localPosition 相对于摄像机的位置 | 图2:屏幕空间中 ScreenPos 的值分布 |
图2:屏幕空间中 ScreenPos 的值分布
因此,如果我们直接赋值屏幕坐标,那么被赋值的 UI 物体只会出现在屏幕的第一象限位置(大多时候显示的范围会超出屏幕的显示范围)。
如果想要修改 localPosition
来移动 UI,需要将 ScreenPos 减去屏幕的一半,即 (Screen.width * 0.5f, Screen.height * 0.5f)
UGUI 基础特效
动画无非是 透明度、缩放、位移、旋转、动态时间 等基本操作的组合。将动画的元素拆分出基本元素,加在一起即可。
制作 UI 特效,游戏开发中常用的开发技术如下所示。
- DoTween:补间动画插件能够方便开发者在代码中精细的控制 UI 的显示。
- Animator / Animation:和 DoTween 的功能类似,方便用可视化的方式实现 UI 的控制。
- Timeline:配合 Animation 实现一系列动画的连播
- UI 粒子系统:将 Canvas 的 Render Mode 设置为 Camera 或 World,然后在 UI 该画布下创建粒子特效
- Spine:2D 骨骼动画工具可以创建更简单的创建更加复杂,效果更好的 UI 动画。不过一般是美术负责处理,作为程序不需要纠结。
- Shader:任何与视觉相关的技术,如果想要做到极致,都往往会使用到 Shader。不过在成为高级程序之前不怎么需要学习。
在制作抽卡动画的时候,Animation 可以更简单的控制 UI 的移动、旋转、缩放,让 UI 元素更具有动感。在制作界面的淡入淡出、以及一些小的 UI 动画时,DoTween 的代码控制会更加简单、方便、节约性能,Timeline 可以将所有的动画进行串联。
UI 粒子系统可以实现例如 UI 里的落叶飘散、雪花等特效。
Spine 可以用来制作抽卡的开盒动画,以及更加灵动的 2D 角色。
Shader 可以做非常非常多的事情,例如 UI 的边缘流光和扫光,背景的水波纹理等等。
一些更复杂的动画,还可以配合 AE 制作一些带有透明度的视频动画,使用 Animation 或 Timeline 将视频融合到游戏中,配合其他的组件实现复杂效果
亮边:概括结构和轮廓,视觉效果
体积光:概括元素结构与轮廓,视觉效果
流光:高级的视觉细节
扩散:能量爆发的结构氛围
光晕:氛围光源,必不可少
粒子:点线面中不可或缺的细节
科技风的设计语言:网格、能量、线条、图形
朋克风的设计语言:机械、齿轮、灯泡(闪光)、昏暗、干扰、线条图形、电流
Q版设计语言:弹力、暗魔法、手绘、动漫
DoTween
DoTween 是 Unity 中非常流行的一个动画插件,用于实现各种平滑动画(如移动、缩放、旋转、颜色变换等)。它的核心思想是通过代码控制对象的属性(如位置、颜色等)在一段时间内平滑地从一个值过渡到另一个值。
在 UGUI 中,常见 UGUI 元素有 Button
、Image
、Text
、Panel
等。使用 DoTween 可以轻松实现这些元素的动态效果,比如:
- 按钮点击时弹跳(缩放动画)
- 面板淡入淡出(透明度动画)
- 图片颜色变换(色彩动画)
- UI 元素移动(位移动画)
DoTween 基础
DoTween 提供的方法有三个固定的前缀,分别是 Do
、Set
和 On
Do
前缀的方法用于创建 Tween(补间动画),让某个属性在一段时间内平滑变化。
DOMove
:位置动画DOScale
:缩放动画DOFade
:透明度动画(如 CanvasGroup、Image)DOColor
:颜色动画DORotate
:旋转动画
Set
前缀用于设置 Tween 的属性或状态,比如动画的缓动方式、循环次数等。
SetEase
:设置缓动曲线(如弹跳、匀速)SetLoops
:设置循环次数和类型SetDelay
:设置延迟SetId
:设置 Tween 的唯一标识
On
前缀用于设置回调函数,在 Tween 的某些阶段触发特定操作。
OnComplete
:动画结束时执行OnStart
:动画开始时执行OnUpdate
:动画更新时执行
Timeline
粒子系统
游戏特效层级
UGUI 和 非 UI 物体如果需要组合使用,往往容易出现层级问题。UGUI 默认的显示层级会将非 UI 物体的层级覆盖掉。
在这种时候,我们有两种方案去处理层级问题。
第一种是直接调整非 UI 物体的材质渲染层级,将其调整到 3001。但是这个方法有一个问题是一些 Shader 会默认将层级调整回3000。
第二种是使用 Sorting Group,在这里调整 Order in Layer
将渲染层级调到比 Canvas 的层级高的数值。
网格重建与合批
在 UGUI 中,Button 的图片和 Button 中的文字不是同一批渲染的。
UI 中的网格特效,很多都是继承自 BaseMeshEffect
,然后改动网格实现。
1// 用于记录顶点数据的 Helper
2private static readonly VertexHelper s_VertexHelper
3
4// 用来应用顶点数据的 Mesh
5protected static Mesh s_Mesh
修改 Helper 然后传递给 Mesh。
Rebuild:重建网格,单个 UI 发生变化后进行的操作。
Batching:网格合并/合批,所有存在变化的 UI 进行完各自的 Rebuild 后 Canvas 进行的操作。
当 Helper 的数据发生更改后,DoMeshGeneration()
将被调用。
DoMeshGeneration()
会先使用 DoPopulateMesh()
得到一个基础的信息,也就是 RectTransform 中的那些长宽高顶点数据。
拿到数据后,会调用当前 UI 挂载的所有 IMeshModifier()
然后调用。像是 Outline 和 Shadow 组件,就是依赖该机制实现。
调用完成后,数据将填充到网格中并传递给 Canvas Renderer。
1s_VertexHelper.FillMesh()
2canvasRenderer.SetMesh()
关于
IMeshModifier
机制比较好玩的一点是,如果你同时使用 Outline 和 Shadow,你会发现你的阴影上也有 Outline。这是因为 Outline 修改的数据是基于 Shadow 调用
IMeshModifier
之后的数据。
DoMeshCeneration()
在 UpdateGeometry()
中调用
UpdateGeometry()
在 Rebuild()
中调用
Rebuild 中,如果顶点数据变了就更新网格,如果材质变了就更新材质。
flowchart TD
Rebuild --> UpdateGeometry & UpdateMaterial
UpdateGeometry --> DoMeshGeneration
CanvasUpdateRegistory
中会管理 CanvasUpdateCycle
。
CanvasUpdateRegistory
中包含两个队列,m_LayoutRebuildQueue
和 m_GraphicRebuildQueue
,这两个队列都需要实现 ICanvasElement
接口成员,该接口中包含了 Rebuild 方法。
CanvasUpdateRegistory
在初始化的时候,会将自身的 PerformUpdate()
注册到 Canvas.willRenderCanvas
中。
CanvasUpdateRegistory.PerformUpdate()
会根据渲染的更新队列逐个更新每个 UI 网格。
每轮循环完成后,m_GraphicRebuildQueue
就会被 Clear,只有发生相关变动的 UI 才会被注册进队列,并在更新后被移出队列。
网格合并的内容是 Unity 公司的机密,被封装在 Canvas.dll 中无法查看,不过我们可以确定每个 Canvas 是单独执行 Batching,且是按照先后顺序进行渲染。
如果需要使用 CPU 动画,最好给这个对象添加一个单独的 Canvas,这样就可以避免顶点变脏触发整个 UI 的重建。
UI 在被 Disable 后就会从 CanvasUpdateRegistory
中移除注册,不会影响性能。所以,如果场景中只有少量的 UI 对象在激活,特效方面就不会有太大的压力。
Q:UI 什么时候会被注册到 Rebuild 队列中?
A:更新顶点数据和材质、层级关系发生变化、大小变化、CanvasRenderer 的 Cull 模式发生变化….
Button 中的文字和普通的文字是没有区别的,只是有渲染顺序的问题。
Overlay 模式下的渲染顺序不止取决于层级顺序,还取决于材质和纹理,也可以简单的理解为渲染顺序与合批的合理性兼容。
有时候你不改变层级,只是调整 UI 的位置,合批的方式都会发生变化。
UGUI 的网格修改
当我们有一些更复杂的 UI 需求时,我们可以自己修改网格来实现复杂的异形 UI。
我们需要继承 UGUI 的 Graphic 基类,然后重写 OnPopulateMesh 方法。
OnPopulateMesh
是一个在Unity用户界面(UI)系统中用于生成自定义UI元素网格数据的方法。
当UI元素的顶点或纹理发生变化时,OnPopulateMesh
方法会被调用。这是因为 CanvasRender
组件在准备渲染 Canvas 时,会调用每个 UI 元素的 OnPopulateMesh
方法来更新其网格数据。
OnPopulateMesh
接受一个 VertexHelper 类型的参数,该类是一个顶点辅助类,用于存储和管理生成Mesh所需的基本信息。通过操作 VertexHelper,可以添加顶点、清除顶点信息或构建三角形面片等。
在自定义UI元素时,可以通过继承 Graphic 类并重写 OnPopulateMesh
方法来实现特定的绘制效果。这样可以在不改变原有渲染流程的情况下,添加额外的绘制逻辑或者修改 UI 元素的视觉效果。
例如下面的示例,通过重写 OnPopulateMesh 方法,我们可以不通过使用 Mask 来让 UGUI 绘制出一个三角形。
1using UnityEngine;
2using UnityEngine.UI;
3
4public class Triangle : Graphic
5{
6 protected override void OnPopulateMesh(VertexHelper vh)
7 {
8 // 清空顶点信息
9 vh.Clear();
10
11 // 获取RectTransform的宽高
12 float width = rectTransform.rect.width;
13 float height = rectTransform.rect.height;
14
15 // 以中心为原点
16 float halfWidth = width / 2;
17 float halfHeight = height / 2;
18
19 // 顶部中心
20 vh.AddVert(new Vector3(0, halfHeight), color, new Vector2(0.5f, 1));
21 // 左下角
22 vh.AddVert(new Vector3(-halfWidth, -halfHeight), color, new Vector2(0, 0));
23 // 右下角
24 vh.AddVert(new Vector3(halfWidth, -halfHeight), color, new Vector2(1, 0));
25
26 // 将三个顶点连接成一个三角形
27 vh.AddTriangle(0, 1, 2);
28 }
29}
控制 Image 形状的 N 种方式
透明贴图、使用内置的 Mask、Shader、修改网格、自定义屏幕空间 Mask
Q:为什么内置的 Mask 会出现锯齿
A:因为内置的 Mask 会写入模板缓冲中,顺带进行自身的绘制。但模板测试是逐像素的,要么通过要么不通过,这会导致 Mask 的图像会产生以像素为单位的锯齿
什么时候使用 Shader
使用 Shader 会影响图集的渲染,增加 Draw Call,因此只在需要额外效果的时候才使用,比如溶解、发光。
修改形状并不是额外的效果,因此不需要使用 Shader。
如何让 UI 更有层次感
真实的软阴影,可以使用 True Shadow 插件
模拟透视视角,例如明日方舟
背景模糊,可以使用 Translucent Image
Graphic
负责UI显示的主要就是Image、RawImage、Text这几个类,这几个类继承MaskableGraphic而MaskableGraphic继承Graphic。Graphic是负责UI显示中最重要的类。
UGUI 优化
网格重建优化策略:
- 使用尽可能少的 UI 元素:在制作 UI 时,一定要仔细检查 UI 层级,删除不必要的 UI 元素,这样可以减少深度排序的时间以及 Rebuild 的时间。
- 减少 Rebuild 频率:将动态 UI 元素(频繁改变例如顶点、alpha、坐标和大小等的元素)与静态 UI 元素分离出来,放到特定的 Canvas 中。
- 谨慎使用 UI 元素的 active 操作,因为会触发 Rebuild
- 谨慎使用 Canvas 的 Pixel Perfect 选项:该选项的开启会导致 UI 元素在发生位移时,其长宽会为了对齐像素造成 Layout Rebuild。
- 谨慎使用 Animator:动画组件每一帧都会改变元素,即使动画中的数值没有变化。因为 Animator 组件没有空指令检查。对于仅响应事件的元素可以自行编写代码或者使用第三方补间插件。
- 谨慎使用 Tiled 类型的 Image,因为 Tiled 模式会增加额外的网格分割与顶点数。普通的 Simple 模式只需要四个顶点绘制一个矩形,而 Tiled 模式会根据图片的平铺次数来切割网格,每一次平铺都会增加新的顶点和三角形。
屏幕填充率优化策略:
- 禁用不可见的面板:如果一个面板可以完全挡住另一个面板,则可以将遮挡住的系统面板禁用。
- 不要使用空的 Image 做按键响应:Unity 中 Raycast 使用 Graphic 作为基本元素来检测 Touch。如果使用空的 Image 会产生不必要的 Overdraw。我们可以实现一个只在逻辑上响应 Raycast 但是不参加绘制的组件避免这部分开销。
- Image Fill Center:在 Image Type 选项为 Sliced 的情况下,不需要勾选 Fill Center。