在上一篇博文"扔掉遮罩,更好的圓形Image組件"中,筆者改變Image的頂點數據,使得Image呈圓形顯示,避免了Mask的使用,從而節省Drawcall消耗,提高渲染效率了。這也啟發了筆者,有沒有可能通過同樣原理實現Mask,做到在某些需要顯示特定形狀Icon的場景下,替代Unity原生Mask,且能保有節省Drawcall,減少渲染像素點,實現精確點擊等優點?經過一番折騰,就有了MeshMask組件。
組件效果#
MeshMask遮罩效果圖
可以看到無論Mask形狀是凸邊形還是復雜的凹邊形,都能准確地將Mask形狀數據序列化成頂點,面片數據,
提供給需要Mask的圖片修改渲染頂點,達到遮罩效果。組件用法類似於Unity Mask,且效率優於Unity Mask。插件已上傳至Github[點擊下載], 歡迎試用~
效率對比#
使用原生Mask,10個Icon占用了15個Drawcall
使用MeshMask,10個Icon僅占用1個Drawcall
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、MeshImage、MeshButton三個UI組件
MeshMask組件Inspector面板
MeshMask組件作用類似Unity Mask,依賴了Image及PolygonCollider2D組件,帶有[根據Image組件生成Mask]、[根據Collider組件生成Mask]兩個菜單項,支持兩種方式生成Mask數據。
被遮罩GameOjecct的Inspector面板
MeshImage、MeshButton組件掛在需要被遮罩的GameObject上,設置好MeshMask對象,就能獲得數據,實現遮罩或者精確點擊。
組件實現#
不同於CircleImage,只需要簡單的對圓形進行頂點,面片計算;MeshMask要考慮幾個點:
- 需要能對所有可能的圖形進行頂點,面片計算。
- 考慮頂點,面片計算需要讀取Image,且有一定性能開銷,所以不能在Run-time中實時計算數據,需要預先計算好vertices,triangle數據,並序列化存放在GameObject中,運行時讀取。
- 保證MeshMask靈活性,除了根據Image進行頂點,面片計算,希望像PS一樣,提供路徑工具,讓開發可以可視化地新增、修改Mask形狀。
- 對所有圖形支持像素級點擊判斷
其中做頂點,面片計算這一步比較麻煩,涉及以下幾個技術點:
圖片處理流程
邊緣檢測##
邊緣檢測算法算是圖形學應用最廣泛最基礎的算法了,主要原理是濾波器對圖形進行濾波從而得到梯度圖像,通過判斷梯度圖像的某像素點灰度值是否超過閾值,就能判斷該點是否為邊緣點。筆者采用了簡單的Sobel算子邊緣檢測算法。
Sobel算子:3x3的矩形濾波器
A代表原始圖像,Gx及Gy分別代表經橫向及縱向邊緣檢測的圖像灰度值
圖像某像素點灰度值
通常,為了提高效率 使用不開平方的近似值
這里拿米老鼠圖來做示例圖,看看Sobel邊緣檢測的效果。
原圖
sobel邊緣檢測后的灰度圖
可以看到算法效果不錯,但我們並不需要這么多邊緣“信息”,只需要最外圍的邊緣“信息”。因此將非透明區域都填充成統一的顏色,再做邊緣檢測。
最終效果:理想的外圍邊緣
離散化##
獲得了外圍邊緣信息后,下一步需要做離散化:剔除冗余信息,並將邊緣信息以有序集合的形式表示。這個有序集合,就是渲染底層所需要的頂點數據。
冗余頂點:對於邊緣的直線,除直線首尾兩點外,其他點都是冗余可剔除的。
有序集合:集合點依次連接起來,就如同用筆按逆時針/順時針方向畫出來的邊緣圖形。
筆者挑選了邊緣點集中x最小的點作為起始點,以順時針順序查找鄰接點的方法來計算有序頂點集。
算法步驟:
- 選擇邊緣點集x最小的點為起始點,當前點
- 查找當前點周邊8個像素點是否有邊緣點,如都沒有就繼續向外圍一圈,直到找到邊緣點。
- 當找到多個邊緣點情況下,比較當前點與各邊緣點所呈夾角,選夾角最小的邊緣點作為鄰接點。
- 若鄰接點即為起始點,則算法結束,否則繼續
- 判斷鄰接點與有序頂點集最后一個點是否共邊,若共邊則刪除最后一個點
- 將鄰接點加入有序頂點集
- 設置鄰接點為當前點,重復步驟2
刪除共邊頂點圖示:當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組件除了讓我們可以看到頂點結果,還可以通過Inspector上的[Edit Collider]按鈕微調,頂點的位置,做出更理想的Mask效果。
甚至,我們可以直接利用PolygonCollider2D組件,從無到有地編輯Mask形狀后,再三角化處理獲得面片數據。
直接用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內
像素級精確點擊##
如上篇博文所講,為了實現精確點擊,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#
- MeshMask組件適合用來顯示特殊形狀的Icon。MeshMask並不能完全取代Unity Mask,在需要顯示特殊形狀Icon時作為Unity Mask的替代方案,能達到提高渲染效率的目的,減少Unity Mask的不必要使用。
- 被Mask的圖片如果被移出Mask范圍外,會因為Sprite Wrap mode而出現邊緣像素拉伸,或者貼圖重復的問題,這個問題暫時不能很好解決,因為Sprite Wrap mode必須設置為clamp或者repeat,就會出現這種問題。只能設置為clamp后,人為為貼圖邊緣留1px的透明邊解決。好在,做特殊形狀Icon的使用場景下,基本無須擔心這個問題。