全文解析圓形Image組件的實(shí)現(xiàn)原理,取關(guān)鍵代碼介紹算法細(xì)節(jié),源碼已經(jīng)上傳Github下載地址,歡迎下載試用。
一、Unity原生Image組件實(shí)現(xiàn)圓形圖片的缺陷#
Mask渲染消耗##
許多游戲項(xiàng)目里免不了有很多圖片是以圓形形式展示的,如頭像,技能Icon等,一般做法是使用Image組件,再加上一個圓形的Mask。實(shí)現(xiàn)非常簡單,但因?yàn)橛绊懶?,許多關(guān)于ui方面的Unity效率優(yōu)化文章,都會建議開發(fā)者少用Mask。
- 使用Mask會額外消耗多一個Drawcall來創(chuàng)建Mask,做像素剔除。
- Mask不利于層級合并。原本同一圖集里的ui可以合并層級,僅需一個Drawcall渲染,如果加入Mask,就會將一個ui整體分割成了Mask下的子ui與其他ui,兩者只能各自進(jìn)行層級合并,至少要兩個Drawcall。Mask用得多了,一個ui整體會被分割得四分五裂,就會嚴(yán)重影響層次合并的效率了。
無法精確點(diǎn)擊##
Image+Mask的實(shí)現(xiàn)的圓形,點(diǎn)擊判斷不精確,點(diǎn)擊到圓形外的四個邊角仍會觸發(fā)點(diǎn)擊,雖然可以通過另外設(shè)置eventAlphaThreshold實(shí)現(xiàn)像素級判斷,但這個方法有天生缺陷,并不是好的選擇。
二、應(yīng)運(yùn)而生的CircleImage組件#
了解了原有做法的缺陷后,我們希望自制圓形Image組件,解決這些問題,并且盡量簡單易用。
干掉Mask##
雖說少用Mask,但游戲項(xiàng)目里總免不了有些圖片要以圓形形式顯示,不得不用,怎么辦?轉(zhuǎn)而從渲染層面思考,Image組件默認(rèn)以矩形形式渲染,如果有辦法定制一個特殊Image組件,重新寫入圓形形狀的渲染頂點(diǎn)、三角面片信息,根本不需要Mask就能渲染出圓形Image。
我們看到的屏幕顯示,是通過GPU渲染出來的,而GPU渲染以三角面片為最小單元。所有的圖形畫面,本質(zhì)是由無數(shù)三角面片組成的,例如矩形是由兩個直角三角面片組成的;圓形可以由若干個相同的以圓心為頂點(diǎn)的等腰三角面片組成正多邊形,近似模擬出來。三角面片分得多了,多邊形的邊越多,夾角越大,就越近似圓形。

另一種精確點(diǎn)擊方案##
組件不再以像素Alpha值判斷是否點(diǎn)擊,而是用Ray-Crossing算法計(jì)算點(diǎn)擊點(diǎn)是否在落多邊形內(nèi),來實(shí)現(xiàn)精確點(diǎn)擊。
三、組件實(shí)現(xiàn)#
繪制圓形##
Unity引擎并不開源,好在其中ugui框架是開源的,簡單看下Image代碼:
public class Image : MaskableGraphic, ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter
Image類繼承自MaskableGraphic,實(shí)現(xiàn)了ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter這三個接口。最關(guān)鍵的是MaskableGraphic類,MaskableGraphic負(fù)責(zé)繪制邏輯,MaskableGraphic繼承自Graphic,Graphic里有個OnPopulateMesh函數(shù),這正是我們需要的函數(shù)。

當(dāng)UI元素生成頂點(diǎn)數(shù)據(jù)時會調(diào)用OnPopulateMesh(VertexHelper vh)函數(shù),我們只要繼承改寫OnPopulateMesh函數(shù),將原先的矩形頂點(diǎn)數(shù)據(jù)清除,改寫入圓形頂點(diǎn)數(shù)據(jù),這樣渲染出來的自然是圓形圖片。
我們希望這個圓形Image組件,能夠自定義某些參數(shù),比如自定義圓形等分面數(shù)(即由多少個三角形組成這個圓形),自定義圓形填充比例等。
由于Unity的限制,繼承UnityEngine基類的派生類不能在Inspector里顯示自定義參數(shù)。為了解決這點(diǎn),我們再造個小輪子,新建BaseImage類來代替Image類。原Image源碼有近千行代碼,BaseImage對其進(jìn)行了部分精簡,只支持Simple Image Type,并去掉了eventAlphaThreshold的相關(guān)代碼。經(jīng)過刪減,得到一個百行代碼的BaseImage類,精簡版Image就完成了。
接著,新建CircleImage類繼承BaseImage,重寫OnPopulateMesh方法。
protected override void OnPopulateMesh(VertexHelper vh)
OnPopulateMesh方法的VertexHelper參數(shù),保存著原來的頂點(diǎn)信息,因?yàn)橐匦聜魅腠旤c(diǎn)信息,需先調(diào)用Clear方法,清除VertexHelper原有頂點(diǎn)信息。在計(jì)算頂點(diǎn)前,通過DataUtility.GetOuterUV(overrideSprite)獲取貼圖uv信息,簡單計(jì)算獲得中心點(diǎn),縮放等信息。
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
Vector4 uv = overrideSprite != null ? DataUtility.GetOuterUV(overrideSprite) : Vector4.zero;
float uvCenterX = (uv.x + uv.z) * 0.5f;
float uvCenterY = (uv.y + uv.w) * 0.5f;
float uvScaleX = (uv.z - uv.x) / tw;
float uvScaleY = (uv.w - uv.y) / th;
...
}
知道了等分面片數(shù)segements,我們可以算出每個面片的頂點(diǎn)夾角,面片數(shù)segements與填充比例fillPercent相乘,就知道要用多少個面片來顯示圓形/扇形
float degreeDelta = (float)(2 * Mathf.PI / segements);
int curSegements = (int)(segements * fillPercent);
通過RectTransform獲取矩形寬高,計(jì)算出半徑
float tw = rectTransform.rect.width;
float th = rectTransform.rect.height;
float outerRadius = rectTransform.pivot.x * tw;
已經(jīng)有了半徑,夾角信息,根據(jù)圓形點(diǎn)坐標(biāo)公式(radius * cosA,radius * sinA)可以算出頂點(diǎn)坐標(biāo),每次迭代新建UIVertex,將求出的坐標(biāo),color,uv等參數(shù)傳入,再將UIVertex傳給VertexHelper。重復(fù)迭代n次,VertexHelper就獲得了多邊形頂點(diǎn)及圓心點(diǎn)信息了。
計(jì)算頂點(diǎn)、指定三角形
float curDegree = 0;
UIVertex uiVertex;
int verticeCount;
int triangleCount;
Vector2 curVertice;
curVertice = Vector2.zero;
verticeCount = curSegements + 1;
uiVertex = new UIVertex();
uiVertex.color = color;
uiVertex.position = curVertice;
uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY);
vh.AddVert(uiVertex);
for (int i = 1; i < verticeCount; i++)
{
float cosA = Mathf.Cos(curDegree);
float sinA = Mathf.Sin(curDegree);
curVertice = new Vector2(cosA * outerRadius, sinA * outerRadius);
curDegree += degreeDelta;
uiVertex = new UIVertex();
uiVertex.color = color;
uiVertex.position = curVertice;
uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY);
vh.AddVert(uiVertex);
outterVertices.Add(curVertice);
}
知道了所有頂點(diǎn)信息,仍不足以渲染圖形,因?yàn)镚PU還不知道頂點(diǎn)之間的關(guān)系,不知道這些頂點(diǎn)分成了多少個三角面片,所以還需要把所有三角形信息一一告訴GPU。VertexHelper是通過AddTriangle接口接受三角形信息:
public void AddTriangle(int idx0, int idx1, int idx2)
接口的傳入?yún)?shù)并不是UIVertex類型,而是int類型的索引值。哪來的索引?還記得之前往VertexHelper傳入了一堆頂點(diǎn)嗎?按照傳入順序,第一個頂點(diǎn),索引記為0,依次類推。每次傳入三個頂點(diǎn)的索引,就記錄下了一個三角形。
需要注意,GPU 默認(rèn)是做backface culling(背面剔除)的,GPU只渲染正對屏幕的三角面片,當(dāng)GPU認(rèn)為某個三角面片是背對屏幕時,直接丟棄該三角面片,不做渲染。那么GPU怎么判斷我們傳入的某個三角形是正對屏幕,還是背對屏幕?答案是通過三個頂點(diǎn)的時針順序,當(dāng)三個頂點(diǎn)是呈順時針時,判定為正對屏幕;呈逆時針時,判定為背對屏幕。

VertexHelper收到的第一個頂點(diǎn)是圓心,且算法是按逆時針方向,迭代計(jì)算出的多邊形頂點(diǎn),并依次傳給VertexHelper。因此按(i, 0, i+1)(i>=1)的規(guī)律取索引,就可以保證頂點(diǎn)順序是順時針的。
triangleCount = curSegements*3;
for (int i = 0, vIdx = 1; i < triangleCount - 3; i += 3, vIdx++)
{
vh.AddTriangle(vIdx, 0, vIdx+1);
}
if (fillPercent == 1)
{
//首尾頂點(diǎn)相連
vh.AddTriangle(verticeCount - 1, 0, 1);
}

到這里為止,我們已經(jīng)完成了繪制圓形的工作了。
繪制圓環(huán)##
考慮還有可能要以圓環(huán)形式顯示,組件也做了支持。圓環(huán)的情況稍微復(fù)雜:頂點(diǎn)集沒有圓心頂點(diǎn)了,只有內(nèi)環(huán)、外環(huán)頂點(diǎn);三角形集也不是簡單的切餅式分割,采用一種比較直觀的三角形劃分,讓內(nèi)外環(huán)相鄰的頂點(diǎn)類似一根鞋帶那樣互相連接,來劃分三角形。
定義fill、thickness變量確定是否填充圖形、圓環(huán)寬度
[Tooltip("是否填充圓形")]
public bool fill = true;
[Tooltip("圓環(huán)寬度")]
public float thickness = 5;
計(jì)算頂點(diǎn)、指定三角形
float tw = rectTransform.rect.width;
float th = rectTransform.rect.height;
float outerRadius = rectTransform.pivot.x * tw;
float innerRadius = rectTransform.pivot.x * tw - thickness;
float curDegree = 0;
UIVertex uiVertex;
int verticeCount;
int triangleCount;
Vector2 curVertice;
verticeCount = curSegements*2;
for (int i = 0; i < verticeCount; i += 2)
{
float cosA = Mathf.Cos(curDegree);
float sinA = Mathf.Sin(curDegree);
curDegree += degreeDelta;
curVertice = new Vector3(cosA * innerRadius, sinA * innerRadius);
uiVertex = new UIVertex();
uiVertex.color = color;
uiVertex.position = curVertice;
uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY);
vh.AddVert(uiVertex);
innerVertices.Add(curVertice);
curVertice = new Vector3(cosA * outerRadius, sinA * outerRadius);
uiVertex = new UIVertex();
uiVertex.color = color;
uiVertex.position = curVertice;
uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY);
vh.AddVert(uiVertex);
outterVertices.Add(curVertice);
}
triangleCount = curSegements*3*2;
for (int i = 0, vIdx = 0; i < triangleCount - 6; i += 6, vIdx += 2)
{
vh.AddTriangle(vIdx+1, vIdx, vIdx+3);
vh.AddTriangle(vIdx, vIdx + 2, vIdx + 3);
}
if (fillPercent == 1)
{
//首尾頂點(diǎn)相連
vh.AddTriangle(verticeCount - 1, verticeCount - 2, 1);
vh.AddTriangle(verticeCount - 2, 0, 1);
}

圓形Image的像素級點(diǎn)擊判斷##
雖然我們完成了圓形Image的繪制,但Unity還是以圖片矩形包圍盒來判斷點(diǎn)擊。點(diǎn)擊圓形之外4個邊角區(qū)域,仍會判定點(diǎn)擊,在要求精確點(diǎn)擊的場景下就有問題了。
Unity本身提供了像素級點(diǎn)擊判斷方案,通過設(shè)置eventAlphaThreshold屬性(在5.4以上版本中改為alphaHitTestMinimumThreshold),根據(jù)點(diǎn)擊像素點(diǎn)是否已超過Alpha閾值來判定是否觸發(fā)點(diǎn)擊。然而這個美好的方案卻有天生缺陷,要求傳入圖片Texture Type不能為默認(rèn)的Sprite,需設(shè)置為Advanced,且需勾選上Read/Write Enabled,這樣會導(dǎo)致圖片占用雙倍內(nèi)存,且不能合并入圖集。


綜合效率和易用性,設(shè)置eventAlphaThreshold都不是一個合適的方案,那么有沒有別的辦法實(shí)現(xiàn)精確的點(diǎn)擊判斷?有的,換個角度思考,我們只需要考慮點(diǎn)擊區(qū)域是在多邊形之內(nèi),還是之外就可以了。這個問題早有人研究,抽象嚴(yán)謹(jǐn)?shù)卣f,這個問題可以描述為“如何判定一點(diǎn)是否在給定頂點(diǎn)的不規(guī)則封閉區(qū)域內(nèi)”,知乎上有相關(guān)回答。拾前人牙慧,我們選用Ray-Crossing算法來判定屏幕點(diǎn)擊是否落在多邊形內(nèi)。
Ray-Crossing算法###
Ray-Crossing算法大概思路是從指定點(diǎn)p發(fā)出一條射線,與多邊形相交,假若交點(diǎn)個數(shù)是奇數(shù),說明點(diǎn)p落在多邊形內(nèi),交點(diǎn)個數(shù)為偶數(shù)說明點(diǎn)p在多邊形外。算法結(jié)論乍看難以理解,但在邏輯上是可證的。假設(shè)有條射線,從起始點(diǎn)向無窮遠(yuǎn)處延伸,無窮遠(yuǎn)處必定處于多邊形之外;而射線從起始點(diǎn)出發(fā)與多邊形相交的過程中,射線尾端狀態(tài)是呈二態(tài)性交替變化的,即在“多邊形外<->多邊形內(nèi)”兩種狀態(tài)里交替變化,已知延長線的狀態(tài),通過交點(diǎn)個數(shù)就可以倒推出起始點(diǎn)的狀態(tài)。
射線選取哪個方向并沒有限制,但為了實(shí)現(xiàn)起來方便,考慮屏幕點(diǎn)擊點(diǎn)為點(diǎn)p,向水平方向右側(cè)發(fā)出射線的情況,那么頂點(diǎn)v1,v2組成的線段與射線若有交點(diǎn)q,則點(diǎn)q必定滿足兩個條件:
- v2.y < q.y = p.y > v1.y
- p.x < q.x
我們根據(jù)這兩個條件,逐一跟多邊形線段求交點(diǎn),并統(tǒng)計(jì)交點(diǎn)個數(shù),最后判斷奇偶即可得知點(diǎn)擊點(diǎn)是否在圓形內(nèi)。
public override bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
{
Sprite sprite = overrideSprite;
if (sprite == null)
return true;
Vector2 local;
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera, out local);
return Contains(local, outterVertices, innerVertices);
}
private bool Contains(Vector2 p, List<Vector3> outterVertices, List<Vector3> innerVertices)
{
var crossNumber = 0;
RayCrossing(p, innerVertices, ref crossNumber);//檢測內(nèi)環(huán)
RayCrossing(p, outterVertices, ref crossNumber);//檢測外環(huán)
return (crossNumber & 1) == 1;
}
/// <summary>
/// 使用RayCrossing算法判斷點(diǎn)擊點(diǎn)是否落在多邊形里
/// </summary>
/// <param name="p"></param>
/// <param name="vertices"></param>
/// <param name="crossNumber"></param>
private void RayCrossing(Vector2 p, List<Vector3> vertices, ref int crossNumber)
{
for (int i = 0, count = vertices.Count; i < count; i++)
{
var v1 = vertices[i];
var v2 = vertices[(i + 1) % count];
//點(diǎn)擊點(diǎn)水平線必須與兩頂點(diǎn)線段相交
if (((v1.y <= p.y) && (v2.y > p.y))
|| ((v1.y > p.y) && (v2.y <= p.y)))
{
//只考慮點(diǎn)擊點(diǎn)右側(cè)方向,點(diǎn)擊點(diǎn)水平線與線段相交,且交點(diǎn)x > 點(diǎn)擊點(diǎn)x,則crossNumber+1
if (p.x < v1.x + (p.y - v1.y) / (v2.y - v1.y) * (v2.x - v1.x))
{
crossNumber += 1;
}
}
}
}
至此,一個能夠靈活地以圓形,扇形,圓環(huán)形式展現(xiàn)圖片的CircleImage組件就完成了,無須使用Mask,無須消耗額外Drawcall,不影響圖集合并效率,且能實(shí)現(xiàn)精確點(diǎn)擊。重新設(shè)置頂點(diǎn),點(diǎn)擊判斷等邏輯的時間復(fù)雜度為O(n),與設(shè)置面片數(shù)相關(guān),面片數(shù)最大支持設(shè)置到100,這個量級對運(yùn)算效率幾乎無影響,實(shí)際上,面片數(shù)設(shè)置為30已能達(dá)到較好效果。
