UGUI的Image组件用起来门槛确实低,往Canvas下面拖一张Sprite,调调Color和ImageType基本就能出效果。但你要是只停留在这一步,后面遇到技能冷却、列表滚动卡顿、Mask导致draw call暴涨这些问题,就很难解释为什么了。这篇(二)接着上一篇,把Image往底层再扒一遍:它怎么生成网格、FillMethod背后的扇形计算逻辑,以及裁剪、合批和Rebuild这几个高频率性能话题。适合已经会用Image、但想知道编辑器后面到底发生什么的人。
1. 从渲染链路看Image:一张UGUI图片是怎么上屏的
1.1 Canvas、CanvasRenderer和Mesh的关系
很多人以为Image就是直接把一张纹理画到屏幕上,实际没这么简单。UGUI的渲染链路里,Image最重要的身份是Graphic的子类,真正负责“画图”的不是Image自己,而是它覆写的OnPopulateMesh方法。
整个流程是这样的:Canvas在Unity底层驱动UI渲染,每帧会检查哪些UI元素标记了“需要重建”。当Image的属性发生变化,比如改了color、sprite、fillAmount,它会通过SetVerticesDirty通知Canvas。下一帧渲染前,Unity的CanvasUpdateRegistry会找到这个Graphic,调用OnPopulateMesh(VertexHelper vh),把顶点、UV、颜色、三角形索引全部填进VertexHelper里。紧接着CanvasRenderer拿着这批Mesh数据和材质、贴图一起提交给Canvas,最终合并批次后交给GPU。
所以可以这么理解:CanvasRenderer是搬运工,Mesh是货物,Image是负责打包货物的工人。你修改Image的任何可视化属性,本质都是在改打包逻辑,改完之后让搬运工重新搬一次。
这里有一个容易忽略的点:OnPopulateMesh不是每帧都调用的,只有当Image被标记为dirty,并且Canvas需要重绘时才会触发。换句话说,如果你只是移动RectTransform的位置,没有改Image本身的任何属性,网格是不会重建的,位移由Canvas的顶点变换矩阵处理。这是UGUI做了优化的地方,很多性能问题排查到后面都要先想清楚:到底有没有触发网格重建。
1.2 ImageType背后的网格规律
Unity编辑器里Image有四种ImageType:Simple、Sliced、Tiled、Filled。从使用角度它们只是几种拉伸策略,但从顶点生成角度,它们各自要构造的Mesh差别很大。
| ImageType | 典型顶点数 | 典型三角形数 | 说明 |
|---|---|---|---|
| Simple | 4 | 2 | 一张矩形图,最简单的两个三角面 |
| Sliced | 24 | 18 | 九宫格切分为9个区域,每个区域一个quad |
| Tiled | 不固定 | 不固定 | 按Sprite原始尺寸重复,区域内部会出现多个quad |
| Filled | 视填充方式而定 | 视填充方式而定 | Radial360会把圆周细分,段数越多顶点越多 |
之前有朋友问,为什么Sliced模式下,不管图片拉伸多大,顶点数都保持不变?因为九宫格切分后,四角固定尺寸,四边和中心区域只是被拉伸或者重复,但逻辑上仍然只有9个quad。Unity把每个quad的顶点合并后,最终写入VertexHelper的通常是24个顶点、18个三角形。这个数字在运行时不会因为尺寸变化而变动,所以Sliced是四种模式里性能特征最稳定的。
Tiled模式则相反。它按Sprite的原始像素尺寸做重复平铺,图片拉得越长,需要画出来的小quad就越多,顶点数跟着涨。如果一张很长的背景图用Tiled,又有人每帧去改它的尺寸,那网格重建成本就会肉眼可见地上升。我一般建议长条背景优先用Sliced拉伸,或者直接把图片制作成可无缝拼接的长条,尽量不要依赖运行时平铺。
Filled模式更特殊,它把一张图切成扇形、条形等部分显示,顶点数完全取决于填充算法和细分参数。以Radial360为例,圆周方向需要生成若干个顶点来模拟弧线,细分段数越多,弧线越平滑,顶点数也越多。这是一个在“视觉效果”和“重建开销”之间做权衡的地方。
1.3 为什么不直接拿SpriteRenderer画UI
这个问题不少新同事问过:UI图片也是Sprite,3D场景里用SpriteRenderer画Sprite很直接,为什么UGUI非要绕一圈用Graphic?
核心在于UGUI有一套独立的渲染管理机制。Canvas需要统一管理UI元素的渲染顺序,按Hierarchy先后、按RectTransform的层级深度,还要支持合批、裁剪、布局系统。SpriteRenderer是挂在Transform下、走3D渲染管线的一个组件,它跟Canvas的排序、合批系统完全没有交集,也不能参与UGUI的RectMask2D裁剪。
再说直白一点,UGUI为了支持一套高效合批的UI渲染体系,把所有UI可视化组件抽象成Graphic,Image只是其中一个具体实现。这样Canvas才能做到:先收集所有Graphic的Mesh,再按层序和材质排序,最后一次性提交大量UI Mesh给GPU。如果UI都用SpriteRenderer,那只能回到早期NGUI之前那种每个Sprite独立提交的老路,draw call会很快爆炸。
所以你在Scene视图里看Image,本质上看到的是一个带有RectTransform、CanvasRenderer、Graphic三者组合的渲染节点。理解了这个组合,后续所有的优化手段就都有了落脚点。
2. 剖开FillMethod:环形进度条和技能冷却的底层真相
2.1 FillMethod的四个模式与fillOrigin坐标系
Image开启Filled后,FillMethod有Horizontal、Vertical、Radial90、Radial180、Radial360几种选项。编辑器里用起来只是拉一个FillAmount滑条,但源码里对应的是不同的顶点生成策略。
Horizontal和Vertical很简单,就是按从左到右或从下到上的比例砍掉一部分矩形区域。fillOrigin在这两个模式下只有两个取值,分别代表起始方向。比如Horizontal的fillOrigin为0时从左往右填充,为1时从右往左填充。
Radial90、Radial180、Radial360则涉及圆形坐标系。这里有个容易搞混的点:fillOrigin在不同模式下含义不同。Radial90的0、1、2、3分别对应底边、右边、顶边、左边,也就是从哪个边缘开始扫过90度。Radial360同样有四个方向,但实际效果会更直观:0从底部开始,1从右边开始,2从顶部开始,3从左边开始,然后配合Clockwise决定顺时针还是逆时针扫。
很多人调了半天发现起始位置不对,就去改fillOrigin,却忽略了Clockwise也会改变扫过的视觉方向。实际调试经验是:先确定起始边,再定方向,不要两个一起盲目改。我曾经在某个项目里做技能冷却图标,一开始只调fillOrigin,转到0还是不对,后来把Clockwise勾上就正常了——起始方向没变,扫过方向反了而已。
2.2 Radial360的顶点生成逻辑
Radial360是几个模式里最复杂的,它的Mesh生成思路大概是这样:
- 先计算扇形扫过的角度:
fillAmount * 360。 - 把这段角度按
crossSegments细分成若干小段,每段生成两个圆周顶点和一个中心顶点。 - 把中心顶点和两个圆周顶点组成一个三角形面片。
源码里实现的时候还会做几个特判:fillAmount接近0时直接生成一个退化三角形或者清空顶点,fillAmount接近1时直接按完整圆生成;同时内部用了一个极小的容差值,防止起始角度和终止角度重合导致除零错误。这就是为什么你看到的源码里有类似kEpicenter和kEpsilon这样的常量,前者是UV采样的中心参考点(一般就是0.5,0.5),后者是浮点精度保护。
我用一个简化版来演示核心逻辑:
public void BuildRadial360Mesh(VertexHelper vh, Rect rect, float fillAmount, int segments, bool clockwise) { vh.Clear(); Vector2 center = rect.center; float radius = Mathf.Min(rect.width, rect.height) * 0.5f; // 根据方向和起始边计算扫过的总角度 float sweepAngle = fillAmount * 360f * (clockwise ? -1f : 1f); int vertexCount = Mathf.Max(1, Mathf.CeilToInt(segments * fillAmount)); // 中心顶点 UIVertex centerVert = UIVertex.simpleVert; centerVert.position = center; centerVert.color = color; centerVert.uv0 = new Vector2(0.5f, 0.5f); vh.AddVert(centerVert); // 圆周顶点 float startAngle = GetOriginAngle(fillOrigin); for (int i = 0; i <= vertexCount; i++) { float t = (float)i / vertexCount; float angle = startAngle + sweepAngle * t; float rad = angle * Mathf.Deg2Rad; Vector2 pos = center + new Vector2(Mathf.Cos(rad), Mathf.Sin(rad)) * radius; // 添加顶点,UV按坐标映射到圆内 } // 生成三角形 for (int i = 0; i < vertexCount; i++) { vh.AddTriangle(0, i + 1, i + 2); } }实际Unity源码里会处理更多边界条件,比如顶点的UV不是简单用0.5偏移,而是基于fillAmount和角度做插值,保证填充区域显示的纹理部分正确。这里我只保留了骨架逻辑,但已经足够说明一点:Filled模式输出的网格是动态的,segments越大、fillAmount变化越频繁,重建成本越高。
2.3 实战:用底层思路做一条平滑的冷却环
理论说完,直接说项目里的做法。某款游戏里我需要做一个技能冷却外圈,要求扫过边缘平滑,不出现明显多边形折线。
第一版直接用UGUI内置Image的Filled,FillMethod选Radial360,FillOrigin设成2(顶部),Clockwise开启。跑起来发现圆弧边缘在低分辨率下还能接受,一旦图放大到屏幕四分之一尺寸,弧线就能看出明显的折线感。然后我去调crossSegments,默认是0,编辑器里没显示,代码里设置成32后效果立刻好了,但代价是顶点数从之前的几个涨到几十个。
如果冷却环只是单张图频繁刷新,这个成本其实可以接受。但如果同一个界面里有十几个冷却技能,每帧都在改fillAmount,那每帧都要重建十几套网格,叠加下来就会在低端机上出现掉帧。后来我换了一种思路:把冷却环做成一个RawImage,配合一个极简的径向渐变Shader,只用一个quad就能画出同样的效果,顶点数永远只有4个,CPU重建成本几乎为零。
这里想说的是,Image.Filled不是万能的,它适合偶尔更新、数量不多的填充效果;高频、大量、带圆弧的填充动画,尽量往Shader方向靠。理解Radial360的网格生成方式后,你会清楚它为什么贵、什么时候该绕开它。
3. 裁剪与遮罩:Mask和RectMask2D对Image的不同待遇
3.1 两者工作原理的差别
UI里经常会遇到“图片只显示某个矩形区域”的需求,比如滚动列表里的头像、聊天框里的气泡。UGUI提供两种最常见的裁剪方案:Mask和RectMask2D,它们虽然都能让Image只显示局部,但底层机制完全不同。
Mask走的是模版缓冲(Stencil Buffer)。当Mask组件启用时,Unity会额外渲染一次Mask自身来写入模板值,子物体的Image在渲染时做模板测试,只有模板值匹配的像素才通过。这套机制支持任意形状的遮罩,比如你可以专门放一张圆形Sprite做Mask,子物体只显示圆形内部。
RectMask2D则是完全另一条路。它实现了IClippable接口,走的是Canvas的裁剪系统,只支持轴对齐矩形。Unity在生成UI顶点时,会把RectMask2D的矩形范围传给Graphic,由顶点Shader在裁剪阶段把矩形外的片段剔除掉。整个过程不依赖模板缓冲,也不需要额外渲染一次Mask来写入模板值。
为了直观对比,我整理了一个表:
| 对比项 | Mask | RectMask2D |
|---|---|---|
| 遮罩形状 | 任意Sprite形状 | 仅轴对齐矩形 |
| 额外渲染Pass | 需要 | 不需要 |
| 合批影响 | 会打断合批 | 不打断合批 |
| 多级嵌套 | 模板值累加,复杂度高 | 支持多个矩形求交 |
| 适合场景 | 圆形头像、不规则气泡 | 滚动列表、面板裁剪 |
3.2 合批影响与选择建议
我在项目里最常见的问题就是:滚动列表里给每个头像都挂了一个Mask,结果draw call翻了好几倍。原因是Mask自己先要渲染一遍模板,子物体再渲染一遍,这本身就把一个Image的批次拆成了两个。如果头像列表里还有背景、文字,合批也被打断,draw call进一步涨。用Frame Debugger一看,全是Mask的模板写入Pass。
如果是纯矩形裁剪,直接用RectMask2D替换Mask,draw call能降下来不少。在某个HUD界面里,我把一个弹窗内容区从Mask换成RectMask2D,总UI draw call从38降到31,而且是在改动几乎不可见的情况下。
但别急着把所有Mask都换掉。如果遮罩形状是圆形、圆角矩形、异形气泡,RectMask2D就无能为力了。这种情况有几个替代方案:把遮罩形状直接做进图片的透明通道里,让美术出带透明区域的Sprite;或者用自定义Shader做裁剪,效率比模板缓冲高;再或者接受Mask的成本,但尽量把多个需要遮罩的节点合并到一个Mask下,减少模板写Pass次数。
我用圆角头像时比较推荐第一种做法:图片本身PS里做成圆角透明PNG,运行时不再需要任何遮罩组件。代价是内存多一点点,但渲染上完全是普通Image,合批和重建都不受影响。如果你在意内存,再考虑用Mask或自定义Shader。
3.3 Image材质与模板参数的常见误区
给Image挂Mask后,你会在CanvasRenderer的material里看到模板相关的属性,有时候Image自身的材质也会被标记成带Stencil参数的变体。这里有个误区:很多人以为Mask改的是Image的材质,于是尝试通过改材质参数来绕过模板逻辑,结果界面渲染得乱七八糟。
实际上UGUI的模板测试参数是Uniform Per Renderer级别的,Unity UI系统在渲染时会动态设置这些参数,不需要也不应该手动改。手动改material的stencil值,要么不生效,要么会影响到同一材质下其他UI的渲染,得不偿失。
还有个小坑:Mask下的子物体如果还有Mask嵌套,模板值会一直累加,嵌套层级多到一定程度,在部分移动设备上会出现边缘锯齿或者显示错乱。我在某个项目里遇到过一个三层层叠的遮罩界面,子层Image边缘总有一条细线,排查了半天,最后发现是模板值溢出的边界情况。后来把嵌套层级压到两层,并保证里面那层用矩形裁剪,问题就消失了。经验之谈:能用RectMask2D的地方就用它,能用透明通道解决的就不要上模板。
4. 性能与优化:Image卡顿的来源与排查实操
4.1 Rebuild:Image的网格什么时候会重建
要优化Image,首先要理解它的重建机制。Image作为Graphic,有三个Dirty标记:SetVerticesDirty表示顶点数据要重新生成,SetMaterialDirty表示材质属性要重新提交,SetLayoutDirty表示布局可能变化。任何属性变化,最后都会落到这几个标记上,然后在Canvas的下一轮更新里触发重建。
以fillAmount为例,它变化时会调用SetVerticesDirty,也就是每帧重建一次网格。像冷却条、加载进度条这种频繁改fillAmount的,每帧做一次小规模网格重建,问题通常不大;但如果你把fillAmount放在Update里,配合一个很大尺寸的Image,比如全屏背景用Filled模式,那每帧重建的顶点量就是另外一回事了。
类似的坑还有运行时改color。color变化触发的是SetVerticesDirty,因为颜色要写进顶点数据里。所以一个闪烁的Image如果每帧换颜色,等价于每帧重新生成一遍网格。反过来,如果你只是改RectTransform.sizeDelta,不涉及Image属性,网格不会重建,成本低得多。
排查Rebuild的思路很简单:先把Profiler打开,看Canvas.SendWillRenderCanvases和Canvas.BuildBatch这两个耗时项;如果它们很高,再用Frame Debugger查看Canvas下有多少个子批次更新了。能定位到具体是哪个Image导致的重建,再针对它做优化。
4.2 Overdraw与合批:UI性能两大拦路虎
Image数量很多但draw call正常,依然卡顿,这种情况八成是Overdraw。UGUI没有遮挡剔除,也没有Z-Buffer优先级——所有UI片段都是按层序画上去的。上层不透明的Image会把下层完全盖住,但GPU依然要在下层上跑一遍片元着色器。尤其当UI里大量使用半透明Image叠加时,同一像素可能被填充四五次,低端机的GPU压力会直线上升。
优化Overdraw的办法不多,核心是减少重叠。常见的做法是:把多张透明小图合并成一张大图;把背景层的半透明色块改成不透明;把多组临时美术资源在界面隐藏时真正SetActive(false)而不是设alpha=0。这里要特别提一句,CanvasGroup.alpha=0只是视觉透明,顶点照常渲染、合批照常参与,该做的Overdraw一点没少。想彻底不渲染,要么SetActive(false),要么把CanvasRenderer的cullTransparentMesh用起来,配合透明材质做剔除。
再说合批。Image合批的前提是:相同的材质实例、相同图集或相同Texture、相同的Shader、并且在UI树里中间没有插入其他不兼容的元素。从实用角度,项目里要避免两件事:一是每张UI小图都单独散图,二是同一个图集里既有不透明材质又有半透明材质。前者会让相邻的Image没法合批,后者会导致图集被拆成多个批次。
利用SpriteAtlas把散图打到一个图集里,是UI合批最基础的保障。如果是动态加载的图,宁可先加载图集再引用Sprite,也不要直接用路径加载散图。我在某项目里看到过运行时动态生成Sprite,结果每帧都在打断合批,后来统一改成从图集里GetSprite,Frame Debugger里的批次立刻清晰了。
4.3 一份可以直接抄的Image优化检查清单
直接给一份我平时排查Image相关性能问题用的清单,按优先级排序,照着查基本能解决大部分卡顿:
不需要响应点击的
Image,一律关闭RaycastTarget。这不仅能减少事件系统的命中测试开销,避免它挡住下层按钮的点击,还能降低Canvas重建时的事件处理成本。静态界面不要每帧改
Image的属性。如果需要闪烁或变色,尽量用Tween一次性完成,避免Update里反复触发SetVerticesDirty。滚动列表里的
Image用对象池管理。频繁Instantiate/Destroy会导致GC和Canvas频繁重建,用一个简单的List做复用池,性能会明显改善。优先使用
SpriteAtlas,并且把经常同时出现的UI放进同一个图集。Image的Sprite尽量在初始化时一次性赋值,不要在运行时反复切换不同图集的Sprite。高频动画优先用Shader方案。比如旋转、描边、径向填充,Shader里做可以做到顶点不变、只改Uniform,
Image的网格永远不重建。能用
RectMask2D就不用Mask,能不用遮罩就不用遮罩。遮罩数量和draw call成正比,尤其是带模板的Mask,使用前先估算一下界面复杂度。动态列表的滚动区域,设置较小的
RectMask2D范围,缩小裁剪区域可以减少参与渲染的UI节点数。定期用
Frame Debugger看UI draw call的断点位置。断点往往比你想的更离谱,比如一张全屏背景图把整个图集批次拆得稀碎,这种情况直接把背景图单独放一个图集或者单独一张Texture就能解决。
在实际项目里,我曾经为了一个环形冷却技能反复调试,最后发现直接用Image的Filled加上合适的Radial360细分就能满足需求,根本不用上Shader;而真正卡顿的地方反而是一个ScrollView里的头像列表——每帧加载、每帧Rebuild,还全是开着RaycastTarget的图在挡事件。回过头来看,Image本身并不难,难的是理解它每一帧在Canvas里做了什么。
这篇就把Colleague这些年跟Image打交道的底层逻辑和排查套路都摊开讲了。下次再遇到UI卡、draw call高、遮罩异常的问题,不要急着改代码,先想清楚你改的到底是Image的哪一部分,是颜色顶点、是网格形状,还是模板状态,再动手。理解到这层,Image就真的只是一个透明盒子了。