深入Unity UGUI源码:手写ExtendImage组件,彻底搞懂Image的Filled与Sliced渲染原理
在Unity的UI开发中,Image组件是最基础也是最常用的组件之一。无论是简单的图标显示,还是复杂的进度条动画,Image组件都扮演着至关重要的角色。然而,当我们需要实现一些特殊效果时,比如支持九宫格裁剪的进度条,原生的Image组件就显得力不从心了。本文将带你深入UGUI源码,通过手写ExtendImage组件,彻底理解Image组件中Filled与Sliced模式的渲染原理。
1. UGUI Image组件基础原理
UGUI的Image组件继承自MaskableGraphic类,主要负责2D精灵的渲染。其核心渲染逻辑是通过OnPopulateMesh方法实现的,这个方法负责生成网格数据(顶点、UV、颜色等)并填充到VertexHelper中。
Image组件支持四种渲染类型:
- Simple:最简单的拉伸渲染
- Sliced:九宫格渲染
- Tiled:平铺渲染
- Filled:填充渲染(常用于进度条)
// Image组件的类型定义 public enum Type { Simple, Sliced, Tiled, Filled }在深入研究Filled和Sliced模式之前,我们需要先了解几个关键概念:
- Sprite的Border属性:定义了九宫格的边界值
- UV坐标:决定了纹理如何在网格上映射
- VertexHelper:用于构建网格数据的辅助类
2. Filled模式的实现原理
Filled模式是制作进度条最常用的方式,它通过fillAmount参数控制显示比例。让我们深入源码看看它是如何工作的。
2.1 Filled模式的核心算法
Filled模式的渲染逻辑主要在GenerateFilledSprite方法中实现。根据不同的填充方法(水平、垂直、径向等),计算顶点和UV的方式也有所不同。
以水平填充为例,关键计算步骤如下:
- 计算填充比例对应的顶点位置
- 根据填充原点(Left或Right)调整顶点位置
- 计算对应的UV坐标
- 生成三角形索引
// 水平填充的核心代码片段 if (fillMethod == FillMethod.Horizontal) { float fill = fillAmount; if (fillOrigin == 1) // Right { xMin = Mathf.Lerp(xMax, xMin, fill); uvMin.x = Mathf.Lerp(uvMax.x, uvMin.x, fill); } else // Left { xMax = Mathf.Lerp(xMin, xMax, fill); uvMax.x = Mathf.Lerp(uvMin.x, uvMax.x, fill); } }2.2 Filled模式的局限性
虽然Filled模式非常适合制作进度条效果,但它有一个明显的缺点:不支持九宫格。这意味着:
- 进度条在不同长度时,边缘会出现拉伸变形
- 需要为不同长度的进度条准备不同的图片资源
- 无法利用九宫格的特性来优化资源
3. Sliced模式的实现原理
Sliced模式(九宫格)是UI优化的重要手段,它通过将图片分为9个区域,只拉伸中间部分来保持边缘不变形。
3.1 九宫格的数据结构
九宫格的信息存储在Sprite的border属性中,它是一个Vector4,分别表示左、下、右、上的边界值:
[left, bottom, right, top]在代码中,我们通过Sprite.border属性获取这些值:
Vector4 border = activeSprite.border;3.2 Sliced模式的渲染流程
Sliced模式的渲染主要在GenerateSlicedSprite方法中实现,大致流程如下:
- 获取Sprite的border值
- 计算调整后的边界(考虑像素密度)
- 将图片分为9个区域
- 为每个区域生成顶点和UV数据
// 九宫格顶点计算的核心代码 s_VertScratch[0] = new Vector2(padding.x, padding.y); s_VertScratch[1] = new Vector2(border.x, border.y); s_VertScratch[2] = new Vector2(rect.width - border.z, rect.height - border.w); s_VertScratch[3] = new Vector2(rect.width - padding.z, rect.height - padding.w);3.3 Sliced模式的局限性
虽然Sliced模式能很好地保持边缘不变形,但它不适合用于进度条效果,因为:
- 无法实现真正的裁剪效果
- 进度变化时,部分区域会保持显示
- 无法精确控制显示比例
4. 实现ExtendImage组件
结合Filled和Sliced模式的优缺点,我们需要创建一个新的ExtendImage组件,让Filled模式支持九宫格。
4.1 组件设计思路
我们的ExtendImage组件需要:
- 继承自UnityEngine.UI.Image
- 添加一个
SlicedClipMode选项 - 重写
OnPopulateMesh方法 - 实现自定义的
GenerateSlicedSprite方法
[AddComponentMenu("UI/ExtendImage")] public class ExtendImage : Image { [SerializeField] private bool m_SlicedClipMode = false; protected override void OnPopulateMesh(VertexHelper vh) { if (type == Type.Filled && m_SlicedClipMode && hasBorder) { GenerateSlicedSprite(vh); } else { base.OnPopulateMesh(vh); } } }4.2 核心算法实现
关键点在于如何结合Filled的裁剪和Sliced的九宫格特性。我们需要:
- 计算九宫格各部分的比例
- 根据fillAmount确定显示哪些部分
- 对最后一个显示的部分进行裁剪
private void GenerateSlicedSprite(VertexHelper toFill) { // 获取九宫格信息 Vector4 border = activeSprite.border / pixelsPerUnit; Vector4 adjustedBorders = GetAdjustedBorders(border, rect); // 计算各部分比例 float xLength = s_VertScratch[3].x - s_VertScratch[0].x; float len1XRatio = (s_VertScratch[1].x - s_VertScratch[0].x) / xLength; float len2XRatio = (s_VertScratch[2].x - s_VertScratch[1].x) / xLength; float len3XRatio = (s_VertScratch[3].x - s_VertScratch[2].x) / xLength; // 根据fillAmount裁剪 if (fillAmount >= (len1XRatio + len2XRatio)) { float ratio = (fillAmount - (len1XRatio + len2XRatio)) / len3XRatio; s_VertScratch[3].x = s_VertScratch[2].x + (s_VertScratch[3].x - s_VertScratch[2].x) * ratio; s_UVScratch[3].x = s_UVScratch[2].x + (s_UVScratch[3].x - s_UVScratch[2].x) * ratio; } else if (fillAmount >= len1XRatio) { float ratio = (fillAmount - len1XRatio) / len2XRatio; s_VertScratch[2].x = s_VertScratch[1].x + (s_VertScratch[2].x - s_VertScratch[1].x) * ratio; } else { float ratio = fillAmount / len1XRatio; s_VertScratch[1].x = s_VertScratch[0].x + (s_VertScratch[1].x - s_VertScratch[0].x) * ratio; } // 生成网格 toFill.Clear(); AddQuad(toFill, ...); }4.3 编辑器扩展
为了让组件更易用,我们还需要创建一个自定义编辑器:
[CustomEditor(typeof(ExtendImage), true)] public class ExtendImageEditor : ImageEditor { private SerializedProperty m_SlicedClipMode; protected override void OnEnable() { base.OnEnable(); m_SlicedClipMode = serializedObject.FindProperty("m_SlicedClipMode"); } public override void OnInspectorGUI() { base.OnInspectorGUI(); if ((Image.Type)m_Type.enumValueIndex == Image.Type.Filled) { EditorGUILayout.PropertyField(m_SlicedClipMode); } } }5. 性能优化与注意事项
在实现自定义UI组件时,性能是需要重点考虑的因素。以下是几个优化建议:
顶点计算优化:
- 尽量减少不必要的计算
- 复用中间计算结果
- 使用局部变量而非频繁访问属性
网格生成优化:
- 只生成必要的三角形
- 避免频繁调用
VertexHelper.Clear() - 使用对象池管理VertexHelper
内存管理:
- 避免在每帧创建新的数组
- 使用静态数组减少GC压力
- 合理使用缓存
// 使用静态数组减少GC private static readonly Vector2[] s_VertScratch = new Vector2[4]; private static readonly Vector2[] s_UVScratch = new Vector2[4];在实际项目中,我发现最常遇到的问题是不正确的边界计算。特别是在处理不同分辨率和像素密度时,需要特别注意:
提示:在计算九宫格边界时,一定要考虑pixelsPerUnit的影响,否则在不同分辨率的设备上可能会出现显示异常。
另一个常见问题是填充方向的处理。我们的ExtendImage组件目前只实现了水平和垂直方向的裁剪,如果需要支持径向填充,还需要扩展算法:
// 径向填充的伪代码 if (fillMethod == FillMethod.Radial90) { // 计算角度 float angle = Mathf.Lerp(0, 90, fillAmount); // 根据角度裁剪顶点 // ... }6. 扩展应用与进阶技巧
掌握了Image组件的核心原理后,我们可以进一步扩展其功能:
- 渐变填充:在顶点颜色中添加渐变效果
- 扭曲效果:通过修改顶点位置实现扭曲动画
- 自定义遮罩:结合Shader实现特殊遮罩效果
以下是一个简单的渐变填充实现思路:
// 渐变填充的顶点颜色设置 Color32 color0 = new Color32(255, 0, 0, 255); // 起始颜色 Color32 color1 = new Color32(0, 255, 0, 255); // 结束颜色 vertexHelper.AddVert(new Vector3(posMin.x, posMin.y, 0), color0, uvMin); vertexHelper.AddVert(new Vector3(posMin.x, posMax.y, 0), color0, new Vector2(uvMin.x, uvMax.y)); vertexHelper.AddVert(new Vector3(posMax.x, posMax.y, 0), color1, uvMax); vertexHelper.AddVert(new Vector3(posMax.x, posMin.y, 0), color1, new Vector2(uvMax.x, uvMin.y));对于更复杂的效果,可以结合Shader来实现。例如,创建一个支持UV动画的Image组件:
public class AnimatedImage : Image { public Vector2 uvAnimationSpeed = Vector2.zero; protected override void OnPopulateMesh(VertexHelper vh) { base.OnPopulateMesh(vh); UIVertex vertex = new UIVertex(); for (int i = 0; i < vh.currentVertCount; i++) { vh.PopulateUIVertex(ref vertex, i); vertex.uv0 += uvAnimationSpeed * Time.unscaledTime; vh.SetUIVertex(vertex, i); } } }在实际项目中,我发现理解UGUI的底层原理不仅能帮助我们解决具体问题,还能激发更多创意。比如,通过分析Image组件的实现,我们可以更好地理解:
- Unity的UI渲染管线
- 网格生成与优化的技巧
- 性能瓶颈的分析方法
- 自定义组件的设计模式