3、光栅化与片元处理:像素化过程、片元着色器、深度测试与模板测试
好,咱们接着往下聊。前面我们把顶点数据送进了GPU,经过各种变换,现在它们已经躺在屏幕空间里了。但这时候,它们还只是一堆孤零零的点、线、三角形。怎么变成屏幕上五彩斑斓的像素?这就是光栅化要干的事。
说白了,光栅化就是把几何数据「翻译」成像素的过程。我个人习惯把这个阶段叫做「从数学到物理的惊险一跃」。因为之前的一切都是连续的、精确的,而一旦落到像素上,就变成了离散的、近似的。
3.1 像素化过程:从三角形到像素碎片
想象一下,你有一个三角形,三个顶点坐标都算好了。光栅化器要做的,就是判断屏幕上的哪些像素点,落在这个三角形内部。
这个过程其实挺暴力的。GPU会遍历每个像素,问它:「嘿,你在三角形里面吗?」如果在,就生成一个对应的「片元」。片元你可以理解为「候选像素」——它带着颜色、深度、纹理坐标等信息,等着被进一步处理。
关键点:一个像素可能对应多个片元。比如两个三角形重叠时,同一个像素位置会生成两个片元。最终谁留下,由后面的深度测试决定。
我在项目中遇到过一个问题:屏幕边缘的三角形,光栅化时会出现「锯齿」。这是因为像素是方的,而三角形边界是斜的。嗯,这就是所谓的「走样」。解决办法?后面讲抗锯齿时会细说。
光栅化时,GPU会做几件事:
- 包围盒计算:先算出三角形的最小矩形区域,只扫描这个区域里的像素,省得全屏遍历。
- 覆盖测试:用重心坐标或者半平面法,判断像素中心是否在三角形内。
- 属性插值:对颜色、法线、纹理坐标等,按重心坐标做线性插值。
你想想看,一个三角形三个顶点,颜色分别是红、绿、蓝。中间某个像素的颜色怎么算?就是根据它离三个顶点的距离,按比例混合。这就是插值。
// 伪代码:重心坐标插值
// 已知三角形三个顶点 v0, v1, v2,以及像素的重心坐标 (u, v, w)
// u + v + w = 1.0
float interpolatedColor = u * color0 + v * color1 + w * color2;
小技巧:插值是在屏幕空间做的,但如果你做透视投影,直接线性插值会出错。因为透视下深度是非线性的。这时候要用「透视校正插值」——先插值1/w,再反算回去。很多新手在这踩坑,我当年也是。
3.2 片元着色器:每个像素的「命运裁决者」
光栅化之后,每个片元都会进入片元着色器。你可以把它理解成「每个像素的专属小程序」。它决定这个像素最终显示什么颜色。
片元着色器能做的事情太多了:
- 计算光照(漫反射、高光、环境光)
- 采样纹理(从贴图上读取颜色)
- 做各种特效(法线贴图、阴影、反射)
- 甚至丢弃片元(比如做镂空效果)
这里有个重要的概念:片元着色器是逐片元执行的。如果你的屏幕分辨率是1920x1080,那就是200多万个片元。每个片元都要跑一遍你的着色器代码。所以,片元着色器的效率至关重要。
注意:不要在片元着色器里做太重的计算。比如循环、分支、复杂的数学函数。我曾经见过有人把物理模拟放在片元着色器里跑,结果帧率直接掉到个位数。记住,片元着色器里每多一条指令,就是乘以200万倍的代价。
一个典型的片元着色器长这样:
// GLSL 片元着色器示例
#version 330 core
in vec2 TexCoord;
in vec3 Normal;
in vec3 FragPos;
uniform sampler2D ourTexture;
uniform vec3 lightPos;
uniform vec3 viewPos;
out vec4 FragColor;
void main()
{
// 从纹理采样
vec4 texColor = texture(ourTexture, TexCoord);
// 简单光照计算
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
// 最终颜色
FragColor = texColor * diff;
}
这段代码做了三件事:采样纹理、算漫反射光照、输出颜色。实际项目中,你可能还要加高光、阴影、环境光遮蔽等等。但核心逻辑不变——每个片元独立计算自己的颜色。
3.3 深度测试:谁在前面谁留下
片元着色器算完颜色后,片元还不能直接上屏。因为场景里可能有多个物体,它们有前后遮挡关系。深度测试就是解决这个问题的。
每个片元都有一个深度值(Z值),表示它离摄像机的距离。深度测试的逻辑很简单:
- 比较当前片元的深度值和深度缓冲区里已有的值
- 如果当前片元更近(深度值更小),就覆盖掉旧的
- 如果更远,就丢弃
为什么会这样?因为远处的物体被近处的挡住了,你看不到它,没必要画。
深度测试的默认规则:GL_LESS。即深度值更小的片元通过测试。你可以改成GL_LEQUAL、GL_GREATER等,但默认的LESS最符合直觉。
我建议你注意一个坑:深度冲突。当两个物体的深度值非常接近时,GPU无法判断谁前谁后,就会出现闪烁。这通常发生在两个平面重叠或者距离极近的情况下。解决办法?
- 手动调整物体的位置,避免完全重叠
- 使用多边形偏移(glPolygonOffset)
- 或者用更高精度的深度缓冲区(比如24位或32位)
我曾经在做一个大场景渲染时,地面和墙壁的接缝处疯狂闪烁。查了半天,发现是深度精度不够。换成24位深度缓冲区后,问题就解决了。
3.4 模板测试:更精细的「遮罩」控制
深度测试只能管前后关系。但有些时候,你需要更精细的控制——比如只渲染某个形状内部的像素,或者实现镜面反射效果。这时候就要用模板测试。
模板测试的工作原理是:
- 每个像素有一个模板值(通常8位,范围0-255)
- 你可以设置比较规则(等于、不等于、大于等)
- 只有满足条件的片元才通过
- 通过后还可以更新模板值
听起来有点抽象?我举个实际例子:
假设你要做一个镜子效果。镜子本身是一个矩形。你先渲染镜子,把镜子区域的模板值设为1。然后渲染镜子里反射的物体,但只渲染模板值等于1的区域。这样反射物体就只出现在镜子范围内,不会跑到外面去。
// 模板测试的典型流程
// 第一步:写入模板值
glEnable(GL_STENCIL_TEST);
glStencilFunc(GL_ALWAYS, 1, 0xFF); // 总是通过,写入1
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); // 通过时替换模板值
// 渲染镜子物体...
// 第二步:使用模板值做遮罩
glStencilFunc(GL_EQUAL, 1, 0xFF); // 只渲染模板值等于1的区域
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP); // 不修改模板值
// 渲染反射物体...
实用技巧:模板测试经常和深度测试配合使用。比如做阴影体积(Shadow Volume)时,先用深度测试确定可见性,再用模板测试标记阴影区域。两者结合,能实现很多酷炫效果。
模板测试的常见应用场景:
- 镜面反射(只反射到镜子区域)
- 轮廓描边(先渲染物体,再在边缘画线)
- 局部后处理(只对某个区域做模糊或颜色调整)
- 阴影体积(标记被阴影覆盖的区域)
嗯,这里要注意:模板测试虽然强大,但不要滥用。每多一个测试,GPU就多一次判断。而且模板缓冲区的读写也有开销。我见过有人为了省事,把所有遮罩逻辑都堆在模板测试里,结果性能反而下降了。
总结一下这三个阶段的流程:
| 阶段 | 输入 | 输出 | 关键操作 |
|---|---|---|---|
| 光栅化 | 顶点数据(三角形) | 片元(带插值属性) | 覆盖测试、属性插值 |
| 片元着色器 | 片元(属性、纹理坐标) | 带颜色的片元 | 光照计算、纹理采样 |
| 深度/模板测试 | 带颜色的片元 | 最终像素 | 深度比较、模板遮罩 |
说白了,光栅化把几何变成片元,片元着色器给片元上色,深度和模板测试决定哪些片元能最终显示。这三步环环相扣,缺一不可。理解了这个流程,你就掌握了GPU渲染管线的核心。