DrawCall优化全链路
从Batcher2D源码出发,系统掌握静态合批、动态合批、GPU Instancing、图集打包、层级优化等核心技术
零、写给初学者:为什么要学DrawCall优化?
如果不懂这个会怎样?
想象你开了一家外卖店:
- 懂DrawCall优化 = 你知道怎么把同一区域的订单合并配送,一个骑手送10单,效率高成本低
- 不懂DrawCall优化 = 每个订单都单独配送,10个订单派10个骑手,成本爆炸还超时
在游戏开发中,DrawCall就是"配送订单":
| 场景 | 懂DrawCall优化 | 不懂DrawCall优化 |
|---|---|---|
| 游戏卡顿 | 用Profiler定位,发现是合批被打断,调整层级后解决 | 盲目降低分辨率、减少特效,问题依旧 |
| UI界面复杂 | 50个UI元素 = 3个DrawCall,流畅运行 | 50个UI元素 = 50个DrawCall,低端机卡顿 |
| 使用图集 | 所有图标一张图,1个DrawCall画完 | 每个图标单独图片,50个DrawCall |
| 背包界面 | 100个物品格子 = 3个DrawCall | 100个物品格子 = 300个DrawCall |
学习本章节后,你将能:
- 精准分析DrawCall瓶颈
- 系统优化2D游戏性能
- 设计"渲染友好"的UI结构
前置知识铺垫
在学习本章前,建议了解以下概念(如果不懂也没关系,文中会解释):
- 什么是DrawCall:CPU通知GPU画一次东西的命令
- 什么是合批(Batching):把多个小DrawCall合并成一个大DrawCall
- 什么是VBO:存储顶点数据的GPU缓冲区
- 什么是图集(Atlas):把多张图片合并到一张大图里
- 什么是纹理(Texture):GPU使用的图片资源
如果你还没读过《Cocos渲染架构详解》,建议先读那篇,了解Batcher2D的工作原理。
一、DrawCall的本质
什么是DrawCall?
为什么需要理解DrawCall的本质?
很多初学者以为DrawCall是GPU的工作,所以拼命优化Shader、减少顶点。但实际上,DrawCall的瓶颈在CPU端!理解这一点,才能找对优化方向。
DrawCall是CPU向GPU发送的一次绘制命令。每次DrawCall,CPU需要完成以下工作:
CPU端每次DrawCall的开销:
├── 设置渲染状态(深度测试、混合模式、裁剪)
├── 绑定Shader程序
├── 绑定纹理(可能多张贴图)
├── 绑定顶点/索引缓冲区
├── 设置Uniform变量(MVP矩阵、颜色等)
├── 调用底层图形API(gl.drawElements/gl.drawArrays)
└── 等待GPU完成(同步点)
生活类比:叫外卖
每次DrawCall就像你下一次外卖订单:
- 打开APP(设置渲染状态)
- 选择餐厅(绑定Shader)
- 选菜品(绑定纹理)
- 填地址(绑定顶点缓冲区)
- 写备注(设置Uniform)
- 点击下单(调用draw API)
- 等骑手送到(等待GPU完成)
如果你一次点10个菜,和分10次每次点1个菜,哪个更快?显然是一次点完!合批就是这个道理。
关键认知:DrawCall开销在CPU端,不是GPU端。GPU处理一个DrawCall很快,但CPU准备这个DrawCall需要大量工作。
自问自答:
Q: 为什么CPU准备DrawCall这么慢? A: 因为CPU要做很多"准备工作":切换渲染状态、绑定资源、验证参数等。这些操作涉及驱动程序、内核态切换,开销很大。而GPU一旦收到命令,执行绘制本身是非常快的(特别是2D游戏,顶点很少)。
Q: 那是不是DrawCall越少越好? A: 理论上是的,但实际有边界。如果为了合批把完全不相关的物体硬凑在一起,可能导致Overdraw(重复绘制)增加,反而降低GPU效率。优化的目标是"在合理范围内减少DrawCall"。
为什么DrawCall是2D游戏的第一性能指标?
| 指标 | 瓶颈位置 | 优化难度 | 重要性 |
|---|---|---|---|
| DrawCall | CPU | 低(合批即可) | ⭐⭐⭐⭐⭐ |
| Overdraw | GPU | 中 | ⭐⭐⭐⭐ |
| 纹理内存 | 显存 | 低 | ⭐⭐⭐ |
| Shader复杂度 | GPU | 高 | ⭐⭐⭐ |
| 顶点数 | GPU | 低 | ⭐⭐ |
为什么DrawCall优化优先级最高?
因为2D游戏的顶点数通常很少(一个Sprite只有4个顶点),Shader也很简单(就是采样纹理)。真正的瓶颈在CPU频繁发送DrawCall。减少DrawCall可以立即获得显著性能提升,而且优化手段简单(合批即可)。
生活类比:餐厅效率分析
假设你经营一家快餐店:
- DrawCall = 接单速度。接单太慢,厨师闲着等单 → 优化简单:培训收银员
- Overdraw = 食材浪费。做多了倒掉 → 优化中等:改进预测
- 纹理内存 = 冰箱容量。放不下食材 → 优化简单:换大冰箱
- Shader复杂度 = 菜品复杂度。做佛跳墙当然慢 → 优化困难:简化菜品
- 顶点数 = 菜品分量。分量太大吃不完 → 优化简单:调整分量
显然,"接单速度"是首先要解决的问题,因为优化简单、效果明显。
二、Batcher2D合批机制深度解析
合批的本质
什么是合批?为什么要合批?
合批 = 把多个小DrawCall合并成一个大DrawCall = 把多个小VBO合并成一个大VBO
不合批: 合批后:
DrawCall1: VBO[4顶点] DrawCall1: VBO[12顶点]
DrawCall2: VBO[4顶点] → (画3个Sprite)
DrawCall3: VBO[4顶点]
生活类比:拼车
- 不合批 = 3个人分别打车,3辆车、3次调度、3份等待
- 合批 = 3个人拼一辆车,1辆车、1次调度、1份等待
CPU就是"调度中心",GPU就是"车"。合批减少了调度次数,让GPU一直"满载运行"。
Batcher2D合批源码回顾
// Batcher2D合批判断的核心逻辑
// 这段代码在Batcher2D的commitComp方法中
commitComp(renderer: UIRenderer, renderData: RenderData, material: Material, stencilStage: number) {
// 获取当前渲染数据的纹理哈希
// dataHash就像纹理的"身份证号",相同纹理 = 相同哈希
const dataHash = renderData.dataHash;
// 4个条件必须全部满足才能合批!
// 只要有一个不满足,就必须新建批次(新DrawCall)
const canBatch =
this._currHash === dataHash && // 1. 相同纹理
this._currMaterial === material && // 2. 相同材质
this._currStencilStage === stencilStage; // 3. 相同模板状态
if (canBatch) {
// ✅ 合批成功:顶点数据追加到当前VBO
// 就像把新乘客加到同一辆车上
this._mergeVertexData(renderData);
} else {
// ❌ 合批失败:提交当前批次,开启新批次
// 就像这辆车满了/路线不同,需要新开一辆车
this._flushCurrentBatch();
this._startNewBatch(dataHash, material, stencilStage);
}
}
逐行注释解读:
const dataHash = renderData.dataHash;—— 获取纹理哈希。哈希是一种"指纹算法",把任意数据映射成一个数字。相同纹理一定有相同哈希,不同纹理大概率有不同哈希(哈希碰撞概率极低)this._currHash === dataHash—— 条件1:纹理相同。===是严格相等,值和类型都要相同。为什么用===而不是==?因为==会做类型转换,可能把0和"0"当成相等,导致错误合批this._currMaterial === material—— 条件2:材质相同。注意比较的是引用(内存地址),不是内容。两个内容相同但不同实例的材质,引擎认为"不同"(保守策略)this._currStencilStage === stencilStage—— 条件3:模板状态相同。Mask组件会改变这个值this._mergeVertexData(renderData);—— 合批成功,把新组件的顶点数据追加到当前VBO。注意是"追加"不是"替换"this._flushCurrentBatch();—— 合批失败,先把当前积累的批次提交。就像"这辆车发车了"this._startNewBatch(...);—— 开启新批次,用新组件的资源作为新批次的起点
合批条件详解
| 条件 | 具体含义 | 常见打断场景 |
|---|---|---|
| 相同纹理 | 使用同一张纹理或同一图集 | 不同图片、不同图集、动态生成的Label纹理 |
| 相同材质 | 同一个Material实例 | 自定义材质、不同颜色(修改了material属性) |
| 相同模板状态 | 相同的Mask/Stencil配置 | 使用Mask组件、不同的裁剪区域 |
每个条件的"为什么"详解:
条件1:相同纹理
- 为什么必须相同:GPU有一个"纹理槽位",一次只能绑定一张纹理。切换纹理就像换唱片,必须停下来操作
- 如果不满足:GPU必须解绑旧纹理、绑定新纹理,这是一个重量级操作,必须新建DrawCall
条件2:相同材质
- 为什么必须相同:材质包含Shader程序。切换Shader就像换演员,必须重新化妆、重新走位
- 如果不满足:GPU必须重新编译/绑定Shader,设置新的渲染状态,必须新建DrawCall
条件3:相同模板状态
- 为什么必须相同:模板测试是全局状态。改变模板状态就像改变安检级别,所有人要重新检查
- 如果不满足:GPU必须重新配置模板测试参数,必须新建DrawCall
初学者常见错误:
| 错误 | 为什么错 | 正确做法 |
|---|---|---|
| 认为"只要图片看起来一样就能合批" | 引擎比较的是纹理引用/哈希,不是视觉内容 | 确保使用同一张纹理或同一图集 |
| 给每个Sprite设置不同的color(通过material) | 每个Sprite变成不同的Material实例 | 使用Sprite的color属性(不修改material) |
| 在运行时动态创建大量纹理 | 动态纹理无法和静态纹理合批 | 预加载资源,使用图集 |
| 忽略Mask对合批的影响 | Mask改变stencilStage,打断合批 | 减少Mask使用,或用矩形裁剪替代 |
三、静态合批(Static Batching)
原理
为什么需要静态合批?
有些UI元素从来不移动、不变化(比如背景图、固定按钮)。每帧都遍历它们、检查它们是否变化,是浪费CPU时间。静态合批就是"标记后跳过"。
静态合批针对不移动、不变化的UI元素。标记为静态后,引擎会:
- 跳过每帧的Transform更新遍历
- 跳过渲染数据的重新生成
- 直接复用上一次的渲染结果
// Cocos 3.x 静态合批设置
// 方式1:编辑器中勾选 Static
// 方式2:代码设置
node.static = true; // 标记节点为静态
生活类比:固定菜单
想象餐厅有一块"固定菜单板",上面的菜从不变化:
- 不标记static = 每天营业前,服务员都要检查菜单板上的每个字有没有变化(浪费)
- 标记static = 服务员知道这块板子从不变化,直接跳过检查,省下的时间可以干别的
静态合批的适用场景
✅ 适合静态合批:
├── 背景图片
├── 固定的UI框架(标题栏、底部导航)
├── 不动的装饰元素
└── 静态文字(BMFont)
❌ 不适合静态合批:
├── 会移动的节点
├── 会改变颜色的节点
├── 有动画的节点
├── 需要响应点击的按钮(虽然按钮本身不动,但点击效果会改变状态)
└── 频繁显示/隐藏的节点
自问自答:
Q: 按钮本身不移动,为什么也不适合静态合批? A: 因为按钮有状态变化:正常态、按下态、禁用态。这些状态变化会改变渲染数据(比如颜色变暗)。如果标记为static,引擎会跳过更新,导致状态变化不生效。
Q: 静态合批和动态合批有什么区别? A:
- 静态合批 = "标记为不动,引擎跳过遍历"
- 动态合批 = "每帧都遍历,但运行时自动合并"
静态合批源码分析
// Batcher2D对静态节点的处理
// 这段代码在walk()方法中
walk(node: Node, level: number) {
// 1. 检查节点是否激活(和之前一样)
if (!node.activeInHierarchy) return;
// 2. 静态节点优化:如果节点标记为static且数据未变,跳过遍历
// _renderFlag是一个标记,表示"这个节点的渲染数据是否需要更新"
// 如果static=true且renderFlag=false,表示"这个节点没变化,不用管"
if (node.static && !node._renderFlag) {
// 直接复用上一次的渲染数据,不遍历子节点
// 这是静态合批的核心优化:跳过整个子树的遍历!
return;
}
// 3. 处理当前节点的渲染组件(和正常流程一样)
const renderer = node.getComponent(UIRenderer);
if (renderer) {
this.commitComp(renderer, renderer.renderData, renderer.material, renderer.stencilStage);
}
// 4. 递归遍历子节点(和正常流程一样)
for (const child of node.children) {
this.walk(child, level + 1);
}
}
逐行注释解读:
if (node.static && !node._renderFlag)—— 两个条件同时满足才跳过:static=true(用户标记为静态)且!_renderFlag(渲染数据没有变化)return;—— 直接返回,不遍历子节点!这意味着整个子树都被跳过了。如果一个静态节点有10个子节点,这10个子节点都不需要遍历,节省大量CPU时间node._renderFlag—— 引擎内部标记。当节点的位置、旋转、缩放、颜色等属性变化时,引擎会自动设置这个标记为true
注意:
node.static是引擎内部属性,在Cocos 3.x中可以通过node.static = true设置,但编辑器中可能没有直接的可视化选项。
静态合批的性能收益
| 场景 | 优化前 | 优化后 | 收益 |
|---|---|---|---|
| 100个静态背景Sprite | 100次节点遍历 + 100个DrawCall | 0次遍历 + 1个DrawCall | 99% CPU遍历减少 |
| 复杂UI界面(50个静态元素) | 50个DrawCall | 1个DrawCall | 98% DrawCall减少 |
生活类比:抄作业
- 不优化 = 每次考试都重新抄一遍笔记(遍历+生成渲染数据)
- 静态合批 = 把笔记复印一份,以后直接复印(跳过遍历,复用数据)
四、动态合批(Dynamic Batching)
原理
为什么需要动态合批?
静态合批只适合不动的节点,但游戏里大部分元素是动态的(玩家、敌人、动画UI)。动态合批在运行时自动合并这些动态元素,不需要手动标记。
动态合批在运行时自动将满足条件的渲染对象合并。与静态合批的区别:
| 特性 | 静态合批 | 动态合批 |
|---|---|---|
| 时机 | 初始化时 | 每帧运行时 |
| 适用对象 | 不动的节点 | 所有节点 |
| 遍历开销 | 跳过遍历 | 每帧遍历 |
| 灵活性 | 低(标记后不能动) | 高(自动处理) |
生活类比:
- 静态合批 = 预制菜,提前做好,热一下就能吃(快,但不能改)
- 动态合批 = 现炒菜,根据订单现场做(灵活,但需要时间)
动态合批的触发条件
// 动态合批的完整条件判断
// 这是伪代码,展示了引擎内部的判断逻辑
function canDynamicBatch(rendererA: UIRenderer, rendererB: UIRenderer): boolean {
// 1. 相同纹理或图集
// 比较两个渲染组件使用的纹理是否是同一个对象
if (rendererA.spriteFrame?.texture !== rendererB.spriteFrame?.texture) {
return false; // 纹理不同,不能合批
}
// 2. 相同材质(包括Shader、混合模式等)
// 注意:比较的是引用,不是内容!
if (rendererA.material !== rendererB.material) {
return false; // 材质不同,不能合批
}
// 3. 相同模板状态(Mask影响)
// Mask组件会改变stencilStage,不同stage不能合批
if (rendererA.stencilStage !== rendererB.stencilStage) {
return false; // 模板状态不同,不能合批
}
// 4. 不超出VBO大小限制(65535个顶点)
// Uint16索引的最大值是65535,超出会溢出
if (currentVertexCount + rendererB.vertexCount > 65535) {
return false; // VBO满了,不能合批
}
// 所有条件都满足,可以合批!
return true;
}
逐行注释解读:
rendererA.spriteFrame?.texture——?.是可选链操作符。如果spriteFrame为null/undefined,不会报错,直接返回undefined。这是TypeScript的安全语法!== rendererB.spriteFrame?.texture—— 比较纹理引用。两个Sprite使用同一张图集的不同区域,纹理引用相同,可以合批!rendererA.material !== rendererB.material—— 比较材质引用。注意:即使两个材质的内容完全相同(相同的Shader、相同的参数),只要不是同一个实例,就不能合批rendererA.stencilStage !== rendererB.stencilStage—— 比较模板阶段。Mask组件内部会修改这个值currentVertexCount + rendererB.vertexCount > 65535—— 检查VBO容量。currentVertexCount是当前批次已积累的顶点数
自问自答:
Q: 为什么条件4是65535而不是其他数字?
A: 因为索引数组使用Uint16Array存储,16位无符号整数的最大值是2^16 - 1 = 65535。如果顶点数超过这个值,索引会溢出(比如第65536个顶点的索引变成0,指向第一个顶点,画面就会错乱)。
Q: 如果VBO满了怎么办? A: 引擎会自动分成两个批次。第一个批次提交(一个DrawCall),第二个批次重新开始积累。对2D游戏来说,65535个顶点可以画约16000个Sprite,通常不会满。
动态合批的实战优化
问题场景:背包界面,50个物品格子,每个格子有图标和数量文字
背包界面结构(优化前):
Backpack
├── Item1
│ ├── Icon (纹理: item_01.png)
│ └── Count (Label: "99")
├── Item2
│ ├── Icon (纹理: item_02.png)
│ └── Count (Label: "5")
├── Item3
│ ├── Icon (纹理: item_03.png)
│ └── Count (Label: "1")
...
渲染顺序(DFS):
Item1.Icon → Item1.Count → Item2.Icon → Item2.Count → Item3.Icon → Item3.Count...
DrawCall = 50个Icon + 50个Label = 100个DrawCall!
生活类比:
想象你在整理书架:
- 优化前 = 书按"主人"分类:小明的小说、小明的教科书、小红的小说、小红的教科书... 找小说时要跳过教科书
- 优化后 = 书按"类型"分类:所有小说放一起,所有教科书放一起。找小说时一次拿完
优化方案1:图集打包
将所有物品图标打包到一张图集:
items_atlas.png (包含 item_01 ~ item_50)
优化后:
所有Icon使用同一图集 → 可以合批!
50个Icon = 1个DrawCall
50个Label = 1个DrawCall(如果Label使用相同字体和大小)
总计 = 2个DrawCall
为什么图集打包能优化?
因为合批的第一个条件是"相同纹理"。50个单独的图片 = 50个不同纹理 = 50个DrawCall。打包成图集后,50个Sprite都引用同一张纹理(图集),满足合批条件,变成1个DrawCall。
图集的工作原理:
图集纹理 (atlas.png):
┌─────────────────────────────┐
│ item_01 │ item_02 │ ... │
│ (0,0) │ (64,0) │ │
├─────────────────────────────┤
│ item_10 │ item_11 │ ... │
│ │ │ │
└─────────────────────────────┘
每个Sprite的UV坐标指向图集中的对应区域:
- Sprite1的UV: (0,0) ~ (0.125, 0.125) ← 指向item_01区域
- Sprite2的UV: (0.125,0) ~ (0.25, 0.125) ← 指向item_02区域
Sprite的顶点数据包含UV坐标。即使所有Sprite用同一张图集纹理,只要UV不同,就能显示不同的图片。这就是图集能合批的原理!
优化方案2:层级重组
// 将相同类型的节点放到同一层级下
// 优化后的结构:
Backpack
├── Icons // 所有图标放这里
│ ├── Item1_Icon
│ ├── Item2_Icon
│ └── Item3_Icon
└── Counts // 所有文字放这里
├── Item1_Count
├── Item2_Count
└── Item3_Count
// 渲染顺序:
// Item1_Icon → Item2_Icon → Item3_Icon (合批!1个DrawCall)
// Item1_Count → Item2_Count → Item3_Count (合批!1个DrawCall)
为什么层级重组能优化?
因为Batcher2D使用DFS遍历。优化前的层级是"图标-文字-图标-文字"交替,每次纹理切换都打断合批。优化后是"图标-图标-图标-文字-文字-文字",相同纹理的Sprite连续出现,可以合批。
自问自答:
Q: 层级重组会不会影响UI的显示效果? A: 可能会!如果图标和文字有重叠,重组后它们的绘制顺序变了,可能导致文字被图标盖住(或相反)。需要确保重组后的层级关系仍然正确。通常图标在底层、文字在顶层是安全的。
Q: 有没有不改层级也能优化的方法? A: 有!如果所有图标已经在图集里,即使层级交错,只要没有其他打断合批的因素,图集内的不同区域仍然可以合批(因为它们用同一张纹理)。层级重组主要是解决"不同纹理交替"的问题。
五、GPU Instancing
原理
为什么需要GPU Instancing?
普通合批是把多个物体的顶点数据合并到一个大VBO里。但如果物体数量非常多(比如10000个草),VBO会非常大,CPU合并的开销也很高。GPU Instancing是更高效的方案。
GPU Instancing允许一次DrawCall绘制多个相同几何的实例,每个实例可以有不同的变换矩阵和颜色。
普通合批: GPU Instancing:
VBO: [顶点A, 顶点B, 顶点C] VBO: [顶点A](只存一份几何)
DrawCall × 1 Instancing Data: [矩阵1, 矩阵2, 矩阵3]
绘制3个Sprite DrawCall × 1
绘制3个实例
生活类比:盖章
- 普通合批 = 手写100个签名。每个签名都写一遍,字一样但过程重复100次
- GPU Instancing = 用印章盖100个章。印章只有一个(几何只存一份),盖在不同位置(实例数据不同)
Cocos中的GPU Instancing
Cocos 3.x在3D渲染中支持GPU Instancing,2D渲染中主要通过动态合批实现类似效果。
// GPU Instancing在Cocos中的使用(3D场景)
// 适用于大量相同模型的场景,如草地、树木、粒子
// 1. 创建Instancing数据
// matrices数组存储每个实例的变换矩阵
const matrices: Mat4[] = [];
for (let i = 0; i < 1000; i++) {
const mat = new Mat4();
// 把每个实例沿X轴偏移2个单位
Mat4.translate(mat, mat, new Vec3(i * 2, 0, 0));
matrices.push(mat);
}
// 2. 使用Instancing绘制
// Cocos引擎内部会自动使用GPU Instancing(如果硬件支持)
// 开发者只需要正常创建节点,引擎会自动优化
逐行注释解读:
const matrices: Mat4[] = [];——Mat4是4x4矩阵,用来表示3D变换(位置、旋转、缩放)Mat4.translate(mat, mat, new Vec3(i * 2, 0, 0));—— 矩阵平移操作。把矩阵沿X轴移动i*2个单位matrices.push(mat);—— 把矩阵加入数组。这个数组就是"实例数据"- 在Cocos中,2D渲染通常不需要手动使用Instancing,动态合批已经足够。3D场景中,引擎会自动检测是否可以使用Instancing
GPU Instancing vs 动态合批
| 特性 | GPU Instancing | 动态合批 |
|---|---|---|
| 原理 | GPU硬件支持,一次绘制多个实例 | CPU合并顶点数据到VBO |
| CPU开销 | 极低(只传实例数据) | 较高(每帧合并顶点) |
| 内存开销 | 低(几何只存一份) | 较高(顶点数据重复存储) |
| 灵活性 | 中(只能变矩阵和颜色) | 高(可以任意变顶点) |
| 适用场景 | 大量相同几何(粒子、草地) | 2D UI、不同形状的Sprite |
| WebGL支持 | WebGL2原生,WebGL1需扩展 | 所有WebGL版本 |
自问自答:
Q: 2D游戏需要用GPU Instancing吗? A: 通常不需要。2D游戏的Sprite形状不同(不同图片),不适合Instancing。动态合批已经足够。Instancing主要用于3D场景中的重复物体(如草地、树木、粒子)。
Q: WebGL1不支持Instancing怎么办?
A: WebGL1有一个扩展叫ANGLE_instanced_arrays,大部分现代浏览器都支持。如果不支持,引擎会回退到普通合批。
六、图集打包策略
为什么需要图集?
如果不使用图集会怎样?
每个单独的图片都是一张纹理。50张单独的图片 = 50个纹理 = 最多50个DrawCall(如果它们连续出现)。使用图集后,50张图片变成1张纹理 = 1个DrawCall。
没有图集: 有图集:
Sprite1: texture_a.png Sprite1: atlas.png (区域1)
Sprite2: texture_b.png → Sprite2: atlas.png (区域2)
Sprite3: texture_c.png Sprite3: atlas.png (区域3)
DrawCall = 3 DrawCall = 1
纹理切换 = 3次 纹理切换 = 0次
生活类比:超市货架
- 没有图集 = 每种商品放一个仓库,取货时要跑3个仓库
- 有图集 = 所有商品放在同一个仓库的不同货架上,一次取完
图集打包的最佳实践
1. 按功能模块打包
atlas_common.png // 通用UI元素(按钮、边框、背景)
atlas_battle.png // 战斗界面专用
atlas_bag.png // 背包界面专用
atlas_role.png // 角色相关
为什么按模块打包?
因为合批只在"同时显示的物体"之间生效。如果背包界面的图标和战斗界面的图标打包到同一张图集,但它们从不会同时显示,那就浪费了内存(加载了不用的纹理)。
生活类比:
就像整理行李箱:
- 按模块打包 = 游泳装备放一个包,登山装备放一个包。去海边只带游泳包
- 全部放一个包 = 不管去哪都带所有东西,很重且很多用不上
2. 按渲染顺序打包
// 同一界面中同时显示的Sprite,打包到同一图集
// 这样它们天然可以合批
LoginScene:
├── atlas_login_bg.png // 背景层
├── atlas_login_ui.png // UI层(按钮、输入框)
└── atlas_login_effect.png // 特效层
3. 图集大小控制
| 平台 | 最大纹理尺寸 | 建议图集大小 |
|---|---|---|
| iOS高端 | 4096×4096 | 2048×2048 |
| Android高端 | 4096×4096 | 2048×2048 |
| 中低端手机 | 2048×2048 | 1024×1024 |
| 微信小游戏 | 2048×2048 | 1024×1024 |
为什么图集不能太大?
- 内存占用:2048×2048的RGBA纹理占用 2048×2048×4 = 16MB显存
- 加载时间:大图加载慢,影响首屏时间
- 兼容性:低端设备可能不支持4096×4096纹理
- 合批限制:即使在大图集上,如果VBO满了(65535顶点),仍然需要多个DrawCall
4. 动态图集(Dynamic Atlas)
// Cocos动态图集配置
import { dynamicAtlasManager } from 'cc';
// 开启动态图集
// 动态图集会在运行时自动把小图合并到大图上
// 适合运行时加载的零散图片(如网络下载的头像)
dynamicAtlasManager.enabled = true;
// 设置参数
dynamicAtlasManager.maxAtlasCount = 5; // 最大图集数量(内存换性能)
dynamicAtlasManager.textureSize = 2048; // 图集大小
dynamicAtlasManager.maxFrameSize = 512; // 最大单帧尺寸(大图不进动态图集)
逐行注释解读:
dynamicAtlasManager.enabled = true;—— 开启动态图集。默认可能是关闭的,需要手动开启maxAtlasCount = 5—— 最多维护5张动态图集。如果超过,旧的图集会被释放。这个值越大,能缓存的纹理越多,但内存占用也越大textureSize = 2048—— 每张动态图集的大小。和静态图集一样,要考虑内存和兼容性maxFrameSize = 512—— 超过这个尺寸的图片不会进入动态图集。因为大图占用太多图集空间,性价比低
动态图集的注意事项:
- 只对小图有效(通常小于512×512)
- 频繁变化的纹理不适合(如视频、实时生成的内容)
- 会增加内存占用(需要额外的图集纹理)
- 首次加载时有构建图集的开销
初学者常见错误:
| 错误 | 为什么错 | 正确做法 |
|---|---|---|
| 把所有游戏图片打包到一张超大图集 | 内存爆炸,低端机无法加载 | 按模块分多张图集 |
| 动态图集开太多 | 内存占用过高 | 根据实际需求设置maxAtlasCount |
| 大图也塞进动态图集 | 浪费动态图集空间,性价比低 | 设置合理的maxFrameSize |
| 忽略图集的padding设置 | 图片边缘可能出现黑线/杂色 | 设置适当的padding(通常2像素) |
七、层级优化
节点层级对合批的影响
为什么层级会影响合批?
因为Batcher2D使用深度优先遍历(DFS),节点层级直接影响渲染顺序。如果相同纹理的Sprite被不同纹理的Sprite隔开,就无法合批。
Batcher2D使用深度优先遍历(DFS),节点层级直接影响合批效果。
❌ 差的层级结构(打断合批):
Canvas
├── Panel1
│ ├── Icon (纹理A)
│ └── Label (字体纹理)
├── Panel2
│ ├── Icon (纹理A) ← 和Panel1.Icon纹理相同,但中间被Label隔开了!
│ └── Label (字体纹理)
└── Panel3
├── Icon (纹理A)
└── Label (字体纹理)
渲染顺序:IconA → Label → IconA → Label → IconA → Label
DrawCall = 6(无法合批)
为什么这里无法合批?
DFS遍历顺序是:Panel1 → Panel1.Icon → Panel1.Label → Panel2 → Panel2.Icon → Panel2.Label...
渲染顺序变成:纹理A → 字体纹理 → 纹理A → 字体纹理 → 纹理A → 字体纹理
每次纹理切换都打断合批,所以6个组件 = 6个DrawCall。
✅ 好的层级结构(促进合批):
Canvas
├── Icons // 所有图标放一起
│ ├── Panel1_Icon (纹理A)
│ ├── Panel2_Icon (纹理A) ← 相邻,可以合批!
│ └── Panel3_Icon (纹理A)
└── Labels // 所有文字放一起
├── Panel1_Label
├── Panel2_Label ← 相邻,可以合批!
└── Panel3_Label
渲染顺序:IconA → IconA → IconA → Label → Label → Label
DrawCall = 2(Icon合批 + Label合批)
为什么这里可以合批?
DFS遍历顺序是:Icons → Panel1_Icon → Panel2_Icon → Panel3_Icon → Labels → Panel1_Label...
渲染顺序变成:纹理A → 纹理A → 纹理A → 字体纹理 → 字体纹理 → 字体纹理
相同纹理连续出现,满足合批条件!所以6个组件 = 2个DrawCall。
层级优化的实用技巧
技巧1:按纹理类型分组
// 在代码中动态重组节点层级
// 这个函数把Sprite节点排在前面,Label节点排在后面
function optimizeHierarchy(root: Node) {
const sprites: Node[] = [];
const labels: Node[] = [];
// 收集所有子节点,按组件类型分类
for (const child of root.children) {
if (child.getComponent(Sprite)) sprites.push(child);
if (child.getComponent(Label)) labels.push(child);
}
// 重新排序:Sprite在前,Label在后
// setSiblingIndex设置节点在父节点children数组中的索引
// 索引越小,越先渲染(DFS先遍历)
let index = 0;
for (const node of sprites) node.setSiblingIndex(index++);
for (const node of labels) node.setSiblingIndex(index++);
}
逐行注释解读:
child.getComponent(Sprite)—— 获取节点上的Sprite组件。如果节点没有Sprite组件,返回nullnode.setSiblingIndex(index++)—— 设置节点的兄弟索引。siblingIndex决定节点在父节点的children数组中的位置,从而影响DFS遍历顺序let index = 0;—— 从0开始设置。所有Sprite的siblingIndex设为0, 1, 2...,所以Sprite会排在前面- 后面的Label从Sprite的最后一个索引继续,所以Label排在Sprite后面
技巧2:使用自定义渲染顺序
// 通过设置节点的siblingIndex控制渲染顺序
// siblingIndex越小,越先渲染
// 将相同纹理的节点放在一起
function sortByTexture(root: Node) {
// slice()创建children数组的副本,避免修改原数组时出错
const children = root.children.slice();
// 按纹理名称排序
children.sort((a, b) => {
// 获取Sprite组件的纹理名称
// ?. 是可选链,避免节点没有Sprite组件时报错
const texA = a.getComponent(Sprite)?.spriteFrame?.texture?.name || '';
const texB = b.getComponent(Sprite)?.spriteFrame?.texture?.name || '';
// localeCompare按字母顺序比较字符串
return texA.localeCompare(texB);
});
// 按排序后的顺序重新设置siblingIndex
children.forEach((child, index) => {
child.setSiblingIndex(index);
});
}
逐行注释解读:
root.children.slice()——children是只读数组,不能直接排序。slice()创建浅拷贝,可以安全排序a.getComponent(Sprite)?.spriteFrame?.texture?.name || ''—— 多层可选链,安全获取纹理名称。如果任何一层为null,返回undefined,然后|| ''转为空字符串texA.localeCompare(texB)—— 字符串比较方法。返回负数表示texA在前,正数表示texB在前,0表示相等children.forEach((child, index) => { ... })—— 遍历排序后的数组,按新顺序设置siblingIndex
初学者常见错误:
| 错误 | 为什么错 | 正确做法 |
|---|---|---|
| 修改siblingIndex后没有立即生效 | siblingIndex只在下一帧遍历生效 | 在节点创建时就规划好顺序 |
| 只按纹理排序,忽略了遮挡关系 | 可能导致后面的物体盖住前面的 | 确保排序后遮挡关系仍然正确 |
| 每帧都调用sortByTexture | 浪费CPU,且可能导致闪烁 | 只在初始化或数据变化时排序 |
| 忽略Mask对层级的影响 | Mask会改变stencilStage,即使纹理相同也不能合批 | 把使用Mask的节点单独分组 |
八、脏标记优化(脏矩形)
什么是脏标记优化?
为什么需要脏标记优化?
每帧遍历场景树时,如果所有节点都重新生成渲染数据,CPU开销很大。但实际上,大部分节点在大部分帧中是"静止"的——它们的位置没变、纹理没变、颜色没变。脏标记优化就是:只更新"脏了"(发生了变化)的渲染数据,跳过没有变化的节点。
生活类比:只擦脏的地方——黑板上有10行字,只有1行写错了,你只需要擦那1行重写,不用把10行全擦了重写。
脏标记机制让引擎只更新真正变化的节点:
每帧遍历时:
├── 节点位置没变 → 不重新计算世界矩阵
├── Sprite纹理没变 → 不重新生成顶点数据
├── Label文字没变 → 不重新生成文字网格
└── 有变化的节点 → 重新计算渲染数据
Cocos中的脏标记机制
TransformBit —— 变换脏标记
enum TransformBit {
NONE = 0, // 没有变化
POSITION = 1 << 0, // 位置变了
ROTATION = 1 << 1, // 旋转变了
SCALE = 1 << 2, // 缩放变了
RS = ROTATION | SCALE,
TRS = POSITION | ROTATION | SCALE, // 全变了
}
当节点的position/rotation/scale发生变化时:
set position(val: Vec3) {
this._lpos = val;
this._transformFlags |= TransformBit.POSITION; // 标记脏
this.invalidateChildren(TransformBit.POSITION); // 子节点也脏了
}
markForUpdateRenderData —— 渲染数据脏标记
// UIRenderer中的脏标记
class UIRenderer extends Component {
_renderData: RenderData | null = null;
_dirty: boolean = true; // 渲染数据是否需要更新
markForUpdateRenderData(enable = true) {
this._dirty = enable;
if (enable) {
this._renderData = null;
}
}
}
如何利用脏标记减少不必要的更新
@ccclass('DirtyFlagOptimizer')
export class DirtyFlagOptimizer extends Component {
private _lastPos: Vec3 = new Vec3();
private _sprite: Sprite = null;
start() {
this._sprite = this.getComponent(Sprite);
this._lastPos.set(this.node.worldPosition);
}
update(dt: number) {
const curPos = this.node.worldPosition;
if (Vec3.equals(curPos, this._lastPos)) {
return; // 位置没变,不需要更新
}
this._lastPos.set(curPos);
// 只有位置变了才做逻辑处理
}
}
脏标记优化的实战技巧
技巧1:减少不必要的属性修改
// ❌ 不好:每帧都设置,即使值没变也标记脏
update(dt) {
this.node.setPosition(this.targetPos);
}
// ✅ 好:只在值变化时设置
update(dt) {
if (!Vec3.equals(this.node.position, this.targetPos)) {
this.node.setPosition(this.targetPos);
}
}
技巧2:减少不必要的markForUpdateRenderData调用
// ❌ 不好:手动标记脏
this.sprite.markForUpdateRenderData(true);
// ✅ 好:只在真正需要时标记
if (this.needUpdate) {
this.sprite.markForUpdateRenderData(true);
}
技巧3:利用节点的静态标记:不变的节点设为static
优化效果
| 场景 | 优化前 | 优化后 | 效果 |
|---|---|---|---|
| 100个Sprite每帧更新 | 100次渲染数据更新 | 只更新变化的 | CPU降低50-80% |
| 列表滚动 | 所有item更新 | 只更新可见item | CPU降低70% |
注意事项
- 脏标记是自动管理的:大部分情况不需要手动处理
- 不要过度优化:如果确实每帧都在变,脏标记没有额外收益
- 注意子节点级联:父节点位置变了,所有子节点都会标记脏
- Label是脏标记大户:每次文字变化都要重新生成网格,非常耗CPU
九、修改DrawCall命令传参(修改Shader)
💡 初级程序员注意:本节涉及Shader编写,难度较高。如果还没学过Shader,可以先只看概念理解部分,具体Shader代码留到Shader篇再学。现在只需要知道"通过Shader可以合并不同效果的DrawCall"这个概念即可。
这一步是什么?
通过修改Shader的Uniform参数,让不同的渲染效果在同一个DrawCall中完成,而不是为每种效果单独发一个DrawCall。
比喻:一个厨师(1个DrawCall)做10道菜,每道菜只需要调整调料(Uniform参数),不需要换厨师(新DrawCall)。
为什么能优化?
每次切换Shader程序或材质参数,都需要一个新的DrawCall。如果能把多个效果合并到一个Shader中,通过Uniform参数控制差异,就能减少DrawCall。
典型场景:
- 10个Sprite,9个正常显示,1个灰度显示 → 原本2个DrawCall(正常+灰度)
- 修改Shader:加一个
u_gray参数 → 所有Sprite用同一个Shader → 1个DrawCall
DrawCall传参是什么意思?
每个DrawCall提交时,CPU需要设置以下参数给GPU:
DrawCall参数 = {
Shader程序, // 用哪个着色器
VBO + IBO, // 顶点数据
纹理列表, // 采样哪些纹理
Uniform变量, // MVP矩阵、颜色、自定义参数
混合模式, // Alpha混合方式
深度/模板状态, // 测试配置
}
任何参数不同 = 新的DrawCall!
通过修改Shader,我们可以把"参数差异"变成"Uniform变量差异",在同一个DrawCall中处理。
CocosCreator 3.8.x中怎么用?
实战1:统一灰度Shader(减少灰度切换的DrawCall)
// 1. 创建自定义Effect文件:custom-sprite.effect
// CCEffect部分
CCEffect %{
techniques:
- passes:
- vert: sprite-vs:vert
frag: sprite-fs:frag
depthStencilState: &depthStencilState
depthTest: false
depthWrite: false
blendState: &blendState
targets:
- blend: true
blendSrc: src_alpha
blendDst: one_minus_src_alpha
rasterizerState:
cullMode: none
properties:
alphaThreshold: { value: 0.5 }
u_gray: { value: 0.0 } // 新增:灰度开关
}%
// Fragment Shader部分
CCProgram sprite-fs %{
precision highp float;
#include <embedded-alpha>
#include <alpha-test>
in vec4 v_color;
in vec2 v_uv;
uniform sampler2D u_texture;
uniform float u_gray; // 灰度参数
void main() {
vec4 color = v_color * CCSampleWithAlphaSeparated(u_texture, v_uv);
if (u_gray > 0.5) {
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
color.rgb = vec3(gray);
}
#if USE_ALPHA_TEST
ALPHA_TEST(color);
#endif
gl_FragColor = color;
}
}%
// 2. 使用自定义Effect
@ccclass('GrayShaderOptimizer')
export class GrayShaderOptimizer extends Component {
@property(Material)
normalMaterial: Material = null;
@property(Material)
grayMaterial: Material = null;
setGray(sprite: Sprite, isGray: boolean) {
const mat = sprite.getMaterial(0);
mat.setProperty('u_gray', isGray ? 1.0 : 0.0);
}
}
实战2:通过顶点颜色传递参数(最灵活,不打断合批)
// 利用顶点颜色的额外通道传递参数
// 比如用color.a传递灰度值,不需要额外Uniform
// 这样所有Sprite用同一个材质 → 完美合批
@ccclass('VertexColorOptimizer')
export class VertexColorOptimizer extends Component {
@property(Sprite)
sprite: Sprite = null;
setGrayLevel(level: number) {
const color = this.sprite.color;
this.sprite.color = new Color(color.r, color.g, color.b, level * 255);
}
}
优化效果
| 场景 | 优化前DrawCall | 优化后DrawCall | 效果 |
|---|---|---|---|
| 10个Sprite(9正常+1灰度) | 2 | 1 | 50%↓ |
| 20个不同小图 | 20 | 1(图集+Shader) | 95%↓ |
| 5种颜色变体的角色 | 5 | 1(Uniform传色) | 80%↓ |
注意事项
- Shader编译很慢:首次使用新Shader会卡顿,建议预热
- Uniform数量有限制:WebGL1最少支持128个vec4 uniform
- 不要在Shader中做复杂计算:片元着色器执行百万次,每多一行都放大百万倍
- Shader调试困难:没有断点,只能通过颜色输出调试
- 跨平台兼容性:WebGL1和WebGL2的GLSL语法不同
DrawCall优化6步链路总结
皮肤层级调整 → 让相同纹理相邻,减少纹理切换
↓
静态合批 → 不变的UI不重复计算
↓
动态合批 → 运行时自动合并满足条件的DrawCall
↓
脏矩形(脏标记优化) → 只更新变化的部分
↓
底层改变渲染层次 → 重组节点结构,让同类型节点相邻
↓
修改Shader传参 → 用Uniform参数替代DrawCall切换
优化优先级:皮肤层级调整 > 图集合批 > 脏标记 > 静态合批 > 节点合并 > Shader优化
原因:前面的优化成本低、收益大、风险小;后面的优化成本高、收益递减、风险大。
十、Profiler分析方法
Cocos内置Profiler
// 开启性能统计
cc.debug.setDisplayStats(true);
// 显示信息:
// FPS: 60 → 帧率
// DrawCall: 15 → DrawCall数
// InstanceCount: 100 → 实例数
// Triangle: 200 → 三角形数
// Vertex: 400 → 顶点数
// Uniform: 50 → Uniform数量
各指标的含义:
| 指标 | 健康值 | 说明 |
|---|---|---|
| FPS | 60 | 帧率,低于30会明显卡顿 |
| DrawCall | < 50(2D) | 重点关注! |
| InstanceCount | 视场景 | GPU Instancing的实例数 |
| Triangle | < 10000 | 2D游戏通常很少 |
| Vertex | < 20000 | 2D游戏通常很少 |
| Uniform | < 100 | Uniform变量数量 |
自问自答:
Q: FPS和DrawCall有什么关系? A: 没有直接的数学关系,但高DrawCall通常会导致低FPS。因为CPU准备DrawCall需要时间,DrawCall越多,CPU越忙,帧率越低。在60FPS下,每帧只有16.67ms。如果100个DrawCall每个花0.5ms,总共50ms,远超16.67ms,帧率就会降到20FPS左右。
Chrome DevTools Performance
分析步骤:
录制性能数据
- 打开Chrome DevTools → Performance面板
- 点击录制按钮(圆形红点)
- 操作游戏30-60秒
- 停止录制
分析CPU时间
Main线程分析: ├── 黄色块:JavaScript执行 ├── 紫色块:渲染(Recalculate Style、Layout) ├── 绿色块:绘制(Paint、Composite) └── 灰色块:空闲定位DrawCall瓶颈
- 查看GPU时间线
- 寻找长任务(Long Task > 50ms)
- 分析调用栈,找到频繁调用的渲染函数
生活类比:体检报告
Performance面板就像体检报告:
- 黄色块(JS) = 血常规,看你的"血液"(代码)是否健康
- 紫色块(渲染) = 心电图,看"心脏"(渲染系统)是否正常工作
- 绿色块(绘制) = X光片,看"骨骼"(画面结构)是否正常
- 长任务 = 异常指标,需要重点关注
Spector.js 抓帧分析
安装使用:
- Chrome商店安装Spector.js扩展
- 点击Spector图标 → 点击红色录制按钮
- 操作游戏一帧
- 查看抓帧结果
关键指标:
| 指标 | 健康值 | 说明 |
|---|---|---|
| DrawCall数 | < 50 | 2D游戏的目标 |
| 纹理切换次数 | < 10 | 越少越好 |
| Shader切换次数 | < 5 | 越少越好 |
| 顶点数 | < 10000 | 2D游戏通常很少 |
Spector.js能看到什么?
Spector.js就像"渲染过程的录像机",可以回放一帧中所有的GPU命令:
- 第1个DrawCall:绑定了什么纹理?用了什么Shader?画了多少顶点?
- 第2个DrawCall:为什么纹理切换了?
- 每个命令的耗时是多少?
自问自答:
Q: Spector.js和Profiler有什么区别? A:
- Profiler = 看"CPU在干什么"(JS执行、函数调用栈)
- Spector.js = 看"GPU收到了什么命令"(DrawCall、纹理绑定、Shader切换)
两者互补:Profiler发现卡顿,Spector.js定位是哪些DrawCall导致的。
自定义性能监控
// 自定义性能监控组件
import { _decorator, Component, game, director } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('PerformanceMonitor')
export class PerformanceMonitor extends Component {
// 帧计数器
private _frameCount = 0;
// 上次统计的时间戳
private _lastTime = 0;
// 当前FPS
private _fps = 60;
// 当前DrawCall
private _drawCall = 0;
update(dt: number) {
// 每帧增加计数
this._frameCount++;
// 获取当前时间
const now = performance.now();
// 每1秒统计一次
if (now - this._lastTime >= 1000) {
// 计算FPS:这一秒内经过了多少帧
this._fps = this._frameCount;
// 重置计数器
this._frameCount = 0;
this._lastTime = now;
// 获取DrawCall(通过私有API,生产环境慎用)
// @ts-ignore 忽略TypeScript类型检查,因为batcher2D是私有属性
// @ts-ignore
this._drawCall = director.root?.batcher2D?.currNumDrawCalls || 0;
// 打印性能数据
console.log(`FPS: ${this._fps}, DrawCall: ${this._drawCall}`);
// 性能告警
if (this._drawCall > 100) {
console.warn('DrawCall过高!建议优化合批');
}
if (this._fps < 30) {
console.warn('帧率过低!建议检查性能瓶颈');
}
}
}
}
逐行注释解读:
@ccclass('PerformanceMonitor')—— Cocos的装饰器,把这个类注册为组件private _frameCount = 0;—— 私有变量,记录这一秒内经过了多少帧performance.now()—— 浏览器提供的高精度时间API,返回自页面加载以来的毫秒数(精确到微秒级)if (now - this._lastTime >= 1000)—— 判断是否过了1秒。1000是1000毫秒 = 1秒// @ts-ignore—— 告诉TypeScript"我知道这里可能有类型错误,请忽略"。因为currNumDrawCalls是引擎内部属性,TypeScript的类型定义可能没有包含它director.root?.batcher2D?.currNumDrawCalls || 0—— 多层可选链获取DrawCall数。如果任何一层为null,返回0
初学者常见错误:
| 错误 | 为什么错 | 正确做法 |
|---|---|---|
| 在生产环境使用私有API | 引擎升级后API可能变化,导致崩溃 | 只在调试时使用,发布前移除 |
| 每帧都打印日志 | 日志本身会消耗性能,且淹没控制台 | 每秒打印一次,或只在异常时打印 |
| 只关注FPS,不关注DrawCall | 低FPS可能是JS逻辑导致的,不一定是渲染问题 | 同时监控多个指标,综合分析 |
十一、DrawCall优化检查清单
开发阶段检查
- 所有UI图片是否打包图集?
- 为什么:单独图片 = 单独纹理 = 无法合批
- 相同图集的Sprite是否在层级上相邻?
- 为什么:DFS遍历顺序决定合批,不相邻会打断合批
- 是否减少了不必要的Mask使用?
- 为什么:Mask改变stencilStage,打断合批
- Label是否使用了BMFont(静态文字)?
- 为什么:TTF Label动态生成纹理,每个Label可能不同纹理
- 静态节点是否标记了static?
- 为什么:跳过遍历,减少CPU开销
- 是否避免了运行时创建/销毁大量节点?
- 为什么:创建/销毁节点是重量级操作,且破坏合批
- 是否使用了对象池复用节点?
- 为什么:复用节点避免创建销毁开销
测试阶段检查
- DrawCall数是否 < 50(2D游戏)?
- FPS是否稳定在60?
- 低端手机是否也能流畅运行?
- 使用Spector.js检查纹理切换次数
- 使用Chrome Performance分析CPU时间分布
发布阶段检查
- 是否移除了调试用的Profiler代码?
- 是否关闭了DisplayStats?
- 是否压缩了纹理(PVR/ETC/ASTC)?
- 是否移除了未使用的资源?
十二、实战案例:背包界面优化
优化前
背包界面:
- 50个物品格子
- 每个格子:背景(Sprite) + 图标(Sprite) + 数量(Label)
- 总计:150个渲染组件
DrawCall分析:
- 50个背景(不同纹理)= 50个DrawCall
- 50个图标(不同纹理)= 50个DrawCall
- 50个Label(动态生成)= 50个DrawCall
- 总计:150个DrawCall!
问题诊断:
- 背景:每个格子一个背景图,50个不同纹理 → 50个DrawCall
- 图标:每个物品一个图标,50个不同纹理 → 50个DrawCall
- Label:TTF字体,每个Label动态生成纹理 → 50个DrawCall
优化后
优化措施:
1. 所有背景使用同一纹理(9宫格拉伸)→ 1个DrawCall
2. 所有图标打包到图集 → 1个DrawCall
3. 数量文字使用BMFont → 1个DrawCall
4. 静态节点标记static → 减少遍历开销
优化后DrawCall:3个!
优化原理详解:
措施1:统一背景纹理
- 为什么有效:50个背景都用同一张纹理(通过9宫格拉伸适配不同大小),满足合批条件
- 9宫格是什么:把一张小图分成9个区域,四个角不变形,中间拉伸。这样一张小图可以适配任意大小的矩形
措施2:图标打包图集
- 为什么有效:50个图标在同一张图集上,引用同一张纹理,满足合批条件
- 图集UV:每个图标在图集上有不同的UV坐标,所以能显示不同的图片
措施3:BMFont代替TTF
- 为什么有效:BMFont是预先生成的位图字体,所有文字都在一张纹理上。"1"和"9"只是UV坐标不同
- TTF的问题:TTF是矢量字体,运行时把文字栅格化成纹理。每个不同的文字组合可能生成不同的纹理
// 背包优化代码示例
@ccclass('BagOptimizer')
export class BagOptimizer extends Component {
@property(SpriteAtlas)
itemAtlas: SpriteAtlas = null; // 物品图集
@property(BitmapFont)
numberFont: BitmapFont = null; // 数字字体
start() {
this.optimizeBag();
}
optimizeBag() {
// 1. 确保所有背景使用同一纹理
// getCommonBgFrame()返回9宫格背景图的SpriteFrame
const bgSprite = this.node.getChildByName('Background').getComponent(Sprite);
bgSprite.spriteFrame = this.getCommonBgFrame();
// 2. 物品图标使用图集
// getComponentsInChildren递归获取所有子节点的ItemIcon组件
const items = this.node.getComponentsInChildren(ItemIcon);
for (const item of items) {
// 设置图集后,ItemIcon会从图集中获取对应图标
item.setAtlas(this.itemAtlas);
}
// 3. 数量使用BMFont
// getComponentsInChildren递归获取所有子节点的Label组件
const counts = this.node.getComponentsInChildren(Label);
for (const count of counts) {
// 设置BMFont后,文字渲染使用位图字体纹理
count.font = this.numberFont;
}
// 4. 标记静态节点
// 背包界面打开后通常不变化,可以标记为静态
this.node.static = true;
}
}
逐行注释解读:
@property(SpriteAtlas)—— Cocos的属性装饰器,让变量在编辑器中可见,可以拖拽赋值itemAtlas: SpriteAtlas = null;—— 声明图集变量。SpriteAtlas是Cocos的图集类型,包含多张SpriteFramethis.node.getChildByName('Background')—— 通过名称获取子节点。注意:名称必须完全匹配.getComponent(Sprite)—— 获取节点上的Sprite组件this.node.getComponentsInChildren(ItemIcon)—— 递归获取所有子节点(包括孙节点)上的ItemIcon组件。这是一个深度优先搜索item.setAtlas(this.itemAtlas);—— 自定义方法,设置ItemIcon使用的图集count.font = this.numberFont;—— 设置Label的字体为BMFont。注意:BMFont和TTF的赋值方式不同this.node.static = true;—— 标记节点为静态。注意:如果背包界面有动画或交互,不能标记static
自问自答:
Q: 优化后真的能从150个DrawCall降到3个吗? A: 理论上是3个,但实际可能有更多:
- 如果背包有滚动条,滚动条本身可能有1-2个DrawCall
- 如果使用了Mask做裁剪,Mask会增加额外DrawCall
- 如果有空格子显示"空"的提示文字,可能增加DrawCall
实际优化后通常在5-10个DrawCall,这已经是非常优秀的结果了。
Q: 9宫格背景图怎么制作? A: 在Cocos编辑器中:
- 导入背景图片
- 选中图片资源,在属性检查器中设置
Type为SLICED - 设置
Inset Left/Right/Top/Bottom,定义四个角的大小 - 在Sprite组件中使用这个SpriteFrame,设置
Type为SLICED
十三、自问自答汇总
Q1: 为什么我的DrawCall比预期的多?
A: 按以下步骤排查:
- 打开Spector.js,看每个DrawCall用了什么纹理
- 检查是否有隐藏的节点(active=false但还在场景中)
- 检查是否有透明物体穿插在不透明物体中
- 检查是否有Mask或自定义Shader
- 检查Label是否使用了TTF(动态生成纹理)
Q2: 动态合批和静态合批可以同时使用吗?
A: 可以!它们是互补的:
- 静态节点标记
static=true,跳过遍历 - 动态节点每帧遍历,但满足条件时自动合批
Q3: 图集打包后内存会增加吗?
A: 可能会。图集有padding(图片之间的间隙),且需要2的幂次方尺寸,可能浪费一些空间。但通常:
- 单独纹理的总内存 > 图集内存(因为每张单独纹理也有padding和幂次方对齐)
- 图集减少了纹理切换开销,性能收益远大于内存代价
Q4: 为什么有时候相同纹理也不能合批?
A: 检查以下条件:
- 材质是否是同一个实例?(不是内容相同,是同一个对象)
- 是否有Mask改变了stencilStage?
- 是否超出了VBO大小限制?
- 节点是否在同一个Camera下?
Q5: 合批对GPU Instancing有什么影响?
A: 2D渲染通常不使用Instancing。动态合批已经能满足2D游戏的需求。Instancing主要用于3D场景中的大量相同模型(如草地、粒子)。
Q6: 我应该先优化DrawCall还是先优化Overdraw?
A: 先优化DrawCall!因为:
- DrawCall优化简单(合批即可),效果明显
- 2D游戏的Overdraw通常不严重(UI元素不重叠太多)
- DrawCall优化后,如果还有性能问题,再考虑Overdraw
Q7: 动态图集和静态图集怎么选择?
A:
- 静态图集:打包时生成,适合已知、固定的资源(UI图片、游戏图标)
- 动态图集:运行时生成,适合未知、动态的资源(网络头像、玩家上传的图片)
Q8: 标记static后,如果节点需要变化怎么办?
A:
- 临时取消static标记,变化完成后再标记
- 或者不要标记static,接受每帧遍历的开销
- 如果变化频率低(如每分钟一次),取消static的代价很小
参考资源
- Cocos官方文档 - 2D渲染优化:https://docs.cocos.com/creator/manual/zh/ui-system/optimize.html
- Cocos引擎源码 - Batcher2D:https://github.com/cocos/cocos-engine/tree/develop/cocos/2d/renderer
- WebGL Insights - DrawCall优化:http://www.webglinsights.com/