一、通过Mask组件实现
第一种实现方式是通过UGUI原生的「Mask」遮罩组件。实现起来非常简单,首先创建一个Image对象,并挂载「Mask」组件。然后将Image的Sprite设置为事先准备好的圆形图片
然后再将这个Image对象设置为目标对象的父物体即可
但是这种方式有个问题,Mask组件会增加额外的Draw Call调用(Shader中的概念),也就会增加额外的性能消耗。可以通过Game窗口中的Stats界面查看Draw Call调用情况
二、手动实现
2.1 修改渲染模式
既然要实现一个圆形图片组件,那么肯定是基于原生的「Image」组件实现的。因此我们创建一个空物体,并挂载一个空的脚本。让脚本中的类继承Image类
public class CircleImage : Image
{
protected override void OnPopulateMesh(VertexHelper toFill)
{
// 清除顶点数据
toFill.Clear();
}
}
当一个UI元素生成顶点数据时,会调用OnPopulateMesh(VertexHelper toFill)
方法。它传入了一个VertexHelper
类型的参数,其中记录了将图片渲染到屏幕上所需的顶点和三角形信息。我们要做的就是重写这个方法,并对参数中的顶点和三角形信息进行修改,以达成将图片渲染成圆形的目的。
接下来我们需要将uv贴图坐标与渲染到场景中的坐标的对应关系计算出来。一张图片对应的uv坐标如下图所示
它的四个坐标点在Unity中由一个四维变量进行存储。获取到这个变量,以及UI的宽高后,就可以计算出uv中心点、uv坐标到UI坐标的换算比率等信息
// 获取当前图片的外层uv
var uv = overrideSprite != null ? DataUtility.GetOuterUV(overrideSprite) : Vector4.zero;
var width = rectTransform.rect.width;
var height = rectTransform.rect.height;
var uvWidth = uv.z - uv.x;
var uvHeight = uv.w - uv.y;
// 获取uv中心点
Vector2 uvCenter = new Vector2(uvWidth * 0.5f, uvHeight * 0.5f);
// 计算换算比率
Vector2 convertRatio = new Vector2(uvWidth / width, uvHeight / height);
// 计算UI中心点
Vector2 originPos = new Vector2((0.5f - rectTransform.pivot.x) * width, (0.5f - rectTransform.pivot.y) * height);
// 整个圆形的半径
var radius = width * 0.5f;
这里需要注意一点,计算UI的中点时,要考虑到UI锚点变化的问题。如果将中点始终设为(0,0)的话,当锚点挪动,图案也会跟着一起挪动。因此当锚点不再是(0.5,0.5)时,就需要给中点坐标加一个偏移值,使它始终保持在UI的中心位置。
我们知道,屏幕上显示出来的图案实际上是由GPU渲染出来的。而GPU渲染时是以三角形面片为基本单位进行绘制。三角形的数量越多,绘制出来的圆形也就越精细。
因而我们需要定义出圆形由多少块三角面片组成,后续可以更改这个值来调整圆形的渲染精度。
// 圆形由多少块三角面片拼成
private int _segments = 100;
有了三角面片的数量,我们就可以求出每个三角形所对应的弧度
// 每个三角形的弧度
var radian = (2 * Mathf.PI) / _segments;
准备工作完成,我们现在可以来创建圆心点了。UI的顶点信息在Unity中以UIVertex对象进行存储。我们可以设置它的颜色、位置、uv坐标等信息。创建完成后将其添加到顶点集合中。
// 创建圆心顶点
UIVertex origin = new();
origin.color = color;
origin.position = originPos;
origin.uv0 = new Vector2(uvCenter.x, uvCenter.y);
toFill.AddVert(origin);
接下来需要计算位于圆周上的顶点坐标。有每个三角形对应的弧度、圆形的半径,计算起来也很简单。这里注意Mathf.Cos(x)
和Mathf.Sin(x)
传入的参数是弧度,而不是角度。
// 顶点总数
var vertexCount = realSegments + 1;
// 当前弧度
var curRadian = 0f;
for (int i = 0; i < vertexCount; i++)
{
// 计算每个三角形面片的顶点坐标
var x = Mathf.Cos(curRadian) * radius;
var y = Mathf.Sin(curRadian) * radius;
curRadian += radian;
// 添加顶点
UIVertex vertexTemp = new();
vertexTemp.color = color;
vertexTemp.position = new Vector2(x, y)+originPos;
vertexTemp.uv0 = new Vector2(x * convertRatio.x + uvCenter.x,
y * convertRatio.y + uvCenter.y);
toFill.AddVert(vertexTemp);
}
最后就是将三角形添加到集合了。这里要注意,GPU在渲染三角形时默认是做背面剔除的,也就是说只有正对屏幕的三角形才会进行渲染。而三角形的正反是根据传入的顶点顺序区分的。顶点顺序是顺时针,则判断为正面,否则是反面。(勘误:UI的默认Shader关闭了剔除,也就是说无论是顺时针还是逆时针都能显示出来)
// 添加三角形
for (int i = 1; i <= realSegments; i++)
{
toFill.AddTriangle(i,0,i+1);
}
下面是完成的代码
public class CircleImage : Image
{
// 圆形由多少块三角面片拼成
[SerializeField]
private int _segments = 100;
// 控制圆形显示比例
[SerializeField]
private float _fillPercent = 1f;
protected override void OnPopulateMesh(VertexHelper toFill)
{
// 清除顶点数据
toFill.Clear();
int realSegments = (int) (_segments * _fillPercent);
AddVert(toFill, realSegments);
AddTriangle(toFill, realSegments);
}
// 添加顶点
private void AddVert(VertexHelper toFill, int realSegments)
{
// 获取当前图片的外层uv
var uv = overrideSprite != null ? DataUtility.GetOuterUV(overrideSprite) : Vector4.zero;
var width = rectTransform.rect.width;
var height = rectTransform.rect.height;
var uvWidth = uv.z - uv.x;
var uvHeight = uv.w - uv.y;
// 获取uv中心点
Vector2 uvCenter = new Vector2(uvWidth * 0.5f, uvHeight * 0.5f);
// 计算换算比率
Vector2 convertRatio = new Vector2(uvWidth / width, uvHeight / height);
// 计算UI中心点
Vector2 originPos = new Vector2((0.5f - rectTransform.pivot.x) * width, (0.5f - rectTransform.pivot.y) * height);
// 每个三角形的弧度
var radian = (2 * Mathf.PI) / _segments;
// 整个圆形的半径
var radius = width * 0.5f;
// 创建圆心顶点
UIVertex origin = new();
origin.color = color;
origin.position = originPos;
origin.uv0 = new Vector2(uvCenter.x, uvCenter.y);
toFill.AddVert(origin);
// 顶点总数
var vertexCount = realSegments + 1;
// 当前弧度
var curRadian = 0f;
for (int i = 0; i < vertexCount; i++)
{
// 计算每个三角形面片的顶点坐标
var x = Mathf.Cos(curRadian) * radius;
var y = Mathf.Sin(curRadian) * radius;
curRadian += radian;
// 添加顶点
UIVertex vertexTemp = new();
vertexTemp.color = color;
vertexTemp.position = new Vector2(x, y) + originPos;
vertexTemp.uv0 = new Vector2(x * convertRatio.x + uvCenter.x,
y * convertRatio.y + uvCenter.y);
toFill.AddVert(vertexTemp);
}
}
// 添加三角形
private static void AddTriangle(VertexHelper toFill, int realSegments)
{
for (int i = 1; i <= realSegments; i++)
{
toFill.AddTriangle(i, 0, i + 1);
}
}
}
[CustomEditor(typeof(CircleImage),true)]
[CanEditMultipleObjects]
public class CircleImageEditor : UnityEditor.UI.ImageEditor
{
private SerializedProperty _segments;
private SerializedProperty _fillPercent;
protected override void OnEnable()
{
base.OnEnable();
_segments = serializedObject.FindProperty("_segments");
_fillPercent = serializedObject.FindProperty("_fillPercent");
}
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
serializedObject.Update();
EditorGUILayout.Slider(_fillPercent, 0, 1, new GUIContent("showPercent"));
EditorGUILayout.PropertyField(_segments);
serializedObject.ApplyModifiedProperties();
if (GUI.changed)
{
EditorUtility.SetDirty(target);
}
}
}
2.2 点击区域判定
现在我们给CircleImage添加一个「Button」组件,会发现无论是点击图片还是点击图片四周,都会触发点击事件。这是因为Image默认的点击触发区域就是矩形的框线所围成的区域。因此我们需要对其进行改造。
在Image类中有抽象方法IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
,它用来判定点击时的射线位置是否是一个有效的命中位置。传入的两个参数分别是点击时的屏幕坐标,以及触发射线的相机。我们只需要重写这个方法,实现自己的点击区域判定逻辑就可以了。
那么如何判定点击是否有效呢?一个很巧妙的方法是在点击位置向右做一条射线,如果与图形的边界交点为奇数个,说明在图形内部,反之则在外部。这个方法同样适用于中间镂空的图形。
接下来就开始着手实现这套逻辑。首先,我们需要把点击时的屏幕坐标转换为本地坐标
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform,
screenPoint, eventCamera,out Vector2 localPoint);
然后对顶点坐标进行遍历,两个相邻点之间进行连线。判断目标点的y值是否在这两点之间,以及x值是否在交点的左侧。顶点坐标我们在渲染过程中已经按逆时针生成过了,可以将其记录到一个全局的集合中。
如果符合上述条件,则记录交点个数加1。最后遍历完成后,判断交点的个数是奇数还是偶数,对应目标点在内部还是外部。
完整代码如下:
public override bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
{
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera,out Vector2 localPoint);
return IsValid(localPoint,_vertPosList);
}
private bool IsValid(Vector2 localPoint,List<Vector2> vertPosList)
{
return GetCrossPointNum(localPoint,vertPosList)%2 == 1;
}
private int GetCrossPointNum(Vector2 localPoint,List<Vector2> vertPosList)
{
Vector2 vert1 = Vector2.zero;
Vector2 vert2 = Vector2.zero;
int vertCount = vertPosList.Count;
int count = 0;
for (int i = 0; i < vertCount; i++)
{
vert1 = vertPosList[i];
vert2 = vertPosList[(i+1)%vertCount];
// 目标点的y在两个顶点之间
if (IsYInRange(vert1, vert2, localPoint.y))
{
// 交点在目标点右侧
if (GetIntersectionX(vert2, vert1, localPoint.y) > localPoint.x)
{
count++;
}
}
}
return count;
}
private bool IsYInRange(Vector2 vert1, Vector2 vert2, float y)
{
if (vert1.y > vert2.y)
{
return y < vert1.y && y > vert2.y;
}
else
{
return y < vert2.y && y > vert1.y;
}
}
private float GetIntersectionX(Vector2 vert1, Vector2 vert2, float y)
{
float k = (vert2.y - vert1.y) / (vert2.x - vert1.x);
return (y - vert2.y) / k + vert2.x;
}