Unity精品文档之UGUI不规则区域点击的实现

一、前言

此文章为网上转载收集而成,非原创文章,请尊重别人的劳动成果,让分享成为一种美德,欢迎转载。另外,文章在表述和代码方面如有不妥之处,欢迎批评指正。

本文链接https://www.cnblogs.com/msxh/p/9283266.html

收集整合

18328685848@163.com                                   
			----转载收集整合 小小Unity

通过本篇博客,我会和大家一起学习*《精品文档之UGUI不规则区域点击的实现》*,希望本篇博客能为大家的工作过程中带来一些帮助与启发。

如果觉得本篇博客对您有帮助,可以扫码添加以下公众号,公众号会推送一些与Unity相关的精品文档与精品专栏以及一些资源
 在这里插入图片描述

在这里插入图片描述

二、简介

之前工作的时候,曾处理了一个UGUI不规则区域点击的问题,制作过程中也有一些收获和需要注意坑,因此记录成博客与大家分享。

​ 众所周知在UGUI中,响应点击通常是依附在一张图片上的,而图片不管美术怎么给你切,导进Unity之后都是一个矩形,如果要做其他形状,最多只能旋转一下,或者自己做一些处理。而为了美术效果,很多时候我们不得不需要特定形状的UI,并且让它们实现精准的响应点击。例如下图就是一个不规则的点击区域。

img

图1:UGUI不规则点击区域示意图

下面是处理了不规则区域点击后的演示效果,当点击按钮的时候,会对点击次数进行累加并且打印到控制台。可以看到进行了不规则区域点击处理以后,对我们原来的普通矩形Sprite的点击不会产生到影响,而不规则区域的表现效果也符合我们的预期。

img

图2:规则区域与不规则区域点击效果对比

三、针对UGUI不规则区域点击的两种处理方法

针对UGUI的不规则区域响应点击,一般来说有两种处理办法:

1.精灵像素检测:该方法是指通过读取精灵(Sprite)在某一点的像素值(RGBA),如果该点的像素值中的Alpha小于一定的阈值(比如0.5)则表示该点处是透明的,即用户点击的位置在精灵边界以外,否则用户点击的位置在精灵边界内部。

2.通过算法计算碰撞区域:通过一定的算法,手动计算出碰撞区域,然后在判断用户是点击在了精灵上面,还是点击在精灵外部。

1.精灵像素检测法

首先来说下精灵像素检测法,因为它实现起来比较简单也好理解。UGUI在处理控件是否被点击的时候,主要是根据IsRaycastLocationValid这个方法的返回值来进行判断的,而这个方法用到的基本原理则是判断指定点对应像素的RGBA数值中的Alpha是否大于某个指定临界值。

​ 例如,我们知道半透明通常是指Alpha=0.5,而对一个后缀名为png格式的图片来说半透明或者完全透明的区域理论上不应该被响应的,所以根据这个原理,我们只需要设定一个透明度的临界值,然后对当前鼠标位置对应的像素进行判断就可以了,因此这种方法叫做精灵像素检测。

​ 对于上面的这个IsRaycastLocationValid接口,我们可以通过下载UGUI源码或者反编译的方式看到它的实现:

public virtual bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
{
    //当透明度>=1.0时,表示点击在可响应区域返回true
    if (this.m_EventAlphaThreshold >= 1f)
    {
        return true;
    }

    //当没有指定精灵时返回true,因为不指定Spirte的时候,Unity将其区域填充为默认的白色,全部区域都是可以响应点击的
    Sprite overrideSprite = this.overrideSprite;
    if (overrideSprite == null)
    {
        return true;
    }

    //坐标系转换 
    Vector2 local;
    RectTransformUtility.ScreenPointToLocalPointInRectangle(base.rectTransform, screenPoint, eventCamera, ref local);
    Rect pixelAdjustedRect = base.GetPixelAdjustedRect();
    local.x += base.rectTransform.get_pivot().x * pixelAdjustedRect.get_width();
    local.y += base.rectTransform.get_pivot().y * pixelAdjustedRect.get_height();
    local = this.MapCoordinate(local, pixelAdjustedRect);
    Rect textureRect = overrideSprite.get_textureRect();
    Vector2 vector = new Vector2(local.x / textureRect.get_width(), local.y / textureRect.get_height());

    //计算屏幕坐标对应的UV坐标
    float num = Mathf.Lerp(textureRect.get_x(), textureRect.get_xMax(), vector.x) / (float)overrideSprite.get_texture().get_width();
    float num2 = Mathf.Lerp(textureRect.get_y(), textureRect.get_yMax(), vector.y) / (float)overrideSprite.get_texture().get_height();
    bool result; 8
     //核心方法:像素检测
    try
    {
        result = (overrideSprite.get_texture().GetPixelBilinear(num, num2).a >= this.m_EventAlphaThreshold);
    }
    catch (UnityException ex)
    {
        Debug.LogError("Using clickAlphaThreshold lower than 1 on Image whose sprite texture cannot be read. " + ex.Message + " Also make sure to disable sprite packing for this sprite.", this);
        result = true;
    }

    return result;
}

可以看到大概的思路就是经过一系列的坐标转换之后,将一个UV坐标的Alpha值与临界值作比较。基于这个像素这个思路我们又可以衍生出两种解决方案,一是直接更改临界值,二是在像素检测的思路上进行拓展与重写,定制我们自己的像素检测方法。

先来看下第一种直接更改阈值的方法,Unity在Image组件中为我们暴露出了一条属性alphaHitTestMinimumThreshold。关于它的含义我们可以参考Unity的官方文档:

img

图3:alphaHitTestMinimumThreshold属性文档

大概的意思就是点击的时候会将该像素的Alpah值与该阈值进行比较,Alpha小于该阈值的部分的点击事件会被忽略掉,意思也就是某一像素的Alpha只有大于设定的阈值,你才能接到响应事件。

​ 当值为1的时候,表示只有完全不透明的部分才能响应。默认值为0,即一个Image不管透明不透明的部分,都会参与事件的响应。为了能够让alphaHitTestMinimumThreshold这个属性生效和工作,我们需要把Advance选项中的Read/Writeable属性勾选上

因此我们将alphaHitTestMinimumThreshold值设置为一个合理的范围就可以实现不规则区域的点击了,代码如下:

 using System.Collections;
 using System.Collections.Generic;
 using UnityEngine;
 using UnityEngine.UI;
 
 /// <summary>
 /// 不规则区域Button
/// </summary>
 [RequireComponent(typeof(RectTransform))]
 [RequireComponent(typeof(Image))]
 public class IrregulaButton : MonoBehaviour
 {
     [Tooltip("设定Sprite响应的Alpha阈值")]
    [Range(0, 0.5f)]
     public float alpahThreshold = 0.5f;
 
     private void Awake()
     {
         var image = this.GetComponent<Image>();
         if (null != image)
         {
            image.alphaHitTestMinimumThreshold = alpahThreshold;
         }
     }
 }

第二种基于像素检测的解决方案是自己重写IsRaycastLocationValid接口里面像素检测方法,将屏幕坐标转换为UI坐标,然后再根据Sprite的类型做一些处理,最后根据x,y坐标取出像素的Alpha值与我们的阈值进行比较,具体代码如下:

using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// 不规则区域图形检测组件
/// </summary>
[RequireComponent(typeof(RectTransform))]
[RequireComponent(typeof(Image))]
public class IrregularRaycastMask : MonoBehaviour, ICanvasRaycastFilter
{
    private Image _image;
    private Sprite _sprite;

    [Tooltip("设定Sprite响应的Alpha阈值")]
    [Range(0, 0.5f)]
    public float alpahThreshold = 0.5f;

    void Start()
    {
        _image = GetComponent<Image>();
    }

    /// <summary>
    /// 重写IsRaycastLocationValid接口
    /// </summary>
    /// <param name="sp"></param>
    /// <param name="eventCamera"></param>
    /// <returns></returns>
    public bool IsRaycastLocationValid(Vector2 sp, Camera eventCamera)
    {
        _sprite = _image.sprite;

        var rectTransform = (RectTransform)transform;
        Vector2 localPositionPivotRelative;
        RectTransformUtility.ScreenPointToLocalPointInRectangle((RectTransform)transform, sp, eventCamera, out localPositionPivotRelative);

        // 转换为以屏幕左下角为原点的坐标系
        var localPosition = new Vector2(localPositionPivotRelative.x + rectTransform.pivot.x * rectTransform.rect.width,
            localPositionPivotRelative.y + rectTransform.pivot.y * rectTransform.rect.height);

        var spriteRect = _sprite.textureRect;
        var maskRect = rectTransform.rect;

        var x = 0;
        var y = 0;
        // 转换为纹理空间坐标
        switch (_image.type)
        {

            case Image.Type.Sliced:
                {
                    var border = _sprite.border;
                    // x 轴裁剪
                    if (localPosition.x < border.x)
                    {
                        x = Mathf.FloorToInt(spriteRect.x + localPosition.x);
                    }
                    else if (localPosition.x > maskRect.width - border.z)
                    {
                        x = Mathf.FloorToInt(spriteRect.x + spriteRect.width - (maskRect.width - localPosition.x));
                    }
                    else
                    {
                        x = Mathf.FloorToInt(spriteRect.x + border.x +
                                             ((localPosition.x - border.x) /
                                             (maskRect.width - border.x - border.z)) *
                                             (spriteRect.width - border.x - border.z));
                    }
                    // y 轴裁剪
                    if (localPosition.y < border.y)
                    {
                        y = Mathf.FloorToInt(spriteRect.y + localPosition.y);
                    }
                    else if (localPosition.y > maskRect.height - border.w)
                    {
                        y = Mathf.FloorToInt(spriteRect.y + spriteRect.height - (maskRect.height - localPosition.y));
                    }
                    else
                    {
                        y = Mathf.FloorToInt(spriteRect.y + border.y +
                                             ((localPosition.y - border.y) /
                                             (maskRect.height - border.y - border.w)) *
                                             (spriteRect.height - border.y - border.w));
                    }
                }
                break;
            case Image.Type.Simple:
            default:
                {
                    // 转换为统一UV空间
                    x = Mathf.FloorToInt(spriteRect.x + spriteRect.width * localPosition.x / maskRect.width);
                    y = Mathf.FloorToInt(spriteRect.y + spriteRect.height * localPosition.y / maskRect.height);
                }
                break;
        }

        // 如果texture导入过程报错,则删除组件
        try
        {
            return _sprite.texture.GetPixel(x, y).a > alpahThreshold;
        }
        catch (UnityException e)
        {
            Debug.LogError("Mask texture not readable, set your sprite to Texture Type 'Advanced' and check 'Read/Write Enabled'" + e.Message);
            Destroy(this);
            return false;
        }
    }
}

最后为了验证我们的组件是否生效,可以在按钮上挂载一个ButtonClickCounter 脚本,当接收到点击事件的时候,记录点击次数并打印到控制台方便观察,具体代码如下:

  using System.Collections;
  using System.Collections.Generic;
  using UnityEngine;
  using UnityEngine.UI;
  
  /// <summary>
  /// 按钮点击次数计数器
  /// </summary>
  public class ButtonClickCounter : MonoBehaviour
 {
     private int count = 0;
     private string btnName;
 
    void Start()
     {
         var text = this.transform.Find("Text").GetComponent<Text>();
         btnName = text.text;
     }
 

     public void Click()
     {
         count++;
         Debug.Log(string.Format("{0}点击了{1}次!", btnName, count));
     }
 }

我们只要简单地直接把组件挂载到Image上面便可以生效了,具体截图如下:

img

图4:不规则区域检测组件使用

2.通过算法计算碰撞区域法

对于这种实现不规则碰撞区域的方法,我并没有进行深入地研究,因为我觉得挑选一个可靠的检测碰撞算法不是很容易,既要考虑到它的精准性又要考虑当图形复杂以后的计算效率,因此从易用性上面来讲,不如第一种实现方案好。

​ 关于这种方法的实现和原理,我也是从网上搜集的一些资料进行整理的,感兴趣的读者可以深入研究一下哈,下面很多内容都是搜集整理网上大神的文章的资料得来的,其中给出了许多链接,大家可以直接参看链接里面的内容。

该方法是指给精灵(Sprite)添加一个多边形碰撞器(Rolygon Collider)组件,利用该组件来标记精灵的边界,这样通过比较鼠标位置和边界可以判断点击是否发生在精灵内部。

​ 关于这个算法与实现,PayneQin大神已经在他的博客中做了很详细的解析和说明,大家可以直接去看他的博客。知乎上关于判断一个点是否在多边形内部也有很多算法地讨论,具体可以看这里。其中这篇文献提供了判断一个点是否在任意多边形内部的两种方法,分别为Corssing Number和Winding Number。这两种方法在理论层面的相关细节请大家自行阅读这篇文章PayneQin大神选择的是前者实现,其基本思想是计算从该点引出的射线与多边形边界相交的次数,当其为奇数时表示该点在多边形内部,当其为偶数时表示在多边形外部。我也在网上找到了相关的实现(偷懒):

bool ContainsPoint2(Vector2[] polyPoints, Vector2 p)
{
    //统计射线和多边形交叉次数
    int cn = 0;

    //遍历多边形顶点数组中的每条边
    for (int i = 0; i < polyPoints.Length - 1; i++)
    {
        //正常情况下这一步骤可以忽略这里是为了统一坐标系
        polyPoints[i].x += transform.GetComponent<RectTransform>().position.x;
        polyPoints[i].y += transform.GetComponent<RectTransform>().position.y;

        //从当前位置发射向上向下两条射线
        if (((polyPoints[i].y <= p.y) && (polyPoints[i + 1].y > p.y))
            || ((polyPoints[i].y > p.y) && (polyPoints[i + 1].y <= p.y)))
        {
            //compute the actual edge-ray intersect x-coordinate
            float vt = (float)(p.y - polyPoints[i].y) / (polyPoints[i + 1].y - polyPoints[i].y);

            //p.x < intersect
            if (p.x < polyPoints[i].x + vt * (polyPoints[i + 1].x - polyPoints[i].x))
                ++cn;
        }
    }

    //实际测试发现cn为0的情况即为宣雨松算法中存在的问题
    //所以在这里进行屏蔽直接返回false这样就可以让透明区域不再响应
    if (cn == 0)
        return false;

    //返回true表示在多边形外部否则表示在多边形内部
    return cn % 2 == 0;
}

基于上面算法制作的多边形碰撞器实现的不规则按钮,以正五边形举例(PayneQin大神实现,在下只是搬运工):

 1/*
* 基于多边形碰撞器实现的不规则按钮 
  * 作者:PayneQin
  * 日期:2016年7月9日
  */

using UnityEngine;
using System.Collections;
using UnityEngine.UI;
using UnityEngine.EventSystems;

public class UnregularButtonWithCollider : MonoBehaviour, IPointerClickHandler
{
    /// <summary>
    /// 多边形碰撞器
    /// </summary>
    PolygonCollider2D polygonCollider;

    void Start()
    {
        //获取多边形碰撞器
        polygonCollider = transform.GetComponent<PolygonCollider2D>();
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        //对2D屏幕坐标系进行转换
        Vector2 local;
        local.x = eventData.position.x - (float)Screen.width / 2.0f;
        local.y = eventData.position.y - (float)Screen.height / 2.0f;
        if (ContainsPoint(polygonCollider.points, local))
        {

            Debug.Log("这是一个正五边形!");
        }

    }

    /// <summary>
    /// 判断指定点是否在给定的任意多边形内
    /// </summary>
    bool ContainsPoint(Vector2[] polyPoints, Vector2 p)
    {
        //统计射线和多边形交叉次数
        int cn = 0;

        //遍历多边形顶点数组中的每条边
        for (int i = 0; i < polyPoints.Length - 1; i++)
        {
            //正常情况下这一步骤可以忽略这里是为了统一坐标系
            polyPoints[i].x += transform.GetComponent<RectTransform>().position.x;
            polyPoints[i].y += transform.GetComponent<RectTransform>().position.y;
            //从当前位置发射向上向下两条射线
            if (((polyPoints[i].y <= p.y) && (polyPoints[i + 1].y > p.y))
                || ((polyPoints[i].y > p.y) && (polyPoints[i + 1].y <= p.y)))
            {
                //compute the actual edge-ray intersect x-coordinate
                float vt = (float)(p.y - polyPoints[i].y) / (polyPoints[i + 1].y - polyPoints[i].y);

                //p.x < intersect
                if (p.x < polyPoints[i].x + vt * (polyPoints[i + 1].x - polyPoints[i].x))
                    ++cn;
            }

        }
        //实际测试发现cn为0的情况即为宣雨松算法中存在的问题
        //所以在这里进行屏蔽直接返回false这样就可以让透明区域不再响应
        if (cn == 0)
            return false;

        //返回true表示在多边形外部否则表示在多边形内部
        return cn % 2 == 0;
    }

四、需要注意的坑

在像素检测法实现UGUI不规则碰撞区域的过程中,也遇到了很多需要注意的问题,在这里和大家分享一下:

1.图片需要开启Read/Writeable属性

如果选择使用像素检测法实现的话,需要注意开启Texture的Read/Writeable属性(我们需要读写该Texture的像素值),而且他必须是Advance类型。这样这张图片就不能打进我们的图集里面了,必须以散图的形式存在于工程当中,不利于统一管理。而且开启了Read/Writeable属性属性的话,在程序运行的时候,它会在内存中多复制出来一份,必然会影响到游戏的运行效率。所以尽量还是减少游戏中这种不规则UI的出现。

2.像素检测有偏移,不准确的问题

在实际操作的过程中,发现实际点击的时候经常会有偏移(经常偏下一些),有的透明的地方可以点击,而明明是不透明的地方却不能点击。刚开始还以为是图片格式或者是图片本身有什么问题,反反复复确认了好多次。直到后来在unity论坛上找到了这篇文章,才找到问题的症结所在。

对于如下图所示的这种周围有空白区域的图片,我们需要在Unity图片导入设置的时候,将Mesh Type格式设置为Full Rect,而unity导入时默认帮我们设置的是Tight模式。

img img

图5:周围有空白的图片

图6:正确的导入设置

那么,它们有什么区别呢?关于它们的区别,Unity官方是这样解释的:

img

图7:Full Rect和Tight两种Mesh Type的官方解释

总的来说就是,用Tight模式的话,如果你的图片周围有空白像素,它会帮你压缩掉减小面积,以减少DrawCall,但是会增加Sprite的面数。如果用Full Rect模式不会压缩,也不会增加面数,直接创建一个quab,然后把图片扔上去。如果尺寸小于32x32的话,Unity默认使用Full Rect格式导入,否则使用Tight格式导入。因此如果我们不对Mesh Type进行设置的话,原来的一些空白区域就相当于裁剪掉了,这样相对于左下角的坐标来说,一些像素坐标就发生了偏移,而我们使用的是像素检测方法,必然也会导致偏移误差。

五、总结

通过本篇博客,和大家一起学习了如何在Unity中实现UGUI不规则区域的点击,希望本篇博客能为大家的工作过程中带来一些帮助与启发。

本篇博客中的样例工程已经同步至Github:https://github.com/XINCGer/Unity3DTraining/tree/master/SomeTest/IrregularButton,欢迎大家Fork!

参考资料:

  • https://blog.csdn.net/qinyuanpei/article/details/51868638
  • https://blog.csdn.net/shenmifangke/article/details/53504036
    入,否则使用Tight格式导入。因此如果我们不对Mesh Type进行设置的话,原来的一些空白区域就相当于裁剪掉了,这样相对于左下角的坐标来说,一些像素坐标就发生了偏移,而我们使用的是像素检测方法,必然也会导致偏移误差。

五、总结

通过本篇博客,和大家一起学习了如何在Unity中实现UGUI不规则区域的点击,希望本篇博客能为大家的工作过程中带来一些帮助与启发。

本篇博客中的样例工程已经同步至Github:https://github.com/XINCGer/Unity3DTraining/tree/master/SomeTest/IrregularButton,欢迎大家Fork!

参考资料:

  • https://blog.csdn.net/qinyuanpei/article/details/51868638
  • https://blog.csdn.net/shenmifangke/article/details/53504036
  • https://www.zhihu.com/question/26551754?f3fb8ead20=b6b9d1289bcc893ff2fa0abd1e65fc52
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值