引子想象你是一个厨师手艺精湛炒菜飞快。但你有个怪脾气每接一个订单你都要重新洗手、换围裙、调火候、摆盘子。哪怕连续三个客人都点了同一道菜你也要重复三次准备工作。你炒菜只要10秒但准备工作要30秒。三个订单准备90秒 炒菜30秒 120秒。如果有个聪明的服务员把三个相同的订单合成一张单子递给你呢准备30秒 炒菜30秒 60秒。省了一半时间。UIDrawCall就是那个聪明的服务员。它把多个UI元素的绘制请求合并成一张订单一次性递给GPU这个大厨。GPU少做几次准备工作画面就更流畅。一、什么是DrawCall1.1 GPU大厨的规矩GPU是一个极其高效的画家。 它一秒钟能画几百万个三角形。 但它有一个规矩 每次画之前你必须告诉我 用什么纹理 用什么着色方式 画哪些顶点 全部准备好我才动笔。 中途不许换。要换重新来。 每次CPU走到GPU面前说请画这批东西 就产生一次DrawCall。 一次DrawCall的时间开销 ┌─────────────────────────────────────┐ │ │ │ 准备阶段固定开销 │ │ 设置纹理 ██ │ │ 设置Shader ██ │ │ 传输顶点数据 ████ │ │ 切换渲染状态 ██ │ │ │ │ 绘制阶段实际工作 │ │ 画三角形 █ │ │ │ │ 注意准备阶段比绘制阶段还长 │ │ 画4个三角形和画400个三角形 │ │ 绘制时间差不多但准备时间一样长。 │ │ │ └─────────────────────────────────────┘ 所以 100个元素 × 100次DrawCall 100次准备 100次绘制 100个元素 × 3次DrawCall 3次准备 3次绘制 省了97次准备 在手机上一次DrawCall的准备开销约0.1~0.5毫秒。 100次就是10~50毫秒。 一帧只有16毫秒60fps。 光准备就超时了还画什么1.2 UIDrawCall的角色在NGUI的世界里 UIWidgetUISprite、UILabel等 菜品 UIPanel 餐厅经理 UIDrawCall 服务员 GPU 厨师 餐厅经理UIPanel的工作 审视所有菜品Widget 把能合并的订单归到同一个服务员DrawCall 让服务员去找厨师GPU 一个服务员DrawCall手里拿着一张大订单 厨师这10道菜都用同样的食材和做法 我一次性告诉你你一口气做完。 层级关系 UIPanel餐厅经理 │ ├── DrawCall #1服务员小王 │ │ 订单用图集A画以下内容 │ ├── UISprite 背景 │ ├── UISprite 边框 │ └── UISprite 图标 │ ├── DrawCall #2服务员小李 │ │ 订单用图集B画以下内容 │ ├── UISprite 头像 │ └── UISprite 装饰 │ └── DrawCall #3服务员小张 │ 订单用字体纹理画以下内容 ├── UILabel 名字 └── UILabel 描述 8个Widget3个DrawCall。 GPU只需要准备3次而不是8次。二、DrawCall里装了什么2.1 一张完整的订单把一个DrawCall想象成一个快递箱。 箱子里装着GPU画画需要的所有东西 ┌─── DrawCall快递箱────────────────────────────┐ │ │ │ 材质信息用什么画 │ │ ┌────────────────────────────────────────────┐ │ │ │ 纹理 图集A.png │ │ │ │ Shader Unlit/Transparent Colored │ │ │ │ 材质 纹理 Shader 组合而成 │ │ │ └────────────────────────────────────────────┘ │ │ │ │ 网格数据画什么形状 │ │ ┌────────────────────────────────────────────┐ │ │ │ │ │ │ │ Sprite背景 Sprite边框 │ │ │ │ v0────v1 v4────v5 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ v3────v2 v7────v6 │ │ │ │ │ │ │ │ Sprite图标 │ │ │ │ v8────v9 总计 │ │ │ │ │ │ 12个顶点 │ │ │ │ │ │ 6个三角形 │ │ │ │ v11───v10 18个索引 │ │ │ │ │ │ │ └────────────────────────────────────────────┘ │ │ │ │ 顶点属性每个角的详细信息 │ │ ┌────────────────────────────────────────────┐ │ │ │ 位置 [(0,0), (100,0), (100,80), ...] │ │ │ │ UV [(0.1,0.2), (0.3,0.2), ...] │ │ │ │ 颜色 [白, 白, 白, 红, 红, 红, ...] │ │ │ └────────────────────────────────────────────┘ │ │ │ │ ✂️ 裁剪参数哪些部分要剪掉 │ │ ┌────────────────────────────────────────────┐ │ │ │ 裁剪区域(0, 0, 400, 300) │ │ │ │ 柔和边缘(20, 20) │ │ │ └────────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────┘2.2 核心代码结构publicclassUIDrawCall:MonoBehaviour{// ═══════════════════════════════════════// 材质三件套——用什么颜料画// ═══════════════════════════════════════MaterialmDynamicMat;// 动态材质运行时创建TexturemTexture;// 纹理图集贴图ShadermShader;// 着色器怎么着色// ═══════════════════════════════════════// 网格数据——画什么形状// ═══════════════════════════════════════MeshmMesh;// 合并后的大网格ListVector3mVerts;// 所有顶点的位置ListVector2mUVs;// 所有顶点的纹理坐标ListColormColors;// 所有顶点的颜色ListintmIndices;// 三角形索引// ═══════════════════════════════════════// Unity渲染组件——递给GPU的工具// ═══════════════════════════════════════MeshFiltermFilter;// 持有网格MeshRenderermRenderer;// 提交绘制// ═══════════════════════════════════════// 归属信息——属于哪个餐厅// ═══════════════════════════════════════UIPanelmPanel;// 所属PanelintmRenderQueue;// 渲染顺序}三、合批——把订单归堆3.1 什么能合并两个Widget能被合并到同一个DrawCall的条件 ┌──────────────────────────────────────────┐ │ │ │ 条件1同一张纹理 │ │ │ │ 都用图集A → ✓ 可以合 │ │ 一个图集A一个图集B → ✗ 不行 │ │ │ │ 这就像炒菜 │ │ 都用鸡肉的菜可以一锅炒 │ │ 一个要鸡肉一个要牛肉分开炒 │ │ │ │ 条件2同一个Shader │ │ │ │ 都用普通Shader → ✓ 可以合 │ │ 一个普通一个带模糊 → ✗ 不行 │ │ │ │ 这就像做法 │ │ 都是红烧可以一起做 │ │ 一个红烧一个清蒸分开做 │ │ │ │ 条件3同一个Panel │ │ │ │ 同一个Panel下 → ✓ 可以合 │ │ 不同Panel → ✗ 不行 │ │ │ │ 这就像餐厅 │ │ 同一桌的菜可以一起上 │ │ 不同桌的菜分开上 │ │ │ │ 条件4深度连续最关键 │ │ │ │ 中间没有被其他材质打断 → ✓ 可以合 │ │ 中间插了别的材质 → ✗ 不行 │ │ │ │ 这个最容易踩坑下面详细讲 │ │ │ └──────────────────────────────────────────┘3.2 深度连续——最容易犯的错NGUI按depth从小到大排列所有Widget 然后从前往后扫描遇到相同材质就合并。 想象一条传送带Widget按depth排队 ════════════════════════════════════════════ 好的排列同类聚在一起 ════════════════════════════════════════════ 传送带→ [A] [A] [A] [B] [B] [C] [C] [C] → 服务员扫描 看到A → 开一张新单DrawCall #1 又是A → 加到这张单上 又是A → 加到这张单上 看到B → 材质变了开新单DrawCall #2 又是B → 加到这张单上 看到C → 材质变了开新单DrawCall #3 又是C → 加到这张单上 又是C → 加到这张单上 结果3个DrawCall ✓ 完美 ════════════════════════════════════════════ 坏的排列不同类交叉穿插 ════════════════════════════════════════════ 传送带→ [A] [B] [A] [C] [B] [A] [C] [B] → 服务员扫描 看到A → 开新单 #1 看到B → 材质变了开新单 #2 看到A → 材质又变了开新单 #3 不能回去合并到#1因为顺序不能乱 看到C → 开新单 #4 看到B → 开新单 #5 看到A → 开新单 #6 看到C → 开新单 #7 看到B → 开新单 #8 结果8个DrawCall ✗ 灾难 同样8个Widget排列不同DrawCall差了5倍。 ════════════════════════════════════════════ 为什么不能回去合并 ════════════════════════════════════════════ 因为渲染有顺序depth小的先画大的后画。 后画的会遮住先画的。 如果把depth3的A合并到DrawCall #1depth 1的A 它就会被提前绘制遮挡关系就错了。 想象叠扑克牌 第1张红色A 第2张黑色B盖在A上面 第3张红色A盖在B上面 你不能把第3张和第1张放在一起 因为那样第3张就跑到B下面去了。 ════════════════════════════════════════════ 黄金法则 ════════════════════════════════════════════ ┌──────────────────────────────────────────┐ │ │ │ 让使用同一图集的Widget │ │ 在depth上连续排列 │ │ │ │ 好AAABBBCCC → 3个DrawCall │ │ 坏ABCABCABC → 9个DrawCall │ │ │ │ 一个简单的depth规划就能 │ │ 让DrawCall数量减少数倍 │ │ │ └──────────────────────────────────────────┘四、网格合并——把碎片拼成大块4.1 合并过程当Panel决定哪些Widget属于同一个DrawCall后 DrawCall要把它们的顶点数据合并成一个大网格。 合并前3个独立的Sprite Sprite 背景 Sprite 按钮 Sprite 图标 各自有4个顶点 各自有4个顶点 各自有4个顶点 各自有2个三角形 各自有2个三角形 各自有2个三角形 ┌──────┐ ┌──────┐ ┌──────┐ │0 1│ │0 1│ │0 1│ │ │ │ │ │ │ │3 2│ │3 2│ │3 2│ └──────┘ └──────┘ └──────┘ 索引0,1,2,0,2,3 索引0,1,2,0,2,3 索引0,1,2,0,2,3 合并后一个大网格 ┌──────┐ ┌──────┐ ┌──────┐ │0 1│ │4 5│ │8 9│ │ │ │ │ │ │ │3 2│ │7 6│ │11 10│ └──────┘ └──────┘ └──────┘ 顶点数组[v0,v1,v2,v3, v4,v5,v6,v7, v8,v9,v10,v11] UV数组 [uv0,uv1,..., uv4,uv5,..., uv8,uv9,...] 颜色数组[c0,c1,..., c4,c5,..., c8,c9,...] 索引数组[0,1,2, 0,2,3, ← 背景的两个三角形 4,5,6, 4,6,7, ← 按钮的两个三角形 8,9,10, 8,10,11] ← 图标的两个三角形 注意索引的变化 第二个Sprite的索引从0开始变成了从4开始 第三个Sprite的索引从0开始变成了从8开始 因为它们的顶点在大数组中的位置偏移了4.2 代码中的合并/// summary/// 填充DrawCall的网格数据/// Panel收集好所有Widget的顶点后调用这个方法/// /summarypublicvoidSet(ListVector3verts,ListVector2uvs,ListColorcols){// 计算三角形索引intcountverts.Count;// 每4个顶点组成一个四边形2个三角形// 索引模式0,1,2, 0,2,3, 4,5,6, 4,6,7, ...// 把数据灌入MeshmMesh.Clear();mMesh.verticesverts.ToArray();mMesh.uvuvs.ToArray();mMesh.colorscols.ToArray();mMesh.trianglesGenerateIndices(count);// Mesh准备好了MeshRenderer会在渲染时提交给GPU}整个过程就像拼积木 每个Widget是一小块积木4个顶点2个三角形。 DrawCall把所有小积木拼成一大块。 然后一次性递给GPU。 GPU看到的不是3个Sprite 而是一个有12个顶点、6个三角形的网格。 它不知道也不关心这些三角形原本属于谁。 它只管一口气全画完。五、DrawCall的一生5.1 生命周期一个DrawCall从出生到死亡的完整故事 ┌──────────────────────────────────────────────────┐ │ │ │ 第一幕出生 │ │ │ │ Panel发现有一批Widget需要新的DrawCall │ │ 材质和之前的不同或者是第一批 │ │ │ │ Panel说创建一个新的DrawCall │ │ │ │ → 创建GameObject │ │ → 添加MeshFilter和MeshRenderer组件 │ │ → 创建动态材质Texture Shader │ │ → 创建空的Mesh │ │ │ │ DrawCall诞生了但还是空的等待填充数据。 │ │ │ └──────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────┐ │ │ │ 第二幕填充 │ │ │ │ Panel遍历属于这个DrawCall的所有Widget │ │ 每个Widget贡献自己的顶点、UV、颜色 │ │ │ │ Widget们排队交出自己的数据 │ │ │ │ 背景Sprite这是我的4个顶点。 │ │ 按钮Sprite这是我的4个顶点。 │ │ 图标Sprite这是我的4个顶点。 │ │ │ │ DrawCall把它们全部收集起来 │ │ 合并成一个大网格灌入Mesh。 │ │ │ └──────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────┐ │ │ │ 第三幕设置材质参数 │ │ │ │ 如果Panel有裁剪DrawCall还要设置裁剪参数 │ │ │ │ mDynamicMat.SetVector(_ClipRange0, clipRange);│ │ mDynamicMat.SetVector(_ClipArgs0, clipArgs); │ │ │ │ 这些参数会传给Shader │ │ 让Shader知道哪些像素该显示哪些该裁掉。 │ │ │ └──────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────┐ │ │ │ 第四幕渲染 │ │ │ │ Unity的渲染管线看到MeshRenderer │ │ 自动把这个DrawCall提交给GPU。 │ │ │ │ GPU收到订单 │ │ 1. 绑定纹理图集A │ │ 2. 绑定Shader │ │ 3. 上传顶点数据 │ │ 4. 一口气画完所有三角形 │ │ │ │ 一次DrawCall完成 │ │ │ └──────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────┐ │ │ │ 第五幕更新每帧可能发生 │ │ │ │ 如果某个Widget发生了变化 │ │ 位置移动了 │ │ 颜色变了 │ │ 透明度变了 │ │ 显示/隐藏了 │ │ │ │ Widget会标记自己为脏dirty。 │ │ Panel在下一帧的LateUpdate中检测到脏标记 │ │ 重新收集顶点数据重新填充DrawCall的Mesh。 │ │ │ │ 注意只要Widget的材质没变 │ │ DrawCall本身不需要重建只需要更新网格数据。 │ │ │ └──────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────┐ │ │ │ 第六幕死亡 │ │ │ │ 当DrawCall不再需要时所有Widget都被销毁了 │ │ 或者Panel被销毁了DrawCall被回收。 │ │ │ │ → 销毁Mesh │ │ → 销毁动态材质 │ │ → 销毁GameObject │ │ │ │ 或者放入对象池等待下次复用。 │ │ │ └──────────────────────────────────────────────────┘六、Panel如何分配DrawCall6.1 分配算法Panel的LateUpdate中核心逻辑大致如下 第一步收集所有可见的Widget按depth排序 排序后 depth 1: Sprite_A图集X depth 2: Sprite_B图集X depth 3: Sprite_C图集X depth 4: Label_D 字体Y depth 5: Label_E 字体Y depth 6: Sprite_F图集Z 第二步从前往后扫描分配DrawCall 当前DrawCall null 当前材质 null 扫描depth 1Sprite_A材质图集X 当前材质为空 → 创建DrawCall #1材质图集X 把Sprite_A的顶点加入DrawCall #1 扫描depth 2Sprite_B材质图集X 材质和当前相同 → 继续用DrawCall #1 把Sprite_B的顶点加入DrawCall #1 扫描depth 3Sprite_C材质图集X 材质和当前相同 → 继续用DrawCall #1 把Sprite_C的顶点加入DrawCall #1 扫描depth 4Label_D材质字体Y 材质变了 → 创建DrawCall #2材质字体Y 把Label_D的顶点加入DrawCall #2 扫描depth 5Label_E材质字体Y 材质和当前相同 → 继续用DrawCall #2 把Label_E的顶点加入DrawCall #2 扫描depth 6Sprite_F材质图集Z 材质变了 → 创建DrawCall #3材质图集Z 把Sprite_F的顶点加入DrawCall #3 第三步填充每个DrawCall的Mesh DrawCall #1.Set(顶点ABC的数据) DrawCall #2.Set(顶点DE的数据) DrawCall #3.Set(顶点F的数据) 结果6个Widget3个DrawCall。6.2 一个真实的UI界面假设你有一个角色信息面板 ┌─────────────────────────────────────┐ │ ┌─────┐ 角色名称 │ ← 背景图集A │ │头像 │ 等级50 │ ← 头像图集B │ │ │ 职业战士 │ ← 文字字体C │ └─────┘ │ │ ┌──────────────────────────────┐ │ │ │ ❤️ 生命值 ████████████░░░░ │ │ ← 血条图集A │ │ 魔法值 ██████░░░░░░░░░░ │ │ ← 蓝条图集A │ └──────────────────────────────┘ │ │ │ │ [攻击力: 1500] [防御力: 800] │ ← 文字字体C │ │ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ │ │技能│ │技能│ │技能│ │技能│ │ ← 技能图标图集A │ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │ │ └────┘ └────┘ └────┘ └────┘ │ └─────────────────────────────────────┘ 如果depth安排得好 depth 1-5: 背景、血条、蓝条、技能图标图集A→depth 1-5: 背景、血条、蓝条、技能图标图集A→ DrawCall #1depth 6: 头像图集B → DrawCall #2depth 7-10: 所有文字字体C → DrawCall #3结果整个角色面板只需要3个DrawCall十几个Widget3次GPU调用非常高效。如果depth安排得不好depth 1: 背景图集Adepth 2: 头像图集B ← 打断了depth 3: 角色名称字体C ← 又打断了depth 4: 血条背景图集A ← 又变回A但不能合并到#1depth 5: 生命值文字字体C← 又打断了depth 6: 蓝条背景图集A ← 又变回A…depth 7: 魔法值文字字体Cdepth 8: 技能图标1图集Adepth 9: 技能图标2图集Adepth 10: 技能图标3图集Adepth 11: 技能图标4图集Adepth 12: 攻击力文字字体Cdepth 13: 防御力文字字体C结果13个Widget可能产生8-9个DrawCall同样的界面差了3倍。┌──────────────────────────────────────────┐│ ││ 优化口诀 ││ ││ “同图集的Widgetdepth要连续” ││ “先画所有背景再画所有文字” ││ “不要让不同图集的Widget交叉排列” ││ │└──────────────────────────────────────────┘--- ## 七、动态更新——Widget变了怎么办 ### 7.1 脏标记机制UI不是静态的。按钮会变色血条会缩短文字会改变。当Widget发生变化时DrawCall需要更新。但不是每次变化都重建DrawCall。NGUI用了一套脏标记机制来优化┌──────────────────────────────────────────────────┐│ ││ 场景玩家受到攻击血条缩短 ││ ││ 第1步血条Sprite修改了自己的宽度 ││ sprite.width 150; // 原来是200 ││ ││ 第2步Sprite内部标记自己为脏 ││ mChanged true; ││ ││ 第3步通知Panel ││ “老板我变了下一帧记得更新我” ││ ││ 第4步Panel在LateUpdate中检查 ││ 发现有脏Widget ││ 重新收集该DrawCall的所有顶点数据 ││ 调用DrawCall.Set()更新Mesh ││ ││ 注意 ││ 只更新网格数据不重建DrawCall ││ 材质没变GameObject没变只是Mesh的顶点变了 ││ 这比销毁重建快得多 ││ │└──────────────────────────────────────────────────┘用餐厅的比喻客人说我的牛排要七分熟不是五分熟。 服务员不需要重新开一张单子。 他只需要在原来的单子上划掉五分熟 写上七分熟然后告诉厨师。 但如果客人说我不要牛排了换鱼 那可能就需要换一张单子了材质变化→重建DrawCall。### 7.2 什么情况会导致DrawCall重建┌──────────────────────────────────────────────────┐│ ││ 轻量更新只更新Mesh不重建DrawCall ││ ││ ✦ Widget位置移动 ││ ✦ Widget大小改变 ││ ✦ Widget颜色/透明度改变 ││ ✦ UILabel文字内容改变 ││ ✦ UISprite的sprite name改变 ││ 只要还在同一个图集内 ││ ││ → 只需要重新填充顶点数据 ││ → 开销较小 ││ ││ ││ 重量更新需要重建DrawCall ││ ││ ✦ Widget更换了图集 ││ ✦ Widget更换了Shader ││ ✦ Widget的depth改变导致排序变化 ││ ✦ 新增或删除Widget ││ ✦ Widget从一个Panel移到另一个Panel ││ ││ → 需要重新分配DrawCall ││ → 可能创建新的、销毁旧的 ││ → 开销较大应尽量避免频繁触发 ││ │└──────────────────────────────────────────────────┘--- ## 八、裁剪——DrawCall的隐形剪刀 ### 8.1 Panel裁剪与DrawCall很多UI需要裁剪滚动列表只显示窗口内的内容超出窗口的部分要剪掉。┌──────────────────────────────────────────┐│ ││ Panel的裁剪区域可见窗口 ││ ┌──────────────────────┐ ││ │ ┌────┐ │ ││ │ │Item│ ← 完全可见 │ ││ │ └────┘ │ ││ │ ┌────┐ │ ││ │ │Item│ ← 完全可见 │ ││ │ └────┘ │ ││ │ ┌────┐ │ ││ │ │Ite │ ← 半可见 │ ││ └──│────│──────────────┘ ││ │m │ ← 被裁掉的部分 ││ └────┘ ││ ┌────┐ ││ │Item│ ← 完全不可见被裁掉 ││ └────┘ ││ │└──────────────────────────────────────────┘裁剪是怎么实现的方式一SoftClip软裁剪DrawCall使用带裁剪功能的Shader Panel把裁剪区域作为参数传给材质 Shader在GPU上逐像素判断是否在裁剪区域内 区域外的像素被丢弃或渐变透明 mDynamicMat.SetVector(_ClipRange0, clipRange); mDynamicMat.SetVector(_ClipArgs0, softness); 优点边缘可以柔和渐变 缺点需要特殊Shader不能和普通Shader合批方式二硬裁剪直接修改顶点数据 把超出裁剪区域的顶点裁掉 在CPU端完成 优点不需要特殊Shader 缺点边缘生硬CPU开销较大┌──────────────────────────────────────────┐│ ││ 重要影响 ││ ││ 裁剪方式不同 Shader不同 ││ Shader不同 不能合批 ││ ││ 所以 ││ 带裁剪的Panel和不带裁剪的Panel ││ 下面的Widget永远不能合批 ││ ││ 这也是为什么不要滥用Panel裁剪的原因 ││ │└──────────────────────────────────────────┘--- ## 九、性能优化实战 ### 9.1 减少DrawCall的十条军规┌──────────────────────────────────────────────────┐│ ││ 第1条尽量把UI元素放进同一个图集 ││ ││ 一个图集 一个纹理 可以合批 ││ 图集越少DrawCall越少 ││ ││ 第2条合理规划depth ││ ││ 同图集的Widgetdepth要连续 ││ 先画背景层再画内容层最后画文字层 ││ ││ 第3条不要让不同图集的Widget交叉排列 ││ ││ 坏背景A → 文字C → 图标A → 文字C ││ 好背景A → 图标A → 文字C → 文字C ││ ││ 第4条减少Panel的数量 ││ ││ 不同Panel的Widget不能合批 ││ 只在需要裁剪或独立移动时才用新Panel ││ ││ 第5条避免频繁改变Widget的depth ││ ││ depth变化会触发DrawCall重建 ││ 重建比更新贵得多 ││ ││ 第6条字体尽量用同一个 ││ ││ 每种字体是一个独立的纹理 ││ 3种字体 至少3个DrawCall ││ ││ 第7条UILabel和UISprite分层放置 ││ ││ Label用字体纹理Sprite用图集纹理 ││ 它们永远不能合批 ││ 所以让它们各自连续排列 ││ ││ 第8条隐藏Widget用SetActive(false) ││ ││ 而不是把alpha设为0 ││ alpha0的Widget仍然会贡献顶点 ││ SetActive(false)才会真正从DrawCall中移除 ││ ││ 第9条静态UI和动态UI分开Panel ││ ││ 静态UI背景、边框很少变化 ││ 动态UI血条、计时器频繁变化 ││ 分开后静态Panel的DrawCall不需要频繁更新 ││ ││ 第10条用Profiler监控DrawCall数量 ││ ││ NGUI的Panel Inspector会显示DrawCall数量 ││ Unity Profiler的Rendering模块也能看到 ││ 目标移动端整个UI不超过15-20个DrawCall ││ │└──────────────────────────────────────────────────┘### 9.2 一个优化案例优化前一个背包界面40个格子每个格子包含格子背景图集A物品图标图集B← 不同物品用不同图集数量文字字体Cdepth排列格子1背景(A) → 格子1图标(B) → 格子1文字© →格子2背景(A) → 格子2图标(B) → 格子2文字© →…DrawCall数量接近120个每个格子3个DrawCall手机直接卡死。优化后第一步把所有物品图标合进同一个图集图集A格子背景 物品图标合并字体C文字第二步重新规划depthdepth 1-40: 所有格子背景图集Adepth 41-80: 所有物品图标图集A← 现在和背景同图集了depth 81-120:所有数量文字字体C第三步因为背景和图标现在用同一个图集depth 1-80 全部合批 → DrawCall #1depth 81-120 全部合批 → DrawCall #2DrawCall数量2个从120个降到2个性能提升60倍。--- ## 十、总结┌──────────────────────────────────────────────────┐│ ││ UIDrawCall是什么 ││ GPU的一张外卖订单。 ││ 把多个Widget的绘制请求打包成一次GPU调用。 ││ ││ 它做了什么 ││ 合并顶点数据 → 一个大Mesh ││ 绑定材质 → 一个Texture 一个Shader ││ 提交绘制 → 一次DrawCall ││ ││ 为什么重要 ││ DrawCall数量直接决定UI的渲染性能。 ││ 在移动端每个DrawCall都有固定开销。 ││ 减少DrawCall 更流畅的帧率。 ││ ││ 怎么优化 ││ 合并图集、规划depth、减少Panel。 ││ 让同材质的Widget在depth上连续排列。 ││ 一句话给GPU的订单越少越好每张订单越大越好。 ││ ││ 记住那个比喻 ││ GPU是大厨DrawCall是订单。 ││ 大厨炒菜飞快但每接一单都要洗手换围裙。 ││ 聪明的服务员会把订单合并让大厨少洗几次手。 ││ UIDrawCall就是那个聪明的服务员。 ││ │└──────────────────────────────────────────────────┘
NGUI UIDrawCall:GPU绘图优化秘籍
发布时间:2026/5/18 16:38:32
引子想象你是一个厨师手艺精湛炒菜飞快。但你有个怪脾气每接一个订单你都要重新洗手、换围裙、调火候、摆盘子。哪怕连续三个客人都点了同一道菜你也要重复三次准备工作。你炒菜只要10秒但准备工作要30秒。三个订单准备90秒 炒菜30秒 120秒。如果有个聪明的服务员把三个相同的订单合成一张单子递给你呢准备30秒 炒菜30秒 60秒。省了一半时间。UIDrawCall就是那个聪明的服务员。它把多个UI元素的绘制请求合并成一张订单一次性递给GPU这个大厨。GPU少做几次准备工作画面就更流畅。一、什么是DrawCall1.1 GPU大厨的规矩GPU是一个极其高效的画家。 它一秒钟能画几百万个三角形。 但它有一个规矩 每次画之前你必须告诉我 用什么纹理 用什么着色方式 画哪些顶点 全部准备好我才动笔。 中途不许换。要换重新来。 每次CPU走到GPU面前说请画这批东西 就产生一次DrawCall。 一次DrawCall的时间开销 ┌─────────────────────────────────────┐ │ │ │ 准备阶段固定开销 │ │ 设置纹理 ██ │ │ 设置Shader ██ │ │ 传输顶点数据 ████ │ │ 切换渲染状态 ██ │ │ │ │ 绘制阶段实际工作 │ │ 画三角形 █ │ │ │ │ 注意准备阶段比绘制阶段还长 │ │ 画4个三角形和画400个三角形 │ │ 绘制时间差不多但准备时间一样长。 │ │ │ └─────────────────────────────────────┘ 所以 100个元素 × 100次DrawCall 100次准备 100次绘制 100个元素 × 3次DrawCall 3次准备 3次绘制 省了97次准备 在手机上一次DrawCall的准备开销约0.1~0.5毫秒。 100次就是10~50毫秒。 一帧只有16毫秒60fps。 光准备就超时了还画什么1.2 UIDrawCall的角色在NGUI的世界里 UIWidgetUISprite、UILabel等 菜品 UIPanel 餐厅经理 UIDrawCall 服务员 GPU 厨师 餐厅经理UIPanel的工作 审视所有菜品Widget 把能合并的订单归到同一个服务员DrawCall 让服务员去找厨师GPU 一个服务员DrawCall手里拿着一张大订单 厨师这10道菜都用同样的食材和做法 我一次性告诉你你一口气做完。 层级关系 UIPanel餐厅经理 │ ├── DrawCall #1服务员小王 │ │ 订单用图集A画以下内容 │ ├── UISprite 背景 │ ├── UISprite 边框 │ └── UISprite 图标 │ ├── DrawCall #2服务员小李 │ │ 订单用图集B画以下内容 │ ├── UISprite 头像 │ └── UISprite 装饰 │ └── DrawCall #3服务员小张 │ 订单用字体纹理画以下内容 ├── UILabel 名字 └── UILabel 描述 8个Widget3个DrawCall。 GPU只需要准备3次而不是8次。二、DrawCall里装了什么2.1 一张完整的订单把一个DrawCall想象成一个快递箱。 箱子里装着GPU画画需要的所有东西 ┌─── DrawCall快递箱────────────────────────────┐ │ │ │ 材质信息用什么画 │ │ ┌────────────────────────────────────────────┐ │ │ │ 纹理 图集A.png │ │ │ │ Shader Unlit/Transparent Colored │ │ │ │ 材质 纹理 Shader 组合而成 │ │ │ └────────────────────────────────────────────┘ │ │ │ │ 网格数据画什么形状 │ │ ┌────────────────────────────────────────────┐ │ │ │ │ │ │ │ Sprite背景 Sprite边框 │ │ │ │ v0────v1 v4────v5 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ v3────v2 v7────v6 │ │ │ │ │ │ │ │ Sprite图标 │ │ │ │ v8────v9 总计 │ │ │ │ │ │ 12个顶点 │ │ │ │ │ │ 6个三角形 │ │ │ │ v11───v10 18个索引 │ │ │ │ │ │ │ └────────────────────────────────────────────┘ │ │ │ │ 顶点属性每个角的详细信息 │ │ ┌────────────────────────────────────────────┐ │ │ │ 位置 [(0,0), (100,0), (100,80), ...] │ │ │ │ UV [(0.1,0.2), (0.3,0.2), ...] │ │ │ │ 颜色 [白, 白, 白, 红, 红, 红, ...] │ │ │ └────────────────────────────────────────────┘ │ │ │ │ ✂️ 裁剪参数哪些部分要剪掉 │ │ ┌────────────────────────────────────────────┐ │ │ │ 裁剪区域(0, 0, 400, 300) │ │ │ │ 柔和边缘(20, 20) │ │ │ └────────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────┘2.2 核心代码结构publicclassUIDrawCall:MonoBehaviour{// ═══════════════════════════════════════// 材质三件套——用什么颜料画// ═══════════════════════════════════════MaterialmDynamicMat;// 动态材质运行时创建TexturemTexture;// 纹理图集贴图ShadermShader;// 着色器怎么着色// ═══════════════════════════════════════// 网格数据——画什么形状// ═══════════════════════════════════════MeshmMesh;// 合并后的大网格ListVector3mVerts;// 所有顶点的位置ListVector2mUVs;// 所有顶点的纹理坐标ListColormColors;// 所有顶点的颜色ListintmIndices;// 三角形索引// ═══════════════════════════════════════// Unity渲染组件——递给GPU的工具// ═══════════════════════════════════════MeshFiltermFilter;// 持有网格MeshRenderermRenderer;// 提交绘制// ═══════════════════════════════════════// 归属信息——属于哪个餐厅// ═══════════════════════════════════════UIPanelmPanel;// 所属PanelintmRenderQueue;// 渲染顺序}三、合批——把订单归堆3.1 什么能合并两个Widget能被合并到同一个DrawCall的条件 ┌──────────────────────────────────────────┐ │ │ │ 条件1同一张纹理 │ │ │ │ 都用图集A → ✓ 可以合 │ │ 一个图集A一个图集B → ✗ 不行 │ │ │ │ 这就像炒菜 │ │ 都用鸡肉的菜可以一锅炒 │ │ 一个要鸡肉一个要牛肉分开炒 │ │ │ │ 条件2同一个Shader │ │ │ │ 都用普通Shader → ✓ 可以合 │ │ 一个普通一个带模糊 → ✗ 不行 │ │ │ │ 这就像做法 │ │ 都是红烧可以一起做 │ │ 一个红烧一个清蒸分开做 │ │ │ │ 条件3同一个Panel │ │ │ │ 同一个Panel下 → ✓ 可以合 │ │ 不同Panel → ✗ 不行 │ │ │ │ 这就像餐厅 │ │ 同一桌的菜可以一起上 │ │ 不同桌的菜分开上 │ │ │ │ 条件4深度连续最关键 │ │ │ │ 中间没有被其他材质打断 → ✓ 可以合 │ │ 中间插了别的材质 → ✗ 不行 │ │ │ │ 这个最容易踩坑下面详细讲 │ │ │ └──────────────────────────────────────────┘3.2 深度连续——最容易犯的错NGUI按depth从小到大排列所有Widget 然后从前往后扫描遇到相同材质就合并。 想象一条传送带Widget按depth排队 ════════════════════════════════════════════ 好的排列同类聚在一起 ════════════════════════════════════════════ 传送带→ [A] [A] [A] [B] [B] [C] [C] [C] → 服务员扫描 看到A → 开一张新单DrawCall #1 又是A → 加到这张单上 又是A → 加到这张单上 看到B → 材质变了开新单DrawCall #2 又是B → 加到这张单上 看到C → 材质变了开新单DrawCall #3 又是C → 加到这张单上 又是C → 加到这张单上 结果3个DrawCall ✓ 完美 ════════════════════════════════════════════ 坏的排列不同类交叉穿插 ════════════════════════════════════════════ 传送带→ [A] [B] [A] [C] [B] [A] [C] [B] → 服务员扫描 看到A → 开新单 #1 看到B → 材质变了开新单 #2 看到A → 材质又变了开新单 #3 不能回去合并到#1因为顺序不能乱 看到C → 开新单 #4 看到B → 开新单 #5 看到A → 开新单 #6 看到C → 开新单 #7 看到B → 开新单 #8 结果8个DrawCall ✗ 灾难 同样8个Widget排列不同DrawCall差了5倍。 ════════════════════════════════════════════ 为什么不能回去合并 ════════════════════════════════════════════ 因为渲染有顺序depth小的先画大的后画。 后画的会遮住先画的。 如果把depth3的A合并到DrawCall #1depth 1的A 它就会被提前绘制遮挡关系就错了。 想象叠扑克牌 第1张红色A 第2张黑色B盖在A上面 第3张红色A盖在B上面 你不能把第3张和第1张放在一起 因为那样第3张就跑到B下面去了。 ════════════════════════════════════════════ 黄金法则 ════════════════════════════════════════════ ┌──────────────────────────────────────────┐ │ │ │ 让使用同一图集的Widget │ │ 在depth上连续排列 │ │ │ │ 好AAABBBCCC → 3个DrawCall │ │ 坏ABCABCABC → 9个DrawCall │ │ │ │ 一个简单的depth规划就能 │ │ 让DrawCall数量减少数倍 │ │ │ └──────────────────────────────────────────┘四、网格合并——把碎片拼成大块4.1 合并过程当Panel决定哪些Widget属于同一个DrawCall后 DrawCall要把它们的顶点数据合并成一个大网格。 合并前3个独立的Sprite Sprite 背景 Sprite 按钮 Sprite 图标 各自有4个顶点 各自有4个顶点 各自有4个顶点 各自有2个三角形 各自有2个三角形 各自有2个三角形 ┌──────┐ ┌──────┐ ┌──────┐ │0 1│ │0 1│ │0 1│ │ │ │ │ │ │ │3 2│ │3 2│ │3 2│ └──────┘ └──────┘ └──────┘ 索引0,1,2,0,2,3 索引0,1,2,0,2,3 索引0,1,2,0,2,3 合并后一个大网格 ┌──────┐ ┌──────┐ ┌──────┐ │0 1│ │4 5│ │8 9│ │ │ │ │ │ │ │3 2│ │7 6│ │11 10│ └──────┘ └──────┘ └──────┘ 顶点数组[v0,v1,v2,v3, v4,v5,v6,v7, v8,v9,v10,v11] UV数组 [uv0,uv1,..., uv4,uv5,..., uv8,uv9,...] 颜色数组[c0,c1,..., c4,c5,..., c8,c9,...] 索引数组[0,1,2, 0,2,3, ← 背景的两个三角形 4,5,6, 4,6,7, ← 按钮的两个三角形 8,9,10, 8,10,11] ← 图标的两个三角形 注意索引的变化 第二个Sprite的索引从0开始变成了从4开始 第三个Sprite的索引从0开始变成了从8开始 因为它们的顶点在大数组中的位置偏移了4.2 代码中的合并/// summary/// 填充DrawCall的网格数据/// Panel收集好所有Widget的顶点后调用这个方法/// /summarypublicvoidSet(ListVector3verts,ListVector2uvs,ListColorcols){// 计算三角形索引intcountverts.Count;// 每4个顶点组成一个四边形2个三角形// 索引模式0,1,2, 0,2,3, 4,5,6, 4,6,7, ...// 把数据灌入MeshmMesh.Clear();mMesh.verticesverts.ToArray();mMesh.uvuvs.ToArray();mMesh.colorscols.ToArray();mMesh.trianglesGenerateIndices(count);// Mesh准备好了MeshRenderer会在渲染时提交给GPU}整个过程就像拼积木 每个Widget是一小块积木4个顶点2个三角形。 DrawCall把所有小积木拼成一大块。 然后一次性递给GPU。 GPU看到的不是3个Sprite 而是一个有12个顶点、6个三角形的网格。 它不知道也不关心这些三角形原本属于谁。 它只管一口气全画完。五、DrawCall的一生5.1 生命周期一个DrawCall从出生到死亡的完整故事 ┌──────────────────────────────────────────────────┐ │ │ │ 第一幕出生 │ │ │ │ Panel发现有一批Widget需要新的DrawCall │ │ 材质和之前的不同或者是第一批 │ │ │ │ Panel说创建一个新的DrawCall │ │ │ │ → 创建GameObject │ │ → 添加MeshFilter和MeshRenderer组件 │ │ → 创建动态材质Texture Shader │ │ → 创建空的Mesh │ │ │ │ DrawCall诞生了但还是空的等待填充数据。 │ │ │ └──────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────┐ │ │ │ 第二幕填充 │ │ │ │ Panel遍历属于这个DrawCall的所有Widget │ │ 每个Widget贡献自己的顶点、UV、颜色 │ │ │ │ Widget们排队交出自己的数据 │ │ │ │ 背景Sprite这是我的4个顶点。 │ │ 按钮Sprite这是我的4个顶点。 │ │ 图标Sprite这是我的4个顶点。 │ │ │ │ DrawCall把它们全部收集起来 │ │ 合并成一个大网格灌入Mesh。 │ │ │ └──────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────┐ │ │ │ 第三幕设置材质参数 │ │ │ │ 如果Panel有裁剪DrawCall还要设置裁剪参数 │ │ │ │ mDynamicMat.SetVector(_ClipRange0, clipRange);│ │ mDynamicMat.SetVector(_ClipArgs0, clipArgs); │ │ │ │ 这些参数会传给Shader │ │ 让Shader知道哪些像素该显示哪些该裁掉。 │ │ │ └──────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────┐ │ │ │ 第四幕渲染 │ │ │ │ Unity的渲染管线看到MeshRenderer │ │ 自动把这个DrawCall提交给GPU。 │ │ │ │ GPU收到订单 │ │ 1. 绑定纹理图集A │ │ 2. 绑定Shader │ │ 3. 上传顶点数据 │ │ 4. 一口气画完所有三角形 │ │ │ │ 一次DrawCall完成 │ │ │ └──────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────┐ │ │ │ 第五幕更新每帧可能发生 │ │ │ │ 如果某个Widget发生了变化 │ │ 位置移动了 │ │ 颜色变了 │ │ 透明度变了 │ │ 显示/隐藏了 │ │ │ │ Widget会标记自己为脏dirty。 │ │ Panel在下一帧的LateUpdate中检测到脏标记 │ │ 重新收集顶点数据重新填充DrawCall的Mesh。 │ │ │ │ 注意只要Widget的材质没变 │ │ DrawCall本身不需要重建只需要更新网格数据。 │ │ │ └──────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────┐ │ │ │ 第六幕死亡 │ │ │ │ 当DrawCall不再需要时所有Widget都被销毁了 │ │ 或者Panel被销毁了DrawCall被回收。 │ │ │ │ → 销毁Mesh │ │ → 销毁动态材质 │ │ → 销毁GameObject │ │ │ │ 或者放入对象池等待下次复用。 │ │ │ └──────────────────────────────────────────────────┘六、Panel如何分配DrawCall6.1 分配算法Panel的LateUpdate中核心逻辑大致如下 第一步收集所有可见的Widget按depth排序 排序后 depth 1: Sprite_A图集X depth 2: Sprite_B图集X depth 3: Sprite_C图集X depth 4: Label_D 字体Y depth 5: Label_E 字体Y depth 6: Sprite_F图集Z 第二步从前往后扫描分配DrawCall 当前DrawCall null 当前材质 null 扫描depth 1Sprite_A材质图集X 当前材质为空 → 创建DrawCall #1材质图集X 把Sprite_A的顶点加入DrawCall #1 扫描depth 2Sprite_B材质图集X 材质和当前相同 → 继续用DrawCall #1 把Sprite_B的顶点加入DrawCall #1 扫描depth 3Sprite_C材质图集X 材质和当前相同 → 继续用DrawCall #1 把Sprite_C的顶点加入DrawCall #1 扫描depth 4Label_D材质字体Y 材质变了 → 创建DrawCall #2材质字体Y 把Label_D的顶点加入DrawCall #2 扫描depth 5Label_E材质字体Y 材质和当前相同 → 继续用DrawCall #2 把Label_E的顶点加入DrawCall #2 扫描depth 6Sprite_F材质图集Z 材质变了 → 创建DrawCall #3材质图集Z 把Sprite_F的顶点加入DrawCall #3 第三步填充每个DrawCall的Mesh DrawCall #1.Set(顶点ABC的数据) DrawCall #2.Set(顶点DE的数据) DrawCall #3.Set(顶点F的数据) 结果6个Widget3个DrawCall。6.2 一个真实的UI界面假设你有一个角色信息面板 ┌─────────────────────────────────────┐ │ ┌─────┐ 角色名称 │ ← 背景图集A │ │头像 │ 等级50 │ ← 头像图集B │ │ │ 职业战士 │ ← 文字字体C │ └─────┘ │ │ ┌──────────────────────────────┐ │ │ │ ❤️ 生命值 ████████████░░░░ │ │ ← 血条图集A │ │ 魔法值 ██████░░░░░░░░░░ │ │ ← 蓝条图集A │ └──────────────────────────────┘ │ │ │ │ [攻击力: 1500] [防御力: 800] │ ← 文字字体C │ │ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ │ │技能│ │技能│ │技能│ │技能│ │ ← 技能图标图集A │ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │ │ └────┘ └────┘ └────┘ └────┘ │ └─────────────────────────────────────┘ 如果depth安排得好 depth 1-5: 背景、血条、蓝条、技能图标图集A→depth 1-5: 背景、血条、蓝条、技能图标图集A→ DrawCall #1depth 6: 头像图集B → DrawCall #2depth 7-10: 所有文字字体C → DrawCall #3结果整个角色面板只需要3个DrawCall十几个Widget3次GPU调用非常高效。如果depth安排得不好depth 1: 背景图集Adepth 2: 头像图集B ← 打断了depth 3: 角色名称字体C ← 又打断了depth 4: 血条背景图集A ← 又变回A但不能合并到#1depth 5: 生命值文字字体C← 又打断了depth 6: 蓝条背景图集A ← 又变回A…depth 7: 魔法值文字字体Cdepth 8: 技能图标1图集Adepth 9: 技能图标2图集Adepth 10: 技能图标3图集Adepth 11: 技能图标4图集Adepth 12: 攻击力文字字体Cdepth 13: 防御力文字字体C结果13个Widget可能产生8-9个DrawCall同样的界面差了3倍。┌──────────────────────────────────────────┐│ ││ 优化口诀 ││ ││ “同图集的Widgetdepth要连续” ││ “先画所有背景再画所有文字” ││ “不要让不同图集的Widget交叉排列” ││ │└──────────────────────────────────────────┘--- ## 七、动态更新——Widget变了怎么办 ### 7.1 脏标记机制UI不是静态的。按钮会变色血条会缩短文字会改变。当Widget发生变化时DrawCall需要更新。但不是每次变化都重建DrawCall。NGUI用了一套脏标记机制来优化┌──────────────────────────────────────────────────┐│ ││ 场景玩家受到攻击血条缩短 ││ ││ 第1步血条Sprite修改了自己的宽度 ││ sprite.width 150; // 原来是200 ││ ││ 第2步Sprite内部标记自己为脏 ││ mChanged true; ││ ││ 第3步通知Panel ││ “老板我变了下一帧记得更新我” ││ ││ 第4步Panel在LateUpdate中检查 ││ 发现有脏Widget ││ 重新收集该DrawCall的所有顶点数据 ││ 调用DrawCall.Set()更新Mesh ││ ││ 注意 ││ 只更新网格数据不重建DrawCall ││ 材质没变GameObject没变只是Mesh的顶点变了 ││ 这比销毁重建快得多 ││ │└──────────────────────────────────────────────────┘用餐厅的比喻客人说我的牛排要七分熟不是五分熟。 服务员不需要重新开一张单子。 他只需要在原来的单子上划掉五分熟 写上七分熟然后告诉厨师。 但如果客人说我不要牛排了换鱼 那可能就需要换一张单子了材质变化→重建DrawCall。### 7.2 什么情况会导致DrawCall重建┌──────────────────────────────────────────────────┐│ ││ 轻量更新只更新Mesh不重建DrawCall ││ ││ ✦ Widget位置移动 ││ ✦ Widget大小改变 ││ ✦ Widget颜色/透明度改变 ││ ✦ UILabel文字内容改变 ││ ✦ UISprite的sprite name改变 ││ 只要还在同一个图集内 ││ ││ → 只需要重新填充顶点数据 ││ → 开销较小 ││ ││ ││ 重量更新需要重建DrawCall ││ ││ ✦ Widget更换了图集 ││ ✦ Widget更换了Shader ││ ✦ Widget的depth改变导致排序变化 ││ ✦ 新增或删除Widget ││ ✦ Widget从一个Panel移到另一个Panel ││ ││ → 需要重新分配DrawCall ││ → 可能创建新的、销毁旧的 ││ → 开销较大应尽量避免频繁触发 ││ │└──────────────────────────────────────────────────┘--- ## 八、裁剪——DrawCall的隐形剪刀 ### 8.1 Panel裁剪与DrawCall很多UI需要裁剪滚动列表只显示窗口内的内容超出窗口的部分要剪掉。┌──────────────────────────────────────────┐│ ││ Panel的裁剪区域可见窗口 ││ ┌──────────────────────┐ ││ │ ┌────┐ │ ││ │ │Item│ ← 完全可见 │ ││ │ └────┘ │ ││ │ ┌────┐ │ ││ │ │Item│ ← 完全可见 │ ││ │ └────┘ │ ││ │ ┌────┐ │ ││ │ │Ite │ ← 半可见 │ ││ └──│────│──────────────┘ ││ │m │ ← 被裁掉的部分 ││ └────┘ ││ ┌────┐ ││ │Item│ ← 完全不可见被裁掉 ││ └────┘ ││ │└──────────────────────────────────────────┘裁剪是怎么实现的方式一SoftClip软裁剪DrawCall使用带裁剪功能的Shader Panel把裁剪区域作为参数传给材质 Shader在GPU上逐像素判断是否在裁剪区域内 区域外的像素被丢弃或渐变透明 mDynamicMat.SetVector(_ClipRange0, clipRange); mDynamicMat.SetVector(_ClipArgs0, softness); 优点边缘可以柔和渐变 缺点需要特殊Shader不能和普通Shader合批方式二硬裁剪直接修改顶点数据 把超出裁剪区域的顶点裁掉 在CPU端完成 优点不需要特殊Shader 缺点边缘生硬CPU开销较大┌──────────────────────────────────────────┐│ ││ 重要影响 ││ ││ 裁剪方式不同 Shader不同 ││ Shader不同 不能合批 ││ ││ 所以 ││ 带裁剪的Panel和不带裁剪的Panel ││ 下面的Widget永远不能合批 ││ ││ 这也是为什么不要滥用Panel裁剪的原因 ││ │└──────────────────────────────────────────┘--- ## 九、性能优化实战 ### 9.1 减少DrawCall的十条军规┌──────────────────────────────────────────────────┐│ ││ 第1条尽量把UI元素放进同一个图集 ││ ││ 一个图集 一个纹理 可以合批 ││ 图集越少DrawCall越少 ││ ││ 第2条合理规划depth ││ ││ 同图集的Widgetdepth要连续 ││ 先画背景层再画内容层最后画文字层 ││ ││ 第3条不要让不同图集的Widget交叉排列 ││ ││ 坏背景A → 文字C → 图标A → 文字C ││ 好背景A → 图标A → 文字C → 文字C ││ ││ 第4条减少Panel的数量 ││ ││ 不同Panel的Widget不能合批 ││ 只在需要裁剪或独立移动时才用新Panel ││ ││ 第5条避免频繁改变Widget的depth ││ ││ depth变化会触发DrawCall重建 ││ 重建比更新贵得多 ││ ││ 第6条字体尽量用同一个 ││ ││ 每种字体是一个独立的纹理 ││ 3种字体 至少3个DrawCall ││ ││ 第7条UILabel和UISprite分层放置 ││ ││ Label用字体纹理Sprite用图集纹理 ││ 它们永远不能合批 ││ 所以让它们各自连续排列 ││ ││ 第8条隐藏Widget用SetActive(false) ││ ││ 而不是把alpha设为0 ││ alpha0的Widget仍然会贡献顶点 ││ SetActive(false)才会真正从DrawCall中移除 ││ ││ 第9条静态UI和动态UI分开Panel ││ ││ 静态UI背景、边框很少变化 ││ 动态UI血条、计时器频繁变化 ││ 分开后静态Panel的DrawCall不需要频繁更新 ││ ││ 第10条用Profiler监控DrawCall数量 ││ ││ NGUI的Panel Inspector会显示DrawCall数量 ││ Unity Profiler的Rendering模块也能看到 ││ 目标移动端整个UI不超过15-20个DrawCall ││ │└──────────────────────────────────────────────────┘### 9.2 一个优化案例优化前一个背包界面40个格子每个格子包含格子背景图集A物品图标图集B← 不同物品用不同图集数量文字字体Cdepth排列格子1背景(A) → 格子1图标(B) → 格子1文字© →格子2背景(A) → 格子2图标(B) → 格子2文字© →…DrawCall数量接近120个每个格子3个DrawCall手机直接卡死。优化后第一步把所有物品图标合进同一个图集图集A格子背景 物品图标合并字体C文字第二步重新规划depthdepth 1-40: 所有格子背景图集Adepth 41-80: 所有物品图标图集A← 现在和背景同图集了depth 81-120:所有数量文字字体C第三步因为背景和图标现在用同一个图集depth 1-80 全部合批 → DrawCall #1depth 81-120 全部合批 → DrawCall #2DrawCall数量2个从120个降到2个性能提升60倍。--- ## 十、总结┌──────────────────────────────────────────────────┐│ ││ UIDrawCall是什么 ││ GPU的一张外卖订单。 ││ 把多个Widget的绘制请求打包成一次GPU调用。 ││ ││ 它做了什么 ││ 合并顶点数据 → 一个大Mesh ││ 绑定材质 → 一个Texture 一个Shader ││ 提交绘制 → 一次DrawCall ││ ││ 为什么重要 ││ DrawCall数量直接决定UI的渲染性能。 ││ 在移动端每个DrawCall都有固定开销。 ││ 减少DrawCall 更流畅的帧率。 ││ ││ 怎么优化 ││ 合并图集、规划depth、减少Panel。 ││ 让同材质的Widget在depth上连续排列。 ││ 一句话给GPU的订单越少越好每张订单越大越好。 ││ ││ 记住那个比喻 ││ GPU是大厨DrawCall是订单。 ││ 大厨炒菜飞快但每接一单都要洗手换围裙。 ││ 聪明的服务员会把订单合并让大厨少洗几次手。 ││ UIDrawCall就是那个聪明的服务员。 ││ │└──────────────────────────────────────────────────┘