Cocos渲染架构详解
引擎从代码到画面的完整流程,Batcher2D源码级解析,场景树遍历顺序如何影响DrawCall
零、写给初学者:为什么要学渲染架构?
如果不懂这个会怎样?
想象一下你去餐厅吃饭:
- 懂渲染架构 = 你知道后厨怎么运作,知道为什么你的菜上得慢,知道怎么点菜能最快上齐
- 不懂渲染架构 = 你只会催服务员"快点上菜",但不知道问题出在哪,优化无从下手
在游戏开发中:
| 场景 | 懂渲染架构 | 不懂渲染架构 |
|---|---|---|
| 游戏卡顿 | 能定位是DrawCall太多还是Shader太复杂 | 只能盲目降低画质 |
| UI优化 | 知道调整层级就能减少DrawCall | 反复改图片大小,毫无效果 |
| 使用Mask后掉帧 | 知道Mask打断合批,改用矩形裁剪 | 以为是Mask本身耗性能,束手无策 |
| 多相机问题 | 理解每个相机独立渲染 | 困惑为什么合批失效 |
学习本章节后,你将能:
- 看懂引擎源码,不再害怕底层
- 精准定位性能瓶颈
- 写出"渲染友好"的代码和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: 执行渲染管线 → 帧缓冲 → 显示到屏幕
类比理解:这就像你点外卖的完整流程:
- 你在APP下单(写代码)
- 餐厅收到订单,准备食材(加载纹理)
- 店长宣布开始营业(Game启动主循环)
- 值班经理安排这单的制作(Director调度)
- 厨师准备原料:切菜、腌肉(生成顶点数据)
- 配餐员收集所有待做订单(walk遍历)
- 配餐员判断:哪些订单可以一起炒?(commitComp合批判断)
- 按优先级安排出餐顺序(RenderPipeline排序)
- 翻译把菜单翻译成厨师的母语(GFX转换API)
- 厨师炒菜,装盘,送出(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是当前活跃的场景对象。这里会递归遍历场景树,调用每个组件的updateif (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支持多相机,比如一个相机画游戏世界,一个相机画UIcamera.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渲染的心脏,负责:
- 遍历场景树,收集所有2D渲染组件
- 判断哪些组件可以合批
- 将可合批的组件顶点数据合并到大VBO
- 提交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存储所有生成的批次。每个批次最终会变成一个DrawCallprivate _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一次只能绑定一张纹理(就像打印机一次只能放一张纸)。纹理不同就必须切换,切换 = 新DrawCallthis._currMaterial === material—— 条件2:材质相同。材质决定用什么Shader、什么混合模式。为什么材质必须相同?因为切换Shader程序是GPU的重量级操作,必须新建DrawCallthis._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)—— 遍历所有批次。每个批次对应一个DrawCallthis._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()或平台对应的APIthis._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个索引。所以更准确的限制是索引数不超过65535if (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的价值
- 跨平台:一套Cocos代码跑所有平台
- 性能优化:GFX层可以做平台特定的优化(如Metal的CommandBuffer合并)
- 功能扩展:新平台只需实现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转换) │ │ (渲染调度) │
└─────────────┘ └─────────────┘ └─────────────┘
详细流程:
- Sprite组件:
updateRenderData()生成4个顶点(位置、UV、颜色)和6个索引 - RenderData:存储顶点的Float32Array和索引的Uint16Array
- Batcher2D:
walk()遍历场景树,commitComp()判断合批,合并到VBO - RenderPipeline:按相机优先级排序,执行RenderPass
- GFX:将Cocos的渲染指令转换为WebGL API调用
- GPU:执行顶点着色器 → 光栅化 → 片元着色器 → 逐片元操作 → 帧缓冲
生活类比:印刷厂 workflow
- 设计师(Sprite) = 设计一张海报,确定内容
- 排版稿(RenderData) = 把设计稿转成印刷用的电子文件
- 拼版员(Batcher2D) = 把多张海报拼到一张大纸上(合批),省纸省时间
- 车间主任(RenderPipeline) = 安排印刷顺序:先印黑色,再印彩色
- 机器操作员(GFX) = 把排版文件转换成印刷机能懂的指令
- 印刷机(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都会打断合批。但可以通过以下方式减少影响:
- 把使用相同Mask的节点放在一起
- 用矩形裁剪(cc.Layout的裁剪)代替Mask
- 用圆角图片代替Mask的圆角效果
Q5: 为什么我的DrawCall在编辑器里和运行时不一样?
A: 编辑器可能有额外的渲染(如Gizmo、选中框)。以运行时的DrawCall为准。另外,确保编辑器中和运行时使用相同的图集配置。
参考资源
- Cocos官方文档:https://docs.cocos.com/
- Cocos引擎源码:https://github.com/cocos/cocos-engine
- 月影《跟月影学可视化》第04篇:《GPU与渲染管线》