夜雨聆风学习资料网

ARTICLE · 1066018

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

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 : GraphicIClippableIMaskableIMaterialModifier

它比 Graphic 多实现了三个接口,而这三者正好就是"被遮罩裁切"所需的全部能力:

  • IMaskable
    :配合 Mask 组件(基于模板缓冲 stencil)工作;
  • IClippable
    :配合 RectMask2D 组件,按矩形范围裁剪;
  • IMaterialModifier
    :通过 GetModifiedMaterial 在材质上注入 stencil 参数。

也就是说,Graphic 只负责"怎么画"(网格、材质、颜色、深度、射线检测),而MaskableGraphic在它之上叠加了"能不能被裁切"的能力MaskableGraphic里那些Cull/SetClipRect/GetModifiedMaterial/UpdateClipParent等方法,本质就是把当前物体注册到父级的MaskRectMask2D 上,让绘制范围被限制住。

为什么这一层对 Image / RawImage 几乎是必选项?因为它们是玩家往场景里塞的"图片本体"——头像要塞进圆形 Mask、滚动列表内容要被 RectMask2D 裁掉溢出部分、技能图标要做圆形进度遮罩——这些全都是"图片被裁切"的日常需求。如果把遮罩逻辑只放在 Graphic,会让所有 UI 元素(包括那些根本不需要遮罩的)都背上这套 stencil / clip 状态机;拆出 MaskableGraphic 后,需要遮罩的图片类继承它,不需要的(或框架内更轻量的绘制)可以停在 GraphicTextImageRawImage 都站在 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(012);    vh.AddTriangle(230);}

这就是最便宜的 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 分三步:

  1. 取 outer / inner / padding / border 四组值;
  2. 用 GetAdjustedBorders 把 border 换算到本地单位并做两层修正;
  3. 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 的哪一部分、起什么作用":

对应 Sprite 的哪一块
量纲
在九宫格里起什么作用
outer
整个 textureRect(已用内容区)的外框 UV
UV 0~1
九宫格全部 9 格采样贴图的总边界;角落格其实只取它最外那一圈
innertextureRect
 往里收一个 border 后的中心矩形 UV
UV 0~1
只有中心格采样它;四边格、四角格都不碰
border
Sprite Editor 画的四条切割线,顺序 (左, 下, 右, 上),把 textureRect 切成 3×3
像素
决定四角/四边的固定尺寸,是九宫格的源头
paddingsprite.rect
 与 textureRect 之间的留白(图集裁白产物)
像素
只做绘制矩形的内缩起点;普通未打包 Sprite 上恒为 0,基本不起作用

可简单记忆为:outer/inner 管"贴图采哪段"(两者同源于 textureRectinner 是 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.xx2 永远距右 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.001freturnfalse;if ((corner & 1) == 1) invert = !invert;if (!invert && fill > 0.999freturntrue;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(012);            vh.AddTriangle(230);        }    }}

几个细节:

  • 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

特性
Image
RawImage
输入
SpriteTexture
 / RenderTexture
网格路径
Simple
SlicedTiledFilled
单矩形
顶点数
4 ~ 36 ~ 数千(取决于 Type 和面积)
固定 4
Sprite Atlas
支持
不直接使用
UV 控制
由 Sprite 和 Type 决定
由 uvRect 手动控制
常见用途
图标、按钮、九宫格、填充条
视频帧、相机画面、网络纹理

8.1 Image和RawImage的决策

光看差异表还不够"落地",实际项目里按下面这张决策流走就行:

逐条说明一下:

  1. 你的图是"动的"还是"静的"?

    • 动的
       = 运行时才拿到、不是美术提前导好的 Sprite:比如小地图的 RenderTexture、录像/直播的 VideoPlayer.texture、摄像头 WebCamTexture、网络下载的 Texture2D。这种没有现成 Sprite,硬要用 Image 得 Sprite.Create 现造一个,纯属多此一举——直接 RawImage 把 Texture 挂上就完事。
    • 静的
       = 美术在 Project 里导好的 Sprite(图标、按钮底、背景图)。这种天然走 Image
  2. 要不要九宫格 / 平铺 / 环形进度?这三个只有 Image 有(Sliced / Tiled / Filled)。RawImage 没有 Type 概念,永远只画一个矩形,做不到"按钮拉大四角不糊""背景重复平铺""技能 CD 转圈"。所以只要你想切图,闭眼选 Image

  3. 要不要图集合批?Image 的 Sprite 可以打进 SpriteAtlas,同图集的多个 Image 能合批、省 DrawCall;RawImage 直接吃 Texture,每张不同的 Texture 基本各占一个 DrawCall(除非共用同一张)。所以批量图标、列表项、背包格,优先 Image + 图集。

  4. 就是一张完整图、不切也不合批?那俩都行。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 概念,永远只画一个矩形)。先简单区分它们各自"干什么":

Type
干什么
网格长什么样
顶点数(见 §10)
Simple
原样铺一张图,不切不裁
1 个矩形(或 Sprite Mesh)
4(或随 Mesh)
Sliced
九宫格:四角不糊、中间拉伸
3×3,最多 36 顶点
最多 36
Tiled
像瓷砖一样重复平铺填满
单 Quad+UV Repeat,或逐块网格
4 ~ 数千
Filled
按方向/角度裁切,只显示一部分
矩形被切,最多 4 个 Quad
4 / 8 / 16

9.1 怎么选:一张决策流

逐条说明一下:

  1. Simple —— 默认选项。 图标、头像、背景、任何"完整显示、不需要拉伸保护"的图都用它。最省,固定 4 顶点,什么都不用想就选它。

  2. Sliced(九宫格)—— 拉大不能糊时用。 当你要把一张带边框的图拉大,又不想四角被拉伸变形:按钮底、对话框气泡、血条外框、面板边框。前提是 Sprite 在 Editor 里画了 border(第 3 节讲的)。拉多大,四角都清晰,只有中间被拉伸。

  3. Tiled(平铺)—— 要重复铺满时用。 想用一小张图"瓷砖式"重复铺一大块:滚动背景、条纹底、网格线。注意它的双路径(第 4 节):Sprite 没 border、没打包、wrapMode=Repeat 时走"单 Quad + UV 缩放"最优(4 顶点,GPU 采样 repeat);否则逐块生成网格,面积越大顶点越多,还可能触发 65000 上限自动放大 tile。

  4. 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. 顶点数量级速查

Type
顶点数
关键条件
Simple(useSpriteMesh=false)
4
固定四边形
Simple(useSpriteMesh=true)
取决于 Sprite Mesh
Tight Mesh 可能更多
Sliced
最多 36
fillCenter=false
 时 32
Tiled(Repeat+无border+无pack)
4
单 Quad + UV 缩放
Tiled(逐块网格)
4 × tiles
上限约 65000,自动放大 tile
Filled
4 / 8 / 16
Radial90/180/360 最多 4 Quad
RawImage
4
固定四边形

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 打断。

与君共勉~

相关学习资料