我们在UGUI事件系统 中介绍过一种UI挖孔方法,是通过直接修改Image的Shader实现的,而且透射遮挡UI也是通过一种使用EventSystem直接向下传递的方法。

在这个教程中,使用另外一种挖孔方法,就是直接修改UI的Mesh,因为UI的Mesh是遮挡Raycast的关键,而Mesh中的挖孔是不会遮挡Raycast的,而且我们还可以通过MonoBehaviour继承ICanvasRaycastFilter接口来让已经挖孔的UIMesh依然全面遮挡Raycast。

HighlightTool

我们首先自定义一个挖孔Mesh的UIImage组件,在Guide——Tools文件夹中新建HighlightTool文件,这个文件继承UGUI的一个基础类,也就是Graphic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class HighlightModule : Graphic, ICanvasRaycastFilter
{
/// <summary>
/// 当CanClick设为true时,IsInValidRange属性才会生效, 表示只有高亮区域内部的UI才能被点击
/// </summary>
public bool CanClick { get; set; }
private readonly Vector2[] _outer = new Vector2[4];
private readonly Vector2[] _inner = new Vector2[4];
public RectTransform[] TargetUI { get; private set; }
public Camera CurrentCamera { get; private set; }

/// <summary>
/// 当前HighlightMesh是否需要显示
/// </summary>
public bool IsValid { get; private set; }
/// <summary>
/// 目前screenpoint是否在有效的点击区域内
/// </summary>
public bool IsInValidRange { get; private set; }

private Vector2 _targetLocalPos;
private Vector3 _lastTargetPos;//这个是世界坐标,在这里也可以理解为屏幕坐标
private Vector2 _lastTargetSize;
private Vector3 _curTargetPos;//这个是世界坐标,在这里也可以理解为屏幕坐标
private Vector2 _curTargetSize;
float _leftX, _rightX, _bottomY, _topY;

private Button _selfButton;
public Button SelfButton
{
get
{
if (_selfButton == null)
{
if (!TryGetComponent(out _selfButton))
{
_selfButton = gameObject.AddComponent<Button>();
}
}
return _selfButton;
}
}
public void Init(RectTransform targetUI, Camera currentCamera, System.Action clickAction)
{
Init(new RectTransform[] {targetUI},currentCamera, clickAction);
}
/// <summary>
/// 如果需要将多个UI的Rect的合并高亮,需要传入一个RectTransform数组
/// </summary>
/// <param name="targetUIs">需要高亮的多个UIRect</param>
/// <param name="currentCamera">当前的UI摄像机</param>
/// <param name="clickAction">点击后的回调</param>
public void Init(RectTransform[] targetUIs, Camera currentCamera, System.Action clickAction)
{
TargetUI = targetUIs;
CurrentCamera = currentCamera;
color = new Color32(0, 0, 0, 190);
SelfButton.onClick.RemoveAllListeners();
AddButtonListener(clickAction);
}
public void AddButtonListener(System.Action onClick)
{
SelfButton.onClick.AddListener(() => onClick.Invoke());
}

private void Update()
{
if (CurrentCamera == null || !JudgeTargetValid())
{
if (IsValid)
{
SetAllDirty();
}
IsValid = false;
return;
}

if (!IsValid)
{
IsValid = true;
SetAllDirty();
}
GetValidRectData();
JudgeChange();
}

private bool JudgeTargetValid()
{
if (TargetUI == null || TargetUI.Length == 0)
return false;

bool isValid = false;
foreach (Transform t in TargetUI)
{
if (t.gameObject.activeSelf)
{
isValid = true;
break;
}
}
return isValid;
}

private void JudgeChange()
{
if (_lastTargetPos != _curTargetPos || _lastTargetSize != _curTargetSize)
{
_lastTargetPos = _curTargetPos;
_lastTargetSize = _curTargetSize;

RectTransformUtility.ScreenPointToLocalPointInRectangle(
rectTransform,
RectTransformUtility.WorldToScreenPoint(CurrentCamera, _lastTargetPos),
CurrentCamera,
out _targetLocalPos
);

SetAllDirty();//设为脏之后会调用OnPopulateMesh
}
}
/// <summary>
/// 计算目前TargetUI数组内所有UI的Rect边界值,用来确定高亮区域大小
/// </summary>
private void GetValidRectData()
{
List<float> pointsX = new();
List<float> pointsY = new();
Tuple<float[], float[]> tuple;
foreach (RectTransform rect in TargetUI)
{
tuple = GetRectCornerPoints(rect);
pointsX.AddRange(tuple.Item1);
pointsY.AddRange(tuple.Item2);
}
var arrayX = pointsX.ToArray();
var arrayY = pointsY.ToArray();
_leftX = Mathf.Min(arrayX);
_rightX = Mathf.Max(arrayX);
_bottomY = Mathf.Min(arrayY);
_topY = Mathf.Max(arrayY);

_curTargetSize = new Vector2(_rightX - _leftX, _topY - _bottomY);
_curTargetPos = new Vector3(_leftX + _curTargetSize.x * 0.5f, _bottomY + _curTargetSize.y * 0.5f);
}
private Tuple<float[],float[]> GetRectCornerPoints(RectTransform rect)
{
float[] cornerX = new float[2];
float[] cornerY = new float[2];
float width = rect.rect.width * rect.localScale.x;
float height = rect.rect.height * rect.localScale.y;
cornerX[0] = rect.position.x - width * 0.5f;//这里使用的是世界坐标,在这里也可以理解为屏幕坐标
cornerX[1] = rect.position.x + width * 0.5f;
cornerY[0] = rect.position.y - height * 0.5f;
cornerY[1] = rect.position.y + height * 0.5f;
return new Tuple<float[], float[]>(cornerX,cornerY);
}
public bool IsRaycastLocationValid(Vector2 sp, Camera eventCamera)//如果返回true,整个UI都会阻挡点击,没有Mesh也会阻挡
{
if (!CanClick || !JudgeTargetValid())
return true;

IsInValidRange = false;

//for (int i = 0; i < TargetUI.Length; i++)
//{
// //判断当前点击的位置是否在TargetUI的Rect框内,如果是,返回false,不阻挡点击
// IsInValidRange = RectTransformUtility.RectangleContainsScreenPoint(TargetUI[i], sp, eventCamera);
// if (IsInValidRange)
// break;
//}
IsInValidRange = JudgePointInInnerRect(sp);
return !IsInValidRange;
}
private bool JudgePointInInnerRect(Vector3 point)
{
return point.x >= _leftX && point.x <= _rightX && point.y >= _bottomY && point.y <= _topY;
}
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();

if (!IsValid) { return; }

UpdateVertexData();

AddVertices(_outer, vh);
AddVertices(_inner, vh);

int count = 4;

for (int i = 0; i < count; ++i)
{
AddTriangles(vh, i, count);
}
}

private void AddVertices(Vector2[] vertices, VertexHelper vh)
{
for (int i = 0; i < vertices.Length; i++)
{
UIVertex vertex = new()
{
position = vertices[i],
color = color
};
vh.AddVert(vertex);
}
}
/// <summary>
/// 添加绘制三角面的顶点顺序
/// </summary>
/// <param name="vh">VertexHelper参数</param>
/// <param name="startIndex">三角面最开始的顶点Index</param>
/// <param name="count">多边形的边数,这里的多边形内边和外边顶点数相同</param>
private void AddTriangles(VertexHelper vh, int startIndex, int count)
{
int outerEnd = GetValidIndex(startIndex + 1, count);//传入的Index需要加1,因为是从0开始的
int innerEnd = GetValidIndex(startIndex + count + 1, count);
vh.AddTriangle(startIndex, outerEnd, startIndex + count);
vh.AddTriangle(innerEnd, startIndex + count, outerEnd);
}
/// <summary>
/// 判断是否返回初始Index
/// </summary>
/// <param name="current">当前的顶点index</param>
/// <param name="count">需要取余的数</param>
/// <returns>正确的Index</returns>
private int GetValidIndex(int current, int count)
{
if (current % count != 0) return current;
else return (current / count - 1) * count;
}
private void UpdateVertexData()
{
//计算最外框各个点的坐标,注意这里都是UI本地坐标, 使用pivot乘长宽来计算,因为pivot对本地坐标有影响
float outerLeftX = -rectTransform.pivot.x * rectTransform.rect.width;
float outerRightX = (1 - rectTransform.pivot.x) * rectTransform.rect.width;
float outerBottomY = -rectTransform.pivot.y * rectTransform.rect.height;
float outerUpperY = (1 - rectTransform.pivot.y) * rectTransform.rect.height;

_outer[0] = new Vector2(outerLeftX, outerBottomY);
_outer[1] = new Vector2(outerLeftX, outerUpperY);
_outer[2] = new Vector2(outerRightX, outerUpperY);
_outer[3] = new Vector2(outerRightX, outerBottomY);

//计算最内框各个点的坐标
float width = _curTargetSize.x;
float height = _curTargetSize.y;

float innerLeftX = _targetLocalPos.x - width * 0.5f;
float innerRightX = _targetLocalPos.x + width * 0.5f;
float innerBottomY = _targetLocalPos.y - height * 0.5f;
float innerUpperY = _targetLocalPos.y + height * 0.5f;

_inner[0] = new Vector2(innerLeftX, innerBottomY);
_inner[1] = new Vector2(innerLeftX, innerUpperY);
_inner[2] = new Vector2(innerRightX, innerUpperY);
_inner[3] = new Vector2(innerRightX, innerBottomY);
}
}

顶点排列规则

按照顺时针,每3个为一组,内外顶点的差值为4,依次观察绘制三角形的顶点:

1
2
3
4
5
014 541 : i=0 i,i+1,i+4 i+5,i+4,i+1
125 652 : i=1 i,i+1,i+4 i+5,i+4,i+1
236 763 : i=2 i,i+1,i+4 i+5,i+4,i+1
307 470 : i=3 i,i+1,i+4 i+5,i+4,i+1 //这个时候超出顶点下标了,我们进行取余
即:设内外顶点差值为count,外边三角形顶点顺序是:(i,i+1,i+count),内边三角形的顶点顺序是:(i+count+1,i+count,i+1)

注意这里的思想,首先内圈和外圈的顶点都顺时针添加,内圈和外圈的顶点数相同,我们这里是4边形,所以按照4的倍数取余。

内圈的顶点Index和外圈Index差4,所以单纯取余不行,我们要先除以4看看是内圈还是外圈。

HighlightTool.GetValidIndex方法就是这么判断的。

公共属性:

CanClick:被HighlightTool高亮的UI是否需要响应点击,在新手引导过程中,我们需要根据情况决定高亮出来的UI需不需要响应点击。

TargetUI:表示当前挖孔内需要高亮显示的UI组。

CurrentCamera:当前的UI摄像机是哪个

IsValid:当前HighLightTool是否能正常显示,一般情况下这个值不需要在外部设置。

IsInValidRange:当前点击的屏幕坐标是否在Target UI的Rect框内。

一般情况下HighLightModule的使用情况有两种,一种是整个Module都需要响应点击,这种时候CanClick设为默认的False;另一种情况是只有高亮区域内的UI需要响应点击,这种时候将CanClick设为true,注意还需要同时将引导模块的退出逻辑注册进相应的UI按钮中。