ARTICLE DETAIL

资讯详情

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

NGUI 合批规则完全解析

NGUI 合批规则完全解析 开篇:两个像素级一致的界面,DrawCall 差 60 倍某项目的背包界面,60 个格子,每格三个元素:底框(Atlas_UI)、图标(Atlas_UI)、数量文字(Font)。两个人各做了一版,截图叠在一起完全重合,Prefab 结构也几乎一样。版本 A —— 按"格子"组织 depth(符合直觉)item0: 底框 depth=0 图标 depth=1 文字 depth=2 item1: 底框 depth=3 图标 depth=4 文字 depth=5 item2: 底框 depth=6 图标 depth=7 文字 depth=8 ... item59: 底框 depth=177 图标 depth=178 文字 depth=179DrawCall: 120版本 B —— 按"类型"分层 depth所有底框:depth 100 所有图标:depth 200 所有文字:depth 400DrawCall: 2差别在哪?把两个版本"排序后的 widget 序列"画出来(A=Atlas 材质,F=Font 材质):版本 A: depth 0 1 2 3 4 5 6 7 8 ... 材质 A A F A A F A A F ... └┬─┘ │ └┬─┘ │ └┬─┘ │ DC1 DC2 DC3 DC4 DC5 DC6 ... → 120 个 版本 B: depth 100...100 200...200 400...400 材质 A A A A A A A A A A F F F F F └──────────┬─────────┘ └────┬───┘ DC1 DC2 → 2 个NGUI 的合批规则从头到尾只有一条:按 depth 排序后,【相邻】且【材质/贴图/Shader 全相同】的 widget,才能合进同一个 DrawCall关键词是"相邻"。版本 A 里的 120 个底框和图标材质全都相同,但被文字一次次隔开——材质相同没用,被隔开就是不同的 DrawCall。第一部分:合批的本质与职责边界一、为什么要合批GPU 每次渲染需要: ① 设置材质、贴图、Shader 参数 ← SetPass Call,最贵 ② 提交顶点数据 ← Draw Call ③ 执行绘制100 个独立的 Sprite: 100 次 SetPass + 100 次 Draw ↓ 合批成 1 个: 1 次 SetPass + 1 次 Draw,顶点数完全相同合批省的不是顶点,是状态切换。移动端上,一次 SetPass Call 的开销大致相当于渲染几百个三角形。这就是为什么"顶点数不变,DrawCall 从 120 降到 2"能带来巨大提升。二、职责链:谁负责哪一段┌───────────────────────────────────────────────────────────┐ │ UIWidget │ │ 职责:提供【三个身份标识】 │ │ material / mainTexture / shader │ │ 职责:提供【排序键】 │ │ depth │ │ 不负责:不知道自己会被分到哪个 DrawCall │ ├───────────────────────────────────────────────────────────┤ │ UIPanel ★ 合批决策全在这里 │ │ 职责:① 收集本 Panel 下所有 widget │ │ ② 按 depth 排序 │ │ ③ 线性扫描分组 │ │ ④ 创建/回收 UIDrawCall │ │ ⑤ 分配 renderQueue │ │ 不负责:不碰 Mesh,不碰材质属性 │ ├───────────────────────────────────────────────────────────┤ │ UIDrawCall │ │ 职责:把分到自己名下的 widget 顶点拼成一个 Mesh │ │ 管理 Material(含裁剪用的动态克隆) │ │ 不负责:完全不参与合批决策,被动接收 │ └───────────────────────────────────────────────────────────┘一个关键结论UIDrawCall 不做任何合批决策。它只是"UIPanel 扫描过程的产物"。所以优化合批 = 优化 UIPanel 的扫描输入,也就是:widget 的 depth 排列 widget 的材质分布 Panel 的划分三、三个身份标识从哪来合批比较的是material / mainTexture / shader三者。它们的来源:WidgetmaterialmainTextureshaderUISpriteatlas.spriteMaterialatlas 的贴图材质的 shaderUILabel(BMFont)bitmapFont.material字体图集贴图同上UILabel(动态字体)dynamicFont.materialUnity 动态字体贴图同上UITexture自己的material,没有则自建mTexturemShader为什么要比较三个而不是只比 material// UIPanel.FillAllDrawCallsif(mat!=mt||tex!=tx||sdr!=sd)// 开新 DrawCall因为 UITexture 可以共享同一个 material,但贴图不同:// 两个 UITexture 都用默认的 Unlit/Transparent ColoredtexA.mainTexture=heroPortrait1;// material 相同texB.mainTexture=heroPortrait2;// material 相同,但贴图不同如果只比 material,这两个会被错误地合进一个 DrawCall,渲染出来全是同一张图。第二部分:从两个 Sprite 开始,逐步加复杂度下面用六种递进情况,每次只引入一个新变量。情况 1:两个同 Atlas 的 SpritespriteA: atlas=Atlas_UI, depth=0 spriteB: atlas=Atlas_UI, depth=1扫描过程:i=0: spriteA mat/tex/sdr 都是 null → 变化 → 记录 current = Atlas_UI dc 为 null → 创建 DC1 写入 spriteA 顶点,count=1 i=1: spriteB mat/tex/sdr 与 current 相同 → 不变化 dc 不为 null → 继续用 DC1 写入 spriteB 顶点,count=2 收尾: drawCalls.Add(DC1)结果:1 个 DrawCall,包含 2 个 widget情况 2:中间插入一个不同 Atlas 的spriteA: atlas=Atlas_UI, depth=0 spriteC: atlas=Atlas_Icon, depth=1 ← 新增 spriteB: atlas=Atlas_UI, depth=2扫描过程:i=0: spriteA (Atlas_UI) → 创建 DC1,写入 i=1: spriteC (Atlas_Icon) 材质变了! → 收尾 DC1(加入 drawCalls) → 创建 DC2,写入 i=2: spriteB (Atlas_UI) 材质又变了!(从 Atlas_Icon 变回 Atlas_UI) → 收尾 DC2 → 创建 DC3,写入 收尾: drawCalls.Add(DC3)结果:3 个 DrawCall这里体现了核心机制:扫描不回头spriteA 和 spriteB 材质完全相同 但中间隔了一个 spriteC ↓ NGUI 不会"回头把 A 和 B 合起来" ↓ 只是线性往下扫,材质一变就切如果把 depth 改一下:spriteA: depth=0 (Atlas_UI) spriteB: depth=1 (Atlas_UI) ← 交换了 spriteC: depth=2 (Atlas_Icon)结果:2 个 DrawCall同样三个 widget,视觉层级关系也没变(C 仍在最上层),DrawCall 从 3 降到 2。情况 3:depth 交错(真正的杀手)回到开篇的背包案例,把规模放大:版本 A(60 格,按格子排 depth): A A F A A F A A F ... × 60 └┬┘ │ └┬┘ │ └┬┘ │ DC DC DC DC DC DC → 120 个 DrawCall版本 B(按类型分层): A A A A A ... A F F F ... F └───────┬──────┘ └────┬────┘ DC1 DC2 → 2 个 DrawCall为什么版本 B 的视觉效果没变版本 A 的层级意图: 每个格子内部:底框 图标 文字 版本 B 的层级: 所有底框(100) 所有图标(200) 所有文字(400) ↓ 对于任意一个格子,仍然是 底框 图标 文字 ✓只要格子之间不重叠,两种排法视觉完全等价。但版本 B 有代价版本 A 能做到:item5 整体盖住 item6 版本 B 做不到:因为底框/图标/文字分别在不同层场景适用哪版平铺式网格/列表(不重叠)版本 B卡牌手牌(扇形展开,互相重叠)版本 A,或每张卡独立 Panel拖拽中的元素需要盖住一切拖拽层用独立 Panel情况 4:加入文字——材质天然不同UISprite → material = Atlas_UI 的 spriteMaterial UILabel → material = 字体的 material这两个必然不同(除非用了后面第七部分的技巧)。一个常见的误判"我的按钮只有一个背景图 + 一个文字,应该合批啊"button0: 背景 depth=0 (Atlas) 文字 depth=1 (Font) button1: 背景 depth=2 (Atlas) 文字 depth=3 (Font) button2: 背景 depth=4 (Atlas) 文字 depth=5 (Font) ↓ 序列:A F A F A F → 6 个 DrawCall3 个按钮 = 6 个 DrawCall。修复// 批量设置:所有背景一层,所有文字一层foreach(varbtninbuttons){btn.background.depth=100;btn.label.depth=200;}序列:A A A F F F → 2 个 DrawCall情况 5:UITexture 的陷阱头像框(Atlas) depth=0 玩家头像(UITexture,独立贴图) depth=1 等级底(Atlas) depth=2序列:Atlas | Texture | Atlas → 3 个 DrawCall如果有 10 个玩家(组队/排行榜):[框 头像 底] × 10 = 30 个 DrawCall三种解法解法DrawCall代价头像打进 Atlas1~2头像数量必须有限(如 50 个固定头像)头像全部排到最上层3头像会盖住等级底,需要调整设计动态图集(运行时拼图)1~2实现复杂,适合自定义头像// 解法二:分层foreach(variteminteamMembers){item.frame.depth=100;// Atlasitem.levelBg.depth=110;// Atlasitem.nameLabel.depth=300;// Fontitem.avatar.depth=200;// UITexture,统一放中间层}序列:A×20 | T×10 | F×10 → 但 10 个 Texture 贴图各不相同 ↓ 实际:A(1) + T(10) + F(1) = 12 个 DrawCallUITexture 每张不同的贴图必然是独立 DrawCall,这一点无法靠 depth 解决。情况 6:跨 Panel——合批域的边界// UIPanel 各自持有自己的 drawCallspublicListUIDrawCalldrawCalls=newListUIDrawCall();Panel_A (depth=0) ├ spriteX (Atlas_UI) └ spriteY (Atlas_UI) Panel_B (depth=1) ├ spriteZ (Atlas_UI) ← 材质和上面完全相同 └ spriteW (Atlas_UI)结果:2 个 DrawCall(每个 Panel 一个) 永远不可能合成 1 个UIPanel 是合批域的硬边界。跨 Panel 绝不合批,无论材质多么相同。这导致一个常见的浪费某界面结构: MainPanel ├ Panel_Header ← 为了整体淡入淡出加的 ├ Panel_Content ← 为了整体淡入淡出加的 ├ Panel_Footer ← 为了整体淡入淡出加的 └ Panel_Buttons ← 为了整体淡入淡出加的 ↓ 每个 Panel 至少 1 个 DrawCall 4 个 Panel = 至少 4 个,哪怕它们全用同一个 Atlas修复:用 UIWidget 的 alpha 代替 Panel// ✗ 为了淡入淡出加 PanelGameObjectheader=newGameObject("Header");header.AddComponentUIPanel();// 多一个合批域// ✓ 用 UIWidget 做容器GameObjectheader=newGameObject("Header");varw=header.AddComponentUIWidget();// 不产生 DrawCall// UIWidget.alpha 会自动向下传递给所有子 widget(finalAlpha 层层相乘)TweenAlpha.Begin(header,0.3f,0f);什么时候必须加 Panel必须加不必加需要裁剪(滚动区)只是整体淡入淡出 → UIWidget.alpha需要独立 renderQueue 区间只是组织层级 → 空 GameObject需要隔离高频变化内容(性能)只是统一显示隐藏 → SetActive需要独立 sortingOrder第三部分:源码级实现过程四、第一步:排序// UIWidget.PanelCompareFunc —— NGUI 实际代码staticpublicintPanelCompareFunc(UIWidgetleft,UIWidgetright){// ① 首要:depthif(left.mDepthright.mDepth)return-1;if(left.mDepthright.mDepth)return1;// ② depth 相同 → 比材质 InstanceIDMaterialleftMat=left.material;MaterialrightMat=right.material;if(leftMat==rightMat)return0;if(leftMat==null)return1;if(rightMat==null)return-1;return(leftMat.GetInstanceID()rightMat.GetInstanceID())?-1:1;}第二条规则的隐含价值depth 相同的 widget,NGUI 会按材质 ID 自动聚拢 ↓ 所以"把同类元素设成【完全相同】的 depth",也能改善合批这解释了一个反直觉的做法:// ✗ 直觉写法:每个底框 depth 递增for(inti=0;i60;i++)items[i].bg.depth=100+i;// 100, 101, 102 ... 159// ✓ 更好:全部用同一个 depthfor(inti=0;i60;i++)items[i].bg.depth=100;// 全是 100两种写法的 DrawCall 数一样(材质都相同) 但第二种更稳健:即使中间混进一个别的材质, 材质 ID 排序会把同材质的重新聚拢但依赖材质 ID 是有风险的材质 InstanceID 是运行时分配的,每次启动可能不同 ↓ depth 完全相同的两个不同材质 widget,前后顺序不确定 ↓ "昨天跑起来图标在上面,今天变成文字在上面了"规则:允许同类元素共享 depth;不允许让【视觉上有遮挡关系】的元素共享 depth。五、第二步:线性扫描分组// UIPanel.FillAllDrawCalls —— 等价简化,保留完整逻辑voidFillAllDrawCalls(){// ── 回收所有旧 DC(进对象池,不销毁)──for(inti=0;idrawCalls.Count;++i)UIDrawCall.Destroy(drawCalls[i]);drawCalls.Clear();Materialmat=null;Texturetex=null;Shadersdr=null;UIDrawCalldc=null;intcount=0;if(mSortWidgets)SortWidgets();// ← 上一节的排序for(inti=0;iwidgets.Count;++i){UIWidgetw=widgets[i];// ── 不可见的 widget 完全跳过 ──if(!w.isVisible||!w.hasVertices){w.drawCall=null;continue;}Materialmt=w.material;Texturetx=w.mainTexture;Shadersd=w.shader;// ══════ 核心判定 ══════
返回列表