NGUI 这套插件在 Unity 项目里存活了好多年,哪怕 UGUI 已经成了默认方案,很多老项目、中小团队的钱包和线上数据还是压在 NGUI 身上。如果你要改这类项目,绕不开 UIWidget;如果你想从源码角度搞明白 NGUI 的渲染链条,UIWidget 是你的起手式。这篇就专门拆它,不聊情绪,直接说机制。
打开 NGUI 源码,你会发现整个体系里 UIWidget 是一个很特殊的类:它既不像 UIButton 那样带交互语义,也不像 UILabel 那样暴露大量文本配置,但它躺在地基上——所有可见的 NGUI 控件都继承自它。说得极端一点,你把 UIWidget 搞明白了,NGUI 里 80% 的渲染问题都能自己定位,剩下的 20% 多半也是它的邻居 UIPanel、UIDrawCall 配合导致。
这篇文章会从类架构、顶点数据流、深度排序、材质绑定、性能调优五个维度,把 UIWidget 的核心机制翻个底朝天。适合正在维护 NGUI 项目的人,接手上古代码库的新手,以及想从另一个 UI 框架里找设计灵感的开发者。
1. UIWidget 在 NGUI 渲染链路里的位置:不是孤立的组件
1.1 从 UI 事件到网格渲染,NGUI 的三层结构
先回忆 NGUI 的宏观结构。Unity 场景里摆一个 UIWidget,它不会自己上屏。它只是逻辑层的“声明”,告诉 NGUI:我这里需要渲染一个矩形区域,里面有些几何数据。真正把它画出来,要经过 UIPanel 和 UIDrawCall 两层。
- UIPanel:负责收集挂在自己下面的所有 UIWidget,做深度排序、裁剪计算,然后把同一类材质的 Widget 合并成一个或几个 UIDrawCall。
- UIDrawCall:实际生成 Mesh、设置材质和渲染状态,最终通过 Graphics.DrawMesh 或内部的 OnRenderObject 提交给 Unity 渲染管线。
UIWidget 在这条链路里是数据源。它不直接拿着 Mesh,而是通过一个抽象的 OnFill 方法,把顶点、UV、颜色数据填进 UIPanel 提供的缓冲列表里。UIPanel 拿到这些数据后,才会做合并、排序、分 DrawCall。
这个设计的经典之处在于:UIWidget 完全不关心最终用什么材质渲染、和谁合批。它只负责“我是谁、我长什么样、我在哪”。材质和图集的绑定是 Widget 的基类属性,但真正的合并决策权在 UIPanel。这样做的好处是,项目里可以自由扩展新的 Widget 子类,只要实现 OnFill,就可以无缝接入 NGUI 的渲染管线。
1.2 UIWidget 的类继承树与职责边界
UIWidget 的直接基类是 UIRect。UIRect 处理的是“矩形区域”的概念:坐标、锚点、层级位置。UIWidget 在 UIRect 之上增加的是“可填充的图形”能力。
实际使用中,你遇到的控件基本是这样的继承关系:
UIRect └── UIWidget ├── UISprite ├── UILabel ├── UITexture ├── UI2DSprite └── UILocalize 之类的功能组件则不继承 UIWidget,只是挂在同一 GameObject 上UIWidget 自己是个 abstract 基类,它定义了以下核心内容:
- 尺寸概念:width、height 以及本地坐标轴下的矩形边界。
- 渲染属性:color、alpha、depth、material、mainTexture、atlas 等。
- 生命周期钩子:OnFill、UpdateGeometry、UpdateTransform、UpdateColliders。
- 面板管理:mPanel、mDepth、panel 的引用。
如果你在项目里搜索过 NGUI 的源码,会发现 UIWidget 的代码量不大,但每一行都牵一发动全身。比如 mDepth 字段,它决定 Widget 在 UIPanel 排序列表里的位置。项目里遇到的“控件被挡住了”“DrawCall 顺序不对”“点击穿透”,大量问题源头就在这个 depth 上。
提示:NGUI 的 depth 可不是默认层级,它在 UIPanel 内部被组合成最终绘制顺序时,还有一套权重计算逻辑。这一块放到第 3 节细讲。
2. 顶点是怎么流到屏幕上的:OnFill 与 UpdateGeometry 的真相
2.1 OnFill:子类唯一需要实现的形状接口
UIWidget 本身并不生成具体的几何数据。它留给子类一个纯虚拟方法 OnFill,签名是这样的:
abstract public void OnFill(BetterList<Vector3> verts, BetterList<Vector2> uvs, BetterList<Color32> cols);三个参数分别代表顶点坐标、UV、顶点颜色。这里没有索引列表。NGUI 的默认约定是每 4 个顶点构成一个四边形,两个三角形共享第 0、第 2 个顶点。
举个例子,UISprite 的 OnFill 会按图集的 sprite 区域计算四个角的 UV,然后将当前 Widget 的绘制区域(由 mInnerUV、mDrawRegion 等计算)映射为四个顶点。UILabel 的 OnFill 则复杂得多:它会遍历解析后的字符信息,把每个字符的图集 UV、缩放比例、颜色梯度填入列表。
这意味着什么?如果你想写一个自定义的 NGUI 控件——比如一个环形进度条、一个六边形头像框——你唯一要做的,就是继承 UIWidget,重写 OnFill,把形状的几何顶点和 UV 填进列表。材质、合批、排序、点击碰撞全部由基类框架接管。
实际开发中,有人误以为需要直接操作 UIDrawCall 或生成 Mesh,其实不需要。OnFill 就是进入 NGUI 管线的唯一入口。写清楚 OnFill,剩下的交给框架。
2.2 BetterList:NGUI 不依赖 Unity 原生 List 的原因
OnFill 的签名里用了 BetterList ,这是 NGUI 自己实现的一个轻量容器。它不做线程安全、不做读写分离,但有几个非常实际的优化:
- 方法名很短:Add 和 Release。
- 内部是普通数组加大小计数,扩容时会把旧数组内容覆盖到新数组,但不立刻清空。
- 提供 Buffer 属性,返回内部数组本身,方便上层直接循环遍历,避免 foreach 的迭代器开销和装箱问题。
- 提供 Clear 方法,只把 size 归零,不释放数组引用,下次继续复用。
每一帧 UI 几何刷新时,UIPanel 会调用各 Widget 的 OnFill 往 BetterList 里塞数据。如果用原生 List,每帧都会产生大量临时对象,GC 压力直接拉满。BetterList 的复用机制把分配降到零。
如果不了解这个背景,你可能会觉得 NGUI 真是老古董,非要用自家容器。实际上这是基于当时 Unity 版本的 GC 性能和内存策略做的妥协。现在写自定义 NGUI 控件时,也建议沿用自己的 BetterList 缓存,不要一上来就 new List。
2.3 UpdateGeometry 与 mChanged:脏标记驱动刷新
UIWidget 的几何数据不会每帧无条件重建。它靠一个 dirty 标记系统驱动。当 UIWidget 的尺寸、颜色、UV 等属性发生变化,会调用 SetDirty,然后 UIPanel 统一在 LateUpdate 里处理。
核心调用链是:
- 属性变更(例如 alpha、width、height) → 调用 SetDirty。
- UIPanel 收集到 mChanged 的 Widget,在下一帧的 LateUpdate 里逐一调用 UpdateGeometry。
- UpdateGeometry 里会先检查 widget 是否可见、材质是否有效,然后调用 OnFill 把数据填入 UIRect 提供的缓冲列表,再将这些数据交给 UIPanel,由它决定是重新生成 UIDrawCall 还是复用旧的。
UpdateGeometry 里隐藏的一个细节是:它并不直接修改 Unity 的 Mesh。Mesh 的构建发生在 UIDrawCall 的 UpdateGeometry,因为 UIDrawCall 持有最终的 Mesh 资源,并负责提交给渲染管线。UIWidget 只是把原始几何数据喂给 UIPanel,UIPanel 在内部完成真正的 Mesh 更新。
正因如此,你在 OnFill 里写的顶点顺序、UV 范围必须严格符合 NGUI 的四边形约定。一旦顺序错乱,出现的不只是叠面错误,还有可能造成 UV 映射错位,最终让图集被拉动。
建议:自定义控件时,先参考 UISprite 的 OnFill 写法,用最朴素的四顶点格式验证,再扩展复杂图形。很多诡异渲染问题,都是在这个环节写坏了三角形绕序。
3. 深度排序与绘制顺序:UIWidget 的 2.5D 游戏规则
3.1 depth 不是简单的“越大越靠前”
NGUI 使用 depth 排序,但它不是直接比较两个 widget 的 depth 大小来决定谁先画。UIPanel 内部维护一个排序列表,Widget 的 depth 只是排序的首要键。排在后面的 Widget 会被认为是绘制顺序靠后,也就是显示在前面的层。
绘制顺序上,NGUI 会尽量把相同材质、相同图集、相同 shader 的 Widget 塞进同一个 UIDrawCall,从而减少 DrawCall 切换。这个合并策略决定了:
- 如果 A 的 depth 是 10,B 的 depth 是 11,但 A 和 B 用了不同的图集,那么它们不会合并到同一个 DrawCall,绘制顺序会按照 depth 依次执行。
- 如果 A 是 10,C 是 100,但 A 和 C 同图集、同 shader,中间没有其他 widget 插入,NGUI 可能会把它们的顶点合并到同一个 UIDrawCall 里。这时,A 和 C 的绘制先后,取决于它们在最终三角形列表中的索引顺序,而不是原始的深度。
动手排查 NGUI 显示问题时,如果只盯着 inspector 里的 Widget Depth,经常会被绕晕。真正的绘制顺序,要结合 UIDrawCall 列表和几何顶点顺序一起看。
3.2 UIPanel 的排序算法与合批边界
UIPanel 对 Widget 的排序不是全局排序,而是分层排序。每个 GameObject 的层(Layer)相同才能混合深度排序。UIPanel 在收集 Widget 时,会先将所有 Widget 按 depth 排序,然后尝试连续合并。
这里有一个关键的“合批边界”逻辑:当遇到不同材质、不同图集、或者 Widget 的 drawRegion 不一致时,UIPanel 会结束当前 UIDrawCall,开启下一个 UIDrawCall。这也就是为什么实战中把两个同图集的 Sprite 放在同一 Panel 下,但中间夹了一个其他图集的 Sprite,会导致本来可以合批的 DrawCall 变成三个。
我维护项目时经常遇到:“我就加了一个小图标,DrawCall 从 10 变成 30。”十有八九是中间的图集切换或者 depth 排序打乱了原有的连续材质段。
为了避免这种情况,你需要理解 NGUI 的一个设计:它不会隔空合并两个材质相同的 Widget。只有连续排列的材质相同的 Widget 才会被归入同一个 UIDrawCall。这个连续性是全局深度顺序上的,不是层级父子关系上的。
这就是为什么 NGUI 项目里,策划更喜欢“一个面板一个图集”,而不是到处混用图集。图集越多,合批碎裂的概率越大。
3.3 2.5D 的误解:不是 3D 空间里的 Z 轴
很多人以为 NGUI 是 2.5D UI,就以为改变本地 Z 轴会影响绘制顺序。其实 UIPanel 在收集 Widget 时,会强制将 Widget 的本地坐标的 Z 轴归零,再根据 depth 排序。Z 轴变化不会直接影响 UI 绘制顺序。
绘制顺序只由以下因素决定:
- UIPanel 的 renderQueue 起始值
- Widget 的 depth 排序
- 材质切换点(合批边界)
- UIPanel 自身的 sortingOrder 属性和相机的 depth
在开发里,UI 相机和 3D 相机的叠加顺序靠的是 Camera depth 和 Clear Flags。NGUI 的 UI 绘制 z 轴固定为 0,所以它不会受到 3D 物体的遮挡干扰——这其实是 NGUI 非常稳的一个设计点。
4. 材质、图集与 UIWidget 的渲染数据绑定
4.1 UIWidget 上的 atlas 和 material 不是一回事
NGUI 的 Inspector 里,你会看到 UISprite 可以选 Atlas,UILabel 可以选 Font。这些配置最终都会映射到 UIWidget 的 sharedMaterial 或 mainTexture 上。
关键在于理解两个层级:
- 逻辑层级:UISprite 知道自己在 atlas 中的哪个 sprite,UILabel 知道自己在 font 的哪个字符表里。它们为用户提供语义化接口。
- 渲染层级:UIWidget 最终需要输出一个 Material 和一个 Texture,交给 UIDrawCall 渲染。NGUI 会从 atlas 或 font 中提取 mainTexture,并创建一个默认的 UI shader 材质(NGUIDefault shader)。
所以,你在 UISprite 里改 sprite 名称,不会直接改 material;改的是 atlas 的引用,以及最终 mainTexture 的指向。
一个常见的误操作是:在运行时动态设置 widget 的 color,结果整个 Atlas 下的所有 Sprite 都受影响。这是因为 UISprite 共享同一个 material,color 属性最终会被写进顶点颜色数据,而不是材质实例。所以每个 Sprite 的 color 会通过顶点数据独立表达,不会污染其他 sprite,但你也别指望给同一个 atlas 下的两个 sprite 用不同的 shader 或 material 实例,因为材料共享是经过图集管理的。
4.2 从 UIWidget 到 UIDrawCall 的材质应用链
当 UIPanel 决定将一个 Widget 放入某个 UIDrawCall 时,会把该 Widget 当前有效的 Material 和 Texture 赋值给该 DrawCall。如果一个 DrawCall 被多个 Widget 共用,那么这些 Widget 的材质、纹理必须一致。
这里引入了一个“透明物”设计:如果某 Widget 的 alpha 为 0,NGUI 默认连顶点数据都不生成,直接跳过。这比 UGUI 的 CanvasRenderer.cull 要激进得多。因为 alpha 为 0 的 Widget 完全不影响 DrawCall 数量,但如果你只是把它移到屏幕外而不是设为不可见,DrawCall 还是会被保留,这就有优化空间。
材质链中还有一个容易被忽略的点:UIWidget 的 mOverlay。部分 NGUI 版本在 UIWidget 上引入了一个 Overlay 纹理的概念。它可以让同一个 Widget 的主纹理之外再叠一张纹理,用于特效。实际项目里用到的不多,但它解释了为什么 UIPanel 判断材质一致时,不只比较 mainTexture,还要比较 shader 参数全列表。
5. 实战中绕不开的 UIWidget 坑,以及对应的排查思路
5.1 动态 Mask 和 UIWidget 的配合陷阱
NGUI 有 UIPanel 的 clip 功能,也有 UIRect 的局部裁剪。这里最典型的问题是:把一个 UIWidget 放进带 Clip 的 UIPanel,如果它的 depth 排序和面板边缘计算不正确,会出现整块漏出、错位裁掉一半,甚至整个 UI 闪烁。
根本原因是 UIPanel 在做裁剪时,会计算 Widget 在面板坐标系下的最终矩形边界,这个计算依赖 widget 的 localCorners 以及面板的 transform。当 Widget 在运行期移动、缩放,UIPanel 需要重新计算裁剪矩阵。NGUI 通过 mChanged 标记和 drawCall 的更新帧同步来处理,但如果你在 Awake 阶段直接修改 widget 尺寸,然后立刻读取裁剪结果,很容易拿到的是上一帧数据。
解决方式是在 Start 或晚一帧操作,确保 UIPanel 的 LateUpdate 已执行。不要尝试直接调用 UIPanel 的内部刷新接口,因为不同 NGUI 版本,内部字段名变化较大。
5.2 大量 Widget 的 CPU 开销控制:比 DrawCall 更棘手
Don't 只盯着 DrawCall。UIWidget 的 CPU 开销主要是三块:
- OnFill 的顶点生成和列表填充。
- UIPanel 的排序算法(对 Widget 列表做稳定排序)。
- UIDrawCall 的 Mesh 重建与提交。
当你在一个界面里动态创建几百个 UIWidget,即便全部隐藏,如果它们的 GameObject 处于 active 状态,UIPanel 依然可能每帧遍历它们,导致 CPU 上扬。这个问题在 UGUI 里不容易暴露,因为 UGUI 的 Canvas 更擅长剔除不可见元素。NGUI 时代,大家普遍的做法是:
- 用 NGUITools.SetActive 彻底禁用不显示的 Widget。
- 尽量避免动态创建、销毁,改用对象池。
- 使用 UIWrapContent 做列表虚拟化,而不是把整条列表的 Widget 全部实例化。
5.3 UIWidget 的裁剪与面板动态重建
另一个高频坑是:在一个 UIPanel 里塞太多不同 depth 且不同材质的 Widget,导致动态重建时,每次的排序结果都不稳定,最终引发闪烁。这种问题多出现在表格、动态列表、跑马灯这类需要频繁刷新布局的功能中。
NGUI 的 UIPanel 有一个 rebuildFlag 机制。在 UIPanel.LateUpdate 中,会检查所有 Widget 的 dirty 状态和面板自身的 clip 状态。一旦检测到大量 Widget 的 mChanged 同时为 true,它会整体重建一遍 DrawCall 列表。
这个过程的代价很高,尤其是在移动端低端机上,你会发现 UI 操作卡顿,帧率从 60 掉到 30 甚至更低。
对应优化思路:
- 尽量让频繁变化的一小组 Widget 放在独立 UIPanel 中,避免牵连大面板全部重建。
- 动态更新的 Widget 使用固定的 depth,不要每帧调整。
- 列表项里不要使用 UIWidget 的尺寸变化,改为整体缩放或 alpha 变化,减少几何数据重建。
- 在自定义 Widget 里,充分利用 mDirty 逻辑,避免对不必要变化的属性触发 SetDirty。
5.4 自定义 UIWidget 样式时的设计建议
如果你要写一个自定义 UIWidget,我给一个可以直接抄作业的骨架:
public class UIRingWidget : UIWidget { [Range(0, 360)] public float fillAmount = 90f; public override void OnFill(BetterList<Vector3> verts, BetterList<Vector2> uvs, BetterList<Color32> cols) { // 1. 清空列表由 UIPanel 在调用前完成,这里直接 Add。 // 2. 按绘制区域生成外环扇形顶点。 // 3. 每个顶点同步传入 UV 和颜色。 // 4. 注意三角形绕序,保持顺时针。 } }一个比较重要的规范是:不要在 OnFill 里 new 临时对象,不要调用 Destroy,不要在 OnFill 里读取其他 Widget 的动态布局。OnFill 的执行频率往往比你想象的高,任何重量级操作都会直接打到每一帧的 UI 绘制开销上。
此外,自定边框类 Widget 最好使用一个独立材质或独立图集区域,避免和标准图集混用,否则很难控制 UV 边缘的拉伸变形。NGUI 的 UISprite 使用 sliced 模式时,对九个切片的 UV 有特殊处理,自定义控件如果没有这些逻辑,就别贸然标称支持 sliced。
6. 从 UIWidget 源码中沉淀出的优化清单
在很多老的 NGUI 项目里,客户端性能瓶颈往往不在渲染,而在这一层的 CPU 几何重建。我把自己过去几年排查 NGUI 问题时的经验做一个清单,你可以直接拿来当排查手册。
一份最典型的 NGUI UI 性能检查表:
| 检查项 | 现象 | 处理建议 |
|---|---|---|
| Widget 数量 | profiler 里 UIPanel.LateUpdate 耗时高 | 用 UIWrapContent 虚拟列表,避免一次创建上千 Widget |
| 材质分裂 | DrawCall 数量暴增 | 检查 depth 排序里是否穿插了不同图集元素 |
| 动态改 depth | 排序不稳定,DrawCall 闪动 | 固定 depth,不要用 z 轴或运行时 depth 排序 |
| 频繁 set width/height | 几何重建频繁 | 尽量用 scale 或 target 动画,不要每帧改尺寸 |
| 全屏 alpha 渐变 | 所有 Widget 都产生变色、重建 | 单独用带 alpha 的 UIPanel,或使用 overlay 做整体淡入淡出 |
| 多个 Panel 叠加 | 渲染顺序错乱、裁切错误 | 用 Panel 的 depth 层级统一管理,而不是让 Panel 嵌套 |
这些经验,每一条都在项目中真实出现过。比如动态改 depth 那个,我们在一个聊天列表里为了做“当前说话人置顶”的效果,运行时把所有 Widget 的 depth 重新排序,最后发现 iPhone 6s 上的 fps 从 60 降到 30,整个列表滑动都发飘。后来改成只改变图集内同材质顺序,并用局部动画模拟置顶,性能立刻回满。
UIWidget 虽然是个普通类名,但你把它拆深了,会发现它背后是一整套已经过验证的 UI 渲染设计。今天 Unity 的 UGUI 很多机制其实和 NGUI 有相似之处——都通过脏标记延迟构建几何,都依赖合批调度。只是 NGUI 更透明一些,源码摆在眼前,你能亲手追踪每一步。
如果你正在维护 NGUI 老项目,遇到“显示不对”“叠层错误”“性能卡顿”的时候,别急着改代码,先打开 UIWidget 和 UIPanel 的源码,沿着数据流看一遍。很多问题的答案,其实早就在 OnFill 和 DrawCall 之间写好了。