在上一篇博文"扔掉遮罩,更好的圓形Image組件"中,筆者改變Image的頂點(diǎn)數(shù)據(jù),使得Image呈圓形顯示,避免了Mask的使用,從而節(jié)省Drawcall消耗,提高渲染效率了。這也啟發(fā)了筆者,有沒有可能通過同樣原理實(shí)現(xiàn)Mask,做到在某些需要顯示特定形狀I(lǐng)con的場景下,替代Unity原生Mask,且能保有節(jié)省Drawcall,減少渲染像素點(diǎn),實(shí)現(xiàn)精確點(diǎn)擊等優(yōu)點(diǎn)?經(jīng)過一番折騰,就有了MeshMask組件。
組件效果##

可以看到無論Mask形狀是凸邊形還是復(fù)雜的凹邊形,都能準(zhǔn)確地將Mask形狀數(shù)據(jù)序列化成頂點(diǎn),面片數(shù)據(jù),
提供給需要Mask的圖片修改渲染頂點(diǎn),達(dá)到遮罩效果。組件用法類似于Unity Mask,且效率優(yōu)于Unity Mask。插件已上傳至Github[點(diǎn)擊下載], 歡迎試用~
效率對比##



從上面三張圖可以看到MeshMask相比Unity的Mask,在減少Drawcall消耗、Overdraw消耗等兩方面都是完勝的。
Drawcall消耗#####
這10個icon都打包在同一圖集的,使用Unity Mask,沒辦法享受圖層合并,消耗了15個Drawcall;使用MeshMask的情況下,看截圖里Batches為2,除去攝像機(jī)占用的1個Batch,10個icon僅占用1個Batch,即1個Drawcall。在Drawcall資源如此昂貴的情況下(一般機(jī)器都會要求Drawcall在200以下),這種性能節(jié)省效果非常顯著。
Overdraw消耗#####
而看圖三的Overdraw,使用Unity Mask的紅框部分,被Mask的圖片全部繪制一次,Unity Mask再做像素剔除,被Mask的部分又繪制了一次,總共需要繪制兩次,且有一次是繪制了完全用不到的區(qū)域。使用MeshMask的藍(lán)框部分,因?yàn)槭强扛淖冺旤c(diǎn)繪制出來的icon,因此僅有被Mask部分被繪制了一次。
面片消耗#####
當(dāng)然,使用MeshMask的Image需要消耗比普通Image多一些的頂點(diǎn)和面片,觀察Stats面板,使用MeshMsk的10個icon多占用1.3K的頂點(diǎn)和面片,即1個icon占用130個頂點(diǎn),面片。然而GPU渲染頂點(diǎn),面片的效率非常高(市面手機(jī)GPU渲染多邊形數(shù)基本上2000-10000+萬多邊形/每秒以上),這點(diǎn)消耗跟Drawcall比起來就微不足道了。
小結(jié)#####
在渲染上,GPU、CPU兩者的性能瓶頸往往是CPU;GPU的性能瓶頸往往是像素點(diǎn)填充率(Overdraw導(dǎo)致),CPU的性能瓶頸往往是Drawcall。所以,渲染性能排查,幾項指標(biāo)關(guān)注優(yōu)先級應(yīng)該是:Drawcall > Overdraw > 面片
組件使用##

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

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

MeshImage、MeshButton組件掛在需要被遮罩的GameObject上,設(shè)置好MeshMask對象,就能獲得數(shù)據(jù),實(shí)現(xiàn)遮罩或者精確點(diǎn)擊。
組件實(shí)現(xiàn)##
不同于CircleImage,只需要簡單的對圓形進(jìn)行頂點(diǎn),面片計算;MeshMask要考慮幾個點(diǎn):
- 需要能對所有可能的圖形進(jìn)行頂點(diǎn),面片計算。
- 考慮頂點(diǎn),面片計算需要讀取Image,且有一定性能開銷,所以不能在Run-time中實(shí)時計算數(shù)據(jù),需要預(yù)先計算好vertices,triangle數(shù)據(jù),并序列化存放在GameObject中,運(yùn)行時讀取。
- 保證MeshMask靈活性,除了根據(jù)Image進(jìn)行頂點(diǎn),面片計算,希望像PS一樣,提供路徑工具,讓開發(fā)可以可視化地新增、修改Mask形狀。
- 對所有圖形支持像素級點(diǎn)擊判斷
其中做頂點(diǎn),面片計算這一步比較麻煩,涉及以下幾個技術(shù)點(diǎn):

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




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


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

離散化####
獲得了外圍邊緣信息后,下一步需要做離散化:剔除冗余信息,并將邊緣信息以有序集合的形式表示。這個有序集合,就是渲染底層所需要的頂點(diǎn)數(shù)據(jù)。
冗余頂點(diǎn):對于邊緣的直線,除直線首尾兩點(diǎn)外,其他點(diǎn)都是冗余可剔除的。
有序集合:集合點(diǎn)依次連接起來,就如同用筆按逆時針/順時針方向畫出來的邊緣圖形。
筆者挑選了邊緣點(diǎn)集中x最小的點(diǎn)作為起始點(diǎn),以順時針順序查找鄰接點(diǎn)的方法來計算有序頂點(diǎn)集。
算法步驟:
- 選擇邊緣點(diǎn)集x最小的點(diǎn)為起始點(diǎn),當(dāng)前點(diǎn)
- 查找當(dāng)前點(diǎn)周邊8個像素點(diǎn)是否有邊緣點(diǎn),如都沒有就繼續(xù)向外圍一圈,直到找到邊緣點(diǎn)。
- 當(dāng)找到多個邊緣點(diǎn)情況下,比較當(dāng)前點(diǎn)與各邊緣點(diǎn)所呈夾角,選夾角最小的邊緣點(diǎn)作為鄰接點(diǎn)。
- 若鄰接點(diǎn)即為起始點(diǎn),則算法結(jié)束,否則繼續(xù)
- 判斷鄰接點(diǎn)與有序頂點(diǎn)集最后一個點(diǎn)是否共邊,若共邊則刪除最后一個點(diǎn)
- 將鄰接點(diǎn)加入有序頂點(diǎn)集
- 設(shè)置鄰接點(diǎn)為當(dāng)前點(diǎn),重復(fù)步驟2

三角化####
三角化(Triangulation)也是圖形學(xué)應(yīng)用較多的算法了,特別是在3D建模、游戲領(lǐng)域。三角化是指從一組已知點(diǎn)集中,構(gòu)建出三角形網(wǎng)格。隨著構(gòu)建條件不同,三角化算法也不同。像最近LowPoly繪畫風(fēng)格比較熱門,一些濾鏡軟件會支持LowPoly轉(zhuǎn)換。軟件在將一張普通圖像轉(zhuǎn)換位LowPoly圖像的過程中,除了一樣要做邊緣檢測,離散化外,在三角化這一步,需要生成顯示質(zhì)量較高的三角形,不能有過于狹長的三角形,就需要用Delaunay算法。在我們這個場景下,對生成的三角形并沒有特殊要求,不需要用上復(fù)雜的Delaunay算法,Unity3d wiki社區(qū)上提供了一個簡單的三角化算法,剛好適用。
算法原理
從點(diǎn)集中隨機(jī)挑選三點(diǎn)組成三角形,然后遍歷其他點(diǎn),看是否有點(diǎn)落在三角形內(nèi),如果三角形內(nèi)無點(diǎn)則為合格三角形。循環(huán)此過程直到所有點(diǎn)都被處理。
可視化編輯####
經(jīng)過前面處理,我們已經(jīng)拿到了頂點(diǎn)數(shù)據(jù)、面片數(shù)據(jù)。筆者希望組件能將這些頂點(diǎn)數(shù)據(jù)可視化,以便讓使用者直觀了解處理結(jié)果。Unity自帶的PolygonCollider2D組件,正好適用。
public sealed class PolygonCollider2D : Collider2D
{
....
public void SetPath(int index, Vector2[] points);
}
通過SetPath接口將頂點(diǎn)數(shù)據(jù)傳入PolygonCollider2D 組件,PolygonCollider2D完美地生成米老鼠的路徑。在一開始實(shí)驗(yàn)中,筆者驚奇地發(fā)現(xiàn)組件竟然也對頂點(diǎn)做了三角化處理。遺憾地是,組件并沒有提供接口獲取三角化結(jié)果,Unity社區(qū)的技術(shù)人員也承認(rèn)此點(diǎn),說Unity的未來版本可能會考慮暴露此接口,并建議自己做三角化處理,就是前面所說的算法(汗.. = . = ||)。通過下圖比較,可以看到組件跟算法的三角化結(jié)果還是有所不同的。


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

渲染####
已經(jīng)有了頂點(diǎn)數(shù)據(jù),面片數(shù)據(jù),終于到了最后的渲染步驟。筆者利用MeshMask組件存放這些數(shù)據(jù),并不直接渲染MeshMask,而是在MeshMask子節(jié)點(diǎn)下添加MeshImage組件,進(jìn)行修改頂點(diǎn)渲染。
在5.3版本里,Unity提供了BaseMeshEffect類,是Unity提供給開發(fā)者用于給Graphic進(jìn)行二次修改繪制的類,我們可以在ModifyMesh方法中修改VertexHelper攜帶的頂點(diǎn),面片,uv等數(shù)據(jù)來改變渲染。(在5.3之前的版本,對應(yīng)的類和接口是BaseVertexEffect、ModifyVertices)
MeshImage繼承BaseMeshEffect,在ModifyMesh里先將VertexHelper的原有數(shù)據(jù)清空,獲取MeshMask的頂點(diǎn)、面片數(shù)據(jù),經(jīng)過坐標(biāo)轉(zhuǎn)換后將再傳給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);
}
}
}
}
}

像素級精確點(diǎn)擊####
如上篇博文所講,為了實(shí)現(xiàn)精確點(diǎn)擊,Unity提供了eventAlphaThreshold字段,但有著Sprite占用雙倍內(nèi)存,無法合入圖集等缺陷,而MeshButton組件正好解決了痛點(diǎn)。MeshButton實(shí)現(xiàn)ICanvasRaycastFilter接口類,實(shí)現(xiàn)IsRaycastLocationValid方法,在方法內(nèi)獲取MeshMask的頂點(diǎn)數(shù)據(jù),通過Ray-Crossing算法就可以判斷點(diǎn)擊點(diǎn)是否在區(qū)域內(nèi)。
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("點(diǎn)擊檢測耗時:" + sw.ElapsedTicks + " tick");
return ret;
}
}
關(guān)于MeshMask##
- MeshMask組件適合用來顯示特殊形狀的Icon。MeshMask并不能完全取代Unity Mask,在需要顯示特殊形狀I(lǐng)con時作為Unity Mask的替代方案,能達(dá)到提高渲染效率的目的,減少Unity Mask的不必要使用。
- 被Mask的圖片如果被移出Mask范圍外,會因?yàn)镾prite Wrap mode而出現(xiàn)邊緣像素拉伸,或者貼圖重復(fù)的問題,這個問題暫時不能很好解決,因?yàn)镾prite Wrap mode必須設(shè)置為clamp或者repeat,就會出現(xiàn)這種問題。只能設(shè)置為clamp后,人為為貼圖邊緣留1px的透明邊解決。好在,做特殊形狀I(lǐng)con的使用場景下,基本無須擔(dān)心這個問題。