外观
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 Positiongl_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;
}
}观察
- 记录三种状态 DrawCall 与帧时间。
- 改变大 Sprite 的透明度但保持覆盖区域,比较。
- 缩小大 Sprite 尺寸,观察覆盖面积改变。
- 在不同分辨率设备/模拟器对比同一全屏效果。
实验结论不能只看 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、宏和平台后端必须参考当前引擎及构建目标。
- Vertex Shader 的核心职责是什么? 处理顶点属性并输出裁剪空间位置及供后续插值的数据。
- Fragment Shader 为什么常受 Overdraw 影响? 同一屏幕像素被多个图元覆盖时,多个片元可能重复执行并混合。
- 一个全屏四边形为何可能比很多小 Sprite 贵? 片元数量、采样和 Pass 可能占主导,顶点/DrawCall 少不等于像素工作少。
- 降低 RenderTexture 分辨率为什么能显著改善模糊? 宽高各减半时像素数约变为四分之一,相关采样与带宽也大幅下降。
能力验收
离开本课前,你应该能画出从 Attribute 到屏幕像素的数据流;能解释四个顶点为什么可能产生数百万次片元处理;还能通过降低覆盖面积或分辨率,证明瓶颈是否主要发生在像素阶段。
本课总结
text
Vertex Shader 处理顶点。
Fragment Shader 处理片段颜色。
顶点少不代表像素少。
透明覆盖和复杂 Shader 会放大 Fragment 成本。十一、下一课预告
text
Lesson046|Texture、Sampler、纹理格式与显存