丟掉Mask遮罩,更好的圓形Image組件[Unity]

寫在前面

全文解析圓形Image組件的實現原理,取關鍵代碼介紹算法細節,源碼已經上傳Github下載地址,歡迎下載試用。git

1、Unity原生Image組件實現圓形圖片的缺陷

Mask渲染消耗

許多遊戲項目裏免不了有不少圖片是以圓形形式展現的,如頭像,技能Icon等,通常作法是使用Image組件,再加上一個圓形的Mask。實現很是簡單,但由於影響效率,許多關於ui方面的Unity效率優化文章,都會建議開發者少用Mask。github

  1. 使用Mask會額外消耗多一個Drawcall來建立Mask,作像素剔除。
  2. Mask不利於層級合併。本來同一圖集裏的ui能夠合併層級,僅需一個Drawcall渲染,若是加入Mask,就會將一個ui總體分割成了Mask下的子ui與其餘ui,二者只能各自進行層級合併,至少要兩個Drawcall。Mask用得多了,一個ui總體會被分割得四分五裂,就會嚴重影響層次合併的效率了。

沒法精確點擊

Image+Mask的實現的圓形,點擊判斷不精確,點擊到圓形外的四個邊角仍會觸發點擊,雖然能夠經過另外設置eventAlphaThreshold實現像素級判斷,但這個方法有天生缺陷,並非好的選擇。算法

2、應運而生的CircleImage組件

瞭解了原有作法的缺陷後,咱們但願自制圓形Image組件,解決這些問題,而且儘可能簡單易用。c#

幹掉Mask

雖然說少用Mask,但遊戲項目裏總免不了有些圖片要以圓形形式顯示,不得不用,怎麼辦?轉而從渲染層面思考,Image組件默認以矩形形式渲染,若是有辦法定製一個特殊Image組件,從新寫入圓形形狀的渲染頂點、三角面片信息,根本不須要Mask就能渲染出圓形Image。框架

咱們看到的屏幕顯示,是經過GPU渲染出來的,而GPU渲染以三角面片爲最小單元。全部的圖形畫面,本質是由無數三角面片組成的,例如矩形是由兩個直角三角面片組成的;圓形能夠由若干個相同的以圓心爲頂點的等腰三角面片組成正多邊形,近似模擬出來。三角面片分得多了,多邊形的邊越多,夾角越大,就越近似圓形。ide


綠色圓圈由60個等腰三角面片構成,黃色圓圈由10個等腰三角形面片構成函數

另外一種精確點擊方案

組件再也不以像素Alpha值判斷是否點擊,而是用Ray-Crossing算法計算點擊點是否在落多邊形內,來實現精確點擊。優化

3、組件實現

繪製圓形

Unity引擎並不開源,好在其中ugui框架是開源的,簡單看下Image代碼:ui

public class Image : MaskableGraphic, ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter

Image類繼承自MaskableGraphic,實現了ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter這三個接口。最關鍵的是MaskableGraphic類,MaskableGraphic負責繪製邏輯,MaskableGraphic繼承自Graphic,Graphic裏有個OnPopulateMesh函數,這正是咱們須要的函數。code

當UI元素生成頂點數據時會調用OnPopulateMesh(VertexHelper vh)函數,咱們只要繼承改寫OnPopulateMesh函數,將原先的矩形頂點數據清除,改寫入圓形頂點數據,這樣渲染出來的天然是圓形圖片。

咱們但願這個圓形Image組件,可以自定義某些參數,好比自定義圓形等分面數(即由多少個三角形組成這個圓形),自定義圓形填充比例等。

因爲Unity的限制,繼承UnityEngine基類的派生類不能在Inspector裏顯示自定義參數。爲了解決這點,咱們再造個小輪子,新建BaseImage類來代替Image類。原Image源碼有近千行代碼,BaseImage對其進行了部分精簡,只支持Simple Image Type,並去掉了eventAlphaThreshold的相關代碼。通過刪減,獲得一個百行代碼的BaseImage類,精簡版Image就完成了。

接着,新建CircleImage類繼承BaseImage,重寫OnPopulateMesh方法。

protected override void OnPopulateMesh(VertexHelper vh)

OnPopulateMesh方法的VertexHelper參數,保存着原來的頂點信息,由於要從新傳入頂點信息,需先調用Clear方法,清除VertexHelper原有頂點信息。在計算頂點前,經過DataUtility.GetOuterUV(overrideSprite)獲取貼圖uv信息,簡單計算得到中心點,縮放等信息。

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;

        ...
    }

知道了等分面片數segements,咱們能夠算出每一個面片的頂點夾角,面片數segements與填充比例fillPercent相乘,就知道要用多少個面片來顯示圓形/扇形

float degreeDelta = (float)(2 * Mathf.PI / segements);
    int curSegements = (int)(segements * fillPercent);

經過RectTransform獲取矩形寬高,計算出半徑

float tw = rectTransform.rect.width;
    float th = rectTransform.rect.height;
    float outerRadius = rectTransform.pivot.x * tw;

已經有了半徑,夾角信息,根據圓形點座標公式(radius * cosA,radius * sinA)能夠算出頂點座標,每次迭代新建UIVertex,將求出的座標,color,uv等參數傳入,再將UIVertex傳給VertexHelper。重複迭代n次,VertexHelper就得到了多邊形頂點及圓心點信息了。

計算頂點、指定三角形

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);
   }

知道了全部頂點信息,仍不足以渲染圖形,由於GPU還不知道頂點之間的關係,不知道這些頂點分紅了多少個三角面片,因此還須要把全部三角形信息一一告訴GPU。VertexHelper是經過AddTriangle接口接受三角形信息:

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

接口的傳入參數並非UIVertex類型,而是int類型的索引值。哪來的索引?還記得以前往VertexHelper傳入了一堆頂點嗎?按照傳入順序,第一個頂點,索引記爲0,依次類推。每次傳入三個頂點的索引,就記錄下了一個三角形。

須要注意,GPU 默認是作backface culling(背面剔除)的,GPU只渲染正對屏幕的三角面片,當GPU認爲某個三角面片是背對屏幕時,直接丟棄該三角面片,不作渲染。那麼GPU怎麼判斷咱們傳入的某個三角形是正對屏幕,仍是背對屏幕?答案是經過三個頂點的時針順序,當三個頂點是呈順時針時,斷定爲正對屏幕;呈逆時針時,斷定爲背對屏幕。


左邊的圖中指定頂點的順序是順時針的,右邊是逆時針的

VertexHelper收到的第一個頂點是圓心,且算法是按逆時針方向,迭代計算出的多邊形頂點,並依次傳給VertexHelper。所以按(i, 0, i+1)(i>=1)的規律取索引,就能夠保證頂點順序是順時針的。

triangleCount = curSegements*3;
    for (int i = 0, vIdx = 1; i < triangleCount - 3; i += 3, vIdx++)
    {
         vh.AddTriangle(vIdx, 0, vIdx+1);
    }
    if (fillPercent == 1)
    {
          //首尾頂點相連
          vh.AddTriangle(verticeCount - 1, 0, 1);
    }

到這裏爲止,咱們已經完成了繪製圓形的工做了。

繪製圓環

考慮還有可能要以圓環形式顯示,組件也作了支持。圓環的狀況稍微複雜:頂點集沒有圓心頂點了,只有內環、外環頂點;三角形集也不是簡單的切餅式分割,採用一種比較直觀的三角形劃分,讓內外環相鄰的頂點相似一根鞋帶那樣互相鏈接,來劃分三角形。

定義fill、thickness變量肯定是否填充圖形、圓環寬度

[Tooltip("是否填充圓形")]
    public bool fill = true;
    [Tooltip("圓環寬度")]
    public float thickness = 5;

計算頂點、指定三角形

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)
        {
            //首尾頂點相連
            vh.AddTriangle(verticeCount - 1, verticeCount - 2, 1);
            vh.AddTriangle(verticeCount - 2, 0, 1);
        }

圓形Image的像素級點擊判斷

雖然咱們完成了圓形Image的繪製,但Unity仍是以圖片矩形包圍盒來判斷點擊。點擊圓形以外4個邊角區域,仍會斷定點擊,在要求精確點擊的場景下就有問題了。
Unity自己提供了像素級點擊判斷方案,經過設置eventAlphaThreshold屬性(在5.4以上版本中改成alphaHitTestMinimumThreshold),根據點擊像素點是否已超過Alpha閾值來斷定是否觸發點擊。然而這個美好的方案卻有天生缺陷,要求傳入圖片Texture Type不能爲默認的Sprite,需設置爲Advanced,且需勾選上Read/Write Enabled,這樣會致使圖片佔用雙倍內存,且不能合併入圖集。


綜合效率和易用性,設置eventAlphaThreshold都不是一個合適的方案,那麼有沒有別的辦法實現精確的點擊判斷?有的,換個角度思考,咱們只須要考慮點擊區域是在多邊形以內,仍是以外就能夠了。這個問題早有人研究,抽象嚴謹地說,這個問題能夠描述爲「如何斷定一點是否在給定頂點的不規則封閉區域內」,知乎上有相關回答。拾前人牙慧,咱們選用Ray-Crossing算法來斷定屏幕點擊是否落在多邊形內。

Ray-Crossing算法

Ray-Crossing算法大概思路是從指定點p發出一條射線,與多邊形相交,倘若交點個數是奇數,說明點p落在多邊形內,交點個數爲偶數說明點p在多邊形外。算法結論乍看難以理解,但在邏輯上是可證的。假設有條射線,從起始點向無窮遠處延伸,無窮遠處一定處於多邊形以外;而射線從起始點出發與多邊形相交的過程當中,射線尾端狀態是呈二態性交替變化的,即在「多邊形外<->多邊形內」兩種狀態裏交替變化,已知延長線的狀態,經過交點個數就能夠倒推出起始點的狀態。

射線選取哪一個方向並無限制,但爲了實現起來方便,考慮屏幕點擊點爲點p,向水平方向右側發出射線的狀況,那麼頂點v1,v2組成的線段與射線如有交點q,則點q一定知足兩個條件:

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

咱們根據這兩個條件,逐一跟多邊形線段求交點,並統計交點個數,最後判斷奇偶便可得知點擊點是否在圓形內。

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);//檢測內環
        RayCrossing(p, outterVertices, ref crossNumber);//檢測外環
        return (crossNumber & 1) == 1;
    }

    /// <summary>
    /// 使用RayCrossing算法判斷點擊點是否落在多邊形裏
    /// </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];

            //點擊點水平線必須與兩頂點線段相交
            if (((v1.y <= p.y) && (v2.y > p.y))
                || ((v1.y > p.y) && (v2.y <= p.y)))
            {
                //只考慮點擊點右側方向,點擊點水平線與線段相交,且交點x > 點擊點x,則crossNumber+1
                if (p.x < v1.x + (p.y - v1.y) / (v2.y - v1.y) * (v2.x - v1.x))
                {
                    crossNumber += 1;
                }
            }
        }
    }

至此,一個可以靈活地以圓形,扇形,圓環形式展示圖片的CircleImage組件就完成了,無須使用Mask,無須消耗額外Drawcall,不影響圖集合並效率,且能實現精確點擊。從新設置頂點,點擊判斷等邏輯的時間複雜度爲O(n),與設置面片數相關,面片數最大支持設置到100,這個量級對運算效率幾乎無影響,實際上,面片數設置爲30已能達到較好效果。

相關文章
相關標籤/搜索