UGUI源码剖析(第七章):图像的几何学——Image组件四种渲染类型的顶点生成原理
在UGUI的所有可视化组件中,Image无疑是基石中的基石。它不仅负责显示图片,更通过其强大的Type属性,为我们提供了四种截然不同的、用于构建多样化UI元素的渲染模式。这些模式的切换,看似只是Inspector上的一个下拉菜单,其背后却是OnPopulateMesh方法中四套完全不同的顶点生成(Vertex Generation)**算法。
这一章,我们将深入Image.cs的源码,逐一解剖Simple, Sliced, Tiled, Filled这四种类型的实现原理。
1. 顶点生成的“总调度室”:OnPopulateMesh
Image组件覆写了其父类Graphic的OnPopulateMesh方法,并将其变成了一个“总调度室”
// Image.cs
protected override void OnPopulateMesh(VertexHelper toFill)
{
if (activeSprite == null)
{
base.OnPopulateMesh(toFill); // 如果没有Sprite,则调用Graphic基类的方法画一个白色的方块
return;
}
switch (type)
{
case Type.Simple:
if (!useSpriteMesh)
GenerateSimpleSprite(toFill, m_PreserveAspect);
else
GenerateSprite(toFill, m_PreserveAspect); // 使用Sprite自带的Mesh
break;
case Type.Sliced:
GenerateSlicedSprite(toFill);
break;
case Type.Tiled:
GenerateTiledSprite(toFill);
break;
case Type.Filled:
GenerateFilledSprite(toFill, m_PreserveAspect);
break;
}
}
技术解读:这个方法的核心,是一个switch语句。它根据用户在Inspector中选择的type,将顶点生成的具体工作,分派给四个不同的私有方法。我们的剖析,也将沿着这四条分支展开。
2. Type.Simple:基础的四边形绘制
这是最简单的模式,其核心方法是GenerateSimpleSprite。
// Image.cs
void GenerateSimpleSprite(VertexHelper vh, bool lPreserveAspect)
{
// 1. 获取绘制区域的四个角的世界坐标
Vector4 v = GetDrawingDimensions(lPreserveAspect);
// 2. 获取Sprite在图集中的外部UV坐标
var uv = (activeSprite != null) ? Sprites.DataUtility.GetOuterUV(activeSprite) : Vector4.zero;
var color32 = color;
vh.Clear();
// 3. 添加四个顶点,位置(v)和UV(uv)一一对应
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)); // 右下
// 4. 用四个顶点组成两个三角形
vh.AddTriangle(0, 1, 2);
vh.AddTriangle(2, 3, 0);
}
技术解读:
- 核心逻辑:Simple模式的本质,就是生成一个简单的四边形(Quad),并将整个Sprite的UV贴图映射上去。
- GetDrawingDimensions: 这个辅助方法是关键。它会获取RectTransform的最终矩形,并根据preserveAspect(保持宽高比)选项,对这个矩形进行必要的缩放调整,以防止Sprite被不均匀地拉伸。
- DataUtility.GetOuterUV: 这个方法从Sprite中获取其在图集纹理上的外部UV坐标。UV坐标是归一化的(0到1),用于告诉GPU应该从纹理的哪个区域采样颜色。
- 最终,它创建了四个顶点,每个顶点都包含了位置(Position)、颜色(Color)和UV坐标这三个核心信息。
3. Type.Sliced:九宫格的几何学
Sliced模式是UGUI中最重要、也最常用的技术之一,用于制作可任意拉伸而边角不变形的UI元素。其核心方法是GenerateSlicedSprite。
技术解读:
- 核心原理:Sliced模式不再是生成一个四边形,而是将整个RectTransform区域,根据Sprite中定义的border(边框)信息,分割成一个3x3的网格(九宫格)。然后,它会为这个网格中的最多9个小矩形,分别生成四边形。
// Image.cs
private void GenerateSlicedSprite(VertexHelper toFill)
{
if (!hasBorder) { /* 退化为Simple模式 */ return; }
Vector4 outer, inner, padding, border;
// ... 获取Sprite的outerUV, innerUV, border等数据
Rect rect = GetPixelAdjustedRect();
// ... 根据pixelsPerUnit和rect尺寸,调整border的大小 ...
// 1. 计算出3x3网格的4条垂直线和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;
// ... 计算其他三条线的坐标 ...
// 2. 计算出对应的4条UV垂直线和4条UV水平线的坐标
s_UVScratch[0] = new Vector2(outer.x, outer.y);
s_UVScratch[1] = new Vector2(inner.x, inner.y);
// ...
toFill.Clear();
// 3. 循环遍历3x3网格,为每个小格子生成一个Quad
for (int x = 0; x < 3; ++x)
{
for (int y = 0; y < 3; ++y)
{
if (!m_FillCenter && x == 1 && y == 1) continue; // 如果不填充中心,则跳过中心格子
AddQuad(toFill, ...); // 使用对应的顶点坐标和UV坐标生成四边形
}
}
}
- 顶点与UV的对应:这个算法的精妙之处在于,它将顶点坐标网格和UV坐标网格进行了精确的对应。
- 四个角部的格子,其顶点尺寸是固定的(等于border大小),UV区域也是固定的(图集的四个角)。
- 四条边的格子,其顶点在一个轴向上被拉伸,UV区域在对应的轴向上也被拉伸。
- 中心的格子,其顶点在两个轴向上都被拉伸,UV区域也在两个轴向上被拉伸。
- 结果:最终生成了一个由最多9个四边形(18个三角形,36个顶点)构成的复杂Mesh,完美地实现了边角不拉伸的效果。
4. Type.Tiled:平铺的几何学
Tiled模式与Sliced类似,但它对非边角部分的处理不是“拉伸”,而是“重复平铺”。
技术解读:
GenerateTiledSprite的逻辑是所有模式中最复杂的。
- 计算瓦片尺寸:首先,它根据Sprite的尺寸和border,计算出中心区域“瓦片”的tileWidth和tileHeight。
- 判断平铺方式:它会检查Sprite的texture.wrapMode。
- 如果wrapMode是Repeat且Sprite没有打包:这是最高效的模式。它只需生成一个大的四边形来覆盖整个平铺区域,然后通过调整UV的缩放值(uvScale),让GPU在采样时自动进行纹理重复。
- 如果wrapMode不是Repeat或Sprite已被打包:这是性能较低的模式。它无法利用GPU的硬件平铺能力,因此必须在CPU端,手动地、循环地,为每一个“瓦片”都生成一个独立的四边形(Quad)。为了防止顶点数量爆炸(Mesh最多65535个顶点),它甚至还有一个保护机制,当瓦片数量过多时,会自动放大瓦片尺寸以减少总数。
- 处理边框:如果hasBorder,它还会为四条边和四个角,单独生成对应的平铺或固定的四边形。
5. Type.Filled:动态裁剪的几何学
Filled模式用于实现各种进度条效果,其核心是在CPU端,动态地对一个完整的四边形进行“裁剪”。
技术解读:
GenerateFilledSprite的逻辑,是根据fillMethod(Horizontal, Vertical, Radial90/180/360)和fillOrigin等参数,对标准四边形的**四个顶点坐标(s_Xy)和四个UV坐标(s_Uv)**进行修改。
- Horizontal / Vertical: 这是最简单的。它只是根据fillAmount,线性地修改v.z(右边界)或v.w(上边界)的顶点位置,以及对应的tx1(右UV)或ty1(上UV)的值,从而实现矩形的“缩短”。
- Radial (径向):这是最复杂的。
- 它首先会将完整的四边形,根据fillOrigin,分割成2个或4个更小的矩形区域(例如,Radial180会将其分割成左右或上下两个矩形,Radial360会分割成4个象限)。
- 然后,它会逐一处理这些小矩形。
- 对于每一个小矩形,它会调用一个名为RadialCut的核心数学函数。
- RadialCut函数会根据fillAmount计算出一个0-90度的角度,然后通过Sin和Cos三角函数,精确地计算出应该如何“切掉”这个矩形的一个角,来形成一个三角形或五边形。它会同时修改顶点坐标和UV坐标,以确保纹理也被正确地裁剪。
- 最终,Image会为每一个被裁剪后剩下的、有效的形状,生成对应的顶点和三角形。
6. 从Sprite到_MainTex:纹理的提交流程
我们已经深入剖析了Image组件是如何通过OnPopulateMesh,根据不同的Type来生成几何数据(Mesh)的。但一个完整的渲染,还需要视觉数据(纹理Texture)和渲染状态(材质Material)。那么,Image.sprite属性所引用的那张图片,究竟是如何被一路传递,最终在GPU的片元着-色器中,被_MainTex采样器读取的呢?
这个过程,是一场由Graphic基类主导、Image子类扩展、并由CanvasRenderer最终执行的精密协作。
第一步:Graphic的UpdateMaterial——提交材质与主纹理
在Canvas的更新循环中,当一个Graphic的材质被标记为“Dirty”时(m_MaterialDirty = true),其Rebuild方法会调用UpdateMaterial()。这是所有Graphic提交渲染状态的统一入口。
// Graphic.cs
protected virtual void UpdateMaterial()
{
if (!IsActive())
return;
// 将最终要使用的材质,设置给CanvasRenderer
canvasRenderer.SetMaterial(materialForRendering, 0);
// 将主纹理,设置给CanvasRenderer
canvasRenderer.SetTexture(mainTexture);
}
UpdateMaterial方法做了两件核心的事:
- 调用canvasRenderer.SetMaterial(),将最终决定渲染状态(Shader、混合模式等)的材质,传递给CanvasRenderer。
- 调用canvasRenderer.SetTexture(),将最终要被采样的主要纹理,传递给CanvasRenderer。CanvasRenderer随后会将这个纹理,绑定到Shader的_MainTex属性上。
第二步:Graphic.mainTexture的默认行为
// Graphic.cs
public virtual Texture mainTexture
{
get
{
return s_WhiteTexture; // 默认返回一张1x1的白色纹理
}
}
这意味着,如果子类不覆写这个属性,所有Graphic默认渲染出来的都是一个白色的方块(颜色由Graphic.color决定)。
第三步:Image.mainTexture的覆写——连接Sprite与渲染管线
public override Texture mainTexture
{
get
{
if (activeSprite == null)
{
// 如果没有sprite,但自身有自定义材质,且材质上有关联纹理,则使用该纹理
if (material != null && material.mainTexture != null)
{
return material.mainTexture;
}
return s_WhiteTexture;
}
// 如果有指定的sprite,则返回sprite所引用的texture
return activeSprite.texture;
}
}
技术解读:
这就是整个流程的关键连接点。Image通过覆写mainTexture属性,将逻辑层的Sprite资产,与渲染层所需的Texture对象,进行了明确的“转译”。
- 当UpdateMaterial被调用时,它会请求mainTexture属性。
- Image的mainTexture实现会检查activeSprite(它会考虑overrideSprite),并返回其内部引用的texture对象。
- 这个texture对象,最终被canvasRenderer.SetTexture()提交,并绑定到了Shader的_MainTex上。
7. 不可见的优化:overrideSprite与“脏标记”
-
overrideSprite: Image提供了一个overrideSprite属性。它允许你在不修改原始sprite字段的情况下,临时替换显示的图片。其内部通过activeSprite这个私有属性来返回当前应该使用的Sprite。这在需要频繁切换状态(如按钮的按下/悬浮)而又不想破坏Prefab原始引用的情况下非常有用。
-
“脏标记”优化: 在sprite属性的set访问器中,有一个重要的优化:
// 如果新旧Sprite的尺寸和纹理都相同(例如,同一个图集内的序列帧动画) m_SkipLayoutUpdate = m_Sprite.rect.size.Equals(value ? value.rect.size : Vector2.zero); m_SkipMaterialUpdate = m_Sprite.texture == (value ? value.texture : null); // ... SetAllDirty();
SetAllDirty内部会检查这两个m_Skip标记。如果为true,它将跳过调用SetLayoutDirty()和SetMaterialDirty(),只调用SetVerticesDirty()。这避免了在不必要时,触发昂贵的布局重建和材质重建,是针对序列帧动画等场景的一个关键性能优化。
总结:
Image组件远非一个简单的“贴图”工具。它是一个高度精密、程序化的几何体与渲染状态的提供者。
- 在几何层面,它通过在OnPopulateMesh中实现四套不同的顶点生成算法,为开发者提供了丰富的视觉表现能力。
- 在渲染状态层面,它通过覆写mainTexture和material属性,扮演了从**逻辑资产(Sprite)到底层渲染数据(Texture, Material)**的关键“翻译官”角色。它负责将正确的纹理提交给CanvasRenderer。
理解Image组件在“几何”和“渲染状态”这两个维度上的双重职责,以及其与Graphic基类、CanvasRenderer之间的紧密协作关系,是我们彻底掌握UGUI渲染管线,并进行深度性能优化的基础。