本节摘要:片元着色器是管线的第二个可编程插槽,对光栅化产出的每个片元执行一次,用插值属性与 uniform 数据算出最终颜色。本节完整解剖一个带纹理采样的片元着色器,讲清 in/out 接口、uniform 的「一次上传逐片元共享」特性、discard 的语义与代价,以及「早深度测试」这个硬件优化如何被 alpha 丢弃悄悄破坏。本节是第 5 章全部光照技术的宿主容器。
先掂量这一站的工作量级。一个 1080p 全屏三角形,每帧约两百万个片元逐个跑一遍你写的代码;如果你的代码里有五次纹理采样,就是每帧一千万次显存访问。片元着色器因此成为多数应用的实际性能瓶颈,「GPU bound」十有八九 bound 在这里。理解它的工作模型,是性能意识的第一课。
执行模型与顶点着色器同构:逐片元并行、彼此独立、看不到邻居。片元 A 不知道片元 B 的颜色——所以模糊、泛光这类「需要看周围像素」的效果不能直接在片元着色器里做,必须绕道离屏渲染(第 3 章 FBO 一节的伏笔)。输入有两路:光栅化插值来的 in 属性(每片元不同),和 uniform 传来的常量(整次绘制共享)。输出只有一个:四分量颜色。
以「一张带光照的贴图物体」为例,这段代码浓缩了本节的全部知识点:
#version 330 core in vec3 FragPos; // 顶点着色器 out 的延续,插值后的世界位置 in vec3 Normal; // 插值后的法线 in vec2 TexCoord; // 插值后的纹理坐标 uniform sampler2D diffuseMap; // 纹理:整次绘制共享的"只读图" uniform vec3 lightPos; // 光源位置:同样逐次绘制才可能变 uniform vec3 viewPos; // 摄像机位置 out vec4 FragColor; // 法定输出:本片元的颜色 void main() { // 一、纹理采样:拿插值坐标去贴图上取色 vec3 baseColor = texture(diffuseMap, TexCoord).rgb; // 二、光照计算(简化版冯氏,第 5 章展开) vec3 N = normalize(Normal); // 插值会破坏长度,必须归一化 vec3 L = normalize(lightPos - FragPos); // 指向光源的方向 float diff = max(dot(N, L), 0.0); // 漫反射:法线与光的夹角 vec3 color = baseColor * (0.1 + diff); // 环境光兜底 + 漫反射 FragColor = vec4(color, 1.0); }
逐个接口拆开看。in 变量必须与顶点着色器的 out 变量同名匹配——链接器按名字对接,名字对不上是「黑屏 + 链接日志警告」的常见病因。uniform 是「绘制级别的全局常量」:一次 glUniform 上传,本次绘制的所有片元共享同一个值;两帧之间可以变,同一次绘制内不变。这个特性是性能设计的关键——把每片元都相同的数据放 uniform(零额外成本),把逐片元不同的数据走 in 插值(硬件自动完成),把逐实例不同的数据用第 3 章的实例化技术喂。
normalize 那一行值得单独说:插值是加权平均,两个单位向量平均后长度通常小于 1,不归一化的话 dot 结果全盘偏小、光照发暗。**「插值之后的向量必须重新归一化」**是片元着色器铁律之一,忘记它是最常见的「画面偏暗查不出原因」的病因。
片元着色器有一个特殊语句 discard:立刻放弃当前片元,不写任何缓冲。经典用途是 alpha 测试——纹理上透明的地方直接丢弃,做镂空贴图(栅栏、树叶):
vec4 texel = texture(diffuseMap, TexCoord); if (texel.a < 0.5) discard; // 半透明以下整体不要 FragColor = texel;
discard 的代价隐蔽而昂贵,涉及一个叫「早深度测试」的硬件优化:正常流程里深度测试在片元着色器之后(2.4 节的关卡序列),但硬件发现你的着色器完全没碰深度时,会把测试偷偷提前到着色器之前——被遮挡的片元直接淘汰,白跑的着色器代码全省了。一旦代码里出现 discard,硬件不敢提前了:万一片元跑完自己丢弃呢?测试只好退回原位,遮挡场景下片元着色量可能翻倍。
工程结论:能用 alpha 混合(2.4 节)表达的效果别用 discard 硬切;必须用 discard 时(比如严格镂空),尽量缩小它的作用面——把不透明部分和镂空部分拆成两次绘制。变式思路:用「纹理 mip 链逐级收紧 alpha 阈值」缓解远处镂空的闪烁,这属于进阶话题,先立路标。
GPU 的并行单位是束(wavefront/warp):几十个片元锁步执行同一条指令。束内片元在 if 语句上分道扬镳时,硬件的解法是「两边都执行,各取所需」——分支没有省时间,反而两头付钱。推论:片元着色器里的分支按束内一致性优化才有收益(比如按图元/材质分支,整束同路),按逐片元条件分支(每个片元走不同光照路径)通常更慢。这也是「着色器变体」技术存在的原因:宁可编译多个特化着色器按材质切换,也不在一个着色器里塞满 if——变体数量与切换成本的权衡,第 6 章 AZDO 一节再算总账。
片元着色器吐出颜色后,这个颜色还不能直接变成像素——它要先闯过深度测试、模板测试、混合这一串关卡。很多「画了但不显示」「透明物体次序不对」的故障其实发生在下一站,却被记在片元着色器头上。下一节把帧缓冲输出的完整关卡序列摆上解剖台,顺带解剖 z-fighting 这种发病率最高的管线疾病。
颜色已经产出,但生死未卜——帧缓冲前的关卡序列,就是片元的最终审判庭。