《Cocos 游戏开发高级编程》2.3 渲染批次与合批机制

卷二 13.1 把《方块冲刺》主场景的 Draw Call 从 180 压到 42,用的是自动图集与层级排序。这一节把那次优化从 Batcher2D 内部解释清楚:遍历怎么走、合批靠什么哈希判定、顶点容量由哪个宏控制、以及 180 到 42 的差额具体来自哪几条被消掉的打断条件。所有结论都对照 cc.d.ts 与 cocos-engine 3.8.8 源码。

2.3 渲染批次与合批机制

卷二 13.1 干过一件事:把《方块冲刺》主场景的 Draw Call 从 180 压到 42。当时的做法是自动图集 + 层级排序 + Label 缓存,讲的是「怎么配」。本节换个视角——从 Batcher2D 内部解释那三条前提为什么成立、打断为什么长成那个样子。做完这节,你再看合批就不再是背规则,而是能自己推导。

本节涉及引擎实现的部分,全部给出 cocos/cocos-engine tag 3.8.8 的文件路径与行号;macro 的默认值以 cc.d.ts 为准。

2.3.1 合批的单位是「批次」,不是节点

先纠一个用词。引擎里没有「把 20 个精灵合成 1 个节点」这种事——它做的是把相邻的渲染单元合并进同一个 DrawBatch,最后一次提交画出多个四边形。cocos/2d/renderer/batcher-2d.ts(tag 3.8.8)第 88 行 export class Batcher2D implements IBatcher 就是这个合并器。

它内部维护一个 _batches 数组(CachedArray<DrawBatch2D>)和一批可复用的 DrawBatch2D 对象池。每帧遍历结束后,_batches 里的每一项就是一次 Draw Call 的数据来源。所以「Draw Call 数」在 2D 里约等于「_batches 的长度」——这也解释了为什么合批优化本质是减少批次数量,而不是减少节点数量。

想在运行时验证这一点,用 profiler 采一次即可(这段与卷二 13.1 的探针同源,字段名都来自 cc.d.ts 的 IProfilerState):

import { _decorator, Component, profiler } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('BatchCounter')
export class BatchCounter extends Component {
  @property
  warnThreshold = 60;

  private elapsed = 0;

  update(dt: number): void {
    this.elapsed += dt;
    if (this.elapsed < 1) return;
    this.elapsed = 0;

    const stats = profiler.stats;
    if (!stats) return;

    const draws = stats.draws.counter.value;
    if (draws > this.warnThreshold) {
      console.warn(`[batch] draws=${draws} 超阈值 ${this.warnThreshold}`);
    }
  }
}

profiler.stats 在引擎未初始化时可能是 null,所以先判空;真正的数值在 stats.draws.counter.value 上。它统计的是全帧的绘制提交次数,包含 2D 与 3D,所以排查 2D 合批时最好把 3D 相机临时关掉,免得数字被干扰。

2.3.2 遍历:walk 按 UI 树深度优先

合批能发生的前提,是引擎得知道「哪些渲染单元是相邻的」。这个顺序来自一次深度优先遍历。batcher-2d.ts 第 906 行的 walk(node, level) 主干是:

  1. 节点不可见(activeInHierarchy 为假)直接返回;
  2. 累计透明度,若整棵子树透明度约等于 0 就跳过;
  3. 若节点挂着 UIRenderer,调用 _handleUIRenderer 处理它;
  4. 递归遍历子节点——但若 node._static 为真,跳过子树;
  5. 若是 Mask 层级的出口,收口并退出遮罩。

第 4 步那个 _static 判断就是 UIStaticBatch 的实现基础,2.3.7 会用到。第 3 步决定了渲染顺序:UI 元素的绘制顺序 = 树的深度优先遍历顺序,先父后子、子按数组顺序。这就是「层级顺序决定批次」的根因——不是引擎额外做了排序,而是它压根就按树序来画。

2.3.3 判定:一个哈希加三个打断条件

_handleUIRenderer(batcher-2d.ts 第 833 行)里有一段判定,是整节的核心。简化后:

if (this._currHash !== dataHash          // 数据哈希变了
    || dataHash === 0                    // 或压根没有渲染数据
    || this._currMaterial !== mat        // 或材质对象引用变了
    || this._currDepthStencilStateStage !== depthStencilStateStage) {  // 或模板状态变了
    this.autoMergeBatches(...);          // 打断:先把上一批收口
    ... 以当前单元开一个新批 ...
}

三个条件里,dataHash 最需要解释。它来自 cocos/2d/renderer/render-data.ts(tag 3.8.8)第 462 行的 updateHash():

hashString = `${bufferId}${this.layer} ${this.textureHash}`;
this.dataHash = murmurhash2_32_gc(hashString, 666);

也就是说,dataHash 由三样东西决定:顶点缓冲 ID、节点 layer、纹理哈希。把它代回上面的判定,「什么会打断合批」就有了机械的答案:

打断条件对应人话常见触发
dataHash 里的 textureHash 变纹理换了相邻精灵用了不同图集 / 不同图
dataHash 里的 layer 变层级不同世界层与 UI 层交替
dataHash 里的 bufferId 变顶点缓冲换了当前缓冲写满,开了新缓冲
dataHash === 0没有标准顶点数据Graphics、Mask 等自管 IA 的组件
_currMaterial !== mat材质对象引用不同自定义材质 / 材质实例(见 2.2.6)
depthStencilStateStage 变模板状态变了Mask 的进入 / 退出

注意材质那条用的是引用比较(!==),不是内容比较。所以两个内容完全一样的 Material 对象也会打断合批——这正好解释了 2.2 里那句「材质实例会打断合批」。

2.3.4 顶点容量:BATCHER2D_MEM_INCREMENT 控制什么

批次不是想合多大就合多大,每个顶点缓冲有容量上限。上限由宏 macro.BATCHER2D_MEM_INCREMENT 控制,cc.d.ts 里它的默认值是 144(单位 KB)。

容量怎么换算成顶点数,源码写得很直接。cocos/2d/renderer/linear-buffer-accessor.ts(tag 3.8.8)第 156 行与 cocos/2d/renderer/static-vb-accessor.ts 第 90 行是同一个公式:

vCount = Math.floor(macro.BATCHER2D_MEM_INCREMENT * 1024 / vertexFormatBytes)

vertexFormatBytes 是每个顶点的字节数。Sprite 用的顶点格式是位置(3 float)+ UV(2 float)+ 颜色(4 float),即 9 个 float、36 字节。代入默认值:144 * 1024 / 36 = 4096 个顶点,而一个 Sprite 占 4 个顶点——默认情况下每个顶点缓冲大约能装 1024 个 Sprite。

这里有个坑要提醒:cc.d.ts 的注释把公式写成了 * 1024 / 9 * 4,与源码的 * 1024 / vertexFormatBytes 口径不一致(源码是除以每顶点字节数 36,注释像是漏了括号)。以源码为准,注释那个式子会算出 16 倍的数。

容量写满时会怎样?cocos/2d/renderer/mesh-buffer.ts(tag 3.8.8)第 359 行的 checkCapacity 会返回失败,linear-buffer-accessor.ts 的 _allocateBuffer() 随即开一个新的 MeshBuffer。新缓冲意味着 bufferId 变了,于是回到 2.3.3——dataHash 变、批次打断。容量溢出和纹理切换在机制上是同一类事件:都让 dataHash 变了。

一段在启动时调大容量的脚本,顺带打印换算结果:

import { _decorator, Component, macro } from 'cc';
const { ccclass } = _decorator;

@ccclass('BatchTuner')
export class BatchTuner extends Component {
  onLoad(): void {
    // 默认 144 KB;这里调到 1024 KB,单缓冲约可容纳 7000 个 Sprite
    macro.BATCHER2D_MEM_INCREMENT = 1024;

    const bytesPerVertex = 9 * 4; // pos3 + uv2 + color4,各 4 字节
    const vCount = Math.floor((macro.BATCHER2D_MEM_INCREMENT * 1024) / bytesPerVertex);
    console.log(`[batch] buffer 顶点上限=${vCount} 约合 Sprite=${vCount / 4}`);
  }
}

调大它并非没有代价:每个顶点缓冲是预先按容量分配的 Float32Array,调大等于多占内存,而且 cc.d.ts 明确建议上限不要超过 1048576 KB。跑酷这种同屏几百个 Sprite 的场景,默认 144 KB 通常就够;真正需要调大的是弹幕、塔防这类同屏上千个 2D 对象的游戏。

反过来说,调小它会主动制造打断。如果某个项目图省内存把 BATCHER2D_MEM_INCREMENT 设得很小,同屏精灵还没画到纹理边界,缓冲就先满了,bufferId 一变批次就断——这时你会看到「纹理明明一样,Draw Call 却下不去」的怪象。排查这类问题时,先把宏改回默认值再复测,能立刻区分「容量打断」与「纹理打断」。

2.3.5 把 180 → 42 拆开算

现在用上面的机制复盘卷二那次优化。180 个 Draw Call 的成因,按打断条件拆开大致是:

打断来源命中条件贡献的批次量级
金币 / 道具 / 障碍各自独立纹理textureHash 变大头,几十次
HUD 文字逐字符绘制每字符一张纹理十几到几十次
Mask 遮罩进出depthStencilStateStage 变个位数,但每次切两段
世界层与 UI 层交错layer 变与层级结构相关

优化后的 42,等于把上面四类打断逐个消掉后剩下的结构性边界:

  • 自动图集把几十张碎图并成 3 张纹理,textureHash 的切换次数从「几十」降到「个位数」;
  • Label 的 cacheMode 设成 BITMAP / CHAR 后,整行文字不再是逐字符换纹理;
  • 把 Mask / Graphics 集中到独立层级,depthStencilStateStage 的切换不再穿插在精灵中间;
  • 层级排序保证世界元素、UI、特效各自成段,layer 不来回跳。

换句话说,42 不是某个魔法数字,而是「同屏必需的纹理段数 + 遮罩段数 + 容量溢出段数」之和。同一台 2019 款 iPad 上帧率从 47 fps 回到 60 fps,是这几十次 CPU 侧提交被省掉的结果(该组数字是参考值,需在你的设备上复测)。

这也给出一条判断准则:如果优化后 Draw Call 还是下不去,先别怀疑合批没生效,而是去数「还有几个必然不同的纹理 / 层级 / 遮罩段」。 那些段是结构决定的,不是配置能消掉的。

用数字走一遍会更直观。假设主场景同屏约 300 个 UI 渲染单元(金币 60、障碍 40、道具 20、粒子 120、HUD 与背景 60),按机制拆:

  • 顶点容量:300 个 Sprite 共 1200 个顶点,远小于单缓冲 4096 的上限,所以容量不是 180 的来源——这条先排除;
  • 纹理:优化前碎图 14 张,遍历中纹理来回切换,按「每次切换断一次」估算,仅这一项就能贡献上百次;
  • 文字:HUD 若有 20 行、每行 10 字,cacheMode = NONE 时理论上能到 200 次纹理切换,实际被遍历顺序稀释后仍是十几到几十次;
  • 遮罩:两处 Mask 各进出一次,每次进出都因模板状态变化断一次,贡献 4 次左右。

优化后纹理并成 3 张、文字整行缓存、遮罩归层,剩下的批次就逼近「3 个纹理段 + 2 个遮罩段 + 若干层级段」这个下界,落在 40 上下完全合理。这条估算链本身就是排查工具:当你的数字对不上时,说明漏了某一类打断来源。

2.3.6 五类打断原因,逐条对应机制

把 2.3.3 的判定表换成排查用的形式:

现象命中的判定条件处置
两个金币之间夹了一次 drawtextureHash 不同归入同一自动图集
UI 文字每行多次 drawtextureHash 逐字符变Label.cacheMode 设 BITMAP 或 CHAR
一排精灵突然断成多段depthStencilStateStage 变把 Mask / Graphics 拆到独立层级
中间插了一个自定义材质精灵_currMaterial !== mat统一材质,或把特殊节点集中
大图集没生效、批次反而变多单图超过图集尺寸被剔除调大图集尺寸或拆目录

补一句 Label 的机制细节:Label.CacheMode 在 cc.d.ts 里有三个取值——NONE(不缓存)、BITMAP(缓存成静态图并进动态图集)、CHAR(拆成字符缓存进一张 1024×1024 的图集)。默认的 NONE 意味着每个字符是一段独立纹理,textureHash 逐个变化,自然逐个打断。所以「HUD 文字贡献几十次 Draw Call」不是文字多,是纹理切换多。

还要区分两件容易混淆的事:「同一张图集」不等于「同一次 draw」。图集只保证了 textureHash 相同,但如果两段之间插了一个 layer 不同、或材质不同的单元,dataHash 或材质判定仍会打断。反过来,「同一次 draw」也不等于「同一张图」——同一次 draw 的前提是所有判定条件都没变。把这两句话记住,排查时就不会只盯着图集看。

最后,动态图集这条捷径要打折使用:cc.d.ts 在 CacheMode.BITMAP 的注释里明确写着「动态图集在 Chrome 以及微信小游戏暂时关闭,该功能无效」。也就是说 BITMAP 模式在微信端拿不到合批收益,跨端项目必须回到构建期自动图集这条主路上来(卷二 13.1.4 已给过配置)。

2.3.7 UIStaticBatch 与工程决策

当一棵 UI 子树完全不变时,还有一条比调容量更彻底的路:UIStaticBatch。cc.d.ts 里 UIStaticBatch extends UIRenderer,只暴露一个公开方法 markAsDirty()。它的机制正好落在 2.3.2 的第 4 步——把子树标记为静态后,walk 遍历到该节点会跳过整棵子树,因为子节点的顶点数据已经被烘焙进一个静态批次,不再逐帧重新合并。

一个挂载形态如下。本段是 Cocos Creator 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

[
  { "__type__": "cc.SceneAsset", "_name": "main", "scene": { "__id__": 1 } },
  {
    "__type__": "cc.Scene",
    "_name": "main",
    "_children": [ { "__id__": 2 } ]
  },
  {
    "__type__": "cc.Node", "_name": "Hud",
    "_children": [ { "__id__": 3 }, { "__id__": 4 } ],
    "_components": [ { "__id__": 5 } ]
  },
  { "__type__": "cc.Node", "_name": "ScoreLabel", "_components": [] },
  { "__type__": "cc.Node", "_name": "CoinIcon", "_components": [] },
  {
    "__type__": "cc.UIStaticBatch",
    "_enabled": true,
    "autoDraw": true
  }
]

在编辑器里打开这个场景,你会看到什么:Hud 节点上挂了一个 UIStaticBatch 组件,属性检查器里只有 Auto Draw 一项可勾。它的语义是「这棵子树的内容是静态的,请把它们合成固定批次」。代价与红线很明确:一旦子节点里的 Sprite / Label 内容在运行时变化,静态批次不会自动更新,必须手动调 markAsDirty() 触发重建;忘了这一步,画面就会停在旧内容上。

把它收成工程决策:

  • 静态 HUD、背景装饰、固定 UI 面板适合 UIStaticBatch,一次烘焙、长期受益;
  • 会变的分数、进度条、倒计时绝不要放进静态子树,否则每帧都得 markAsDirty(),比不合还慢;
  • 调 BATCHER2D_MEM_INCREMENT 是最后手段,先确认打断来自容量而不是纹理 / 层级;
  • 不要为了压低 Draw Call 硬塞图集。把用途不同的图塞进一张 4096 大图,会白白吃纹理内存(见卷二 13.2),而且低端 GPU 上大图反而更慢。

交付前,把下面这张清单过一遍,每一项都能对回本节的一条机制:

  • 同屏碎图已归入自动图集,textureHash 的切换次数降到个位数;
  • 所有动态 Label 都设了 cacheMode,不再逐字符换纹理;
  • Mask / Graphics 集中到独立层级,不穿插在精灵中间;
  • 静态 HUD 用 UIStaticBatch 烘焙,且它内部没有运行时变化的节点;
  • 没有为单个节点创建的材质实例夹在合批段中间(对照 2.2.6);
  • 若动过 BATCHER2D_MEM_INCREMENT,确认打断来源确实是容量而非纹理 / 层级。

一句话收口:合批不是一个开关,而是「让相邻渲染单元的 dataHash 与材质尽量保持一致」这件事的自然结果。 你控制不了引擎怎么合并,但你能控制资源怎么组织、节点怎么排序——这两件事做对,批次自然就少了。

下一章离开 2D 批次,进入着色器世界:从 .effect 的完整语法与编译流程讲起,把本节只露了一角的 CCEffect / CCProgram 写全。

阅读导航:上一节:2.2 材质与 Effect 体系 · 下一节:3.1 Cocos Effect 语法与编译 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画