DrawCall优化实战案例

4个真实场景,从发现问题、分析原因、制定方案、代码实现到效果验证的完整过程。每一步都有详细讲解,告诉你"为什么要这样做"和"不这样做会怎样"。


实战前的准备工作

你需要先知道的事情

在开始优化之前,你必须先了解:

什么是DrawCall?

  • 一句话:DrawCall = CPU向GPU发送的一次"画图命令"
  • 打电话比喻:10个DrawCall = 打10次电话叫10辆出租车;1个DrawCall = 打1次电话叫1辆大巴(载10人)
  • 为什么DrawCall多是问题? 因为每次DrawCall,CPU都要做大量准备工作(设置VBO、Shader、纹理、混合模式等),这些都在CPU端执行,是CPU瓶颈!GPU反而可能在等CPU。

合批的4个条件(缺一不可):

条件 说明 比喻
相同纹理 Sprite使用的纹理必须是同一张 同一批布料
相同材质 Material实例必须相同 同一种加工方式
相同Shader 着色器程序必须相同 同一台机器
相同混合/模板状态 blend模式和stencil配置必须相同 同一套工艺参数

Cocos的遍历方式:深度优先(DFS)

Content
├── Item1
│   ├── Icon    ← 第1个渲染
│   ├── Name    ← 第2个渲染
│   └── Frame   ← 第3个渲染
├── Item2
│   ├── Icon    ← 第4个渲染(和第1个纹理相同,但中间被Name和Frame打断了!)
│   ├── Name    ← 第5个渲染
│   └── Frame   ← 第6个渲染

优化目标: 用最少的DrawCall画出相同的画面。


案例1:背包UI从50个DrawCall优化到5个

📋 问题描述

一个RPG游戏的背包界面,有30个格子,每个格子包含4个组件:

  • 背景框(Sprite,使用图集A)
  • 物品图标(Sprite,使用图集B)
  • 数量文字(Label)
  • 品质边框(Sprite,使用图集C)

现象: DrawCall高达50个,滚动背包列表时明显卡顿,帧率只有35fps。

🔍 分析原因

第1步:打开调试信息

import { debug } from 'cc';
debug.setDisplayStats(true);

你会看到左下角显示:

FPS: 35
DrawCall: 50
Triangle: 8400
Frame: 28ms

第2步:分析节点树和渲染顺序

原始节点结构:
ScrollView
└── Content
    ├── Slot1
    │   ├── Bg(Sprite, 图集A)      ← 渲染第1个,DC1
    │   ├── Icon(Sprite, 图集B)     ← 渲染第2个,DC2(纹理切换!A→B)
    │   ├── Count(Label)            ← 渲染第3个,DC3(又切换!B→Label)
    │   └── Border(Sprite, 图集C)   ← 渲染第4个,DC4(又切换!Label→C)
    ├── Slot2
    │   ├── Bg(Sprite, 图集A)       ← 渲染第5个,DC5(和Slot1的Bg纹理相同,但中间被Icon/Count/Border打断了!)
    │   ├── Icon(Sprite, 图集B)     ← 渲染第6个,DC6
    │   ├── Count(Label)            ← 渲染第7个,DC7
    │   └── Border(Sprite, 图集C)   ← 渲染第8个,DC8
    ├── Slot3 ...                  ← 同样4个DC
    └── ... (共30个Slot)

深度优先遍历顺序:
Bg(A) → Icon(B) → Label → Border(C) → Bg(A) → Icon(B) → Label → Border(C) → ...

纹理切换路径:
A → B → Label → C → A → B → Label → C → A → B → Label → C → ...
   ↑切换    ↑切换    ↑切换   ↑切换    ↑切换   ↑切换
   每次切换 = 新DrawCall!

计算:30个格子 × 4个组件 = 120个渲染节点
实际DrawCall ≈ 50个(部分Label可能合批了)

为什么会有50个而不是120个?

  • 因为Cocos有动态合批机制,相邻的同类型Label可能合批了
  • 但Bg(A)和Bg(A)之间被Icon(B)、Label、Border(C)打断了,无法合批
  • 核心问题:纹理不断切换,合批条件不满足

🛠️ 优化步骤

Step 1:图集整合(把3张图集合并为1张)

为什么要这样做?

  • 当前Bg用图集A、Icon用图集B、Border用图集C → 3张不同纹理
  • 每次从图集A切换到图集B,合批就被打断
  • 如果把3张图集合并为1张"背包图集",所有Sprite都使用同一纹理 → 满足合批第1个条件!

具体操作:

  1. 在CocosCreator编辑器中,右键资源管理器 → 创建 → Auto Atlas(自动图集)
  2. 命名为 bag-atlas
  3. 把图集A、B、C中的所有散图拖入这个图集
  4. 构建项目时,Cocos会自动把这3张图集打包为一张大图

效果预期:

原来:Bg(A) → Icon(B) → Label → Border(C)  // 3次纹理切换
现在:Bg(背包) → Icon(背包) → Label → Border(背包)  // 0次纹理切换(纹理相同!)

Step 2:使用虚拟列表(只渲染可见格子)

为什么要这样做?

  • 30个格子同时渲染,但屏幕一次只能看到约8个
  • 不可见的22个格子也在占用CPU/GPU资源,纯属浪费
  • 虚拟列表的核心思想:只渲染看得见的,看不见的回收掉

具体操作:

import { _decorator, Component, Node, Prefab, instantiate, Sprite, Label,
         UITransform, ScrollView, Vec3 } from 'cc';
const { ccclass, property } = _decorator;

/**
 * 背包虚拟列表
 * 原理:30个格子 → 只渲染可见的8个 → 渲染节点从120降到32
 */
@ccclass('BagVirtualList')
export class BagVirtualList extends Component {
    @property(ScrollView)
    scrollView: ScrollView = null;    // 滚动视图组件(需要在编辑器中关联)

    @property(Prefab)
    slotPrefab: Prefab = null;        // 格子的预制体(需要在编辑器中关联)

    @property
    slotHeight: number = 100;         // 每个格子的高度

    @property
    spacing: number = 10;             // 格子之间的间距

    // 私有变量
    private _dataList: any[] = [];            // 格子数据列表
    private _itemPool: Node[] = [];           // 格子节点池(只创建可见数量的格子)
    private _visibleCount: number = 0;        // 可见格子数量
    private _startIndex: number = 0;          // 当前滚动到的起始索引

    start() {
        // 计算可见数量:视口高度 ÷ 格子高度 + 缓冲2个(防止滚动时出现空白)
        const viewHeight = this.scrollView.node.getComponent(UITransform).height;
        this._visibleCount = Math.ceil(viewHeight / (this.slotHeight + this.spacing)) + 2;

        // 创建格子节点池
        this.createItemPool();

        // 监听滚动事件
        this.scrollView.node.on(Node.EventType.SCROLL_ENDED, this.onScroll, this);
    }

    /**
     * 创建格子节点池
     * 为什么只创建_visibleCount个而不是30个?
     * 因为屏幕一次只能看到_visibleCount个,多余的创建了也看不见,浪费性能
     */
    createItemPool() {
        for (let i = 0; i < this._visibleCount; i++) {
            const item = instantiate(this.slotPrefab);   // 从预制体实例化
            this.scrollView.content.addChild(item);      // 添加到Content下
            this._itemPool.push(item);                    // 加入节点池
        }
    }

    /**
     * 滚动结束时调用
     * 作用:根据滚动位置,更新格子节点的位置和数据
     */
    onScroll() {
        const scrollOffset = this.scrollView.getScrollOffset().y;
        // 计算当前应该从第几个格子开始显示
        this._startIndex = Math.floor(scrollOffset / (this.slotHeight + this.spacing));

        // 更新每个可见格子
        for (let i = 0; i < this._visibleCount; i++) {
            const dataIndex = this._startIndex + i;   // 这个格子对应的数据索引

            if (dataIndex < this._dataList.length) {
                // 有数据 → 显示并更新
                this._itemPool[i].active = true;
                this._itemPool[i].setPosition(0, -dataIndex * (this.slotHeight + this.spacing), 0);
                this.updateSlot(this._itemPool[i], this._dataList[dataIndex]);
            } else {
                // 没有数据 → 隐藏
                this._itemPool[i].active = false;
            }
        }
    }

    /**
     * 更新单个格子的显示
     */
    updateSlot(slotNode: Node, data: any) {
        // 更新图标
        const icon = slotNode.getChildByName('Icon')?.getComponent(Sprite);
        if (icon && data.iconFrame) {
            icon.spriteFrame = data.iconFrame;
        }

        // 更新数量文字
        const countLabel = slotNode.getChildByName('Count')?.getComponent(Label);
        if (countLabel) {
            countLabel.string = data.count.toString();
        }

        // 更新品质边框颜色
        const border = slotNode.getChildByName('Border')?.getComponent(Sprite);
        if (border && data.qualityColor) {
            border.color = data.qualityColor;
        }
    }

    /**
     * 设置背包数据
     * @param dataList 背包物品数据列表
     */
    setData(dataList: any[]) {
        this._dataList = dataList;

        // 设置Content的总高度(30个格子的总高度)
        const content = this.scrollView.content;
        const contentTransform = content.getComponent(UITransform);
        contentTransform.setContentSize(
            contentTransform.contentSize.width,
            dataList.length * (this.slotHeight + this.spacing)
        );

        // 触发一次更新
        this.onScroll();
    }
}

效果预期:

原来:30个格子 × 4组件 = 120个渲染节点
现在:8个格子 × 4组件 = 32个渲染节点 → 减少了73%!

Step 3:节点合并(让同类型节点相邻)

为什么要这样做?

  • 即使图集整合了,每个格子内部的渲染顺序还是:Bg → Icon → Label → Border
  • 不同格子的Bg之间仍然被Icon、Label、Border打断
  • 节点合并的思路:把所有格子的Bg放在一起,所有Icon放在一起,以此类推

具体操作:

/**
 * 节点合并优化器
 * 原理:把30个格子的同类型组件拆分到不同层
 * 效果:所有Bg相邻 → 合批!所有Icon相邻 → 合批!
 */
@ccclass('BagNodeMerger')
export class BagNodeMerger extends Component {
    @property(ScrollView)
    scrollView: ScrollView = null;

    private _bgLayer: Node = null;       // 背景层
    private _iconLayer: Node = null;     // 图标层
    private _labelLayer: Node = null;    // 文字层
    private _borderLayer: Node = null;   // 边框层

    start() {
        this.initLayers();
    }

    /**
     * 初始化分层容器
     * 为什么需要4个层?
     * 因为这样可以让所有Bg在同一个层里(相邻),所有Icon在同一个层里(相邻)...
     * 渲染时:Bg层全部渲染 → Icon层全部渲染 → Label层全部渲染 → Border层全部渲染
     * 同层内纹理相同 → 完美合批!
     */
    initLayers() {
        const content = this.scrollView.content;

        // 创建4个层容器
        this._bgLayer = new Node('BgLayer');
        this._iconLayer = new Node('IconLayer');
        this._labelLayer = new Node('LabelLayer');
        this._borderLayer = new Node('BorderLayer');

        // 按渲染顺序添加到Content:背景 → 图标 → 文字 → 边框
        // 为什么是这个顺序?因为渲染顺序决定了遮挡关系
        // 先渲染的在下层,后渲染的在上层
        content.addChild(this._bgLayer);
        content.addChild(this._iconLayer);
        content.addChild(this._labelLayer);
        content.addChild(this._borderLayer);

        // 设置每个层的大小和Content一样
        const contentSize = content.getComponent(UITransform).contentSize;
        [this._bgLayer, this._iconLayer, this._labelLayer, this._borderLayer].forEach(layer => {
            const lt = layer.addComponent(UITransform);
            lt.setContentSize(contentSize);
        });
    }
}

合并后的渲染顺序:

原来(深度优先):
Slot1.Bg(A) → Slot1.Icon(B) → Slot1.Label → Slot1.Border(C) →
Slot2.Bg(A) → Slot2.Icon(B) → Slot2.Label → Slot2.Border(C) → ...
DrawCall: 1   2   3   4   5   6   7   8   ...  ≈ 50个

现在(节点合并):
Bg层:Slot1.Bg → Slot2.Bg → Slot3.Bg → ... → Slot8.Bg  ← 全部合批!1个DC
Icon层:Slot1.Icon → Slot2.Icon → ... → Slot8.Icon      ← 全部合批!1个DC
Label层:Slot1.Label → Slot2.Label → ... → Slot8.Label  ← 全部合批!1个DC
Border层:Slot1.Border → Slot2.Border → ... → Slot8.Border ← 全部合批!1个DC
总计:4个DrawCall!(加上ScrollView的Mask额外2个 = 6个)

Step 4:Label优化(BMFont替代TTF)

为什么要这样做?

  • 默认的TTF Label需要CPU每帧重新计算文字网格,很慢
  • BMFont(位图字体)把文字预渲染为纹理,和Sprite一样渲染 → 可以和Sprite合批!
  • 注意:BMFont需要提前用工具(如BMFont Generator)生成.fnt文件和.png纹理

具体操作:

  1. 在CocosCreator编辑器中,选中Label组件
  2. 在Inspector面板,Font Family → 改为BMFont
  3. 关联.fnt文件和.png纹理文件
  4. 构建项目时,BMFont纹理会加入图集 → 可以和Sprite合批

📊 优化前后对比

指标 优化前 优化后 效果 说明
DrawCall 50 5 90%↓ 5 = 4个组件层 + 1个ScrollView裁剪
渲染节点 120 32 73%↓ 虚拟列表只渲染可见格子
滚动帧率 35fps 58fps 66%↑ CPU每帧从28ms降到6ms
内存 正常 +2MB 可接受 背包图集额外占用2MB
CPU每帧耗时 28ms 6ms 79%↓ 节点少了,合批好了

💡 核心收获

背包优化的本质:
1. 图集整合 → 解决"纹理不同"的问题 → 满足合批条件
2. 虚拟列表 → 解决"渲染太多"的问题 → 减少节点总数
3. 节点合并 → 解决"顺序不对"的问题 → 让合批真正生效
4. BMFont → 解决"Label无法合批"的问题 → 统一渲染格式

案例2:角色列表界面优化(皮肤层级调整实战)

📋 问题描述

角色选择界面有10个角色卡片,每张卡片包含:

  • 角色立绘(Sprite,图集A)
  • 名称(Label)
  • 星级图标(Sprite,图集B)
  • 属性图标(Sprite,图集C)

现象: DrawCall 35个,打开界面时有短暂卡顿。

🔍 分析原因

原始结构(深度优先遍历):
Card1: 立绘(图集A) → 名称(Label) → 星级(图集B) → 属性(图集C)
Card2: 立绘(图集A) → 名称(Label) → 星级(图集B) → 属性(图集C)
...

遍历渲染顺序:
Card1.立绘(A) → Card1.名称 → Card1.星级(B) → Card1.属性(C) →
Card2.立绘(A) → Card2.名称 → Card2.星级(B) → Card2.属性(C) → ...

纹理切换路径:
A → Label → B → C → A → Label → B → C → A → Label → B → C → ...
↑切换      ↑切换 ↑切换 ↑切换     ↑切换     ↑切换     ↑切换

每张卡片4个DrawCall
10张卡片 = 40个DrawCall(部分可能合批,实际35个)

**关键发现**:所有角色的立绘都使用图集A,所有星级都使用图集B
但它们被Label和属性图标打断了,无法合批!

🛠️ 优化步骤

Step 1:图集合并

把图集A、B、C合并为"角色界面图集":

  1. 创建Auto Atlas资源 character-atlas
  2. 把立绘、星级、属性图标全部拖入
  3. 构建后所有Sprite使用同一纹理

Step 2:皮肤层级调整(核心!)

为什么要这样做?

  • 图集合并后,所有Sprite纹理相同了 → 满足合批条件1
  • 但渲染顺序还是:立绘 → Label → 星级 → 属性 → 立绘 → Label → ...
  • 立绘和立绘之间被Label打断了 → 仍然无法合批!
  • 解决方案:把所有立绘放在一起,所有星级放在一起 → 让相同类型的节点相邻

完整代码:

import { _decorator, Component, Node, Sprite, Label, UITransform } from 'cc';
const { ccclass, property } = _decorator;

/**
 * 角色列表优化器
 * 原理:把10张卡片的同类型组件拆分到不同层
 * 效果:所有立绘相邻 → 合批!所有星级相邻 → 合批!
 */
@ccclass('CardListOptimizer')
export class CardListOptimizer extends Component {
    start() {
        this.reorderCards();
    }

    reorderCards() {
        // 获取所有角色卡片节点
        const cards = this.node.children;

        // 第1步:按组件类型分组
        // 为什么要分组?因为我们要把同类型的组件放到一起
        const portraits: Node[] = [];    // 所有立绘
        const stars: Node[] = [];        // 所有星级
        const attrs: Node[] = [];        // 所有属性图标

        for (const card of cards) {
            // 从每张卡片中提取对应组件
            const portrait = card.getChildByName('Portrait');
            const star = card.getChildByName('Star');
            const attr = card.getChildByName('Attr');

            // 收集到对应数组
            if (portrait) portraits.push(portrait);
            if (star) stars.push(star);
            if (attr) attrs.push(attr);
        }

        // 第2步:创建分组容器(层)
        // 为什么需要层?因为要把不同类型的组件放到不同的父节点下
        // 这样渲染时,同一层内的组件是相邻的,可以合批
        const portraitLayer = new Node('PortraitLayer');
        const starLayer = new Node('StarLayer');
        const attrLayer = new Node('AttrLayer');

        // 按渲染顺序添加层:立绘(最底层) → 星级(中间层) → 属性(最上层)
        this.node.addChild(portraitLayer);
        this.node.addChild(starLayer);
        this.node.addChild(attrLayer);

        // 第3步:把收集到的组件移动到对应层
        // 注意:moveNodeToLayer会保持组件的世界坐标不变
        portraits.forEach(p => this.moveNodeToLayer(p, portraitLayer));
        stars.forEach(s => this.moveNodeToLayer(s, starLayer));
        attrs.forEach(a => this.moveNodeToLayer(a, attrLayer));
    }

    /**
     * 把节点从一个父节点移动到另一个父节点
     * 关键:保持世界坐标不变!
     * 如果不保持世界坐标,组件会突然跳到一个错误的位置
     */
    moveNodeToLayer(node: Node, targetParent: Node) {
        // 保存当前的世界坐标
        const worldPos = node.worldPosition.clone();
        const worldScale = node.worldScale.clone();

        // 从原父节点移除
        node.removeFromParent();

        // 添加到新父节点
        targetParent.addChild(node);

        // 恢复世界坐标
        node.setWorldPosition(worldPos);
        node.setWorldScale(worldScale);
    }
}

📊 优化前后对比

指标 优化前 优化后 效果 说明
DrawCall 35 4 89%↓ 4 = 立绘层 + 星级层 + 属性层 + Label层
纹理切换次数 30 3 90%↓ 从30次切换降到只有3次
打开界面卡顿 消失 DrawCall从35降到4,初始化瞬间完成

案例3:聊天界面优化(动态合批+脏标记+虚拟列表)

📋 问题描述

聊天界面有100条消息,每条消息包含:

  • 头像(Sprite)
  • 昵称(Label)
  • 聊天内容(Label)
  • 时间(Label)

现象: 滚动时卡顿,DrawCall 80+,帧率25fps。

🔍 分析原因

  1. 100条消息全部渲染 → 400个渲染节点,大量不可见节点也在更新
  2. 每条消息4个组件,纹理不同 → 头像用头像图集,气泡用气泡图集 → 无法合批
  3. Label每帧都在更新渲染数据 → 即使文字内容没变,也在重复计算网格

🛠️ 优化步骤

Step 1:虚拟列表(只渲染可见消息)

import { _decorator, Component, Node, Prefab, instantiate, Sprite, Label,
         UITransform, ScrollView } from 'cc';
const { ccclass, property } = _decorator;

interface ChatMessage {
    avatar: string;       // 头像路径
    nickname: string;     // 昵称
    content: string;      // 聊天内容
    time: string;         // 时间
}

/**
 * 聊天虚拟列表
 * 原理:100条消息 → 只渲染可见的8条 → 渲染节点从400降到32
 */
@ccclass('ChatVirtualList')
export class ChatVirtualList extends Component {
    @property(ScrollView)
    scrollView: ScrollView = null;

    @property(Prefab)
    messagePrefab: Prefab = null;

    private _messages: ChatMessage[] = [];        // 所有消息数据
    private _items: Node[] = [];                  // 消息节点池
    private _itemHeight: number = 100;            // 每条消息的高度
    private _visibleCount: number = 0;            // 可见消息数量
    private _startIndex: number = 0;              // 起始索引

    start() {
        // 计算可见数量
        const viewHeight = this.scrollView.node.getComponent(UITransform).height;
        this._visibleCount = Math.ceil(viewHeight / this._itemHeight) + 2;

        // 创建节点池
        this.createPool();

        // 监听滚动
        this.scrollView.node.on(Node.EventType.SCROLL_ENDED, this.onScroll, this);
    }

    /**
     * 创建消息节点池
     * 为什么只创建_visibleCount个?
     * 因为屏幕一次只能看到_visibleCount条消息
     * 创建100个太浪费,创建8个就够了
     */
    createPool() {
        for (let i = 0; i < this._visibleCount; i++) {
            const item = instantiate(this.messagePrefab);
            this.scrollView.content.addChild(item);
            this._items.push(item);
        }
    }

    /**
     * 滚动时更新可见消息
     */
    onScroll() {
        const scrollOffset = this.scrollView.getScrollOffset().y;
        this._startIndex = Math.floor(scrollOffset / this._itemHeight);

        for (let i = 0; i < this._visibleCount; i++) {
            const dataIndex = this._startIndex + i;

            if (dataIndex < this._messages.length) {
                this._items[i].active = true;
                this._items[i].setPosition(0, -dataIndex * this._itemHeight, 0);
                this.updateItem(this._items[i], this._messages[dataIndex]);
            } else {
                this._items[i].active = false;
            }
        }
    }

    /**
     * 更新单条消息的显示
     * 注意:只有内容变化时才更新Label,避免每帧重复计算
     */
    updateItem(itemNode: Node, msg: ChatMessage) {
        // 更新头像
        const avatar = itemNode.getChildByName('Avatar')?.getComponent(Sprite);
        if (avatar) {
            // 这里假设你已经加载了头像资源
            // avatar.spriteFrame = loadedAvatarFrame;
        }

        // 更新昵称(只在内容变化时更新 → 脏标记优化)
        const nicknameLabel = itemNode.getChildByName('Nickname')?.getComponent(Label);
        if (nicknameLabel && nicknameLabel.string !== msg.nickname) {
            nicknameLabel.string = msg.nickname;
        }

        // 更新内容(只在内容变化时更新 → 脏标记优化)
        const contentLabel = itemNode.getChildByName('Content')?.getComponent(Label);
        if (contentLabel && contentLabel.string !== msg.content) {
            contentLabel.string = msg.content;
        }

        // 更新时间
        const timeLabel = itemNode.getChildByName('Time')?.getComponent(Label);
        if (timeLabel && timeLabel.string !== msg.time) {
            timeLabel.string = msg.time;
        }
    }

    /**
     * 添加新消息
     */
    addMessage(msg: ChatMessage) {
        this._messages.push(msg);

        // 更新Content高度
        const content = this.scrollView.content;
        const transform = content.getComponent(UITransform);
        transform.setContentSize(transform.contentSize.width, this._messages.length * this._itemHeight);

        // 滚动到最新消息
        this.scrollView.scrollToBottom(0.3);

        // 更新显示
        this.onScroll();
    }
}

Step 2:图集合并 + 动态合批

把头像、气泡背景等打包到同一图集:

  1. 创建Auto Atlas chat-atlas
  2. 把头像框、气泡背景、箭头等全部拖入
  3. 所有Sprite使用同一纹理 → 自动合批

Step 3:Label脏标记优化

/**
 * 聊天Item组件
 * 核心:只有文字内容真正变化时才更新Label
 */
@ccclass('ChatItem')
export class ChatItem extends Component {
    private _lastContent: string = '';       // 上次的内容
    private _lastNickname: string = '';      // 上次的昵称
    private _contentLabel: Label = null;
    private _nicknameLabel: Label = null;

    onLoad() {
        this._contentLabel = this.node.getChildByName('Content')?.getComponent(Label);
        this._nicknameLabel = this.node.getChildByName('Nickname')?.getComponent(Label);
    }

    /**
     * 更新数据
     * 为什么要对比上次内容?
     * 因为Label每次设置string都会重新计算文字网格(很耗CPU)
     * 如果内容没变,就不要重新计算
     */
    updateData(msg: ChatMessage) {
        if (msg.content !== this._lastContent) {
            this._contentLabel.string = msg.content;
            this._lastContent = msg.content;   // 更新记录
        }
        if (msg.nickname !== this._lastNickname) {
            this._nicknameLabel.string = msg.nickname;
            this._lastNickname = msg.nickname;
        }
    }
}

📊 优化前后对比

指标 优化前 优化后 效果 说明
DrawCall 80+ 8-10 88%↓ 虚拟列表+图集合并
渲染节点 400 32 92%↓ 只渲染8条可见消息
滚动帧率 25fps 55fps 120%↑ CPU从25ms降到5ms
CPU每帧耗时 15ms 4ms 73%↓ 脏标记+虚拟列表

案例4:战斗界面优化(统一Shader + 节点合并)

📋 问题描述

战斗界面有6个角色(3友方+3敌方),每个角色包含:

  • 身体(Sprite)
  • 武器(Sprite)
  • 特效(Sprite + 自定义材质)
  • 血条(Sprite + 自定义材质)

现象: DrawCall 40+,战斗时帧率掉到30fps。

🔍 分析原因

  1. 每个角色4个组件,不同角色使用不同纹理 → 纹理切换频繁
  2. 血条使用自定义材质(渐变色) → 打断合批(材质不同)
  3. 特效使用自定义Shader → 又打断合批(Shader不同)
  4. 角色死亡时灰度化 → 又一个材质 → 又打断
  5. 6个角色 × 4组件 × 2种材质 = 48个DrawCall(部分合批后约40个)

🛠️ 优化步骤

Step 1:角色图集整合

把所有角色的身体、武器纹理打包到一个大图集 battle-atlas

  1. 创建Auto Atlas battle-atlas
  2. 把6个角色的所有身体、武器纹理拖入
  3. 所有Sprite使用同一纹理 → 满足合批条件

Step 2:统一Shader(核心!)

为什么要统一Shader?

  • 当前:正常角色用默认Shader,灰度角色用灰度Shader,血条用血条Shader → 3种Shader
  • 每次切换Shader → 打断合批 → 新DrawCall
  • 解决方案:创建一个"万能战斗Shader",把所有效果都包含进去,通过Uniform参数控制

完整的万能战斗Shader代码:

// battle-sprite.effect
// 这个Shader包含了:正常显示、灰度、受击闪白、溶解 4种效果
// 通过Uniform参数控制使用哪种效果,不需要切换Shader!

CCEffect %{
  techniques:
  - name: opaque
    passes:
    - vert: battle-vs:vert
      frag: battle-fs:frag
      depthStencilState: &depthStencilState
        depthTest: false             // 2D游戏关闭深度测试
        depthWrite: false            // 2D游戏关闭深度写入
      blendState: &blendState
        targets:
        - blend: true                // 开启混合(半透明需要)
          blendSrc: src_alpha        // 源因子 = 源颜色的alpha
          blendDst: one_minus_src_alpha  // 目标因子 = 1 - 源alpha
      rasterizerState:
        cullMode: none               // 不做背面剔除
      properties:                    // 暴露给TypeScript的参数
        u_grayAmount:                // 灰度程度 0=正常 1=完全灰度
          value: 0.0
          editor: { range: [0, 1], tooltip: "灰度程度" }
        u_hitAmount:                 // 受击闪白程度 0=正常 1=完全白色
          value: 0.0
          editor: { range: [0, 1], tooltip: "受击闪白" }
        u_dissolveAmount:            // 溶解程度 0=完整 1=完全溶解
          value: 0.0
          editor: { range: [0, 1], tooltip: "溶解程度" }
        u_edgeWidth:                 // 溶解边缘宽度
          value: 0.05
          editor: { range: [0, 0.2], tooltip: "溶解边缘宽度" }
        u_edgeColor:                 // 溶解边缘颜色
          value: { r: 1.0, g: 0.3, b: 0.0, a: 1.0 }
          editor: { tooltip: "溶解边缘颜色" }
}%

// ==========================================
// 顶点着色器:定位
// ==========================================
CCProgram battle-vs %{
  precision highp float;

  // 输入属性(从VBO读取,每个顶点不同)
  attribute vec3 a_position;    // 顶点位置
  attribute vec2 a_uv;          // UV坐标
  attribute vec4 a_color;       // 顶点颜色

  // Uniform变量(从CPU传入,所有顶点共享)
  uniform Constant u_mvp;       // MVP矩阵
  uniform mat4 u_mvp;

  // 输出给片元着色器(GPU会自动插值)
  varying vec2 v_uv;
  varying vec4 v_color;

  void main () {
    v_uv = a_uv;                                    // 传递UV
    v_color = a_color;                              // 传递颜色
    gl_Position = u_mvp * vec4(a_position, 1.0);   // MVP变换
  }
}%

// ==========================================
// 片元着色器:上色(万能效果)
// ==========================================
CCProgram battle-fs %{
  precision highp float;

  // 从顶点着色器接收(已插值)
  varying vec2 v_uv;
  varying vec4 v_color;

  // Uniform变量
  uniform sampler2D u_texture;     // 纹理
  uniform float u_grayAmount;      // 灰度程度
  uniform float u_hitAmount;       // 受击闪白
  uniform float u_dissolveAmount;  // 溶解程度
  uniform float u_edgeWidth;       // 溶解边缘宽度
  uniform vec4 u_edgeColor;        // 溶解边缘颜色

  // 简单的噪声函数(不需要额外纹理)
  // 这个函数会根据UV坐标生成一个伪随机值(0~1)
  // 为什么用这个函数?因为溶解效果需要随机值来决定哪些像素被丢弃
  float random(vec2 st) {
    return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453);
  }

  void main () {
    // 第1步:基础颜色(纹理采样 × 顶点颜色)
    vec4 color = v_color * texture2D(u_texture, v_uv);

    // 第2步:灰度效果
    // 原理:RGB转灰度 = R×0.299 + G×0.587 + B×0.114
    // 为什么是这个系数?因为人眼对绿色最敏感,对蓝色最不敏感
    if (u_grayAmount > 0.0) {
      float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
      // mix(a, b, t) = a×(1-t) + b×t,在原始颜色和灰度之间插值
      color.rgb = mix(color.rgb, vec3(gray), u_grayAmount);
    }

    // 第3步:受击闪白
    // 原理:用受击程度在原始颜色和白色之间插值
    color.rgb = mix(color.rgb, vec3(1.0), u_hitAmount);

    // 第4步:溶解效果
    if (u_dissolveAmount > 0.0) {
      // 计算当前像素的噪声值
      float noise = random(v_uv);

      // 如果噪声值小于溶解程度 → 丢弃这个像素(透明)
      if (noise < u_dissolveAmount) {
        discard;  // 丢弃片元,不渲染这个像素
      }

      // 边缘发光:在溶解边缘添加发光效果
      float edgeStart = u_dissolveAmount;
      float edgeEnd = u_dissolveAmount + u_edgeWidth;
      if (noise < edgeEnd) {
        // 计算边缘因子(越靠近边缘越亮)
        float edgeFactor = 1.0 - (noise - edgeStart) / u_edgeWidth;
        // 混合边缘颜色
        color.rgb = mix(color.rgb, u_edgeColor.rgb, edgeFactor);
      }
    }

    // 输出最终颜色
    gl_FragColor = color;
  }
}%

在TypeScript中使用这个Shader:

import { _decorator, Component, Sprite, Material, tween, Color } from 'cc';
const { ccclass, property } = _decorator;

/**
 * 战斗角色效果控制器
 * 通过修改Uniform参数来控制角色的显示效果
 * 不需要切换材质/Shader → 不会打断合批!
 */
@ccclass('BattleCharacter')
export class BattleCharacter extends Component {
    @property(Sprite)
    bodySprite: Sprite = null;

    private _material: Material = null;

    start() {
        // 获取材质实例(注意:实例化后每个角色的材质引用不同,会打断合批)
        // 解决方案:所有角色共享同一个Material实例,只通过顶点颜色传递差异
        this._material = this.bodySprite.getMaterial(0);
    }

    /**
     * 角色死亡 → 渐变灰度
     */
    playDeath(duration: number = 1.0) {
        tween(this._material)
            .to(duration, { u_grayAmount: 1.0 })
            .start();
    }

    /**
     * 受击 → 闪白后恢复
     */
    playHit(duration: number = 0.3) {
        tween(this._material)
            .to(duration * 0.3, { u_hitAmount: 1.0 })    // 0.3秒闪白
            .to(duration * 0.7, { u_hitAmount: 0.0 })    // 0.7秒恢复
            .start();
    }

    /**
     * 消失 → 溶解效果
     */
    playDissolve(duration: number = 1.0) {
        tween(this._material)
            .to(duration, { u_dissolveAmount: 1.0 })
            .start();
    }
}

⚠️ 重要提示:材质实例化会打断合批!

上面的代码有个问题:sprite.getMaterial(0) 返回的是材质实例,每个角色的材质实例不同 → 不满足合批条件 → 打断合批!

材质实例化 vs 共享材质:完整对比

场景:3个角色Sprite,使用同一个Effect

方案A:材质实例化(每个角色getMaterial获取独立实例)
┌──────────────────────────────────────────────────────┐
│ 角色1: Material实例A (u_gray=1.0) → DrawCall 1      │
│ 角色2: Material实例B (u_gray=0.0) → DrawCall 2      │
│ 角色3: Material实例C (u_gray=1.0) → DrawCall 3      │
│                                                       │
│ 总DrawCall = 3(每个材质实例都不同,无法合批)         │
└──────────────────────────────────────────────────────┘

方案B:共享材质 + 顶点颜色传参
┌──────────────────────────────────────────────────────┐
│ 角色1: 共享Material + color.alpha=150 → 合批 ─┐     │
│ 角色2: 共享Material + color.alpha=255 → 合批 ──┤ DC1 │
│ 角色3: 共享Material + color.alpha=150 → 合批 ─┘     │
│                                                       │
│ 总DrawCall = 1(所有Sprite共享同一材质,完美合批)    │
└──────────────────────────────────────────────────────┘

关键区别

  • 材质实例化:每个Sprite的材质引用不同 → Batcher2D判断materialHash不同 → 打断合批
  • 顶点颜色传参:所有Sprite的材质引用相同 → materialHash相同 → 可以合批
  • 顶点颜色是VBO数据的一部分,不影响合批判断(就像两个三角形颜色不同也能合批一样)

解决方案:使用顶点颜色传递参数

/**
 * 不实例化材质的方案
 * 原理:所有角色共享同一个Material
 * 通过顶点颜色传递效果参数
 */
@ccclass('BattleCharacterNoInstance')
export class BattleCharacterNoInstance extends Component {
    @property(Sprite)
    bodySprite: Sprite = null;

    /**
     * 通过顶点颜色alpha通道传递灰度值
     * 所有Sprite共享同一个Material → 可以合批!
     */
    setGray(isGray: boolean) {
        const color = this.bodySprite.color;
        // alpha < 200 → Shader中判断为灰度
        this.bodySprite.color = new Color(
            color.r,
            color.g,
            color.b,
            isGray ? 150 : 255
        );
    }
}

Step 3:节点合并

把所有角色的身体放在一起,所有武器放在一起,所有血条放在一起:

@ccclass('BattleNodeMerger')
export class BattleNodeMerger extends Component {
    start() {
        this.mergeBattleNodes();
    }

    mergeBattleNodes() {
        // 获取所有角色
        const characters = this.node.children;

        // 按类型分组
        const bodies: Node[] = [];
        const weapons: Node[] = [];
        const hpBars: Node[] = [];

        for (const char of characters) {
            const body = char.getChildByName('Body');
            const weapon = char.getChildByName('Weapon');
            const hpBar = char.getChildByName('HpBar');

            if (body) bodies.push(body);
            if (weapon) weapons.push(weapon);
            if (hpBar) hpBars.push(hpBar);
        }

        // 创建分层容器
        this.createLayer('BodyLayer', bodies);
        this.createLayer('WeaponLayer', weapons);
        this.createLayer('HpBarLayer', hpBars);
    }

    createLayer(name: string, nodes: Node[]) {
        const layer = new Node(name);
        layer.addComponent(UITransform);
        this.node.addChild(layer);

        nodes.forEach(n => {
            // 保存世界坐标
            const worldPos = n.worldPosition.clone();
            n.removeFromParent();
            layer.addChild(n);
            // 恢复世界坐标
            n.setWorldPosition(worldPos);
        });
    }
}

Step 4:血条Shader统一

把血条的自定义材质替换为统一Shader,通过Uniform传参而不是切换材质:

@ccclass('HpBarShader')
export class HpBarShader extends Component {
    @property(Sprite)
    hpBar: Sprite = null;

    /**
     * 设置血量
     * @param ratio 血量比例 0.0~1.0
     */
    setHp(ratio: number) {
        const mat = this.hpBar.getMaterial(0);
        mat.setProperty('u_hpRatio', ratio);

        // 根据血量设置颜色
        if (ratio > 0.5) {
            // 血量充足 → 绿色
            mat.setProperty('u_hpColor', new Color(0, 255, 0, 255));
        } else if (ratio > 0.2) {
            // 血量不足 → 黄色
            mat.setProperty('u_hpColor', new Color(255, 255, 0, 255));
        } else {
            // 危险 → 红色
            mat.setProperty('u_hpColor', new Color(255, 0, 0, 255));
        }
    }
}

📊 优化前后对比

指标 优化前 优化后 效果 说明
DrawCall 40+ 6-8 80%↓ 图集+节点合并+统一Shader
材质切换 15次 2次 87%↓ 统一Shader减少材质切换
战斗帧率 30fps 55fps 83%↑ DrawCall从40降到6-8

💡 核心收获

战斗界面优化的本质:
1. 图集整合 → 解决"纹理不同" → 满足合批条件
2. 统一Shader → 解决"效果不同需要切换材质" → 用Uniform参数代替材质切换
3. 节点合并 → 解决"顺序不对" → 让合批真正生效
4. 顶点颜色传参 → 解决"材质实例化打断合批" → 共享材质

关键认知:
- Shader切换 = DrawCall切换(因为Shader程序不同)
- 材质实例化 = DrawCall切换(因为材质引用不同)
- 把"材质差异"变成"Uniform差异" → 可以合批
- 但Uniform差异也不能太大(每个Sprite实例化材质 → 还是打断)
- 终极方案:顶点颜色传参 → 所有Sprite完全共享材质 → 完美合批

🔍 如何验证优化效果

每个案例都可以用以下步骤验证,确保优化真的生效了。

验证步骤

  1. 开启DisplayStats:在游戏启动脚本中调用 debug.setDisplayStats(true)
  2. 记录优化前数据:运行游戏,记录左下角的 DrawCall 数、FPS、Frame时间
  3. 应用优化:按照案例中的步骤修改代码
  4. 记录优化后数据:再次运行游戏,记录 DrawCall 数、FPS、Frame时间
  5. 对比结果:确认 DrawCall 数量确实下降了

常见问题

  • 优化后DrawCall没变化?→ 检查是否还有其他打断合批的因素(Mask、散图等)
  • 优化后帧率没提升?→ 可能瓶颈不在DrawCall,而在CPU逻辑或GPU片元着色器
  • 优化后画面异常?→ 检查Shader代码是否正确,Effect文件是否正确关联

性能分析工具使用指南

工具1:DisplayStats(最基础)

import { debug } from 'cc';
debug.setDisplayStats(true);

// 左下角会显示:
// FPS: 60        → 当前帧率(目标:>55)
// DrawCall: 15   → 当前DrawCall数(目标:2D游戏<50)
// Triangle: 1200 → 当前三角形数(一个Sprite=2个三角形)
// Frame: 12ms    → 帧总耗时(目标:<16ms)

如何解读:

指标 正常范围 警告 危险
FPS 55-60 40-55 <40
DrawCall <30 30-50 >50
Frame <10ms 10-16ms >16ms
Triangle <5000 5000-20000 >20000

工具2:Chrome DevTools Performance面板

使用步骤:

  1. F12 打开开发者工具
  2. 切换到 Performance 面板
  3. 点击录制按钮(圆点)
  4. 操作游戏3-5秒
  5. 停止录制

关注指标:

Main线程时间线:
├── 脚本执行(黄色块)→ 游戏逻辑耗时
├── 渲染(紫色块)→ DOM/CSS计算
├── 绘制(绿色块)→ Canvas/WebGL绘制  ← 重点关注!
└── 空闲(灰色块)→ 等待下一帧

关键判断:
- 绿色块 > 10ms → 渲染是瓶颈 → 需要优化DrawCall
- 黄色块 > 10ms → 脚本是瓶颈 → 需要优化游戏逻辑
- 整帧 > 16ms → 帧率会低于60fps

工具3:SpectorJS WebGL抓帧工具

安装和使用:

  1. Chrome扩展商店搜索 "Spector.js" 并安装
  2. 点击Spector图标 → 点击红色录制按钮
  3. 操作游戏一帧
  4. 查看抓帧结果

能看到什么:

每个DrawCall的详细信息:
├── 使用的Shader程序 → 看是否有不必要的Shader切换
├── 使用的纹理 → 看是否有不必要的纹理切换
├── VBO数据(顶点位置、UV、颜色)
├── 混合模式 → 看是否一致
├── 深度/模板测试状态 → 看是否有Mask打断
└── 绘制的三角形数 → 看每个DrawCall画了多少

优化线索:
- 相邻DrawCall使用相同纹理 → 应该能合批但没有 → 检查渲染顺序
- 大量小DrawCall → 需要图集合批
- Shader频繁切换 → 需要统一Shader

工具4:自定义性能统计

import { _decorator, Component, Node, Sprite, Label, Mask, director } from 'cc';
const { ccclass, property } = _decorator;

/**
 * 性能诊断组件
 * 自动检测常见性能问题并输出报告
 */
@ccclass('PerformanceMonitor')
export class PerformanceMonitor extends Component {
    private _frameCount: number = 0;
    private _lastTime: number = 0;
    private _fps: number = 0;

    update(dt: number) {
        this._frameCount++;
        const now = performance.now();

        if (now - this._lastTime >= 1000) {
            this._fps = this._frameCount;
            this._frameCount = 0;
            this._lastTime = now;

            // FPS低于50时输出诊断报告
            if (this._fps < 50) {
                this.printDiagnostic();
            }
        }
    }

    /**
     * 打印诊断报告
     */
    printDiagnostic() {
        console.warn('=== 渲染诊断报告 ===');
        console.warn(`FPS: ${this._fps} (低于50!)`);
        console.warn(`节点总数: ${this.countNodes()}`);
        console.warn(`活跃Sprite: ${this.countActiveSprites()}`);
        console.warn(`Mask数量: ${this.countMasks()} (每个Mask+2 DrawCall!)`);
        console.warn(`Label数量: ${this.countLabels()} (TTF Label很耗CPU!)`);
        console.warn(`自定义材质: ${this.countCustomMaterials()} (打断合批!)`);
    }

    countNodes(): number {
        let count = 0;
        const walk = (node: Node) => {
            count++;
            node.children.forEach(walk);
        };
        director.getScene().children.forEach(walk);
        return count;
    }

    countActiveSprites(): number {
        let count = 0;
        const walk = (node: Node) => {
            if (node.activeInHierarchy) {
                const sprite = node.getComponent(Sprite);
                if (sprite && sprite.enabledInHierarchy) count++;
            }
            node.children.forEach(walk);
        };
        director.getScene().children.forEach(walk);
        return count;
    }

    countMasks(): number {
        let count = 0;
        const walk = (node: Node) => {
            if (node.getComponent(Mask)) count++;
            node.children.forEach(walk);
        };
        director.getScene().children.forEach(walk);
        return count;
    }

    countLabels(): number {
        let count = 0;
        const walk = (node: Node) => {
            if (node.getComponent(Label)) count++;
            node.children.forEach(walk);
        };
        director.getScene().children.forEach(walk);
        return count;
    }

    countCustomMaterials(): number {
        let count = 0;
        const sprites = this.node.getComponentsInChildren(Sprite);
        for (const sprite of sprites) {
            if (sprite.customMaterial) count++;
        }
        return count;
    }
}

性能优化检查清单

  • DisplayStats开启,DrawCall < 50
  • 帧率 > 55fps
  • 没有内存泄漏(Chrome Memory面板对比快照)
  • 使用图集减少纹理切换
  • 虚拟列表替代全量渲染
  • 静态节点标记_static
  • 减少Mask使用(每个Mask+2 DrawCall)
  • Label使用BMFont替代TTF
  • 不每帧设置不变的属性
  • Shader中没有冗余计算
  • 材质实例化只在必要时使用