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渲染管线的核心。