ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity UI优化:Mask遮罩如何破坏合批及四种替代方案

Unity UI优化:Mask遮罩如何破坏合批及四种替代方案 1. 先搞明白一个核心问题遮罩到底做了什么1.1 Mask组件的模板缓冲机制接触过Unity UI优化的朋友应该都受过遮罩的折磨。明明一帧只画十几个UI元素加了Mask之后DrawCall直接翻倍甚至翻三倍搞不清楚状况的同事已经在排查贴图了可是瓶颈根本不在贴图上。我最早被这个问题坑的时候压根没想到罪魁祸首就是那个看起来人畜无害的Mask组件。先说一下Mask在Unity中的真实工作方式。从底层看Mask组件靠的是模板缓冲Stencil Buffer。模板缓冲是GPU渲染管线里一个额外的缓冲区和颜色缓冲、深度缓冲平级。它存储的不是颜色而是整数值每一帧GPU都可以对模板值执行读、写、比较操作。渲染一个带Mask的UI时GPU实际上干了两件额外的事先在模板缓冲里生成一个遮罩区域的标记然后再裁剪后续像素是否落在标记范围内。UIMask的shader会把模板值写进去被它遮挡的子物体shader则加上模板测试不通过的话直接丢弃像素。对比一下RectMask2D。RectMask2D并不走模板缓冲它是在CPU侧的Cull系列调用中直接处理矩形裁剪。它更轻量逻辑也更简单没有额外的模板写入和测试开销。这一点很多人不清楚误把Mask组件当默认选项用其实在很多场景下都是性能开倒车。1.2 为什么合批会因此被打断合批的本质是提交给GPU的顶点数据能共用一份渲染状态。DrawCall的定义是一次渲染状态的切换切换内容包含Shader、纹理、混合模式、材质参数等。你只要在渲染序列里插入一个不同shader的物体那就得产生一次新的DrawCall并且批次也在这里断掉。Mask组件把这个坑放大了。因为带Mask的UI会引入一套完全不同的渲染状态——模板写入、模板测试、渲染队列的调整这些都跟普通UI不一样。所以一个Canvas里只要有一个Mask存在它的子物体以及它周围的普通UI都会被明显的批次断层隔开。说白了就是整个Canvas的合批被Mask撕裂成好几块每一块都是独立DrawCall。更狠的是多层Mask嵌套的情况。每嵌套一层渲染状态就要再换一次DrawCall次数是以指数或者至少线性级别往上走的。比如一个ScrollView里面放一个MaskMask里再套一个Mask你表面看到的只是十几个小图标实际渲染调用的次数可能比一个完整3D场景还多。这也是为什么很多Unity项目跑到后期UI的DrawCall数量轻松破百甚至破两百回头看几乎每个界面上都躺着三四个Mask。注意Mask一旦参与渲染它影响的不仅是自身和子物体还包括同一Canvas下其他本来可以合批的UI元素。这也是一个Mask毁掉整个Canvas合批这种说法的来源。2. 合批机制回顾因为越基础越容易被忽略2.1 Unity合批的三条路线要真正理解遮罩对合批的破坏力得先把Unity的合批机制捋一捋。Unity目前主要提供三种合批路线动态合批、静态合批、SRP Batcher可编程渲染管线批处理。UI系统里还有一个专门的Canvas Batcher但底层逻辑都离不开减少渲染状态切换这个核心。动态合批是运行时自动完成的。Unity会检测两个物体的Mesh数据是否满足条件比如顶点数不超过900、面数不超过某个限制并且材质和shader一致然后把它们拼到同一个Mesh里提交。对于UI来说Canvas系统有一套自己的动态合并逻辑它会把同一Canvas下所有可合并的UI元素组合成一个或少数几个Mesh。这个合并结果是按层级顺序计算的只要中间插入一个不同材质或不同shader的节点合并就停止。静态合批是针对场景里不动物体的运行前进行预处理。注意UI一般不会走静态合批因为UI是动态变化的Canvas系统本身就相当于自带动态合批。SRP Batcher则走的是另一条路它不合并顶点而是通过快速切换材质参数的缓冲来大幅降低CPU端的提交开销前提是Shader要兼容SRP Batcher。问题来了遮罩组件用的Shader和普通UI的Shader不同模板测试状态也不同。这三条合批路线随便哪一条遇到Mask都得停下来。尤其是UI下如果混用了Mask和普通Image那么Canvas的批处理系统会把Mask当成一个栅栏把连续的合批区域从中间切断。2.2 UI合批的隐形规则这里聊一个很多人不知道的细节Unity的UI合批并不只看材质和贴图它还看渲染的先后层级和自定义的material实例。一个UI元素如果被插入到两个可合批元素中间它可能会阻断前后两部分的合批使得本来可以合并的两个元素被迫拆开增多DrawCall。举个具体例子。一个界面上有A、B、C三张图A和C都用默认图集里的同一个小图中间B是带Mask的ScrollView。看起来A和C材质、贴图都一样理论上可以合批。但实际渲染顺序是A - B - CB的shader和模板状态跟A、C不同所以Canvas会先把A和B各提交一次C再单独提交。你以为是一次DrawCall实际是三次。所以当你看到某项性能报告上写着某界面DrawCall从15变成45排查时别光盯着贴图和材质先数数界面上有几个Mask凡是有Mask的地方一定存在批次断裂。没有例外。3. 踩坑实录一个滚动列表引发的性能灾难3.1 场景复现从帧率暴跌到定位问题说个我自己实际踩过的坑。之前接手了一个活动界面的优化界面上有一块圆形的头像展示区域需求是头像要支持裁切成圆形并且下面有一串可滑动的道具列表列表外面是一个矩形遮罩。最初同事实现得很直接头像加Mask列表外面的ScrollView加Mask嵌套在同一个Canvas下面。功能倒是跑起来了但是真机在低端机上帧率掉得厉害Profiler一抓CPU侧Canvas.Batch耗时飙升到每帧8毫秒左右GPU侧的RenderPass数量也翻了好几倍。当时第一反应是不是头像图片太大了或者列表里的元素太多了。压缩图片、减少列表项、开图集一顿操作下来帧率还是没回来。最后用Frame Debugger逐帧去看DrawCall才发现每一个列表项都被Mask和普通UI之间的状态切换拆成了单独的批次。列表假设20个可见项Mask本身一次、列表内容被切成了20多次再加上头像Mask两次整个界面直接接近40个DrawCall。这个案例说明一个道理遮罩的批次破坏力不看它本身有多复杂而看它挡在多少可合批元素前面。Mask就是一个大坝把它撤掉后面的水就汇成一股把它留着后面的水全部打散。3.2 实测数据三种方案的性能对比为了把这个问题讲透我当场做了一个小实验。同一个界面同一台测试机分三种实现方式第一种用Mask组件第二种用RectMask2D第三种用拆分Canvas加RectMask2D。测试指标是DrawCall数量和CPU耗时以Unity Profiler中的数据为准。实现方式DrawCall数Canvas.Batch耗时备注原始Mask方案427.8ms列表被切成每项一帧替换为RectMask2D173.2ms矩形区域可批量裁剪拆分Canvas RectMask2D92.1ms遮罩区域独立渲染当然数据会受设备影响但趋势是稳定的。可以发现RectMask2D对合批的破坏程度明显低于Mask组件因为它少了一次模板写入的开销而且RectMask2D在设计之初就更多地考虑了矩形裁剪场景下的批量处理。不过它也有代价那就是它只能做矩形裁剪没法做圆形和异形遮罩。4. 解决方案与替代路线4.1 方案一把Mask替换成RectMask2D能用RectMask2D的地方优先用RectMask2D。它最大的优势是裁剪逻辑简单只支持矩形范围但UI里大部分遮罩场景都是矩形比如滚动列表、聊天框、背包格子根本用不上异形裁剪。你们项目里如果有一堆圆形头像遮罩其实也可以考虑用SpriteMask或者直接把图片做成圆形白底但后头再细说。RectMask2D还有一个隐藏优势它允许同一层级下多个RectMask2D实例存在并且裁剪操作可以共享。这意味着两个相邻的RectMask2D如果矩形范围不重叠理论上它们的绘制可以被合到同一个批次里。实测下来这比Mask组件每次切换模板状态要友好得多。注意RectMask2D只对Graphic类型组件生效如果你在它的范围内放的不是UI Graphic而是UI粒子特效那RectMask2D是不裁剪粒子的。这种场景该用Mask还是得用Mask但使用前一定要想清楚粒子特效的量级和目标机型的承受能力。4.2 方案二把遮罩区域拆成独立Canvas拆Canvas是一个立竿见影的方案但很多人拆错了方向。正确的拆法是按渲染状态分组而不是按业务逻辑分组。业务上一个完整的界面是一个模块看起来顺理成章应该放同一个Canvas但渲染状态一旦割裂同一个Canvas会变成合批孤岛的集合反而比拆开更慢。拆Canvas的原则是把带遮罩的区域单独拆成一个Canvas或者把遮罩区和下面的普通内容拆开让遮罩区的内容和它周围的普通UI不再争夺同一个合批序列。比如头像圆形遮罩那块你可以把那块放到一个单独的Canvas里下面列表区用另一个Canvas每个Canvas各自的合批逻辑都能顺利跑完互不干扰。要注意的是Canvas拆分的代价是增加OverDraw管理和排序负担。Unity中多个Canvas如果层级重叠会有额外的OverDraw检查深度排序也要靠Canvas的SortingOrder管理。拆之前先想好层级结构避免出现Canvas互相穿插那会比不拆更麻烦。一般建议把拆出来的Canvas的数量控制在个位数每增加一个Canvas都要在Profiler里对比一下净收益。4.3 方案三用Shader和贴图技巧绕过遮罩有些非遮罩不可的场景其实可以换个思路绕过去。圆形头像这种需求最笨但最高效的替代方案就是直接用一张带Alpha通道的圆形图片。图片中心不透明、边缘透明配合图集压缩完了。渲染时它跟普通Image完全一样没有任何模板操作合批也不受影响。如果要求背景不能透过圆形头像显示出来那就得给头像加一层背景。这种背景其实也就是一张Image放到头像下面设置好层级让它刚好露一圈边缘出来视觉效果就和Mask裁切一样但完全没有使用Mask组件。这种方法在效率上是极高的因为所有的元素都保持普通UI的渲染状态合批序列完全不会被打断。异形遮罩也可以考虑SpriteMask加自定义Shader的玩法。SpriteMask走的是另一套渲染管线它的本质也是模板缓冲但它的设计理念是面向2D精灵的在大量场景下会比UI中的Mask组件更轻一些。不过SpriteMask在UI下的支持度还是有限需要部分手写Shader调模板测试对大多数项目来说性价比一般。4.4 方案四UI字体的遮罩处理这里补一个很多人踩过的坑Text组件的遮罩。Text在UI中的合批本来就比较特殊因为字体纹理的打包和动态合批逻辑跟图片不一样如果Text外面套一个Mask那批次破坏效应只会更糟。处理Text的遮罩靠谱的手段是将文本框范围嵌入RectMask2D的矩形裁剪区域。如果文本要滚动的场景直接在ScrollView的Viewport上挂RectMask2D不要用Mask。RectMask2D能够确保文本的Mesh被裁剪但不会打断文字和背景之间的合批。在我实际的项目里一个带滚动文本的界面从Mask切换到RectMask2D后DrawCall直接降了60%。如果遇到必须用Mask来裁剪Text的情况比如文本框在圆形区域内就先检查Text的size和字体大小尽量压缩顶点数还可以把Text烘焙成纹理再显示。把文本转成图片纹理看似是一个搬起石头砸自己脚的方案实际上避免了每次文字内容变化时的重建Mesh以及Mask造成的批次断层。要不要走这条路得看项目团队对动态文本和本地化内容的要求。如果文本内容稳定我非常推荐烘焙方案。5. 工具链与排查方法快速定位遮罩引起的批次问题5.1 用Frame Debugger逐帧取证排查合批问题最直观的工具是Frame DebuggerWindow - Analysis - Frame Debugger。打开后逐帧调试可以看到当前帧内的所有DrawCall以及每个DrawCall对应的渲染状态。在UI界面里如果发现多条DrawCall之间的shader或者stencil状态存在差异并且其中一部分来自Mask相关的shader那就基本可以锁定遮罩是元凶。Frame Debugger里还有一个非常实用的能力点开某个DrawCall后能看到它所引用的网格数据、材质信息和shader变体。对于遮罩问题重点是看stencil操作是否有Enable以及渲染队列是否为Transparent。UI组件的常规透明队列和Mask的stencil pass混在一起时Frame Debugger会直接把它们清晰区分开初学者也很容易看出端倪。我一般会这样操作先开启Frame Debugger然后进入目标界面记录一下总的DrawCall数然后在Game窗口里逐个关闭可能引起疑虑的组件比如把Mask组件勾选掉再看DrawCall的下降幅度。下降了多少就是Mask造成的额外开销。这个减法实验非常有效能帮你快速确认为什么帧率上不来。技巧不要只看DrawCall数量还要看渲染状态切换的次数。Frame Debugger下方的ShaderProperties面板会显示每次切换时的材质参数凡是从默认UI材质切换到Mask相关材质的位置都可以标注出来重点核查。5.2 Profiler与Stats窗口的配合使用排查遮罩问题时Stats窗口Game视图右上角是一个零成本的快速观察点。Stats窗口的Batches数值会显示当前场景里的批次数量。如果界面上元素不多但Batches很高多半是有遮罩或特殊的shader中断了合批。Profiler则要重点看UI模块下的Canvas.Batch和Canvas.RenderOverdraw。Canvas.Batch是CPU侧为UI合批计算所花的耗时如果这个值异常高说明UI的合批逻辑出现了瓶颈。配上Frame Debugger去看每一帧的DrawCall情况基本上就能定位到遮罩所在的界面。补充一个容易忽略的点Profiler里统计的DrawCall在GPU端可能不是均匀分布的。有时Canvas.Batch不高但GPU端的Overdraw很高这是因为遮罩写入模板缓冲之后所有子物体要被绘制两次一次是模板写入pass一次是模板测试pass。这个双pass开销在Profiler里会被统计到但很多人分不清它是哪来的。看到UI的RenderPass数量高于逻辑元素数量时优先怀疑Mask。5.3 常见问题速查表现象原因排查手段推荐解决UI元素不多但DrawCall高Mask或特殊shader打断合批Frame Debugger查看stencil状态换RectMask2D或拆分Canvas同一界面Frame Debugger出现大量重复材质材质实例互相不兼容检查是否需要MaterialPropertyBlock统一材质参数减少自定义材质实例滚动列表掉帧严重ScrollView和内容合批被Mask切断Stats窗口看BatchesViewport区域用RectMask2D矩形裁剪区域出现异常白边RectMask2D的裁剪边界有子像素偏移检查Pixels Per Unit和Canvas缩放调整像素对齐设置RectMask2D的Padding粒子特效无法被遮罩正确裁剪RectMask2D不支持粒子渲染查看粒子特效的材质类型使用Mask或自定义粒子ShaderUI后处理特效导致所有批次失效后处理材质本身需要全局状态切换Profiler查看RenderFeature耗时尽量用Overlay相机避免全屏后处理叠加UI这个表是按我实际排查问题的顺序总结的不一定覆盖所有情况但覆盖了大部分UI遮罩相关的坑。6. 实操案例一个社交头像模块的改造过程拿一个我最近做的头像模块改造来收尾。这个模块包含一个圆形头像、一圈金色描边、一个在线状态小圆点和底下的昵称文字。最初的实现用的是Mask组件裁圆形头像然后在同一个Canvas下面又放了不少其他信息。DrawCall一共38个在低端机上表现很拉胯。改造开始。第一步把圆形头像的Mask组件删掉换成预先切割好的圆形PNG图片。其实圆形头像本质不要求背景透明我就直接改用圆形的资源贴图加圆形边框的贴图二者都是普通Image和描边、状态点的渲染状态完全一致理论上可以连续合批。描边和状态点也没必要额外裁切直接用带透明通道的圆环图片压上去就好。第二步处理头像底下的昵称文本。原来昵称是在一个带Mask的框里滑动的后来我把这个框改成了RectMask2D挂到Viewport上同时把整个列表区域拆分到独立Canvas避免它和其他静态UI争夺合批序列。文字部分保持不变还是动态的Text组件但没有Mask的干扰后Text的Mesh生成和渲染都比之前顺滑。改造完后的数据是DrawCall从38降到了11Canvas.Batch从5.6毫秒降到了1.2毫秒帧率在低端机上从28帧回升到了接近满帧。整个过程没有引入任何复杂的Shader也没新增任何插件纯粹靠理解遮罩的本质和合批的规则。这种改法最大的好处是可维护性。后续加需求、换图、改文案都不会影响现有的渲染结构既没有自定义Shader依赖也没有层级叠加上限风险。如果项目里还有很多类似的为了好看而加Mask的地方也完全可以按这个思路逐个替换。7. 关于遮罩合批我的最后几点体会做了这么多年Unity优化我最大的体会就是遮罩这个东西是典型的收益递减设计。遮罩提供的视觉裁剪效果看似很爽但背后的成本是双份的——GPU端多了模板写入和测试的开销CPU端多了合批断层的计算压力。所以新手和老手的区别往往就在于能不能在最开始就判断出这个遮罩要不要上、要用哪一种。我们团队现在的内部规范是能用贴图实现的效果绝不加Mask能用RectMask2D实现的矩形裁剪绝不叠Mask必须用Mask的场景一定单独拆Canvas并且控制元素数量。这套规则执行下来UI模块的DrawCall率基本稳定后面再来优化的人也轻松不少。如果你手头现在就有一个被Mask拖垮的界面我建议你先别急着删功能和换素材先打开Frame Debugger看一眼是不是Mask在作祟。如果是就按前面写的几种方案逐个对比选性价比最高的那一条落地。遮罩合批的问题不会自己消失但它绝对有办法解决。
返回列表