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

iwiniwin發表於2021-08-12

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

Unity UGUI主要提供兩種遮罩,分別是MaskRect Mask 2D。在2D遊戲開發中,可能還會用到Sprite Mask,雖然不是本文的重點,但後面也會提到。原本是希望將對各個遮罩的分析與對比整合在一篇文章中,但在書寫過程中發現篇幅過長,因此只好拆分為三個部分。本篇文章是第一部分,專門解讀Mask遮罩。後續關於Rect Mask 2D和遮罩間對比分析的文章會在完成後發出,敬請期待~

本文使用的原始碼與內建資源均基於Unity2019.4版本

Mask

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

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

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

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

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

模板緩衝?

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

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

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

綠色矩形是遮罩區域,模板值都被寫入為1,當渲染橫著的紅色矩形時,只有模板值為1的區域才會顯示,非1的會被丟棄不會顯示。從而實現了裁剪效果

原始碼

在瞭解了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"

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的幀偵錯程式也可以看到這個清除過程

  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的原理分析以後,我們知道通過修改模板緩衝的比較函式是可以輕易的實現這種效果的,感興趣的同學趕快動手試一下吧


參考

相關文章