• 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();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    当一个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;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    这里需要注意一点,计算UI的中点时,要考虑到UI锚点变化的问题。如果将中点始终设为(0,0)的话,当锚点挪动,图案也会跟着一起挪动。因此当锚点不再是(0.5,0.5)时,就需要给中点坐标加一个偏移值,使它始终保持在UI的中心位置。

    我们知道,屏幕上显示出来的图案实际上是由GPU渲染出来的。而GPU渲染时是以三角形面片为基本单位进行绘制。三角形的数量越多,绘制出来的圆形也就越精细。

    因而我们需要定义出圆形由多少块三角面片组成,后续可以更改这个值来调整圆形的渲染精度。

    // 圆形由多少块三角面片拼成
    private int _segments = 100;
    
    • 1
    • 2

    有了三角面片的数量,我们就可以求出每个三角形所对应的弧度

    // 每个三角形的弧度  
    var radian = (2 * Mathf.PI) / _segments;
    
    • 1
    • 2

    准备工作完成,我们现在可以来创建圆心点了。UI的顶点信息在Unity中以UIVertex对象进行存储。我们可以设置它的颜色、位置、uv坐标等信息。创建完成后将其添加到顶点集合中。

    // 创建圆心顶点  
    UIVertex origin = new();  
    origin.color = color;  
    origin.position = originPos;  
    origin.uv0 = new Vector2(uvCenter.x, uvCenter.y);  
    toFill.AddVert(origin);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    接下来需要计算位于圆周上的顶点坐标。有每个三角形对应的弧度、圆形的半径,计算起来也很简单。这里注意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);
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    最后就是将三角形添加到集合了。这里要注意,GPU在渲染三角形时默认是做背面剔除的,也就是说只有正对屏幕的三角形才会进行渲染。而三角形的正反是根据传入的顶点顺序区分的。顶点顺序是顺时针,则判断为正面,否则是反面。(勘误:UI的默认Shader关闭了剔除,也就是说无论是顺时针还是逆时针都能显示出来)

    // 添加三角形
    for (int i = 1; i <= realSegments; i++)
    {
    	toFill.AddTriangle(i,0,i+1);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    下面是完成的代码

    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);
            }
        }
    }
    
    • 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

    2.2 点击区域判定

    现在我们给CircleImage添加一个「Button」组件,会发现无论是点击图片还是点击图片四周,都会触发点击事件。这是因为Image默认的点击触发区域就是矩形的框线所围成的区域。因此我们需要对其进行改造。

    在Image类中有抽象方法IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera),它用来判定点击时的射线位置是否是一个有效的命中位置。传入的两个参数分别是点击时的屏幕坐标,以及触发射线的相机。我们只需要重写这个方法,实现自己的点击区域判定逻辑就可以了。

    那么如何判定点击是否有效呢?一个很巧妙的方法是在点击位置向右做一条射线,如果与图形的边界交点为奇数个,说明在图形内部,反之则在外部。这个方法同样适用于中间镂空的图形。

    接下来就开始着手实现这套逻辑。首先,我们需要把点击时的屏幕坐标转换为本地坐标

    RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, 
     screenPoint, eventCamera,out Vector2 localPoint);
    
    • 1
    • 2

    然后对顶点坐标进行遍历,两个相邻点之间进行连线。判断目标点的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;
    }
    
    • 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

    源码下载

  • 相关阅读:
    用python把docx批量转为pdf
    MATLAB中syms函数使用
    【三维重建-PatchMatchNet复现笔记】
    【python】实现NMS
    软件测试/测试开发/人工智能丨Python类型转换
    移动端css问题
    测试 C、Python、Java 等 16 种编程语言的 Hello World:7 种存在 Bug?
    前端自定义导出PPT
    详解eval
    [附源码]java毕业设计海南生鲜冷链物流配送系统论文
  • 原文地址:https://blog.csdn.net/LWR_Shadow/article/details/126778096