【Unity游戲開發】UGUI不規則區域點擊的實現


一、簡介

  馬三從上一家公司離職了,最近一直在出去面試,忙得很,所以這一篇博客拖到現在才寫出來。馬三在上家公司工作的時候,曾處理了一個UGUI不規則區域點擊的問題,制作過程中也有一些收獲和需要注意坑,因此記錄成博客與大家分享。眾所周知在UGUI中,響應點擊通常是依附在一張圖片上的,而圖片不管美術怎么給你切,導進Unity之后都是一個矩形,如果要做其他形狀,最多只能旋轉一下,或者自己做一些處理。而為了美術效果,很多時候我們不得不需要特定形狀的UI,並且讓它們實現精准的響應點擊。例如下圖就是一個不規則的點擊區域。

      圖1:UGUI不規則點擊區域示意圖

  下面是處理了不規則區域點擊后的演示效果,當點擊按鈕的時候,會對點擊次數進行累加並且打印到控制台。可以看到進行了不規則區域點擊處理以后,對我們原來的普通矩形Sprite的點擊不會產生到影響,而不規則區域的表現效果也符合我們的預期。

圖2:規則區域與不規則區域點擊效果對比

二、針對UGUI不規則區域點擊的兩種處理方法

  針對UGUI的不規則區域響應點擊,一般來說有兩種處理辦法:

  1.精靈像素檢測:該方法是指通過讀取精靈(Sprite)在某一點的像素值(RGBA),如果該點的像素值中的Alpha小於一定的閾值(比如0.5)則表示該點處是透明的,即用戶點擊的位置在精靈邊界以外,否則用戶點擊的位置在精靈邊界內部。

  2.通過算法計算碰撞區域:通過一定的算法,手動計算出碰撞區域,然后在判斷用戶是點擊在了精靈上面,還是點擊在精靈外部。

1.精靈像素檢測法

  首先來說下精靈像素檢測法,因為它實現起來比較簡單也好理解。uGUI在處理控件是否被點擊的時候,主要是根據IsRaycastLocationValid這個方法的返回值來進行判斷的,而這個方法用到的基本原理則是判斷指定點對應像素的RGBA數值中的Alpha是否大於某個指定臨界值。例如,我們知道半透明通常是指Alpha=0.5,而對一個后綴名為png格式的圖片來說半透明或者完全透明的區域理論上不應該被響應的,所以根據這個原理,我們只需要設定一個透明度的臨界值,然后對當前鼠標位置對應的像素進行判斷就可以了,因此這種方法叫做精靈像素檢測。對於上面的這個IsRaycastLocationValid接口,我們可以通過下載UGUI源碼或者反編譯的方式看到它的實現:

 1 public virtual bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
 2 {
 3     //當透明度>=1.0時,表示點擊在可響應區域返回true
 4     if(this.m_EventAlphaThreshold >= 1f){
 5         return true;
 6     }
 7 
 8     //當沒有指定精靈時返回true,因為不指定Spirte的時候,Unity將其區域填充為默認的白色,全部區域都是可以響應點擊的
 9     Sprite overrideSprite = this.overrideSprite;
10     if(overrideSprite == null){
11         return true;
12     }
13 
14     //坐標系轉換 
15     Vector2 local;
16     RectTransformUtility.ScreenPointToLocalPointInRectangle(base.rectTransform, screenPoint, eventCamera, ref local);
17     Rect pixelAdjustedRect = base.GetPixelAdjustedRect ();
18     local.x += base.rectTransform.get_pivot ().x * pixelAdjustedRect.get_width ();
19     local.y += base.rectTransform.get_pivot ().y * pixelAdjustedRect.get_height ();
20     local = this.MapCoordinate(local, pixelAdjustedRect);
21     Rect textureRect = overrideSprite.get_textureRect ();
22     Vector2 vector = new Vector2(local.x / textureRect.get_width (), local.y / textureRect.get_height ());
23 
24     //計算屏幕坐標對應的UV坐標
25     float num = Mathf.Lerp(textureRect.get_x (), textureRect.get_xMax (), vector.x) / (float)overrideSprite.get_texture().get_width();
26     float num2 = Mathf.Lerp(textureRect.get_y (), textureRect.get_yMax (), vector.y) / (float)overrideSprite.get_texture().get_height();
27     bool result;
28 
29     //核心方法:像素檢測
30     try{
31         result = (overrideSprite.get_texture().GetPixelBilinear(num, num2).a >= this.m_EventAlphaThreshold);
32     }catch(UnityException ex){
33         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);
34         result = true;
35     }
36 
38     return result;
39 }

   可以看到大概的思路就是經過一系列的坐標轉換之后,將一個UV坐標的Alpha值與臨界值作比較。基於這個像素這個思路我們又可以衍生出兩種解決方案,一是直接更改臨界值,二是在像素檢測的思路上進行拓展與重寫,定制我們自己的像素檢測方法。

  先來看下第一種直接更改閾值的方法,Unity在Image組件中為我們暴露出了一條屬性alphaHitTestMinimumThreshold。關於它的含義我們可以參考Unity的官方文檔:

   圖3:alphaHitTestMinimumThreshold屬性文檔

  大概的意思就是點擊的時候會將該像素的Alpah值與該閾值進行比較,Alpha小於該閾值的部分的點擊事件會被忽略掉,意思也就是某一像素的Alpha只有大於設定的閾值,你才能接到響應事件。當值為1的時候,表示只有完全不透明的部分才能響應。默認值為0,即一個Image不管透明不透明的部分,都會參與事件的響應。為了能夠讓alphaHitTestMinimumThreshold這個屬性生效和工作,我們需要把Advance選項中的Read/Writeable屬性勾選上

  因此我們將alphaHitTestMinimumThreshold值設置為一個合理的范圍就可以實現不規則區域的點擊了,代碼如下:

 1 using System.Collections;
 2 using System.Collections.Generic;
 3 using UnityEngine;
 4 using UnityEngine.UI;
 5 
 6 /// <summary>
 7 /// 不規則區域Button
 8 /// </summary>
 9 [RequireComponent(typeof(RectTransform))]
10 [RequireComponent(typeof(Image))]
11 public class IrregulaButton : MonoBehaviour
12 {
13     [Tooltip("設定Sprite響應的Alpha閾值")]
14     [Range(0, 0.5f)]
15     public float alpahThreshold = 0.5f;
16 
17     private void Awake()
18     {
19         var image = this.GetComponent<Image>();
20         if (null != image)
21         {
22             image.alphaHitTestMinimumThreshold = alpahThreshold;
23         }
24     }
25 }

  第二種基於像素檢測的解決方案是自己重寫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 腳本,當接收到點擊事件的時候,記錄點擊次數並打印到控制台方便觀察,具體代碼如下:

 1 using System.Collections;
 2 using System.Collections.Generic;
 3 using UnityEngine;
 4 using UnityEngine.UI;
 5 
 6 /// <summary>
 7 /// 按鈕點擊次數計數器
 8 /// </summary>
 9 public class ButtonClickCounter : MonoBehaviour
10 {
11     private int count = 0;
12     private string btnName;
13 
14     void Start()
15     {
16         var text = this.transform.Find("Text").GetComponent<Text>();
17         btnName = text.text;
18     }
19 
20 
21     public void Click()
22     {
23         count++;
24         Debug.Log(string.Format("{0}點擊了{1}次!", btnName, count));
25     }
26 }

  我們只要簡單地直接把組件掛載到Image上面便可以生效了,具體截圖如下:

圖4:不規則區域檢測組件使用

2.通過算法計算碰撞區域法

  對於這種實現不規則碰撞區域的方法,馬三並沒有進行深入地研究,因為馬三覺得挑選一個可靠的檢測碰撞算法不是很容易,既要考慮到它的精准性又要考慮當圖形復雜以后的計算效率,因此從易用性上面來講,不如第一種實現方案好。關於這種方法的實現和原理,馬三也是從網上搜集的一些資料進行整理的,感興趣的讀者可以深入研究一下哈,下面很多內容都是馬三搜集整理網上大神的文章的資料得來的,其中給出了許多鏈接,大家可以直接參看鏈接里面的內容。

  該方法是指給精靈(Sprite)添加一個多邊形碰撞器(Rolygon Collider)組件,利用該組件來標記精靈的邊界,這樣通過比較鼠標位置和邊界可以判斷點擊是否發生在精靈內部。關於這個算法與實現,PayneQin大神已經在他的博客中做了很詳細的解析和說明,大家可以直接去看他的博客知乎上關於判斷一個點是否在多邊形內部也有很多算法地討論,具體可以看這里。其中這篇文獻提供了判斷一個點是否在任意多邊形內部的兩種方法,分別為Corssing Number和Winding Number。這兩種方法在理論層面的相關細節請大家自行閱讀這篇文章PayneQin大神選擇的是前者實現,其基本思想是計算從該點引出的射線與多邊形邊界相交的次數,當其為奇數時表示該點在多邊形內部,當其為偶數時表示在多邊形外部。馬三在網上找到了相關的實現(偷懶):

 1 bool ContainsPoint2(Vector2[] polyPoints,Vector2 p)
 2 {
 3     //統計射線和多邊形交叉次數
 4     int cn = 0;
 5 
 6     //遍歷多邊形頂點數組中的每條邊
 7     for(int i=0; i<polyPoints.Length-1; i++) 
 8     {
 9         //正常情況下這一步驟可以忽略這里是為了統一坐標系
10         polyPoints [i].x += transform.GetComponent<RectTransform> ().position.x;
11         polyPoints [i].y += transform.GetComponent<RectTransform> ().position.y;
12 
13         //從當前位置發射向上向下兩條射線
14         if(((polyPoints [i].y <= p.y) && (polyPoints [i + 1].y > p.y)) 
15             || ((polyPoints [i].y > p.y) && (polyPoints [i + 1].y <= p.y)))
16         {
17             //compute the actual edge-ray intersect x-coordinate
18             float vt = (float)(p.y - polyPoints [i].y) / (polyPoints [i + 1].y - polyPoints [i].y);
19 
20             //p.x < intersect
21             if(p.x < polyPoints [i].x + vt * (polyPoints [i + 1].x - polyPoints [i].x))
22                 ++cn;
23         }
24     }
25 
26     //實際測試發現cn為0的情況即為宣雨松算法中存在的問題
27     //所以在這里進行屏蔽直接返回false這樣就可以讓透明區域不再響應
28     if(cn == 0)
29         return false;
30 
31     //返回true表示在多邊形外部否則表示在多邊形內部
32     return cn % 2 == 0;
33 }

  基於上面算法制作的多邊形碰撞器實現的不規則按鈕,以正五邊形舉例(PayneQin大神實現,馬三只是搬運工):

 1 /*
 2  * 基於多邊形碰撞器實現的不規則按鈕 
 3  * 作者:PayneQin
 4  * 日期:2016年7月9日
 5  */
 6 
 7 using UnityEngine;
 8 using System.Collections;
 9 using UnityEngine.UI;
10 using UnityEngine.EventSystems;
11 
12 public class UnregularButtonWithCollider : MonoBehaviour,IPointerClickHandler
13 {
14     /// <summary>
15     /// 多邊形碰撞器
16     /// </summary>
17     PolygonCollider2D polygonCollider;
18 
19     void Start()
20     {
21         //獲取多邊形碰撞器
22         polygonCollider = transform.GetComponent<PolygonCollider2D>();
23     }
24 
25 
26     public void OnPointerClick(PointerEventData eventData)
27     {
28         //對2D屏幕坐標系進行轉換
29         Vector2 local;
30         local.x = eventData.position.x - (float)Screen.width / 2.0f;
31         local.y = eventData.position.y - (float)Screen.height / 2.0f;
32         if(ContainsPoint(polygonCollider.points,local))
33         {
34 
35             Debug.Log ("這是一個正五邊形!");
36         }
37 
38     }
39 
40     /// <summary>
41     /// 判斷指定點是否在給定的任意多邊形內
42     /// </summary>
43     bool ContainsPoint(Vector2[] polyPoints,Vector2 p)
44     {
45         //統計射線和多邊形交叉次數
46         int cn = 0;
47 
48         //遍歷多邊形頂點數組中的每條邊
49         for(int i=0; i<polyPoints.Length-1; i++) 
50         {
51             //正常情況下這一步驟可以忽略這里是為了統一坐標系
52             polyPoints [i].x += transform.GetComponent<RectTransform> ().position.x;
53             polyPoints [i].y += transform.GetComponent<RectTransform> ().position.y;
54 
55             //從當前位置發射向上向下兩條射線
56             if(((polyPoints [i].y <= p.y) && (polyPoints [i + 1].y > p.y)) 
57                || ((polyPoints [i].y > p.y) && (polyPoints [i + 1].y <= p.y)))
58             {
59                 //compute the actual edge-ray intersect x-coordinate
60                 float vt = (float)(p.y - polyPoints [i].y) / (polyPoints [i + 1].y - polyPoints [i].y);
61 
62                 //p.x < intersect
63                 if(p.x < polyPoints [i].x + vt * (polyPoints [i + 1].x - polyPoints [i].x))
64                     ++cn;
65             }
66 
67         }
68 
69         //實際測試發現cn為0的情況即為宣雨松算法中存在的問題
70         //所以在這里進行屏蔽直接返回false這樣就可以讓透明區域不再響應
71         if(cn == 0)
72             return false;
73 
74         //返回true表示在多邊形外部否則表示在多邊形內部
75         return cn % 2 == 0;
76     }

三、需要注意的坑

  在像素檢測法實現UGUI不規則碰撞區域的過程中,馬三也遇到了很多需要注意的問題,在這里和大家分享一下:

1.圖片需要開啟Read/Writeable屬性

  如果選擇使用像素檢測法實現的話,需要注意開啟Texture的Read/Writeable屬性(我們需要讀寫該Texture的像素值),而且他必須是Advance類型。這樣這張圖片就不能打進我們的圖集里面了,必須以散圖的形式存在於工程當中,不利於統一管理。而且開啟了Read/Writeable屬性屬性的話,在程序運行的時候,它會在內存中多復制出來一份,必然會影響到游戲的運行效率。所以盡量還是減少游戲中這種不規則UI的出現。

2.像素檢測有偏移,不准確的問題

  馬三在實際操作的過程中,發現實際點擊的時候經常會有偏移(經常偏下一些),有的透明的地方可以點擊,而明明是不透明的地方卻不能點擊。剛開始馬三還以為是圖片格式或者是圖片本身有什么問題,反反復復確認了好多次。直到后來馬三在unity論壇上找到了這篇文章,才找到問題的症結所在。

  對於如下圖所示的這種周圍有空白區域的圖片,我們需要在Unity圖片導入設置的時候,將Mesh Type格式設置為Full Rect,而unity導入時默認幫我們設置的是Tight模式。

         

圖5:周圍有空白的圖片                                                                                     圖6:正確的導入設置

   那么,它們有什么區別呢?關於它們的區別,Unity官方是這樣解釋的:

圖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
  • https://www.zhihu.com/question/26551754?f3fb8ead20=b6b9d1289bcc893ff2fa0abd1e65fc52

 

作者:馬三小伙兒
出處:https://www.cnblogs.com/msxh/p/9283266.html
請尊重別人的勞動成果,讓分享成為一種美德,歡迎轉載。另外,文章在表述和代碼方面如有不妥之處,歡迎批評指正。留下你的腳印,歡迎評論!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM