《Unity3D高级编程之进阶主程》第四章,UI(四) - UGUI核心源码剖析

前面我们对NGUI和UGUI进行了比较,讲述了UGUI的组件使用详解以及一些内在运作机制,又对UGUI源码中输入事件模块源码进行了剖析。此篇我们接着上篇的源码剖析,讲解下UGUI组件部分的核心源码。

===

UGUI核心源码剖析

我们依然从文件夹结构下手,从最容易看懂的地方下手,寻找某块之间的划分,我们先来看下核心部分的文件结构,如下图:

ugui-core

从图中可以看出,以文件夹为单位,拆分模块有,Culling(裁剪), Layout(布局), MaterialModifiers(材质球修改器), SpecializedCollections(收集), Utility(实用工具), VertexModifiers(顶点修改器),我们下面就来分析这些模块.

Culling 裁剪模块

ugui-core

Culling 里是对模型裁剪的工具类,大都用在了 Mask 遮罩上,只有 Mask 才有裁剪的需求。

里面四个文件,其中一个是静态类,一个是接口类。

代码不多,但其中Clipping 类中有2个函数比较重要,常常被用在Mask的裁剪上,如下:


public static Rect FindCullAndClipWorldRect(List<RectMask2D> 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 布局模块

ugui-core

我们从文件夹结构可以看出,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来计算缩放比例可以表现的更好。

MaterialModifiers, SpecializedCollections, Utility

材质球修改器,特殊收集器,实用工具,这三块相对代码量少却很重要,他们是其他模块所依赖的工具。

文件夹结构如下图:

ugui-core

IMaterialModifier 是一个接口类,为Mask 遮罩修改材质球所准备的,但所用方法都需要各自实现。

IndexedSet 是一个容器,在很多核心代码上都有使用,它加速了移除元素的速度,以及加速了元素包含判断。

ListPool是List容器对象池,ObjectPool是普通对象池,很多代码上都用到了它们,对象池让内存利用率更高。

VertexHelper 特别重要,它是用来存储生成 Mesh 网格需要的所有数据,由于在Mesh生成的过程中顶点的生成频率非常高,因此 VertexHelper 存储了 Mesh 的所有相关数据的同时,用上面提到的ListPool和ObjectPool做为对象池来生成和销毁,使得数据高效得被重复利用,不过它并不负责计算和生成 Mesh,计算和生成由各自图形组件来完成,它只为它们提供计算后的数据存储服务。

VertexModifiers

顶点修改器为效果制作提供了更多基础方法和规则。

文件夹结构如下图:

ugui-core

VertexModifiers 模块,主要用于修改图形网格,尤其是在UI元素网格生成完毕后对其进行二次修改。

其中 BaseMeshEffect 是抽象基类,提供所有在修改UI元素网格时所需的变量和接口。

IMeshModifier 是关键接口,在下面的渲染核心类 Graphic 中会获取所有拥有这个接口的组件,然后依次遍历并调用 ModifyMesh 接口来触发改变图像网格的效果。

当前在源码中拥有的二次效果包括,Outline(包边框),Shadow(阴影),PositionAsUV1(位置UV) 都继承了 BaseMeshEffect 基类,并实现了关键接口 ModifyMesh。其中 Outline 继承自 Shadow, 他们的共同的关键代码,我们可以重点看一下:


protected void ApplyShadowZeroAlloc(List<UIVertex> 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 是存储和管理所有可绘制元素的管理类也是个蛮重要的类,我们会在下面的内容中介绍。

我们首先来看 Graphic 核心部分,它有两个地方比较重要,这两个地方揭示了 Graphic 的运作机制。

第一个如下:


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<ICanvasElement> 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 函数进行重构,最后做一些清理的事务。

我们再来看看 Graphic 另一个重要的函数,即执行网格构建函数。

如下代码:


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<Component>.Get();
    GetComponents(typeof(IMeshModifier), components);

    for (var i = 0; i < components.Count; i++)
        ((IMeshModifier)components[i]).ModifyMesh(s_VertexHelper);

    ListPool<Component>.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<Vector3> m_Positions = ListPool<Vector3>.Get();
    private List<Color32> m_Colors = ListPool<Color32>.Get();
    private List<Vector2> m_Uv0S = ListPool<Vector2>.Get();
    private List<Vector2> m_Uv1S = ListPool<Vector2>.Get();
    private List<Vector3> m_Normals = ListPool<Vector3>.Get();
    private List<Vector4> m_Tangents = ListPool<Vector4>.Get();
    private List<int> m_Indicies = ListPool<int>.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使用效率。

除了 Graphic类,遮罩部分也是我们非常关心的问题,我们继续看 Mask 遮罩部分的核心部分:

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 是我们无法得知内容。不过我们可以想到这里面的内容,计算两个四边形的相交点,再组合成裁切后的内容。

至此我们把 UGUI 的源代码都剖析完毕了。其实并没有高深的算法或者技术。所有核心部分都围绕着,如何构建Mesh,谁将重构,以及如何裁切的问题上。很多性能关键在于,如何减少重构次数,以及提高内存和CPU的使用效率。

UGUI源码地址

Unity3D的C#部分开源代码地址

· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第四章,UI(四) - UGUI核心源码剖析

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)