场景树与合批机制详解
理解场景树遍历和合批规则,是优化DrawCall的根基
初级程序员读完这篇后应该能:
- 给出一段节点树结构,准确算出有多少个DrawCall
- 理解为什么调整节点顺序能减少DC
- 知道什么操作会打断合批,如何避免
一、场景树是什么?
1.1 家族族谱比喻
想象一个大家族的家谱:
Scene(族长 - 整个场景的根)
├── Background(大儿子 - 背景层)
│ ├── bg_sky(孙子 - 天空背景)
│ └── bg_ground(孙子 - 地面背景)
├── GameLayer(二儿子 - 游戏层)
│ ├── Player(孙子 - 玩家)
│ │ ├── body(曾孙 - 玩家身体)
│ │ └── weapon(曾孙 - 玩家武器)
│ └── Enemy(孙子 - 敌人)
│ ├── body(曾孙 - 敌人身体)
│ └── healthBar(曾孙 - 血条)
└── UILayer(三儿子 - UI层)
├── Button1(孙子 - 按钮1)
└── Button2(孙子 - 按钮2)
关键理解:
- Scene = 族长:所有节点的祖先,没有父节点
- 父节点 = 家长:管理子节点的位置(子节点的位置是相对于父节点的)
- 兄弟节点 = 兄弟姐妹:同一层级的节点,siblingIndex决定渲染顺序
1.2 为什么要有场景树?
问题:假设你要开发一个游戏,有100个对象(角色、怪物、UI、特效...)。如果没有场景树:
- 每个对象都要单独管理位置、旋转、缩放
- 对象之间的父子关系(比如"武器跟着玩家移动")需要手动计算
- 渲染顺序(谁先画、谁后画)无法统一管理
有了场景树:
- 子节点自动跟随父节点移动(世界坐标 = 父节点世界坐标 × 子节点本地坐标)
- 渲染顺序自然确定(按遍历顺序,先遍历的先画)
- 批量操作方便(隐藏父节点 → 所有子节点都隐藏)
没有场景树会怎样? 你需要手动计算每个对象的世界坐标、手动管理渲染顺序、手动处理父子关系——代码会极其混乱且容易出错。
1.3 Node的核心属性详解
class Node {
_parent: Node | null; // 父节点(没有父节点 = 根节点/Scene的直接子节点)
_children: Node[]; // 子节点列表(可能有0个或多个)
_active: boolean; // 自身是否激活(相当于"开关")
_activeInHierarchy: boolean; // 在场景树中是否激活(自身+所有祖先都激活才算激活)
_siblingIndex: number; // 在父节点children中的索引(决定渲染顺序!)
_transformFlags: number; // 脏标记(告诉引擎"我变了,需要重新计算")
_static: boolean; // 是否静态(静态节点不会被遍历,用于优化)
layer: number; // 层级(影响可见性,比如UI层和游戏层分离)
worldPosition: Vec3; // 世界坐标(相对于Scene原点的绝对位置)
worldMatrix: Mat4; // 世界矩阵(包含位置+旋转+缩放的4x4矩阵)
}
每个属性详细解释:
_active vs _activeInHierarchy 的区别
这是新手最容易混淆的地方!
// 场景结构:
// Root(_active = true)
// └── Parent(_active = false)
// └── Child(_active = true)
Child._active = true; // 自己说"我开了!"
Child._activeInHierarchy = false; // 但是爸爸是关的,所以实际上Child还是不可见!
为什么需要两个?
_active:这个节点自己的开关_activeInHierarchy:整个家族链路上的开关(只要有一个祖先关了,这个节点就不生效)
没有_activeInHierarchy会怎样? 你需要手动检查每个祖先的active状态。有了这个属性,引擎内部已经帮你算好了,直接用就行。
_siblingIndex(渲染顺序的关键!)
// 同一个父节点的子节点:
Parent
├── A (siblingIndex = 0) ← 第0个,先渲染(在下层)
├── B (siblingIndex = 1) ← 第1个,后渲染(在上层,会覆盖A)
└── C (siblingIndex = 2) ← 第2个,最后渲染(在最上层,会覆盖A和B)
渲染顺序 = siblingIndex从小到大的顺序
重要认知:
- siblingIndex = 0 的节点最先渲染(在最下层)
- siblingIndex 越大,越后渲染(在上层,会遮挡下层)
- 改变siblingIndex = 改变渲染顺序 = 可能影响合批!
_transformFlags(脏标记)
问题:假设你有1000个节点,每帧都重新计算世界矩阵,性能极差!
解决方案:用脏标记记录"哪些节点变了",只重新计算变了的节点。
第1帧:
Player.position = (10, 20) → 设置脏标记 TransformBit.POSITION
引擎检测到脏标记 → 重新计算Player的世界矩阵
没有脏标记的999个节点 → 跳过计算(省下99.9%的计算量!)
没有脏标记会怎样? 每帧都要重新计算1000个节点的世界矩阵,即使999个都没动过。CPU时间从0.5ms变成5ms+。
二、Node和Component的关系
2.1 "人和衣服"比喻
这是理解Cocos架构最核心的比喻!
Node = 人
- 有位置(站在哪里)
- 有大小(多高多宽)
- 有父子关系(谁的孩子)
- 但人本身什么都不会做!
Component = 衣服/工具
- Sprite = 穿上了"显示图片"的衣服 → 能显示图片
- Label = 拿起了"写字的笔" → 能显示文字
- Button = 装了"按钮传感器" → 能响应点击
- Mask = 戴了"遮罩面具" → 能裁剪子节点
- Script = "学了技能" → 能执行你的自定义逻辑
关键认知:
- 一个Node可以挂多个Component(一个人可以同时穿外套+戴帽子+拿笔)
- Component不能独立存在(衣服不能没有人穿)
- UIRenderer是特殊的Component(专门负责生成渲染数据的那类衣服)
2.2 Component的核心属性
class Component {
node: Node; // 所属节点(这件衣服穿在谁身上)
enabled: boolean; // 是否启用(这件衣服有没有穿上)
enabledInHierarchy: boolean; // 在场景树中是否启用(自己和节点都启用才算)
// 生命周期(什么时候执行)
onLoad()? : void; // 节点首次激活时执行一次(相当于"出生")
start()? : void; // 第一次update前执行一次(相当于"准备好")
update(dt: number)? : void; // 每帧执行(相当于"呼吸",一直在做)
lateUpdate(dt: number)? : void; // update之后执行(相当于"整理")
onDestroy()? : void; // 销毁时执行(相当于"死亡")
}
生命周期执行顺序(以Scene中有A、B、C三个节点为例):
场景加载:
1. A.onLoad() → B.onLoad() → C.onLoad() // 所有节点的onLoad按顺序执行
2. A.start() → B.start() → C.start() // 所有节点的start按顺序执行
每帧循环:
3. A.update(dt) → B.update(dt) → C.update(dt) // 所有节点的update按顺序执行
4. A.lateUpdate(dt) → B.lateUpdate(dt) → C.lateUpdate(dt)
场景销毁:
5. C.onDestroy() → B.onDestroy() → A.onDestroy() // 注意:onDestroy是倒序!
为什么onDestroy是倒序? 因为子节点需要先销毁,父节点才能安全销毁(父节点可能要访问子节点做清理工作)。
三、渲染数据收集
3.1 引擎如何遍历场景树找到要渲染的东西?
这是最核心的部分!理解了这个,你就理解了DrawCal是怎么产生的。
Batcher2D的 walk() 方法是核心(位于 batcher-2d.ts):
// batcher-2d.ts walk() 方法(简化版,方便理解)
walk(scene: Scene) {
// 第1步:清空上一帧的数据
// 为什么?因为每一帧都可能不一样(节点可能移动、显示/隐藏等)
this._batches.length = 0;
this._currHash = 0;
// 第2步:定义遍历函数(深度优先搜索 DFS)
const walkLevel = (node: Node) => {
// ⚠️ 跳过条件1:节点未激活
// 包括两种情况:
// - node._active = false(自己关了)
// - node._activeInHierarchy = false(某个祖先关了)
if (!node._activeInHierarchy) return;
// 第3步:处理当前节点的UIRenderer
// UIRenderer包括:Sprite、Label、Mask、Graphics等
const render = node.getComponent(UIRenderer);
if (render && render.enabledInHierarchy) {
// 调用fillBuffers,让渲染器把顶点数据填充到Batcher
// 例如Sprite会填充4个顶点+6个索引
render.fillBuffers(this);
}
// 第4步:递归遍历子节点
// 为什么检查 _children.length > 0?避免空循环
// 为什么检查 !_static?静态节点不需要每帧遍历(优化!)
if (node._children.length > 0 && !node._static) {
for (const child of node._children) {
walkLevel(child); // 递归调用
}
}
};
// 第5步:从Scene的直接子节点开始遍历
scene.children.forEach(walkLevel);
}
遍历过程详解(以一个具体例子说明):
假设场景树结构如下:
Scene
├── A (Sprite, 纹理X)
│ ├── B (Sprite, 纹理Y)
│ └── C (Sprite, 纹理X)
├── D (Sprite, 纹理Y)
└── E (Sprite, 纹理X)
walk()的执行顺序:
1. walkLevel(A) → A有Sprite → fillBuffers(A) → 渲染A(纹理X)
2. walkLevel(B) → B有Sprite → fillBuffers(B) → 渲染B(纹理Y)
3. walkLevel(C) → C有Sprite → fillBuffers(C) → 渲染C(纹理X)
4. walkLevel(D) → D有Sprite → fillBuffers(D) → 渲染D(纹理Y)
5. walkLevel(E) → E有Sprite → fillBuffers(E) → 渲染E(纹理X)
渲染顺序 = 遍历顺序:
A(纹理X) → B(纹理Y) → C(纹理X) → D(纹理Y) → E(纹理X)
3.2 为什么渲染顺序影响DrawCall数量?
这是理解优化的关键!
案例1:纹理交替 = DrawCall爆炸
渲染顺序:A(X) → B(Y) → C(X) → D(Y) → E(X)
合批判断过程:
A(纹理X) → 新开批次,DrawCall #1
B(纹理Y) → 纹理不同!打断合批 → 新开批次,DrawCall #2
C(纹理X) → 纹理不同!打断合批 → 新开批次,DrawCall #3
D(纹理Y) → 纹理不同!打断合批 → 新开批次,DrawCall #4
E(纹理X) → 纹理不同!打断合批 → 新开批次,DrawCall #5
结果:5个DrawCall!
案例2:纹理连续 = DrawCall大幅减少
现在调整节点顺序,把相同纹理的放在一起:
Scene
├── A (Sprite, 纹理X)
├── C (Sprite, 纹理X)
├── E (Sprite, 纹理X)
├── B (Sprite, 纹理Y)
└── D (Sprite, 纹理Y)
渲染顺序:A(X) → C(X) → E(X) → B(Y) → D(Y)
合批判断过程:
A(纹理X) → 新开批次,DrawCall #1
C(纹理X) → 纹理相同!✅ 合批成功,合并到DrawCall #1
E(纹理X) → 纹理相同!✅ 合批成功,合并到DrawCall #1
B(纹理Y) → 纹理不同!打断合批 → 新开批次,DrawCall #2
D(纹理Y) → 纹理相同!✅ 合批成功,合并到DrawCall #2
结果:2个DrawCall!从5降到2,减少60%!
这就是"皮肤层级调整"优化的原理!
- 做法:把相同纹理的Sprite在场景树中放在一起
- 效果:减少合批打断,降低DrawCall数量
- 副作用:可能改变视觉层次(上层Sprite会遮挡下层),需要重新调整zIndex
四、Batcher2D合批机制详解
4.1 什么是"合批"?
一句话解释:把多个渲染请求合并成一个DrawCall,减少CPU→GPU的通信次数。
比喻:快递打包
场景:你要给10个朋友寄礼物,3个去北京,7个去上海。
方案1(不合批):
10个礼物分别寄 → 10次快递 = 10个DrawCall
方案2(合批):
北京的3个装一个箱子,上海的7个装一个箱子 → 2次快递 = 2个DrawCall
效果:从10次减少到2次,减少80%!
4.2 合批的4个前提条件
合批 = 把多个渲染数据合并到一个DrawCall中。必须同时满足以下4个条件:
| 条件 | 含义 | 比喻 | 不满足的后果 |
|---|---|---|---|
| ① 相同纹理 | spriteFrame的texture相同 | 快递去同一个城市 | 换了目的地 → 必须分开发 |
| ② 相同材质 | Material引用相同 | 用同一种快递方式 | 换了快递方式 → 必须分开发 |
| ③ 相同着色器 | Effect/Shader程序相同 | 用同一种打包规则 | 打包规则不同 → 不能合包 |
| ④ 相同混合模式 | BlendMode + DepthStencilState相同 | 用同一种运输条件 | 运输条件不同 → 不能合包 |
4个条件全部满足 → 合批成功,1个DrawCall搞定! 任何一个不满足 → 打断合批,新增1个DrawCall!
4.3 "快递打包"比喻详解
快递员要送10个包裹:
情况A(完美合批):
5个去北京(纹理A,普通快递)
5个去上海(纹理B,普通快递)
打包:
北京的5个 → 装一车 → 1个DrawCall
上海的5个 → 装一车 → 1个DrawCall
结果:2个DrawCall ✅
情况B(加急件打断合批):
北京的3个 → 加急件 → 北京的2个 → 上海的5个
打包:
北京的3个 → 1个DrawCall
加急件 → 不同材质!→ 1个DrawCall
北京的2个 → 纹理又变回北京,但中间被打断了!→ 1个DrawCall
上海的5个 → 1个DrawCall
结果:4个DrawCall ❌(加急件打断了北京的合批!)
情况C(Mask组件打断合批):
纹理A的3个 → Mask → 纹理A的2个
打包:
纹理A的3个 → 1个DrawCall
Mask → 改变了模板状态!→ 2个DrawCall(写模板+清模板)
纹理A的2个 → 模板状态变了!→ 1个DrawCall
结果:4个DrawCall ❌(Mask是合批杀手!)
4.4 合批判断核心代码
// batcher-2d.ts 中的 commitComp() 核心逻辑
// 这个函数每遇到一个UIRenderer就会被调用一次
commitComp(render: UIRenderer, renderData, mat, depthStencilStateStage) {
const dataHash = renderData.dataHash; // 纹理的哈希值(唯一标识)
// 判断是否能合批:4个条件必须全部满足
if (this._currHash === dataHash && // 条件①:纹理相同
dataHash !== 0 && // 有有效数据(不是空渲染)
this._currMaterial === mat && // 条件②:材质相同
this._currDepthStencilStateStage === depthStencilStateStage) { // 条件④:混合+模板状态相同
// ✅ 4个条件都满足!合批成功!
// 把当前渲染器的顶点数据追加到当前VBO中
this._appendVBDatas(renderData);
// 注意:条件③(相同着色器)其实隐含在条件②(相同材质)中
// 因为一个材质只能绑定一个Shader程序
} else {
// ❌ 至少一个条件不满足!合批被打断!
// 第1步:提交当前批次(产生1个DrawCall)
this.autoMergeBatches();
// 第2步:开始新批次,更新当前状态
this._currHash = dataHash;
this._currMaterial = mat;
this._currDepthStencilStateStage = depthStencilStateStage;
// 第3步:把当前渲染器加入新批次
this._appendVBDatas(renderData);
}
}
代码解读:
this._currHash:记录当前批次的纹理哈希值this._currMaterial:记录当前批次的材质引用this._currDepthStencilStateStage:记录当前批次的深度/模板状态- 如果新来的渲染器与当前批次状态一致 → 追加数据(合批成功)
- 如果不一致 → 先提交当前批次(flush),再开新批次
4.5 什么操作会打断合批?
| 操作 | 打断哪个条件 | 原因 | 比喻 | 解决方案 |
|---|---|---|---|---|
| 切换纹理 | 条件① | 新纹理的dataHash与当前批次不同 | 换了快递目的地 | 使用图集把多张图合成一张 |
| 自定义材质 | 条件② | 创建了新的Material实例 | 换了快递方式 | 统一使用内置材质 |
| Mask组件 | 条件④ | Mask修改了模板状态 | 需要特殊安检 | 减少Mask使用,用ScrollView替代 |
| Graphics组件 | 条件①② | Graphics使用不同的纹理和材质 | 包裹形状特殊,需要特殊包装 | 避免Graphics与Sprite混排 |
| 不同Layer | 条件②④ | 不同Layer可能走不同渲染流程 | 走不同物流渠道 | 同层渲染,减少跨层 |
| 中间插入不同纹理的Sprite | 条件① | 纹理交替 | 插队换了目的地 | 调整节点顺序让相同纹理连续 |
| Label组件 | 条件①② | Label使用内置字体纹理和builtin-label shader | 信件格式不同 | 使用BMFont替代TTF Label |
Mask是合批杀手!重点说明:
每个Mask至少增加2个额外DrawCall:
- 写模板DrawCall:渲染Mask的形状到模板缓冲
- 清模板DrawCall:清除模板状态(恢复默认)
没有Mask的情况:
Sprite A → Sprite B → Sprite C = 1个DrawCall(假设纹理相同)
有Mask的情况:
Sprite A → Mask(写模板) → Sprite B(Mask内) → Mask(清模板) → Sprite C
= 1个DrawCall(A) + 1个(写模板) + 1个(B) + 1个(清模板) + 1个(C) = 5个DrawCall!
如何减少Mask的影响:
- 能用ScrollView的地方,不用Mask(ScrollView使用Scissor Test,几乎零开销)
- 必须用Mask时,把Mask内的节点放在一起,减少Mask前后的合批打断
- 避免嵌套Mask(Mask1内套Mask2 = 4个额外DrawCall!)
五、2D渲染完整数据流
现在我们把前面的知识整合起来,看一个完整的渲染流程:
第0步:你设置Sprite的spriteFrame
sprite.spriteFrame = textureA;
第1步:Sprite.updateRenderData()(在update阶段)
- 检查节点是否脏了(TransformBit标志)
- 如果脏了 → 重新计算4个顶点的世界坐标
- 生成渲染数据:
4个顶点 = [位置, UV, 颜色]
6个索引 = [0,1,2, 0,2,3](2个三角形)
第2步:Batcher2D.walk() 遍历场景树(在渲染阶段)
- 从Scene开始,DFS遍历所有活跃节点
- 对每个有UIRenderer的节点:
render.fillBuffers(this); // 填充顶点数据到Batcher
第3步:UIRenderer.fillBuffers(batcher)
- 调用 batcher.commitComp(this, renderData, material, depthStencilState)
第4步:Batcher2D.commitComp()
- 计算 dataHash = renderData.dataHash
- 比较4个合批条件
- 如果满足 → 追加顶点数据到当前VBO
- 如果不满足 → flush()提交当前批次,开新批次
第5步:Batcher2D.flush()
- 把收集到的所有DrawBatch提交给RenderPipeline
- 每个DrawBatch = 1个DrawCall
第6步:RenderPipeline.render()
- 遍历DrawBatch列表
- 对每个DrawBatch:
设置VBO/IBO/Shader/Texture/BlendMode
调用 gl.drawElements() → GPU开始工作
第7步:GPU执行渲染管线
- 顶点着色器:MVP变换(位置计算)
- 光栅化:三角形→片元
- 片元着色器:纹理采样(颜色计算)
- 逐片元操作:模板测试 → 深度测试 → Alpha混合
- 写入帧缓冲
第8步:屏幕显示
- V-Sync同步 → SwapBuffers → 显示到屏幕
关键时间点:
update()阶段:更新节点Transform、Sprite顶点数据(CPU)lateUpdate()阶段:Batcher2D.walk()收集渲染数据(CPU)RenderPipeline.render():提交DrawCall给GPU(CPU→GPU通信)- GPU执行:顶点着色器→片元着色器→帧缓冲(GPU)
六、实战:查看和优化DrawCall
6.1 开启调试信息
// 在任意脚本的start()中
import { debug } from 'cc';
start() {
debug.setDisplayStats(true);
// 左下角会显示:FPS / DrawCall数 / 三角形数
// FPS = 每秒帧数(目标60)
// DrawCall = CPU发送给GPU的渲染命令数(目标<100)
// 三角形数 = GPU处理的三角形总数
//
// 💡 也可以用 cc.debug.setDisplayStats(true),效果相同
// import { debug } from 'cc' 是推荐写法(TypeScript类型安全)
}
6.2 DrawCall优化5步法
步骤1:看DrawCall数
打开游戏 → 左下角显示 DrawCall: 85
目标:降到30以下
步骤2:找打断点
使用SpectorJS(Chrome扩展)抓帧:
- 打开Chrome → 按F12 → Spector标签
- 点击"Capture"按钮
- 查看每个DrawCall用了什么纹理、什么材质
典型问题:
- 看到5个DrawCall都用了不同的纹理 → 需要打包图集
- 看到中间插入了Mask → 需要调整节点顺序
步骤3:调整层级
优化前:
Scene
├── bg (背景,纹理A)
├── icon1 (图标1,纹理B)
├── enemy1 (敌人1,纹理C)
├── icon2 (图标2,纹理B) ← 与icon1相同纹理但不连续!
├── enemy2 (敌人2,纹理C) ← 与enemy1相同纹理但不连续!
└── icon3 (图标3,纹理B)
DrawCall = 6(bg + icon1 + enemy1 + icon2 + enemy2 + icon3)
优化后(相同纹理的放一起):
Scene
├── bg (纹理A)
├── icon1 (纹理B)
├── icon2 (纹理B) ← 与icon1连续!
├── icon3 (纹理B) ← 与icon2连续!
├── enemy1 (纹理C)
└── enemy2 (纹理C) ← 与enemy1连续!
DrawCall = 3(bg + icon组 + enemy组)减少50%!
步骤4:使用图集
优化前(散图):
icon1.png → 纹理A
icon2.png → 纹理B
icon3.png → 纹理C
DrawCall = 3(3个不同纹理)
优化后(打包图集):
atlas.png → 纹理ATLAS
icon1 → atlas.png的区域1
icon2 → atlas.png的区域2
icon3 → atlas.png的区域3
DrawCall = 1(1个纹理,3个区域都在同一张图上!)
Cocos中打包图集的方法:
- 在编辑器中右键文件夹 → "创建" → "Auto Atlas"
- 把需要打包的图片放入该文件夹
- 构建时会自动打包成图集
步骤5:减少Mask
优化前(使用Mask):
ScrollView (Scissor裁剪) → 0个额外DrawCall
Mask (任意形状) → +2个额外DrawCall
优化后:
能用ScrollView的地方,不用Mask
必须用Mask时,把Mask内的节点放在一起
避免嵌套Mask
6.3 性能参考指标
| 场景类型 | 节点数 | DrawCall | 评价 | 优化建议 |
|---|---|---|---|---|
| 简单UI(登录界面) | <50 | <10 | 优秀 ✅ | 保持现状 |
| 中等UI(主界面) | 50-150 | 10-30 | 良好 ✅ | 检查散图,考虑图集 |
| 复杂UI(背包界面) | 150-300 | 30-50 | 一般 ⚠️ | 必须使用图集+BFS虚拟列表 |
| 过于复杂(商城界面) | 300-500 | 50-100 | 需要优化 ❌ | 虚拟列表 + 减少Mask + 层级调整 |
| 危险(复杂游戏界面) | >500 | >100 | 必须优化 ❌❌ | 全面重构UI结构 |
手机端目标:DrawCall < 100(理想<50) PC端目标:DrawCall < 300(理想<150)
记住:DrawCall数是2D游戏性能的第一指标!但不是唯一指标,还要考虑Overdraw和GPU负载。
七、理解场景树和合批的关键认知
场景树遍历顺序 = 渲染顺序 = 合批的关键
- 先遍历的节点先渲染(在下层)
- 后遍历的节点后渲染(在上层,遮挡下层)
- 相同纹理的节点必须连续出现才能合批
siblingIndex决定渲染顺序
- siblingIndex = 0 最先渲染(最下层)
- 改变siblingIndex = 改变渲染顺序 = 可能改善或恶化合批
合批的4个条件缺一不可
- 任何一个条件不满足 → 打断合批 → 新增1个DrawCall
- 优化DrawCall = 让尽可能多的节点满足4个条件
Mask是合批杀手
- 每个Mask至少+2个DrawCall
- 嵌套Mask更严重:Mask1内套Mask2 = +4个DrawCall
- 能用ScrollView替代的,尽量用ScrollView
图集是减少DrawCall最有效的手段
- 把多张散图合成一张 → 纹理相同 → 可以合批
- 20张散图 → 打包成1个图集 → DrawCall从20降到1(如果节点连续)
节点顺序比节点数量更重要
- 100个相同纹理的连续节点 = 1个DrawCall
- 10个交替纹理的节点 = 10个DrawCall
- 调整顺序比减少节点更有效!