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

前文回顾 UI(一)

    对NGUI和UGUI进行了比较,讲述了如何选择UI系统作为项目的UI框架。

前文回顾 UI(二)

    UGUI的原理,以及组件使用详解。

前文回顾 UI(三)

    UGUI源码中输入事件模块源码剖析。

此篇我们来讲解下,UGUI渲染部分的核心源码。

===

UGUI渲染核心源码剖析

我们先来看下核心部分的文件结构,如下图:

ugui-core

从图中可以看出,由文件夹拆分的模块有,Culling, Layout, MaterialModifiers, SpecializedCollections, Utility, VertexModifiers,我们下面就先来分析下这几个工具模块.

Culling文件夹

ugui-core

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

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

代码虽然不多,但有2个函数比较重要,如下:

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);
}

第一个函数的意义是,当有很多 RectMask2D 重叠时,计算出重叠部分。

第二个函数的意义是,计算两个矩阵的重叠部分。

他们都是静态函数,所以视为工具类。

Layout文件夹

ugui-core

从图中文件夹结构可以看出,Layout 主要做的都是布局方面的事,横向布局,纵向布局,方格布局等等。

总共12个文件,有9个带有 Layout 字样,都是处理布局的。

除了处理布局类意外,其余3个文件,CanvasScaler,AspectRatioFitter,ContentSizeFitter 是调整自适应的。

ContentSizeFitter,AspectRatioFitter 从都带有 Fitter 字样可以了解到,它们的功能都是处理自适应的。

ContentSizeFitter 是处理内容自适应的, AspectRatioFitter 是处理朝向自适应的,以长度为准,以宽度为准,以父节点为准,以外层父节点为准,这四种类型的自适应。

相对于它们两个,CanvasScaler 做的功能更重要点,它操作Canvas 画布针对不同屏幕进行自适应调整。

下面代码是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 对屏幕的适应算法,优先匹配长或宽的,最小化固定拉伸的,以及最大化固定拉伸三种算法。

MaterialModifiers, SpecializedCollections, Utility

文件夹结构如下图:

ugui-core

这三块相对代码量少,但并不是说代码量少就不重要。

IMaterialModifier 是一个接口类为Mask 遮罩修改Material提供接口。

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

ListPool是List对象池,ObjectPool是对象池,很多狠心代码上用到了它们,它们让内存使用更加高效。

VertexHelper 是用来存储生成 Mesh 网格需要的所有数据,在Mesh生成的过程中使用频率非常高,不过 VertexHelper 只负责存储 Mesh 相关数据,不负责计算和生成 Mesh。

VertexModifiers

文件夹结构如下图:

ugui-core

VertexModifiers 模块,主要用于修改网格图形,在UI元素网格生成后再对其进行修改。

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

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

Outline,Shadow,PositionAsUV1 都继承了 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 核心部分有两个地方,第一个如下:

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();
}

Graphic 通知 LayoutRebuilder 布局管理类进行重新布局,以及通知 CanvasUpdateRegistry 绘制物体管理进行重新构建Mesh网格,

而 LayoutRebuilder 源码中最终还是通过 CanvasUpdateRegistry 来实现重新布局的。

所以重构Mesh的管理类 CanvasUpdateRegistry 比较关键,他掌握着Mesh重构的生死。

CanvasUpdateRegistry 被通知重新重构Mesh后,并没有立即重新构建,而是将需要重构的元件数据加入到容器中,等待下一帧时统一重构。

注意,CanvasUpdateRegistry 只负责重构Mesh网格,并不负责渲染和合并。


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 的部分,先创建自己的Mesh(OnPopulateMesh),然后调用所有需要修改 Mesh 的修改者(IMeshModifier)进行修改,后放入 CanvasRenderer 。

CanvasRenderer 是每个绘制元素都必须有的组件,它是画布渲染的连接组件,通过 CanvasRenderer 我们才能把网格绘制到 Canvas 画布上去。

这里使用静态的 sVertexHelper 是为了节省内存和CPU消耗,所有 Graphic 元素都要用的临时变量设置为静态。还有 workerMesh 也是静态变量,目的和 sVertexHelper 一样,都是为了节省临时变量的内存消耗和CPU消耗,因为 Graphic 数量很多,这么能大幅提高内存和CPU的使用效率。

Image, RawImage, Text 都override重写了 OnPopulateMesh 函数。

    protected override void OnPopulateMesh(VertexHelper toFill)

他们需要有自己自定义的网格样式,已达到构建不同类型的网格。

看样子 CanvasRenderer 和 Canvas 才是合并Mesh网格的关键,但 CanvasRenderer 和 Canvas 并没有开源出来。并且从源码上看,他们是 C++ 编写的,从另外dll引用进来。

我试图通过查找反编译的代码查看相关内容,也没有找到,我们无法完整的得到核心的代码。但仔细一想,也差不多能想个大概。合并部分无非就是每次重构时获取 Canvas 下面所有的 CanvasRenderer 实例,将它们的 Mesh 合并起来,其实也没有什么可变化的操作。

关键还是要看,如何减少重构次数,以及提高内存,CPU使用效率。


我们继续看 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 组件使用Shader 渲染管线方式来裁切不需要显示的部分,也就是说,所有在 Mask 组件后面的物体都会进行裁切。

但是 RectMask2D 不一样的哦。RectMask2D 只针对 UGUI 中的可绘制可裁切物体进行裁切。

我们来看 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);
}

上述源码中,

    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,如何重构Mesh,以及如何裁切的问题上。关键还是在于,如何减少重构次数,以及提高内存和CPU的使用效率。

UGUI源码地址

Unity3D开源代码地址

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

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

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

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号