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) 主干是:
- 节点不可见(
activeInHierarchy为假)直接返回; - 累计透明度,若整棵子树透明度约等于 0 就跳过;
- 若节点挂着
UIRenderer,调用_handleUIRenderer处理它; - 递归遍历子节点——但若
node._static为真,跳过子树; - 若是 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 的判定表换成排查用的形式:
| 现象 | 命中的判定条件 | 处置 |
|---|---|---|
| 两个金币之间夹了一次 draw | textureHash 不同 | 归入同一自动图集 |
| UI 文字每行多次 draw | textureHash 逐字符变 | 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 语法与编译 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。