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); —— 设置索引缓冲区。indexOffsetindexCount同理
  • 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._vDatathis._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. Batcher2Dwalk()遍历场景树,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为准。另外,确保编辑器中和运行时使用相同的图集配置。


参考资源