关于UGUI的Image,Text (转雨凇momo)
Image源码解读
接着我们来看看放在相同图集中的Sprite是如何合并DrawCall的,从原理上来讲,每个Mesh都需要给顶点设置UV信息,也就是说我们只需要将图集上的某个区域一一抠出来贴到Mesh正确的区域即可。如下代码所示,只要观察GenerateSimpleSprite()方法,UGUI通过Sprites.DataUtility.GetOuterUV(activeSprite)方法将当前待显示的Sprite的UV信息取出来,通过vh.AddVert()和vh.AddTriangle()来填充Mesh信息。
Image.cs(部分代码):
public class Image : MaskableGraphic, ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter { //...略 protected override void OnPopulateMesh(VertexHelper toFill) { if (activeSprite == null) { base.OnPopulateMesh(toFill); return; } //Image有4种显示模式,正常、九宫、平铺、填充 //分别生成对应的顶点信息 switch (type) { case Type.Simple: if (!useSpriteMesh) GenerateSimpleSprite(toFill, m_PreserveAspect); else GenerateSprite(toFill, m_PreserveAspect); break; case Type.Sliced: GenerateSlicedSprite(toFill); break; case Type.Tiled: GenerateTiledSprite(toFill); break; case Type.Filled: GenerateFilledSprite(toFill, m_PreserveAspect); break; } } void GenerateSimpleSprite(VertexHelper vh, bool lPreserveAspect) { Vector4 v = GetDrawingDimensions(lPreserveAspect); //获取根据Sprite获取正确的UV信息 var uv = (activeSprite != null) ? Sprites.DataUtility.GetOuterUV(activeSprite) : Vector4.zero; var color32 = color; vh.Clear(); //填充顶点、颜色、uv vh.AddVert(new Vector3(v.x, v.y), color32, new Vector2(uv.x, uv.y)); vh.AddVert(new Vector3(v.x, v.w), color32, new Vector2(uv.x, uv.w)); vh.AddVert(new Vector3(v.z, v.w), color32, new Vector2(uv.z, uv.w)); vh.AddVert(new Vector3(v.z, v.y), color32, new Vector2(uv.z, uv.y)); //填充三角形顶点序列 vh.AddTriangle(0, 1, 2); vh.AddTriangle(2, 3, 0); } }
最终,不同的UI使用相同的材质、Shader、贴图,只是它们拥有不同的UV信息,这符合Draw Call合并的规则,所以就能合批。
Text源码解读
UGUI的Text就是位图字体,先通过TTF字体将字体形状生成在位图中,接着就是将正确的UV设置给字体的Mesh,这和前面介绍的Image组件几乎一样了。如下代码所示,首先需要根据文本的区域、字体、填充文字调用GetGenerationSettings()创建文本生成器,顶点、uv信息都会被填充好,由于每个文本都是一个Quad,所以还需要设置它们的位置。
Text.cs(部分代码):
public class Text : MaskableGraphic, ILayoutElement { //...略 //字体生成器 public TextGenerator cachedTextGenerator { get { return m_TextCache ?? (m_TextCache = (m_Text.Length != 0 ? new TextGenerator(m_Text.Length) : new TextGenerator())); } } readonly UIVertex[] m_TempVerts = new UIVertex[4]; protected override void OnPopulateMesh(VertexHelper toFill) { if (font == null) return; m_DisableFontTextureRebuiltCallback = true; Vector2 extents = rectTransform.rect.size; //获取字体的生成规则设置 var settings = GetGenerationSettings(extents); //根据待填充字体、生成规则,生成顶点信息 cachedTextGenerator.PopulateWithErrors(text, settings, gameObject); // IList<UIVertex> verts = cachedTextGenerator.verts; float unitsPerPixel = 1 / pixelsPerUnit; int vertCount = verts.Count - 4; if (vertCount <= 0) { toFill.Clear(); return; } Vector2 roundingOffset = new Vector2(verts[0].position.x, verts[0].position.y) * unitsPerPixel; roundingOffset = PixelAdjustPoint(roundingOffset) - roundingOffset; toFill.Clear(); if (roundingOffset != Vector2.zero) { for (int i = 0; i < vertCount; ++i) { int tempVertsIndex = i & 3; //填充顶点信息 m_TempVerts[tempVertsIndex] = verts[i]; //设置字体偏移 m_TempVerts[tempVertsIndex].position *= unitsPerPixel; m_TempVerts[tempVertsIndex].position.x += roundingOffset.x; m_TempVerts[tempVertsIndex].position.y += roundingOffset.y; if (tempVertsIndex == 3) toFill.AddUIVertexQuad(m_TempVerts); //填充UI顶点面片 } } else { for (int i = 0; i < vertCount; ++i) { int tempVertsIndex = i & 3; //填充顶点信息 m_TempVerts[tempVertsIndex] = verts[i]; //设置字体偏移 m_TempVerts[tempVertsIndex].position *= unitsPerPixel; if (tempVertsIndex == 3) toFill.AddUIVertexQuad(m_TempVerts);//填充UI顶点面片 } } m_DisableFontTextureRebuiltCallback = false; } public TextGenerationSettings GetGenerationSettings(Vector2 extents) { //字体设置信息 var settings = new TextGenerationSettings(); //下面对Text信息进行提取 settings.generationExtents = extents; if (font != null && font.dynamic) { settings.fontSize = m_FontData.fontSize; settings.resizeTextMinSize = m_FontData.minSize; settings.resizeTextMaxSize = m_FontData.maxSize; } settings.textAnchor = m_FontData.alignment; settings.alignByGeometry = m_FontData.alignByGeometry; settings.scaleFactor = pixelsPerUnit; settings.color = color; settings.font = font; settings.pivot = rectTransform.pivot; settings.richText = m_FontData.richText; settings.lineSpacing = m_FontData.lineSpacing; settings.fontStyle = m_FontData.fontStyle; settings.resizeTextForBestFit = m_FontData.bestFit; settings.updateBounds = false; settings.horizontalOverflow = m_FontData.horizontalOverflow; settings.verticalOverflow = m_FontData.verticalOverflow; return settings; } }
如下代码所示, 字体的贴图保存在Font.material.mainTexture中,Mesh信息准备好后将字体的材质贴上就可以将文字渲染出来了,最终字体和Image绘制完全一样,通过Graphic.UpdateMaterial()将材质贴上。
顶点辅助类VertexHelper
前面我们介绍的Image和Text 合并网格都用到了VertexHelper类,如下代码所示,它只是个普通的类对象,里面只保存了生成Mesh的基本信息并非Mesh对象,最后通过这些基本信息就可以生成Mesh网格了。
VertexHelper.cs(部分代码):
public class VertexHelper : IDisposable { //保存每个顶点的位置、颜色、UV、法线、切线 private List<Vector3> m_Positions; private List<Color32> m_Colors; private List<Vector2> m_Uv0S; private List<Vector2> m_Uv1S; private List<Vector2> m_Uv2S; private List<Vector2> m_Uv3S; private List<Vector3> m_Normals; private List<Vector4> m_Tangents; //记录三角形的索引 private List<int> m_Indices; //开始添加顶点的位置、颜色、UV、法线、切线数据 public void AddVert(Vector3 position, Color32 color, Vector2 uv0, Vector2 uv1, Vector3 normal, Vector4 tangent) { InitializeListIfRequired(); m_Positions.Add(position); m_Colors.Add(color); m_Uv0S.Add(uv0); m_Uv1S.Add(uv1); m_Uv2S.Add(Vector2.zero); m_Uv3S.Add(Vector2.zero); m_Normals.Add(normal); m_Tangents.Add(tangent); } //添加三角形的索引 public void AddTriangle(int idx0, int idx1, int idx2) { InitializeListIfRequired(); m_Indices.Add(idx0); m_Indices.Add(idx1); m_Indices.Add(idx2); } }
Graphic中有个静态对象s_VertexHelper保存每次生成的网格信息,使用完后会立即清理掉等待下个Graphic对象使用。
Graphic.cs(部分代码):
public abstract class Graphic : UIBehaviour, ICanvasElement { //...略 [NonSerialized] private static readonly VertexHelper s_VertexHelper = new VertexHelper(); private void DoMeshGeneration() { //...略 //s_VertexHelper中的数据信息,调用FillMesh()方法生成真正的网格信息。 s_VertexHelper.FillMesh(workerMesh); //s_VertexHelper.FillMesh内部实现 //就是Unity自己生成Mesh的API而已 //public void FillMesh(Mesh mesh) //{ // InitializeListIfRequired(); // mesh.Clear(); // if (m_Positions.Count >= 65000) // throw new ArgumentException("Mesh can not have more than 65000 vertices"); // mesh.SetVertices(m_Positions); // mesh.SetColors(m_Colors); // mesh.SetUVs(0, m_Uv0S); // mesh.SetUVs(1, m_Uv1S); // mesh.SetUVs(2, m_Uv2S); // mesh.SetUVs(3, m_Uv3S); // mesh.SetNormals(m_Normals); // mesh.SetTangents(m_Tangents); // mesh.SetTriangles(m_Indices, 0); // mesh.RecalculateBounds(); //} //最终提交网格信息,在C++底层中合并网格 canvasRenderer.SetMesh(workerMesh); } }
Layout源码解读
UGUI的布局功能确实很强大,只要挂在节点下就可以设置HorizontalLayoutGroup(横向)、VerticalLayoutGroup(纵向)、GridLayoutGroup(表格)的布局了。虽然使用方便,但是效率是不高的,这里我们以纵向来举例。无论横向还是纵向排列,首先得计算出每个子对象的区域才行。如下代码所示,在GetChildSizes()方法中拿到每个元素的区域。
HorizontalOrVerticalLayoutGroup.cs(部分代码):
public abstract class HorizontalOrVerticalLayoutGroup : LayoutGroup { //...略 private void GetChildSizes(RectTransform child, int axis, bool controlSize, bool childForceExpand, out float min, out float preferred, out float flexible) { //获取每个子元素的区域,min最小区域、preferred准确区域、flexible弹性区域 if (!controlSize) { min = child.sizeDelta[axis]; preferred = min; flexible = 0; } else { min = LayoutUtility.GetMinSize(child, axis); preferred = LayoutUtility.GetPreferredSize(child, axis); flexible = LayoutUtility.GetFlexibleSize(child, axis); } if (childForceExpand) flexible = Mathf.Max(flexible, 1); } }
如下代码所示,最核心的计算在LayoutUtility. GetLayoutProperty()方法中,把每个实现ILayoutElement接口的对象的信息取出来。
LayoutUtility.cs(部分代码):
public static class LayoutUtility { //...略 public static float GetMinWidth(RectTransform rect) { //计算最小宽度 return GetLayoutProperty(rect, e => e.minWidth, 0); } public static float GetLayoutProperty(RectTransform rect, System.Func<ILayoutElement, float> property, float defaultValue, out ILayoutElement source) { source = null; if (rect == null) return 0; float min = defaultValue; int maxPriority = System.Int32.MinValue; var components = ListPool<Component>.Get(); rect.GetComponents(typeof(ILayoutElement), components); //遍历每一个实现ILayoutElement接口的子对象(Image和Text都实现了ILayoutElement接口) //或者绑定了LayoutElement对象的脚本也实现了ILayoutElement接口 for (int i = 0; i < components.Count; i++) { //确保layoutComp对象有效 var layoutComp = components[i] as ILayoutElement; if (layoutComp is Behaviour && !((Behaviour)layoutComp).isActiveAndEnabled) continue; //确保当前优先级小于最大优先级 int priority = layoutComp.layoutPriority; if (priority < maxPriority) continue; float prop = property(layoutComp); if (prop < 0) continue; //如果有更高的优先级,那么就覆盖最小数值,并且覆盖最大优先级数值 if (priority > maxPriority) { min = prop; maxPriority = priority; source = layoutComp; } //如果组件有相同的优先级,取较大的值 else if (prop > min) { min = prop; source = layoutComp; } } ListPool<Component>.Release(components); //返回最小值 return min; } }
如下代码所示,由于Image和Text都实现了ILayoutElement接口,所以LayoutGroup下的Image和Text元素会自动布局,也可以绑定LayoutElement脚本主动设置区域。
public class Image : MaskableGraphic, ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter public class Text : MaskableGraphic, ILayoutElement
但是Layout还有Min Wdith和Flexible Width可设置最小宽高和弹性宽高,这都需要进行额外的计算产生额外的开销,如果对效率要求比较高的UI,最好可以考虑自行封装一套布局组件。 如图9-1所示,有时候希望布局以后自动计算RectTransform的区域,那么就不得不再挂上一个Content Size Fitter组件了,它是在LayoutRebuilder中等待Rebuild()时调用,那么势必会再次造成Rebuild()。
不得不说 Content Size Fitter、VerticalLayoutGroup、HorizontalLayoutGroup、 AspectRatioFitter、GridLayoutGroup组件效率是很低的,它们势必会导致所有元素的Rebuild()执行两次。
1、界面第一次打开需要进行第一次Rebuild()
2、Layout组件要算位置或者大小会强制再执行一次Rebuild()
很有可能有些元素是不需要Rebuild的,但是Layout组件也会强制执行,那么势必造成额外的开销。
遮罩:Mask与Mask2D
UGUI的裁切分为Mask和Mask2D两种,我们先来看Mask。它可以给Mask指定一张裁切图裁切子元素。如图10-1所示,我们给Mask指定了一张圆形图片,那么子节点下的元素都会被裁切在这个圆形区域中。
功能确实很强大,我们来看看它的效率如何呢?由于裁切需要同时裁切图片和文本,所以Image和Text都会派生自MaskableGraphic。如果要让Mask节点下的元素裁切,那么它需要占一个DrawCall,因为这些元素需要一个新的Shader参数来渲染。如下代码所示,MaskableGraphic实现了IMaterialModifier接口, 而StencilMaterial.Add()就是设置Shader中的裁切参数。
MaskableGraphic.cs(部分代码):
public abstract class MaskableGraphic : Graphic, IClippable, IMaskable, 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; } //确保Mask组件有效 Mask maskComponent = GetComponent<Mask>(); if (m_StencilValue > 0 && (maskComponent == null || !maskComponent.IsActive())) { //设置模板缓冲值,并且设置在该区域内的显示,不在的裁切掉 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; } }
如下代码所示,Image对象在进行Rebuild()时,UpdateMaterial()方法中会获取需要渲染的材质,并且判断当前对象的组件是否有继承IMaterialModifier接口,如果有那么它就是绑定了Mask脚本,接着调用上面提到的GetModifiedMaterial方法修改材质上Shader的参数。
Graphic.cs(部分代码):
public abstract class Graphic : UIBehaviour,ICanvasElement { //...略 public virtual void Rebuild(CanvasUpdate update) { if (canvasRenderer.cull) return; switch (update) { case CanvasUpdate.PreRender: if (m_VertsDirty) { //开始更新网格 UpdateGeometry(); m_VertsDirty = false; } if (m_MaterialDirty) { //开始更新材质 UpdateMaterial(); m_MaterialDirty = false; } break; } } public virtual Material materialForRendering { get { //遍历UI中的每个Mask组件 var components = ListPool<Component>.Get(); GetComponents(typeof(IMaterialModifier), components); //并且更新每个Mask组件的模板缓冲材质 var currentMat = material; for (var i = 0; i < components.Count; i++) currentMat = (components[i] as IMaterialModifier).GetModifiedMaterial(currentMat); ListPool<Component>.Release(components); //返回新的材质,用于裁切 return currentMat; } } protected virtual void UpdateMaterial() { if (!IsActive()) return; //更新刚刚替换的新的模板缓冲的材质 canvasRenderer.materialCount = 1; canvasRenderer.SetMaterial(materialForRendering, 0); canvasRenderer.SetTexture(mainTexture); } }
Mask的原理就是利用了StencilBuffer(模板缓冲),它里面记录了一个ID,被裁切元素也有StencilBuffer(模板缓冲)的ID,并且和Mask里的比较,相同才会被渲染。因为模板缓冲可以提供模板的区域,也就是前面设置的圆形图片,所以最终会将元素裁切到这个圆心图片中。 如图10-2所示,在Mask外面放一个普通的图片,默认情况下Stencil Ref的值是0,所以它不会被裁切,永远会显示出来。
如图10-3所示,因为Mask的Stencil Ref 值是1,所需被裁切的元素它的Stencil Ref 值也应该是1就会被裁切。
接着我们再来看看Mask2D的原理,在前面介绍Canvas.willRenderCanvases()时在PerformUpdate方法中会调用ClipperRegistry.instance.Cull();来处理界面中所有的Mask2D裁切。
ClipperRegistry.instance.Cull();的原理就是遍历界面中的所有Mask2D组件,并且调用每个组件的PerformClipping();方法。
如下代码所示,Mask2D会在OnEnable()方法中,将当前组件注册ClipperRegistry.Register(this);这样在上面ClipperRegistry.instance.Cull();方法时就可以遍历所有Mask2D组件并且调用它们的PerformClipping()方法了。
PerformClipping()方法,需要找到所有需要裁切的UI元素,因为Image和Text都继承了IClippable接口,最终将调用Cull()进行裁切。
RectMask2D.cs(部分代码):
public class RectMask2D : UIBehaviour, IClipper, ICanvasRaycastFilter { //...略 protected override void OnEnable() { //注册当前RectMask2D裁切对象,保证下次Rebuild时可进行裁切。 base.OnEnable(); m_ShouldRecalculateClipRects = true; ClipperRegistry.Register(this); MaskUtilities.Notify2DMaskStateChanged(this); } public virtual void PerformClipping() { if (ReferenceEquals(Canvas, null)) { return; } //重新计算裁切区域 if (m_ShouldRecalculateClipRects) { MaskUtilities.GetRectMasksForClip(this, m_Clippers); m_ShouldRecalculateClipRects = false; } //由于裁切可能有多个区域,这里会计算出正确包含重复的一个区域 bool validRect = true; Rect clipRect = Clipping.FindCullAndClipWorldRect(m_Clippers, out validRect); RenderMode renderMode = Canvas.rootCanvas.renderMode; bool maskIsCulled = (renderMode == RenderMode.ScreenSpaceCamera || renderMode == RenderMode.ScreenSpaceOverlay) && !clipRect.Overlaps(rootCanvasRect, true); bool clipRectChanged = clipRect != m_LastClipRectCanvasSpace; bool forceClip = m_ForceClip; // Avoid looping multiple times. foreach (IClippable clipTarget in m_ClipTargets) { if (clipRectChanged || forceClip) { //准备把裁切区域传到每个UI元素的Shader中 clipTarget.SetClipRect(clipRect, validRect); } //确保裁切可用 var maskable = clipTarget as MaskableGraphic; if (maskable != null && !maskable.canvasRenderer.hasMoved && !clipRectChanged) continue; //准备开始裁切,准备重建裁切的UI clipTarget.Cull( maskIsCulled ? Rect.zero : clipRect, maskIsCulled ? false : validRect); } m_LastClipRectCanvasSpace = clipRect; m_ForceClip = false; } }
如图10-4所示,RectMask2D会将RectTransform的区域作为_ClipRect传入Shader中,并且激活UNITY_UI_CLIP_RECT的Keywords。Stencil Ref 的值是0 表示它并没有使用模板缓冲比较,如果只是矩形裁切,RectMask2D并且它不需要一个无效的渲染用于模板比较,所以RectMask2D的效率会比Mask要高。
如下代码所示,在Shader的Frag处理像素中,被裁切掉的区域是通过UnityGet2DClipping()将Color.a变成了透明。
RectMask2D.cs(部分代码):