FishPlayer

一个喜欢摸鱼的废物

0%

UGUI不规则形状按钮

最近几个月都是猛猛加班和服务器兄弟联调做系统。工作确实重复又无趣。最近周末不用强制加班了,于是用省下时间把以前UI那边的一个人认为还算有点意思的小需求做了。
今天的主题是 UGUI不规则形按钮!

UGUI射线点击流程

既然要做一个和UI点击相关的功能,那就就得先看一看UI这部分的点击检测是如何实现的。
我只是大概看了一下源码,简化着说一下

1
2
3
4
5
6
7
EventSystem 每一帧驱动当前的 InputModule 处理当前的输入

InputModule 从输入中捕获到点击信息,以点击信息调用EventSystem对所有附带了Raycaster的Canvas发起对点击点的检测

InputModule 收集所有Raycaster得到的结果,进行排序,得到最终的发送点击信息的目标物体

让目标物体执行对应的点击回调 (IPointerClickHandler)

对于今天的这个功能,我们需要关注的是 InputModule 从输入中捕获到点击信息,以点击信息调用EventSystem对所有附带了Raycaster的Canvas发起对点击点的检测 这一步。

UI物体的Raycast点击检测

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

private static void Raycast(Canvas canvas, Camera eventCamera, Vector2 pointerPosition, IList<Graphic> foundGraphics, List<Graphic> results)
{
// Necessary for the event system
int totalCount = foundGraphics.Count;
for (int i = 0; i < totalCount; ++i)
{
Graphic graphic = foundGraphics[i];
// -1 means it hasn't been processed by the canvas, which means it isn't actually drawn
if (!graphic.raycastTarget || graphic.canvasRenderer.cull || graphic.depth == -1)
continue;
// The 1st check
if (!RectTransformUtility.RectangleContainsScreenPoint(graphic.rectTransform, pointerPosition, eventCamera, graphic.raycastPadding))
continue;
if (eventCamera != null && eventCamera.WorldToScreenPoint(graphic.rectTransform.position).z > eventCamera.farClipPlane)
continue;
if (graphic.Raycast(pointerPosition, eventCamera))
{
s_SortedGraphics.Add(graphic);
}
}
s_SortedGraphics.Sort((g1, g2) => g2.depth.CompareTo(g1.depth));
totalCount = s_SortedGraphics.Count;
for (int i = 0; i < totalCount; ++i)
results.Add(s_SortedGraphics[i]);
s_SortedGraphics.Clear();
}

public virtual bool Raycast(Vector2 sp, Camera eventCamera)
{
if (!isActiveAndEnabled)
return false;
var t = transform;
var components = ListPool<Component>.Get();
bool ignoreParentGroups = false;
bool continueTraversal = true;
while (t != null)
{
t.GetComponents(components);
for (var i = 0; i < components.Count; i++)
{
var canvas = components[i] as Canvas;
if (canvas != null && canvas.overrideSorting)
continueTraversal = false;
var filter = components[i] as ICanvasRaycastFilter;
if (filter == null)
continue;
var raycastValid = true;
var group = components[i] as CanvasGroup;
if (group != null)
{
if (!group.enabled)
continue;
if (ignoreParentGroups == false && group.ignoreParentGroups)
{
ignoreParentGroups = true;
raycastValid = filter.IsRaycastLocationValid(sp, eventCamera);
}
else if (!ignoreParentGroups)
raycastValid = filter.IsRaycastLocationValid(sp, eventCamera);
}
else
{
raycastValid = filter.IsRaycastLocationValid(sp, eventCamera);
}
if (!raycastValid)
{
ListPool<Component>.Release(components);
return false;
}
}
t = continueTraversal ? t.parent : null;
}
ListPool<Component>.Release(components);
return true;
}

从源码中得知,在判断了点击位置是在Graphic的RectTranform范围里之后,会让graphic根据点击位置做自定义的点击检查(我说自定义是因为这个函数是可以被覆写的)。
graphic 对于点击的检查也没有想象中的那么简单,大概内容是这样的:

1
2
3
1 获取当前自己身上的与点击检测相关的额外检查组件,如果有,就让和它们来判断这个点击是否合法
2 如果上一步判断合法,那么就持续对父级物体做这样的判断直到父级/当前物体身上有Canvas
3 如果上一步判断合法,那么这个点击就成立!

这个所谓的“额外检查组件”实际上是一个接口(ICanvasRaycastFilter),这个接口就是今天这个功能的重点。通过源码能清楚看到,如果没有这个额外的检查,点击检测的结果会沿用之前的结果(点击位置是否在RectTransfrom范围内)。

简易UI多边形点击范围

交互同学想要的自定义点击范围,其实可以简单的认为是要规划一个简单多边形。
为了描述这个多边形,我决定以顺时针记录描述这个多边形的点(便于gizmo绘制也方便做点击检测)。

判断点击位置是否在自定义的多边形内,就用比较简单的射线法来判断。
射线法:

  • 从需要检测的点引出一条射线
  • 计算射线和多边形的交点数量
  • 交点数如果是奇数,说明点在多边形内;如果是偶数,则点不在多边形内

当然这个做法会遇到一些需要处理的边界状况(射线刚好与若干条边重合或者点就在多边形的点上)。

于是实际检测的步骤就是对每条边都先检查特殊情况,不符合特殊情况的时候再做常规的涉嫌检查。
在检测代码中,我每次取到多边形的点都会根据实际情况排上,下两个点。原因也很简单,检测是否相交用的是向量的叉乘只能判断是否会相交,但是不能明确相交的位置。
这个时候我还需要判断一下这个理论交点是否存在,因为检测边的点已经明确了上下,再根据向量的叉乘就可以明确对于检测边(从下往上方向),需要检测的点在左侧(理论交点有效)还是右侧(理论交点无效)。

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

namespace UnityEngine.UI
{
[RequireComponent(typeof(CanvasRenderer))]
public partial class RaycastOnlyPolygonGraphic : MaskableGraphic, ICanvasRaycastFilter
{
// Use normalized value, must be clockwise :)
[SerializeField]
private List<Vector2> m_innerPoints;

public IReadOnlyList<Vector2> PointList => m_innerPoints;
public int PointCount => null == m_innerPoints ? 0 : m_innerPoints.Count;

public override void SetMaterialDirty() { return; }
public override void SetVerticesDirty() { return; }

public bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
{
// Point in rect
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera, out Vector2 local))
{
Vector2 bottomLeft = GetLocalRectPosition(rectTransform, Vector2.zero);
Vector2 localDelta = new(local.x - bottomLeft.x, local.y - bottomLeft.y);

#if UNITY_EDITOR
Debug.DrawLine(rectTransform.TransformPoint(bottomLeft), rectTransform.TransformPoint(local), Color.red);
#endif
Rect rect = rectTransform.rect;
Vector2 normalizedLocalPoint = new(localDelta.x / rect.width, localDelta.y / rect.height);
bool result = RaycastCheckPointInPolygon(normalizedLocalPoint);
return result;
}

return false;
}

// TODO let them choose where to create de new point
[ContextMenu(nameof(CreateNewPoint))]
public void CreateNewPoint()
{
m_innerPoints ??= new List<Vector2>();
if (2 > m_innerPoints.Count)
{
m_innerPoints.Add(Vector2.zero);
return;
}

int pointCount = m_innerPoints.Count;
Vector2 newPoint = m_innerPoints[pointCount - 1];
Vector2 tempDir = m_innerPoints[0] - m_innerPoints[pointCount - 1];
newPoint += 0.5f * tempDir;
m_innerPoints.Add(newPoint);
}

public Vector2 GetPoint(int index)
{
if (null == m_innerPoints)
{
return Vector2.zero;
}
return m_innerPoints[index];
}

public void SetPoint(int index, Vector2 point)
{
if (0 > index || m_innerPoints.Count <= index)
{
Debug.LogError($"Index {index} is out of range for points list.");
return;
}
m_innerPoints[index] = point;
}

protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
}

/// <summary>
/// Bottom left as (0,0)
/// </summary>
/// <param name="target"></param>
/// <param name="normalizedRectPosition"></param>
/// <returns></returns>
private Vector2 GetLocalRectPosition(RectTransform target, Vector2 normalizedRectPosition)
{
Vector2 result = Vector2.zero;
Vector2 targetSize = target.rect.size;
Vector2 targetPivot = target.pivot;

Vector2 pivotOffset = Vector2.one * 0.5f - targetPivot;
pivotOffset.x *= targetSize.x;
pivotOffset.y *= targetSize.y;

result += pivotOffset;

// start from bottom left
result.x -= targetSize.x * 0.5f;
result.y -= targetSize.y * 0.5f;

result.x += normalizedRectPosition.x * targetSize.x;
result.y += normalizedRectPosition.y * targetSize.y;

return result;
}

private bool RaycastCheckPointInPolygon(Vector2 normalizedPoint)
{
int pointCount = m_innerPoints.Count;
if (3 > pointCount)
{
return false;
}

if (3 == pointCount)
{
return PointInTriangle
(
normalizedPoint,
m_innerPoints[0],
m_innerPoints[1],
m_innerPoints[2]
);
}

int intersectionCount = 0;
for (int i = 0, length = pointCount; i < length; i++)
{
Vector2 upPoint = m_innerPoints[i];
Vector2 downPoint = m_innerPoints[(i + 1) % length];
if (upPoint.y < downPoint.y)
{
(upPoint, downPoint) = (downPoint, upPoint);
}

// Right edge-point
Vector2 edge = new(1f, normalizedPoint.y);

// First check
if (Mathf.Max(upPoint.x, downPoint.x) < normalizedPoint.x ||
upPoint.y < normalizedPoint.y ||
downPoint.y > normalizedPoint.y)
{
continue; // Wont collide with this segment
}

Vector2 p2up = upPoint - normalizedPoint;
Vector2 down2up = upPoint - downPoint;

// Point is the same as upPoint or downPoint or the point is on the segment or point is
if ((Mathf.Approximately(upPoint.x, normalizedPoint.x) && Mathf.Approximately(upPoint.y, normalizedPoint.y)) ||
(Mathf.Approximately(downPoint.x, normalizedPoint.x) && Mathf.Approximately(downPoint.y, normalizedPoint.y)) ||
0 == Vector3.Cross(p2up, down2up).z)
{
return true;
}

Vector2 p2edge = edge - normalizedPoint;
Vector2 p2down = downPoint - normalizedPoint;

Vector3 cross1 = Vector3.Cross(p2up, p2edge);
Vector3 cross2 = Vector3.Cross(p2down, p2edge);

Vector2 edge2p = normalizedPoint - edge;
Vector2 edge2up = upPoint - edge;
Vector2 edge2down = downPoint - edge;

Vector3 cross3 = Vector3.Cross(edge2up, edge2p);
Vector3 cross4 = Vector3.Cross(edge2down, edge2p);

// Lines are intersecting
if (0 >= cross1.z * cross2.z &&
0 >= cross3.z * cross4.z &&
0 < Vector3.Cross(p2down, p2up).z) // Check if point is at the left side of current segment
{
intersectionCount++;
}
}

return intersectionCount % 2 == 1;
}

private bool PointInTriangle(Vector2 p, Vector2 a, Vector2 b, Vector2 c)
{
Vector3 p2a = a - p;
Vector3 p2b = b - p;
Vector3 p2c = c - p;

Vector3 cross1 = Vector3.Cross(p2a, p2b);
Vector3 cross2 = Vector3.Cross(p2b, p2c);
Vector3 cross3 = Vector3.Cross(p2c, p2a);

float dot1 = Vector3.Dot(cross1, cross2);
float dot2 = Vector3.Dot(cross2, cross3);
float dot3 = Vector3.Dot(cross3, cross1);

return dot1 >= 0 &&
dot2 >= 0 &&
dot3 >= 0;
}

}
}

一些编辑器的绘制,凑合用的

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

[CanEditMultipleObjects]
[CustomEditor(typeof(RaycastOnlyPolygonGraphic), true)]
public class RaycastOnlyPolygonGraphic : Editor
{
private RaycastOnlyPolygonGraphic m_target;
private SerializedProperty m_pointsProperty;
private ReorderableList m_pointList;

private bool m_normalized;

private void OnEnable()
{
m_target = target as RaycastOnlyPolygonGraphic;
m_pointsProperty = serializedObject.FindProperty("m_innerPoints");

m_normalized = true;
m_pointList = CreatePointList();
}

private void OnDisable()
{
m_target = null;
}

public override void OnInspectorGUI()
{
// TODO draw only raycast target field and readonly points, triangles
base.OnInspectorGUI();

serializedObject.Update();
EditorGUILayout.Space();
m_pointList.DoLayoutList();

if (GUILayout.Button("Create New Point"))
{
m_target.CreateNewPoint();
}
if (GUILayout.Button("Create Convex Hull"))
{
m_target.CreateConvexHullFromPoints();
}

serializedObject.ApplyModifiedProperties();
}

private ReorderableList CreatePointList()
{
ReorderableList reorderableList = new(serializedObject, m_pointsProperty, true, true, true, true);
reorderableList.drawElementCallback = (rect, index, isActive, isFocused) =>
{
const float spacing = 2, fieldWidth = 10;
rect.y += spacing; rect.height = EditorGUIUtility.singleLineHeight;
SerializedProperty pointProp = m_pointsProperty.GetArrayElementAtIndex(index);
Vector2 pointValue = pointProp.vector2Value;
rect.width = (rect.width - spacing) / 2;
EditorGUIUtility.labelWidth = fieldWidth;
OnNormalizeFloatField(rect, "X", ref pointValue.x, 0);
rect.x += rect.width + spacing;
OnNormalizeFloatField(rect, "Y", ref pointValue.y, 1);
EditorGUIUtility.labelWidth = 0;
pointProp.vector2Value = pointValue;
};

reorderableList.onAddCallback = list =>
{
ReorderableList.defaultBehaviours.DoAddButton(list);
if (m_pointsProperty.arraySize <= 1)
{
return;
}
SerializedProperty startPointProp = m_pointsProperty.GetArrayElementAtIndex(0);
SerializedProperty newPointProp = m_pointsProperty.GetArrayElementAtIndex(m_pointsProperty.arraySize - 1);
newPointProp.vector2Value = (newPointProp.vector2Value + startPointProp.vector2Value) / 2;
};

reorderableList.onRemoveCallback = list =>
{
if (m_pointsProperty.arraySize <= 3)
{
return;
}
ReorderableList.defaultBehaviours.DoRemoveButton(list);
};

reorderableList.drawHeaderCallback = rect =>
{
const int buttonWidth = 75;
rect.width -= buttonWidth;
EditorGUI.LabelField(rect, "Points");
rect.x += rect.width;
rect.width = buttonWidth;
m_normalized = GUI.Toggle(rect, m_normalized, "Normalize", EditorStyles.miniButton);
};

return reorderableList;
}

private void OnNormalizeFloatField(Rect rect, string label, ref float value, int axis)
{
if (m_normalized)
{
value = Mathf.Clamp01(EditorGUI.FloatField(rect, label, value));
}
else
{
Vector2 size = ((target as RaycastOnlyPolygonGraphic).transform as RectTransform).rect.size;
value = Mathf.Clamp01(EditorGUI.FloatField(rect, label, value * size[axis]) / size[axis]);
}
}

private void OnSceneGUI()
{
if (m_target == null || !m_target.enabled)
{
return;
}
DrawPointHandles();
}

private void DrawPointHandles()
{
Color beforeColor = Handles.color;
Matrix4x4 beforeMatrix = Handles.matrix;
RectTransform rectTransform = m_target.transform as RectTransform;

Handles.color = Color.blue;
Handles.matrix = m_target.transform.localToWorldMatrix;

Vector3 moveHandle = Vector3.zero;
Vector3 moveSnap = Vector3.one * 0.5f;
float moveSize = HandleUtility.GetHandleSize(m_target.transform.position) * 0.1f;

EditorGUI.BeginChangeCheck();
int pointCount = m_target.PointCount;
for (int i = 0; i < pointCount; i++)
{
moveHandle = (m_target.GetPoint(i) - rectTransform.pivot) * rectTransform.rect.size;
moveHandle = Handles.FreeMoveHandle(moveHandle, moveSize, moveSnap, Handles.CircleHandleCap);
m_target.SetPoint(i, Clamp01(moveHandle / rectTransform.rect.size + rectTransform.pivot));
Handles.Label(moveHandle, $"{i}");
}
if (EditorGUI.EndChangeCheck())
{
EditorUtility.SetDirty(m_target);
}

Handles.color = beforeColor;
Handles.matrix = beforeMatrix;
}

private Vector2 Clamp01(Vector2 vector)
{
vector.x = Mathf.Clamp01(vector.x);
vector.y = Mathf.Clamp01(vector.y);
return vector;
}
}

除开射线法,还可以用回转法:

  • 检测点与多边形的每一个点连线
  • 算出每两个相邻点之间的夹角旋转角度
  • 角度总和为0时点在多边形外,和为360则点在多边形内

没有选择这个方法是因为该方法设计开方运算,而这个检测需要每一帧跑,感觉上用射线法的消耗会少一些。

根据点集生成简单多边形

为了方便交互同学编辑点击区域,我加了一个简单的凸多边形生成(并保证缓存下来的点是按照顺时针排布的)。
这样交互同学就无需花时间去手动排序点集,编辑耗时更短,也方便直接从大改的形状进行进一步修改。
凸多边形生成的算法也是使用最简单的 Gift Wrapping 算法,网上一搜就有思路。因为这项操作是编辑器时进行,所以我没有在意性能。

算法流程大概就是这样的:

  • 遍历所有点,找到极左点
  • 以极左点开始从剩下的点中,找对于自己来说,角度最大的在顺时针方向的凸点
  • 再以找到的凸点作为起始寻找下一个凸点
  • 最后直到下一个凸点是最开始找到的极左点时,退出循环
  • 得到顺时针排布的凸包点集
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

private static List<Vector2> GenerateConvexHull(IReadOnlyList<Vector2> points)
{
List<Vector2> result = new();
// Find left most point
int leftMostIndex = 0;
for (int i = 1, length = points.Count; i < length; i++)
{
if (points[leftMostIndex].x > points[i].x)
{
leftMostIndex = i;
}
}
result.Add(points[leftMostIndex]);
Vector2 leftMostPoint = points[leftMostIndex];

// Start from leftmost point, then find rest points
List<Vector2> collinearPoints = new();
Vector2 current = points[leftMostIndex];
while (true)
{
bool hasTaraget = false;
Vector2 nextTarget = default;
for (int i = 0, length = points.Count; i < length; i++)
{
Vector2 tempPoint = points[i];
if (V2PointApproximately(tempPoint, current))
{
continue;
}

if (hasTaraget)
{
Vector2 current2Target = nextTarget - current;
Vector2 current2Temp = tempPoint - current;
Vector3 corssResult = Vector3.Cross(current2Temp, current2Target);
float zValue = corssResult.z;
if (zValue > 0) // Find a farther point that makes a convex angle
{
nextTarget = tempPoint;

collinearPoints = new List<Vector2>();
}
else if (Mathf.Approximately(0, zValue)) // Handle collinear points(with current target)
{

if ((current - nextTarget).sqrMagnitude < (current - tempPoint).sqrMagnitude)
{
collinearPoints.Add(nextTarget); // Add and move to farther collinear point
nextTarget = tempPoint;
}
else
{
collinearPoints.Add(tempPoint); // Add a closer collinear point
}
}
else { } // Ah this point would make prev-target point into a concave point, do not add it :(
}
else
{
nextTarget = tempPoint;
hasTaraget = true;
}
}

if (hasTaraget)
{
for (int i = 0, length = collinearPoints.Count; i < length; i++)
{
result.Add(collinearPoints[i]);
}

if (V2PointApproximately(nextTarget, leftMostPoint))
{
break;
}

result.Add(nextTarget);
current = nextTarget;
continue;
}

// If we cant find a next target, we are done
break;
}
return result;
}