遮罩,顧名思義是一種可以掩蓋其它元素的控制元件,常用于修改其它元素的外觀,或限制元素的形狀,比如ScrollView或者圓頭像效果都有用到遮罩功能,本系列文章希望通過閱讀UGUI原始碼的方式,來探究遮罩的實作原理,以及通過Unity不同遮罩之間實作方式的對比,找到每一種遮罩的最佳使用場合,
Unity UGUI主要提供兩種遮罩,分別是Mask和Rect 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 = https://www.cnblogs.com/iwiniwin/p/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原理描述中的后兩句話
嵌套的遮罩會將增量位掩碼寫入緩沖區,這意味著可渲染的子項需要具有要渲染的邏輯和模板值,
補充
最后還有幾處地方覺得值得提一下
-
StencilMaterial.Add傳入引數的最后兩個分別是readMask讀取掩碼和writeMask寫入掩碼,讀取掩碼不僅是在讀取模板緩沖中的值時會與其相與,對于要比較的參考值也會相與
-
細心的同學可能會發現,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的幀除錯器也可以看到這個清除程序

-
為什么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 -
關于SpriteMask
Sprite Mask不屬于UGUI的范圍,Unity官方并沒有將它開源,不過通過官方論壇我們可以了解到其實作原理也是利用了模版緩沖,
不像Mask,只實作了Visible Inside Mask功能,SpriteMask不僅實作了Visible Inside Mask功能,也實作了Visible Outside Mask功能,在經過對Mask的原理分析以后,我們知道通過修改模板緩沖的比較函式是可以輕易的實作這種效果的,感興趣的同學趕快動手試一下吧

參考
- 【詳細決議版】Unity UGUI Mask組件實作原理
- 原始碼探析Mask、Rect Mask2D與Sprite Mask的異同
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/296248.html
標籤:其他
下一篇:在游戲中播放cg視頻遇到的問題
