寫在前面

全文解析圓形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)橛绊懶?,許多關(guān)于ui方面的Unity效率優(yōu)化文章,都會(huì)建議開發(fā)者少用Mask。

  1. 使用Mask會(huì)額外消耗多一個(gè)Drawcall來創(chuàng)建Mask,做像素剔除。

  2. 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)重影響層次合并的效率了。

無法精確點(diǎn)擊

Image+Mask的實(shí)現(xiàn)的圓形,點(diǎn)擊判斷不精確,點(diǎn)擊到圓形外的四個(gè)邊角仍會(huì)觸發(fā)點(diǎn)擊,雖然可以通過另外設(shè)置eventAlphaThreshold實(shí)現(xiàn)像素級(jí)判斷,但這個(gè)方法有天生缺陷,并不是好的選擇。

二、應(yīng)運(yùn)而生的CircleImage組件

了解了原有做法的缺陷后,我們希望自制圓形Image組件,解決這些問題,并且盡量簡(jiǎn)單易用。

干掉Mask

雖說少用Mask,但游戲項(xiàng)目里總免不了有些圖片要以圓形形式顯示,不得不用,怎么辦?轉(zhuǎn)而從渲染層面思考,Image組件默認(rèn)以矩形形式渲染,如果有辦法定制一個(gè)特殊Image組件,重新寫入圓形形狀的渲染頂點(diǎn)、三角面片信息,根本不需要Mask就能渲染出圓形Image。

我們看到的屏幕顯示,是通過GPU渲染出來的,而GPU渲染以三角面片為最小單元。所有的圖形畫面,本質(zhì)是由無數(shù)三角面片組成的,例如矩形是由兩個(gè)直角三角面片組成的;圓形可以由若干個(gè)相同的以圓心為頂點(diǎn)的等腰三角面片組成正多邊形,近似模擬出來。三角面片分得多了,多邊形的邊越多,夾角越大,就越近似圓形。

Android培訓(xùn),安卓培訓(xùn),手機(jī)開發(fā)培訓(xùn),移動(dòng)開發(fā)培訓(xùn),云培訓(xùn)培訓(xùn)
綠色圓圈由60個(gè)等腰三角面片構(gòu)成,黃色圓圈由10個(gè)等腰三角形面片構(gòu)成

另一種精確點(diǎn)擊方案

組件不再以像素Alpha值判斷是否點(diǎn)擊,而是用Ray-Crossing算法計(jì)算點(diǎn)擊點(diǎn)是否在落多邊形內(nèi),來實(shí)現(xiàn)精確點(diǎn)擊。

三、組件實(shí)現(xiàn)

繪制圓形

Unity引擎并不開源,好在其中ugui框架是開源的,簡(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ù)。

Android培訓(xùn),安卓培訓(xùn),手機(jī)開發(fā)培訓(xùn),移動(dòng)開發(fā)培訓(xùn),云培訓(xùn)培訓(xùn)

當(dāng)UI元素生成頂點(diǎn)數(shù)據(jù)時(shí)會(huì)調(diào)用OnPopulateMesh(VertexHelper vh)函數(shù),我們只要繼承改寫OnPopulateMesh函數(shù),將原先的矩形頂點(diǎn)數(shù)據(jù)清除,改寫入圓形頂點(diǎn)數(shù)據(jù),這樣渲染出來的自然是圓形圖片。

我們希望這個(gè)圓形Image組件,能夠自定義某些參數(shù),比如自定義圓形等分面數(shù)(即由多少個(gè)三角形組成這個(gè)圓形),自定義圓形填充比例等。

由于Unity的限制,繼承UnityEngine基類的派生類不能在Inspector里顯示自定義參數(shù)。為了解決這點(diǎn),我們?cè)僭靷€(gè)小輪子,新建BaseImage類來代替Image類。原Image源碼有近千行代碼,BaseImage對(duì)其進(jìn)行了部分精簡(jiǎn),只支持Simple Image Type,并去掉了eventAlphaThreshold的相關(guān)代碼。經(jīng)過刪減,得到一個(gè)百行代碼的BaseImage類,精簡(jiǎn)版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信息,簡(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è)面片來顯示圓形/扇形

    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)分成了多少個(gè)三角面片,所以還需要把所有三角形信息一一告訴GPU。VertexHelper是通過AddTriangle接口接受三角形信息:

public void AddTriangle(int idx0, int idx1, int idx2)

接口的傳入?yún)?shù)并不是UIVertex類型,而是int類型的索引值。哪來的索引?還記得之前往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ì)屏幕?答案是通過三個(gè)頂點(diǎn)的時(shí)針順序,當(dāng)三個(gè)頂點(diǎn)是呈順時(shí)針時(shí),判定為正對(duì)屏幕;呈逆時(shí)針時(shí),判定為背對(duì)屏幕。

Android培訓(xùn),安卓培訓(xùn),手機(jī)開發(fā)培訓(xùn),移動(dòng)開發(fā)培訓(xùn),云培訓(xùn)培訓(xùn)
左邊的圖中指定頂點(diǎn)的順序是順時(shí)針的,右邊是逆時(shí)針的

VertexHelper收到的第一個(gè)頂點(diǎn)是圓心,且算法是按逆時(shí)針方向,迭代計(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);
    }

Android培訓(xùn),安卓培訓(xùn),手機(jī)開發(fā)培訓(xùn),移動(dòng)開發(fā)培訓(xùn),云培訓(xùn)培訓(xùn)

到這里為止,我們已經(jīng)完成了繪制圓形的工作了。

繪制圓環(huán)

考慮還有可能要以圓環(huán)形式顯示,組件也做了支持。圓環(huán)的情況稍微復(fù)雜:頂點(diǎn)集沒有圓心頂點(diǎn)了,只有內(nèi)環(huán)、外環(huán)頂點(diǎn);三角形集也不是簡(jiǎ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);
        }

Android培訓(xùn),安卓培訓(xùn),手機(jī)開發(fā)培訓(xùn),移動(dòng)開發(fā)培訓(xùn),云培訓(xùn)培訓(xùn)

圓形Image的像素級(jí)點(diǎn)擊判斷

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

Android培訓(xùn),安卓培訓(xùn),手機(jī)開發(fā)培訓(xùn),移動(dòng)開發(fā)培訓(xùn),云培訓(xùn)培訓(xùn)
Android培訓(xùn),安卓培訓(xùn),手機(jī)開發(fā)培訓(xùn),移動(dòng)開發(fā)培訓(xùn),云培訓(xùn)培訓(xùn)

綜合效率和易用性,設(shè)置eventAlphaThreshold都不是一個(gè)合適的方案,那么有沒有別的辦法實(shí)現(xiàn)精確的點(diǎn)擊判斷?有的,換個(gè)角度思考,我們只需要考慮點(diǎn)擊區(qū)域是在多邊形之內(nèi),還是之外就可以了。這個(gè)問題早有人研究,抽象嚴(yán)謹(jǐn)?shù)卣f,這個(gè)問題可以描述為“如何判定一點(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)個(gè)數(shù)是奇數(shù),說明點(diǎn)p落在多邊形內(nèi),交點(diǎn)個(gè)數(shù)為偶數(shù)說明點(diǎn)p在多邊形外。算法結(jié)論乍看難以理解,但在邏輯上是可證的。假設(shè)有條射線,從起始點(diǎn)向無窮遠(yuǎn)處延伸,無窮遠(yuǎn)處必定處于多邊形之外;而射線從起始點(diǎn)出發(fā)與多邊形相交的過程中,射線尾端狀態(tài)是呈二態(tài)性交替變化的,即在“多邊形外<->多邊形內(nèi)”兩種狀態(tài)里交替變化,已知延長(zhǎng)線的狀態(tài),通過交點(diǎn)個(gè)數(shù)就可以倒推出起始點(diǎn)的狀態(tài)。

射線選取哪個(gè)方向并沒有限制,但為了實(shí)現(xiàn)起來方便,考慮屏幕點(diǎn)擊點(diǎn)為點(diǎn)p,向水平方向右側(cè)發(fā)出射線的情況,那么頂點(diǎn)v1,v2組成的線段與射線若有交點(diǎn)q,則點(diǎn)q必定滿足兩個(gè)條件:

  1. v2.y < q.y = p.y > v1.y

  2. 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組件就完成了,無須使用Mask,無須消耗額外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)算效率幾乎無影響,實(shí)際上,面片數(shù)設(shè)置為30已能達(dá)到較好效果。

Android培訓(xùn),安卓培訓(xùn),手機(jī)開發(fā)培訓(xùn),移動(dòng)開發(fā)培訓(xùn),云培訓(xùn)培訓(xùn)

標(biāo)簽: Unity效率優(yōu)化

http://www.cnblogs.com/leoin2012/p/6425089.html