我们依然从文件夹结构下手,从最容易看懂的地方下手,寻找某块之间的划分,我们先来看下核心部分的文件结构,如下图:
从图中可以看出,以文件夹为单位,拆分模块有,Culling(裁剪), Layout(布局), MaterialModifiers(材质球修改器), SpecializedCollections(收集), Utility(实用工具), VertexModifiers(顶点修改器),我们下面就来分析这些模块.
Culling 里是对模型裁剪的工具类,大都用在了 Mask 遮罩上,只有 Mask 才有裁剪的需求。
里面四个文件,其中一个是静态类,一个是接口类。
代码不多,但其中Clipping 类中有2个函数比较重要,常常被用在Mask的裁剪上,如下:
public static Rect FindCullAndClipWorldRect(List rectMaskParents, out bool validRect)
{
if (rectMaskParents.Count == 0)
{
validRect = false;
return new Rect();
}
var compoundRect = rectMaskParents[0].canvasRect;
for (var i = 0; i < rectMaskParents.Count; ++i)
compoundRect = RectIntersect(compoundRect, rectMaskParents[i].canvasRect);
var cull = compoundRect.width <= 0 || compoundRect.height <= 0;
if (cull)
{
validRect = false;
return new Rect();
}
Vector3 point1 = new Vector3(compoundRect.x, compoundRect.y, 0.0f);
Vector3 point2 = new Vector3(compoundRect.x + compoundRect.width, compoundRect.y + compoundRect.height, 0.0f);
validRect = true;
return new Rect(point1.x, point1.y, point2.x - point1.x, point2.y - point1.y);
}
private static Rect RectIntersect(Rect a, Rect b)
{
float xMin = Mathf.Max(a.x, b.x);
float xMax = Mathf.Min(a.x + a.width, b.x + b.width);
float yMin = Mathf.Max(a.y, b.y);
float yMax = Mathf.Min(a.y + a.height, b.y + b.height);
if (xMax >= xMin && yMax >= yMin)
return new Rect(xMin, yMin, xMax - xMin, yMax - yMin);
return new Rect(0f, 0f, 0f, 0f);
}
上述中,Clipping 类的函数里,第一个函数 FindCullAndClipWorldRect 的意义是,将很多 RectMask2D 重叠部分,计算出它们的重叠部分的区域。第二个函数 RectIntersect 为第一函数提供了计算服务,其意义是计算两个矩阵的重叠部分。
这两个函数都是静态函数,可以视为工具函数,直接调用就可以,不需要实例化。
我们从文件夹结构可以看出,Layout 主要功能都是布局方面,包括横向布局,纵向布局,方格布局等等。总共12个文件,有9个带有 Layout 字样,它们都是处理布局的。
除了处理布局内容以外,其余3个文件,CanvasScaler,AspectRatioFitter,ContentSizeFitter 则是调整自适应功能。
从 ContentSizeFitter,AspectRatioFitter 都带有 Fitter 字样可以了解到,它们的功能都是处理自适应。其中 ContentSizeFitter 处理的是内容自适应的, 而 AspectRatioFitter 处理的是朝向自适应的,包括以长度为基准的,以宽度为基准的,以父节点为基准的,以外层父节点为基准的自适应,四种类型的自适应方式。
另外 CanvasScaler 做的功能非常重要,它操作的是 Canvas 整个画布针对不同屏幕进行的自适应调整。
我们着重来看看 CanvasScaler 里的代码,其CanvasScaler的核心函数:
protected virtual void HandleScaleWithScreenSize()
{
Vector2 screenSize = new Vector2(Screen.width, Screen.height);
float scaleFactor = 0;
switch (m_ScreenMatchMode)
{
case ScreenMatchMode.MatchWidthOrHeight:
{
// We take the log of the relative width and height before taking the average.
// Then we transform it back in the original space.
// the reason to transform in and out of logarithmic space is to have better behavior.
// If one axis has twice resolution and the other has half, it should even out if widthOrHeight value is at 0.5.
// In normal space the average would be (0.5 + 2) / 2 = 1.25
// In logarithmic space the average is (-1 + 1) / 2 = 0
float logWidth = Mathf.Log(screenSize.x / m_ReferenceResolution.x, kLogBase);
float logHeight = Mathf.Log(screenSize.y / m_ReferenceResolution.y, kLogBase);
float logWeightedAverage = Mathf.Lerp(logWidth, logHeight, m_MatchWidthOrHeight);
scaleFactor = Mathf.Pow(kLogBase, logWeightedAverage);
break;
}
case ScreenMatchMode.Expand:
{
scaleFactor = Mathf.Min(screenSize.x / m_ReferenceResolution.x, screenSize.y / m_ReferenceResolution.y);
break;
}
case ScreenMatchMode.Shrink:
{
scaleFactor = Mathf.Max(screenSize.x / m_ReferenceResolution.x, screenSize.y / m_ReferenceResolution.y);
break;
}
}
SetScaleFactor(scaleFactor);
SetReferencePixelsPerUnit(m_ReferencePixelsPerUnit);
}
不同 ScreenMathMode 模式下 CanvasScaler 对屏幕的适应算法,包括优先匹配长或宽的,最小化固定拉伸的,以及最大化固定拉伸三种数学计算方式。其中代码中在优先匹配长或宽算法中,介绍了使用Log和Pow来计算缩放比例可以表现的更好。
材质球修改器,特殊收集器,实用工具,这三块相对代码量少却很重要,他们是其他模块所依赖的工具。
文件夹结构如下图:
IMaterialModifier 是一个接口类,为Mask 遮罩修改材质球所准备的,但所用方法都需要各自实现。
IndexedSet 是一个容器,在很多核心代码上都有使用,它加速了移除元素的速度,以及加速了元素包含判断。
ListPool是List容器对象池,ObjectPool是普通对象池,很多代码上都用到了它们,对象池让内存利用率更高。
VertexHelper 特别重要,它是用来存储生成 Mesh 网格需要的所有数据,由于在Mesh生成的过程中顶点的生成频率非常高,因此 VertexHelper 存储了 Mesh 的所有相关数据的同时,用上面提到的ListPool和ObjectPool做为对象池来生成和销毁,使得数据高效得被重复利用,不过它并不负责计算和生成 Mesh,计算和生成由各自图形组件来完成,它只为它们提供计算后的数据存储服务。
顶点修改器为效果制作提供了更多基础方法和规则。
文件夹结构如下图:
VertexModifiers 模块,主要用于修改图形网格,尤其是在UI元素网格生成完毕后对其进行二次修改。
其中 BaseMeshEffect 是抽象基类,提供所有在修改UI元素网格时所需的变量和接口。
IMeshModifier 是关键接口,在下面的渲染核心类 Graphic 中会获取所有拥有这个接口的组件,然后依次遍历并调用 ModifyMesh 接口来触发改变图像网格的效果。
当前在源码中拥有的二次效果包括,Outline(包边框),Shadow(阴影),PositionAsUV1(位置UV) 都继承了 BaseMeshEffect 基类,并实现了关键接口 ModifyMesh。其中 Outline 继承自 Shadow, 他们的共同的关键代码,我们可以重点看一下:
protected void ApplyShadowZeroAlloc(List verts, Color32 color, int start, int end, float x, float y)
{
UIVertex vt;
var neededCpacity = verts.Count * 2;
if (verts.Capacity < neededCpacity)
verts.Capacity = neededCpacity;
for (int i = start; i < end; ++i)
{
vt = verts[i];
verts.Add(vt);
Vector3 v = vt.position;
v.x += x;
v.y += y;
vt.position = v;
var newColor = color;
if (m_UseGraphicAlpha)
newColor.a = (byte)((newColor.a * verts[i].color.a) / 255);
vt.color = newColor;
verts[i] = vt;
}
}
此函数作用是,在原有的Mesh顶点基础上,加入新的顶点,这些新的顶点复制了原来的顶点数据,修改颜色并向外扩充,使得原图形外渲染出外描边或者阴影。
现在我们来看看核心渲染类的奥秘所在。
我们常用的组件 Image,RawImage,Mask,RectMask2D,Text,InputField 中,Image,RawImage,Text 都是继承了 MaskableGraphic ,而 MaskableGraphic 又继承自 Graphic 类,这里 Graphic 类相对比较重要,是基础类也存些核心算法。除了这几个类外 CanvasUpdateRegistry 是存储和管理所有可绘制元素的管理类也是个蛮重要的类,我们会在下面的内容中介绍。
第一个如下:
public virtual void SetAllDirty()
{
SetLayoutDirty();
SetVerticesDirty();
SetMaterialDirty();
}
public virtual void SetLayoutDirty()
{
if (!IsActive())
return;
LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
if (m_OnDirtyLayoutCallback != null)
m_OnDirtyLayoutCallback();
}
public virtual void SetVerticesDirty()
{
if (!IsActive())
return;
m_VertsDirty = true;
CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
if (m_OnDirtyVertsCallback != null)
m_OnDirtyVertsCallback();
}
public virtual void SetMaterialDirty()
{
if (!IsActive())
return;
m_MaterialDirty = true;
CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
if (m_OnDirtyMaterialCallback != null)
m_OnDirtyMaterialCallback();
}
上述代码中,SetAllDirty 设置并通知元素需要重新布局、重新构建网格、以及重新构建材质球。 它通知 LayoutRebuilder 布局管理类进行重新布局,在 LayoutRebuilder.MarkLayoutForRebuild 中它调用 CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild 加入重构队伍,最终重构布局。
SetLayoutDirty、SetVerticesDirty、SetMaterialDirty 都调用了 CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild,它被调用时可以认为是通知它去重新重构Mesh,但它并没有立即重新构建,而是将需要重构的元件数据加入到IndexedSet容器中,等待下次重构。注意,CanvasUpdateRegistry 只负责重构Mesh网格,并不负责渲染和合并。我们来看看 CanvasUpdateRegistry 的RegisterCanvasElementForGraphicRebuild 函数部分:
public static void RegisterCanvasElementForGraphicRebuild(ICanvasElement element)
{
instance.InternalRegisterCanvasElementForGraphicRebuild(element);
}
public static bool TryRegisterCanvasElementForGraphicRebuild(ICanvasElement element)
{
return instance.InternalRegisterCanvasElementForGraphicRebuild(element);
}
private bool InternalRegisterCanvasElementForGraphicRebuild(ICanvasElement element)
{
if (m_PerformingGraphicUpdate)
{
Debug.LogError(string.Format("Trying to add {0} for graphic rebuild while we are already inside a graphic rebuild loop. This is not supported.", element));
return false;
}
if (m_GraphicRebuildQueue.Contains(element))
return false;
m_GraphicRebuildQueue.Add(element);
return true;
}
上述代码中,InternalRegisterCanvasElementForGraphicRebuild 将元素放入重构队列中等待下一次重构。
以及重构时的逻辑:
private static readonly Comparison s_SortLayoutFunction = SortLayoutList;
private void PerformUpdate()
{
CleanInvalidItems();
m_PerformingLayoutUpdate = true;
//布局重构
m_LayoutRebuildQueue.Sort(s_SortLayoutFunction);
for (int i = 0; i <= (int)CanvasUpdate.PostLayout; i++)
{
for (int j = 0; j < m_LayoutRebuildQueue.Count; j++)
{
var rebuild = instance.m_LayoutRebuildQueue[j];
try
{
if (ObjectValidForUpdate(rebuild))
rebuild.Rebuild((CanvasUpdate)i);
}
catch (Exception e)
{
Debug.LogException(e, rebuild.transform);
}
}
}
for (int i = 0; i < m_LayoutRebuildQueue.Count; ++i)
m_LayoutRebuildQueue[i].LayoutComplete();
instance.m_LayoutRebuildQueue.Clear();
m_PerformingLayoutUpdate = false;
// 裁剪
// now layout is complete do culling...
ClipperRegistry.instance.Cull();
//元素重构
m_PerformingGraphicUpdate = true;
for (var i = (int)CanvasUpdate.PreRender; i < (int)CanvasUpdate.MaxUpdateValue; i++)
{
for (var k = 0; k < instance.m_GraphicRebuildQueue.Count; k++)
{
try
{
var element = instance.m_GraphicRebuildQueue[k];
if (ObjectValidForUpdate(element))
element.Rebuild((CanvasUpdate)i);
}
catch (Exception e)
{
Debug.LogException(e, instance.m_GraphicRebuildQueue[k].transform);
}
}
}
for (int i = 0; i < m_GraphicRebuildQueue.Count; ++i)
m_GraphicRebuildQueue[i].LayoutComplete();
instance.m_GraphicRebuildQueue.Clear();
m_PerformingGraphicUpdate = false;
}
上述代码中,PerformUpdate 为 CanvasUpdateRegistry 在重构调用中的逻辑。先将需要重新布局的元素取出来一个个调用Rebuild 函数重构,再对布局后的元素进行裁剪,裁剪后对布局中每个需要重构的元素取出来调用 Rebuild 函数进行重构,最后做一些清理的事务。
如下代码:
private void DoMeshGeneration()
{
if (rectTransform != null && rectTransform.rect.width >= 0 && rectTransform.rect.height >= 0)
OnPopulateMesh(s_VertexHelper);
else
s_VertexHelper.Clear(); // clear the vertex helper so invalid graphics dont draw.
var components = ListPool.Get();
GetComponents(typeof(IMeshModifier), components);
for (var i = 0; i < components.Count; i++)
((IMeshModifier)components[i]).ModifyMesh(s_VertexHelper);
ListPool.Release(components);
s_VertexHelper.FillMesh(workerMesh);
canvasRenderer.SetMesh(workerMesh);
}
此段代码是 Graphic 构建 Mesh 的部分,先调用OnPopulateMesh创建自己的Mesh网格,然后调用所有需要修改 Mesh 的修改者(IMeshModifier)也就是网格后处理组件(描边等效果组件)进行修改,最后放入 CanvasRenderer 。
其中 CanvasRenderer 是每个绘制元素都必须有的组件,它是画布与渲染的连接组件,通过 CanvasRenderer 我们才能把网格绘制到 Canvas 画布上去。
这里使用 VertexHelper 是为了节省内存和CPU消耗,它内部采用List容器对象池,将所有使用过的废弃的数据都存储在里pool池子的容器中,当需要时再拿旧的继续使用。
public class VertexHelper : IDisposable
{
private List m_Positions = ListPool.Get();
private List m_Colors = ListPool.Get();
private List m_Uv0S = ListPool.Get();
private List m_Uv1S = ListPool.Get();
private List m_Normals = ListPool.Get();
private List m_Tangents = ListPool.Get();
private List m_Indicies = ListPool.Get();
}
上述代码为 VertexHelper 的定义部分。
组件中,Image, RawImage, Text 都override重写了 OnPopulateMesh 函数。
protected override void OnPopulateMesh(VertexHelper toFill)
因为这些需要有自己自定义的网格样式,构建不同类型的画面。
其实 CanvasRenderer 和 Canvas 才是合并Mesh网格的关键,但 CanvasRenderer 和 Canvas 并没有开源出来。并且从源码上看,他们是 C++ 编写的,从另外dll或so引进来。
我试图通过查找反编译的代码查看相关内容,也没有找到,我们无法获得这部分的源码。但仔细一想,也差不多能想个大概。合并部分无非就是每次重构时获取 Canvas 下面所有的 CanvasRenderer 实例,将它们的 Mesh 合并起来,仅此而已。因此关键还是要看,如何减少重构次数,以及提高内存,CPU使用效率。
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;
从上述代码中看出来,Mask 组件调用了模板材质球构建了一个自己的材质球,因此它使用了实时渲染中的模板方法来裁切不需要显示的部分,所有在 Mask 组件后面的物体都会进行裁切。我们可以说 Mask 是在 GPU 中做的裁切,使用的方法是着色器中的模板方法。
不过 RectMask2D 并不和 Mask 一样。我们来看 RectMask2D 核心部分源码:
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);
}
上述源码中我们可以看到,RectMask2D 会先计算并设置裁切的范围,再对所有子节点调用裁切操作。其中:
MaskUtilities.GetRectMasksForClip(this, m_Clippers);
获取了所有有关联的 RectMask2D 遮罩范围,然后
Rect clipRect = Clipping.FindCullAndClipWorldRect(m_Clippers, out validRect);
计算了需要裁切的部分,实际上是计算了不需要裁切的部分,其他部分都进行裁切。最后
for (int i = 0; i < m_ClipTargets.Count; ++i)
m_ClipTargets[i].SetClipRect(clipRect, validRect);
对所有需要裁切的UI元素,进行裁切操作。其中 SetClipRect 裁切操作的源码,如下:
public virtual void SetClipRect(Rect clipRect, bool validRect)
{
if (validRect)
canvasRenderer.EnableRectClipping(clipRect);
else
canvasRenderer.DisableRectClipping();
}
最后操作是在 CanvasRenderer 中进行的。前面说过 CanvasRenderer 是我们无法得知内容。不过我们可以想到这里面的内容,计算两个四边形的相交点,再组合成裁切后的内容。