3.3 批处理与 Draw Call
前两节我们解决了「画在哪」和「谁盖谁」,渲染列表已经能按正确顺序提交每一张精灵。但《方块冲刺》跑到第 5 关就会遇到新问题:屏幕上有 200 个金币、30 个粒子、十几块平台,如果每个精灵都单独发一次绘制命令,中端手机直接掉到 30 帧。瓶颈不在像素,而在提交次数。
本节讨论 Draw Call 与批处理。核心结论只有一句:把「同纹理、同材质、命令连续」的精灵合并成一次提交。我们会用一个记录式渲染器把 Draw Call 真正数出来,量化纹理切换的代价,并解释图集为什么是合批的前提。
本节把《方块冲刺》推进到「能扛住满屏精灵」:所有精灵帧打进图集,渲染列表按纹理分组,Draw Call 从 200+ 压到个位数。这一步完成后,第 17 章的产物体积与性能剖析会用到这里的统计口径。
3.3.1 一次 Draw Call 到底贵在哪
每次绘制调用,CPU 都要做一整套准备:绑定纹理、绑定着色器、上传统一变量、提交顶点缓冲,然后通知 GPU 执行。GPU 端画 4 个顶点几乎免费,但 CPU 端这套「准备」在移动端动辄几微秒。当精灵数量上千,CPU 会先于 GPU 成为瓶颈——这就是常说的「CPU 提交瓶颈」。
所以优化目标不是「少画像素」,而是「少做提交」。把 N 张精灵合并成一次提交,CPU 的准备成本从 N 次降到 1 次。
3.3.2 批处理的三个条件
一次合批要同时满足三个条件,缺一不可:
| 条件 | 含义 | 违反后的后果 |
|---|---|---|
| 同纹理 | 精灵来自同一张贴图 | 切纹理 → 强制断批 |
| 同材质 | 着色器与渲染状态一致 | 切材质 → 强制断批 |
| 命令连续 | 在渲染列表里彼此相邻 | 中间夹了别的纹理 → 断成两批 |
前两条是「能不能合」,第三条是「合不合成一段」。三个条件都满足时,N 张精灵共享一次提交;只要中间插进一张别的纹理,批次就从中间断开。
3.3.3 纹理切换的代价
假设渲染列表里的纹理序列是 A A B A A。按「相邻同纹理合并」的规则,它会被切成三段:A、B、A——也就是三次 Draw Call,哪怕只有两种纹理。如果顺序是 A A A A B,就只有两段。同样五张精灵,顺序不同,Draw Call 数不同:
序列 A A B A A → 3 次 Draw Call
序列 A A A A B → 2 次 Draw Call
这就是「命令连续」条件的直观体现:纹理来回切换是合批的头号杀手。一个常见的坏味道是「每隔一个金币插一个粒子」,如果两者来自不同贴图,合批会被切得稀碎。
3.3.4 记录式渲染器:把 Draw Call 数下来
要优化,先测量。mini2d 提供一个记录式渲染器:它不真的画,只把提交的命令存下来,flush 时按合批规则算出一批批 Draw Call。这样性能回归可以在 vitest 里被断言,而不必肉眼盯着帧率。
// src/render/recorder.ts
export interface DrawCommand {
texture: string; // 贴图标识;真实引擎里是纹理句柄
material: string; // 材质标识
x: number; y: number; w: number; h: number;
}
export interface DrawCall {
texture: string;
material: string;
count: number; // 合并了多少条命令
}
export class RecordingRenderer {
private readonly commands: DrawCommand[] = [];
submit(cmd: DrawCommand): void {
this.commands.push(cmd);
}
clear(): void {
this.commands.length = 0;
}
flush(): DrawCall[] {
const calls: DrawCall[] = [];
let current: DrawCall | null = null;
for (const cmd of this.commands) {
const mergeable =
current !== null &&
current.texture === cmd.texture &&
current.material === cmd.material;
if (mergeable && current) {
current.count += 1; // 同纹理同材质且连续 → 并入当前批次
} else {
current = { texture: cmd.texture, material: cmd.material, count: 1 };
calls.push(current);
}
}
return calls;
}
}
flush 就是那三条规则的直接翻译:纹理或材质任一不同就开新批次。把 flush() 返回的数组长度记下来,就是这一帧的 Draw Call 数。
3.3.5 图集为什么能合批
回顾第 2 章的图集切分:把很多小图打进一张大贴图,每个精灵帧记住自己在图集里的矩形。到了合批这一步,图集的价值才完全兑现——所有精灵共享同一张贴图,纹理条件天然满足。于是只要它们材质一致、在渲染列表里连续,就能一次画完。
反过来,如果每个精灵各自加载一张 PNG,纹理条件永远不满足,合批无从谈起。这就是为什么「资源管理」和「渲染性能」其实是同一件事的两面。
不过图集与排序有天然张力:排序键是 layer + zIndex + 遍历序,而合批要求同纹理连续,两者可能冲突。实践中的折中办法是让同一图集内的精灵尽量落在同一层——把「按语义分层」和「按图集分组」对齐,冲突就消失了。
3.3.6 按纹理重排与它的代价
既然「命令连续」这么重要,能不能直接把渲染列表按纹理重排,让同纹理的精灵凑到一起?可以,但要付出代价——重排会破坏绘制顺序。
// src/render/recorder.ts(续)
export function sortByTexture(commands: DrawCommand[]): DrawCommand[] {
return [...commands].sort((a, b) => {
if (a.texture !== b.texture) return a.texture < b.texture ? -1 : 1;
return 0; // 同纹理保持原有先后(稳定排序)
});
}
对一个 A A B A A 的序列,重排后变成 A A A A B,Draw Call 从 3 降到 2。但如果 A 和 B 本来分属不同的可见层次——比如 A 是地面、B 是玩家——重排会让玩家被地面盖住。画面错了,帧率再高也没有意义,这是渲染优化里最需要克制的地方。
所以真实渲染器不会全局按纹理重排,而是分层内重排:先按 layer 分桶,每个桶内部再按纹理凑批。这样跨层的先后不变,层内的合批机会被榨干:
// 前提:commands 已按 layer 分桶,本函数只在桶内按纹理稳定排序
export function batchWithinLayer(commands: DrawCommand[]): DrawCommand[] {
return sortByTexture(commands);
}
回头核对前面算过的数:不做任何重排,A A B A A 是 3 次;分层内重排后,只要 A、B 分属不同层就仍是 3 次,只有它们同层时才会降到 2 次。这正是「图集与分层对齐」的价值——同层精灵共享图集,重排几乎无损;不同层共享图集,重排也救不了跨层的断批。
3.3.7 用 vitest 验证合批与计数
合批逻辑是纯函数,最适合用测试锁死。
// test/recorder.test.ts
import { describe, it, expect } from 'vitest';
import { RecordingRenderer, sortByTexture } from '../src/render/recorder';
function cmd(texture: string, x = 0): {
texture: string; material: string; x: number; y: number; w: number; h: number;
} {
return { texture, material: 'default', x, y: 0, w: 16, h: 16 };
}
describe('batching', () => {
it('相邻同纹理合并为一个 draw call', () => {
const r = new RecordingRenderer();
r.submit(cmd('atlas'));
r.submit(cmd('atlas'));
r.submit(cmd('atlas'));
const calls = r.flush();
expect(calls).toHaveLength(1);
expect(calls[0].count).toBe(3);
});
it('纹理交替则批次翻倍', () => {
const r = new RecordingRenderer();
for (const t of ['a', 'b', 'a', 'b']) r.submit(cmd(t));
expect(r.flush()).toHaveLength(4);
});
it('材质不同也会断批', () => {
const r = new RecordingRenderer();
r.submit(cmd('atlas'));
r.submit({ ...cmd('atlas'), material: 'additive' });
expect(r.flush()).toHaveLength(2);
});
it('按纹理重排后 draw call 下降', () => {
const cmds = ['a', 'b', 'a', 'a'].map((t) => cmd(t));
const r = new RecordingRenderer();
for (const c of sortByTexture(cmds)) r.submit(c);
expect(r.flush()).toHaveLength(2); // a a a b → 2 批
});
});
四条断言分别对应三个条件加一次重排收益:同纹理合并、纹理交替断批、材质差异断批、重排降批。有了它们,任何「顺手改一下排序」的提交都会在 CI 里被立刻发现。
3.3.8 Cocos Creator 3.8 对照:Sprite、SpriteAtlas 与动态合图
Cocos Creator 3.8 的 2D 合批由引擎的 Batcher2D 在底层完成,你通常不需要手写批次,但需要理解什么会让它断批。
同纹理 = 同 SpriteFrame 的来源贴图。 把多张图打进一个 SpriteAtlas,或让编辑器在构建时自动合图,就能让大量精灵共享贴图:
import { _decorator, Component, Sprite, SpriteFrame, SpriteAtlas } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('CoinSpawner')
export class CoinSpawner extends Component {
@property(SpriteAtlas)
coinAtlas: SpriteAtlas | null = null;
@property(Sprite)
template: Sprite | null = null;
spawn(): void {
if (!this.template || !this.coinAtlas) return;
const frame: SpriteFrame | null = this.coinAtlas.getSpriteFrame('coin');
if (frame) this.template.spriteFrame = frame;
}
}
动态合图。 对于零散加载的小图,引擎提供 dynamicAtlasManager,在运行时把满足条件的 SpriteFrame 打进一张运行时图集。受 SpriteFrame.packable 控制——设为 false 的帧不参与动态合图:
import { _decorator, Component, SpriteFrame, dynamicAtlasManager } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('AtlasConfig')
export class AtlasConfig extends Component {
@property(SpriteFrame)
bigBackground: SpriteFrame | null = null;
start(): void {
dynamicAtlasManager.enabled = true;
dynamicAtlasManager.maxAtlasCount = 4;
// 大背景不合图,避免把动态图集撑爆
if (this.bigBackground) this.bigBackground.packable = false;
}
}
对照要点:
| 能力 | mini2d | Cocos Creator 3.8 |
|---|---|---|
| 提交单位 | DrawCommand | 渲染组件(Sprite 等) |
| 合批条件 | 同纹理 + 同材质 + 连续 | 引擎 Batcher2D 自动判定 |
| 静态图集 | 第 2 章的图集切分 | SpriteAtlas + spriteFrame |
| 运行时合图 | 自行实现 | dynamicAtlasManager(enabled) |
| 可选合图开关 | 无 | SpriteFrame.packable |
| 材质/着色器 | 自研材质字符串 | Material(effectName / passes) |
三点差异。其一,Cocos 的合批是引擎自动完成的:你只负责让精灵共享贴图、控制节点顺序,Batcher2D 在底层决定批次边界。其二,SpriteFrame.packable 是单张粒度的开关,能精确控制谁进动态图集、谁不进,这在 mini2d 里要自己设计字段。其三,材质在 Cocos 里是 Material 对象,effectName 不同或渲染状态不同都会断批——这和 mini2d 的 material 字符串是同一个判据的两种表达。
至此第 3 章收尾:我们已经能把一棵场景树按正确顺序、正确层次、尽量少的 Draw Call 画出来。下一章转向时间——同样的画面,怎么让它以帧率无关的速度动起来。
阅读导航:上一节:3.2 绘制顺序、层级与遮罩 · 下一节:4.1 帧率无关的移动与 deltaTime 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。