Skip to content

Lesson 045|Vertex Shader 和 Fragment Shader 分别做什么? ​

Tags: #Creator2.x #Stage04 #VertexShader #FragmentShader #GPUDifficulty: ⭐⭐⭐⭐☆


真实问题:全屏模糊只有一个 DrawCall,为什么 GPU 仍然很慢 ​

后处理模糊用一个全屏四边形绘制,Profiler 显示 DrawCall 只增加 1。低端设备帧时间却明显上涨。

因为 DrawCall 只说明提交次数。全屏 Fragment Shader 会对大量像素执行,并且多次采样周围纹理:

text
屏幕 1920×1080
× 每像素若干采样
× 多个模糊 Pass
→ 大量片元和纹理带宽

Vertex Shader 只处理四个顶点很轻,不代表 Fragment 阶段轻。

🎯 本课目标 ​

本课建立 GPU 渲染管线最小模型:

text
顶点输入
→ Vertex Shader
→ 图元装配和光栅化
→ Fragment Shader
→ 颜色写入

二、Vertex Shader ​

Vertex Shader 的首要硬性输出不是“世界坐标”,而是供裁剪和投影使用的位置:

text
Local Position
→ Model / World Matrix
→ View Matrix
→ Projection Matrix
→ Clip Position

gl_Position 保存裁剪空间位置。随后 GPU 才进行裁剪、透视除法和 Viewport 映射。Vertex Shader 决定三角形落在屏幕哪里,但不会遍历三角形覆盖的每个像素。

它还把 UV、颜色等数据交给光栅化阶段。若三角形三个顶点分别为红、绿、蓝,内部片元会得到按位置插值后的连续颜色,而不是只有三种离散颜色。

Vertex Shader 通常处理:

  • 顶点位置。
  • 世界、视图和投影变换。
  • UV、颜色等属性传递。
  • 顶点动画或变形。

简化伪代码:

glsl
void main() {
    gl_Position = u_mvp * vec4(a_position, 1.0);
    v_uv = a_uv;
    v_color = a_color;
}

它主要以“每个顶点”为单位执行。


三、Fragment Shader ​

光栅化阶段根据三角形覆盖范围生成片元,再调用 Fragment Shader:

text
顶点 UV / 颜色
→ 光栅化插值
→ 当前片元 UV / 颜色
→ 采样 Texture
→ 与顶点色、Uniform 等组合
→ 输出候选颜色

之所以叫“候选颜色”,是因为它还可能经过 Depth、Stencil、Blend 和颜色写入掩码。Fragment Shader 执行过,不代表结果一定成为最终像素。

纹理采样也不是普通数组读取。GPU 需要结合 UV、Sampler、mipmap 和缓存取得数据;采样次数、访问连续性、纹理格式和带宽都会影响成本。

Fragment Shader 计算覆盖到的片段颜色:

glsl
void main() {
    vec4 texColor = texture2D(u_texture, v_uv);
    gl_FragColor = texColor * v_color;
}

它主要以“每个片段”为单位执行。一个大面积半透明 Sprite 可能产生大量 Fragment 计算,即使顶点数量很少。


四、为什么 Overdraw 与 Fragment Shader 有关 ​

多个透明层叠加时:

text
背景片段
→ 半透明面板片段
→ 图标片段
→ 文字片段
→ 特效片段

同一屏幕像素可能被多次计算和混合。Shader 越复杂、屏幕分辨率越高,成本越明显。


五、顶点阶段和像素阶段的优化方向 ​

Vertex 侧 ​

  • 减少不必要顶点。
  • 避免复杂变形无条件更新。
  • 合理使用实例化或批处理。

Fragment 侧 ​

  • 减少全屏透明覆盖。
  • 降低 Shader 分支和采样次数。
  • 控制纹理尺寸和过滤成本。
  • 减少不必要的后处理。

六、Uniform、Attribute、Varying ​

可以粗略区分:

text
Attribute:每个顶点不同的输入
Uniform:一批顶点或一次绘制共享的参数
Varying:Vertex Shader 传给 Fragment Shader 的插值数据

理解这些输入有助于判断一个参数改变会不会影响批次和绘制。

三者的数据粒度不同:

text
Attribute:同一次 DrawCall 中,每个顶点可以不同
Uniform:同一次绘制范围内通常共享
Varying:由顶点输出,在三角形内部插值

如果 100 个 Sprite 的颜色写在兼容的顶点 Attribute 中,它们可以在同一批次携带不同颜色;如果颜色只能作为一次绘制共享的 Uniform,而 100 个对象值都不同,Renderer 就需要其他数据方案,或拆成不同提交。


七、案例:全屏模糊为什么贵 ​

全屏模糊可能需要:

text
读取大面积纹理
多个采样点
多次 Pass
中间 RenderTarget

即使只有一个全屏四边形,Fragment Shader 也可能执行数百万次。DrawCall 少不代表像素计算少。

做一个数量级估算。1920×1080 约有 207 万像素。假设横向和纵向两个 Pass,每个片元各采样 9 次:

text
207 万像素
× 2 个 Pass
× 9 次采样
≈ 3732 万次纹理采样

这还没有计算 RenderTexture 写入、两次全屏覆盖和最终合成。数字不是精确指令数,但足以说明为什么“只有两个 DrawCall”仍可能超过低端机带宽预算。

如果宽高各降到一半,像素数约变成四分之一。同样算法的主要片元和采样工作会显著下降,这就是降采样模糊常见的收益来源。


八、常见误区 ​

误区一:Vertex Shader 只负责移动图片 ​

它还可以处理顶点属性、变形和数据传递。

误区二:一个 Sprite 只有四次 Fragment 计算 ​

Fragment 以覆盖区域和采样结果为基础,数量远多于顶点。

误区三:Shader 代码短就一定快 ​

采样、分支、精度和覆盖面积都会影响实际成本。

误区四:GPU 计算和 CPU 没关系 ​

CPU 仍需准备数据、设置状态和提交绘制。



九、深入推导:顶点与片元处理的“数量”为什么不同 ​

假设一个全屏四边形:

text
Vertex Shader:大约处理 4 个顶点
Rasterizer:生成覆盖屏幕的片元
Fragment Shader:可能处理数百万片元

若有大量小三角形:

  • Vertex 工作可能显著增加;
  • 重叠区域仍产生大量 Fragment;
  • 被深度/模板提前拒绝的效率依平台和状态而异。

优化前必须判断瓶颈主要在顶点、片元、纹理带宽还是 CPU 提交。

四个顶点并不是直接变成四个像素。两个三角形经过图元装配后,光栅化阶段会判断它们覆盖哪些屏幕采样点,并为覆盖区域生成片元。一个铺满 1920×1080 屏幕的四边形,虽然只有四个顶点,却可能形成约 207 万个基础片元;如果它被绘制四遍,覆盖数量级就接近 829 万。

片元也不严格等于最终屏幕像素。片元还可能因为裁剪、Depth、Stencil 或 Shader 中的丢弃而不写入颜色;开启多重采样、Overdraw 和多 Pass 时,同一最终像素又可能对应多次片元工作。因此性能分析应该问“生成并处理了多少片元”,而不是把屏幕像素数当作固定工作量。

十、数据怎样从 Vertex 流向 Fragment ​

高层关系:

text
Attribute:每顶点输入,如 position、uv、color
Uniform:一批绘制共享参数,如矩阵、时间、纹理
Varying:Vertex 输出,经光栅化插值后交给 Fragment
Texture/Sampler:Fragment 常用的采样来源与规则

一个三角形三个顶点各有 UV,内部片元的 UV 由光栅化阶段插值,因此 Fragment 能在连续区域采样纹理。

十一、Creator 2.4.x 实验:覆盖面积比顶点数更重要吗 ​

使用 Creator 编辑器的 Sprite、Material 和 Profiler。不要直接编辑 .effect 或 .mtl 序列化文件。

场景准备 ​

创建两组使用相同 SpriteFrame/材质的节点:

text
SmallSprites:100 个 32×32
LargeSprites:10 个覆盖大面积的 1000×1000 半透明 Sprite

保证两组可分别启用。

ShaderStageProbe.ts ​

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

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

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

    showSmall() {
        this.smallGroup.active = true;
        this.largeGroup.active = false;
    }

    showLarge() {
        this.smallGroup.active = false;
        this.largeGroup.active = true;
    }

    showBoth() {
        this.smallGroup.active = true;
        this.largeGroup.active = true;
    }
}

观察 ​

  1. 记录三种状态 DrawCall 与帧时间。
  2. 改变大 Sprite 的透明度但保持覆盖区域,比较。
  3. 缩小大 Sprite 尺寸,观察覆盖面积改变。
  4. 在不同分辨率设备/模拟器对比同一全屏效果。

实验结论不能只看 Sprite 数。大面积重叠可能让 Fragment 工作主导。

十二、为什么透明像素也可能执行 Fragment Shader ​

纹理像素 Alpha 为 0,不代表 GPU 在执行 Shader 之前就知道它无贡献。通常仍需:

text
生成片元
→ 执行 shader / 采样
→ 得到 alpha
→ 混合或 discard

是否能早期剔除取决于几何、深度/模板和 Shader。用巨大透明 PNG 包围一个小图案,会制造无效覆盖。

十三、真实项目故障:弹窗背景模糊让高端机也掉帧 ​

实现 ​

每帧把场景渲染到全分辨率 RenderTexture,执行横向与纵向各 9 次采样,再叠加半透明遮罩。

成本链 ​

text
额外场景渲染
→ 全分辨率纹理写入
→ 两个全屏 Pass
→ 每片元多次纹理读取
→ 中间 RenderTexture 带宽与内存

优化 ​

  • 弹窗打开时捕获一次,而不是背景静止仍每帧更新;
  • 降采样后模糊,再放大合成;
  • 减少采样核或使用更高效算法;
  • 限制效果分辨率和范围;
  • 低端机使用暗色遮罩降级;
  • 用 GPU Frame Time 和目标机视觉验收。

十四、Shader 故障诊断 ​

编辑器正常、原生平台变黑 ​

检查编译日志、精度、宏分支、纹理格式、平台支持和未初始化 Uniform。不要假设所有图形后端语法与精度一致。

边缘出现锯齿或采样缝 ​

检查 UV、Filter、Wrap、Atlas padding、半像素/精度和采样范围。

一个 DrawCall 仍很慢 ​

测量覆盖像素、采样次数、Pass 数、分辨率和 RenderTexture 带宽。

十五、练习、推导答案与版本边界 ​

本课讲通用 GPU 管线,并以 Creator 2.4.x Material/Effect 为语境。具体 GLSL、宏和平台后端必须参考当前引擎及构建目标。

  1. Vertex Shader 的核心职责是什么? 处理顶点属性并输出裁剪空间位置及供后续插值的数据。
  2. Fragment Shader 为什么常受 Overdraw 影响? 同一屏幕像素被多个图元覆盖时,多个片元可能重复执行并混合。
  3. 一个全屏四边形为何可能比很多小 Sprite 贵? 片元数量、采样和 Pass 可能占主导,顶点/DrawCall 少不等于像素工作少。
  4. 降低 RenderTexture 分辨率为什么能显著改善模糊? 宽高各减半时像素数约变为四分之一,相关采样与带宽也大幅下降。

能力验收 ​

离开本课前,你应该能画出从 Attribute 到屏幕像素的数据流;能解释四个顶点为什么可能产生数百万次片元处理;还能通过降低覆盖面积或分辨率,证明瓶颈是否主要发生在像素阶段。

本课总结 ​

text
Vertex Shader 处理顶点。
Fragment Shader 处理片段颜色。
顶点少不代表像素少。
透明覆盖和复杂 Shader 会放大 Fragment 成本。

十一、下一课预告 ​

text
Lesson046|Texture、Sampler、纹理格式与显存