Skip to content

Lesson 054|阶段复习:一次 UI 修改如何经过 Renderer 到达 GPU ​

Tags: #Creator2.x #Stage04 #Renderer #Batching #GPUDifficulty: ⭐⭐⭐⭐☆


阶段挑战:解释“点击按钮后变红”背后的全部工作 ​

业务代码只有:

ts
buttonNode.color = cc.Color.RED;

完整链路却至少涉及:

text
输入命中与回调
→ Node 颜色状态改变
→ Sprite 显示数据失效
→ Assembler 更新顶点颜色
→ Renderer 收集 Camera 可见组件
→ 与相邻对象比较批次状态
→ 写入顶点/索引缓冲
→ 提交 DrawCall
→ Vertex Shader 处理位置与颜色
→ Fragment Shader 采样 Texture
→ Blend 到 FrameBuffer

本课要求你不仅能背流程,还能通过实验判断链路中的哪一层导致了错误或性能变化。

🎯 本课目标 ​

本课串联 Lesson037~Lesson053,完成 Renderer 阶段闭环。

综合问题是:

把一个按钮的文字颜色改掉,最终为什么会影响屏幕像素、DrawCall 和 GPU 时间?


一、先把 Renderer 看成一条证据链 ​

text
Transform / Camera
    ↓
Sprite / Label / Graphics
    ↓
RenderData / Assembler
    ↓
Material / Effect / Shader
    ↓
Texture / Sampler / Render State
    ↓
排序 / 合批 / DrawCall
    ↓
Vertex Shader / Fragment Shader
    ↓
Blend / Depth / Stencil
    ↓
像素写入

这张知识树可以进一步分成四个取证层:

text
第一层:业务与场景状态
Node / Component / Transform / active / Camera
        ↓
第二层:CPU 渲染数据
RenderData / Assembler / 顶点 / UV / 颜色 / 索引
        ↓
第三层:提交与批次
Material / Effect / Pass / Texture / Render State / DrawCall
        ↓
第四层:GPU 像素
Vertex Shader / Rasterization / Fragment Shader / Blend / FrameBuffer

这四层不只是知识分类,也是排错顺序。例如按钮没有变红时,应依次证明:

text
Node 的颜色值真的改变了吗?
→ RenderData 是否带上新颜色?
→ 当前批次是否提交了新数据?
→ Shader 是否使用该颜色并写入目标?

上游证据尚未成立,就没有理由跳到下游猜 Shader。

二、贯穿案例一:修改按钮颜色 ​

代码:

ts
this.startButton.color = cc.Color.YELLOW;

高层数据流:

text
Node / Render Component 颜色状态改变
    ↓
标记顶点颜色或渲染参数需要更新
    ↓
Assembler 更新颜色数据
    ↓
Renderer 读取新的 RenderData
    ↓
检查 Material、Texture、Blend 是否仍兼容
    ↓
合批或拆分 DrawCall
    ↓
Vertex Shader 接收颜色
    ↓
Fragment Shader 与纹理颜色组合
    ↓
Blend 写入目标
    ↓
屏幕像素改变

具体是否重建完整几何,要以组件实现为准。稳定结论是“业务状态先改变,后续渲染阶段消费新状态”,而不是“设置颜色会立即让 GPU 画一次”。

如果同一帧连续把按钮设为红、绿、蓝,屏幕通常只在后续渲染时看到最终蓝色。三次脚本写入不等于同一帧出现三张画面,也不等于产生三个 DrawCall。

这一课始终区分三个容易混淆的概念:

text
修改状态 ≠ 立即绘制
对象数量 ≠ DrawCall 数量
DrawCall 数量 ≠ GPU 工作量

三、贯穿案例二:移动按钮 ​

ts
this.startButton.x += 10;
text
修改 Local Transform
    ↓
Dirty 传播
    ↓
计算 World Matrix
    ↓
Camera / Viewport 转换
    ↓
RenderData 使用新变换
    ↓
Renderer 重新提交顶点位置

如果按钮位于复杂父节点下,父子矩阵传播和 UI Layout 也可能增加 CPU 成本。


四、贯穿案例三:为什么 DrawCall 突然增加 ​

按钮修改后 DrawCall 变多,不要直接归因于“改颜色”:

text
检查是否换了 SpriteFrame / Texture
检查是否换了 Material / Shader
检查是否启用 Mask / Stencil
检查是否跨了 Camera 或 Render Layer
检查是否改变了排序
检查是否只是对象和顶点数量增加

要通过对比实验找出真正的状态边界。


五、贯穿案例四:DrawCall 不高但低端机卡 ​

text
DrawCall 低
    ↓
继续检查 GPU Frame Time
    ↓
Overdraw 可视化
    ↓
全屏透明、粒子、Mask、Shader 采样
    ↓
定位 Fill Rate 或 Fragment 瓶颈

这时减少 DrawCall 可能不是正确方向。


六、先按症状选择证据,不要按熟悉程度猜 ​

画面不对 ​

text
Node active / activeInHierarchy
Transform 和坐标系
Camera 和渲染层
Material / Texture
Blend / Depth / Stencil
排序和遮挡

DrawCall 高 ​

text
Texture 是否分散
Material / Shader 是否分散
Render State 是否变化
Mask / 特殊组件是否插入
图集和 Dynamic Atlas 是否合理

GPU 卡 ​

text
Overdraw
Fill Rate
透明覆盖面积
Fragment Shader
纹理采样和带宽
分辨率和 RenderTarget

七、第一次闭卷推导:先回答,再进入实验 ​

场景 ​

text
Canvas
└── Dialog
    ├── SemiTransparentMask
    ├── PanelBackground
    ├── Icon
    ├── MessageLabel
    └── ConfirmButton

问题:

  1. 修改 MessageLabel.string 可能触发哪些工作?
  2. 为什么 Dialog 的位置变化可能影响多个对象?
  3. 为什么 Mask 可能让批次变多?
  4. 如果 DrawCall 不高但 GPU 时间高,应先查什么?
  5. 如果 ConfirmButton 换了一张不同图集的图,可能发生什么?
完成五道题后再展开参考答案

参考答案 ​

  1. 文本测量、排版、字形缓存、RenderData 更新和纹理/批次变化。
  2. 父节点 Transform 变化会传播到子树。
  3. Mask 可能需要 Stencil、额外 Pass 和状态边界。
  4. Overdraw、透明覆盖、Shader、纹理采样、分辨率和 Fill Rate。
  5. 纹理绑定可能改变,合批机会下降,DrawCall 可能增加。

八、带着这些问题完成 RendererLab ​

  1. Node 为什么不是渲染对象?
  2. Dirty Flag 和 Renderer 有什么关系?
  3. SpriteFrame 和 Texture 的区别是什么?
  4. Assembler 生成了哪些数据?
  5. Material 和 Shader 的区别是什么?
  6. DrawCall 和 Overdraw 分别衡量什么?
  7. 为什么两个 Sprite 同图集仍可能无法合批?
  8. 为什么 Label 往往比 Sprite 更容易产生更新成本?
  9. Mask 和 Particle 为什么需要单独分析?
  10. 如何定位一个低 DrawCall 但高 GPU 时间的问题?

九、阶段综合实验:RendererLab ​

所有场景、材质、图片、Mask 和组件都通过 Creator 2.4.x 编辑器创建与配置。不手工修改 .scene、.prefab、.meta、.effect、.mtl 或生成目录。

场景结构 ​

text
Canvas
├── StaticLayer
│   └── 100 个同 Atlas Sprite
├── DynamicLayer
│   ├── 100 个 Filled Sprite
│   └── 50 个 Dynamic Label
├── SpecialLayer
│   ├── Mask
│   ├── Graphics
│   └── Particle
├── OverdrawLayer
│   └── 6 个全屏透明 Sprite
└── ControlPanel

每个 Layer 独立开关,保证控制变量。

RendererLab.ts ​

ts
const { ccclass, property } = cc._decorator;

@ccclass
export default class RendererLab extends cc.Component {
    @property(cc.Node)
    staticLayer: cc.Node = null;

    @property(cc.Node)
    dynamicLayer: cc.Node = null;

    @property(cc.Node)
    specialLayer: cc.Node = null;

    @property(cc.Node)
    overdrawLayer: cc.Node = null;

    @property([cc.Sprite])
    filledSprites: cc.Sprite[] = [];

    @property([cc.Label])
    labels: cc.Label[] = [];

    private animate = false;
    private time = 0;
    private lastText = '';

    showOnly(index: number) {
        const layers = [
            this.staticLayer,
            this.dynamicLayer,
            this.specialLayer,
            this.overdrawLayer,
        ];
        for (let i = 0; i < layers.length; i++) {
            layers[i].active = i === index;
        }
    }

    toggleAnimation() {
        this.animate = !this.animate;
    }

    update(dt: number) {
        if (!this.animate) {
            return;
        }

        this.time += dt;
        const fill = (Math.sin(this.time) + 1) * 0.5;
        for (const sprite of this.filledSprites) {
            sprite.fillRange = fill;
        }

        const text = String(Math.floor(this.time));
        if (text !== this.lastText) {
            this.lastText = text;
            for (const label of this.labels) {
                label.string = text;
            }
        }
    }
}

基线实验 ​

  1. 只开 StaticLayer。
  2. 记录 FPS、Frame Time、DrawCall 和目标设备。
  3. 确认 100 个 Sprite 使用的底层 Texture/Material。
  4. 不做任何优化,保存基线截图和数据。

Transform 实验 ​

text
只移动 StaticLayer 父节点
对比
每帧移动 100 个子节点

解释 Dirty 范围和数据更新差异。

RenderData 实验 ​

开启 DynamicLayer,比较:

text
静态 Filled/Label
→ 每帧 Filled
→ 每秒 Label
→ 每帧 Label

记录节点数相同但 CPU 工作不同。

批次实验 ​

给第 50 个 Sprite 换独立 Texture/Material,再把它移动到序列开头、中间、末尾。解释状态隔断为什么产生不同批次分段。

特殊组件实验 ​

分别启用 Mask、Graphics 动态路径和 Particle,记录它们影响 CPU、DrawCall 还是覆盖像素。

Overdraw 实验 ​

启用 1、3、6 层全屏透明 Sprite,再降低预览分辨率或缩小覆盖面积。若 DrawCall近似而帧时间随像素量变化,形成像素瓶颈证据。

十、一张 Renderer 证据表 ​

改动先看 CPU先看 DrawCall先看 GPU/像素
大量节点移动Transform/遍历可能不变通常次要
Label 每帧改字排版/字形/顶点字体页可能变化字形采样
换独立 Texture资源/数据更新纹理切批上传/带宽
独立 Material参数与状态常切批Shader/Pass
Mask 嵌套组件/几何Stencil 切换模板与覆盖
大粒子模拟可能少Overdraw
全屏模糊提交少可能 1~几次采样/带宽主导

表格是优先检查方向,不是固定结论。

十一、阶段真实故障:排行榜打开时卡一帧,滚动时持续掉帧 ​

分成两个问题 ​

打开卡一帧:

text
动态加载 Atlas
→ Texture 解码/上传
→ 200 个 Item instantiate
→ Label 首次字形
→ Layout 全量排版

滚动持续掉帧:

text
全部 200 Item 常驻
→ Content Transform 影响后代
→ 每帧世界包围盒判断
→ Label 动态排名动画
→ 每 Item 圆形 Mask

修复组合 ​

  1. 虚拟列表只保留可见 Item;
  2. 分批创建,首屏优先;
  3. 字体/资源按实际字符集预热;
  4. 固定 Label 与 Item 尺寸,减少 Layout;
  5. 移除每 Item Mask 或选择成本更可控方案;
  6. 排名动画降低更新频率;
  7. 对打开峰值与滚动稳定帧分别验收。

“降低 DrawCall”无法单独解决这两个阶段的所有问题。

十二、Stage04 故障定位树 ​

text
画面错误?
├── 不显示
│   ├── active/component/resource
│   ├── Camera group/cullingMask
│   ├── coordinate/viewport
│   └── material/depth/stencil
├── 顺序错误
│   ├── sibling/order
│   ├── Camera depth/clear
│   ├── transparency/depth
│   └── Mask context
└── 颜色/边缘错误
    ├── Blend/premultiplied alpha
    ├── Texture filter/wrap
    ├── Atlas padding/UV
    └── Shader/platform

性能错误?
├── CPU
│   ├── Transform Dirty
│   ├── Label/Layout
│   ├── Assembler/Graphics
│   └── Spine/Particle simulation
├── Submit
│   ├── Texture/Material
│   ├── Render State
│   ├── Mask/Special component
│   └── incompatible order
└── GPU
    ├── vertex count
    ├── fragment complexity
    ├── Overdraw/Fill Rate
    └── texture bandwidth/upload

十三、阶段能力验收 ​

你应该能够:

  1. 画出 Node 属性到 GPU 像素的数据流。
  2. 解释 Local 与 World Matrix 及父 Dirty 传播。
  3. 完成 World → Screen → UI Local 坐标链。
  4. 用 Camera Group/Mask 证明节点为何不可见。
  5. 区分 SpriteFrame、RenderData 与 Assembler。
  6. 解释 Material、Pass、Shader 和 Render State。
  7. 用控制变量找出一个合批隔断。
  8. 计算纹理未压缩内存数量级。
  9. 证明一个 DrawCall 低但 Overdraw 高的场景。
  10. 对特殊组件分别判断 CPU、Submit 和 GPU 成本。

十四、综合练习与推导答案 ​

练习一 ​

100 个 Sprite 同 Atlas,但每个之间插一个动态 Label。如何判断重排是否值得?

答案: 先记录批次与 CPU;确认透明遮挡允许把同类层连续组织;评估重排对组件复用和数据绑定的成本;再对比 DrawCall、Label 排版和视觉。不能只凭 Atlas 下结论。

练习二 ​

全屏特效只有两个三角形,为什么可能 GPU-bound?

答案: 顶点少,但覆盖每个屏幕像素;多采样、多 Pass、Blend 和高分辨率让 Fragment/带宽主导。

练习三 ​

父节点动画导致大量 Transform 更新,是否应把所有子节点改成世界坐标独立节点?

答案: 不应直接这样做。先缩小动画子树、避免无关节点成为后代、减少世界查询;扁平化会增加管理复杂度并破坏布局/层级语义,需实测。

练习四 ​

Dynamic Atlas 让稳定 DrawCall 降低,但首帧更卡,怎样决策?

答案: 同时量化首次合图成本和稳定收益;可将稳定资源改静态 Atlas、分批预热、限制候选;若页面很少停留,首帧代价可能不值得。

练习五 ​

一个 UI 不显示,怎样避免从 Shader 开始盲查?

答案: 按 active/组件/资源、Transform/尺寸、Camera mask/viewport、顺序/Mask、Material/Shader 的顺序逐层证明,先检查高概率且便宜的条件。

十五、版本边界与验证入口 ​

Stage04 固定以 Creator 2.4.x 渲染体系为主。内部 RenderFlow、Assembler 和批处理实现会随小版本变化;业务与实验不访问私有字段。

十、Stage04 总结 ​

text
Node 提供 Transform 和结构。
渲染组件生成 RenderData。
Assembler 把组件属性转换成几何数据。
Material / Shader / Texture / Render State 决定绘制方式。
Renderer 负责排序、合批和 DrawCall。
GPU 负责顶点、片段和像素写入。
DrawCall、Overdraw、Frame Time 必须结合分析。

最终能力是:

能从一次业务属性修改出发,沿 Node、RenderData、Material、Batch、GPU 逐层定位画面问题和性能问题。


十一、下一阶段预告 ​

Stage04 完成,下一阶段进入:

text
Stage05|Performance
Lesson055|性能问题的四个维度:CPU、GPU、Memory、IO