外观
Lesson 037|Cocos 2.x 渲染流程总览
Tags: #Creator2.x #Renderer #RenderData #Assembler #GPUDifficulty: ⭐⭐⭐⭐☆
🎯 本课目标
这一课不是让你背一张渲染流程图,而是建立后续所有渲染课共同使用的排错地图。
学完后,你应该能够:
- 解释 Node 为什么不是直接交给 GPU 的渲染对象。
- 把一次属性修改拆成“状态改变、数据失效、数据更新、批次提交、GPU 执行”。
- 区分 CPU 数据更新、DrawCall 提交和 GPU 像素处理三类成本。
- 判断
node.x、node.color、spriteFrame、label.string分别可能影响哪些层。 - 用控制变量实验判断页面卡在业务更新、渲染数据、提交还是像素阶段。
🧩 从前面三章走到 Renderer
前面已经建立了三条链:
text
Stage01:Node / Component 保存什么状态
Stage02:这些状态在一帧中的什么时候被更新
Stage03:Scene、Prefab、Texture、SpriteFrame 怎样成为运行时对象Stage04 要继续回答:
这些运行时对象怎样变成屏幕上的像素?
最容易产生的错误理解是:
text
修改 Node
→ Node 直接调用 GPU
→ 屏幕立刻变化真实情况要多经过几层。理解这些层,才知道画面不更新、DrawCall 增加和 GPU 卡顿分别应该查哪里。
一、本课从一次“只改文字却卡了一帧”开始
排行榜中有 100 个 Item,每个 Item 都显示玩家战力。服务器推送数据后,代码只做了:
ts
item.powerLabel.string = String(player.power);开发者认为:
只是给 100 个字符串赋值,不可能是渲染问题。
但这一帧出现明显卡顿。可能发生的工作包括:
text
100 次业务赋值
→ 判断文本是否变化
→ 文本测量与排版
→ 检查需要哪些字形
→ 更新字形缓存或纹理
→ 重新生成 Label 顶点和 UV
→ 影响 Label 或父 Layout 尺寸
→ Renderer 重新收集绘制数据
→ 字体纹理或材质变化形成新批次
→ GPU 采样字形纹理并混合像素不是每次赋值都会触发以上全部工作,具体取决于内容、缓存模式、布局和版本实现。但它证明了一点:
一行业务代码的成本,不能只按这一行在 JavaScript 中做了什么来判断,还要看下游哪些缓存因此失效。
二、先看完整路线:从业务属性到屏幕像素
把 Creator 2.4.x 的渲染过程抽象为五层:
text
第一层:业务与场景状态
Node / Component / Transform / active
↓
第二层:渲染组件
Sprite / Label / Graphics / Spine / Particle
↓
第三层:CPU 渲染数据
Assembler / RenderData / Vertex / UV / Color / Index
↓
第四层:Renderer 提交
Camera / Sort / Material / Texture / Batch / DrawCall
↓
第五层:GPU 管线
Vertex Shader / Rasterization / Fragment Shader / Blend / FrameBuffer这是一张职责图,不是要求记忆 Creator 私有函数的逐行调用顺序。不同 2.4.x 小版本、Web 与 Native 后端可能有不同实现,但五层分别解决的问题是稳定的。
三、第一层:Node 保存场景状态,但 Node 不是图像
假设编辑器里有:
text
StartButton
├── Node:位置、旋转、缩放、颜色、active、父子关系
├── Sprite:使用哪个 SpriteFrame、什么 Sprite Type
├── Button:交互状态
└── StartGame:业务逻辑其中 Node 负责的是:
text
它在哪里;
它在什么父节点下面;
它是否激活;
后代如何继承变换;
组件挂在哪里。Node 本身不包含“用哪张纹理、哪些 UV、运行哪个 Fragment Shader”这类完整绘制信息。
一个空 Node 可以有位置和父子关系,却完全不产生像素。只有挂载 Sprite、Label、Graphics 等渲染组件后,Renderer 才有可收集的显示内容。
所以:
Node 是场景树对象,Render Component 才表达“我要显示什么”。
四、第二层:渲染组件把业务属性翻译成显示需求
不同组件接收的输入不同:
text
Sprite
→ SpriteFrame、颜色、尺寸、Sprite Type
Label
→ 字符串、字体、字号、行高、Overflow、缓存模式
Graphics
→ 路径命令、描边、填充
Spine
→ 动画、骨骼、插槽、附件、纹理
Particle
→ 发射、生命周期、位置、尺寸、颜色组件并不会把这些高级描述原样交给 GPU。GPU 不认识“九宫格 Sprite”“自动换行 Label”或“圆形进度条”,它最终消费的是顶点、纹理、参数和绘制状态。
因此组件状态需要被转换为更底层的渲染数据。
五、第三层:Assembler 与 RenderData 解决“具体画哪些三角形”
以普通 Sprite 为例,CPU 侧通常需要准备:
text
顶点位置:四个角在哪里
UV:四个角采样纹理的什么位置
颜色:顶点携带什么颜色和透明度
索引:四个顶点如何组成两个三角形高层过程可以理解为:
text
Sprite 当前属性
↓
Assembler 根据 Sprite Type 选择生成规则
↓
得到或更新 RenderData
↓
顶点、UV、颜色、索引进入可提交缓冲不同属性变化,失效的数据范围并不相同。
只移动 Node
ts
iconNode.x += 10;主要改变 Transform。SpriteFrame 的纹理区域没有变,UV 通常没有理由变化。后续需要使用新的世界变换,但不应笼统说“Sprite 所有数据全部重建”。
只修改颜色
ts
iconNode.color = cc.Color.RED;可能更新顶点颜色或组件颜色相关数据。它不必然改变顶点几何,也不必然换 Material。
更换同一图集里的 SpriteFrame
ts
sprite.spriteFrame = anotherFrame;可能改变:
text
UV 区域;
原始尺寸与裁剪信息;
节点 Content Size(取决于 Size Mode);
顶点数据。如果两个 Frame 的底层 Texture 相同,纹理绑定仍可能兼容。
修改 Label.string
它可能改变:
text
字符数量;
需要的字形;
文本宽高;
换行;
字形纹理页;
顶点数量;
父 Layout。这就是开头的字符串赋值为什么可能比颜色修改昂贵。
六、什么是“脏”:修改时先宣布旧结果不能再用
如果每次属性赋值都立即完成世界矩阵、顶点生成、批次提交和 GPU 绘制,会发生大量重复工作:
ts
node.x = 100;
node.y = 200;
node.rotation = 30;
node.scale = 1.2;四次写入之间的中间结果通常没有必要依次显示。更合理的方向是:
text
修改属性
→ 保存新值
→ 标记相关缓存失效
→ 后续真正需要时计算最终结果“Dirty”不是“GPU 现在开始画”,它只是:
旧缓存已经不能保证正确,下一个消费者必须取得新结果。
Dirty 也不是只有一种。Transform、颜色、UV、几何、材质和布局可能有不同的失效范围。后面的 L038、L041 和 L042 会分别深入。
七、第四层:Renderer 不是简单遍历后逐个绘制
Renderer 收集可见组件后,还要解决:
text
哪台 Camera 能看到它?
绘制顺序是什么?
使用哪个 Material、Pass 和 Shader?
绑定哪张 Texture 和什么 Sampler?
Blend、Depth、Stencil 是什么状态?
相邻对象能否合批?
顶点和索引放进哪个缓冲?简化流程:
text
收集 Camera 可见对象
→ 按正确性要求组织绘制顺序
→ 比较相邻对象的纹理、材质和状态
→ 兼容则追加到当前批次
→ 不兼容则提交当前批次
→ 建立新批次继续因此:
text
一个 Sprite 不一定对应一个 DrawCall;
一百个 Sprite 也不一定只有一个 DrawCall;
DrawCall 数量由连续对象的实际兼容状态决定。Renderer 不能为了减少 DrawCall 任意打乱透明 UI。顺序变化会改变遮挡和混合结果,画面正确性优先于合批数字。
八、第五层:GPU 接到命令后怎样得到像素
一次绘制命令进入 GPU 后,可以先建立最小模型:
text
读取顶点数据
→ Vertex Shader 计算裁剪空间位置
→ 图元装配组成三角形
→ 光栅化生成覆盖区域的片元
→ Fragment Shader 计算片元颜色
→ Depth / Stencil 判断是否通过
→ Blend 与目标颜色组合
→ 写入 FrameBuffer这解释了为什么“顶点少”和“GPU 便宜”不是同一件事。
一个全屏 Sprite 通常只有四个顶点、两个三角形,却可能覆盖数百万像素;如果 Fragment Shader 做多次纹理采样,又叠加多层透明效果,GPU 仍可能很慢。
反过来,几百个很小的 Sprite 可能像素压力不高,却因为状态分散形成大量 DrawCall,让 CPU 提交成为瓶颈。
九、必须分开的三类渲染成本
看到“渲染卡”时,至少拆成三类:
1. CPU 数据更新
text
Transform 传播
Label 排版
Graphics 三角化
Assembler 更新几何
Spine 骨骼与蒙皮
Particle 模拟2. CPU/驱动提交
text
排序和合批
绑定 Material、Texture、Buffer
更新 Uniform
切换 Render State
发出 DrawCall3. GPU 执行
text
顶点处理
片元处理
纹理采样
透明混合
Overdraw
RenderTarget 读写与带宽三者可以组合出现。优化 DrawCall 只主要针对第二类,不会自动减少 Label 排版,也不会自动减少全屏 Fragment。
十、用四种属性修改练习预测成本
| 修改 | 首先改变 | 可能继续影响 | 不应直接断言 |
|---|---|---|---|
node.x | Local Transform | World Matrix、可见位置 | 一定换纹理或拆批 |
node.color | 颜色状态 | 顶点颜色、RenderData | 一定复制 Material |
spriteFrame | Frame 引用 | UV、尺寸、Texture、批次 | 同图集一定拆批 |
label.string | 文本内容 | 排版、字形、尺寸、几何、Layout | 只是字符串赋值 |
真正的进阶能力不是背表格,而是沿着“谁消费了这个状态”继续追问。
十一、Creator 2.4.x 实验:一次只改变一层
场景、节点和组件全部通过 Creator 编辑器创建与绑定。不要直接修改
.scene、.prefab、.meta或生成目录。
场景准备
text
Canvas
├── MoveGroup
│ └── 100 个同图集 Sprite
├── ColorGroup
│ └── 100 个同图集 Sprite
├── LabelGroup
│ └── 100 个 Label
└── ControlPanel每次只启用一个测试组。
RenderFlowProbe.ts
ts
const { ccclass, property } = cc._decorator;
@ccclass
export default class RenderFlowProbe extends cc.Component {
@property(cc.Node)
moveGroup: cc.Node = null;
@property([cc.Node])
colorNodes: cc.Node[] = [];
@property([cc.Label])
labels: cc.Label[] = [];
private mode = 0;
private elapsed = 0;
private lastSecond = -1;
setMode(mode: number) {
this.mode = mode;
}
update(dt: number) {
this.elapsed += dt;
if (this.mode === 1) {
this.moveGroup.x = Math.sin(this.elapsed) * 100;
}
if (this.mode === 2) {
const value = Math.floor(
(Math.sin(this.elapsed) + 1) * 127
);
for (const node of this.colorNodes) {
node.color = cc.color(255, value, value);
}
}
if (this.mode === 3) {
for (const label of this.labels) {
label.string = String(this.elapsed);
}
}
if (this.mode === 4) {
const second = Math.floor(this.elapsed);
if (second !== this.lastSecond) {
this.lastSecond = second;
for (const label of this.labels) {
label.string = String(second);
}
}
}
}
}对照步骤
- 全部静止,记录 CPU Frame、DrawCall 和帧时间基线。
- Mode 1:只移动父节点,观察 Transform 相关变化。
- Mode 2:只更新颜色,观察 DrawCall 是否必然增加。
- Mode 3:Label 每帧写入不同字符串。
- Mode 4:只在整数秒变化时更新 Label。
- 在同一设备、分辨率和构建模式下重复三次。
记录表
| 模式 | CPU Frame | DrawCall | GPU/总帧时间 | 解释 |
|---|---|---|---|---|
| 静止 | 实测 | 实测 | 实测 | 基线 |
| 移动父节点 | 实测 | 实测 | 实测 | Transform 传播 |
| 修改颜色 | 实测 | 实测 | 实测 | 顶点颜色更新 |
| Label 每帧更新 | 实测 | 实测 | 实测 | 文本与几何 |
| Label 每秒更新 | 实测 | 实测 | 实测 | 降低更新频率 |
实验目标不是证明某个模式固定慢多少,而是回答:
text
属性改动影响了哪一层?
DrawCall 是否变化?
DrawCall 不变时 CPU 是否仍变化?
数据更新减少后帧时间是否恢复?十二、真实项目故障:战力数字跳动导致列表卡顿
现象
排行榜打开后有 100 个 Item。每个 Item 的战力数字做滚动动画,持续一秒。DrawCall 没有明显暴涨,但 CPU Frame 很高。
错误判断
DrawCall 没变,所以不是渲染问题。
成本链
text
100 个数值每帧变化
→ 100 个 Label.string 每帧不同
→ 文本测量和顶点数据持续更新
→ 部分 Label 宽度变化
→ 父 Layout 反复重排
→ CPU 超出帧预算修复推导
- 业务数值可以高频插值,但显示不必每帧变化。
- 固定数字区域宽度,避免推动父 Layout。
- 只更新屏幕内 Item。
- Item 离屏或动画结束后停止调度。
- 对比修改前后 CPU Frame,而不是只看 DrawCall。
修复后的核心不是“少写几行代码”,而是缩小下游缓存失效的频率和范围。
十三、渲染故障的证据式排查
画面没有更新
text
业务属性值真的改变了吗?
→ Node / Component 是否 active、enabled?
→ RenderData 是否应该失效?
→ Camera 是否可见?
→ Material / Texture 是否有效?
→ GPU 状态是否把它裁掉或变透明?DrawCall 突然增加
text
批次边界两侧是什么对象?
→ Texture 是否相同?
→ Material / Pass 是否兼容?
→ Blend / Depth / Stencil 是否变化?
→ 是否插入 Mask 或特殊组件?
→ 顺序是否允许重新组织?DrawCall 不变但 CPU 变慢
text
Transform 是否大范围传播?
Label / Layout 是否重复刷新?
Assembler 是否重建几何?
Graphics / Spine / Particle 是否持续更新?CPU 不高但 GPU 变慢
text
分辨率是否提高?
全屏透明层是否增加?
Fragment Shader 是否多采样?
Overdraw 是否上升?
RenderTexture 是否变大或增加 Pass?十四、常见误区
Node 就是渲染对象
Node 保存场景结构和状态;Sprite、Label 等渲染组件才表达显示内容。
改属性就会立即调用 GPU
属性通常先改变 CPU 状态并使缓存失效,Renderer 在后续渲染阶段收集和提交。
每次改 x 都会完整重建 Sprite
Transform、UV、颜色和几何是不同数据。具体失效范围要看修改内容与组件实现。
DrawCall 越少,页面一定越快
DrawCall 主要描述提交次数。CPU 数据更新、顶点数、片元、纹理带宽和 Overdraw 仍可能超时。
GPU 慢就只优化 Shader
覆盖面积、分辨率、透明层、纹理采样、RenderTarget 和 Shader 共同决定 GPU 成本。
十五、练习与推导答案
练习一
同一帧连续修改一个 Sprite 的 x、颜色和 SpriteFrame。分别列出三者首先使哪类状态失效。
练习二
一个页面 DrawCall 从 50 降到 10,帧时间没有改善。至少提出三个互不相同的后续假设,并说明如何验证。
练习三
为什么“100 个 Node”和“100 个 DrawCall”之间没有必然关系?
完成推导后再展开参考答案
- x 首先影响 Transform;颜色首先影响颜色或相关 RenderData;SpriteFrame 首先影响 Frame 引用,可能继续影响 UV、尺寸、Texture 和批次。
- 可能是 Label/Layout 等 CPU 数据更新、全屏透明造成 GPU Overdraw、纹理解码上传峰值、复杂 Shader 或顶点量。分别通过冻结对应更新、缩小覆盖面积、预热资源、替换简单材质等单变量实验验证。
- 空 Node 不绘制;多个兼容 Sprite 可以合批;一个多 Pass 或特殊组件又可能产生多次提交。节点是场景对象,DrawCall 是绘制提交,两者统计对象不同。
🚀 能力验收
不看正文,你应该能够画出:
text
Node / Component 状态
→ Dirty / 数据失效
→ Assembler / RenderData
→ Camera / Material / Texture / Batch
→ DrawCall
→ Vertex / Fragment / Blend
→ FrameBuffer 像素并且面对一次卡顿时,先回答:
text
是 CPU 数据更新?
是 CPU/驱动提交?
还是 GPU 像素执行?
证据是什么?✅ 本课总结
text
Node 保存场景状态,但不是直接提交给 GPU 的图像。
渲染组件把高级显示需求转成可生成的数据。
Assembler 和 RenderData 描述顶点、UV、颜色与索引。
Renderer 负责 Camera、顺序、材质、合批和提交。
GPU 负责顶点、片元、状态测试、混合和像素写入。最重要的一句话是:
一次业务属性修改可能穿过多层缓存和系统;定位问题时,要证明成本发生在哪一层,而不是根据代码行数猜测。
下一课: Transform Dirty 如何在渲染阶段得到最终 World Matrix?
