ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

UGUI自定义Graphic:原理拆解与环形进度条实现

UGUI自定义Graphic:原理拆解与环形进度条实现

做Unity UI开发的朋友应该都遇到过这种场景:UI设计稿里画了一个带缺口的圆环、一个六边形技能图、一条带渐变的曲线,翻遍UGUI自带组件发现要么拼不出来,要么拼出来一团糟。这时候就该自己动手,写一个继承Graphic的自定义组件,也就是大家常说的“自定义Graphic”。这篇文章直接从原理讲到代码实现,完整拆解自定义Graphic的核心链路,适合已经写过UGUI交互、但对底层网格绘制比较陌生的开发者。我不打算把API文档念一遍,只讲实际项目里真正用得到的那部分,顺便把踩过的几个坑都标出来。

1. 自定义Graphic到底在解决什么问题

1.1 UGUI里的Graphic到底是什么

UGUI所有可见UI组件,Image、Text、RawImage,往上追三层,父类全是Graphic。Graphic的本质,是把“一段可见内容”抽象成“网格数据+材质+射线判定”的载体。UGUI的Canvas系统不管你这块内容是文字还是图片,只知道一个GameObject上有一个CanvasRenderer、一份顶点数据,然后统一合批进同一个CanvasBatch。

官方注释里Graphic被描述为“所有图形UI元素的基类”,这个描述其实已经把事情说完了。很多人在编辑器里看到Button的targetGraphic字段,类型就是Graphic,原因就在这里:Button做交互反馈、触发过渡效果,只需要操作Graphic的颜色属性,不需要关心具体是Image还是Text。

这里要注意一个使用禁忌:一个GameObject上不能同时存在两个驱动网格的可见组件,比如Image和自定义Graphic不能共存。如果同时出现,编辑器会给出提示,渲染结果通常是按其中一个组件出,另一个被整个禁用。我之前接手过一个半成品的任务界面,就是既有Image又有排布出来的自定义Graphic,结果图形区域直接白屏,排查了很久才发现两个Graphic在互相抢渲染入口。这个坑在多人协作的工程里特别常见,所以看到Graphic类时先确认同物体上没有多余组件。

1.2 用组件拼和自绘的差距到底在哪里

还是拿“带缺口的圆环进度条”举例。用Image做,常规路线是准备一张圆环贴图,再配合fillAmount的径向填充,或者用Mask做裁剪。单看效果,一套素材伺候一个固定大小、固定宽度的环问题不大。但换几个场景就露馅:环的宽度要跟着UI缩放变化、颜色要沿圆周渐变、缺口位置要动态指定、还要在高分屏下保证边缘不发虚。每加一个需求,贴图方案就要多叠一层,DrawCall和内存也肉眼可见地上涨。

自绘方案完全不一样:圆环的几何形状是代码实时生成的,宽度由参数控制,颜色可以直接在顶点上做渐变,缩放之后重新算一遍顶点就够了,不需要任何贴图。一个64段的圆环,顶点数只有256个,索引384个,这个体量在UI合批里基本可以忽略不计。对比之下,一张1024分辨率的九宫格贴图光纹理内存就有不少开销,还要考虑mipmap、GPU纹理采样成本。

这是选择“拼贴图”还是“画网格”的分水岭:凡是几何体本身能数学描述的,比如圆、多边形、折线、网格渐变,都更适合自绘;凡是纯美术风格纹理,比如噪点、笔刷质感、复杂光晕,才适合继续用Image加纹理。我做某跨平台系统里的技能面板时,六个技能图标外圈的冷却环、血条的渐变底、雷达图背景,全部换成了自定义Graphic,最小的模块只有几十行代码,但效果比贴图方案干净得多,尤其是圆环边缘,不再有压缩贴图带来的马赛克抖动。

1.3 什么情况下果断选择自定义Graphic

列几个我实际判断过的场景:

  • 需要实时变化的几何图形,比如进度环、仪表盘、波形图,参数每帧变,几何体本身也在变。
  • 需要颜色渐变或者顶点级颜色的UI,用贴图做很难覆盖任意角度、任意组合的渐变需求,在顶点上一改坐标颜色就出来了。
  • 需要精确点击区域的UI,比如环、扇形、多边形按钮,Graphic允许你重写射线判定,Image默认只能提供矩形命中区。
  • 需要跟Mask、RectMask2D、合批、Canvas重建机制完全兼容,任何纯Shader方案在这个维度都差一截。
  • 不想新增DrawCall,也不想依赖额外贴图资源的情况。

反之,如果只是想显示一张不变又有丰富纹理的界面图,老老实实用Image,别自找麻烦。Graphic自绘适合几何、数据、交互强相关的组件,不适合贴图美术内容。判断标准就这么简单:能用数学描述形状,并且形状本身需要动态变化,就自绘;否则用现成组件。

2. 写之前必须搞懂的三件事

2.1 Graphic、CanvasRenderer、VertexHelper的分工

很多人写自定义Graphic,一上来就找OnPopulateMesh,往里面塞顶点,结果卡了半天,原因是对三个类的分工不清晰。

Graphic是策略层。它负责对外暴露属性、触发Vertex的刷新标记、决定mesh何时重建。它不关心CanvasRenderer怎么把数据画出来。

CanvasRenderer是执行层。Graphic生成的mesh交给它,它负责上传、合批、提交给Canvas最终渲染。这个类大多数情况你不需要直接调方法,只需要确保Graphic的mesh数据是合法的。

VertexHelper是数据层。它在OnPopulateMesh里作为一个工具类出现,封装了顶点列表和索引列表的操作。你往里面AddVert一个顶点,AddTriangle一个三角形,最后它把数据灌进Graphic的mesh。VertexHelper内部维护了一个顶点列表和一个索引列表,AddTriangle的参数是整数索引,指向上一步添加的顶点序号。所以每次添加顶点之后,最新顶点的索引就是vh.currentVertCount - 1,这个细节是排查花屏问题的关键。

UIVertex则是顶点数据的具体格式,包含position、color、uv0以及normal、tangent等字段。对于纯色UI组件,只需要关心position和color,uv0可以给默认的Vector2.zero。从工程实现上说,OnPopulateMesh就是你唯一需要override的入口。系统认为网格需要重建时,会自动调用它,你只需要在方法里先vh.Clear()再按自己的算法填充。

2.2 OnPopulateMesh的触发时机与刷新方式

Graphic继承自ICanvasElement,Canvas系统在布局和渲染前会检查哪些元素dirty,dirty的就调用OnPopulateMesh重建网格。在实际项目里,下面这些操作都会触发重建:

  • 修改Graphic的color属性。
  • 调用SetVerticesDirty()或SetAllDirty()。
  • RectTransform尺寸变化,基类的OnRectTransformDimensionsChange会标记网格失效。
  • 组件从禁用变为启用。
  • Canvas的尺寸或分辨率发生变化。

这带来一个非常自然的用法:当你改了Graphic的任意参数,比如fillAmount从0.5变成0.6,就调用一次SetVerticesDirty(),Canvas系统会在下一帧重新构建网格。不要直接去改CanvasRenderer的mesh,也不用自己new一个Mesh再赋值,这是新手常犯的错误。

另一个经验是别为了省一次调用而自己维护mesh。Graphic已经帮你管理了mesh生命周期,你只要保证OnPopulateMesh里每次都是从头开始clear再填充,结果一定是干净的。如果在这种回调里做了增量式添加,比如不清空直接AddVert,顶点会越积越多,截图、阴影、Mask都会出问题。我见过有人为了性能缓存顶点,结果改一次参数画出来四条环叠在一起,排查时一脸懵。

2.3 RectTransform本地坐标与像素对齐

OnPopulateMesh里使用的位置是RectTransform本地坐标,不是世界坐标也不是屏幕坐标。RectTransform的rect提供了xMin、xMax、yMin、yMax、width、height、center这些字段,且这些值都是相对于pivot的偏移量。

最容易被坑的是pivot。默认UI的pivot是(0.5, 0.5),rect.center刚好是零点。如果美术在编辑器里把pivot拖到了(0, 0),rect的范围就变成从(0,0)到(width,height)。这时候你要画一个圆心在中心的圆,就得自己计算rect.center,否则画出来的图形会朝某个方向偏移一大截。

像素对齐方面,UGUI的Graphic提供了GetPixelAdjustedRect()方法,它在Canvas的pixel snap开启时会把rect调整到像素网格上。对于精确的图形边缘,尤其是直线、边框,可以在计算顶点之前先取一次调整后的rect。不过对于动态动画组件,我建议还是直接用rectTransform.rect,因为它不会引入额外的小数抖动,动画更平滑。如果要做静止的UI图标,反而可以开pixel snap。各有取舍,大家按场景选。

3. 环形进度条:第一个自绘组件的完整实现

3.1 思路:把圆环拆成四边形扇带

环形进度条的形状很好描述:由内圆半径innerR和外圆半径outerR夹出的一个环形区域,从某个起始角开始扫过fillAmount乘360度的弧。程序画这种几何图形,方法就是把弧线均匀切成N段,每一段用四个顶点组成一个四边形,每个四边形再加两个三角形。

为什么按段数逐个四边形而不是一次性生成所有顶点再统一三角化?因为环形是一个有洞的形状,统一三角化要处理内外边界和孔洞,算法复杂多了。按圆弧切分成小四边形,每段的两个边界都落在半径方向上,天然就是合法四边形,不需要额外的多边形三角化算法。只要段数足够多,拼起来视觉上就是光滑的圆环。

关于段数,圆弧部分一般64段足够,直径特别大的UI可以上128段。少于32段会看到明显的折线。这里的性能压力很小:128段的环形,满打满算五百多个顶点,在Canvas的网格里是个小数目。

3.2 可直接复制的环形进度条代码

using UnityEngine; using UnityEngine.UI; public class RingProgress : Graphic { [Range(0f, 1f)] public float fillAmount = 0.75f; [Range(0.2f, 0.95f)] public float innerRatio = 0.7f; [Range(4, 256)] public int segments = 64; public Color startColor = new Color(0.2f, 0.8f, 1f, 1f); public Color endColor = new Color(0.1f, 0.2f, 1f, 1f); private const float StartAngle = -Mathf.PI * 0.5f; protected override void OnPopulateMesh(VertexHelper vh) { vh.Clear(); Rect rect = rectTransform.rect; if (rect.width <= 0 || rect.height <= 0) return; float outerR = Mathf.Min(rect.width, rect.height) * 0.5f; float innerR = outerR * innerRatio; Vector2 center = rect.center; float sweep = fillAmount * Mathf.PI * 2f; float step = sweep / segments; for (int i = 0; i < segments; i++) { float a0 = StartAngle + i * step; float a1 = StartAngle + (i + 1) * step; if (a1 > StartAngle + sweep) a1 = StartAngle + sweep; float t0 = (float)i / segments; float t1 = (float)(i + 1) / segments; Color c0 = Color.Lerp(startColor, endColor, t0); Color c1 = Color.Lerp(startColor, endColor, t1); int i0 = AddUiVertex(vh, center + ToVec(a0) * outerR, c0); int i1 = AddUiVertex(vh, center + ToVec(a1) * outerR, c1); int i2 = AddUiVertex(vh, center + ToVec(a1) * innerR, c1); int i3 = AddUiVertex(vh, center + ToVec(a0) * innerR, c0); vh.AddTriangle(i0, i1, i2); vh.AddTriangle(i0, i2, i3); } } private static int AddUiVertex(VertexHelper vh, Vector2 pos, Color color) { UIVertex vertex = new UIVertex { position = pos, color = color, uv0 = Vector2.zero }; vh.AddVert(vertex); return vh.currentVertCount - 1; } private static Vector2 ToVec(float rad) { return new Vector2(Mathf.Cos(rad), Mathf.Sin(rad)); } }

这份代码在Inspector里调整参数就能看到效果,因为Graphic基类在OnValidate里会触发SetVerticesDirty,编辑器会重建网格。

需要注意,Inspector修改会触发OnValidate,但是运行时通过代码直接改fillAmount却不会自动刷新,你需要在setter里或Update里手动调用SetVerticesDirty。很多新人第一次写这个,把fillAmount改了没反应,然后怀疑组件坏了,其实只是没有把修改通知给Canvas系统。凡是运行时修改画图参数,记得补一句SetVerticesDirty()。

3.3 颜色渐变和端点的处理细节

上面的代码里,颜色是按当前段的下标比例从startColor渐变到endColor,这是“沿圆周方向渐变”。实现成本几乎是零,只需要把Lerp的因子作为顶点的颜色传给UIVertex。UI默认着色器会把这个顶点颜色直接乘到最终输出上,所以不需要新增任何Shader。

如果你想要“径向渐变”,也就是圆环内侧浅、外侧深,就把渐变方向从“段之间的变化”改成“内外边缘之间的变化”,每个quad的四个顶点分别用innerColor和outerColor。这是顶点色的灵活之处,同一个网格,换一下顶点颜色分配方式,视觉风格就完全不同。我建议初学时把这两种渐变都试一下,会很快建立对UIVertex.color的体感。

端点处理是一个容易被忽略的细节。上面的循环在最后一段用a1 = StartAngle + sweep强行把角度夹到终点,实际上绘制是安全的。但如果要做比较精致的环,起点和终点不应该像刀切一样平,两端应该画成半圆帽。做法是在起点和终点各生成一个小半圈,用多个顶点拼一个胶囊状的盖帽。代码会复杂一点,但视觉效果差别很大。特别是做技能冷却环的时候,平切端点会显得很“程序生成”,客户第一眼就会挑刺。

3.4 交互判定:点击只有落在环上才算数

默认情况下,Graphic的raycast实现是矩形判定,也就是说你画了一个圆环,实际上鼠标点在圆环的四个角上也能触发点击。这对于进度环本身问题不大,但如果你要做一个圆环冷却技能的点击按钮,这个矩形判定就会让交互区域大出一圈,误触很严重。

要改成精确命中,override Raycast方法:

public override bool Raycast(Vector2 sp, Camera eventCamera) { if (!base.Raycast(sp, eventCamera)) return false; Rect rect = rectTransform.rect; Vector2 local; if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( rectTransform, sp, eventCamera, out local)) return false; Vector2 offset = local - rect.center; float dist = offset.magnitude; float outerR = Mathf.Min(rect.width, rect.height) * 0.5f; float innerR = outerR * innerRatio; if (dist < innerR || dist > outerR) return false; float angle = Mathf.Atan2(offset.y, offset.x); float normalized = (angle + Mathf.PI * 0.5f) / (Mathf.PI * 2f); if (normalized < 0f) normalized += 1f; return normalized <= fillAmount; }

这段代码和OnPopulateMesh必须保持一致的坐标系和起始角度。如果OnPopulateMesh里从别的角度起画,Raycast里的角度基准也要改,否则会出现“图形在右边,点击判定在左边”的诡异现象。我在项目里就因为把起始角从-pi/2改成0忘改Raycast,被测试提了一个“点不到按钮又觉得能点到”的bug,最后对着网格画了半天才想起来。

4. 进阶:雷达图那种多边形怎么画

4.1 数据转顶点:多边形扇形三角化

环形是规则几何体,雷达图则更像“多边形”。雷达图每个轴一个维度,每个维度有自己的数值,所有相邻轴的点围成一个封闭多边形。要把这个多边形写成网格,最简单的方法是以雷达图中心为公共顶点,把多边形切成一系列三角形,每个三角形由中心、当前轴顶点、下一个轴顶点组成。三角化的术语叫fan triangulation,处理凸多边形非常可靠。

先把每个维度的数值归一化成0到1的半径缩放比例:

Vector2[] pts = new Vector2[n]; float radius = Mathf.Min(rect.width, rect.height) * 0.5f - borderWidth; for (int i = 0; i < n; i++) { float angle = Mathf.PI * 2f * i / n - Mathf.PI * 0.5f; pts[i] = center + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius * Mathf.Clamp01(values[i]); }

然后对每个三角形调用AddTriangle即可。如果其中一个维度的数值是0,对应的顶点会和中心重合,形成一个退化三角形。退化三角形在渲染上没有任何可见面积,性能影响也极小,通常不需要额外处理。但如果你的雷达图允许凹多边形,fan三角化就不再可靠,会出现三角形覆盖错误,这时需要更通用的多边形三角化或者用shader处理,那是另一个话题。

4.2 填充和边框一次画完

雷达图通常不只是填充,还要描边、画网格线。网格线和外圈网格如果单独用LineRenderer或者额外Image,合批就是问题。更好的做法是把边框的每条线段也做成四边形,塞进同一个VertexHelper。这样雷达图整体是一个Graphic、一个Mesh、一次合批。

线段转四边形的方法很直接:取线段的法线方向,向两侧各偏移thickness/2,形成一个矩形。核心代码如下:

private void AddLine(VertexHelper vh, Vector2 a, Vector2 b, float thickness, Color color) { Vector2 dir = b - a; float len = dir.magnitude; if (len < 0.001f) return; Vector2 normal = new Vector2(-dir.y, dir.x) / len * thickness * 0.5f; int i0 = AddUiVertex(vh, a - normal, color); int i1 = AddUiVertex(vh, a + normal, color); int i2 = AddUiVertex(vh, b + normal, color); int i3 = AddUiVertex(vh, b - normal, color); vh.AddTriangle(i0, i1, i2); vh.AddTriangle(i0, i2, i3); }

这个做法有个视觉上的小瑕疵:多段线段在拐角处会有接缝,因为每段的端面是垂直于该段方向的,两段在角点处没法完美拼合。要彻底解决,需要在角点按两条相邻边的角平分线扩展,生成一个尖角。这个优化代码量翻一倍,但对于雷达图这么细的线来说,很小的接缝肉眼基本看不出来,可以先不管。真正需要尖角的场景是粗边框的六边形按钮,内容可以单独展开。

4.3 动态更新和性能优化的思路

雷达图多数时候数据会动态刷新,比如属性变化、加点升级。每次刷新只需要调用SetVerticesDirty(),OnPopulateMesh就会重新算所有顶点。数据量不大时,每帧刷新都没压力。

但如果雷达图只是某一个值每秒闪动,而外圈网格、背景网格都不变,每次刷新就重建全部顶点就浪费了。推荐的拆分方式是:把静态背景和动态数据拆成两个Graphic子组件。背景网格的外圈多边形和刻度线永远不变,挂一个不依赖数值的RadarGridGraphic,从不标记dirty。动态数据多边形挂一个RadarDataGraphic,只在数值变化时调用SetVerticesDirty()。两个组件叠在同一RectTransform上,渲染合批可能分成两批,但重建范围大大缩小。这种“把不变的部分隔离出来”的思路,比我见过的一些同学用一个Graphic硬扛所有刷新要高效得多。

另外一个真实的性能坑是:动态刷新时,不要为计算临时数组频繁分配内存。比如上面代码里的pts数组,如果每帧OnPopulateMesh都新建,会有GC压力。直接用一个成员数组字段复用,或者在类里缓存List,数据变了再重新填。UI网格重建本身不慢,但配合每帧的数组分配,Profile里就会看到频繁的Alloc。这个细节在实际项目里很容易被忽略。

5. 踩过才知道的坑:常见问题与排查速查表

5.1 图形偏移、不显示、花屏

先说偏移。偏移基本都是坐标系没弄对。OnPopulateMesh里的所有position要作用在RectTransform的本地坐标系里,任何世界坐标、屏幕坐标直接塞进去都会导致图形跑飞。老经验是用Gizmos在编辑器里把rect的范围画出来,确认顶点坐标是否落在rect内。画出来的点全都在rect外面,基本就是坐标系错了。

不显示的原因要多查几个。第一,检查Graphic的color的alpha是不是0,很多人新建组件后忘了设颜色,顶点色是全透明;第二,检查CanvasRenderer的material是否有值,Graphic默认会用UI内置材质,但如果你在自定义材质上做文章,就很容易出现材质丢失导致网格不可见;第三,打开材质如果是UIShader但带了错误的混合状态,也会显示成黑块。建议不熟时先不要动材质,保持默认,等基本几何体画出来再考虑效果。

花屏或者边缘出现乱七八糟的三角形,八成是顶点索引和AddTriangle索引对不上。用手工写死索引的代码,一旦中间加了条件判断或换了AddVert顺序,索引就会错位。正确做法是我前面代码里那样,每AddVert一次之后取vh.currentVertCount - 1作为最新索引,避免自己数数。

5.2 点击区域、阴影和描边的坑

点击区域的坑前面已经讲了,默认矩形判定会带来很大误差。重写Raycast是正路。但要注意override之前先调用base.Raycast,它能帮你处理组件启停状态、CanvasRaycastFilter链、以及RectTransform矩形粗筛,漏掉这些会在某些特定布局下出现奇怪bug。

Shadow和Outline这类BaseMeshEffect,在自定义网格上经常失效或者形状异常。原因在于那些效果组件是通过复制网格里的顶点、沿某个方向偏移生成新的三角形。如果我们的网格是几十个互不连接的四边形,Shadow会把每个四边形独立偏移,看起来就像被打散的碎片。绕行方案:如果想给进度环加阴影,一种是用另一个半透明的环放在下层,视觉上模拟阴影;另一种是干脆把阴影作为网格的一部分自己画,至少你知道它在哪。直接挂UGUI自带的Shadow组件,效果很可能让你怀疑人生。

5.3 性能、Canvas重建、材质相关的取舍

最后整理几条常见性能问题的判定表:

症状常见原因正确处理
拖动进度时掉帧每帧SetVerticesDirty触发全量重建数值变化超过阈值才dirty,或拆静态动态两个组件
改了fillAmount没反应运行时改字段没触发SetVerticesDirtysetter里调用SetVerticesDirty或Update中检测变化
阴影碎成残片Shadow按四边形独立复制顶点用半透明同形组件模拟阴影,或手动画阴影
点击区域不匹配默认Raycast是矩形判定重写Raycast做几何判定
顶点索引花屏索引没有和AddVert顺序对应使用currentVertCount-1取最新索引

关于材质还有一个值得提的经验:不要为了让Shader跑起来就把Graphic的material设置为null。Graphic会通过materialForRendering去拿最终渲染材质,如果你手动清了材质,CanvasRenderer可能拿到空材质,画出来的东西就是不可见的。更稳妥做法是在OnPopulateMesh里只负责顶点数据,材质交给Canvas系统默认的UI/Default,等确实有特殊效果需要自定义shader时,再在Inspector里显式指定一个material资产。我见过有人为了解决Graphic颜色异常,直接把material清掉,结果整个UI组件在打包机上消失,找了一天。

另外,如果项目开了SRP或使用自定义渲染管线,UGUI的默认UI Shader可能会被替换。这时候自绘Graphic的默认材质行为也可能变化。排查思路是先切回内置渲染管线看图形是否正常,如果不正常就说明问题在Graphic自身,反之则说明是渲染管线切换带来的材质兼容问题。多测试几个渲染管线分支,比空看代码猜原因要快得多。

其实自定义Graphic的技巧就那么多,一旦把OnPopulateMesh当成一个普通的顶点输出函数来用,后面就都是几何学问题了。我个人最喜欢的调试方式,是在自定义Graphic所在物体上挂一个简单的Gizmos脚本,在OnDrawGizmos里把rect的四个角和当前生成的顶点连线画出来,调试偏移和三角剖分问题非常直观。还有一个算是压箱底的技巧:如果圆弧或者多边形的边缘总是出现锯齿,可以先考虑开MSAA,如果项目环境不允许,就在边缘最外侧加一圈透明度渐变带,用顶点色把alpha从1降到0,视觉上会平滑得多。

写到最后再说一句实话:能用现成Image拼的场景,我不会为了“炫技术”去自绘;但一旦某个UI几何体需要动态变化、又需要精确交互,自定义Graphic一定是最顺手的那把刀。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进