Unity UI网格自定义应用
引言
Unity UI是Unity官方支持的UI框架,在设计上有一定的灵活性和扩展性。UGUI除了RectTransform, Canvas,CanvasRender稍微底层的组件,主要组件如Image,Text等都是开源的。
UGUI基础结构
UGUI里面所有可以绘制与响应事件的图形组件都继承于Graphic。由于基础组件都支持遮罩层,实际上Image,Text这些并不直接继承Graphic,而是继承MaskableGraphic。在UGUI内部进行Graphic网格生成的时候,会去调用OnPopulateMesh方法。也就是说,当子类需要自定义网格时,只需要重载OnPopulateMesh这个方法就可以了。下面是该方法的声明:
public abstract class Graphic [Obsolete("Use OnPopulateMesh(VertexHelper vh) instead.", false)] protected virtual void OnPopulateMesh(Mesh m){ } protected virtual void OnPopulateMesh(VertexHelper vh) { } }
除了Graphic子类可以自定义网格外,还可以通过IMeshModifier接口来实现。比如一些效果组件Outline, Shadow等,就是通过这种方式实现的。下面是IMeshModifier接口的声明:
public interface IMeshModifier { [Obsolete("use IMeshModifier.ModifyMesh (VertexHelper verts) instead", false)] void ModifyMesh(Mesh mesh); void ModifyMesh(VertexHelper verts); }
可以看出ModifyMesh和OnPopulateMesh非常类似,如出一辙。事实上,UGUI在执行完OnPopulateMesh之后,会检测同一个GameObject上有没有实现了IMeshModifier接口的组件,有的话就会调用ModifyMesh方法的实现。值得注意的是,组件的挂载是有顺序的,也就是当同时有多个IMeshModifier组件时,会按照挂载顺序依次执行ModifyMesh。
事件UI层的处理
在某些情景下,我们需要一个UI层去接受事件,又不需要显示这一层UI。针对这种情况,一种简单的做法是挂载一个空Image,将Alpha设为0。但是这种做法有个缺陷,会造成无用的重复绘制,在一些手机平台上,会产生无用的额外的开销。最好的办法是自定义一个Graphic组件,但又不进行渲染。这可以通过自定义网格来实现,也就是清除网格。
在Quizdom项目里面,UIBlank就实现了这么一个机制:
[AddComponentMenu("UGUI/UIBlank", 1)] public class UIBlank : MaskableGraphic { protected UIBlank() { useLegacyMeshGeneration = false; } protected override void OnPopulateMesh(VertexHelper toFill) { toFill.Clear(); } }
值得注意的是,在构造函数里面申明了useLegacyMeshGeneration为false,也就是说不用Obsolete生成方法,只需要覆写OnPopulateMesh就可以了。方法里面很简单,该行代码就是把网格顶点信息清除。
圆形Mask优化
在处理人物头像的时候,往往需要头像显示成圆形的,而头像本身的图片是方的。在UGUI中,可以采用Mask组件实现这个功能,具体是给组件一个圆形白底不透明的遮罩图片。不幸的是,这种方式有比较大的缺陷。Mask组件是采用模板缓存去实现的,意味着Mask下面的UI跟Mask之外的UI不能进行Batch操作。这样割裂了原本一体的UI界面,导致了DrawCall的增加。由于头像可能会比较多,Mask的开销会很明显。如下图所示,一个Mask头像就占用了3个DrawCall(也就是Batch数)。
如果采用圆形网格的形式,就可以避免这种问题,只是会增加网格的内存占用。相比较而言,少量的内存换取DrawCall数的下降,还是值得的。
CircleImage脚本通过继承Image,覆写OnPopulateMesh的方式来实现圆的绘制。下面是简化的代码,基本思想是将圆等分成segements个扇形,以三角形近似扇形来实现的。等分得越多,越接近于圆。
protected override void OnPopulateMesh(VertexHelper vh) { vh.Clear(); float degreeDelta = 2 * Mathf.PI / segements; float tw = rectTransform.rect.width; float th = rectTransform.rect.height; float outerRadius = rectTransform.pivot.x * tw; Vector4 uv = overrideSprite != null ? DataUtility.GetOuterUV(overrideSprite) : Vector4.zero; float uvCenterX = (uv.x + uv.z) * 0.5f; float uvCenterY = (uv.y + uv.w) * 0.5f; float uvScaleX = (uv.z - uv.x) / tw; float uvScaleY = (uv.w - uv.y) / th; var verticeCount = segements + 1; var curVertice = Vector2.zero; float curDegree = 0; var uiVertex = new UIVertex(); uiVertex.color = color; uiVertex.position = curVertice; uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY); vh.AddVert(uiVertex); for (int i = 1; i < verticeCount; i++) { float cosA = Mathf.Cos(curDegree); float sinA = Mathf.Sin(curDegree); curVertice = new Vector2(cosA * outerRadius, sinA * outerRadius); curDegree += degreeDelta; uiVertex = new UIVertex(); uiVertex.color = color; uiVertex.position = curVertice; uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY); vh.AddVert(uiVertex); } var triangleCount = segements * 3; for (int i = 0, vIdx = 1; i < triangleCount - 3; i += 3, vIdx++) { vh.AddTriangle(vIdx, 0, vIdx + 1); } vh.AddTriangle(verticeCount - 1, 0, 1); }
采用CircleImage的DrawCall如下所示,只占用本身的绘制开销,DrawCall为1.
图文混排的处理
UGUI官方Text并不支持图文混排。一种图文混排的方式是将文字和图片合在一张图集上,但Text一般是动态字体,这种方式不太适合;另一方面,也不利于UGUI的batch操作,会导致DrawCall增加。一种可行的思路是,通过修改Text网格,给图片预留空间,然后将图片补上空缺的位置。
具体实现,看下面代码。值得注意的地方是,SetVerticesDirty方法。当该方法被调用时,说明网格需要重新生成,下一步需要执行OnPopulateMesh方法。
public override void SetVerticesDirty() { base.SetVerticesDirty(); UpdateQuadImage(); }
UpdateQuadImage的逻辑是,通过正则表达式去匹配原有文本中的<quad></quad>标签,然后用Image子物体去替换它们,具体代码可以参考附件工程。LinkImageText的实现,有一定局限性,它只支持单行文本。
protected override void OnPopulateMesh(VertexHelper toFill) { var orignText = m_Text; m_Text = GetFinalText(orignText); base.OnPopulateMesh(toFill); m_Text = orignText; float offsetX = 0; UIVertex vert = new UIVertex(); for (var i = 0; i < m_ImagesVertexIndex.Count; i++) { var endIndex = m_ImagesVertexIndex[i]; var rt = m_ImagesPool[i].rectTransform; var size = rt.sizeDelta; if (endIndex < toFill.currentVertCount) { toFill.PopulateUIVertex(ref vert, endIndex); rt.localPosition = new Vector2(vert.position.x + size.x / 2, vert.position.y + size.y / 2 + offsetY); offsetX = offsetX + size.x; for (int j = endIndex - 3; j < toFill.currentVertCount; j++) { toFill.PopulateUIVertex(ref vert, j); vert.position += new Vector3(size.x, 0, 0); toFill.SetUIVertex(vert, j); } } } var offset = offsetX; if (alignment == TextAnchor.LowerLeft || alignment == TextAnchor.MiddleLeft || alignment == TextAnchor.UpperLeft) { offset = 0f; } else if (alignment == TextAnchor.LowerCenter || alignment == TextAnchor.MiddleCenter || alignment == TextAnchor.UpperCenter) { offset = offsetX * -1f / 2; } else if (alignment == TextAnchor.LowerRight || alignment == TextAnchor.MiddleRight || alignment == TextAnchor.UpperRight) { offset = offsetX * -1f; } for (int i = 0; i < toFill.currentVertCount; i++) { toFill.PopulateUIVertex(ref vert, i); vert.position += new Vector3(offset, 0, 0); toFill.SetUIVertex(vert, i); } for (var i = 0; i < m_ImagesVertexIndex.Count; i++) { var rt = m_ImagesPool[i].rectTransform; rt.localPosition += new Vector3(offset, 0f, 0f); } }
在这个实现中,利用了父类Text的网格生成结果,在这个基础上进行文本和图片位置的调整。这个主要由于Unity Text生成规则并不开放,只提供TextGenerator类可以调用。Text内部会使用构造好的TextGenerator进行生成。这里就不再展开。参考工程效果图如下:
总结
Unity UI框架的可扩展性相对NGUI来说要好一点,合理利用扩展接口,可以不局限于现有的UI组件,同时也可以提高UI的渲染效率。本文针对网格自定义这一点,列举了一些应用场景和优化的点,主要供大家扩展思路。