全文解析圓形Image組件的實(shí)現(xiàn)原理,取關(guān)鍵代碼介紹算法細(xì)節(jié)刑巧,源碼已經(jīng)上傳Github下載地址喧兄,歡迎下載試用。
一啊楚、Unity原生Image組件實(shí)現(xiàn)圓形圖片的缺陷#
Mask渲染消耗##
許多游戲項(xiàng)目里免不了有很多圖片是以圓形形式展示的吠冤,如頭像,技能Icon等恭理,一般做法是使用Image組件拯辙,再加上一個(gè)圓形的Mask。實(shí)現(xiàn)非常簡(jiǎn)單颜价,但因?yàn)橛绊懶恃谋#S多關(guān)于ui方面的Unity效率優(yōu)化文章,都會(huì)建議開(kāi)發(fā)者少用Mask周伦。
- 使用Mask會(huì)額外消耗多一個(gè)Drawcall來(lái)創(chuàng)建Mask夕春,做像素剔除。
- Mask不利于層級(jí)合并专挪。原本同一圖集里的ui可以合并層級(jí)及志,僅需一個(gè)Drawcall渲染,如果加入Mask寨腔,就會(huì)將一個(gè)ui整體分割成了Mask下的子ui與其他ui速侈,兩者只能各自進(jìn)行層級(jí)合并,至少要兩個(gè)Drawcall迫卢。Mask用得多了倚搬,一個(gè)ui整體會(huì)被分割得四分五裂,就會(huì)嚴(yán)重影響層次合并的效率了靖避。
無(wú)法精確點(diǎn)擊##
Image+Mask的實(shí)現(xiàn)的圓形潭枣,點(diǎn)擊判斷不精確比默,點(diǎn)擊到圓形外的四個(gè)邊角仍會(huì)觸發(fā)點(diǎn)擊,雖然可以通過(guò)另外設(shè)置eventAlphaThreshold實(shí)現(xiàn)像素級(jí)判斷盆犁,但這個(gè)方法有天生缺陷命咐,并不是好的選擇。
二谐岁、應(yīng)運(yùn)而生的CircleImage組件#
了解了原有做法的缺陷后醋奠,我們希望自制圓形Image組件,解決這些問(wèn)題伊佃,并且盡量簡(jiǎn)單易用窜司。
干掉Mask##
雖說(shuō)少用Mask,但游戲項(xiàng)目里總免不了有些圖片要以圓形形式顯示航揉,不得不用塞祈,怎么辦?轉(zhuǎn)而從渲染層面思考帅涂,Image組件默認(rèn)以矩形形式渲染议薪,如果有辦法定制一個(gè)特殊Image組件,重新寫(xiě)入圓形形狀的渲染頂點(diǎn)媳友、三角面片信息斯议,根本不需要Mask就能渲染出圓形Image。
我們看到的屏幕顯示醇锚,是通過(guò)GPU渲染出來(lái)的哼御,而GPU渲染以三角面片為最小單元。所有的圖形畫(huà)面焊唬,本質(zhì)是由無(wú)數(shù)三角面片組成的恋昼,例如矩形是由兩個(gè)直角三角面片組成的;圓形可以由若干個(gè)相同的以圓心為頂點(diǎn)的等腰三角面片組成正多邊形赶促,近似模擬出來(lái)焰雕。三角面片分得多了,多邊形的邊越多芳杏,夾角越大矩屁,就越近似圓形。
另一種精確點(diǎn)擊方案##
組件不再以像素Alpha值判斷是否點(diǎn)擊吝秕,而是用Ray-Crossing算法計(jì)算點(diǎn)擊點(diǎn)是否在落多邊形內(nèi),來(lái)實(shí)現(xiàn)精確點(diǎn)擊空幻。
三烁峭、組件實(shí)現(xiàn)#
繪制圓形##
Unity引擎并不開(kāi)源,好在其中ugui框架是開(kāi)源的,簡(jiǎn)單看下Image代碼:
public class Image : MaskableGraphic, ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter
Image類繼承自MaskableGraphic约郁,實(shí)現(xiàn)了ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter這三個(gè)接口缩挑。最關(guān)鍵的是MaskableGraphic類,MaskableGraphic負(fù)責(zé)繪制邏輯鬓梅,MaskableGraphic繼承自Graphic供置,Graphic里有個(gè)OnPopulateMesh函數(shù),這正是我們需要的函數(shù)绽快。
當(dāng)UI元素生成頂點(diǎn)數(shù)據(jù)時(shí)會(huì)調(diào)用OnPopulateMesh(VertexHelper vh)函數(shù)芥丧,我們只要繼承改寫(xiě)OnPopulateMesh函數(shù),將原先的矩形頂點(diǎn)數(shù)據(jù)清除坊罢,改寫(xiě)入圓形頂點(diǎn)數(shù)據(jù)续担,這樣渲染出來(lái)的自然是圓形圖片。
我們希望這個(gè)圓形Image組件活孩,能夠自定義某些參數(shù)物遇,比如自定義圓形等分面數(shù)(即由多少個(gè)三角形組成這個(gè)圓形),自定義圓形填充比例等憾儒。
由于Unity的限制挎挖,繼承UnityEngine基類的派生類不能在Inspector里顯示自定義參數(shù)。為了解決這點(diǎn)航夺,我們?cè)僭靷€(gè)小輪子,新建BaseImage類來(lái)代替Image類崔涂。原Image源碼有近千行代碼阳掐,BaseImage對(duì)其進(jìn)行了部分精簡(jiǎn),只支持Simple Image Type冷蚂,并去掉了eventAlphaThreshold的相關(guān)代碼缭保。經(jīng)過(guò)刪減,得到一個(gè)百行代碼的BaseImage類蝙茶,精簡(jiǎn)版Image就完成了艺骂。
接著,新建CircleImage類繼承BaseImage隆夯,重寫(xiě)OnPopulateMesh方法钳恕。
protected override void OnPopulateMesh(VertexHelper vh)
OnPopulateMesh方法的VertexHelper參數(shù),保存著原來(lái)的頂點(diǎn)信息蹄衷,因?yàn)橐匦聜魅腠旤c(diǎn)信息忧额,需先調(diào)用Clear方法,清除VertexHelper原有頂點(diǎn)信息愧口。在計(jì)算頂點(diǎn)前睦番,通過(guò)DataUtility.GetOuterUV(overrideSprite)獲取貼圖uv信息,簡(jiǎn)單計(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巩检,我們可以算出每個(gè)面片的頂點(diǎn)夾角,面片數(shù)segements與填充比例fillPercent相乘示启,就知道要用多少個(gè)面片來(lái)顯示圓形/扇形
float degreeDelta = (float)(2 * Mathf.PI / segements);
int curSegements = (int)(segements * fillPercent);
通過(guò)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)分成了多少個(gè)三角面片辙售,所以還需要把所有三角形信息一一告訴GPU。VertexHelper是通過(guò)AddTriangle接口接受三角形信息:
public void AddTriangle(int idx0, int idx1, int idx2)
接口的傳入?yún)?shù)并不是UIVertex類型飞涂,而是int類型的索引值旦部。哪來(lái)的索引?還記得之前往VertexHelper傳入了一堆頂點(diǎn)嗎较店?按照傳入順序士八,第一個(gè)頂點(diǎn),索引記為0梁呈,依次類推婚度。每次傳入三個(gè)頂點(diǎn)的索引,就記錄下了一個(gè)三角形官卡。
需要注意蝗茁,GPU 默認(rèn)是做backface culling(背面剔除)的,GPU只渲染正對(duì)屏幕的三角面片寻咒,當(dāng)GPU認(rèn)為某個(gè)三角面片是背對(duì)屏幕時(shí)评甜,直接丟棄該三角面片,不做渲染仔涩。那么GPU怎么判斷我們傳入的某個(gè)三角形是正對(duì)屏幕忍坷,還是背對(duì)屏幕?答案是通過(guò)三個(gè)頂點(diǎn)的時(shí)針順序,當(dāng)三個(gè)頂點(diǎn)是呈順時(shí)針時(shí)佩研,判定為正對(duì)屏幕柑肴;呈逆時(shí)針時(shí),判定為背對(duì)屏幕旬薯。
VertexHelper收到的第一個(gè)頂點(diǎn)是圓心,且算法是按逆時(shí)針?lè)较虬硇颍?jì)算出的多邊形頂點(diǎn)硕舆,并依次傳給VertexHelper。因此按(i, 0, i+1)(i>=1)的規(guī)律取索引骤公,就可以保證頂點(diǎn)順序是順時(shí)針的抚官。
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)集沒(méi)有圓心頂點(diǎn)了洒试,只有內(nèi)環(huán)倍奢、外環(huán)頂點(diǎn);三角形集也不是簡(jiǎn)單的切餅式分割垒棋,采用一種比較直觀的三角形劃分卒煞,讓內(nèi)外環(huán)相鄰的頂點(diǎn)類似一根鞋帶那樣互相連接,來(lái)劃分三角形叼架。
定義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的像素級(jí)點(diǎn)擊判斷##
雖然我們完成了圓形Image的繪制,但Unity還是以圖片矩形包圍盒來(lái)判斷點(diǎn)擊淮韭。點(diǎn)擊圓形之外4個(gè)邊角區(qū)域垢粮,仍會(huì)判定點(diǎn)擊,在要求精確點(diǎn)擊的場(chǎng)景下就有問(wèn)題了靠粪。
Unity本身提供了像素級(jí)點(diǎn)擊判斷方案蜡吧,通過(guò)設(shè)置eventAlphaThreshold屬性(在5.4以上版本中改為alphaHitTestMinimumThreshold),根據(jù)點(diǎn)擊像素點(diǎn)是否已超過(guò)Alpha閾值來(lái)判定是否觸發(fā)點(diǎn)擊占键。然而這個(gè)美好的方案卻有天生缺陷昔善,要求傳入圖片Texture Type不能為默認(rèn)的Sprite,需設(shè)置為Advanced畔乙,且需勾選上Read/Write Enabled君仆,這樣會(huì)導(dǎo)致圖片占用雙倍內(nèi)存,且不能合并入圖集。
綜合效率和易用性返咱,設(shè)置eventAlphaThreshold都不是一個(gè)合適的方案钥庇,那么有沒(méi)有別的辦法實(shí)現(xiàn)精確的點(diǎn)擊判斷?有的咖摹,換個(gè)角度思考评姨,我們只需要考慮點(diǎn)擊區(qū)域是在多邊形之內(nèi),還是之外就可以了萤晴。這個(gè)問(wèn)題早有人研究吐句,抽象嚴(yán)謹(jǐn)?shù)卣f(shuō),這個(gè)問(wèn)題可以描述為“如何判定一點(diǎn)是否在給定頂點(diǎn)的不規(guī)則封閉區(qū)域內(nèi)”店读,知乎上有相關(guān)回答嗦枢。拾前人牙慧,我們選用Ray-Crossing算法來(lái)判定屏幕點(diǎn)擊是否落在多邊形內(nèi)两入。
Ray-Crossing算法###
Ray-Crossing算法大概思路是從指定點(diǎn)p發(fā)出一條射線净宵,與多邊形相交,假若交點(diǎn)個(gè)數(shù)是奇數(shù)裹纳,說(shuō)明點(diǎn)p落在多邊形內(nèi)择葡,交點(diǎn)個(gè)數(shù)為偶數(shù)說(shuō)明點(diǎn)p在多邊形外。算法結(jié)論乍看難以理解剃氧,但在邏輯上是可證的敏储。假設(shè)有條射線,從起始點(diǎn)向無(wú)窮遠(yuǎn)處延伸朋鞍,無(wú)窮遠(yuǎn)處必定處于多邊形之外已添;而射線從起始點(diǎn)出發(fā)與多邊形相交的過(guò)程中,射線尾端狀態(tài)是呈二態(tài)性交替變化的滥酥,即在“多邊形外<->多邊形內(nèi)”兩種狀態(tài)里交替變化更舞,已知延長(zhǎng)線的狀態(tài),通過(guò)交點(diǎn)個(gè)數(shù)就可以倒推出起始點(diǎn)的狀態(tài)坎吻。
射線選取哪個(gè)方向并沒(méi)有限制缆蝉,但為了實(shí)現(xiàn)起來(lái)方便,考慮屏幕點(diǎn)擊點(diǎn)為點(diǎn)p瘦真,向水平方向右側(cè)發(fā)出射線的情況刊头,那么頂點(diǎn)v1,v2組成的線段與射線若有交點(diǎn)q,則點(diǎn)q必定滿足兩個(gè)條件:
- v2.y < q.y = p.y > v1.y
- p.x < q.x
我們根據(jù)這兩個(gè)條件诸尽,逐一跟多邊形線段求交點(diǎn)原杂,并統(tǒng)計(jì)交點(diǎn)個(gè)數(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);//檢測(cè)內(nèi)環(huán)
RayCrossing(p, outterVertices, ref crossNumber);//檢測(cè)外環(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;
}
}
}
}
至此被碗,一個(gè)能夠靈活地以圓形某宪,扇形,圓環(huán)形式展現(xiàn)圖片的CircleImage組件就完成了锐朴,無(wú)須使用Mask兴喂,無(wú)須消耗額外Drawcall,不影響圖集合并效率焚志,且能實(shí)現(xiàn)精確點(diǎn)擊衣迷。重新設(shè)置頂點(diǎn),點(diǎn)擊判斷等邏輯的時(shí)間復(fù)雜度為O(n)酱酬,與設(shè)置面片數(shù)相關(guān)壶谒,面片數(shù)最大支持設(shè)置到100,這個(gè)量級(jí)對(duì)運(yùn)算效率幾乎無(wú)影響膳沽,實(shí)際上汗菜,面片數(shù)設(shè)置為30已能達(dá)到較好效果。