Unity UI Image 源码解析(一):Simple、Sliced、Tiled 与 Filled

本文基于 Unity 2022.3.62f3、com.unity.ugui@1.0.0 的源码,主要梳理 Image 如何根据不同 Type 生成 UI 网格。

本文重点介绍 SimpleSlicedTiled 的完整生成过程,以及 Filled 的 Horizontal、Vertical 线性填充。Radial90、Radial180、Radial360 和 RadialCut 的详细算法放在第二篇单独说明。

本文对照的源码版本:

  • Unity:2022.3.62f3;
  • uGUI:com.unity.ugui@1.0.0

源码位置:

Library/PackageCache/com.unity.ugui@1.0.0/Runtime/UI/Core/Graphic.cs
Library/PackageCache/com.unity.ugui@1.0.0/Runtime/UI/Core/Image.cs

系列导航:


一、从 Graphic.Rebuild 到 Image.OnPopulateMesh

Image 继承自 MaskableGraphic

public class Image : MaskableGraphic,
    ISerializationCallbackReceiver,
    ILayoutElement,
    ICanvasRaycastFilter

MaskableGraphic 又继承自 Graphic

public abstract class MaskableGraphic : Graphic,
    IClippable,
    IMaskable,
    IMaterialModifier

这里需要先修正一个容易产生误解的说法:Graphic.Rebuild 不是所有 Graphic 每帧无条件执行。属性变化后,SetVerticesDirtySetMaterialDirty 会把需要更新的组件注册到 CanvasUpdateRegistry,之后在 Canvas 的 PreRender 阶段调用 Rebuild。没有变脏的 Graphic 不需要重复生成网格。

关于 Graphic 的完整流程可以看:Unity UI 核心类解析之Graphic

1. Rebuild

public virtual void Rebuild(CanvasUpdate update)
{
    if (canvasRenderer == null || canvasRenderer.cull)
        return;

    switch (update)
    {
        case CanvasUpdate.PreRender:
            if (m_VertsDirty)
            {
                UpdateGeometry();
                m_VertsDirty = false;
            }
            if (m_MaterialDirty)
            {
                UpdateMaterial();
                m_MaterialDirty = false;
            }
            break;
    }
}

顶点数据变脏时执行 UpdateGeometry,材质变脏时执行 UpdateMaterial。两条更新路径互相独立。

2. UpdateGeometry

protected virtual void UpdateGeometry()
{
    if (useLegacyMeshGeneration)
    {
        DoLegacyMeshGeneration();
    }
    else
    {
        DoMeshGeneration();
    }
}

Image 的构造函数会设置:

protected Image()
{
    useLegacyMeshGeneration = false;
}

所以当前 Image 走的是 VertexHelper 版本的 DoMeshGeneration

3. DoMeshGeneration

private void DoMeshGeneration()
{
    if (rectTransform != null
        && rectTransform.rect.width >= 0
        && rectTransform.rect.height >= 0)
    {
        OnPopulateMesh(s_VertexHelper);
    }
    else
    {
        s_VertexHelper.Clear();
    }

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

OnPopulateMesh 只负责生成基础顶点和索引。之后 ShadowOutline 等实现了 IMeshModifier 的组件还可以修改网格,最终才通过 FillMesh 写入 workerMesh 并交给 CanvasRenderer

4. Image.OnPopulateMesh

Image 重写了 OnPopulateMesh

// toFill 是待填充的顶点数据
protected override void OnPopulateMesh(VertexHelper toFill)
{
    if (activeSprite == null)
    {
        base.OnPopulateMesh(toFill);
        return;
    }

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

activeSprite 不是固定等于 sprite。如果设置了 overrideSprite,会优先使用它:

private Sprite activeSprite
{
    get
    {
        return m_OverrideSprite != null
            ? m_OverrideSprite
            : sprite;
    }
}

没有 activeSprite 时会回退到 Graphic.OnPopulateMesh。有 Sprite 时,Image.Type 决定具体的网格生成函数:

public enum Type
{
    Simple,
    Sliced,
    Tiled,
    Filled
}

Inspector 中对应下面四种类型:

在这里插入图片描述

在进入四种类型之前,还要记住几个公共数据:

  • GetPixelAdjustedRect():得到像素对齐后的绘制矩形;
  • GetDrawingDimensions():计算最终绘制范围,返回值 x/y/z/w 分别代表左、下、右、上;
  • GetOuterUV():Sprite 外部矩形的 UV;
  • GetInnerUV():九宫格中心区域的 UV;
  • pixelsPerUnit:Sprite PPU 与 Canvas Reference PPU 的比值;
  • multipliedPixelsPerUnit:再乘上 pixelsPerUnitMultiplier,用于 Sliced 和 Tiled。

二、Simple 类型

case Type.Simple:
    if (!useSpriteMesh)
        GenerateSimpleSprite(toFill, m_PreserveAspect);
    else
        GenerateSprite(toFill, m_PreserveAspect);
    break;

m_PreserveAspect 对应 Inspector 中的 Preserve Aspect。开启后,GetDrawingDimensionsGenerateSprite 会缩放绘制矩形,使 Sprite 保持原始宽高比:

在这里插入图片描述

useSpriteMesh 的作用:

  • 当此属性设置为 false 时,UI Image 使用简单的四边形。
  • 当设置为 true 时,Image 使用 TextureImporter 生成的 Sprite 网格。
  • 如果导入设置中的 Mesh Type 是 Full Rect,Sprite 网格本身仍然只是四边形。只有 Mesh Type 为 Tight 时,useSpriteMesh=true 才能使用按透明区域收紧后的网格。

Inspector 中对应 Use Sprite Mesh:

在这里插入图片描述

1. useSpriteMesh = false

useSpriteMesh=false 时调用 GenerateSimpleSprite

void GenerateSimpleSprite(VertexHelper vh, bool lPreserveAspect)
{
    Vector4 v = GetDrawingDimensions(lPreserveAspect);
    var uv = activeSprite != null
        ? Sprites.DataUtility.GetOuterUV(activeSprite)
        : Vector4.zero;

    var color32 = color;
    vh.Clear();
    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);
}

这条路径固定生成 4 个顶点、2 个三角形。GetDrawingDimensions 已经处理了 RectTransform、Sprite Padding 和 Preserve Aspect,GetOuterUV 则避免直接假设 UV 一定是 0 到 1。

在这里插入图片描述

在这里插入图片描述

2. useSpriteMesh = true

useSpriteMesh=true 时调用 GenerateSprite

private void GenerateSprite(VertexHelper vh, bool lPreserveAspect)
{
    // 获取当前精灵(Sprite)的原始尺寸(以像素为单位)
    var spriteSize = new Vector2(activeSprite.rect.width, activeSprite.rect.height);

    // 将精灵的轴心点(pivot)转换为归一化坐标(0~1 范围内),相对于精灵本身的尺寸
    var spritePivot = activeSprite.pivot / spriteSize;

    // 获取 RectTransform 自身的轴心点(pivot)
    var rectPivot = rectTransform.pivot;

    // 获取经过像素调整后的矩形区域(考虑Canvas的缩放等)
    Rect r = GetPixelAdjustedRect();

    // 如果需要保持宽高比,并且精灵尺寸有效,则进行宽高比适配处理
    if (lPreserveAspect & spriteSize.sqrMagnitude > 0.0f)
    {
        PreserveSpriteAspectRatio(ref r, spriteSize);
    }

    // 计算绘制区域的大小(即UI元素的实际宽高)
    var drawingSize = new Vector2(r.width, r.height);

    // 获取 Sprite 本地空间中的边界尺寸
    var spriteBoundSize = activeSprite.bounds.size;

    // 根据 Sprite Pivot 与 RectTransform Pivot 的差异计算绘制偏移
    var drawOffset = (rectPivot - spritePivot) * drawingSize;

    // 获取当前颜色并缓存
    var color32 = color;

    // 清空顶点助手中的已有数据,准备重新绘制
    vh.Clear();

    // 获取精灵网格的顶点数组
    Vector2[] vertices = activeSprite.vertices;

    // 获取精灵网格的UV坐标数组
    Vector2[] uvs = activeSprite.uv;

    // 遍历所有顶点,将它们从精灵本地坐标系转换到UI绘制坐标系,并添加到顶点助手中
    for (int i = 0; i < vertices.Length; ++i)
    {
        // 将顶点位置归一化到精灵边界,再乘以绘制区域大小,并减去偏移量,得到最终位置
        float x = (vertices[i].x / spriteBoundSize.x) * drawingSize.x - drawOffset.x;
        float y = (vertices[i].y / spriteBoundSize.y) * drawingSize.y - drawOffset.y;

        // 添加顶点信息:位置、颜色、UV
        vh.AddVert(new Vector3(x, y), color32, new Vector2(uvs[i].x, uvs[i].y));
    }

    // 获取精灵网格的三角形索引数组(每三个索引构成一个三角形)
    UInt16[] triangles = activeSprite.triangles;

    // 遍历每个三角形,将索引添加到顶点助手中,形成完整的网格结构
    for (int i = 0; i < triangles.Length; i += 3)
    {
        vh.AddTriangle(triangles[i + 0], triangles[i + 1], triangles[i + 2]);
    }
}

调用栈如下:

在这里插入图片描述

图中的 Sprite 网格包含 8 个顶点,但这不是固定数量。最终顶点数和三角形数来自 activeSprite.verticesactiveSprite.triangles,会随 Sprite 的 Mesh Type、透明轮廓和导入结果变化。

在这里插入图片描述

在这里插入图片描述


三、Sliced 类型

case Type.Sliced:
    GenerateSlicedSprite(toFill);
    break;
private void GenerateSlicedSprite(VertexHelper toFill)
{
    // 如果没有定义边框,则直接使用简单精灵生成方式,并返回。
    if (!hasBorder)
    {
        GenerateSimpleSprite(toFill, false);
        return;
    }

    // 初始化外 UV、内 UV、Padding 和 Border。
    Vector4 outer, inner, padding, border;

    if (activeSprite != null)
    {
        // outer:Sprite 外部矩形的左下、右上 UV
        outer = Sprites.DataUtility.GetOuterUV(activeSprite);
        // inner:九宫格中心区域的左下、右上 UV
        inner = Sprites.DataUtility.GetInnerUV(activeSprite);
        padding = Sprites.DataUtility.GetPadding(activeSprite);
        border = activeSprite.border;
    }
    else
    {
        // 如果没有激活的精灵,则初始化为空向量。
        outer = Vector4.zero;
        inner = Vector4.zero;
        padding = Vector4.zero;
        border = Vector4.zero;
    }

    // 获取调整像素后的矩形,这通常是基于 UI 缩放和其他因素调整后的矩形。
    Rect rect = GetPixelAdjustedRect();

    // 根据像素单位调整边框和填充。
    Vector4 adjustedBorders = GetAdjustedBorders(border / multipliedPixelsPerUnit, rect);
    padding = padding / multipliedPixelsPerUnit;

    // 计算横向和纵向各 4 条分割坐标。
    s_VertScratch[0] = new Vector2(padding.x, padding.y);
    s_VertScratch[3] = new Vector2(
        rect.width - padding.z,
        rect.height - padding.w
    );

    s_VertScratch[1].x = adjustedBorders.x; // 中心框左下角
    s_VertScratch[1].y = adjustedBorders.y;

    s_VertScratch[2].x = rect.width - adjustedBorders.z; // 中心框右上角
    s_VertScratch[2].y = rect.height - adjustedBorders.w;

    // 将所有顶点的位置偏移至矩形的实际位置。
    for (int i = 0; i < 4; ++i)
    {
        s_VertScratch[i].x += rect.x;
        s_VertScratch[i].y += rect.y;
    }

    // 设置 UV 坐标。
    s_UVScratch[0] = new Vector2(outer.x, outer.y); // 左下角 UV
    s_UVScratch[1] = new Vector2(inner.x, inner.y); // 中心框左下角 UV
    s_UVScratch[2] = new Vector2(inner.z, inner.w); // 中心框右上角 UV
    s_UVScratch[3] = new Vector2(outer.z, outer.w); // 右上角 UV

    // 清空 VertexHelper 以准备新的顶点数据。
    toFill.Clear();

    // 遍历 9 个区域(3×3 网格),为每个区域添加四边形。
    for (int x = 0; x < 3; ++x)
    {
        int x2 = x + 1;

        for (int y = 0; y < 3; ++y)
        {
            // 如果不填充中心并且当前是中心区域,则跳过。
            if (!m_FillCenter && x == 1 && y == 1)
                continue;

            int y2 = y + 1;

            // Rect 太小时,缩放后的边框可能产生零面积区域。
            // 新版源码会跳过宽或高小于等于 0 的 Quad。
            if ((s_VertScratch[x2].x - s_VertScratch[x].x <= 0)
                || (s_VertScratch[y2].y - s_VertScratch[y].y <= 0))
            {
                continue;
            }

            // 从九宫格左下角开始,为每个区域添加独立 Quad。
            AddQuad(toFill,
                new Vector2(s_VertScratch[x].x, s_VertScratch[y].y),
                new Vector2(s_VertScratch[x2].x, s_VertScratch[y2].y),
                color, // 填充颜色
                new Vector2(s_UVScratch[x].x, s_UVScratch[y].y),
                new Vector2(s_UVScratch[x2].x, s_UVScratch[y2].y));
        }
    }
}

hasBorder 检查 activeSprite.border 是否至少有一个分量非零。没有 Border 时,Sliced 会直接回退到 GenerateSimpleSprite(toFill, false),并且不启用 Preserve Aspect。

Border 信息如下:

在这里插入图片描述

GetAdjustedBorders 还会处理两种情况:

  • GetPixelAdjustedRect 与原始 RectTransform 尺寸略有差异时,按比例修正 Border,避免像素对齐后出现缝隙;
  • 当 Rect 的宽或高小于两侧 Border 之和时,同时缩小两侧 Border,避免边框互相重叠。

m_FillCenter 的作用:

  • 是否渲染平铺(Tiled)或切片(Sliced)图像的中心部分。
  • 对 Sliced 来说,没有 Border 会直接退回 Simple,因此 Fill Center 不生效;
  • 对 Tiled 来说,没有 Border 时整张 Sprite 就是中心区域,因此 Fill Center 仍会决定是否生成网格。

在 Inspector 中对应如下:

在这里插入图片描述

1. m_FillCenter = true

在这里插入图片描述

在这里插入图片描述

九宫格共有 9 个区域,每次 AddQuad 都会独立写入 4 个顶点,所以最终是 36 个顶点、18 个三角形和 54 个索引。

在这里插入图片描述
这里不能说“有 20 个共用顶点”。从几何位置看,九宫格只有 4×4=16 个交点;但当前 AddQuad 没有跨区域共享索引,相邻 Quad 会重复写入边界顶点。

2. m_FillCenter = false

中心区域被跳过,因此少生成 1 个 Quad,也就是从 36 个顶点变成 32 个顶点,三角形数量从 18 个变成 16 个。

在这里插入图片描述

在这里插入图片描述

表现如下:

在这里插入图片描述


四、Tiled 类型

case Type.Tiled:
    GenerateTiledSprite(toFill);
    break;

Tiled 并不是“只有存在 Border 才生效”。源码有两条路径:

  • Sprite 有 Border、被打进图集,或者纹理 Wrap Mode 不是 Repeat:生成多个独立 Quad;
  • Sprite 没有 Border、没有被打包,并且纹理允许 Repeat:只生成一个 Quad,通过扩大 UV 范围让 GPU 重复采样。

首先根据 Sprite 中心区域计算单个 Tile 的宽高:

Vector2 spriteSize = activeSprite.rect.size;

float tileWidth = (spriteSize.x - border.x - border.z) / multipliedPixelsPerUnit;
float tileHeight = (spriteSize.y - border.y - border.w) / multipliedPixelsPerUnit;

long nTilesW = (long)Math.Ceiling((xMax - xMin) / tileWidth);
long nTilesH = (long)Math.Ceiling((yMax - yMin) / tileHeight);

multipliedPixelsPerUnit 不应简单理解为永远等于 1。它等于 Sprite PPU、Canvas Reference PPU 和 pixelsPerUnitMultiplier 共同换算后的结果。

生成多个 Quad 前,源码还会估算顶点数。如果预计超过 65000 个顶点,会增大 Tile 尺寸、减少分块数量,避免超过 Mesh 的索引上限。

1. m_FillCenter = true

if (hasBorder)
{
    // 如果有外框,需要在长和宽方向各增加 2 个 Tile,每个 Tile 需要 4 个顶点。
    nVertices = (nTilesW + 2.0) * (nTilesH + 2.0) * 4.0; // 4 vertices per tile
}
else
{
    nVertices = nTilesW * nTilesH * 4.0; // 4 vertices per tile
}
(1)hasBorder = true

在这里插入图片描述

(2)hasBorder = false

不绘制

这张截图中的“不绘制”不是源码对 hasBorder=false 的普遍行为。只要 m_FillCenter=true,无 Border 的 Tiled Image 仍会绘制中心区域:要么生成多个 Tile Quad,要么使用 Repeat UV 的单 Quad。具体走哪条路径取决于 Sprite 是否被打包以及纹理的 Wrap Mode。

2. m_FillCenter = false

(1)hasBorder = true

在这里插入图片描述

关闭 Fill Center 后,只生成边框区域。

(2)hasBorder = false

没有 Border 且不绘制中心时,没有任何区域可以生成,因此不会输出网格。


五、Filled 类型

case Type.Filled:
    GenerateFilledSprite(toFill, m_PreserveAspect);
    break;

GenerateFilledSprite 先清空旧网格。fillAmount 的公开属性会通过 Mathf.Clamp01 限制在 0 到 1;当值小于 0.001 时直接返回,不生成任何顶点。

接着取得绘制区域和 Outer UV:

void GenerateFilledSprite(VertexHelper toFill, bool preserveAspect)
{
    toFill.Clear();
    if (m_FillAmount < 0.001f)
        return;

    // 获取绘制区域:x/y/z/w = 左/下/右/上
    Vector4 v = GetDrawingDimensions(preserveAspect);

    // 获取 Outer UV
    Vector4 outer = activeSprite != null
        ? Sprites.DataUtility.GetOuterUV(activeSprite)
        : Vector4.zero;

    UIVertex uiv = UIVertex.simpleVert;
    uiv.color = color;

    float tx0 = outer.x;
    float ty0 = outer.y;
    float tx1 = outer.z;
    float ty1 = outer.w;
}

上面只是初始化部分。Horizontal、Vertical 会直接裁剪矩形位置和 UV;三个 Radial 模式还需要执行径向裁剪,详细算法留到第二篇。

FillMethod 的类型:

public enum FillMethod
{
    // 图像将被水平填充。
    Horizontal,
    // 图像将被垂直填充。
    Vertical,
    // 图像将以其中一个角为中心进行径向填充。
    Radial90,
    // 图像将以其中一条边的中心为径向中心进行填充。
    Radial180,
    // 图像将以图像中心为径向中心进行填充。
    Radial360,
}

1. FillMethod.Horizontal

在这里插入图片描述

// 从左边还是右边开始填充
[SerializeField] private int m_FillOrigin;

if (fillMethod == FillMethod.Horizontal)
{
    float fill = (tx1 - tx0) * m_FillAmount;

    if (m_FillOrigin == 1)
    {
        v.x = v.z - (v.z - v.x) * m_FillAmount;
        tx0 = tx1 - fill;
    }
    else
    {
        v.z = v.x + (v.z - v.x) * m_FillAmount;
        tx1 = tx0 + fill;
    }
}
(1)m_FillOrigin = 0

OriginHorizontal.Left,也就是从左向右填充,需要重新计算 v.ztx1

在这里插入图片描述

其中:

  • A 点坐标为(-540,-960),UV 为(0, 0),即(tx0, ty0);
  • B 点坐标为(540,960),UV 为(1, 1),即(tx1, ty1);
  • CD = fill = (tx1 - tx0) * m_FillAmount
(2)m_FillOrigin = 1

OriginHorizontal.Right,也就是从右向左填充,需要重新计算 v.xtx0

在这里插入图片描述

2. FillMethod.Vertical

在这里插入图片描述

else if (fillMethod == FillMethod.Vertical)
{
    float fill = (ty1 - ty0) * m_FillAmount;

    if (m_FillOrigin == 1)
    {
        v.y = v.w - (v.w - v.y) * m_FillAmount;
        ty0 = ty1 - fill;
    }
    else
    {
        v.w = v.y + (v.w - v.y) * m_FillAmount;
        ty1 = ty0 + fill;
    }
}
(1)m_FillOrigin = 0

OriginVertical.Bottom,也就是从下向上填充,需要重新计算 v.wty1

在这里插入图片描述

(2)m_FillOrigin = 1

OriginVertical.Top,也就是从上向下填充,需要重新计算 v.yty0

在这里插入图片描述

Horizontal 和 Vertical 修改完矩形及 UV 后,仍然只调用一次 AddQuad,因此固定生成 4 个顶点和 2 个三角形。

3. Radial 填充放在第二篇

Radial90、Radial180 和 Radial360 不只是简单修改矩形边界,还涉及区域拆分、局部 fillAmountcornerinvert 以及 sin/cos 插值。为了避免第一篇同时承担“类型总览”和“径向算法推导”两个主题,这部分不在本文展开。

详细内容见:Unity UI Image 源码解析(二):Filled 径向填充与 RadialCut


六、网格数量与性能

类型 典型顶点数 说明
Simple,useSpriteMesh=false 4 固定一个 Quad
Simple,useSpriteMesh=true 不固定 由 Sprite Tight Mesh 决定
Sliced,Fill Center=true 36 9 个独立 Quad,不共享顶点
Sliced,Fill Center=false 32 跳过中心 Quad
Tiled 不固定 可能一个 Repeat UV Quad,也可能生成大量 Tile Quad
Filled Horizontal/Vertical 4 修改一个 Quad 的边界和 UV

Tiled 是最需要关注顶点数量的类型。Sprite 有 Border、被打包或纹理不能 Repeat 时,Unity 会用多个 Quad 模拟平铺。Tile 很小而 RectTransform 很大时,顶点数量会明显增加。

三个径向 Filled 模式的区域拆分和顶点数量放在第二篇统一比较。


七、总结

Image.OnPopulateMesh 的核心并不是直接绘制,而是根据 Image.TypeVertexHelper 写入位置、颜色、UV 和三角形索引:

  • Simple:一个普通 Quad,或者直接使用 Sprite Importer 生成的网格;
  • Sliced:把矩形切成 3×3 区域,边框保持尺寸,中心和边缘拉伸;
  • Tiled:重复中心和边缘区域,必要时生成大量 Quad;
  • Filled:Horizontal 和 Vertical 通过同步修改矩形边界与 UV 完成线性填充;径向模式在第二篇展开。

网格生成完成后,还会经过 IMeshModifier.ModifyMesh,最后才写入 CanvasRenderer。所以看到最终顶点数量与 Image 源码预期不一致时,还需要检查同一对象上是否挂了 ShadowOutline 或其它自定义网格修改器。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐