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

学习本章节后,你将能

  1. 精准分析DrawCall瓶颈
  2. 系统优化2D游戏性能
  3. 设计"渲染友好"的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元素。标记为静态后,引擎会:

  1. 跳过每帧的Transform更新遍历
  2. 跳过渲染数据的重新生成
  3. 直接复用上一次的渲染结果
// 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

为什么图集不能太大?

  1. 内存占用:2048×2048的RGBA纹理占用 2048×2048×4 = 16MB显存
  2. 加载时间:大图加载慢,影响首屏时间
  3. 兼容性:低端设备可能不支持4096×4096纹理
  4. 合批限制:即使在大图集上,如果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组件,返回null
  • node.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%

注意事项

  1. 脏标记是自动管理的:大部分情况不需要手动处理
  2. 不要过度优化:如果确实每帧都在变,脏标记没有额外收益
  3. 注意子节点级联:父节点位置变了,所有子节点都会标记脏
  4. 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%↓

注意事项

  1. Shader编译很慢:首次使用新Shader会卡顿,建议预热
  2. Uniform数量有限制:WebGL1最少支持128个vec4 uniform
  3. 不要在Shader中做复杂计算:片元着色器执行百万次,每多一行都放大百万倍
  4. Shader调试困难:没有断点,只能通过颜色输出调试
  5. 跨平台兼容性: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

分析步骤

  1. 录制性能数据

    • 打开Chrome DevTools → Performance面板
    • 点击录制按钮(圆形红点)
    • 操作游戏30-60秒
    • 停止录制
  2. 分析CPU时间

    Main线程分析:
    ├── 黄色块:JavaScript执行
    ├── 紫色块:渲染(Recalculate Style、Layout)
    ├── 绿色块:绘制(Paint、Composite)
    └── 灰色块:空闲
    
  3. 定位DrawCall瓶颈

    • 查看GPU时间线
    • 寻找长任务(Long Task > 50ms)
    • 分析调用栈,找到频繁调用的渲染函数

生活类比:体检报告

Performance面板就像体检报告:

  • 黄色块(JS) = 血常规,看你的"血液"(代码)是否健康
  • 紫色块(渲染) = 心电图,看"心脏"(渲染系统)是否正常工作
  • 绿色块(绘制) = X光片,看"骨骼"(画面结构)是否正常
  • 长任务 = 异常指标,需要重点关注

Spector.js 抓帧分析

安装使用

  1. Chrome商店安装Spector.js扩展
  2. 点击Spector图标 → 点击红色录制按钮
  3. 操作游戏一帧
  4. 查看抓帧结果

关键指标

指标 健康值 说明
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!

问题诊断

  1. 背景:每个格子一个背景图,50个不同纹理 → 50个DrawCall
  2. 图标:每个物品一个图标,50个不同纹理 → 50个DrawCall
  3. 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的图集类型,包含多张SpriteFrame
  • this.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编辑器中:

  1. 导入背景图片
  2. 选中图片资源,在属性检查器中设置TypeSLICED
  3. 设置Inset Left/Right/Top/Bottom,定义四个角的大小
  4. 在Sprite组件中使用这个SpriteFrame,设置TypeSLICED

十三、自问自答汇总

Q1: 为什么我的DrawCall比预期的多?

A: 按以下步骤排查:

  1. 打开Spector.js,看每个DrawCall用了什么纹理
  2. 检查是否有隐藏的节点(active=false但还在场景中)
  3. 检查是否有透明物体穿插在不透明物体中
  4. 检查是否有Mask或自定义Shader
  5. 检查Label是否使用了TTF(动态生成纹理)

Q2: 动态合批和静态合批可以同时使用吗?

A: 可以!它们是互补的:

  • 静态节点标记static=true,跳过遍历
  • 动态节点每帧遍历,但满足条件时自动合批

Q3: 图集打包后内存会增加吗?

A: 可能会。图集有padding(图片之间的间隙),且需要2的幂次方尺寸,可能浪费一些空间。但通常:

  • 单独纹理的总内存 > 图集内存(因为每张单独纹理也有padding和幂次方对齐)
  • 图集减少了纹理切换开销,性能收益远大于内存代价

Q4: 为什么有时候相同纹理也不能合批?

A: 检查以下条件:

  1. 材质是否是同一个实例?(不是内容相同,是同一个对象)
  2. 是否有Mask改变了stencilStage?
  3. 是否超出了VBO大小限制?
  4. 节点是否在同一个Camera下?

Q5: 合批对GPU Instancing有什么影响?

A: 2D渲染通常不使用Instancing。动态合批已经能满足2D游戏的需求。Instancing主要用于3D场景中的大量相同模型(如草地、粒子)。

Q6: 我应该先优化DrawCall还是先优化Overdraw?

A: 先优化DrawCall!因为:

  1. DrawCall优化简单(合批即可),效果明显
  2. 2D游戏的Overdraw通常不严重(UI元素不重叠太多)
  3. DrawCall优化后,如果还有性能问题,再考虑Overdraw

Q7: 动态图集和静态图集怎么选择?

A:

  • 静态图集:打包时生成,适合已知、固定的资源(UI图片、游戏图标)
  • 动态图集:运行时生成,适合未知、动态的资源(网络头像、玩家上传的图片)

Q8: 标记static后,如果节点需要变化怎么办?

A:

  1. 临时取消static标记,变化完成后再标记
  2. 或者不要标记static,接受每帧遍历的开销
  3. 如果变化频率低(如每分钟一次),取消static的代价很小

参考资源