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个条件!
具体操作:
- 在CocosCreator编辑器中,右键资源管理器 → 创建 → Auto Atlas(自动图集)
- 命名为
bag-atlas - 把图集A、B、C中的所有散图拖入这个图集
- 构建项目时,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纹理
具体操作:
- 在CocosCreator编辑器中,选中Label组件
- 在Inspector面板,Font Family → 改为BMFont
- 关联.fnt文件和.png纹理文件
- 构建项目时,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合并为"角色界面图集":
- 创建Auto Atlas资源
character-atlas - 把立绘、星级、属性图标全部拖入
- 构建后所有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。
🔍 分析原因
- 100条消息全部渲染 → 400个渲染节点,大量不可见节点也在更新
- 每条消息4个组件,纹理不同 → 头像用头像图集,气泡用气泡图集 → 无法合批
- 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:图集合并 + 动态合批
把头像、气泡背景等打包到同一图集:
- 创建Auto Atlas
chat-atlas - 把头像框、气泡背景、箭头等全部拖入
- 所有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。
🔍 分析原因
- 每个角色4个组件,不同角色使用不同纹理 → 纹理切换频繁
- 血条使用自定义材质(渐变色) → 打断合批(材质不同)
- 特效使用自定义Shader → 又打断合批(Shader不同)
- 角色死亡时灰度化 → 又一个材质 → 又打断
- 6个角色 × 4组件 × 2种材质 = 48个DrawCall(部分合批后约40个)
🛠️ 优化步骤
Step 1:角色图集整合
把所有角色的身体、武器纹理打包到一个大图集 battle-atlas:
- 创建Auto Atlas
battle-atlas - 把6个角色的所有身体、武器纹理拖入
- 所有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完全共享材质 → 完美合批
🔍 如何验证优化效果
每个案例都可以用以下步骤验证,确保优化真的生效了。
验证步骤:
- 开启DisplayStats:在游戏启动脚本中调用
debug.setDisplayStats(true) - 记录优化前数据:运行游戏,记录左下角的 DrawCall 数、FPS、Frame时间
- 应用优化:按照案例中的步骤修改代码
- 记录优化后数据:再次运行游戏,记录 DrawCall 数、FPS、Frame时间
- 对比结果:确认 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面板
使用步骤:
- F12 打开开发者工具
- 切换到 Performance 面板
- 点击录制按钮(圆点)
- 操作游戏3-5秒
- 停止录制
关注指标:
Main线程时间线:
├── 脚本执行(黄色块)→ 游戏逻辑耗时
├── 渲染(紫色块)→ DOM/CSS计算
├── 绘制(绿色块)→ Canvas/WebGL绘制 ← 重点关注!
└── 空闲(灰色块)→ 等待下一帧
关键判断:
- 绿色块 > 10ms → 渲染是瓶颈 → 需要优化DrawCall
- 黄色块 > 10ms → 脚本是瓶颈 → 需要优化游戏逻辑
- 整帧 > 16ms → 帧率会低于60fps
工具3:SpectorJS WebGL抓帧工具
安装和使用:
- Chrome扩展商店搜索 "Spector.js" 并安装
- 点击Spector图标 → 点击红色录制按钮
- 操作游戏一帧
- 查看抓帧结果
能看到什么:
每个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中没有冗余计算
- 材质实例化只在必要时使用