一、簡介
馬三從上一家公司離職了,最近一直在出去面試,忙得很,所以這一篇博客拖到現在才寫出來。馬三在上家公司工作的時候,曾處理了一個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
請尊重別人的勞動成果,讓分享成為一種美德,歡迎轉載。另外,文章在表述和代碼方面如有不妥之處,歡迎批評指正。留下你的腳印,歡迎評論!