《Cocos 游戏开发入门》3.3 批处理与 Draw Call

画 100 个精灵不等于 100 次 Draw Call。本节讲透批处理的三个条件——同纹理、同材质、命令连续,用记录式渲染器把 Draw Call 数下来,量化纹理切换的代价,说明图集为何是合批的前提,并给出 mini2d 实现、vitest 测试与 Cocos Creator 3.8 的 Sprite、SpriteAtlas 与动态合图对照。

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;
  }
}

对照要点:

能力mini2dCocos 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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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