ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

UGUI性能优化:深入解析Graphic渲染机制与重建陷阱

UGUI性能优化:深入解析Graphic渲染机制与重建陷阱

1. 从一次UI卡顿排查说起:Graphic到底管了什么

几个月前帮一个朋友看他们团队做的一个移动端项目,场景不复杂,就是一个列表页里塞了几十个带图标和文字的条目,滚动起来却明显掉帧。他们一开始怀疑是布局重建太频繁,又怀疑是图集没打对,折腾了两天没找到根因。我过去看了一眼层级面板,发现每个条目下面挂了一堆独立的Image和Text,而且这些元素各自带着不同的材质球。问题其实就出在这里——他们完全没意识到,UGUI里所有能被渲染出来的东西,背后都站着一个共同的基类:Graphic。

这篇内容我想把Graphic这个类彻底讲透。它不是那种你天天挂在嘴边的东西,但只要你做Unity的UI开发,你写的每一行和显示相关的代码,最终都会落到它身上。Image、Text、RawImage、MaskableGraphic,这些你熟悉的组件,全都是Graphic的子类。理解Graphic,等于理解了UGUI渲染管线的入口。它解决的核心问题是:一个UI元素如何把自己变成GPU能画的网格,并且在这个过程中如何参与批处理、如何响应重建、如何被裁剪和遮罩。

适合谁看?如果你已经能熟练拖拽UGUI控件、写过一些简单的UI逻辑,但遇到性能问题只会盲目优化图集,或者想自己写一个自定义UI组件却不知道从哪继承,那这篇就是给你准备的。我会从Graphic的职责边界讲起,拆解它的生命周期、网格生成机制、材质与纹理的配合方式,再落到实际项目里怎么用它做自定义组件、怎么避开常见的重建陷阱。全程按我自己的排查和实操经验来写,不堆砌文档原文。

2. Graphic在UGUI体系里的位置:它到底承担了哪些职责

2.1 渲染层级的入口:CanvasRenderer与Graphic的绑定关系

很多人以为Graphic就是“画图”的,这个理解太窄了。Graphic真正做的事情,是充当逻辑层和渲染层之间的桥梁。你在Hierarchy里看到的每一个UI节点,如果它需要被画出来,那它身上一定挂着一个继承自Graphic的组件。这个组件在OnEnable的时候会去获取同一个GameObject上的CanvasRenderer,然后把自己注册到所在的Canvas里。

CanvasRenderer是Unity底层真正负责提交绘制指令的东西,但它不认识Image也不认识Text。Graphic的职责就是把自己“翻译”成CanvasRenderer能听懂的数据:顶点、三角形索引、材质、纹理。你可以把CanvasRenderer想象成一个画框,Graphic就是往画框里塞画的人。画框本身不知道画的内容,它只负责把画挂到墙上。

这里有个容易被忽略的细节:一个GameObject上只能有一个CanvasRenderer,但可以有多个Graphic吗?答案是不行,因为Graphic在Awake时会去GetComponent (),如果同一个物体上挂两个Graphic子类,它们会抢同一个CanvasRenderer,导致渲染错乱。我见过有人想在一个物体上同时挂Image和自定义的Graphic,结果只有一个能正常显示,就是这个原因。

2.2 批处理的基本单位:为什么Graphic的材质和纹理决定了DrawCall

UGUI的批处理逻辑说穿了很简单:相邻的、使用相同材质和相同纹理的Graphic,可以被合并到一个DrawCall里。注意这里的“相邻”是指在Canvas的渲染顺序里相邻,而不是Hierarchy里的相邻。渲染顺序由层级顺序和SortingOrder共同决定。

所以当你看到DrawCall居高不下的时候,第一反应应该是去检查:是不是有大量Graphic在渲染顺序上被其他不同材质的元素隔开了?是不是每个Text都用了不同的字体纹理?是不是Image的材质球被意外替换成了不同的实例?

我那个朋友的项目就是典型:每个条目的Image用了默认UI材质,Text用了字体材质,两者交替出现,导致每个条目至少产生两个DrawCall。再加上条目之间没有共用图集,几十个条目就是上百个DrawCall。后来把图标打进同一张图集,文字统一字体,DrawCall直接降到了个位数。

这里要强调一个概念:Graphic的material和texture不是随便设置的。Image组件会根据sprite是否打包进图集、是否使用自定义材质来决定最终提交给CanvasRenderer的材质和纹理。如果你在代码里直接改Graphic.material,会打断批处理,因为Unity会认为这是一个不同的材质实例。

2.3 重建机制的触发源:什么操作会让Graphic重新生成网格

Graphic最容易被忽视但又最影响性能的部分,就是它的重建机制。所谓重建,就是Graphic重新计算顶点位置、UV、颜色,然后重新提交给CanvasRenderer。这个过程在Profiler里表现为Canvas.BuildBatch或者Canvas.SendWillRenderCanvases的开销。

哪些操作会触发重建?我列几个最常见的:

  • 修改RectTransform的尺寸或位置(包括锚点和轴心)
  • 修改Image的sprite、color、fillAmount
  • 修改Text的text、fontSize、color
  • 启用或禁用Graphic组件
  • 修改Canvas的scale或renderMode

注意,修改位置和尺寸也会触发重建,这一点很多人不知道。因为Graphic需要根据新的RectTransform重新计算顶点坐标。所以如果你在Update里每帧修改一个UI元素的位置,哪怕只是移动一个像素,都会导致整个Canvas的网格重建。正确的做法是用CanvasRenderer的SetAlpha或者直接操作Transform的localPosition(在某些情况下不会触发重建,但取决于具体版本和设置)。

提示:在Unity 2019之后的版本里,UGUI对重建做了一些优化,比如把位置变化和顶点变化分开处理,但基本原则没变——频繁修改Graphic的几何相关属性,一定会带来重建开销。

3. 拆开Graphic的生命周期:从OnEnable到OnPopulateMesh

3.1 初始化阶段:OnEnable里到底发生了什么

当一个Graphic组件被启用时,OnEnable会依次做几件事。首先它会去获取CanvasRenderer,如果获取不到就报错。然后它会把自己注册到Canvas的willRenderCanvases回调里,这样每帧渲染前Canvas会通知所有Graphic检查是否需要重建。接着它会调用SetAllDirty(),把所有脏标记都置为true,包括布局脏、顶点脏、材质脏。

这里有个关键点:SetAllDirty会触发一次完整的重建,包括重新计算布局和重新生成网格。所以如果你在运行时频繁地启用禁用Graphic,每次启用都会带来一次重建开销。我见过有人在做UI显隐时用SetActive(true/false)来切换,如果这个UI元素很多,切换瞬间就会卡一下。更好的做法是用CanvasGroup的alpha来控制显隐,或者把Graphic的color的alpha设为0,这样不会触发重建。

3.2 脏标记体系:LayoutDirty、VerticesDirty、MaterialDirty的区别

Graphic内部维护了三个脏标记:LayoutDirty、VerticesDirty、MaterialDirty。它们分别对应不同的重建范围。

LayoutDirty表示布局需要重新计算,比如RectTransform的尺寸变了,需要重新确定子元素的位置。这个标记会触发LayoutRebuilder的重建,影响范围可能包括整个布局层级。

VerticesDirty表示顶点需要重新生成,比如颜色变了、sprite变了、文字内容变了。这个标记会触发OnPopulateMesh,重新生成网格数据。

MaterialDirty表示材质或纹理需要重新设置,比如sprite被打进不同的图集了、材质球被替换了。这个标记会触发CanvasRenderer的材质更新。

理解这三个标记的区别很重要,因为不同的修改操作会触发不同的脏标记。比如修改color只会触发VerticesDirty,不会触发LayoutDirty;修改RectTransform的sizeDelta会同时触发LayoutDirty和VerticesDirty。如果你能准确判断一个操作会触发哪些脏标记,就能预估它的性能开销。

3.3 OnPopulateMesh:网格数据是怎么被填进去的

OnPopulateMesh是Graphic最核心的方法,它接收一个VertexHelper参数,你在这个方法里往VertexHelper里添加顶点、三角形和UV。VertexHelper是一个辅助类,它帮你管理顶点列表和索引列表,避免你直接操作底层数组。

默认的Graphic.OnPopulateMesh会生成一个简单的矩形网格,使用RectTransform的rect作为范围。Image和Text都重写了这个方法,Image会根据sprite的类型(Simple、Sliced、Tiled、Filled)生成不同的网格,Text会根据文字内容生成每个字符的四边形。

如果你要写自定义Graphic,重写OnPopulateMesh是必须的。你需要在里面调用vh.Clear()清空旧数据,然后调用vh.AddVert()和vh.AddTriangle()添加新数据。注意顶点数量不要超过65535,因为UGUI默认使用16位索引缓冲,超过这个数量会导致渲染异常。如果确实需要更多顶点,可以考虑拆分成多个Graphic。

注意:在OnPopulateMesh里不要做复杂的计算,比如字符串拼接、循环嵌套太深。这个方法会被频繁调用,任何性能问题都会被放大。如果计算量大,考虑缓存结果,只在必要时重新计算。

4. 材质与纹理:Graphic如何决定最终渲染成什么样

4.1 defaultGraphicMaterial与材质实例化的坑

Graphic有一个默认材质defaultGraphicMaterial,它是一个共享的UI/Default材质。当你没有给Graphic指定自定义材质时,它就会用这个默认材质。这个材质是共享的,所以所有用默认材质的Graphic可以合批。

但如果你在代码里写了graphic.material = new Material(...),就会创建一个材质实例,这个实例和其他材质不共享,导致批处理被打断。我见过有人在运行时给某个Image换材质来实现特效,结果整个Canvas的DrawCall暴涨。正确的做法是使用MaterialPropertyBlock来修改材质属性,或者提前创建好共享材质,在多个Graphic之间复用。

4.2 纹理与图集:sprite打包对批处理的影响

Image的sprite如果被打进了图集,那么它的texture就是图集的纹理。多个Image如果使用同一张图集的sprite,并且渲染顺序相邻,就可以合批。如果sprite没有打包,每个sprite就是一张独立的纹理,无法合批。

所以图集规划是UI性能优化的第一课。我通常会把同一界面用到的图标、背景、边框都打进同一张图集,图集大小控制在1024x1024或2048x2048。如果图集太大,加载和内存开销也会增加,需要权衡。

Text的纹理是字体纹理。动态字体在运行时会把用到的字符渲染到一张字体纹理上,如果字符集很大,字体纹理可能会被撑大甚至重建。所以对于大量文字,建议使用静态字体或者TextMeshPro,后者对字体纹理的管理更高效。

4.3 自定义材质与Shader:什么时候需要自己写

默认的UI/Default Shader支持基本的纹理采样、颜色混合和裁剪。如果你需要特殊效果,比如灰度、描边、模糊、溶解,就需要自己写Shader和材质。

写自定义UI Shader时要注意几点:必须支持UI的裁剪(Clip Rect),否则在Mask或RectMask2D下面会显示异常;必须处理顶点颜色,因为Graphic会把color传递到顶点颜色里;必须支持模板测试(Stencil),否则在Mask下面会穿透。

我一般会基于UI/Default改,保留它的裁剪和模板处理逻辑,只修改颜色计算部分。这样能保证兼容性,减少踩坑。

5. 自定义Graphic实战:从零写一个环形进度条

5.1 为什么选择继承Graphic而不是MaskableGraphic

MaskableGraphic继承自Graphic,额外增加了对Mask和RectMask2D的支持。如果你写的组件需要被遮罩裁剪,就应该继承MaskableGraphic。如果不需要,继承Graphic就够了,少一层开销。

环形进度条通常用在技能冷却、加载进度等场景,一般不需要被遮罩裁剪,所以我选择继承Graphic。但如果你把它放在一个ScrollView里,可能就需要被RectMask2D裁剪,这时候继承MaskableGraphic更合适。

5.2 顶点生成逻辑:如何用三角形拼出一个圆环

环形进度条的本质是一个圆环,圆环可以拆分成若干个扇形,每个扇形由若干个三角形组成。我的做法是:把圆环分成N段,每段对应一个角度范围,然后计算内外圆上的顶点,用三角形连接起来。

具体步骤:

  1. 确定圆环的内半径和外半径,以及起始角度和结束角度。
  2. 把角度范围分成N份,每份的角度为deltaAngle。
  3. 对于每个角度,计算内外圆上的顶点坐标。
  4. 用两个三角形组成一个四边形,连接相邻的两个角度对应的内外顶点。
  5. 把顶点和三角形索引添加到VertexHelper里。

顶点数量是2*(N+1),三角形数量是6*N。N越大,圆环越平滑,但顶点也越多。一般N取36到72就够了,对应每10度或5度一段。

protected override void OnPopulateMesh(VertexHelper vh) { vh.Clear(); int segments = 60; float deltaAngle = 360f / segments; float outerRadius = rectTransform.rect.width * 0.5f; float innerRadius = outerRadius * 0.8f; for (int i = 0; i <= segments; i++) { float angle = i * deltaAngle * Mathf.Deg2Rad; float cos = Mathf.Cos(angle); float sin = Mathf.Sin(angle); Vector2 outer = new Vector2(cos * outerRadius, sin * outerRadius); Vector2 inner = new Vector2(cos * innerRadius, sin * innerRadius); vh.AddVert(outer, color, new Vector2(1f, 1f)); vh.AddVert(inner, color, new Vector2(0f, 0f)); } for (int i = 0; i < segments; i++) { int idx = i * 2; vh.AddTriangle(idx, idx + 1, idx + 2); vh.AddTriangle(idx + 1, idx + 3, idx + 2); } }

这段代码生成的是一个完整的圆环。如果要实现进度效果,可以根据fillAmount控制哪些段被绘制。比如只绘制前fillAmount*segments段,后面的段不添加三角形。

5.3 颜色与UV的传递:让进度条支持渐变和纹理

顶点颜色可以直接用Graphic的color,也可以根据角度计算渐变。比如从起始角度到结束角度,颜色从红色渐变到绿色。UV可以根据角度映射到一张纹理上,实现纹理环绕效果。

注意UV的坐标系:UGUI的UV原点在左下角,范围是0到1。如果你用纹理,要确保纹理的wrap mode是Clamp或Repeat,根据需求选择。

5.4 性能考量:顶点数量与重建频率的平衡

环形进度条的顶点数量取决于segments。60段就是122个顶点,120个三角形。这个数量很小,性能开销可以忽略。但如果你的进度条每帧都在变化,就会每帧触发重建。这时候可以考虑用Shader来实现进度效果,只传递一个fillAmount参数,不重建网格。

用Shader实现的方式是:在Shader里根据UV计算角度,判断当前像素是否在进度范围内,如果在就显示,不在就透明。这样只需要修改材质的属性,不需要重建网格。缺点是Shader的复杂度会增加,而且需要处理抗锯齿。

6. 那些年我在Graphic上踩过的坑

6.1 修改color导致的意外重建

有一次我在做一个按钮的点击反馈,按下时把按钮的Image颜色调暗,松开时恢复。逻辑很简单,就是在Update里根据状态设置color。结果发现每次点击都会卡一下。排查后发现,修改color会触发VerticesDirty,导致整个Canvas的网格重建。如果Canvas里元素很多,重建开销就很大。

后来我改用CanvasRenderer.SetColor,这个方法直接修改CanvasRenderer的颜色,不会触发Graphic的重建。但要注意,SetColor修改的是顶点颜色之外的乘算颜色,效果和修改Graphic.color不完全一样。如果需要精确控制,还是得用Graphic.color,但要尽量减少修改频率。

6.2 动态字体与Text重建的连锁反应

Text组件在文字内容变化时会触发重建,这个大家都知道。但很多人不知道的是,动态字体在遇到新字符时,会触发字体纹理的重建,这个重建会影响所有使用该字体的Text。如果界面里有大量Text,而且文字内容是动态变化的,就可能出现连锁重建,导致卡顿。

我的做法是:对于频繁变化的文字,比如倒计时、分数,使用预先烘焙好的字符集,或者用TextMeshPro。TextMeshPro对字体纹理的管理更精细,不会因为一个新字符就重建整张纹理。

6.3 Mask与RectMask2D对Graphic的额外开销

Mask组件会给子Graphic增加模板测试,每个被Mask影响的Graphic都会多一次Stencil操作。RectMask2D则是在Shader里做裁剪,开销相对小一些。但如果RectMask2D的范围很大,覆盖了很多Graphic,每个Graphic都要做裁剪计算,累积起来也不可忽视。

我一般会优先用RectMask2D,只有在需要非矩形遮罩时才用Mask。另外,Mask会打断批处理,因为Mask本身也是一个Graphic,它会插入一个不同的材质和模板状态。如果性能敏感,尽量减少Mask的使用。

6.4 嵌套Canvas与Graphic的更新隔离

Canvas的更新是以Canvas为单位的,一个Canvas里的任何Graphic重建,都会导致整个Canvas的网格重新合并。所以如果界面很复杂,可以考虑把频繁变化的部分拆到独立的子Canvas里。子Canvas的重建不会影响父Canvas,这样可以隔离更新开销。

但子Canvas也有代价:每个Canvas都会产生额外的DrawCall,因为不同Canvas之间不能合批。所以拆Canvas要适度,不能为了隔离更新而拆得太碎。我的经验是:把频繁变化的元素(比如进度条、倒计时)放在一个子Canvas里,把静态的背景和装饰放在主Canvas里。

7. 从Graphic出发的进阶思路

7.1 用Graphic实现自定义图元:不只是矩形和圆

Graphic的OnPopulateMesh可以生成任意形状的网格,不只是矩形和圆。你可以用它来画多边形、贝塞尔曲线、甚至简单的3D模型投影。我见过有人用Graphic实现了雷达图、折线图、热力图,效果很好,而且完全在UGUI体系内,不需要额外的渲染管线。

关键是要理解顶点和三角形的组织方式。任何复杂的形状都可以拆分成三角形,只要你能计算出顶点的位置和UV,就能用Graphic画出来。

7.2 结合JobSystem与Burst加速网格生成

如果网格生成的计算量很大,比如有几千个顶点需要动态计算,可以考虑用JobSystem和Burst来加速。把顶点计算放到Job里,利用多核并行,然后在主线程把结果填充到VertexHelper里。

但要注意,VertexHelper不是线程安全的,不能在Job里直接操作。你需要先在Job里计算出顶点数组,然后在主线程里遍历数组添加到VertexHelper。这样虽然多了一次拷贝,但计算部分被加速了,整体开销还是降低的。

7.3 与TextMeshPro的对比:什么时候该换阵营

TextMeshPro本质上也是基于Graphic的,但它对文字渲染做了大量优化,比如Signed Distance Field字体、更高效的网格生成、更好的字体纹理管理。如果你在做大量文字渲染,或者需要高质量的文字效果,TextMeshPro是更好的选择。

但TextMeshPro也有学习成本和兼容性问题。如果你的项目已经大量使用UGUI的Text,迁移到TextMeshPro需要一定的工作量。我的建议是:新项目直接用TextMeshPro,老项目如果文字性能不是瓶颈,可以暂时不迁移。

7.4 一个容易被忽略的优化点:Graphic的raycastTarget

最后说一个和渲染无关但和性能相关的点:Graphic的raycastTarget。这个属性默认是true,意味着这个Graphic会接收射线检测。如果界面上有很多不需要点击的装饰性元素,把它们的raycastTarget关掉,可以减少射线检测的开销。

我见过一个项目,界面上有几百个Image,全部开着raycastTarget,每次点击都要遍历所有Graphic做射线检测,开销很大。后来把不需要交互的元素的raycastTarget关掉,点击响应明显变快。

这个优化很简单,但效果立竿见影。每次创建UI元素时,养成习惯问自己一句:这个元素需要接收点击吗?不需要就关掉。

提示:raycastTarget的开关不会触发重建,可以随时修改,没有额外开销。

我在实际项目里养成了一个习惯:每次做完一个界面,都会用Profiler看一眼Canvas的重建次数和DrawCall数量。如果重建次数很高,就去检查是不是有频繁修改Graphic属性的代码;如果DrawCall很高,就去检查图集和材质的使用。这两个指标基本能反映UI的性能状况,比盲目优化有效得多。

RELATED READING

延伸阅读

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