Skip to content

Lesson 037|Cocos 2.x 渲染流程总览 ​

Tags: #Creator2.x #Renderer #RenderData #Assembler #GPUDifficulty: ⭐⭐⭐⭐☆


🎯 本课目标 ​

这一课不是让你背一张渲染流程图,而是建立后续所有渲染课共同使用的排错地图。

学完后,你应该能够:

  1. 解释 Node 为什么不是直接交给 GPU 的渲染对象。
  2. 把一次属性修改拆成“状态改变、数据失效、数据更新、批次提交、GPU 执行”。
  3. 区分 CPU 数据更新、DrawCall 提交和 GPU 像素处理三类成本。
  4. 判断 node.x、node.color、spriteFrame、label.string 分别可能影响哪些层。
  5. 用控制变量实验判断页面卡在业务更新、渲染数据、提交还是像素阶段。

🧩 从前面三章走到 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
发出 DrawCall

3. GPU 执行 ​

text
顶点处理
片元处理
纹理采样
透明混合
Overdraw
RenderTarget 读写与带宽

三者可以组合出现。优化 DrawCall 只主要针对第二类,不会自动减少 Label 排版,也不会自动减少全屏 Fragment。


十、用四种属性修改练习预测成本 ​

修改首先改变可能继续影响不应直接断言
node.xLocal TransformWorld Matrix、可见位置一定换纹理或拆批
node.color颜色状态顶点颜色、RenderData一定复制 Material
spriteFrameFrame 引用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);
                }
            }
        }
    }
}

对照步骤 ​

  1. 全部静止,记录 CPU Frame、DrawCall 和帧时间基线。
  2. Mode 1:只移动父节点,观察 Transform 相关变化。
  3. Mode 2:只更新颜色,观察 DrawCall 是否必然增加。
  4. Mode 3:Label 每帧写入不同字符串。
  5. Mode 4:只在整数秒变化时更新 Label。
  6. 在同一设备、分辨率和构建模式下重复三次。

记录表 ​

模式CPU FrameDrawCallGPU/总帧时间解释
静止实测实测实测基线
移动父节点实测实测实测Transform 传播
修改颜色实测实测实测顶点颜色更新
Label 每帧更新实测实测实测文本与几何
Label 每秒更新实测实测实测降低更新频率

实验目标不是证明某个模式固定慢多少,而是回答:

text
属性改动影响了哪一层?
DrawCall 是否变化?
DrawCall 不变时 CPU 是否仍变化?
数据更新减少后帧时间是否恢复?

十二、真实项目故障:战力数字跳动导致列表卡顿 ​

现象 ​

排行榜打开后有 100 个 Item。每个 Item 的战力数字做滚动动画,持续一秒。DrawCall 没有明显暴涨,但 CPU Frame 很高。

错误判断 ​

DrawCall 没变,所以不是渲染问题。

成本链 ​

text
100 个数值每帧变化
→ 100 个 Label.string 每帧不同
→ 文本测量和顶点数据持续更新
→ 部分 Label 宽度变化
→ 父 Layout 反复重排
→ CPU 超出帧预算

修复推导 ​

  1. 业务数值可以高频插值,但显示不必每帧变化。
  2. 固定数字区域宽度,避免推动父 Layout。
  3. 只更新屏幕内 Item。
  4. Item 离屏或动画结束后停止调度。
  5. 对比修改前后 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”之间没有必然关系?

完成推导后再展开参考答案
  1. x 首先影响 Transform;颜色首先影响颜色或相关 RenderData;SpriteFrame 首先影响 Frame 引用,可能继续影响 UV、尺寸、Texture 和批次。
  2. 可能是 Label/Layout 等 CPU 数据更新、全屏透明造成 GPU Overdraw、纹理解码上传峰值、复杂 Shader 或顶点量。分别通过冻结对应更新、缩小覆盖面积、预热资源、替换简单材质等单变量实验验证。
  3. 空 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?