UGUI学习笔记(七)自己实现圆形图片组件

一、通过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;
}

源码下载

猜你喜欢

转载自blog.csdn.net/LWR_Shadow/article/details/126778096
今日推荐