13.1 Draw Call 与合批
性能优化最容易踩的坑,是不量化就开始改代码。第 12 章给《方块冲刺》接完激励视频之后,我们在 2019 款 iPad(Safari,960 × 540,横屏)上跑了一遍主场景:Draw Call 稳定在 170~190,其中光金币和道具图标就占了一多半。这还只是个 2D 跑酷,说明瓶颈不在 GPU 的像素填充,而在 CPU 每帧要提交上百次渲染命令。
本节把 Draw Call 从「一个数字」拆成可操作的工程项:它由什么决定、Cocos Creator 3.8 什么时候会替你合批、以及有哪些常见写法会把已经合好的批次又打断。站内 game/client/ 那 116 篇多讲引擎概念,本节给的是能在 Creator 工程里照做的配置与诊断代码。
13.1.1 一次 Draw Call 的代价
渲染一帧时,CPU 要把每个「绘制单元」翻译成一组图形命令交给 GPU:绑定材质、绑定纹理、设置混合状态、下发顶点。GPU 侧换纹理和换材质有成本,CPU 侧每提交一次也要一次函数调用与状态校验。2D 游戏节点数量大——一个跑酷关卡里金币可能有 60 个、障碍 40 个、粒子上百个——如果每个节点都单独绘制,180 个 Draw Call 毫不稀奇。
关键认知是:Draw Call 的数量主要影响 CPU,不直接影响 GPU 的填充速度。所以它在低端手机、微信小游戏这种 CPU 弱的设备上最先暴露。同一台 2019 款 iPad,把主场景 Draw Call 从 180 压到 42 之后,帧率从 47 fps 回到 60 fps;而在台式机上几乎看不出差别。这组数字是参考值,需在你的设备上复测。
合批前后的大致对照(同一关卡、同一设备):
| 指标 | 合批前 | 合批后 | 说明 |
|---|---|---|---|
| Draw Call | ~180 | ~42 | CPU 每帧提交次数 |
| 帧时间(logic + render) | ~21 ms | ~14 ms | 差值主要在 render |
| 帧率 | 47 fps | 60 fps | 受限于 vsync 上限 |
| 纹理数量 | 14 张 | 3 张 | 自动图集合并碎图 |
还要注意一个反直觉的点:Draw Call 并不是越低越好。为了压低它而把不同用途的图强行塞进同一张大图集,会白白浪费纹理内存(13.2 会讲),而且一张 4096 的大图在某些低端 GPU 上反而更慢。合批的目标是「把同屏、同频切换的图合起来」,不是「把全站资源压成一张」。
13.1.2 先搭一条可复现的测量线
没有基线就谈不上优化。在动任何代码之前,先把测量条件固定下来,否则改完你根本不知道是合批起了作用,还是那天设备恰好凉快。
一套够用的测量流程:
- 固定设备与浏览器:记录机型、系统版本、浏览器版本(我们这里锁定 2019 款 iPad + Safari)。
- 固定场景与输入:从关卡 1 开始,用同一段回放脚本(固定点按序列)跑满 60 秒,不要靠手动玩。
- 打开 Profiler:编辑器中「开发者 → 显示统计信息」,或运行时调用
profiler.showStats()(见 13.1.7)。 - 记录四个数:平均 FPS、Draw Call 峰值、
logic毫秒、render毫秒。只看 FPS 是不够的,因为 vsync 会把 58 fps 和 60 fps 抹平。 - 连续跑三次取中位数,避免首帧编译与资源加载的干扰。
把这张基线表存进工程的 docs/perf-baseline.md,每次优化后重跑一次,形成对比。后面几节的所有数字都基于这条线,你也应该用自己设备上的值替换它。
一个常见误区是「用编辑器预览来测性能」。编辑器预览跑在桌面浏览器里,CPU 与 GPU 都比目标设备强得多,测出来的 Draw Call 数字虽然相对可信,但帧时间完全不能代表真机。要真机数据,就得用 13.3 讲的真机构建与 Profiler。
13.1.3 合批的三个前提
Cocos Creator 3.8 的 2D 渲染器会自动把满足条件的相邻渲染单元合成一个 Draw Call。条件是三条,缺一不可:
- 同一材质:通常意味着同一个
Effect。默认的builtin-sprite材质就行,别给单个精灵挂自定义 Effect。 - 同一纹理:要么指向同一张
Texture2D,要么属于同一张图集。这是最常被破坏的一条。 - 渲染顺序连续:两个单元在渲染队列里必须挨着,中间不能被别的纹理或材质插队。
此外还有一条容量限制:单个合批批次的顶点数有上限,超过就会自动拆成新批次。一个 Sprite 四角八个顶点(两个三角形),所以单批次能容纳的精灵数量相当可观;真正拖后腿的是纹理切换,不是顶点数。
理解这三条,就能自己推导出「什么写法会打断合批」——凡是让相邻单元材质或纹理不一致的写法,都会断。
需要强调的是:合批是引擎自动做的,你不需要、也不应该在业务代码里手动拼批次。你能控制的只有两件事——资源怎么组织(图集、材质),以及节点怎么排序。把这两件事做对,合批自然发生;把这两件事做错,写多少代码都救不回来。这也是为什么本节的篇幅几乎都花在「图集配置」和「层级顺序」上,而不是某个 API。
13.1.4 用自动图集把碎图合并
《方块冲刺》的 assets/textures/ 下有几十张小图标:金币、三种道具、障碍、UI 按钮。它们默认各自是一张独立纹理,渲染时交替出现,把合批切得稀碎。解决办法是打自动图集(Auto Atlas):把一组小图合并成一张大图,运行时它们是同一张纹理,自然合批。
在编辑器里创建自动图集:资源管理器里选中要合并的目录(例如 assets/textures/atlas/),右键 → 创建 → 自动图集配置,生成 game-atlas 资源;把需要合并的 SpriteFrame 拖进这个目录即可,目录里的图会被自动打包。
下面是自动图集资源的 .meta 片段。本段是 Cocos Creator 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
{
"ver": "1.0.28",
"importer": "auto-atlas",
"imported": true,
"uuid": "9b2f4c6e-1a37-4d58-9f02-3c6b7e1d0a44",
"files": [".json"],
"subMetas": {},
"userData": {
"maxWidth": 2048,
"maxHeight": 2048,
"padding": 2,
"powerOfTwo": true,
"packable": true,
"filterMode": "bilinear",
"wrapMode": "clamp-to-edge"
}
}
在编辑器里打开这个自动图集资源,你会看到什么:属性面板顶部是「图集配置」,Max Width / Max Height 都是 2048,Padding 为 2,勾选了 Power of Two;下方是一个实时预览的图集画布,显示当前目录里所有 SpriteFrame 被打包后的位置——如果某张图放不进去,它会被留在目录里单独成纹理,这时就该调大 Max Width 或减少目录里的图。右侧的 Filter Mode 显示 bilinear,Wrap Mode 显示 clamp-to-edge。
(以上工程文件需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。)
打包好之后,这些 SpriteFrame 运行时共享同一张纹理,只要它们在节点树里顺序相邻,就会被合成一个 Draw Call。注意自动图集是在构建时才真正生成大图,编辑器预览阶段看到的仍可能是散图,别以为没生效。
13.1.5 渲染顺序:为什么节点顺序会决定批次
自动图集解决了「同一纹理」,但「顺序连续」要靠节点树来保证。Cocos 的 2D 渲染顺序由节点在场景树里的位置决定:同一父节点下按子节点顺序绘制,先出现的先画;跨父节点时按整棵子树的遍历顺序。于是「先画所有关卡元素、再画所有 UI」这种整齐的层级,天然利于合批;而「关卡元素和 UI 交替、还夹着特效」的层级,会把批次切得粉碎。
下面是主场景 assets/scenes/main.scene 的节点结构节选。本段是 Cocos Creator 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
[
{ "__type__": "cc.SceneAsset", "_name": "main", "scene": { "__id__": 1 } },
{
"__type__": "cc.Scene",
"_name": "main",
"_children": [ { "__id__": 2 }, { "__id__": 5 }, { "__id__": 8 } ]
},
{
"__type__": "cc.Node",
"_name": "Level",
"_children": [ { "__id__": 3 }, { "__id__": 4 } ],
"_components": []
},
{ "__type__": "cc.Node", "_name": "Coins", "_components": [ { "__id__": 6 } ] },
{ "__type__": "cc.Node", "_name": "Obstacles", "_components": [ { "__id__": 7 } ] },
{ "__type__": "cc.Node", "_name": "Hud", "_components": [ { "__id__": 9 } ] }
]
在编辑器里打开这个场景,你会看到什么:层级管理器里 main 下有三个一级子节点,依次是 Level、Hud、Effects;展开 Level 能看到 Coins 与 Obstacles 两个分组节点,它们各自挂着一个只负责批量生成子节点的脚本组件(_components 里的 __type__ 是自定义脚本,形如 cc.Script 加你的脚本名)。关键约定是:所有关卡内的金币与障碍都生成在 Level 下,UI 一律在 Hud 下,特效单独归到 Effects 并排在最后。这样金币、障碍、UI 各自成段,批次不会互相穿插。
(以上工程文件需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。)
层级定好之后,运行时如果要调整顺序,用的是节点的 siblingIndex——把新生成的金币统一 setSiblingIndex 到 Coins 节点末尾,就能保证它们始终连在一起。反过来,若在 update 里频繁改动 siblingIndex,等于每帧重排渲染队列,反而会破坏合批并引入额外开销,务必避免。
13.1.6 打断合批的五类常见原因
| 现象 | 根因 | 处置 |
|---|---|---|
| 两个金币之间夹了一次 draw | 金币 SpriteFrame 来自不同纹理 | 归入同一自动图集 |
| UI 文字每行一次 draw | Label 未开启缓存,按字符绘制 | cacheMode 设为 BITMAP 或 CHAR |
| 一排精灵突然断成多段 | 中间插了 Mask / Graphics / 自定义材质 | 把 Mask 图层拆到独立层级,集中放置 |
| 大图集没生效 | 单图超过 maxWidth 被剔除 | 调大图集尺寸或拆目录 |
| 微信端合批效果差 | 动态图集在 Chrome 与微信小游戏默认关闭 | 改用构建期自动图集,不依赖动态图集 |
逐条解释其中三条最容易忽略的。第一,Label 默认 cacheMode 是 NONE,每个字符都当成一次独立绘制,一行十来个字的 HUD 就能贡献十几次 Draw Call;改成 BITMAP 后整行缓存成一张小图进图集。第二,Mask 与 Graphics 会强制切换材质与模板状态,把前后两段合批一刀切断,所以任何带遮罩的列表项都应集中在一个层级里,而不是散落在精灵中间。第三,也就是最后一行,Label.CacheMode.BITMAP 的官方说明里明确写着「动态图集在 Chrome 以及微信小游戏暂时关闭,该功能无效」——指望运行时把散图动态塞进图集来合批,在微信端根本不成立,跨端项目必须走构建期自动图集这条路。
13.1.7 合批与 Bundle:跨包的纹理不会合并
《方块冲刺》按 §C 的约定把关卡资源放进 bundle-levels 分包,首包只留启动场景与主 UI。这条分包策略和合批之间有一条容易忽视的边界:不同 Bundle 里的纹理不会被合进同一批次,因为它们是分别加载、分别持有纹理的。所以「主包 UI 图标 + 分包关卡图标」这种组合,即便形状再像,也不可能在一个 Draw Call 里画完。
实践上的结论有三条:
- 主包与分包各自维护自己的自动图集,不要指望一张跨包大图集;
- 同屏必然同时出现的资源,必须放进同一个 Bundle,否则每帧都会在包与包之间来回切纹理;
- 分包加载时机要提前——加载中的一帧如果只有部分纹理就绪,那一帧的 Draw Call 会异常高,属于「加载期抖动」,别拿它当优化目标。
判断某张图属于哪个 Bundle,最直接的办法是看它在资源管理器里的路径:assets/resources/ 下的进 resources 包,标记为 Bundle 的目录(如 assets/bundles/levels/)进对应包,其余进主包。构建后在 build/web-mobile/assets/ 下能看到每个包各自的 config.json 与图集文件。
13.1.8 L2:运行时把 Draw Call 采出来
改之前先量。下面这段组件挂在主场景的常驻节点上,每秒采一次引擎的 Profiler 统计,Draw Call 超阈值就打日志。它只依赖 cc 的公开 API,能被类型检查。
import { _decorator, Component, profiler } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('DrawCallProbe')
export class DrawCallProbe extends Component {
@property
warnThreshold = 120;
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;
const instances = stats.instances.counter.value;
const textureMem = stats.textureMemory.counter.value;
if (draws > this.warnThreshold) {
console.warn(
`[perf] draws=${draws} instances=${instances} texMem=${textureMem.toFixed(1)}MB`
);
}
}
}
profiler.stats 在引擎未初始化时可能为 null,所以先判空再读;stats.draws 是一个计数项,真正的数值在 stats.draws.counter.value 上。这几个字段名都来自 cc.d.ts 的 IProfilerState 接口,写错编译不过。
第二段脚本负责让散图「愿意」进图集:SpriteFrame.packable 默认为 true,但一旦某张图被手动改过或参与了别的用途,可能被关掉,运行时统一纠一遍。
import { _decorator, Component, SpriteFrame, dynamicAtlasManager } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('AtlasBatcher')
export class AtlasBatcher extends Component {
@property([SpriteFrame])
frames: SpriteFrame[] = [];
onLoad(): void {
// 动态图集在微信小游戏与 Chrome 上会被引擎关闭,这里只作为桌面端兜底
dynamicAtlasManager.enabled = true;
dynamicAtlasManager.textureSize = 2048;
dynamicAtlasManager.maxAtlasCount = 4;
for (const frame of this.frames) {
if (frame) frame.packable = true;
}
}
}
再强调一次:dynamicAtlasManager 只在支持动态图集的平台上有效,微信端要靠 13.1.4 的构建期自动图集。两段代码是互补的,不是二选一。
13.1.9 收口:本节给工程留下了什么
做完本节,《方块冲刺》工程里多了三样东西:
assets/textures/atlas/game-atlas自动图集资源与它的.meta,主场景的散图归入其中;assets/scripts/core/DrawCallProbe.ts与assets/scripts/core/AtlasBatcher.ts两个诊断/配置组件;docs/perf-baseline.md里的第一张基线表,以及一条可复述的验收线:主场景 Draw Call ≤ 60,且金币与障碍的绘制不再逐个出现(在低端设备上量)。
交付前,把下面这张清单再走一遍:
- 主场景所有小图标都在同一张自动图集里,且目录内没有「放不进去」的漏网之鱼;
- HUD 上的
Label都设了cacheMode,不再逐字符绘制; - 同屏的关卡元素、UI、特效各自成段,层级里不交叉;
- 主包与
bundle-levels各自有独立图集,没有跨包合批的期待; - 基线表已用本机设备复测,数字替换成实测值。
下一节把视角从「一帧提交多少次」转到「这些纹理本身占多少内存」——纹理尺寸、压缩格式与释放时机,才是真正决定小游戏能不能启动的那部分。
阅读导航:上一节:目录 · 下一节:13.2 纹理、内存与 GC 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。