天天看點

【UGUI源碼分析】Unity遮罩之Mask詳細解讀

遮罩,顧名思義是一種可以掩蓋其它元素的控件。常用于修改其它元素的外觀,或限制元素的形狀。比如ScrollView或者圓頭像效果都有用到遮罩功能。本系列文章希望通過閱讀UGUI源碼的方式,來探究遮罩的實作原理,以及通過Unity不同遮罩之間實作方式的對比,找到每一種遮罩的最佳使用場合。

Unity UGUI主要提供兩種遮罩,分别是Mask和Rect Mask 2D。在2D遊戲開發中,可能還會用到Sprite Mask,雖然不是本文的重點,但後面也會提到。原本是希望将對各個遮罩的分析與對比整合在一篇文章中,但在書寫過程中發現篇幅過長,是以隻好拆分為三個部分。本篇文章是第一部分,專門解讀Mask遮罩。另外兩篇分别是

  • 【UGUI源碼分析】Unity遮罩之RectMask2D詳細解讀
  • Unity遮罩之Mask、RectMask2D與Sprite Mask适用場景分析

本文使用的源碼與内置資源均基于Unity2019.4版本

Mask

查閱Unity的官方文檔,對Mask有如下定義

遮罩不是可見的 UI 控件,而是一種修改控件子元素外觀的方法。遮罩将子元素限制(即“掩蓋”)為父元素的形狀。是以,如果子項比父項大,則子項僅包含在父項以内的部分才可見。

也有簡單提到Mask的實作原理

使用 GPU 的模闆緩沖區來實作遮罩。第一個遮罩元素将 1 寫入模闆緩沖區。遮罩下面的所有元素在渲染時進行檢查,僅渲染到模闆緩沖區中有 1 的區域。 嵌套的遮罩會将增量位掩碼寫入緩沖區,這意味着可渲染的子項需要具有要渲染的邏輯和模闆值。

是不是有些晦澀難懂?沒關系,接下來的分析就是對這個實作原理的展開,每句話都會有對應的解讀

模闆緩沖?

要搞懂模闆緩沖,先要了解模闆測試。在渲染流水線的逐片元操作階段,會有一個模闆測試,可以作為一種丢棄片元的輔助方法(這裡的片元可以簡單了解為對應着一個像素),而要進行模闆測試就要用到模闆緩沖。每個像素/片段都可以有一個與之對應的模闆值,就存儲在模闆緩沖中。

如果開啟了模闆測試,GPU會首先讀取(使用讀取掩碼)模闆緩沖區中該片元位置的模闆值,然後将該值和讀取到(使用讀取掩碼)的參考值進行比較,這個比較函數可以是由開發者指定的,例如小于時舍棄該片元,或者大于等于時舍棄。如果這個片元沒有通過這個測試,該片元就會被舍棄。不管一個片元有沒有通過模闆測試,我們都可以根據模闆測試和下面的深度測試結果來修改模闆緩沖區,這個修改操作也是由開發者指定的。開發者可以設定不同結果下的修改操作,例如,在失敗時模闆緩沖區保持不變,通過時将模闆緩沖區中對應位置的值加1等。

而Mask就是通過在渲染時,将其對應位置像素的模闆值都置為特定值(不一定是1),然後當遮罩下的子元素渲染時,逐像素判斷模闆值是否為特定值,如果是特定值,就表示在遮罩範圍内,可以顯示。如果不是,則表示不在遮罩範圍内,不顯示。借用一張網上的圖,很形象的描述了這種方式。

綠色矩形是遮罩區域,模闆值都被寫入為1,當渲染橫着的紅色矩形時,隻有模闆值為1的區域才會顯示,非1的會被丢棄不會顯示。進而實作了裁剪效果

【UGUI源碼分析】Unity遮罩之Mask詳細解讀

源碼

在了解了Mask的基本實作原理後,再來通過源碼看看具體的實作方式

UGUI中所有可顯示的圖形都有一個基類,Graphic。比如Image和Text就是間接繼承于Graphic的。Graphic定義了一個materialForRendering屬性。它表示傳遞給CanvasRenderer,實際被用于渲染的材質。從這個屬性的get通路器可以發現,在擷取最終被用于渲染的材質時,會先依次調用這個GameObject上所有實作了IMaterialModifier接口元件的GetModifiedMaterial方法來修改最後傳回的材質。

public virtual Material materialForRendering
{
    get
    {
        var components = ListPool<Component>.Get();
        GetComponents(typeof(IMaterialModifier), components);

        var currentMat = material;
        for (var i = 0; i < components.Count; i++)
            currentMat = (components[i] as IMaterialModifier).GetModifiedMaterial(currentMat);
        ListPool<Component>.Release(components);
        return currentMat;
    }
}
           

IMaterialModifier定義如下所示,也就是說其它元件可以通過實作IMaterialModifier接口來達到修改最終渲染所使用的材質的目的

public interface IMaterialModifier
{
    /// <summary>
    /// Perform material modification in this function.
    /// </summary>
    /// <param name="baseMaterial">The material that is to be modified</param>
    /// <returns>The modified material.</returns>
    Material GetModifiedMaterial(Material baseMaterial);
}
           

Mask元件就實作了IMaterialModifier接口,并通過這個接口傳回了一個新材質,并通過這個新材質設定修改模闆緩沖值

/// Stencil calculation time!
public virtual Material GetModifiedMaterial(Material baseMaterial)
{
    // ...
    var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
    var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);
    if (stencilDepth >= 8)
    {
        Debug.LogWarning("Attempting to use a stencil mask with depth > 8", gameObject);
        return baseMaterial;
    }

    int desiredStencilBit = 1 << stencilDepth;
    
    // 第一部分
    // if we are at the first level...
    // we want to destroy what is there
    if (desiredStencilBit == 1)
    {
        // CompareFunction.Always,始終通過,執行StencilOp.Replace操作,将模闆緩沖中的值替換為(1 & 255)= 1
        var maskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Replace, CompareFunction.Always, m_ShowMaskGraphic ? ColorWriteMask.All : 0);
        StencilMaterial.Remove(m_MaskMaterial);
        m_MaskMaterial = maskMaterial;

        var unmaskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Zero, CompareFunction.Always, 0);
        StencilMaterial.Remove(m_UnmaskMaterial);
        m_UnmaskMaterial = unmaskMaterial;
        // 設定渲染器可使用的材質數量為1
        graphic.canvasRenderer.popMaterialCount = 1;
        // 設定渲染器使用的材質
        graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);

        return m_MaskMaterial;
    }
    // 第二部分
    // ...
}
           

GetModifiedMaterial的實作可以分兩部分來看,上面的代碼隻列出了第一部分。簡單起見,我們先隻看第一部分,主要是if (desiredStencilBit == 1)語句塊内代碼,它是用于處理隻有自身有Mask的簡單情況的

  • 代碼中的stencilDepth表示自身到Canvas之間Mask的個數,如果每層有多個Mask則隻計一個。如果除了自身的Mask,再往上沒有Mask了,則stencilDepth為0,如果再往上找到1個,stencilDepth為1,找到2個,stencilDepth為2,以此類推。
  • desiredStencilBit表示實際要寫入模闆緩沖的參考值。desiredStencilBit = 1 << stencilDepth。當stencilDepth >= 8時會列印警告,是因為模闆值一般是8位的,desiredStencilBit将超出這個範圍無法寫入
  • 如果隻是自身有Mask,再往上沒有了。那stencilDepth就是0,desiredStencilBit就是1,此時通過StencilMaterial.Add獲得一個新材質,并将這個材質傳回,進而達到修改最終渲染使用材質的目的。StencilMaterial.Add方法具體實作如下所示,主要是對材質設定一些傳入的參數。
public static Material Add(Material baseMat, int stencilID, StencilOp operation, CompareFunction compareFunction, ColorWriteMask colorWriteMask, int readMask, int writeMask)
{
    // ...
    var newEnt = new MatEntry();
    newEnt.count = 1;
    newEnt.baseMat = baseMat;
    newEnt.customMat = new Material(baseMat);
    newEnt.customMat.hideFlags = HideFlags.HideAndDontSave;
    newEnt.stencilId = stencilID;
    newEnt.operation = operation;
    newEnt.compareFunction = compareFunction;
    newEnt.readMask = readMask;
    newEnt.writeMask = writeMask;
    newEnt.colorMask = colorWriteMask;
    newEnt.useAlphaClip = operation != StencilOp.Keep && writeMask > 0;

    newEnt.customMat.name = string.Format("Stencil Id:{0}, Op:{1}, Comp:{2}, WriteMask:{3}, ReadMask:{4}, ColorMask:{5} AlphaClip:{6} ({7})", stencilID, operation, compareFunction, writeMask, readMask, colorWriteMask, newEnt.useAlphaClip, baseMat.name);

    newEnt.customMat.SetInt("_Stencil", stencilID);
    newEnt.customMat.SetInt("_StencilOp", (int)operation);
    newEnt.customMat.SetInt("_StencilComp", (int)compareFunction);
    newEnt.customMat.SetInt("_StencilReadMask", readMask);
    newEnt.customMat.SetInt("_StencilWriteMask", writeMask);
    newEnt.customMat.SetInt("_ColorMask", (int)colorWriteMask);
    newEnt.customMat.SetInt("_UseUIAlphaClip", newEnt.useAlphaClip ? 1 : 0);

    if (newEnt.useAlphaClip)
        newEnt.customMat.EnableKeyword("UNITY_UI_ALPHACLIP");
    else
        newEnt.customMat.DisableKeyword("UNITY_UI_ALPHACLIP");

    m_List.Add(newEnt);
    return newEnt.customMat;
}
           

StencilMaterial本質上隻是緩存材質的一個工具類,主要作用就是提供一個新的材質。再結合下面這句代碼傳入的參數。這個新材質起到的作用是始終通過模闆測試(CompareFunction.Always),替換模闆緩沖中的模闆值(StencilOp.Replace)為1

var maskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Replace, CompareFunction.Always, m_ShowMaskGraphic ? ColorWriteMask.All : 0);
           

對材質設定的參數,實際上是設定給Shader的,檢視UI預設使用的Shader是UI/Default,這是Unity的内置Shader,源碼可以在Unity官網下載下傳,下載下傳時選擇"Built in shaders"

【UGUI源碼分析】Unity遮罩之Mask詳細解讀

UI-Default.shader的部分源碼如下所示,可以看到主要是利用Unity ShaderLab的模闆語句來實作對模闆緩沖區的一些操作,詳細介紹可以點選這裡檢視,就不再贅述了

// Unity built-in shader source. Copyright (c) 2016 Unity Technologies. MIT license (see license.txt)

Shader "UI/Default"
{
    Properties
    {
        [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
        _Color ("Tint", Color) = (1,1,1,1)

        _StencilComp ("Stencil Comparison", Float) = 8
        _Stencil ("Stencil ID", Float) = 0
        _StencilOp ("Stencil Operation", Float) = 0
        _StencilWriteMask ("Stencil Write Mask", Float) = 255
        _StencilReadMask ("Stencil Read Mask", Float) = 255

        _ColorMask ("Color Mask", Float) = 15

        [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0
    }

    SubShader
    {
        // ...
        Stencil
        {
            Ref [_Stencil]
            Comp [_StencilComp]
            Pass [_StencilOp]
            ReadMask [_StencilReadMask]
            WriteMask [_StencilWriteMask]
        }
        // ...
    }
}

           

到這裡不難發現,Unity文檔Mask原理描述中的第一句話就是對上面過程的一個概括

使用 GPU 的模闆緩沖區來實作遮罩。第一個遮罩元素将 1 寫入模闆緩沖區。

接下來我們再來看被遮掩的對象,是怎樣利用模闆緩沖實作遮罩效果的

UGUI中所有可被遮掩的圖形都有一個基類,MaskableGraphic,同樣MaskableGraphic是繼承于Graphic的。比如Image和Text就是繼承于MaskableGraphic的。同理,MaskableGraphic也實作了IMaterialModifier接口來修改最終渲染使用的材質

public virtual Material GetModifiedMaterial(Material baseMaterial)
{
    var toUse = baseMaterial;

    if (m_ShouldRecalculateStencil)
    {
        var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
        m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0;
        m_ShouldRecalculateStencil = false;
    }

    // if we have a enabled Mask component then it will
    // generate the mask material. This is an optimization
    // it adds some coupling between components though :(
    if (m_StencilValue > 0 && !isMaskingGraphic)
    {
        var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
        StencilMaterial.Remove(m_MaskMaterial);
        m_MaskMaterial = maskMat;
        toUse = m_MaskMaterial;
    }
    return toUse;
}
           
  • 代碼中的m_StencilValue表示在自身層級之上有多少個Mask,如果隻有父節點有Mask元件,則m_StencilValue值為1
  • 可以看到它傳回的新材質主要作用是,比較傳入的參考值((1 << m_StencilValue) - 1)與模闆緩沖中的值,如果相等就通過(CompareFunction.Equal),即使通過了模闆測試也仍保留模闆緩沖中的值(StencilOp.Keep)。
var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
           
  • 當隻有父節點有Mask元件時,(1 << m_StencilValue) - 1值即為1,與前面Mask元件提前設定的模闆緩沖區的值相同,是以在Mask範圍内的元素将能夠通過模闆測試,最終顯示出來,未通過的将被裁剪無法顯示出來

這裡就對應了Unity文檔Mask原理描述中的中間部分

遮罩下面的所有元素在渲染時進行檢查,僅渲染到模闆緩沖區中有 1 的區域。

實際上到這裡,一個簡單的,隻有父節點有Mask的圖形是怎樣實作遮罩效果的,我們已經徹底搞清楚了,接下來,讓我們來看看複雜點的情況

如果大家還沒忘記的話,讓我們回到Mask的GetModifiedMaterial實作(注意是Mask的哦~),檢視它的第二部分,即if語句塊後面的代碼,他們是被用來處理嵌套Mask的

public virtual Material GetModifiedMaterial(Material baseMaterial)
{
    if (!MaskEnabled())
        return baseMaterial;

    var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
    var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);
    if (stencilDepth >= 8)
    {
        Debug.LogWarning("Attempting to use a stencil mask with depth > 8", gameObject);
        return baseMaterial;
    }

    int desiredStencilBit = 1 << stencilDepth;

    // 第一部分
    // ...

    // 第二部分
    //otherwise we need to be a bit smarter and set some read / write masks
    var maskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit | (desiredStencilBit - 1), StencilOp.Replace, CompareFunction.Equal, m_ShowMaskGraphic ? ColorWriteMask.All : 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
    StencilMaterial.Remove(m_MaskMaterial);
    m_MaskMaterial = maskMaterial2;

    graphic.canvasRenderer.hasPopInstruction = true;
    var unmaskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit - 1, StencilOp.Replace, CompareFunction.Equal, 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
    StencilMaterial.Remove(m_UnmaskMaterial);
    m_UnmaskMaterial = unmaskMaterial2;
    graphic.canvasRenderer.popMaterialCount = 1;
    graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);

    return m_MaskMaterial;
}
           
  • 與第一部分不同的是,StencilMaterial.Add傳入的參數不同,而這些不同就是處理嵌套Mask的關鍵。嵌套Mask是指除了自身Mask,層級再往上還有Mask。針對這種情況,傳入的參考值是desiredStencilBit | (desiredStencilBit - 1),而不再固定是1了。這個值的實際含義是利用每一位是否是1來表示每一層是否有Mask。舉個栗子,如果除了自身,再往上還能找到兩個Mask,則stencilDepth為2,desiredStencilBit為8,二進制形式為100,經過計算傳入的參考值是111,用每個1來分别表示,自身有Mask,第一層有,第二層有。這個參考值被Unity稱之為增量位掩碼
  • 這個增量位掩碼正好可以與MaskableGraphic部分判斷模闆值是否相等時用到的(1 << m_StencilValue) - 1對應上
// Mask處理嵌套遮罩所用的新材質
var maskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit | (desiredStencilBit - 1), StencilOp.Replace, CompareFunction.Equal, m_ShowMaskGraphic ? ColorWriteMask.All : 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));

// MaskableGraphic判斷是否在遮罩内所用的新材質
var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
           

實際上這部分就對應了Unity文檔Mask原理描述中的後兩句話

嵌套的遮罩會将增量位掩碼寫入緩沖區,這意味着可渲染的子項需要具有要渲染的邏輯和模闆值。

補充

最後還有幾處地方覺得值得提一下

  1. StencilMaterial.Add傳入參數的最後兩個分别是readMask讀取掩碼和writeMask寫入掩碼,讀取掩碼不僅是在讀取模闆緩沖中的值時會與其相與,對于要比較的參考值也會相與
  2. 細心的同學可能會發現,Mask在擷取新材質的時候,會多擷取一個。這個材質實際是用來清除模闆緩沖區的。以避免不要影響後續的渲染
    // 第一部分
    var unmaskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Zero, CompareFunction.Always, 0);
    
    // 第二部分
    var unmaskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit - 1, StencilOp.Replace, CompareFunction.Equal, 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
               
    利用Unity的幀調試器也可以看到這個清除過程
    【UGUI源碼分析】Unity遮罩之Mask詳細解讀
  3. 為什麼Mask可以實作圓形遮罩效果?

    衆所周知,圓頭像效果可以使用Mask實作,具體方式是使用一張隻顯示圓形,非圓形區域是透明像素的切圖實作的。但這張切圖實際上還是矩形的,根據上面的原了解讀,矩形區域對應的模闆值都會被Mask設定為特定值,進而使其下的子元素都能通過模闆測試,是無法實作圓形裁剪的

    關鍵代碼還是在UI-Default.shader中,它通過clip指令,将透明度低于0.001的片元都裁剪掉了,是以被裁剪的片元也就不會再設定對應的模闆值了。UNITY_UI_ALPHACLIP宏定義是通過Shader參數_UseUIAlphaClip控制的,Mask擷取的新材質會将該參數設定為true

    #ifdef UNITY_UI_ALPHACLIP
    clip (color.a - 0.001);
    #endif
               
  4. 關于SpriteMask

    Sprite Mask不屬于UGUI的範圍,Unity官方并沒有将它開源,不過通過官方論壇我們可以了解到其實作原理也是利用了模版緩沖。

    不像Mask,隻實作了Visible Inside Mask功能,SpriteMask不僅實作了Visible Inside Mask功能,也實作了Visible Outside Mask功能。在經過對Mask的原理分析以後,我們知道通過修改模闆緩沖的比較函數是可以輕易的實作這種效果的,感興趣的同學趕快動手試一下吧

    【UGUI源碼分析】Unity遮罩之Mask詳細解讀
    【UGUI源碼分析】Unity遮罩之Mask詳細解讀

參考

  • 【詳細解析版】Unity UGUI Mask元件實作原理
  • 源碼探析Mask、Rect Mask2D與Sprite Mask的異同

作者:iwiniwin

出處:http://www.cnblogs.com/iwiniwin/

本文為部落客原創文章,轉載請附上原文出處連結和本聲明。