场景树与合批机制详解

理解场景树遍历和合批规则,是优化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 = "学了技能" → 能执行你的自定义逻辑

关键认知

  1. 一个Node可以挂多个Component(一个人可以同时穿外套+戴帽子+拿笔)
  2. Component不能独立存在(衣服不能没有人穿)
  3. 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

  1. 写模板DrawCall:渲染Mask的形状到模板缓冲
  2. 清模板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扩展)抓帧:

  1. 打开Chrome → 按F12 → Spector标签
  2. 点击"Capture"按钮
  3. 查看每个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中打包图集的方法

  1. 在编辑器中右键文件夹 → "创建" → "Auto Atlas"
  2. 把需要打包的图片放入该文件夹
  3. 构建时会自动打包成图集

步骤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负载。


七、理解场景树和合批的关键认知

  1. 场景树遍历顺序 = 渲染顺序 = 合批的关键

    • 先遍历的节点先渲染(在下层)
    • 后遍历的节点后渲染(在上层,遮挡下层)
    • 相同纹理的节点必须连续出现才能合批
  2. siblingIndex决定渲染顺序

    • siblingIndex = 0 最先渲染(最下层)
    • 改变siblingIndex = 改变渲染顺序 = 可能改善或恶化合批
  3. 合批的4个条件缺一不可

    • 任何一个条件不满足 → 打断合批 → 新增1个DrawCall
    • 优化DrawCall = 让尽可能多的节点满足4个条件
  4. Mask是合批杀手

    • 每个Mask至少+2个DrawCall
    • 嵌套Mask更严重:Mask1内套Mask2 = +4个DrawCall
    • 能用ScrollView替代的,尽量用ScrollView
  5. 图集是减少DrawCall最有效的手段

    • 把多张散图合成一张 → 纹理相同 → 可以合批
    • 20张散图 → 打包成1个图集 → DrawCall从20降到1(如果节点连续)
  6. 节点顺序比节点数量更重要

    • 100个相同纹理的连续节点 = 1个DrawCall
    • 10个交替纹理的节点 = 10个DrawCall
    • 调整顺序比减少节点更有效!