UGUI使用教程(八)MaskableGraphic
发表于2018-01-27
MaskableGraphic是UGUI的核心组件,它继承自Graphic。MaskableGraphic是一个抽象类,它的派生类有RawImage、Image、Text。顾名思义,MaskableGraphic是可遮罩的图像,那本篇文章就和大家介绍下MaskableGraphic的实现原理和使用。
按照惯例,附上UGUI源码下载地址。
MaskableGraphic重写了OnEnable、OnDisable、OnTransformParentChanged(当父对象改变)和OnCanvasHierarchyChanged(当画布层次改变)方法,这些方法间接继承自UIBehavior。这些方法里都设置了m_ShouldRecalculateStencil(是否需要重新计算模板)为true,并调用UpdateClipParent(更新裁剪的父对象)和SetMaterialDirty(设置材质为脏的)。OnDisable方法里还额外从StencilMaterial(静态方法,用于管理模板材质)移除了m_MaskMaterial(遮罩材质),并设置m_MaskMaterial为null。
UpdateClipParent方法使用MaskUtilities的GetRectMaskForClippable方法从父对象中找到RectMask2D组件。我们知道RectMask2D是一个可以根据RectTransform裁剪子对象的组件。如图:
我们为Canvas添加了RectMask2D组件,超出Canvas矩形范围的部分就被裁剪掉了。
StencilMaterial是一个静态类,负责管理模板材质。它维护了一个类型为MatEntry的List,外部可以调用Add、Remove和ClearAll方法来对这个List进行操作。
Add方法,会创建一个MatEntry,并将输入的baseMat以及其他参数赋值给MatEntry的变量,并创建一个赋值baseMat新的材质customMat。并将stencilID、operation等参数设置给customMat(实际上是shader的参数)。
MaskableGraphic继承了IClippable,需要实现RecalculateClipping、Cull和SetClipRect方法。这些方法在MaskUtilities和RectMask2D被调用,用于图像的裁剪。
RecalculateClipping方法调用了上文提到过的UpdateClipParent,找到父对象中的RectMask2D组件。它在MaskUtilities中的Notify2DMaskStateChanged被调用(遍历RectMask2D的所有子对象,找到IClippable接口的组件,调用RecalculateClipping方法),这个方法是在RectMask2D的OnEnable、OnDisable以及编辑器模式下的OnValidate方法中被调用。
Cull方法:
public virtual void Cull(Rect clipRect, bool validRect) { if (!canvasRenderer.hasMoved) return; var cull = !validRect || !clipRect.Overlaps(canvasRect); var cullingChanged = canvasRenderer.cull != cull; canvasRenderer.cull = cull; if (cullingChanged) { m_OnCullStateChanged.Invoke(cull); SetVerticesDirty(); } }
如果输入参数validRect为false或者输入的clipRect与所属Canvas的矩形区域不重合,cull为true,并设置给canvasRenderer。canvasRenderer是Graphic里的属性,表示本对象上的CanvasRenderer组件。如果cull的值发生变化回调m_OnCullStateChanged(发送事件),并设置顶点为脏的,等待重新绘制。
SetClipRect方法很简单,只是根据输入validRect为canvasRenderer开启或关闭矩形裁剪(开启时传递参数clipRect)。
MaskableGraphic继承了IMaskable,需要实现RecalculateMasking方法。这个方法会在MaskUtilities中调用,用于图像的遮罩。方法名为NotifyStencilStateChanged(遍历Mask的所有子对象,找到IMaskable接口的组件,调用RecalculateMasking方法),在Mask的OnEnable、OnDisable以及编辑器模式下的OnValidate方法中被调用。
Mask是可以通过图片裁剪图片的组件,如图
一个圆形图像的对象包含了一个子对象,是一个方形图像(实际上是默认图像),稍微错开一点。我们为圆形图像添加Mask组件,就看到方形图像超出圆形图像的部分没有被渲染出来。
RecalculateMasking这个方法只是设置了m_ShouldRecalculateStencil为true并SetMaterialDirty。
MaskableGraphic还继承了IMaterialModifier,需要实现GetModifiedMaterial方法。这个方法在Graphic中被调用,用于重建图像时,或许修改过的材质。
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 Mask component then it will // generate the mask material. This is an optimisation // it adds some coupling between components though :( if (m_StencilValue > 0 && GetComponent<Mask>() == null) { 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; }
如果需要重算模板,便会从根画布(或者父对象中overrideSorting为true的Canvas)获取模板的深度。
如果深度大于零,且本对象含有Mask组件,便将baseMaterial(即当前的material)加入StencilMaterial,并移除旧的m_MaskMaterial。最后,替换m_MaskMaterial并返回(似乎m_MaskMaterial这个变量只是为了从StencilMaterial移除才存在的)。
深度获取方法:
public static int GetStencilDepth(Transform transform, Transform stopAfter) { var depth = 0; if (transform == stopAfter) return depth; var t = transform.parent; var components = ListPool<Component>.Get(); while (t != null) { t.GetComponents(typeof(Mask), components); for (var i = 0; i < components.Count; ++i) { if (components[i] != null && ((Mask)components[i]).IsActive() && ((Mask)components[i]).graphic.IsActive()) { ++depth; break; } } if (t == stopAfter) break; t = t.parent; } ListPool<Component>.Release(components); return depth; }
简单来讲就是从本对象往上找,找到一个图像有效的Mask组件,便深度加1,直到找到stopAfter(也就是上面说的根画布或者父对象中overrideSorting为true的画布)。
接着顺带介绍一下RectMask2D和Mask两个类。
RectMask2D维护了一个类型为IClippable的List——m_ClipTargets。MaskableGraphic会在UpdateClipParent的时候,将自己从旧的RectMask2D的RemoveClippable方法,将自己从m_ClipTargets中移除,并调用新的RectMask2D方法,加入m_ClipTargets。而m_ClipTargets会在PerformClipping方法中被调用。
PerformClipping是继承自IClipper接口的方法。RectMask2D会在OnEnable的时候将自己注册到ClipperRegistry,并再OnDisable的时候从后者移除。ClipperRegistry是一个单例,是裁剪器的注册处。我们在《UGUI使用教程(六)CanvasUpdateRegistry》中提到过它,当Layout(布局)重建结束后,会调用它的Cull方法,遍历每一个Clipper执行PerformClipping方法。
public virtual void PerformClipping() { // if the parents are changed // or something similar we // do a recalculate here if (m_ShouldRecalculateClipRects) { MaskUtilities.GetRectMasksForClip(this, m_Clippers); m_ShouldRecalculateClipRects = false; } // get the compound rects from // the clippers that are valid bool validRect = true; Rect clipRect = Clipping.FindCullAndClipWorldRect(m_Clippers, out validRect); if (clipRect != m_LastClipRectCanvasSpace) { for (int i = 0; i < m_ClipTargets.Count; ++i) m_ClipTargets[i].SetClipRect(clipRect, validRect); m_LastClipRectCanvasSpace = clipRect; m_LastClipRectValid = validRect; } for (int i = 0; i < m_ClipTargets.Count; ++i) m_ClipTargets[i].Cull(m_LastClipRectCanvasSpace, m_LastClipRectValid); }
MaskUtilities中还剩一个方法没讲,就是GetRectMasksForClip,这个方法将自己和父对象中的所有有效的RectMask2D组件打包给m_Clippers.
Clipping的FindCullAndClipWorldRect方法将这些m_Clippers的canvasRect(最近的Canvas父对象的矩形区域)求交集,得到一个最小的裁剪区域(如果这个裁剪区域不合理,validRect便为false)。接着,如果新的出来的clipRect与旧的不同,那么遍历m_ClipTargets,为他们设置裁剪区域,就是我们前面讲的SetClipRect。最后调用所有IClippable的剔除方法(前面讲的Cull)。
Mask与MaskableGraphic一样,继承了IMaterialModifier接口。Mask的GetModifiedMaterial方法:
public virtual Material GetModifiedMaterial(Material baseMaterial) { if (graphic == null) return baseMaterial; var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform); var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas); if (stencilDepth >= 8) { Debug.LogError("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) { 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; graphic.canvasRenderer.popMaterialCount = 1; graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0); return m_MaskMaterial; } //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; }
与MaskableGraphic相比,多了stencilDepth大于等于8时的警告,以及desiredStencilBit等于1(stencilDepth等于0)时的特殊处理(无图可遮),并根据m_ShowMaskGraphic来设置遮罩图像是否显示出来,此外额外给canvasRenderer设置了一个材质。(似乎是为了上一级的CanvasRenderer渲染时方便调用,Unity3D官方喜欢藏着掖着,本来属于UI的CanvasRenderer却非要放在UnityEngine里面,所以无法知道SetPopMaterial具体实现了什么逻辑。docs.unity3d.com里关于这个方法的介绍也很含糊,我也没有搜索到相关的资料。等我研究出了结果,再把这一段介绍补完。)
另外RectMask2D和Mask都实现了IsRaycastLocationValid方法,继承自UnityEngine.ICanvasRaycastFilter,实现是判断矩形(RectTransform)区域是否包含输入的点。它在Graphic中被调用,用于筛选出被射线照射到的图像。