UGUI源码剖析(7):图像的几何学——Image组件四种渲染类型的顶点生成原理

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的逻辑是所有模式中最复杂的。

  1. 计算瓦片尺寸:首先,它根据Sprite的尺寸和border,计算出中心区域“瓦片”的tileWidth和tileHeight。
  2. 判断平铺方式:它会检查Sprite的texture.wrapMode。
    • 如果wrapMode是Repeat且Sprite没有打包:这是最高效的模式。它只需生成一个大的四边形来覆盖整个平铺区域,然后通过调整UV的缩放值(uvScale),让GPU在采样时自动进行纹理重复。
    • 如果wrapMode不是Repeat或Sprite已被打包:这是性能较低的模式。它无法利用GPU的硬件平铺能力,因此必须在CPU端,手动地、循环地,为每一个“瓦片”都生成一个独立的四边形(Quad)。为了防止顶点数量爆炸(Mesh最多65535个顶点),它甚至还有一个保护机制,当瓦片数量过多时,会自动放大瓦片尺寸以减少总数。
  3. 处理边框:如果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 (径向):这是最复杂的。
    1. 它首先会将完整的四边形,根据fillOrigin,分割成2个或4个更小的矩形区域(例如,Radial180会将其分割成左右或上下两个矩形,Radial360会分割成4个象限)。
    2. 然后,它会逐一处理这些小矩形。
    3. 对于每一个小矩形,它会调用一个名为RadialCut的核心数学函数
    4. RadialCut函数会根据fillAmount计算出一个0-90度的角度,然后通过Sin和Cos三角函数,精确地计算出应该如何“切掉”这个矩形的一个角,来形成一个三角形或五边形。它会同时修改顶点坐标和UV坐标,以确保纹理也被正确地裁剪。
    5. 最终,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方法做了两件核心的事:

  1. 调用canvasRenderer.SetMaterial(),将最终决定渲染状态(Shader、混合模式等)的材质,传递给CanvasRenderer。
  2. 调用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渲染管线,并进行深度性能优化的基础。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值