ARTICLE · 1066018
UGUI源码学习笔记_第四篇_Image和RawImage篇

Image 与 RawImage:顶点是怎么算出来的
同一张 Sprite 放进 Image,换一个 Type,网格数量、UV 和拉伸方式都会变。做九宫格、平铺背景、技能冷却时,真正需要盯住的是 Image.OnPopulateMesh 最终走了哪条生成路径。下面只按 com.unity.ugui@1.0.0 的实现展开。
0. 先理清一条线:Graphic → OnPopulateMesh → VertexHelper
Image 和 RawImage 都继承自 MaskableGraphic。Canvas 发现需要重建时,会回调 OnPopulateMesh(VertexHelper),让组件往 VertexHelper 里写顶点坐标、颜色和 UV。也就是说:
- 组件本身不"画"东西
,它只是按规则把顶点推给 VertexHelper; - 顶点长什么样
完全由 OnPopulateMesh决定; 最后由 CanvasRenderer把这些顶点提交给渲染管线。
所以本篇的所有问题——"为什么 Sliced 有 9 块"、"为什么 Tiled 会卡"、"为什么 Filled 可以裁掉半张图"——都收敛到同一个问题:OnPopulateMesh 写了多少个 AddVert / AddTriangle,写的是什么位置、什么 UV。

0.1 为什么 Image / RawImage 继承的是 MaskableGraphic,而不是 Graphic
开头就提了一句:Image 和 RawImage 都继承自 MaskableGraphic。但 uGUI 里真正"能被画出来"的基类是 Graphic——那为什么不直接继承 Graphic,中间还隔着一层 MaskableGraphic?
答案在 MaskableGraphic 的类声明上:
// MaskableGraphic.cspublicabstractclassMaskableGraphic : Graphic, IClippable, IMaskable, IMaterialModifier它比 Graphic 多实现了三个接口,而这三者正好就是"被遮罩裁切"所需的全部能力:
IMaskable:配合 Mask组件(基于模板缓冲 stencil)工作;IClippable:配合 RectMask2D组件,按矩形范围裁剪;IMaterialModifier:通过 GetModifiedMaterial在材质上注入 stencil 参数。
也就是说,Graphic 只负责"怎么画"(网格、材质、颜色、深度、射线检测),而MaskableGraphic在它之上叠加了"能不能被裁切"的能力。MaskableGraphic里那些Cull/SetClipRect/GetModifiedMaterial/UpdateClipParent等方法,本质就是把当前物体注册到父级的Mask或RectMask2D 上,让绘制范围被限制住。
为什么这一层对 Image / RawImage 几乎是必选项?因为它们是玩家往场景里塞的"图片本体"——头像要塞进圆形 Mask、滚动列表内容要被 RectMask2D 裁掉溢出部分、技能图标要做圆形进度遮罩——这些全都是"图片被裁切"的日常需求。如果把遮罩逻辑只放在 Graphic,会让所有 UI 元素(包括那些根本不需要遮罩的)都背上这套 stencil / clip 状态机;拆出 MaskableGraphic 后,需要遮罩的图片类继承它,不需要的(或框架内更轻量的绘制)可以停在 Graphic。Text、Image、RawImage 都站在 MaskableGraphic 这层,所以开箱即被遮罩。

Graphic 管"画",MaskableGraphic 在"画"之上管"被裁切"。Image / RawImage 既要画图片、又要天天被遮罩裁切,所以站在 MaskableGraphic 这层,而不是裸 Graphic。1. 总入口:OnPopulateMesh 按 Type 派发
Image.OnPopulateMesh 的源码非常短,就是先判空 Sprite,再按 type 派发到对应的生成函数:
protectedoverridevoidOnPopulateMesh(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不是 Inspector 里的 sprite,而是"实际渲染用的 Sprite":m_OverrideSprite != null ? m_OverrideSprite : sprite。所以运行时给overrideSprite赋值,走的还是同一条顶点生成路径。useSpriteMesh只影响 Simple分支。开了它,Simple不再固定 4 顶点,而是直接使用activeSprite.vertices/activeSprite.triangles/activeSprite.uv,顶点数取决于导入时生成的 Tight/Fully Rect Mesh。其余三种类型都最终落到 GenerateSlicedSprite/GenerateTiledSprite/GenerateFilledSprite里的AddQuad/RadialCut,本质上还是在拼矩形。
1.1 先认识 Quad:UI 网格的最小单元
Quad = Quadrilateral(四边形),是 2D 图像在 GPU 上渲染的最小单元。一个 Quad 由 4 个顶点 + 2 个三角形组成:

上图是 Quad 的 4 个顶点与矩形边界;渲染时 GPU 会沿"顶点0—顶点2"这条对角线把矩形拆成 2 个三角形(顶点 0→1→2 与 2→3→0)。
为什么是"4 顶点 + 2 三角形"而不是一个矩形?因为 GPU 只光栅化三角形,不认矩形。一个四边形只能沿对角线拆成 2 个三角形(两个三角形的顶点正是 0→1→2 和 2→3→0,也是 AddQuad 内部写下的两个三角形),所以"1 Quad = 4 个顶点、2 个三角形"是固定等式。
每个顶点都带三样东西:位置(position)、UV(贴图坐标)、颜色(color)。位置决定画在屏幕哪,UV 决定这片区域从纹理的哪采颜色,颜色一般就是 Image 的 color(可整体染色/透明度)。
uGUI 里怎么造一个 Quad?看 VertexHelper 的两类方法:
AddVert(position, color, uv)—— 加一个顶点; AddTriangle(a, b, c)—— 用三个顶点的下标加一个三角形。
AddQuad 只是个顺手封装:内部连做 4 次 AddVert + 2 次 AddTriangle(对角线 0-1-2 和 2-3-0,正是 §2.1 那段代码手动写出来的样子)。所以后文看到 AddQuad(toFill, ...),等价于"往网格里塞了一个四边形"。
把 Quad 的概念带回去读全文就不会懵:
- "单 Quad"
(Tiled 最优路径)= 只有 1 个矩形 = 4 顶点 / 2 三角形; - "拼 9 个 Quad"
(Sliced 九宫格)= 9 个矩形 = 36 顶点 / 18 三角形; - "切 Quad"
(Filled 的 Radial)= 顶点数没变,只是把顶点位置/UV 沿着对角线挪动,露出三角/梯形区域,看起来像"被切掉一块"; 关键技巧:Quad 的 UV 可以超出 [0,1]。当纹理wrapMode = Repeat时,GPU 会在"同一个 Quad"上把纹理重复采样——所以 Tiled 的"单 Quad + UV 缩放"能用 4 个顶点无限平铺,而不是真的去拼无数个格子。
一句话:Image / RawImage 干的所有事,本质都是"往 VertexHelper 里塞若干个 Quad,并给每个 Quad 安排好位置和 UV"。
2. Simple:默认四边形,也可以改用 Sprite Mesh
2.1 默认路径:GenerateSimpleSprite
voidGenerateSimpleSprite(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);}这就是最便宜的 Image:4 个顶点,2 个三角形。GetDrawingDimensions 负责算出当前 RectTransform 里"应该画到哪儿"的矩形(含 preserveAspect 收缩逻辑),GetOuterUV 拿到 Sprite 在纹理里的外框 UV,两个一一对应即可。
preserveAspect=true 时,源码用 PreserveSpriteAspectRatio 把绘制 Rect 按 Sprite 宽高比缩小,再按 RectTransform.pivot 对齐。没被网格覆盖的部分只是透明空白,显示什么颜色取决于后面的 UI,不应称为"黑边"。
2.2 useSpriteMesh:把导入器算的 Mesh 直接拿来用
privatevoidGenerateSprite(VertexHelper vh, bool lPreserveAspect){var spriteSize = new Vector2(activeSprite.rect.width, activeSprite.rect.height);var spritePivot = activeSprite.pivot / spriteSize;var rectPivot = rectTransform.pivot; Rect r = GetPixelAdjustedRect();// ... preserveAspect 调整 r ...var drawingSize = new Vector2(r.width, r.height);var spriteBoundSize = activeSprite.bounds.size;var drawOffset = (rectPivot - spritePivot) * drawingSize;var color32 = color; vh.Clear(); Vector2[] vertices = activeSprite.vertices; Vector2[] uvs = activeSprite.uv;for (int i = 0; i < vertices.Length; ++i) { vh.AddVert(new Vector3( (vertices[i].x / spriteBoundSize.x) * drawingSize.x - drawOffset.x, (vertices[i].y / spriteBoundSize.y) * drawingSize.y - drawOffset.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]); }}开启 useSpriteMesh 后,Image 不再自己拼矩形,而是把 Sprite 导入时生成的顶点数组原样搬过来,按 RectTransform 尺寸缩放。这样:
顶点数不再固定为 4,可以是几十甚至几百; 可以贴合 Tight Mesh 的轮廓,减少透明像素的过度绘制; 但 Simple "最便宜" 的前提就变成" useSpriteMesh=false或导入器给的是 FullRect"。
3. Sliced:九宫格,用 border 把图切成 3×3
Sliced 的核心思想就一句:把 Sprite 的 border 当两刀,把图切成九宫格——四角恒定、四边单向拉伸、中心双向拉伸;空间不够时再压缩边框。
GenerateSlicedSprite 分三步:
取 outer / inner / padding / border四组值;用 GetAdjustedBorders把 border 换算到本地单位并做两层修正;3×3循环 AddQuad拼九格(fillCenter=false可跳过中心格)。
privatevoidGenerateSlicedSprite(VertexHelper toFill){if (!hasBorder) { GenerateSimpleSprite(toFill, false);return; } Vector4 outer, inner, padding, border;// ... 取数见 3.1 ... Rect rect = GetPixelAdjustedRect(); Vector4 adjustedBorders = GetAdjustedBorders(border / multipliedPixelsPerUnit, rect); padding = padding / multipliedPixelsPerUnit; 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;// ... s_UVScratch 取 outer/inner UV ... toFill.Clear();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; 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)); } }}3.1 四个 Vector4 从哪来,量纲各不同
源码省略号处的取数长这样:
Vector4 outer, inner, padding, border;if (activeSprite != null){ outer = Sprites.DataUtility.GetOuterUV(activeSprite); // 整张 Sprite 的外框 UV inner = Sprites.DataUtility.GetInnerUV(activeSprite); // 扣掉 border 后的中间 UV padding = Sprites.DataUtility.GetPadding(activeSprite); // 图集留白(像素) border = activeSprite.border; // 九宫格边框(像素)}else outer = inner = padding = border = Vector4.zero;先别管公式,这四组值到底各代表 Sprite 上的哪一块、起什么作用,两张图看明白。
先看"嵌套关系"——从外到内一层套一层。用嵌套矩形表示最直观:外框包着内框,框与框之间的"环"就是对应的留白 / 切割区:

看图说话:从外往里数,rect 是最外层大框;
rect外框到 textureRect 内框之间那圈间隙 = ② padding(图集裁白留白,常规 Sprite = 0);textureRect 是真正被画出来的内容区(=outer 的像素来源);textureRect 内框到 inner 中区之间那一圈 = border 切割区(也就是九宫格里四角、四边的固定带);正中心的 inner 中区就是中心格。四组值的"空间位置"一眼就位。
再看"textureRect 被 border 切成九宫格"后,每个格子的身份(颜色:橙=四角固定,浅蓝=四边单向拉伸,深蓝=中心双向拉伸):

图里的四组颜色,正好对应四组值"代表 Sprite 的哪一部分、起什么作用":
outer | textureRect(已用内容区)的外框 UV | ||
inner | textureRectborder 后的中心矩形 UV | ||
border | (左, 下, 右, 上),把 textureRect 切成 3×3 | ||
padding | sprite.recttextureRect 之间的留白(图集裁白产物) |
可简单记忆为:outer/inner 管"贴图采哪段"(两者同源于 textureRect,inner 是 outer 抠掉 border 那一圈后的中心),border 管"切几刀、角多大",padding 是打包裁白顺带留下的边距、常规用不上。
这四个值都不是凭空来的,都从 Sprite 自身的几何属性(textureRect / rect / border / textureRectOffset)换算,本质是一组把"像素矩形"转成"贴图 0~1 UV"或"像素留白"的代数。把它们展开最直观(设 texW/texH 为当前贴图宽高,packed 时贴图就是图集):
// GetOuterUV:已用内容区 textureRect 在贴图里的整框 UVRect r = sprite.textureRect;outer = new Vector4(r.x / texW, r.y / texH, (r.x + r.width) / texW, (r.y + r.height) / texH);// GetInnerUV:textureRect 再往里收一个 border 的中区 UVVector4 b = sprite.border;inner = new Vector4((r.x + b.x) / texW, (r.y + b.y) / texH, (r.x + r.width - b.z) / texW, (r.y + r.height - b.w) / texH);// GetPadding:rect(裁白前)与 textureRect(裁白后)四条边的像素差,// 仅当 sprite 被打进图集并裁过白(textureRectOffset != 0)时非零Vector2 off = sprite.textureRectOffset; // (左, 下) 方向的偏移padding = new Vector4(off.x, off.y, sprite.rect.width - r.width - off.x, sprite.rect.height - r.height - off.y); // (左,下,右,上)上面是引擎层
UnityEngine.Sprites.DataUtility的实现(C#,属于UnityEngine.CoreModule,不在 uGUI 包内,所以前面我们从com.unity.ugui@1.0.0取的是Image.cs,而DataUtility直接复用引擎的)。它跨 Unity 版本稳定,与本文引用的 uGUI 1.0.0 用法一致。
要点:
outer与 inner都由同一个textureRect决定,区别只是inner在每边再往里收一个border;两者之间夹的那一圈 UV,正是九宫格四个角的采样来源。padding不是 Sprite Editor 里画的,而是图集打包"裁掉空白像素"留下的边距;普通未打包、或 MeshType=FullRect未裁白的 Sprite 上它恒为 0,所以 Sliced 在常规 Sprite 上padding基本不起作用。
两组量纲不同,这是后面理解的关键:
outer/inner—— UV(0~1,无量纲),来自DataUtility决定每个九宫格子"采样贴图的哪一段"。 outer是整图外框,inner是扣掉 border 的中区。padding/border—— 像素,有量纲padding是图集打包留白(未打包通常 0),只做绘制矩形内缩; border是 Sprite Editor 画的四刀,顺序(左,下,右,上),九宫格的真正来源。
因为 padding/border 是像素,必须先除以 multipliedPixelsPerUnit 换算到 RectTransform 本地单位,才能和 GetPixelAdjustedRect() 的绘制矩形直接相加减;outer/inner 不用,它们是归一化 UV。
3.2 边框怎么修正:空间够就不动,不够就压缩
拿到像素 border 后,GetAdjustedBorders 每轴独立做两层修正:
private Vector4 GetAdjustedBorders(Vector4 border, Rect adjustedRect){ Rect originalRect = rectTransform.rect;for (int axis = 0; axis <= 1; axis++) {if (originalRect.size[axis] != 0) // 修正 1:像素对齐后 Rect 稍大,边框同比放大,避免缝隙 {float r = adjustedRect.size[axis] / originalRect.size[axis]; border[axis] *= r; border[axis + 2] *= r; }float combined = border[axis] + border[axis + 2];if (adjustedRect.size[axis] < combined && combined != 0) // 修正 2:Rect 比两刀之和还小,边框同比压缩,防穿透 {float r = adjustedRect.size[axis] / combined; border[axis] *= r; border[axis + 2] *= r; } }return border;}结论很直接:
- 空间够
:四角保持 border 尺寸,四边单向拉伸,中心双向拉伸; - 空间不够
:Rect 宽/高小于左右或上下 border 之和时,边框同比压缩,四角跟着变小; fillCenter=false:跳过中心格 (x==1 && y==1),只画 8 块边框。
3.3 为什么偏偏是 9 块(不是 4、不是 16)
看到 for (int x = 0; x < 3; ++x) 这种两层 3×3 循环,第一个问题往往是:为什么是 9 块,而不是 2×2=4 或 4×4=16?
答案不在 Unity,而在"九宫格缩放"要解决的问题本身:矩形 UI 元素(对话框、按钮、面板)在任意拉大时,四角的装饰必须保持清晰,不能被拉伸糊掉;而中间的身体可以随意变长变宽。
要做到这一点,每个轴(X、Y)上必须切两刀——恰好就是 Sprite 的 border:
border.x(左边框)和 border.z(右边框)是 X 轴上的两刀,把宽度切成 3 段;border.y(下边框)和 border.w(上边框)是 Y 轴上的两刀,把高度切成 3 段。
两轴各 3 段,组合起来就是 3×3 = 9 块。每一块对应一种"拉伸行为",而这 9 块的拉伸行为恰好只有 3 类,已经覆盖了矩形缩放的全部需求:

- 4 个角
:两个轴都不拉伸 → 世界尺寸恒定,纹理原样平铺,所以圆角/花纹永远清晰; - 4 条边
:只沿一个轴拉伸 → 框的左右/上下边可以延长,但不会变粗或变形; - 1 个中心
:两个轴都拉伸 → 主体区域任意扩展。
为什么不能更少(比如 2×2=4)? 一个轴上只切一刀只能分出 2 段,那就保不住"两个角都固定"——要么左角被拉、要么右角被拉。必须切两刀(= 两个 border)才能把左角、中间、右角同时隔离。两刀是最少刀数,对应 3 段是最少段数。
为什么不需要更多(比如 4×4=16)? 多加刀数只会产生"更多单轴拉伸 / 双轴拉伸"的格子,并不会出现新的拉伸行为;而它们全挤在同一段纹理区间里,反而白白增加顶点数、没有收益。9 是"能同时锁住四角 + 四条边单向 + 中心双向"的最小且恰好够用的划分。
这也解释了代码开头的 if (!hasBorder) { GenerateSimpleSprite(toFill, false); return; }:Sliced 的本质就是"用 border 切两刀",Sprite 没定义 border 就根本切不出九宫格,只能退化成 Simple 整张拉伸。代码里 x < 3 / y < 3 的那个 3,正是"两个 border 把一条轴切成 3 段"的直接体现。
3.4 九宫格节点是怎么排出来的
Sliced 的本质是把绘制矩形切成 3×3 共 9 个格子,每个格子对应一个 AddQuad(中心格可被 fillCenter 跳过)。驱动这一切的是两条边界线:s_VertScratch 存每个轴上的 4 个分界点(顶点位置),s_UVScratch 存对应的 4 个分界点(UV)。AddQuad 只是对 x∈{0,1,2}、y∈{0,1,2} 做两层循环,把相邻分界点拼成格子。
以 X 轴为例,4 个分界点把宽度切成 3 段(对应源码 s_VertScratch[0..3]):

- 第一段 (x0→x1)
宽度 = adjustedBorders.x - padding.x,约等于border.x(左边框)→ 左列两角的宽度; - 第二段 (x1→x2)
宽度 = x2 - x1,随 Rect 宽度一起增长 → 中列(两条竖边 + 中心)拉伸区; - 第三段 (x2→x3)
宽度 = adjustedBorders.z - padding.z,约等于border.z(右边框)→ 右列两角的宽度。
Y 轴同理,用 border.y / border.w 决定上下角的高度。于是每个轴被 4 个分界点切成 3 段,两轴组合出 9 格,身份固定为:四角(不拉伸)、四边(单轴拉伸)、中心(双向拉伸)——拉伸行为分布见 3.3 的九宫格图。
为什么四个角不会变形:角落格子的顶点尺寸锁死在 border 上(border.x/border.z 定左右列宽,border.y/border.w 定上下排高)。经 GetAdjustedBorders 调整后,空间够用时它们与 Rect 整体尺寸无关:x1 永远距左 border.x、x2 永远距右 border.z,四角的世界尺寸恒定,纹理原样平铺进固定范围。
为什么中间能随意拉伸:中列宽 = x2 - x1,随 Rect 变宽而增长,两端锚点 x1/x2 不动;中行同理。同时 UV 也按 outer/inner 切成 3 段且范围不变(角落格采固定角区 UV,中间格采 inner 范围),只是中间格顶点在变宽——纹理采样区间不动、网格尺寸在动,所以被"拉伸"而非重复。
综上:九宫格不是给中间单独加节点,而是用 4×4 边界点把整图框成九格,让角落顶点锚定 border、中间顶点夹在两条 border 之间,宽高自然随 Rect 增长。
顶点数:最多 3×3×4 = 36 顶点(9 格 × 4 顶点),比 Simple 贵但可控;fillCenter=false 时少一个中心格,变成 32 顶点。
4. Tiled:先分清"单 Quad 硬件平铺"和"逐块网格"
GenerateTiledSprite 有两条完全不同的路径,分叉条件在源码里写得很清楚:
if (activeSprite != null && (hasBorder || activeSprite.packed || activeSprite.texture != null && activeSprite.texture.wrapMode != TextureWrapMode.Repeat)){// 路径 A:逐 tile 生成 Quad 网格}else{// 路径 B:单 Quad + UV 缩放,让纹理硬件重复}4.1 路径 B:单 Quad + UV Repeat(最优)
只有同时满足以下三个条件才会进入:
Sprite 没有 border; Sprite 没有被 packed(不在图集里); 主纹理的 wrapMode == TextureWrapMode.Repeat。
这时源码只提交一个矩形,把 UV 乘上一个 uvScale 系数,让纹理硬件自动重复:
Vector2 uvScale = new Vector2( (xMax - xMin) / tileWidth, (yMax - yMin) / tileHeight);if (m_FillCenter){ AddQuad(toFill,new Vector2(xMin, yMin) + rect.position,new Vector2(xMax, yMax) + rect.position, color, Vector2.Scale(uvMin, uvScale), Vector2.Scale(uvMax, uvScale));}顶点数固定为 4,与平铺面积无关。这是 Tiled 真正便宜的场景。
4.2 路径 A:逐块 Quad 网格
先说 tile(瓷砖)是什么:平铺模式下,原始 Sprite 被当成一个固定大小的"小方块"来用——这个小方块本质就是 1 个 Quad(4 顶点 / 2 三角形,见 §1.1),尺寸等于 Sprite 去掉 border 后的一格内容。平铺就是把它像地板砖一样一块挨一块铺满整个绘制矩形。
所以"逐 tile 生成 Quad 网格"的意思很直白:每铺一块瓷砖,就调用一次 AddQuad 往 VertexHelper 里塞一个 Quad;整片区域被 N×M 块瓷砖填满,于是网格里就有 N×M 个 Quad。每块瓷砖各带一份完整的 Sprite UV,所以纹理是被"复制重复"而非"拉伸"。
只要不满足上面三个条件(有 border、在图集里、或 WrapMode 不是 Repeat),就会走逐块生成。规则是:
- 中心区域
:二维平铺,按 (spriteSize - border) / multipliedPixelsPerUnit算出单个 tile 的世界尺寸; - 四边
:沿单轴平铺; - 四角
:单独绘制; 最后一行/列通过收缩位置和 UV 填满剩余空间。
源码会先估算顶点数,超过 65000 时自动放大 tile 尺寸,把 Quad 数压到约 16250 以内:
if (nVertices > 65000.0){ Debug.LogError("Too many sprite tiles on Image ...");double maxTiles = 65000.0 / 4.0;// ... 按 imageRatio 重新分配 nTilesW / nTilesH ... tileWidth = (xMax - xMin) / nTilesW; tileHeight = (yMax - yMin) / nTilesH;}所以 WrapMode=Repeat 是性能优化条件,不是正确性条件。非 Repeat 会回退到逐块网格,可能产生大量顶点,但不会必然错位或出现接缝。
5. Filled:在矩形及其 UV 上做裁切
GenerateFilledSprite 先处理最简单的 Horizontal/Vertical,再处理三种 Radial。
5.1 Horizontal / Vertical
if (m_FillMethod == FillMethod.Horizontal || m_FillMethod == FillMethod.Vertical){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; } }elseif (fillMethod == FillMethod.Vertical) {// 同理,改 v.y / v.w 和 ty0 / ty1 }}做法很直接:按 fillAmount 同步收缩矩形边界和 UV,不增加额外顶点,还是 1 个 Quad。
5.2 Radial:不是"拟合圆周",而是"切 Quad"
Radial 模式的核心是 RadialCut。它把 0~1 的 fillAmount 换算成 0~90° 的角度,用 sin / cos 同时修改 Quad 的顶点位置和 UV:
staticboolRadialCut(Vector3[] xy, Vector3[] uv, float fill, bool invert, int corner){if (fill < 0.001f) returnfalse;if ((corner & 1) == 1) invert = !invert;if (!invert && fill > 0.999f) returntrue;float angle = Mathf.Clamp01(fill);if (invert) angle = 1f - angle; angle *= 90f * Mathf.Deg2Rad;float cos = Mathf.Cos(angle);float sin = Mathf.Sin(angle); RadialCut(xy, cos, sin, invert, corner); RadialCut(uv, cos, sin, invert, corner);returntrue;}RadialCut(xy, cos, sin, ...) 会在 Quad 内部做线性插值,把矩形"斜着切一刀",露出三角/梯形区域。三种 Radial 的区别只是把这个切分逻辑应用到不同数量的子矩形上:
Radial90:1 个 Quad,最多切 1 次; Radial180:拆成 2 个半区,每个半区各切一次,最多 2 个 Quad; Radial360:拆成 4 个象限,每个象限各切一次,最多 4 个 Quad。
所以 Filled 不是靠增加圆周段数来拟合圆。它始终是在矩形 Quad 上切边;最终看起来是不是圆,取决于贴图本身的透明轮廓,而不是顶点数变多了。
6. overrideSprite 与 SetNativeSize
6.1 overrideSprite
public Sprite overrideSprite{get { return activeSprite; }set {if (SetPropertyUtility.SetClass(ref m_OverrideSprite, value)) { SetAllDirty(); TrackSprite(); } }}设置后会走 SetAllDirty(),触发布局+顶点+材质重建。所有生成函数统一读 activeSprite,所以覆盖图会参与网格、UV、border 和原生尺寸计算;清空后回退到 sprite。
6.2 SetNativeSize
publicoverridevoidSetNativeSize(){if (activeSprite != null) {float w = activeSprite.rect.width / pixelsPerUnit;float h = activeSprite.rect.height / pixelsPerUnit; rectTransform.anchorMax = rectTransform.anchorMin; rectTransform.sizeDelta = new Vector2(w, h); SetAllDirty(); }}而 pixelsPerUnit 的定义是:
publicfloat pixelsPerUnit{get {float spritePixelsPerUnit = 100;if (activeSprite) spritePixelsPerUnit = activeSprite.pixelsPerUnit;if (canvas) m_CachedReferencePixelsPerUnit = canvas.referencePixelsPerUnit;return spritePixelsPerUnit / m_CachedReferencePixelsPerUnit; }}所以 SetNativeSize 的实际结果是:
sizeDelta = Sprite.rect 像素 × (Canvas.referencePixelsPerUnit / Sprite.pixelsPerUnit)单位是 RectTransform 所在 Canvas 的本地单位,不是笼统的"世界尺寸"。
7. RawImage:没有 Type,只有 Texture + uvRect
RawImage 的 OnPopulateMesh 简单得多:没有 Type 分支,没有 Sliced / Tiled / Filled,只生成一个矩形。
protectedoverridevoidOnPopulateMesh(VertexHelper vh){ Texture tex = mainTexture; vh.Clear();if (tex != null) {var r = GetPixelAdjustedRect();var v = new Vector4(r.x, r.y, r.x + r.width, r.y + r.height);var scaleX = tex.width * tex.texelSize.x;var scaleY = tex.height * tex.texelSize.y; {var color32 = color; vh.AddVert(new Vector3(v.x, v.y), color32, new Vector2(m_UVRect.xMin * scaleX, m_UVRect.yMin * scaleY)); vh.AddVert(new Vector3(v.x, v.w), color32, new Vector2(m_UVRect.xMin * scaleX, m_UVRect.yMax * scaleY)); vh.AddVert(new Vector3(v.z, v.w), color32, new Vector2(m_UVRect.xMax * scaleX, m_UVRect.yMax * scaleY)); vh.AddVert(new Vector3(v.z, v.y), color32, new Vector2(m_UVRect.xMax * scaleX, m_UVRect.yMin * scaleY)); vh.AddTriangle(0, 1, 2); vh.AddTriangle(2, 3, 0); } }}几个细节:
uvRect决定采样范围,默认 (0,0,1,1);改它可以实现裁剪或局部显示。tex.width * tex.texelSize.x看起来绕了一圈,其实 texelSize就是1/width,所以scaleX ≈ 1。这个写法是为了兼容某些特殊纹理(如 RenderTexture 翻转)。SetNativeSize用的是 tex.width * uvRect.width,所以只显示 UV 子区域时,尺寸也会按子区域像素数算。
8. Image 还是 RawImage
Sprite | TextureRenderTexture | |
SimpleSliced、Tiled、Filled | ||
uvRect 手动控制 | ||
8.1 Image和RawImage的决策
光看差异表还不够"落地",实际项目里按下面这张决策流走就行:

逐条说明一下:
你的图是"动的"还是"静的"?
- 动的
= 运行时才拿到、不是美术提前导好的 Sprite:比如小地图的 RenderTexture、录像/直播的VideoPlayer.texture、摄像头WebCamTexture、网络下载的Texture2D。这种没有现成 Sprite,硬要用Image得Sprite.Create现造一个,纯属多此一举——直接RawImage把Texture挂上就完事。 - 静的
= 美术在 Project 里导好的 Sprite(图标、按钮底、背景图)。这种天然走 Image。 要不要九宫格 / 平铺 / 环形进度?这三个只有
Image有(Sliced/Tiled/Filled)。RawImage没有 Type 概念,永远只画一个矩形,做不到"按钮拉大四角不糊""背景重复平铺""技能 CD 转圈"。所以只要你想切图,闭眼选Image。要不要图集合批?
Image的Sprite可以打进SpriteAtlas,同图集的多个 Image 能合批、省 DrawCall;RawImage直接吃Texture,每张不同的Texture基本各占一个 DrawCall(除非共用同一张)。所以批量图标、列表项、背包格,优先Image+ 图集。就是一张完整图、不切也不合批?那俩都行。
RawImage代码更短(就一个Texture字段),Image多一层 Sprite 封装但功能全。工程惯例:有 Sprite 就用 Image,纯动态纹理才上 RawImage——这样团队写法一致,哪天想加九宫格也不用换组件。
可简单记忆为:数据源决定组件——动态/原始纹理用 RawImage,Sprite 资源用 Image;要切图(九宫格/平铺/填充)或要合批,必选 Image。
关于 DrawCall 的常见误解:RawImage 不等于"一张图一个 DrawCall"。Canvas 合批取决于纹理、材质、绘制顺序、Mask/Stencil 等渲染状态:
不同 Texture通常会拆批;多个 RawImage使用同一Texture、同一Material,且其他批处理条件一致时,仍可能合批;工程上应避免在列表里堆大量不同纹理,但不必把问题错误归因于 RawImage类型本身。
9. 四种 Type 怎么选:区别与性能影响
这四个 Type 都只在 Image 上有(RawImage 没有 Type 概念,永远只画一个矩形)。先简单区分它们各自"干什么":
| Simple | |||
| Sliced | |||
| Tiled | |||
| Filled |
9.1 怎么选:一张决策流

逐条说明一下:
Simple —— 默认选项。 图标、头像、背景、任何"完整显示、不需要拉伸保护"的图都用它。最省,固定 4 顶点,什么都不用想就选它。
Sliced(九宫格)—— 拉大不能糊时用。 当你要把一张带边框的图拉大,又不想四角被拉伸变形:按钮底、对话框气泡、血条外框、面板边框。前提是 Sprite 在 Editor 里画了
border(第 3 节讲的)。拉多大,四角都清晰,只有中间被拉伸。Tiled(平铺)—— 要重复铺满时用。 想用一小张图"瓷砖式"重复铺一大块:滚动背景、条纹底、网格线。注意它的双路径(第 4 节):Sprite 没 border、没打包、wrapMode=Repeat 时走"单 Quad + UV 缩放"最优(4 顶点,GPU 采样 repeat);否则逐块生成网格,面积越大顶点越多,还可能触发 65000 上限自动放大 tile。
Filled(填充)—— 要做"按量显示一部分"时用。 技能 CD 转圈(Radial360)、血条/进度条(Horizontal/Vertical)、圆形头像遮罩。本质是裁掉矩形的一部分,顶点很少(最多 4 个 Quad)。
9.2 性能影响:重点盯顶点数和重建,不是 Type 本身
- 顶点/三角形开销
:Simple 和"最优 Tiled"都只有 4 顶点,最轻;Sliced 36 顶点( fillCenter=false时 32)仍很轻;逐块 Tiled 和超大 Filled/Radial360 的顶点随面积增长。uGUI 每帧会重建发生变化的网格,面积越大 CPU 成本越高——所以大面板用 Sliced 比用逐块 Tiled 更划算。 - 合批
:四个 Type 同属 Image,只要用同一图集的 Sprite、同材质、绘制顺序相邻,都能合批。Type 本身不阻断合批;真正打断合批的是"不同纹理 / 不同材质 / Mask 或 Stencil 状态变化"。 - 重建频率
:Simple / Sliced 顶点固定,动画时不会因 Type 而每帧重建;但 Filled 在做动画(如 CD 转圈)时每帧改填充量 → 每帧重建这块网格。单块开销可忽略,若整屏大量 Filled 动画,注意用子 Canvas 隔离重建范围。 - useSpriteMesh(Simple 特例)
:Simple 勾了 useSpriteMesh后顶点数随 Sprite 的 Tight Mesh,可能比 4 多,但能省掉透明三角形、减少 overdraw,对不规则图标有时反而更优(第 2.2 节)。
简单记忆为:默认 Simple;要保四角用 Sliced;要重复铺用 Tiled(留意逐块顶点);要做进度/转圈用 Filled。四个 Type 性能都不重,真正要盯的是"逐块 Tiled 的面积顶点"和"Filled 动画的每帧重建",以及合批是否被纹理/材质打断。
10. 顶点数量级速查
fillCenter=false | ||
11. 总结
Image / RawImage 通过给 VertexHelper 里塞若干个 Quad(4 顶点 + 2 三角形),并安排好每个顶点"画在哪、采贴图哪段"。
- 总线
: Graphic.OnPopulateMesh → VertexHelper → CanvasRenderer,组件继承MaskableGraphic只是顺手带上"能被遮罩裁切"。 - Quad 是最小单元
:GPU 只认三角形,所以四边形都拆 2 片;UV 可超出 [0,1] 是 Tiled 单 Quad 平铺的关键技巧。 - 四种 Type 都是拼 Quad 的不同策略
: Simple一个 Quad 原样铺; Sliced用 border把图切成 3×3 共 9 个 Quad,四角锚定边框、中间自由拉伸;Tiled要么单 Quad + UV 缩放(最省),要么逐 tile 复制网格(多顶点); Filled把 Quad 顶点挪动实现裁切,做进度/转圈。 - 四个 Vector4 的来源
: outer/inner是textureRect的 UV(管采哪段),border是 Sprite Editor 画的刀(管切几刀、角多大),padding是图集裁白边距(常规用不上)。 - 选型
:动态/原始纹理用 RawImage,Sprite 资源用Image;要九宫格/平铺/填充或要合批,必选Image。 - 性能
:四个 Type 都不重,真正要盯的是「逐块 Tiled 的面积顶点」和「Filled 动画每帧重建」,以及合批是否被不同纹理/材质/Mask-Stencil 打断。
与君共勉~