核心结论
- 计算出
gl_FragColor之后,并不代表这个颜色一定会显示到屏幕上。 - GPU 后面还会经过一串
逐片元操作,Per-Fragment Operations,只有通过这些步骤,这个片元才会真正写入帧缓冲。 - 你可以先记住主线:
text
gl_FragColor
-> 片元丢弃判断
-> 裁剪/裁切测试
-> 模板测试
-> 深度测试
-> 混合
-> 写入颜色/深度/模板缓冲
-> 最终显示到屏幕
先纠正一个关键认知
gl_FragColor是"片元着色器给出的候选颜色"。- 它不是"最终屏幕颜色"。
- 真正最终显示出来的结果,还要看:
- 这个片元有没有被丢弃
- 有没有被深度测试挡住
- 有没有被模板测试拦住
- 是否要和已有颜色混合
- 当前写入掩码是否允许写入
所以:
片元着色器负责算颜色后续固定管线负责决定这个颜色能不能落盘
1. 片元可能先被丢弃
即使片元着色器已经执行完,片元仍然可能被直接丢掉。
常见有两种情况:
- 你在片元着色器里手动
discard - 后续测试不通过
例如透明裁剪里常见:
glsl
if (alpha < 0.1) {
discard;
}
gl_FragColor = vec4(color, alpha);
这表示:
- 即使本来算出了颜色
- 只要执行了
discard - 这个片元就不会继续后续流程
所以第一个重点是:
算出 gl_FragColor不等于一定进入帧缓冲
2. 裁切测试,Scissor Test
如果启用了裁切测试:
js
gl.enable(gl.SCISSOR_TEST);
gl.scissor(x, y, width, height);
GPU 会检查:
- 当前片元是否落在指定矩形区域内
如果不在这个矩形里:
- 直接丢弃
- 后面深度测试、混合都不会走
它的作用是:
- 限制绘制只发生在屏幕某个局部区域
- 常用于分屏、局部刷新、UI 子区域绘制
所以这一步的本质是:
先看这个片元是否在允许绘制的区域里
3. 模板测试,Stencil Test
如果启用了模板测试:
js
gl.enable(gl.STENCIL_TEST);
GPU 会拿当前片元对应位置的模板缓冲值,和你的模板规则比较。
比如你可以设置:
- 只有模板值等于某个数字的地方才允许画
- 或者只在某些区域禁止画
如果模板测试失败:
- 片元被丢弃
- 颜色不会写入
模板测试常见用途:
- 描边
- 遮罩
- 镜面/传送门效果
- 限制某区域可见
所以这一步可以理解成:
这个片元所在的位置,逻辑上允许绘制吗
4. 深度测试,Depth Test
这是最关键的一步之一。
如果启用了深度测试:
js
gl.enable(gl.DEPTH_TEST);
GPU 会比较:
- 当前片元的深度值
- 和深度缓冲里该位置已有的深度值
如果当前片元更靠前,通常测试通过; 如果它被前面的物体挡住,通常测试失败。
例如默认常见规则是:
js
gl.depthFunc(gl.LESS);
意思是:
- 新片元深度更小,才通过
如果深度测试失败:
- 这个片元即使颜色算好了,也不会显示
所以深度测试的本质是:
这个片元是不是当前看到的最前面那个
这就是为什么 3D 场景里,后面的物体不会盖到前面的物体上。
5. 深度缓冲可能会更新
如果片元通过了深度测试,而且深度写入是开启的:
js
gl.depthMask(true);
那么 GPU 不只是写颜色,还会把这个片元的深度值写入深度缓冲。
这样后面再来的片元就能拿它做比较。
所以深度相关其实有两件事:
深度测试:决定这个片元能不能画深度写入:决定这个片元会不会更新深度缓冲
这两个概念要分清。
例如做透明物体时,经常会:
- 开启深度测试
- 但关闭深度写入
6. 颜色不会立刻直接覆盖,可能先混合
如果启用了混合:
js
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
GPU 不会简单把 gl_FragColor 直接写进去,而是会把:
- 新片元颜色,
source - 帧缓冲中原有颜色,
destination
按混合公式合成一个新颜色。
最典型的透明混合就是:
text
finalColor = srcColor * srcAlpha + dstColor * (1 - srcAlpha)
所以对于半透明物体来说:
gl_FragColor只是输入颜色- 最终屏幕颜色是"新旧颜色混合后的结果"
这就是透明效果的根本来源。
7. 颜色写入还受写掩码控制
即使通过了所有测试,也不一定所有通道都能写。
例如:
js
gl.colorMask(true, false, false, true);
表示:
- 允许写入
R - 不允许写入
G - 不允许写入
B - 允许写入
A
类似地也有:
js
gl.depthMask(...)
gl.stencilMask(...)
所以 GPU 在真正写缓冲时,还会看:
- 哪些颜色通道能写
- 深度能不能写
- 模板能不能写
这一步可以理解成:
即使片元合法,最终写多少内容还受权限控制
8. 写入帧缓冲
通过所有测试、混合和掩码处理后,GPU 才会把结果写入帧缓冲,Framebuffer。
帧缓冲里通常有几类数据:
颜色缓冲:最终颜色深度缓冲:深度值模板缓冲:模板值
如果你在默认帧缓冲上绘制:
- 最终会显示到屏幕
如果你绑定的是离屏帧缓冲,FBO:
- 结果可能写入纹理或渲染缓冲
- 后续再作为贴图继续使用
所以"写入帧缓冲"不一定等于"马上显示在显示器上"。
9. 如果启用了多重采样,还会有采样解析
如果使用了抗锯齿,尤其是多重采样,MSAA,那么情况会更细一点:
- 一个像素内部可能有多个采样点
- 片元结果可能会对多个采样进行测试和记录
- 最后还会有一次
resolve,把多个采样合成一个最终像素颜色
你可以先简单理解为:
- 光栅化和逐片元测试可能不是"每像素一个样本"那么简单
- 抗锯齿时,一个像素内部可能有更细粒度的数据
不过在初学阶段,记住主线就够了:
最终仍然要合成成帧缓冲里的像素颜色
10. 最后浏览器把帧缓冲内容显示出来
如果你渲染的是默认帧缓冲,那么这一帧绘制完成后:
- 浏览器会在合适时机把颜色缓冲内容提交到屏幕
- 用户最终看到的,就是这一帧的结果
所以从逻辑上可以分两层:
- WebGL 负责把结果写进当前帧缓冲
- 浏览器 / 显示系统负责把帧缓冲内容显示到屏幕
用一个最直观的例子串起来
假设你画了一个半透明红色三角形,片元着色器输出:
glsl
gl_FragColor = vec4(1.0, 0.0, 0.0, 0.5);
GPU 后面会做的事大致是:
- 看这个片元有没有被
discard - 看它是否落在
scissor允许区域里 - 看模板测试是否通过
- 看深度测试是否通过,是否被前面的物体挡住
- 如果启用了混合,就把这个半透明红色和背景颜色混合
- 按颜色掩码决定哪些通道真正写入
- 把结果写入颜色缓冲,必要时也更新深度/模板缓冲
- 最终显示到屏幕
所以你屏幕上看到的那个红色,往往不是 vec4(1,0,0,0.5) 原样显示,而是和背景合成后的颜色。
要特别区分的 3 件事
gl_FragColor:片元着色器算出来的候选输出最终片元结果:经过深度、模板、混合后的结果最终像素显示:写入帧缓冲后,由屏幕显示出来的结果
这三者不是一回事。
最常见的几个误区
- 误区1:
gl_FragColor一算出来就上屏:不是,后面还有测试、混合、写缓冲。 - 误区2:片元着色器决定一切:不是,固定管线后处理同样关键,尤其是深度测试和混合。
- 误区3:透明只靠 alpha 就能显示正确:不是,还必须正确开启
BLEND,通常还要注意绘制顺序。 - 误区4:片元通过深度测试后一定会完全覆盖旧颜色:不一定,如果开启混合,结果可能只是部分叠加。
- 误区5:写入颜色和写入深度是同一件事:不是,它们是两套独立控制。
总结
- 计算出
gl_FragColor之后,GPU 还会对这个片元做一系列逐片元操作,例如裁切、模板测试、深度测试、混合和写掩码控制,只有通过这些步骤后,结果才会真正写入帧缓冲并最终显示到屏幕上。
最短记忆版
gl_FragColor之后主线就是:
text
discard/裁切 -> 模板测试 -> 深度测试 -> 混合 -> 写入帧缓冲 -> 显示
计算得到gl_Position(顶点着色器)之后续GPU流程,请看之前的这篇文章