Unity UGUI九宫格填充图像组件开发实战

📅 发布时间:2026/8/9 17:26:26
Unity UGUI九宫格填充图像组件开发实战 1. 项目概述为什么我们需要一个支持九宫格的Filled Image在Unity UGUI的开发中Image组件的Filled模式是制作进度条、技能冷却、血条等动态填充效果的绝对主力。它简单、高效通过一个fillAmount参数就能驱动一切。然而但凡做过稍微精致一点的UI尤其是那些带有精美边框、圆角或者特殊纹理的进度条时你肯定踩过这个坑当fillAmount小于1时填充区域会直接裁剪原始图片导致精美的边框或圆角被生硬地切断视觉效果惨不忍睹。这就是UGUI Image组件一个长期存在的“痛点”Filled模式不支持九宫格Sliced渲染。官方提供的Sliced模式虽然能完美解决拉伸变形问题但它是一个静态的、完整的显示无法进行百分比填充。于是我们常常陷入两难要动态填充就得忍受丑陋的裁剪要保持边框美观就只能用静态图片或者写复杂的Shader。网络上常见的“歪招”是使用两个Image叠加一个Sliced模式做底图显示完整边框一个Filled模式做上层填充。但这带来了新的问题层级管理麻烦、额外的Draw Call、填充区域与底图边框难以精确匹配尤其是当填充方向非从左到右时对齐更是噩梦。所以这个项目的核心目标非常明确扩展UGUI的Image组件创造一个新的组件我们姑且称之为SlicedFilledImage让它同时继承Filled模式的动态填充能力和Sliced模式的九宫格抗拉伸能力。最终实现一个既能按百分比填充又能保持四角与边框不变形的“完美”进度条。这不仅仅是解决一个具体问题更是深入理解UGUI渲染流程、Mesh生成以及组件扩展的绝佳实践。2. 核心原理与架构设计要解决这个问题我们不能简单地修改Unity源码而是需要在理解其工作原理的基础上进行合理的扩展。UGUI的渲染核心是CanvasRenderer和由Graphic类Image的父类生成的网格Mesh。2.1 UGUI Image渲染流程拆解标准的Image组件继承自MaskableGraphic在需要更新时如fillAmount改变会调用OnPopulateMesh方法向VertexHelper中填充顶点和三角形数据。Image类根据不同的ImageTypeSimple, Sliced, Tiled, Filled实现了不同的网格生成逻辑。Sliced模式将Sprite的九宫格信息通过Sprite的border属性定义映射到网格生成中。它把图像分成9个格子四个角、四个边、一个中心在缩放UI矩形时只拉伸四个边的格子而保持四个角的格子原始比例。其OnPopulateMesh方法会生成9个独立的四边形网格。Filled模式根据fillMethod水平、垂直、径向等、fillOrigin和fillAmount计算出一个裁剪区域然后只生成位于该裁剪区域内的三角形。它本质上是先按Simple模式生成完整网格再进行几何裁剪。问题的根源就在于Filled模式的裁剪计算是建立在Simple模式的网格基础上的它完全没有考虑九宫格的网格结构。当它一刀切下去时自然就会把某个“格子”切碎。2.2 我们的解决方案融合与重构我们的思路不是去修改Unity内置的、复杂的填充裁剪算法而是**“曲线救国”**继承与组合我们创建一个新的类SlicedFilledImage继承自Image。这样我们天然拥有了所有Image的属性。重写核心方法关键步骤是重写OnPopulateMesh方法。在这个方法里我们将接管网格的生成过程。分而治之的网格生成步骤一生成完整九宫格网格。我们首先忽略fillAmount像标准的Sliced模式一样生成一个完整的、由9个子四边形构成的网格。每个四边形有4个顶点共36个顶点。我们需要记录每个顶点对应的UV、颜色以及它属于哪个“格子”如左上角、上边、中心等。步骤二应用填充裁剪。根据fillMethod,fillOrigin,fillAmount计算出一个裁剪边界比如从左到右填充时就是一个垂直于X轴的平面。然后我们不是对整个大网格进行裁剪而是对这9个子四边形逐个进行判断和裁剪。步骤三顶点处理与重组。对于每个子四边形如果它完全在填充区域内则保留它的所有顶点和三角形。如果它完全在填充区域外则丢弃它。如果它被填充边界穿过则需要对这个四边形进行三角剖分生成新的顶点和三角形以精确匹配裁剪后的形状。这是本项目的技术难点。这个方案的优点是逻辑清晰与UGUI原有设计哲学吻合基于网格操作。缺点是需要在CPU端进行一些几何计算但考虑到UI更新的频率和顶点数量最多几十个性能开销完全可以忽略不计。3. 关键实现步骤详解下面我们进入实战环节一步步实现SlicedFilledImage。3.1 创建组件与基础框架首先在Unity中创建一个新的C#脚本命名为SlicedFilledImage.cs。using UnityEngine; using UnityEngine.UI; using System.Collections.Generic; [AddComponentMenu(UI/Sliced Filled Image, 11)] public class SlicedFilledImage : Image { // 我们暂时不需要添加新的Serialized属性所有属性从父类Image继承。 // fillAmount, fillMethod, fillOrigin等直接使用。 protected override void OnPopulateMesh(VertexHelper vh) { // 清空传入的VertexHelper vh.Clear(); // 如果没有任何Sprite或者FillAmount为0直接返回空网格 if (activeSprite null || fillAmount 0.001f) return; // 调用我们的自定义网格生成方法 GenerateSlicedFilledMesh(vh); } private void GenerateSlicedFilledMesh(VertexHelper vh) { // 核心实现将在这里完成 } }[AddComponentMenu]属性让我们能在Unity的“Component/UI”菜单中找到它方便使用。3.2 计算九宫格几何数据在生成网格前我们需要根据Sprite的border和当前RectTransform的尺寸计算出9个格子的屏幕空间坐标和UV坐标。这是Sliced模式的标准计算我们需要将其复现出来。private void GenerateSlicedFilledMesh(VertexHelper vh) { // 1. 获取Sprite的像素尺寸和九宫格边框像素单位 Vector4 spriteBorder activeSprite.border; Rect spriteRect activeSprite.rect; // 2. 将像素边框转换为UV比例边框 (0~1) Vector4 border new Vector4( spriteBorder.x / spriteRect.width, spriteBorder.y / spriteRect.height, spriteBorder.z / spriteRect.width, spriteBorder.w / spriteRect.height ); // 3. 获取Image在Canvas中的绘制矩形以像素为单位已考虑scaleFactor Rect rect GetPixelAdjustedRect(); Vector2 rectSize rect.size; // 4. 计算九宫格在屏幕空间的位置 // 原则四个角的大小固定由Sprite的border和当前像素PerUnit决定四个边和中心进行拉伸。 // 但需要注意如果UI矩形小于Sprite的角部尺寸之和需要压缩角部。 Vector4 adjustedBorder GetAdjustedBorders(border, rectSize); // GetAdjustedBorders 方法需要处理矩形过小的情况确保角部不重叠。 // 5. 定义9个格子的屏幕空间顶点位置 // 从左到右从下到上的X、Y分界值 float xLeft rect.xMin; float xMiddleLeft rect.xMin adjustedBorder.x; float xMiddleRight rect.xMax - adjustedBorder.z; float xRight rect.xMax; float yBottom rect.yMin; float yMiddleBottom rect.yMin adjustedBorder.y; float yMiddleTop rect.yMax - adjustedBorder.w; float yTop rect.yMax; // 类似地计算9个格子的UV坐标分界值... Vector4 uvBorder border; // 这里border已经是UV比例了 float uvLeft 0f; float uvMiddleLeft uvBorder.x; float uvMiddleRight 1f - uvBorder.z; float uvRight 1f; float uvBottom 0f; float uvMiddleBottom uvBorder.y; float uvMiddleTop 1f - uvBorder.w; float uvTop 1f; // 现在我们有了生成9个独立四边形所需的所有边界数据。 }这里的关键是GetAdjustedBorders方法它确保了当UI控件非常小时九宫格的角不会相互重叠甚至超出范围。Unity内置的Image组件也有类似的逻辑。3.3 实现填充裁剪算法这是最核心的部分。我们需要遍历9个格子对每个格子应用填充裁剪。以最常用的FillMethod.Horizontal和FillOrigin.Left为例// 假设我们只处理 Horizontal Left 填充其他模式原理类似 if (fillMethod FillMethod.Horizontal fillOrigin 0) // 0通常代表Left { // 计算填充边界在X轴上的位置 float fillWidth rect.width * fillAmount; float fillBoundaryX rect.xMin fillWidth; // 定义9个格子的位置和UV数组简化表示实际应用需用循环或更结构化的方式 ListVector3 posList new ListVector3(); ListVector2 uvList new ListVector2(); Listint triangleList new Listint(); // 遍历9个格子i代表行j代表列 for (int i 0; i 3; i) { for (int j 0; j 3; j) { // 计算当前格子的四个顶点的屏幕坐标和UV float x0, x1, y0, y1, u0, u1, v0, v1; // ... 根据i,j索引从之前计算的边界数组中取值 ... // **关键判断**当前格子与填充边界的位置关系 if (x1 fillBoundaryX) { // 格子完全在填充区内添加完整四边形两个三角形 AddQuad(posList, uvList, triangleList, ...); } else if (x0 fillBoundaryX) { // 格子完全在填充区外跳过 continue; } else { // 格子被边界穿过需要裁剪 // 计算裁剪比例 float clipRatio (fillBoundaryX - x0) / (x1 - x0); // 生成一个被裁剪的四边形可能是梯形或三角形 AddClippedQuad(posList, uvList, triangleList, ..., clipRatio, true); } } } // 最后将posList, uvList, colorList, triangleList的数据填充到VertexHelper vh中 PopulateVertexHelper(vh, posList, uvList, triangleList); }AddClippedQuad方法是另一个难点。对于一个被垂直边界穿过的四边形我们需要将其拆分为两个部分保留左侧部分。这意味着我们可能需要生成3个、4个或5个顶点形成一个多边形然后对这个多边形进行三角剖分Triangulation。对于矩形最简单的三角剖分就是将其分为两个三角形但被裁剪后可能是一个五边形需要更通用的算法。为了简化我们可以强制要求被裁剪的格子生成一个由两个三角形组成的凸四边形如果裁剪比例不是0或1这在实际视觉效果上是可以接受的。3.4 处理其他填充方法与原点上面的例子只处理了Horizontal Left。一个完整的组件需要支持Image组件所有的FillMethod(Horizontal, Vertical, Radial90, Radial180, Radial360)和FillOrigin。这意味着我们需要为每种组合编写特定的边界判断和裁剪逻辑。Horizontal/Vertical相对简单边界是一条直线。Radial边界是一条射线或一个扇形区域。判断一个点是否在填充区域内需要计算该点与填充原点的角度和距离比例。对九宫格格子的裁剪也会更复杂可能需要对格子进行更精细的三角剖分。一个实用的建议是在初期可以先完美实现最常用的Horizontal和Vertical填充这对于99%的进度条、血条场景已经足够。Radial填充的实现可以作为高级特性后续迭代。4. 性能优化与实用技巧直接生成36个顶点并逐个判断裁剪在性能上没有问题。但我们可以做得更好。4.1 顶点缓存与脏标记UI元素在每帧都可能因为布局变化而重建网格。我们的OnPopulateMesh会被频繁调用。如果fillAmount没有变化且RectTransform的尺寸没有变化我们其实不需要重新计算网格。我们可以借鉴UGUI的优化思路引入脏标记系统private bool _isDirty true; private ListUIVertex _cachedVertices new ListUIVertex(); private Listint _cachedTriangles new Listint(); protected override void OnPopulateMesh(VertexHelper vh) { if (activeSprite null || fillAmount 0.001f) { vh.Clear(); return; } // 检查是否需要更新缓存 if (_isDirty || !_lastRect.Equals(GetPixelAdjustedRect()) || Mathf.Abs(_lastFillAmount - fillAmount) 0.001f) { RegenerateMeshCache(); _lastRect GetPixelAdjustedRect(); _lastFillAmount fillAmount; _isDirty false; } // 从缓存中填充数据到vh vh.Clear(); if (_cachedVertices.Count 0) { vh.AddUIVertexStream(_cachedVertices, _cachedTriangles); } } private void RegenerateMeshCache() { _cachedVertices.Clear(); _cachedTriangles.Clear(); // ... 调用之前的网格生成逻辑但将结果存入 _cachedVertices 和 _cachedTriangles ... } // 在fillAmount等属性变化时标记为脏 public override void SetAllDirty() { base.SetAllDirty(); _isDirty true; }4.2 与Mask和RectMask2D的兼容性我们的组件继承自Image自然也继承自MaskableGraphic。这意味着它天然支持Mask和RectMask2D组件。但是由于我们自定义了网格需要确保裁剪后的网格在Mask的裁剪区域内能正确工作。幸运的是Unity的Mask是在GPU端基于Stencil Buffer实现的只要我们的顶点数据正确提交就不会有问题。RectMask2D是CPU端的简单矩形裁剪我们的网格本身就已经是裁剪后的所以也不会有冲突。4.3 在Editor中的预览为了让组件在Unity编辑器场景视图和Inspector中实时预览fillAmount的变化我们需要在OnValidate方法或属性Setter中标记网格为脏。protected override void OnValidate() { base.OnValidate(); if (IsActive()) { SetVerticesDirty(); // 这会触发OnPopulateMesh的调用 } } // 或者如果我们将fillAmount等属性封装可以在setter中调用 public float customFillAmount { get { return m_FillAmount; } set { if (SetPropertyUtility.SetStruct(ref m_FillAmount, Mathf.Clamp01(value))) { SetVerticesDirty(); } } }5. 使用指南与效果对比实现完成后使用起来和原生Image组件一样简单。导入将SlicedFilledImage.cs脚本放入项目。准备素材准备一张带有边框的九宫格Sprite。在Sprite Editor中设置好Border边框。创建UI在Hierarchy中创建UI - Image然后将它的脚本从Image替换为SlicedFilledImage。或者通过菜单Component/UI/Sliced Filled Image直接添加。配置在Inspector中将Image Type设置为Filled我们的组件内部会忽略这个选择但保持Filled以使用其参数。设置Fill Method、Fill Origin然后动态修改Fill Amount。效果对比原生Filled Image填充时边框被直接切断角部变形。SlicedFilledImage填充时只有中心和平移的边被拉伸填充四个角的边框始终保持原始形状和比例视觉效果完美。6. 常见问题与排查填充区域边缘出现“白线”或锯齿原因裁剪计算时顶点位置或UV的精度问题导致生成的三角形之间有微小的缝隙。解决在计算裁剪后顶点的位置时可以稍微向外扩展一个极小的值如0.001像素确保三角形之间略有重叠。或者在Shader中允许轻微的过度绘制。Radial填充模式下角落显示异常原因Radial填充的边界是曲线我们对九宫格格子的“完全在内/外”判断可能不准确或者三角剖分在曲线边界处不够平滑。解决对于Radial填充可以采用更保守的策略。如果一个格子有任何部分在填充区内就保留整个格子但将其顶点颜色Alpha值根据该顶点在填充区内的比例进行渐变。这虽然不够精确但能避免复杂的曲线裁剪视觉效果在大多数情况下可以接受。性能开销比想象中大原因每帧都因为微小的布局变化而触发完整的网格重建。解决确保实现了上面提到的顶点缓存机制。检查是否在Update中频繁修改fillAmount可以考虑在值变化超过一定阈值时才标记为脏。与动画系统Animator、DOTween结合时填充不更新原因动画系统直接修改fillAmount字段可能绕过了我们属性Setter中的SetVerticesDirty()调用。解决重写fillAmount的Setter确保任何对其的修改都能触发网格重建。同时在LateUpdate中检查fillAmount是否被外部修改。private float _lastCachedFillAmount; void LateUpdate() { if (!Mathf.Approximately(_lastCachedFillAmount, fillAmount)) { SetVerticesDirty(); _lastCachedFillAmount fillAmount; } }这个项目从提出痛点到分析原理再到动手实现和优化是一个完整的Unity UGUI深度定制案例。它不仅仅给出了一个可用的组件更重要的是展示了如何深入引擎内部理解其设计并在此基础上进行创新扩展。下次当你再面对那个丑陋的进度条时你完全可以自信地说这个问题我已经解决了。