Cocos渲染架构详解

引擎从代码到画面的完整流程,Batcher2D源码级解析,场景树遍历顺序如何影响DrawCall


零、写给初学者:为什么要学渲染架构?

如果不懂这个会怎样?

想象一下你去餐厅吃饭:

  • 懂渲染架构 = 你知道后厨怎么运作,知道为什么你的菜上得慢,知道怎么点菜能最快上齐
  • 不懂渲染架构 = 你只会催服务员"快点上菜",但不知道问题出在哪,优化无从下手

在游戏开发中:

场景 懂渲染架构 不懂渲染架构
游戏卡顿 能定位是DrawCall太多还是Shader太复杂 只能盲目降低画质
UI优化 知道调整层级就能减少DrawCall 反复改图片大小,毫无效果
使用Mask后掉帧 知道Mask打断合批,改用矩形裁剪 以为是Mask本身耗性能,束手无策
多相机问题 理解每个相机独立渲染 困惑为什么合批失效

学习本章节后,你将能:

  1. 看懂引擎源码,不再害怕底层
  2. 精准定位性能瓶颈
  3. 写出"渲染友好"的代码和UI结构

前置知识铺垫

在学习本章前,建议先了解以下概念(如果不懂也没关系,文中会解释):

  • 什么是GPU:图形处理器,专门负责画画的"电子画师"
  • 什么是顶点:3D/2D图形的基本构成点,一个矩形有4个顶点
  • 什么是纹理:贴在模型表面的图片,就像给白墙贴壁纸
  • 什么是Shader:告诉GPU"怎么画"的程序(比如"把纹理红色部分变透明")

一、渲染架构全景图

"游戏渲染公司"比喻

想象Cocos引擎是一家"游戏画面制作公司":

CEO (Game) ──→ 统筹全局,决定什么时候开工
    │
    ├── 项目经理 (Director) ──→ 安排每天的工作计划
    │       │
    │       ├── 美术总监 (Root) ──→ 管理所有美术资源
    │       │       │
    │       │       ├── 渲染车间 (RenderPipeline) ──→ 安排渲染顺序
    │       │       │       │
    │       │       │       └── 合批工程师 (Batcher2D) ──→ 把相同材质合并
    │       │       │
    │       │       └── 仓库管理 (AssetManager) ──→ 管理纹理/材质
    │       │
    │       └── 脚本部门 (Component System) ──→ 处理游戏逻辑
    │
    └── 翻译部 (GFX) ──→ 把公司指令翻译成各国语言(WebGL/Metal/Vulkan)

生活类比:这就像一家外卖餐厅:

  • CEO (Game) = 店长,决定营业时间(游戏启动/关闭)
  • 项目经理 (Director) = 值班经理,安排每单的制作顺序(每帧调度)
  • 美术总监 (Root) = 后厨主管,管理所有锅灶(相机、灯光)
  • 合批工程师 (Batcher2D) = 配餐员,把相同口味的订单合并一起炒(合批)
  • 翻译部 (GFX) = 翻译,把中文菜单翻译成英文/日文(跨平台API转换)

从代码到画面的10步流程

1. 你写下代码: this.node.addComponent(Sprite)
       ↓
2. 引擎加载纹理,创建Sprite组件
       ↓
3. Game主循环: requestAnimationFrame驱动每帧更新
       ↓
4. Director: 调度场景更新、组件update、事件处理
       ↓
5. Sprite组件: updateRenderData() 生成4个顶点+6个索引
       ↓
6. Batcher2D.walk(): 深度优先遍历场景树,收集渲染数据
       ↓
7. Batcher2D.commitComp(): 判断合批条件,合并到VBO
       ↓
8. RenderPipeline: 按相机优先级排序,执行RenderPass
       ↓
9. GFX层: 将渲染指令转换为WebGL API调用
       ↓
10. GPU: 执行渲染管线 → 帧缓冲 → 显示到屏幕

类比理解:这就像你点外卖的完整流程:

  1. 你在APP下单(写代码)
  2. 餐厅收到订单,准备食材(加载纹理)
  3. 店长宣布开始营业(Game启动主循环)
  4. 值班经理安排这单的制作(Director调度)
  5. 厨师准备原料:切菜、腌肉(生成顶点数据)
  6. 配餐员收集所有待做订单(walk遍历)
  7. 配餐员判断:哪些订单可以一起炒?(commitComp合批判断)
  8. 按优先级安排出餐顺序(RenderPipeline排序)
  9. 翻译把菜单翻译成厨师的母语(GFX转换API)
  10. 厨师炒菜,装盘,送出(GPU渲染到屏幕)

二、四大核心模块详解

1. Game —— "CEO"

为什么需要Game?

没有Game的话,谁来决定"什么时候开始画下一帧"?就像没有店长,厨师不知道什么时候该开工。Game就是整个游戏的"心跳起搏器",它让游戏以稳定的频率(通常是每秒60次)更新和渲染。

职责:游戏的生命周期管理,主循环驱动。

// Game的核心循环(简化版)
class Game {
    // 存储requestAnimationFrame返回的回调函数引用
    private _frameCallback: FrameRequestCallback;

    run() {
        // 启动主循环
        // requestAnimationFrame是浏览器提供的API,在屏幕下次刷新时调用回调
        this._frameCallback = (timestamp) => {
            // 1. 计算deltaTime(距上一帧经过的时间,单位:毫秒)
            // 例如:60FPS时,dt约等于16.67ms
            const dt = this.calculateDeltaTime(timestamp);

            // 2. 更新Director(场景、组件、物理)
            // 把"这一帧要做什么"交给项目经理安排
            director.mainLoop(dt);

            // 3. 请求下一帧
            // 递归调用:这一帧做完,预约下一帧继续
            requestAnimationFrame(this._frameCallback);
        };

        // 启动第一帧
        requestAnimationFrame(this._frameCallback);
    }
}

逐行注释解读:

  • private _frameCallback: FrameRequestCallback; —— 声明一个变量,用来存储每一帧的回调函数。FrameRequestCallback是浏览器类型,表示requestAnimationFrame的回调签名。
  • run() —— 游戏启动时调用,开启无限循环
  • this._frameCallback = (timestamp) => { ... } —— 定义回调函数,timestamp是浏览器传入的高精度时间戳(单位:毫秒)
  • const dt = this.calculateDeltaTime(timestamp); —— 计算两帧之间的时间差。为什么要算这个?因为不同设备的刷新率不同(有的60Hz,有的120Hz),用dt可以让游戏速度保持一致
  • director.mainLoop(dt); —— 调用Director的主循环,传入时间差。Director会安排这一帧的所有工作
  • requestAnimationFrame(this._frameCallback); —— 向浏览器预约下一帧。浏览器会在屏幕下次刷新前调用这个回调,形成无限循环

关键认知:

  • Game是单例,整个应用只有一个Game实例
  • 主循环频率由显示器刷新率决定(通常60Hz)
  • 如果一帧处理时间超过16.67ms,帧率下降

初学者常见错误:

错误 后果 正确做法
在update里写死循环 游戏卡死,浏览器崩溃 把耗时操作分散到多帧,或用异步
忽略dt,直接用固定值 高帧率设备上游戏速度变快 所有动画都用position += speed * dt
每帧创建大量对象 GC(垃圾回收)导致卡顿 使用对象池复用对象

2. Director —— "项目经理"

为什么需要Director?

Game只管"心跳",但每一帧具体做什么?谁来更新玩家位置?谁来处理碰撞?谁来渲染画面?Director就是安排这些工作的"项目经理"。

类比:就像店长(Game)敲钟说"开工了",但具体谁炒菜、谁切菜、谁送外卖,由值班经理(Director)安排。

职责:场景管理、游戏暂停/恢复、主循环调度。

// Director的主循环(简化版)
class Director {
    mainLoop(dt: number) {
        // 1. 更新场景树(组件的update)
        // 遍历所有节点的所有组件,调用它们的update方法
        // 例如:玩家移动、敌人AI、动画播放等逻辑都在这里执行
        this._scene.update(dt);

        // 2. 渲染场景
        if (this._root) {
            // 2.1 更新相机、灯光等渲染相关状态
            // 例如:相机跟随玩家移动,需要更新视图矩阵
            this._root.frameMove(dt);  // 更新相机、灯光
            
            // 2.2 执行实际渲染
            // 这是本章重点:Batcher2D在这里被调用
            this._root.render();        // 执行渲染
        }

        // 3. 清理销毁的节点
        // 上一帧标记为销毁的节点,在这一帧末尾真正删除
        // 为什么要延迟销毁?避免遍历过程中修改遍历对象
        this._deferredDestroy();
    }
}

逐行注释解读:

  • mainLoop(dt: number) —— Director的主入口,每帧被Game调用一次
  • this._scene.update(dt); —— 更新当前场景。_scene是当前活跃的场景对象。这里会递归遍历场景树,调用每个组件的update
  • if (this._root) —— 检查Root是否初始化。_root是渲染系统的根节点,管理所有相机
  • this._root.frameMove(dt); —— 更新渲染系统的状态。比如相机跟随玩家,需要在这里更新相机的位置
  • this._root.render(); —— 调用渲染!这是从"逻辑更新"到"画面渲染"的分界点
  • this._deferredDestroy(); —— 延迟销毁。为什么延迟?假设你在update里销毁了一个敌人,但遍历还没结束,立即删除会导致遍历出错

关键认知:

  • Director管理当前场景(_scene)
  • 每帧先执行逻辑更新(update),再执行渲染(render)
  • 场景切换时,旧场景销毁,新场景加载

自问自答:

Q: 为什么先update再render?不能反过来吗? A: 如果先render再update,你看到的画面是上一帧的逻辑状态。比如玩家按了右键,这一帧应该先更新玩家位置(update),再画到新位置(render)。如果反过来,玩家按了右键但画面没变化,会感觉很"延迟"。

Q: _deferredDestroy是什么意思?为什么不能立即销毁? A: 想象你在排队买票,排到一半工作人员说"这个窗口关闭了",但你还没买完。延迟销毁就是"等这轮服务完再关窗口"。如果立即销毁,正在遍历的列表会被修改,导致遍历出错(比如跳过某些节点)。


3. Root —— "美术总监"

为什么需要Root?

Director只管"要不要渲染",但具体"用什么相机渲染""用什么灯光""渲染到哪里",需要专门的管理者。Root就是管理这些"美术资源"的总监。

类比:导演喊"Action"后,美术总监要确认:摄像机摆好了吗?灯光调好了吗?背景布到位了吗?然后才正式开拍。

职责:管理渲染窗口、相机、灯光、渲染管线。

// Root的渲染流程(简化版)
class Root {
    render() {
        // 1. 更新所有相机
        // 每个相机需要计算:视图矩阵(相机在哪看向哪)、投影矩阵(透视/正交)
        for (const camera of this._cameras) {
            camera.update();
        }

        // 2. 执行渲染管线
        // 把相机和场景交给RenderPipeline,由它安排具体怎么画
        this._pipeline.render(this._cameras);

        // 3. 交换缓冲区(双缓冲)
        // 双缓冲:GPU在"后缓冲"画画,画完后和"前缓冲"交换,避免画面撕裂
        this._device.present();
    }
}

逐行注释解读:

  • for (const camera of this._cameras) —— 遍历所有相机。Cocos支持多相机,比如一个相机画游戏世界,一个相机画UI
  • camera.update(); —— 更新相机。计算视图矩阵(View Matrix)和投影矩阵(Projection Matrix)。简单说:确定"从哪看"和"怎么看"
  • this._pipeline.render(this._cameras); —— 调用渲染管线。RenderPipeline是下一节要讲的"渲染车间"
  • this._device.present(); —— 呈现画面。使用双缓冲技术:GPU在后台缓冲区画画,画完后把前后缓冲区交换,这样用户看到的是完整的一帧,而不是画到一半的画面

关键认知:

  • Root持有所有Camera
  • 多相机时,按priority排序渲染
  • 最后调用device.present()将后缓冲显示到屏幕

生活类比:双缓冲

想象你在黑板报比赛:

  • 单缓冲 = 你直接在展示的黑板上画,观众看到你一笔一笔画的过程(画面闪烁、不完整)
  • 双缓冲 = 你在背后的草稿纸上画,画完后把草稿纸贴到展示板上(观众只看到完整画面)

device.present()就是"把草稿纸贴上去"的动作。


4. RenderPipeline —— "渲染车间"

为什么需要RenderPipeline?

Root说"开始渲染",但具体怎么渲染?先画什么后画什么?不透明物体和透明物体怎么处理?这些细节需要"渲染车间"来安排。

类比:美术总监说"开拍",但车间主任要安排:先拍远景,再拍近景;先拍不透明的墙,再拍透明的玻璃窗。

职责:组织渲染流程,管理RenderPass,执行实际渲染。

// RenderPipeline的渲染流程(简化版)
class RenderPipeline {
    render(cameras: Camera[]) {
        // 1. 按priority排序相机
        // priority越小越先渲染。比如priority=0的UI相机先画,priority=1的主相机后画
        cameras.sort((a, b) => a.priority - b.priority);

        // 2. 对每个相机执行渲染
        for (const camera of cameras) {
            // 2.1 设置相机视口、投影矩阵
            // 告诉GPU:这一帧用这个相机的视角来画
            camera.update();

            // 2.2 收集可见物体
            // 视锥体裁剪:只画在相机视野内的物体,视野外的跳过
            const visibleRenderers = this.cull(camera);

            // 2.3 按RenderQueue排序
            // RenderQueue决定绘制顺序:不透明物体先画(从远到近),透明物体后画(从近到远)
            visibleRenderers.sort((a, b) => a.renderQueue - b.renderQueue);

            // 2.4 执行RenderPass列表
            // 一个RenderPass是一组渲染操作。比如"先画阴影,再画主画面"
            for (const pass of this._renderPasses) {
                pass.render(camera, visibleRenderers);
            }
        }

        // 3. 提交CommandBuffer到GPU
        // 把所有渲染指令打包,一次性发给GPU
        this._device.flush();
    }
}

逐行注释解读:

  • cameras.sort((a, b) => a.priority - b.priority); —— 按优先级排序相机。priority数值小的先渲染。为什么需要排序?比如UI相机(priority=1)要在游戏相机(priority=0)之后渲染,这样UI会盖在游戏画面上
  • camera.update(); —— 再次更新相机(确保所有状态最新)
  • const visibleRenderers = this.cull(camera); —— 视锥体裁剪(Frustum Culling)。只保留在相机视野内的渲染器。视野外的物体直接跳过,不进入后续流程。这是重要的性能优化!
  • visibleRenderers.sort((a, b) => a.renderQueue - b.renderQueue); —— 按渲染队列排序。不透明物体(queue < 2500)从远到近画,透明物体(queue >= 2500)从近到远画。这样可以正确混合颜色
  • for (const pass of this._renderPasses) —— 遍历所有RenderPass。一个RenderPass可以输出到不同目标(屏幕、纹理、阴影贴图等)
  • this._device.flush(); —— 刷新设备,把所有积累的渲染命令提交给GPU

关键认知:

  • 相机priority越小越先渲染
  • 同一相机内,按RenderQueue排序(不透明先,透明后)
  • 每个RenderPass可以输出到不同目标(屏幕、纹理等)

自问自答:

Q: 为什么透明物体要从近到远画? A: 想象你透过玻璃窗看外面的树。如果先画树(远的),再画玻璃(近的),玻璃的颜色会和树的颜色正确混合。如果反过来先画玻璃,树会完全盖住玻璃,你就看不到玻璃了。

Q: 什么是视锥体裁剪? A: 想象你拿着相机拍照,相机能拍到的范围是一个"金字塔"(叫视锥体)。场景里有一万个物体,但只有一千个在相机视野内。视锥体裁剪就是快速判断"这个物体在视野内吗?",不在就直接跳过,省得浪费GPU时间去画看不见的东西。


三、Batcher2D源码级解析

Batcher2D的核心职责

为什么需要Batcher2D?

如果没有Batcher2D,每个Sprite都会单独发一个DrawCall给GPU。100个Sprite = 100个DrawCall,游戏会卡成PPT。

Batcher2D就像一个"拼单系统":把使用相同"外卖地址"(纹理+材质+状态)的订单合并成一个大单,一次性配送。

Batcher2D是Cocos 2D渲染的心脏,负责:

  1. 遍历场景树,收集所有2D渲染组件
  2. 判断哪些组件可以合批
  3. 将可合批的组件顶点数据合并到大VBO
  4. 提交DrawCall给GPU

源码解析:walk()方法

为什么需要walk()?

场景里的节点是树形结构(有父子关系),但渲染需要按顺序处理每个节点。walk()就是"走遍整棵树,按顺序收集渲染数据"。

什么是深度优先遍历(DFS)?

想象你在家找东西:

  • 深度优先(DFS) = 先翻遍第一个抽屉的所有角落,再翻第二个抽屉
  • 广度优先(BFS) = 先看看每个抽屉的第一层,再看第二层

Batcher2D用DFS,因为UI的层级关系天然适合DFS(先画父节点背景,再画子节点内容)。

// Batcher2D.walk() 简化版源码解析
class Batcher2D {
    // 当前批次的状态 —— 这些变量记录"当前正在合并的这一批"用的是什么资源
    private _currHash: number = 0;                    // 当前纹理哈希(用来快速判断纹理是否相同)
    private _currMaterial: Material | null = null;    // 当前材质(决定Shader和渲染状态)
    private _currDepthStencilStateStage: number = 0;  // 当前深度/模板状态(Mask会影响这个)
    private _batches: DrawBatch[] = [];               // 批次列表(每个批次对应一个DrawCall)
    private _vData: Float32Array;                     // 顶点数据数组(VBO的内存缓冲区)
    private _iData: Uint16Array;                      // 索引数据数组(IBO的内存缓冲区)

    /**
     * 遍历场景树,收集渲染数据
     * 遍历顺序:深度优先(DFS)
     * 
     * @param node 当前遍历到的节点
     * @param level 当前层级(用于调试,打印缩进)
     */
    walk(node: Node, level: number = 0) {
        // 1. 检查节点是否可见
        // activeInHierarchy表示:这个节点和它所有父节点都是激活状态
        // 如果父节点隐藏了,子节点即使active=true也不会显示
        if (!node.activeInHierarchy) return;

        // 2. 获取节点的渲染组件(Sprite、Label、Graphics等)
        // UIRenderer是所有2D渲染组件的基类
        const renderer = node.getComponent(UIRenderer);

        // 3. 如果节点有渲染组件,且组件是激活的,就处理它
        if (renderer && renderer.enabledInHierarchy) {
            // 3.1 获取渲染数据(包含顶点和索引)
            const renderData = renderer.renderData;
            
            // 3.2 获取材质(决定用什么Shader画)
            const material = renderer.getMaterial(0);
            
            // 3.3 获取深度/模板状态(Mask组件会改变这个值)
            const depthStencilState = renderer.stencilStage;

            // 4. 尝试合批 —— 这是核心!把当前组件和之前的组件合并
            this.commitComp(renderer, renderData, material, depthStencilState);
        }

        // 5. 递归遍历子节点(深度优先!)
        // 先处理完当前节点的所有子节点,再处理兄弟节点
        for (const child of node.children) {
            this.walk(child, level + 1);
        }
    }
}

逐行注释解读:

  • private _currHash: number = 0; —— _currHash记录当前批次使用的纹理哈希值。0表示"还没有开始任何批次"。哈希值就像纹理的"身份证号",两个纹理哈希相同 = 是同一张纹理
  • private _currMaterial: Material | null = null; —— _currMaterial记录当前批次使用的材质。材质包含Shader程序、混合模式等。null表示还没有批次
  • private _batches: DrawBatch[] = []; —— _batches存储所有生成的批次。每个批次最终会变成一个DrawCall
  • private _vData: Float32Array; —— Float32Array是JavaScript的类型化数组,专门存储32位浮点数。顶点位置、UV坐标、颜色都是浮点数,所以用这个
  • private _iData: Uint16Array; —— Uint16Array存储16位无符号整数。索引数据就是整数(指向第几个顶点),所以用这个
  • if (!node.activeInHierarchy) return; —— activeInHierarchy是Node的属性,表示"这个节点在层级中是否激活"。注意和active的区别:active只是节点自己的开关,activeInHierarchy还考虑了父节点的状态
  • const renderer = node.getComponent(UIRenderer); —— getComponent获取节点上挂载的组件。UIRenderer是基类,Sprite、Label、Graphics都继承自它
  • renderer.enabledInHierarchy —— 和activeInHierarchy类似,但针对组件。组件可能被禁用(enabled=false)
  • const renderData = renderer.renderData; —— renderData包含了这个组件的所有顶点数据和索引数据。一个Sprite有4个顶点(矩形四角)和6个索引(两个三角形)
  • renderer.getMaterial(0); —— 获取第0个材质。大多数2D组件只有一个材质
  • renderer.stencilStage; —— 模板阶段。Mask组件使用模板测试来实现裁剪,会改变这个值
  • this.commitComp(...); —— 调用合批判断核心方法!这是Batcher2D最重要的方法
  • for (const child of node.children) —— 遍历所有子节点。注意:遍历顺序就是node.children数组的顺序,也就是节点在层级管理器中的排列顺序

关键认知:深度优先遍历的影响

Batcher2D使用深度优先(DFS)遍历场景树。这意味着:

  • 先处理完一个节点的所有子节点,再处理兄弟节点
  • 渲染顺序 = DFS遍历顺序
  • 如果两个相同纹理的Sprite被不同纹理的Sprite隔开,就无法合批!

示例:

Root
├── Item1 (纹理A)
│   ├── Icon (纹理A)
│   └── Name (纹理B)
└── Item2 (纹理A)
    ├── Icon (纹理A)  ← 和Item1.Icon纹理相同,但中间被Name隔开了!
    └── Name (纹理B)

DFS顺序:Item1 → Item1.Icon(A) → Item1.Name(B) → Item2 → Item2.Icon(A) → Item2.Name(B) 渲染顺序:A → B → A → B = 4个DrawCall(A被打断两次)

初学者常见错误:

错误 后果 正确做法
认为"只要纹理相同就能合批" 忽略了层级顺序的影响 用层级管理器调整节点顺序,让相同纹理相邻
频繁修改节点active状态 导致Batcher2D每帧重新遍历 用透明度(opacity=0)代替隐藏,或标记static
在walk遍历中修改场景树 可能遍历出错或遗漏节点 在遍历完成后再修改结构

源码解析:commitComp()方法(合批判断核心)

为什么需要commitComp()?

walk()遍历到了一个新的渲染组件,要不要把它和之前的组件合并?这就是commitComp()要回答的问题。就像快递分拣:来了一个新包裹,要不要和上一批一起发?要看地址是否相同。

/**
 * commitComp: 合批判断的核心方法
 * 这是Batcher2D最重要的方法,决定了哪些Sprite能合批
 * 
 * 比喻:就像餐厅配餐员判断"这个新订单能和上一单一起炒吗?"
 * 能一起炒 = 合批成功;不能 = 开新锅(新DrawCall)
 */
commitComp(
    renderer: UIRenderer,           // 渲染组件(Sprite/Label等)
    renderData: RenderData,         // 渲染数据(顶点、索引)
    material: Material,             // 材质(决定Shader和渲染状态)
    depthStencilStateStage: number  // 深度/模板状态(Mask会影响)
) {
    // 1. 计算渲染数据的哈希值(用于快速比较)
    // dataHash是纹理的"指纹",相同纹理 = 相同哈希
    const dataHash = renderData.dataHash;

    // 2. 判断是否能合批(4个条件必须全部满足!)
    // 只要有一个不满足,就必须新建批次
    const canBatch =
        this._currHash === dataHash &&                    // 条件1: 相同纹理/图集
        this._currMaterial === material &&                // 条件2: 相同材质
        this._currDepthStencilStateStage === depthStencilStateStage;  // 条件3: 相同深度/模板状态

    // 3. 根据判断结果处理
    if (canBatch && this._currHash !== 0) {
        // ✅ 合批成功!将顶点数据追加到当前VBO
        // 就像把新订单加到同一锅一起炒
        this._appendVertexData(renderData);
    } else {
        // ❌ 合批失败!需要新建一个批次

        // 2.1 先提交之前的批次(如果有的话)
        // _currHash !== 0 表示"已经有正在积累的批次了"
        if (this._currHash !== 0) {
            // 把之前积累的批次正式加入批次列表
            this._batchToDrawBatch();
        }

        // 2.2 开始新批次
        // 更新当前批次状态为"这个新组件的资源"
        this._currHash = dataHash;
        this._currMaterial = material;
        this._currDepthStencilStateStage = depthStencilStateStage;

        // 2.3 将当前组件的顶点数据作为新批次的起点
        // 这是新批次的第一个组件
        this._appendVertexData(renderData);
    }
}

逐行注释解读:

  • const dataHash = renderData.dataHash; —— dataHash是渲染数据的哈希值。它通常由纹理的ID计算得出。为什么要用哈希?因为比较两个数字(哈希)比比较两个对象(纹理)快得多
  • this._currHash === dataHash —— 条件1:纹理相同。===是严格相等,不仅值要相同,类型也要相同。为什么纹理必须相同?因为GPU一次只能绑定一张纹理(就像打印机一次只能放一张纸)。纹理不同就必须切换,切换 = 新DrawCall
  • this._currMaterial === material —— 条件2:材质相同。材质决定用什么Shader、什么混合模式。为什么材质必须相同?因为切换Shader程序是GPU的重量级操作,必须新建DrawCall
  • this._currDepthStencilStateStage === depthStencilStateStage —— 条件3:深度/模板状态相同。Mask组件使用模板测试,会改变这个状态。为什么必须相同?因为模板状态是全局的,改变后需要重新设置
  • if (canBatch && this._currHash !== 0) —— 两个条件:能合批 并且 当前已经有批次了(_currHash !== 0)。如果_currHash === 0,表示这是第一个组件,自然"合批"(实际上是开启第一个批次)
  • this._appendVertexData(renderData); —— 合批成功时,把新组件的顶点数据拷贝到当前VBO的末尾。注意:是"追加",不是"替换"
  • if (this._currHash !== 0) —— 如果已经有积累的批次,先把它"封存"起来。就像上一锅菜炒完了,盛出来装盘
  • this._batchToDrawBatch(); —— 把当前积累的批次转换成一个正式的DrawBatch对象,加入_batches数组
  • this._currHash = dataHash; —— 开启新批次,更新当前状态为新组件的资源

为什么这4个条件缺一不可?

条件 为什么必须相同 不满足会怎样
纹理哈希 GPU一次只能绑定一个纹理 纹理不同必须切换,切换 = 新DrawCall
材质 材质决定Shader程序和渲染状态 Shader不同必须切换程序,切换 = 新DrawCall
深度/模板状态 深度测试和模板测试是全局状态 状态变化必须重新设置,设置 = 新DrawCall

注意:这里没有"相同混合模式"的显式检查,因为混合模式已经包含在material中。

自问自答:

Q: 为什么用===比较材质,而不是比较材质的内容? A: ===比较的是引用(内存地址)。如果两个材质是同一个对象实例,那它们的内容必然相同。如果内容相同但不是同一个实例,引擎认为它们"不同"(保守策略,避免错误合批)。所以优化时要确保使用同一个Material实例。

Q: 为什么_currHash初始值是0? A: 0是一个特殊值,表示"还没有开始任何批次"。因为正常的纹理哈希几乎不可能是0(哈希算法通常产生很大的数字),所以用0作为"空状态"的标记。

Q: 如果第一个组件的纹理哈希恰好是0怎么办? A: 实际上dataHash的计算会确保有效纹理的哈希不会是0。即使真的是0,代码逻辑也正确:_currHash === 0表示空状态,dataHash === 0表示第一个组件,会走"新建批次"分支。


源码解析:flush()方法(提交GPU)

为什么需要flush()?

walk()遍历完整个场景树后,所有渲染数据都收集到了VBO中,但还没有真正发给GPU。flush()就是"把积累的所有批次一次性提交给GPU"。

类比:walk()和commitComp()是"收集订单、配餐",flush()是"把所有配好的餐一起送出"。

/**
 * flush: 将所有批次提交给GPU
 * 在walk()遍历完成后调用
 * 
 * 比喻:就像快递员把一天积累的所有包裹,按路线依次配送
 */
flush() {
    // 1. 提交最后一个批次
    // walk()结束后,可能还有一个"正在积累中"的批次没有封存
    // 就像下班前,把桌上最后一单也打包送走
    if (this._currHash !== 0) {
        this._batchToDrawBatch();
    }

    // 2. 遍历所有批次,逐个提交DrawCall
    // 每个批次对应一次GPU绘制命令
    for (const batch of this._batches) {
        // 2.1 设置材质(Shader、纹理、混合模式)
        // 告诉GPU:这一批要用什么Shader、什么纹理
        this._setMaterial(batch.material);

        // 2.2 设置顶点数据
        // 告诉GPU:顶点在VBO中的起始位置和数量
        this._setVertexBuffer(batch.vertexOffset, batch.vertexCount);

        // 2.3 设置索引数据
        // 告诉GPU:索引在IBO中的起始位置和数量
        this._setIndexBuffer(batch.indexOffset, batch.indexCount);

        // 2.4 执行DrawCall!
        // 这是真正的GPU绘制命令!CPU对GPU说:"画吧!"
        this._device.draw(batch.indexCount);
    }

    // 3. 清空批次,准备下一帧
    // 这一帧画完了,清理状态,下一帧从头开始
    this._batches.length = 0;     // 清空批次数组(不释放内存,只重置长度)
    this._currHash = 0;           // 重置当前哈希
    this._currMaterial = null;    // 重置当前材质
}

逐行注释解读:

  • if (this._currHash !== 0) —— 检查是否还有"未封存"的批次。walk()结束后,最后一个批次可能还在积累状态,需要先封存
  • this._batchToDrawBatch(); —— 把最后一个批次加入_batches数组
  • for (const batch of this._batches) —— 遍历所有批次。每个批次对应一个DrawCall
  • this._setMaterial(batch.material); —— 设置材质。这包括:绑定Shader程序、绑定纹理、设置混合模式等。这是GPU的"状态切换"操作
  • this._setVertexBuffer(batch.vertexOffset, batch.vertexCount); —— 设置顶点缓冲区。vertexOffset是这批顶点在VBO中的起始位置,vertexCount是顶点数量
  • this._setIndexBuffer(batch.indexOffset, batch.indexCount); —— 设置索引缓冲区。indexOffset和indexCount同理
  • this._device.draw(batch.indexCount); —— 真正的绘制命令! 调用GFX层的draw方法,最终转换为gl.drawElements()或平台对应的API
  • this._batches.length = 0; —— 清空数组。注意:这是JavaScript清空数组的高效方式,比this._batches = []更好(不创建新数组对象)

自问自答:

Q: 为什么_batches.length = 0比_batches = []更好? A: length = 0只是修改数组长度,不创建新对象。=[]会创建一个新的空数组,旧数组成为垃圾等待回收。虽然现代JS引擎优化很好,但length = 0是更明确的"我要复用这个数组"的意图。

Q: flush()每帧都调用吗? A: 是的!每帧渲染时,Batcher2D会:walk()遍历 → commitComp()合批 → flush()提交。flush()在walk()完成后调用,通常在RenderPipeline的某个阶段触发。


合批的4个前提条件详解

为什么要了解这些条件?

合批是2D游戏性能优化的核心。不理解合批条件,就像不知道"哪些订单可以一起配送",永远无法优化DrawCall。

┌─────────────────────────────────────────────────────────────┐
│                     合批条件检查流程                           │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  纹理哈希相同? ──→ 否 ──→ ❌ 不合批,新建批次                 │
│       │                                                      │
│      是                                                      │
│       ↓                                                      │
│  材质相同? ─────→ 否 ──→ ❌ 不合批,新建批次                 │
│       │                                                      │
│      是                                                      │
│       ↓                                                      │
│  深度/模板状态相同? → 否 ──→ ❌ 不合批,新建批次             │
│       │                                                      │
│      是                                                      │
│       ↓                                                      │
│  ✅ 合批成功!顶点数据追加到当前VBO                            │
│                                                              │
└─────────────────────────────────────────────────────────────┘

每个条件的"为什么"详解:

条件1:相同纹理/图集

  • 是什么:两个Sprite使用同一张纹理(或同一张图集上的不同区域)
  • 为什么必须相同:GPU有一个"纹理单元",一次只能绑定一张纹理。切换纹理就像打印机换纸,必须停下来操作
  • 生活类比:复印机一次只能放一种纸。你要复印A4和A3,必须分两次,中间要换纸

条件2:相同材质

  • 是什么:两个Sprite使用同一个Material实例(不是内容相同,是同一个对象)
  • 为什么必须相同:材质包含Shader程序。切换Shader就像换厨师,每个厨师擅长不同的菜系,换人要花时间
  • 生活类比:一个厨房有中餐厨师和西餐厨师。如果订单要中餐和西餐交替做,厨师要来回换,效率极低

条件3:相同深度/模板状态

  • 是什么:两个Sprite的Mask/Stencil配置相同
  • 为什么必须相同:模板测试是全局状态。改变模板状态就像改变安全门的密码,所有人都要重新验证
  • 生活类比:工厂的生产线有安全设置。如果安全设置变了,整条线要停下来重新配置

条件4:不超出VBO大小限制

  • 是什么:合并后的顶点数不超过65535
  • 为什么必须满足:索引类型是Uint16,最大值65535。超出后索引会溢出,导致画面错乱
  • 生活类比:一辆公交车最多坐50人。第51个人必须等下一辆车

打断合批的操作列表

以下操作会导致合批被打断(产生新的DrawCall):

操作 打断原因 解决方案
使用不同纹理 纹理哈希不同 使用图集/动态图集
使用不同材质 材质引用不同 统一材质,用Uniform区分
使用Mask 模板状态变化 减少Mask使用,用矩形裁剪替代
使用自定义Shader 材质不同 统一Shader,用Uniform控制效果
修改混合模式 材质中的blendState不同 统一混合模式
使用Graphics组件 顶点格式不同 Graphics单独渲染
使用TTF Label 纹理是动态生成的 用BMFont替代
节点层级穿插 DFS遍历顺序导致纹理不连续 调整节点顺序或使用BFS

初学者常见错误:

错误 为什么错 正确做法
给每个Sprite单独设置颜色(修改material) 每个Sprite变成不同材质实例,无法合批 使用同一个材质,通过Sprite的color属性设置(不修改material)
频繁切换Sprite的spriteFrame到不同纹理 每次切换都打断合批 把所有可能用到的图片打包到同一图集
在UI中大量使用Mask做圆角效果 Mask改变模板状态,打断合批 用圆角图片代替,或只在必要时使用Mask
忽略层级顺序,随意排列节点 相同纹理的Sprite被隔开,无法合批 按纹理类型分组排列节点

VBO大小限制与处理

为什么需要了解VBO限制?

VBO(Vertex Buffer Object,顶点缓冲区)是GPU上的一块内存,用来存储顶点数据。它的大小是有限的,超出后需要特殊处理。

// Batcher2D中的VBO大小限制
const MAX_VERTEX_COUNT = 65535;  // uint16索引的最大值
const MAX_INDICE_COUNT = 65535 * 3;  // 每个顶点最多3个索引(实际上2D是2个三角形=6个索引/4个顶点)

/**
 * 当VBO满时的处理
 * 
 * 比喻:就像笔记本写满了,需要换新本子
 */
_appendVertexData(renderData: RenderData) {
    // 获取当前组件的顶点数量和索引数量
    const vertexCount = renderData.vertexCount;
    const indexCount = renderData.indexCount;

    // 检查是否超出VBO容量
    // this._vertexCount 是已经写入的顶点数
    // 如果加上新组件的顶点数超过上限,就必须先提交当前批次
    if (this._vertexCount + vertexCount > MAX_VERTEX_COUNT) {
        // VBO满了!先提交当前批次,再开新VBO
        this._batchToDrawBatch();   // 封存当前批次
        this._allocNewVBO();        // 分配新的VBO空间
    }

    // 拷贝顶点数据到VBO
    // this._vData.set(源数据, 目标偏移) —— 把renderData的顶点数据拷贝到VBO的当前位置
    this._vData.set(renderData.vData, this._vertexOffset);
    
    // 拷贝索引数据到IBO
    // 注意:索引需要偏移!因为VBO里已经有之前的顶点了
    // 新组件的索引0应该指向VBO中this._vertexOffset的位置
    this._iData.set(renderData.iData, this._indexOffset);

    // 更新计数器
    this._vertexCount += vertexCount;
    this._indexCount += indexCount;
}

逐行注释解读:

  • const MAX_VERTEX_COUNT = 65535; —— 65535是Uint16Array能表示的最大值(2^16 - 1)。索引数组用这个类型,所以顶点数不能超过这个值
  • const MAX_INDICE_COUNT = 65535 * 3; —— 这个值实际上不太准确。2D Sprite用两个三角形画矩形,4个顶点对应6个索引。所以更准确的限制是索引数不超过65535
  • if (this._vertexCount + vertexCount > MAX_VERTEX_COUNT) —— 检查溢出。this._vertexCount是VBO中已使用的顶点数,vertexCount是新组件的顶点数
  • this._batchToDrawBatch(); —— VBO满了,先把当前积累的批次封存
  • this._allocNewVBO(); —— 分配新的VBO缓冲区。实际上可能是复用之前的缓冲区,或者使用双缓冲
  • this._vData.set(renderData.vData, this._vertexOffset); —— TypedArray.set(array, offset)方法:把renderData.vData拷贝到this._vData的this._vertexOffset位置
  • this._iData.set(renderData.iData, this._indexOffset); —— 同理拷贝索引数据

为什么最大顶点是65535?

因为索引类型是Uint16Array,最大值为65535。如果顶点数超过这个值,索引会溢出。 WebGL2支持Uint32Array索引(最大4294967295),但Cocos 2D渲染为了兼容性仍使用Uint16。

超出后怎么办? Batcher2D会自动分成多个批次,每个批次最多65535个顶点。对2D游戏来说,一个批次通常足够画几百个Sprite。

自问自答:

Q: 一个Sprite有几个顶点? A: 4个顶点(矩形的四个角),6个索引(两个三角形:0-1-2和0-2-3)。所以65535个顶点可以画约16383个Sprite(65535 / 4)。对2D游戏来说完全够用。

Q: 为什么索引需要偏移? A: 想象你在写一本合集书。第一章的页码是1-10,第二章的页码应该是11-20,而不是重新从1开始。索引偏移就是"给新章节的页码加上前面章节的页数"。


四、RenderPipeline详细流程

RenderPipeline的渲染流程

┌─────────────────────────────────────────────────────────────┐
│                    RenderPipeline.render()                   │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  1. 更新所有相机                                              │
│     for each camera:                                         │
│         camera.update()  // 更新视图矩阵、投影矩阵             │
│                                                              │
│  2. 按priority排序相机                                        │
│     cameras.sort((a, b) => a.priority - b.priority)         │
│                                                              │
│  3. 对每个相机执行渲染                                         │
│     for each camera (按priority顺序):                        │
│                                                              │
│         3.1 视锥体裁剪                                        │
│             visible = cull(camera.frustum, scene)           │
│                                                              │
│         3.2 按RenderQueue排序                                 │
│             visible.sort((a, b) => a.queue - b.queue)       │
│                                                              │
│         3.3 执行RenderPass列表                                │
│             for each pass in _renderPasses:                 │
│                 pass.render(camera, visible)                │
│                                                              │
│  4. 提交CommandBuffer                                         │
│     device.flush()                                           │
│                                                              │
└─────────────────────────────────────────────────────────────┘

多相机渲染时的合批行为

关键问题:多个Camera时,合批是否还生效?

答案:每个Camera独立渲染,合批只在同一个Camera内生效。

Camera1 (priority=0, UI相机)
├── Sprite A (纹理X)
├── Sprite B (纹理X)  ← 和A合批!
└── Sprite C (纹理Y)

Camera2 (priority=1, 主相机)
├── Sprite D (纹理X)  ← 不和A/B合批!不同相机
├── Sprite E (纹理X)  ← 和D合批!
└── Sprite F (纹理Y)

为什么不同相机不能合批?

因为每个相机有自己的视图矩阵和投影矩阵。即使两个Sprite使用相同纹理,如果它们在不同相机下,MVP矩阵不同,顶点位置计算结果不同,无法合并到同一个VBO中。

生活类比:

想象两个摄影师(相机)同时拍摄:

  • 摄影师A拍全景(广角镜头)
  • 摄影师B拍特写(长焦镜头)

同一个苹果,在A的照片里很小,在B的照片里很大。虽然苹果是同一个(纹理相同),但照片上的位置和大小不同(MVP矩阵不同)。所以不能把它们合并到同一张照片里。


五、GFX层具体实现

GFX的抽象架构

为什么需要GFX?

不同平台(Web、iOS、Android、Windows)使用不同的图形API:

  • Web平台用WebGL
  • iOS用Metal
  • Android用Vulkan或OpenGL ES
  • Windows用DirectX或Vulkan

如果没有GFX层,Cocos引擎要为每个平台写一套渲染代码,维护成本极高。GFX层就是"翻译官",把Cocos的通用指令翻译成各平台的方言。

┌─────────────────────────────────────────────────────────────┐
│                         GFX 抽象层                            │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐       │
│  │ Device  │  │ Buffer  │  │ Texture │  │ Shader  │       │
│  │ (设备)   │  │ (缓冲区) │  │ (纹理)  │  │ (着色器) │       │
│  └────┬────┘  └────┬────┘  └────┬────┘  └────┬────┘       │
│       │            │            │            │              │
│  ┌────▼────┐  ┌────▼────┐  ┌────▼────┐  ┌────▼────┐       │
│  │WebGL    │  │WebGL    │  │WebGL    │  │WebGL    │       │
│  │Device   │  │Buffer   │  │Texture  │  │Shader   │       │
│  │(H5平台) │  │(H5平台) │  │(H5平台) │  │(H5平台) │       │
│  └─────────┘  └─────────┘  └─────────┘  └─────────┘       │
│                                                              │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐       │
│  │Metal    │  │Metal    │  │Metal    │  │Metal    │       │
│  │Device   │  │Buffer   │  │Texture  │  │Shader   │       │
│  │(iOS原生)│  │(iOS原生)│  │(iOS原生)│  │(iOS原生)│       │
│  └─────────┘  └─────────┘  └─────────┘  └─────────┘       │
│                                                              │
└─────────────────────────────────────────────────────────────┘

生活类比:

GFX就像一个国际会议的翻译团队:

  • Cocos引擎 = 中国代表,说中文
  • GFX抽象层 = 翻译主管,制定翻译标准
  • WebGL实现 = 英语翻译
  • Metal实现 = 日语翻译
  • Vulkan实现 = 韩语翻译

中国代表只需要说一次中文,翻译团队负责转成各国语言。

GFX后端对比

GFX类 WebGL实现 Metal实现 Vulkan实现
Device WebGLRenderingContext MTLDevice VkDevice
Buffer WebGLBuffer MTLBuffer VkBuffer
Texture WebGLTexture MTLTexture VkImage
Shader WebGLProgram MTLLibrary+MTLFunction VkShaderModule
CommandBuffer gl命令队列 MTLCommandBuffer VkCommandBuffer
Framebuffer WebGLFramebuffer MTLRenderPassDescriptor VkFramebuffer
Sampler WebGL无独立Sampler MTLSamplerState VkSampler

GFX的价值

  1. 跨平台:一套Cocos代码跑所有平台
  2. 性能优化:GFX层可以做平台特定的优化(如Metal的CommandBuffer合并)
  3. 功能扩展:新平台只需实现GFX接口,上层代码零改动

GFX不是性能瓶颈:GFX是薄封装,几乎没有额外开销。真正的性能瓶颈在DrawCall数量和Shader复杂度。

自问自答:

Q: 为什么WebGL没有独立的Sampler? A: WebGL 1.0的设计比较老,纹理采样器的参数(过滤方式、寻址模式)是直接绑定在纹理对象上的。WebGL 2.0和现代API(Metal/Vulkan)都有独立的Sampler对象,可以把"纹理数据"和"采样方式"分开管理。

Q: 如果我要学习底层图形API,应该学哪个? A: 建议顺序:WebGL → WebGL2 → Vulkan/Metal。WebGL最容易上手(在浏览器就能跑),概念理解后学其他API会很快。


六、材质系统详解

Material / Effect / Technique / Pass 关系

为什么需要这么复杂的材质系统?

材质系统要回答"怎么画"的问题。不同的效果需要不同的Shader,同一效果可能有不同质量级别(比如低画质/高画质),所以设计了分层结构。

Effect (.effect文件)
│
├── CCEffect 块
│   └── techniques[]
│       └── passes[]
│           ├── vert: 顶点着色器入口
│           ├── frag: 片元着色器入口
│           ├── depthStencilState: 深度/模板状态
│           ├── blendState: 混合状态
│           ├── rasterizerState: 光栅化状态
│           └── properties: 暴露给外部的参数
│
Material (运行时实例)
│
├── effectAsset: 引用哪个Effect
├── technique: 当前使用哪个technique索引
├── passes[]: Pass实例数组
│   └── shader: 编译后的Shader程序
│       ├── pipelineState
│       └── descriptorSet
│
└── properties: 运行时参数值
    ├── mainTexture: 主纹理
    ├── tintColor: 颜色
    └── ...

生活类比:做蛋糕

  • Effect(配方书) = 一本蛋糕食谱,里面有多种做法
  • Technique(做法) = 书中的"简易版"/"专业版"/"豪华版"
  • Pass(步骤) = 每个做法的具体步骤:先打蛋、再和面、最后烘烤
  • Material(实际做的蛋糕) = 你按照食谱做出来的具体蛋糕
  • Properties(配料调整) = 你做的蛋糕用了多少糖、什么水果

代码示例:材质系统的使用

// 1. 加载Effect资源
// Effect是.asset文件,包含Shader代码和渲染状态配置
const effectAsset = await assetManager.loadAny('db://assets/effects/sprite.effect');

// 2. 创建Material
// Material是Effect的"运行时实例",可以设置具体参数
const material = new Material();
material.initialize({
    effectAsset: effectAsset,  // 使用哪个Effect
    technique: 0,              // 使用第一个technique(通常是默认质量)
});

// 3. 设置参数
// 这些参数会传递给Shader的Uniform变量
material.setProperty('mainTexture', myTexture);    // 设置主纹理
material.setProperty('tintColor', Color.RED);      // 设置颜色

// 4. 应用到Sprite
const sprite = node.getComponent(Sprite);
sprite.material = material;  // 替换Sprite的默认材质

逐行注释解读:

  • assetManager.loadAny(...) —— Cocos的资源加载API。db://是Cocos的虚拟文件系统路径
  • new Material() —— 创建材质实例。注意:一个Material可以被多个Sprite共享!
  • material.initialize({...}) —— 初始化材质。必须指定effectAsset,否则材质不知道"怎么画"
  • technique: 0 —— 使用Effect中定义的第0个technique。一个Effect可以有多个technique,比如"高质量"/"低质量"
  • material.setProperty('mainTexture', myTexture); —— 设置Shader的Uniform变量。mainTexture是Shader中声明的采样器名称
  • sprite.material = material; —— 把材质赋给Sprite。注意:如果多个Sprite用同一个material实例,它们可以合批!

初学者常见错误:

错误 后果 正确做法
每个Sprite创建新的Material 无法合批,DrawCall爆炸 共享同一个Material实例
修改material属性后不解构 可能影响其他共享该材质的Sprite 复制材质实例(material = material.clone())再修改
在运行时频繁加载Effect 造成卡顿 预加载Effect资源,运行时只创建Material

七、渲染线程模型

Cocos的线程架构

为什么需要了解线程模型?

因为不同平台的线程架构不同,这直接影响你的优化策略。在H5平台,JS逻辑会阻塞渲染;在原生平台,渲染有独立线程。

┌─────────────────────────────────────────────────────────────┐
│                      主线程 (JavaScript)                      │
│                                                              │
│  Game.mainLoop()                                             │
│    ├── 逻辑更新 (update)                                      │
│    ├── 物理更新                                               │
│    └── 渲染数据准备 (Batcher2D.walk)                          │
│                                                              │
│  ↓ 提交渲染指令                                               │
├─────────────────────────────────────────────────────────────┤
│                   渲染线程 (C++ / 原生)                        │
│                                                              │
│  GFX Device                                                  │
│    ├── CommandBuffer 收集                                     │
│    ├── 资源上传 (纹理、缓冲区)                                  │
│    └── GPU 提交                                               │
│                                                              │
│  ↓ GPU驱动                                                    │
├─────────────────────────────────────────────────────────────┤
│                      GPU                                      │
│                                                              │
│  渲染管线执行                                                  │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Cocos 3.8.x的线程模型

  • H5平台:单线程。JS主线程直接调用WebGL API,没有独立的渲染线程。
  • 原生平台:双线程。JS主线程准备渲染数据,C++渲染线程执行GPU提交。

为什么H5没有独立渲染线程?

因为WebGL API必须在创建它的线程上调用,而WebGL上下文是在JS主线程创建的。所以H5平台的渲染是同步的:JS准备数据 → 立即调用WebGL → GPU执行。

这对性能有什么影响?

H5平台上,如果JS逻辑太重(复杂的AI、物理计算),会阻塞渲染,导致掉帧。原生平台由于有独立渲染线程,JS逻辑不会直接影响渲染。

生活类比:餐厅后厨

  • 原生平台(双线程) = 大餐厅,有专门的配菜员(JS主线程)和厨师(渲染线程)。配菜员切菜不影响厨师炒菜,两边并行工作。
  • H5平台(单线程) = 小餐馆,一个人既要配菜又要炒菜。配菜太慢的话,炒菜也只能等着,客人(玩家)就饿着了(掉帧)。

自问自答:

Q: 既然H5是单线程,那我写H5游戏是不是一定比原生卡? A: 不一定!单线程的问题是"JS逻辑太重会阻塞渲染"。如果你的JS逻辑很轻(2D游戏通常如此),单线程完全够用。而且现代浏览器和JS引擎优化很好,H5游戏也能跑60FPS。

Q: 原生平台的双线程,数据怎么传递? A: 通过CommandBuffer。JS主线程把渲染命令写入CommandBuffer,渲染线程读取并执行。这有点像餐厅的点菜单:服务员写单(JS),厨师看单做菜(渲染线程)。


八、2D渲染完整数据流

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Sprite    │────→│  RenderData │────→│  Batcher2D  │
│  组件        │     │  (顶点+索引)  │     │  (合批+VBO)  │
└─────────────┘     └─────────────┘     └──────┬──────┘
                                                │
                                                ↓
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│    GPU      │←────│    GFX      │←────│ RenderPipeline│
│  (渲染执行)  │     │  (API转换)   │     │  (渲染调度)   │
└─────────────┘     └─────────────┘     └─────────────┘

详细流程:

  1. Sprite组件:updateRenderData() 生成4个顶点(位置、UV、颜色)和6个索引
  2. RenderData:存储顶点的Float32Array和索引的Uint16Array
  3. Batcher2D:walk()遍历场景树,commitComp()判断合批,合并到VBO
  4. RenderPipeline:按相机优先级排序,执行RenderPass
  5. GFX:将Cocos的渲染指令转换为WebGL API调用
  6. GPU:执行顶点着色器 → 光栅化 → 片元着色器 → 逐片元操作 → 帧缓冲

生活类比:印刷厂 workflow

  1. 设计师(Sprite) = 设计一张海报,确定内容
  2. 排版稿(RenderData) = 把设计稿转成印刷用的电子文件
  3. 拼版员(Batcher2D) = 把多张海报拼到一张大纸上(合批),省纸省时间
  4. 车间主任(RenderPipeline) = 安排印刷顺序:先印黑色,再印彩色
  5. 机器操作员(GFX) = 把排版文件转换成印刷机能懂的指令
  6. 印刷机(GPU) = 实际印刷,出成品

九、实战:查看和优化DrawCall

如何查看当前DrawCall

// 开启调试信息
cc.debug.setDisplayStats(true);

// 显示内容:
// FPS: 60        → 当前帧率
// DrawCall: 15   → 当前DrawCall数(重点关注!)
// Triangle: 1200 → 当前三角形数
// Frame: 12ms    → 帧总耗时

自问自答:

Q: DrawCall多少算正常? A: 2D游戏的目标通常是< 50。移动端建议< 30。如果超过100,在低端机上可能会卡顿。

Q: Triangle数为什么也很重要? A: 虽然2D游戏顶点少,但如果用了大量Mask或复杂图形,三角形数会增加。GPU处理每个三角形都需要时间。

如何分析合批是否成功

// 在Batcher2D中添加调试日志(开发调试时使用)
// 修改引擎源码 batcher-2d.ts

commitComp(renderer, renderData, material, depthStencilStateStage) {
    const dataHash = renderData.dataHash;

    if (this._currHash !== dataHash || dataHash === 0 ||
        this._currMaterial !== material ||
        this._currDepthStencilStateStage !== depthStencilStateStage) {

        // 打断合批!记录原因
        console.log(`[Batch Break] node=${renderer.node.name}`);
        console.log(`  hashMatch=${this._currHash === dataHash}`);
        console.log(`  materialMatch=${this._currMaterial === material}`);
        console.log(`  stencilMatch=${this._currDepthStencilStateStage === depthStencilStateStage}`);
    }
}

逐行注释解读:

  • if (this._currHash !== dataHash || ...) —— 检查是否打断合批的条件。和之前的canBatch判断相反
  • console.log([Batch Break] node=${renderer.node.name}); —— 打印打断合批的节点名。这样你可以快速定位问题节点
  • hashMatch=${this._currHash === dataHash} —— 打印纹理是否匹配。false表示纹理不同
  • materialMatch=... —— 打印材质是否匹配。false表示材质不同
  • stencilMatch=... —— 打印模板状态是否匹配。false表示Mask状态不同

DrawCall优化检查清单

  • DisplayStats开启,DrawCall < 50
  • 所有UI图片打包图集
  • 相同纹理的Sprite相邻排列
  • 静态节点标记_static
  • 减少Mask使用
  • Label考虑使用BMFont
  • 不每帧设置不变的属性
  • 检查控制台是否有[Batch Break]日志

十、自问自答汇总

Q1: 为什么Batcher2D使用DFS而不是BFS遍历?

A: 因为UI的层级关系天然适合DFS。父节点通常是背景/容器,子节点是内容。DFS先画父节点再画子节点,符合"先画背景再画前景"的直觉。如果用BFS,同一层的兄弟节点会连续画,可能破坏父子之间的遮挡关系。

Q2: 我修改了Sprite的color,为什么还能合批?

A: 因为Sprite的color属性是在顶点数据层面修改的(修改每个顶点的颜色值),不是修改Material。只要Material实例相同,就能合批。但如果你修改了material.setProperty('tintColor', ...),就改变了Material,会打断合批。

Q3: 动态图集(Dynamic Atlas)和静态图集有什么区别?

A:

  • 静态图集:打包时生成,运行时不变。适合UI图片、固定图标。
  • 动态图集:运行时把小图动态合并到大图上。适合运行时加载的零散图片。但有内存开销和首次构建开销。

Q4: 使用Mask一定会增加DrawCall吗?

A: 是的。Mask使用模板测试(Stencil Test),会改变stencilStage状态。每个不同的stencilStage都会打断合批。但可以通过以下方式减少影响:

  1. 把使用相同Mask的节点放在一起
  2. 用矩形裁剪(cc.Layout的裁剪)代替Mask
  3. 用圆角图片代替Mask的圆角效果

Q5: 为什么我的DrawCall在编辑器里和运行时不一样?

A: 编辑器可能有额外的渲染(如Gizmo、选中框)。以运行时的DrawCall为准。另外,确保编辑器中和运行时使用相同的图集配置。


参考资源