Unity UGUI Image解析(一)
Unity UI Image 源码解析(一):Simple、Sliced、Tiled 与 Filled
本文基于 Unity 2022.3.62f3、com.unity.ugui@1.0.0 的源码,主要梳理 Image 如何根据不同 Type 生成 UI 网格。
本文重点介绍 Simple、Sliced、Tiled 的完整生成过程,以及 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
系列导航:
- 当前:Unity UI Image 源码解析(一):Simple、Sliced、Tiled 与 Filled;
- 下一篇:Unity UI Image 源码解析(二):Filled 径向填充与 RadialCut。
一、从 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 每帧无条件执行。属性变化后,SetVerticesDirty 或 SetMaterialDirty 会把需要更新的组件注册到 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 只负责生成基础顶点和索引。之后 Shadow、Outline 等实现了 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。开启后,GetDrawingDimensions 或 GenerateSprite 会缩放绘制矩形,使 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.vertices 与 activeSprite.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.z 和 tx1:

其中:
- 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.x 和 tx0:

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.w 和 ty1:

(2)m_FillOrigin = 1
OriginVertical.Top,也就是从上向下填充,需要重新计算 v.y 和 ty0:

Horizontal 和 Vertical 修改完矩形及 UV 后,仍然只调用一次 AddQuad,因此固定生成 4 个顶点和 2 个三角形。
3. Radial 填充放在第二篇
Radial90、Radial180 和 Radial360 不只是简单修改矩形边界,还涉及区域拆分、局部 fillAmount、corner、invert 以及 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.Type 向 VertexHelper 写入位置、颜色、UV 和三角形索引:
- Simple:一个普通 Quad,或者直接使用 Sprite Importer 生成的网格;
- Sliced:把矩形切成 3×3 区域,边框保持尺寸,中心和边缘拉伸;
- Tiled:重复中心和边缘区域,必要时生成大量 Quad;
- Filled:Horizontal 和 Vertical 通过同步修改矩形边界与 UV 完成线性填充;径向模式在第二篇展开。
网格生成完成后,还会经过 IMeshModifier.ModifyMesh,最后才写入 CanvasRenderer。所以看到最终顶点数量与 Image 源码预期不一致时,还需要检查同一对象上是否挂了 Shadow、Outline 或其它自定义网格修改器。
更多推荐


所有评论(0)