畫地為Mask,隨心所欲的高效遮罩組件[Unity]


在上一篇博文"扔掉遮罩,更好的圓形Image組件"中,筆者改變Image的頂點數據,使得Image呈圓形顯示,避免了Mask的使用,從而節省Drawcall消耗,提高渲染效率了。這也啟發了筆者,有沒有可能通過同樣原理實現Mask,做到在某些需要顯示特定形狀Icon的場景下,替代Unity原生Mask,且能保有節省Drawcall,減少渲染像素點,實現精確點擊等優點?經過一番折騰,就有了MeshMask組件。

組件效果#

MeshMask遮罩效果圖
MeshMask遮罩效果圖

可以看到無論Mask形狀是凸邊形還是復雜的凹邊形,都能准確地將Mask形狀數據序列化成頂點,面片數據,
提供給需要Mask的圖片修改渲染頂點,達到遮罩效果。組件用法類似於Unity Mask,且效率優於Unity Mask。插件已上傳至Github[點擊下載], 歡迎試用~

效率對比#

使用原生Mask,10個Icon占用了15個Drawcall
使用原生Mask,10個Icon占用了15個Drawcall

使用MeshMask,10個Icon僅占用1個Drawcall
使用MeshMask,10個Icon僅占用1個Drawcall

Scene切換到Overdraw模式:紅框為Mask的Overdraw;藍框為MeshMask的Overdraw
Scene切換到Overdraw模式:紅框為Mask的Overdraw;藍框為MeshMask的Overdraw

從上面三張圖可以看到MeshMask相比Unity的Mask,在減少Drawcall消耗、Overdraw消耗等兩方面都是完勝的。

Drawcall消耗###

這10個icon都打包在同一圖集的,使用Unity Mask,沒辦法享受圖層合並,消耗了15個Drawcall;使用MeshMask的情況下,看截圖里Batches為2,除去攝像機占用的1個Batch,10個icon僅占用1個Batch,即1個Drawcall。在Drawcall資源如此昂貴的情況下(一般機器都會要求Drawcall在200以下),這種性能節省效果非常顯著。

Overdraw消耗###

而看圖三的Overdraw,使用Unity Mask的紅框部分,被Mask的圖片全部繪制一次,Unity Mask再做像素剔除,被Mask的部分又繪制了一次,總共需要繪制兩次,且有一次是繪制了完全用不到的區域。使用MeshMask的藍框部分,因為是靠改變頂點繪制出來的icon,因此僅有被Mask部分被繪制了一次。

面片消耗###

當然,使用MeshMask的Image需要消耗比普通Image多一些的頂點和面片,觀察Stats面板,使用MeshMsk的10個icon多占用1.3K的頂點和面片,即1個icon占用130個頂點,面片。然而GPU渲染頂點,面片的效率非常高(市面手機GPU渲染多邊形數基本上2000-10000+萬多邊形/每秒以上),這點消耗跟Drawcall比起來就微不足道了。

小結###

在渲染上,GPU、CPU兩者的性能瓶頸往往是CPU;GPU的性能瓶頸往往是像素點填充率(Overdraw導致),CPU的性能瓶頸往往是Drawcall。所以,渲染性能排查,幾項指標關注優先級應該是:Drawcall > Overdraw > 面片

組件使用#

MeshMask插件目錄結構
MeshMask插件目錄結構

插件里有MeshMask、MeshImage、MeshButton三個UI組件

MeshMask組件Inspector面板
MeshMask組件Inspector面板

MeshMask組件作用類似Unity Mask,依賴了Image及PolygonCollider2D組件,帶有[根據Image組件生成Mask]、[根據Collider組件生成Mask]兩個菜單項,支持兩種方式生成Mask數據。

被遮罩GameOjecct的Inspector面板
被遮罩GameOjecct的Inspector面板

MeshImage、MeshButton組件掛在需要被遮罩的GameObject上,設置好MeshMask對象,就能獲得數據,實現遮罩或者精確點擊。

組件實現#

不同於CircleImage,只需要簡單的對圓形進行頂點,面片計算;MeshMask要考慮幾個點:

  1. 需要能對所有可能的圖形進行頂點,面片計算。
  2. 考慮頂點,面片計算需要讀取Image,且有一定性能開銷,所以不能在Run-time中實時計算數據,需要預先計算好vertices,triangle數據,並序列化存放在GameObject中,運行時讀取。
  3. 保證MeshMask靈活性,除了根據Image進行頂點,面片計算,希望像PS一樣,提供路徑工具,讓開發可以可視化地新增、修改Mask形狀。
  4. 對所有圖形支持像素級點擊判斷

其中做頂點,面片計算這一步比較麻煩,涉及以下幾個技術點:

圖片處理流程
圖片處理流程

邊緣檢測##

邊緣檢測算法算是圖形學應用最廣泛最基礎的算法了,主要原理是濾波器對圖形進行濾波從而得到梯度圖像,通過判斷梯度圖像的某像素點灰度值是否超過閾值,就能判斷該點是否為邊緣點。筆者采用了簡單的Sobel算子邊緣檢測算法。

Sobel算子:3x3的矩形濾波器
Sobel算子:3x3的矩形濾波器

A代表原始圖像,Gx及Gy分別代表經橫向及縱向邊緣檢測的圖像灰度值
A代表原始圖像,Gx及Gy分別代表經橫向及縱向邊緣檢測的圖像灰度值

圖像某像素點灰度值
圖像某像素點灰度值

通常,為了提高效率 使用不開平方的近似值
通常,為了提高效率 使用不開平方的近似值

這里拿米老鼠圖來做示例圖,看看Sobel邊緣檢測的效果。
原圖
原圖

sobel邊緣檢測后的灰度圖
sobel邊緣檢測后的灰度圖

可以看到算法效果不錯,但我們並不需要這么多邊緣“信息”,只需要最外圍的邊緣“信息”。因此將非透明區域都填充成統一的顏色,再做邊緣檢測。

最終效果:理想的外圍邊緣
最終效果:理想的外圍邊緣

離散化##

獲得了外圍邊緣信息后,下一步需要做離散化:剔除冗余信息,並將邊緣信息以有序集合的形式表示。這個有序集合,就是渲染底層所需要的頂點數據。

冗余頂點:對於邊緣的直線,除直線首尾兩點外,其他點都是冗余可剔除的。
有序集合:集合點依次連接起來,就如同用筆按逆時針/順時針方向畫出來的邊緣圖形。

筆者挑選了邊緣點集中x最小的點作為起始點,以順時針順序查找鄰接點的方法來計算有序頂點集。

算法步驟:

  1. 選擇邊緣點集x最小的點為起始點,當前點
  2. 查找當前點周邊8個像素點是否有邊緣點,如都沒有就繼續向外圍一圈,直到找到邊緣點。
  3. 當找到多個邊緣點情況下,比較當前點與各邊緣點所呈夾角,選夾角最小的邊緣點作為鄰接點。
  4. 若鄰接點即為起始點,則算法結束,否則繼續
  5. 判斷鄰接點與有序頂點集最后一個點是否共邊,若共邊則刪除最后一個點
  6. 將鄰接點加入有序頂點集
  7. 設置鄰接點為當前點,重復步驟2

刪除共邊頂點圖示:當C即將加入頂點集中,發現ABC三點共邊的情況,刪除中間點B
刪除共邊頂點圖示:當C即將加入頂點集中,發現ABC三點共邊的情況,刪除中間點B

三角化##

三角化(Triangulation)也是圖形學應用較多的算法了,特別是在3D建模、游戲領域。三角化是指從一組已知點集中,構建出三角形網格。隨着構建條件不同,三角化算法也不同。像最近LowPoly繪畫風格比較熱門,一些濾鏡軟件會支持LowPoly轉換。軟件在將一張普通圖像轉換位LowPoly圖像的過程中,除了一樣要做邊緣檢測,離散化外,在三角化這一步,需要生成顯示質量較高的三角形,不能有過於狹長的三角形,就需要用Delaunay算法。在我們這個場景下,對生成的三角形並沒有特殊要求,不需要用上復雜的Delaunay算法,Unity3d wiki社區上提供了一個簡單的三角化算法,剛好適用。

算法原理
從點集中隨機挑選三點組成三角形,然后遍歷其他點,看是否有點落在三角形內,如果三角形內無點則為合格三角形。循環此過程直到所有點都被處理。

可視化編輯##

經過前面處理,我們已經拿到了頂點數據、面片數據。筆者希望組件能將這些頂點數據可視化,以便讓使用者直觀了解處理結果。Unity自帶的PolygonCollider2D組件,正好適用。

public sealed class PolygonCollider2D : Collider2D
{
      ....
      public void SetPath(int index, Vector2[] points);
}

通過SetPath接口將頂點數據傳入PolygonCollider2D 組件,PolygonCollider2D完美地生成米老鼠的路徑。在一開始實驗中,筆者驚奇地發現組件竟然也對頂點做了三角化處理。遺憾地是,組件並沒有提供接口獲取三角化結果,Unity社區的技術人員也承認此點,說Unity的未來版本可能會考慮暴露此接口,並建議自己做三角化處理,就是前面所說的算法(汗.. = . = ||)。通過下圖比較,可以看到組件跟算法的三角化結果還是有所不同的。

頂點數據傳入PolygonCollider2D后的效果
頂點數據傳入PolygonCollider2D后的效果

算法處理后的三角化效果
算法處理后的三角化效果

利用PolygonCollider2D組件除了讓我們可以看到頂點結果,還可以通過Inspector上的[Edit Collider]按鈕微調,頂點的位置,做出更理想的Mask效果。
甚至,我們可以直接利用PolygonCollider2D組件,從無到有地編輯Mask形狀后,再三角化處理獲得面片數據。

直接用PolygonCollider2D編輯出來的“愛心”
直接用PolygonCollider2D編輯出來的“愛心”

渲染##

已經有了頂點數據,面片數據,終於到了最后的渲染步驟。筆者利用MeshMask組件存放這些數據,並不直接渲染MeshMask,而是在MeshMask子節點下添加MeshImage組件,進行修改頂點渲染。
在5.3版本里,Unity提供了BaseMeshEffect類,是Unity提供給開發者用於給Graphic進行二次修改繪制的類,我們可以在ModifyMesh方法中修改VertexHelper攜帶的頂點,面片,uv等數據來改變渲染。(在5.3之前的版本,對應的類和接口是BaseVertexEffect、ModifyVertices)
MeshImage繼承BaseMeshEffect,在ModifyMesh里先將VertexHelper的原有數據清空,獲取MeshMask的頂點、面片數據,經過坐標轉換后將再傳給VertexHelper。

public abstract class BaseMeshEffect : UIBehaviour, IMeshModifier
{
      public abstract void ModifyMesh(VertexHelper vh);
}

public class MeshImage : BaseMeshEffect{
  
  ...
  public override void ModifyMesh(VertexHelper vh)
  {
    if (this.enabled)
    {
        vh.Clear();
        _uiVertices.Clear();
        if (mask)
        {
            if (mask.vertices != null && mask.triangles != null)
            {
                float tw = image.rectTransform.rect.width;
                float th = image.rectTransform.rect.height;
                Vector4 uv = image.overrideSprite != null ? DataUtility.GetOuterUV(image.overrideSprite) : Vector4.zero;
                float uvCenterX = (uv.x + uv.z) * image.rectTransform.pivot.x;
                float uvCenterY = (uv.y + uv.w) * image.rectTransform.pivot.y;
                float uvScaleX = (uv.z - uv.x) / tw;
                float uvScaleY = (uv.w - uv.y) / th;
                List<Vector3> vertices = this.mask.vertices.Select(
                    x => { return this.transform.InverseTransformPoint(this.mask.transform.TransformPoint(x)); }).ToList();

                for (int i = 0; i < mask.vertices.Count; i++)
                {
                    UIVertex v = new UIVertex();
                    v.color = image.color;
                    v.position = vertices[i];
                    v.uv0 = new Vector2(v.position.x * uvScaleX + uvCenterX, v.position.y * uvScaleY + uvCenterY);
                    _uiVertices.Add(v);
                }

                vh.AddUIVertexStream(_uiVertices, mask.triangles);
            }
        }
    }
  }
}

拖動MeshImage的位置,圖片外顯區域始終限定在米老鼠Mask內
拖動MeshImage的位置,圖片外顯區域始終限定在米老鼠Mask內

像素級精確點擊##

如上篇博文所講,為了實現精確點擊,Unity提供了eventAlphaThreshold字段,但有着Sprite占用雙倍內存,無法合入圖集等缺陷。而MeshButton組件正好解決了痛點。MeshButton實現ICanvasRaycastFilter接口類,實現IsRaycastLocationValid方法,在方法內獲取MeshMask的頂點數據,通過Ray-Crossing算法就可以判斷點擊點是否在區域內。

public class MeshButton : UIBehaviour, ICanvasRaycastFilter
{
    public virtual bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera){

        //Stopwatch sw = new Stopwatch();
        //sw.Start();

        Sprite sprite = image.overrideSprite;
        if (sprite == null)
            return true;

        bool ret = true;
        if (this.mask != null && this.mask.vertices != null)
        {
            Vector2 local;
            RectTransformUtility.ScreenPointToLocalPointInRectangle(image.rectTransform, screenPoint, eventCamera, out local);

            List<Vector2> vertices = this.mask.vertices.Select(
                x =>
                {
                    Vector3 p = this.transform.InverseTransformPoint(this.mask.transform.TransformPoint(x));
                    return new Vector2(p.x, p.y);
                }).ToList();

            ret = ImageUtil.Contains(local, vertices);
        }

        //sw.Stop();
        //UnityEngine.Debug.Log("點擊檢測耗時:" + sw.ElapsedTicks + " tick");

        return ret;
    }
}

關於MeshMask#

  1. MeshMask組件適合用來顯示特殊形狀的Icon。MeshMask並不能完全取代Unity Mask,在需要顯示特殊形狀Icon時作為Unity Mask的替代方案,能達到提高渲染效率的目的,減少Unity Mask的不必要使用。
  2. 被Mask的圖片如果被移出Mask范圍外,會因為Sprite Wrap mode而出現邊緣像素拉伸,或者貼圖重復的問題,這個問題暫時不能很好解決,因為Sprite Wrap mode必須設置為clamp或者repeat,就會出現這種問題。只能設置為clamp后,人為為貼圖邊緣留1px的透明邊解決。好在,做特殊形狀Icon的使用場景下,基本無須擔心這個問題。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM