2、图形管线:渲染管线概述、顶点着色器、片元着色器、光栅化、逐片元操作

好,咱们今天聊聊图形管线。这玩意儿说白了,就是 GPU 把 3D 数据变成屏幕上 2D 像素的整套流水线。我刚开始学 WebGL 的时候,总觉得这东西太抽象,不就是画个三角形吗?后来踩了不少坑才明白——不理解管线,你连一个 bug 都调不明白。

2.1 渲染管线概述

图形管线,你可以把它想象成一条工厂流水线。原料是顶点数据,成品是屏幕上的像素。这条流水线分几个固定阶段,每个阶段干自己的活。

整个管线大致分这么几步:

  • 顶点处理——把 3D 坐标变成裁剪坐标
  • 图元装配——把顶点连成三角形、线段
  • 光栅化——把三角形变成像素碎片
  • 片元处理——给每个像素算颜色
  • 逐片元操作——深度测试、混合、最终输出

嗯,这里要注意:WebGL 里,顶点着色器和片元着色器是我们可以编程的。中间的光栅化是 GPU 硬件干的,你改不了。我见过不少新手想自己实现光栅化,其实没必要,GPU 比你快得多。

核心要点:顶点着色器决定「画在哪」,片元着色器决定「画成啥」。两者缺一不可。

2.2 顶点着色器

顶点着色器是管线的第一站。它的输入是每个顶点的属性——位置、颜色、法线、纹理坐标等等。输出只有一个:gl_Position,也就是裁剪空间里的坐标。

我个人习惯把顶点着色器看作「坐标转换器」。模型坐标 -> 世界坐标 -> 观察坐标 -> 裁剪坐标,这一整套矩阵运算都在这里完成。

看个最简单的例子:

// 顶点着色器
attribute vec4 a_position;
uniform mat4 u_modelViewMatrix;
uniform mat4 u_projectionMatrix;

void main() {
    gl_Position = u_projectionMatrix * u_modelViewMatrix * a_position;
}

我在项目中遇到过一个问题:顶点数量太多,着色器里做了大量矩阵运算,帧率直接掉到 20。后来我把矩阵乘法挪到了 CPU 端预计算,只传一个合并后的矩阵给着色器,帧率立马回到 60。

小技巧:顶点着色器是逐顶点执行的。如果你的模型有 10 万个顶点,那这段代码就跑 10 万次。尽量少做复杂计算,能预处理的就预处理。

2.3 光栅化

光栅化,说白了就是把三角形「拆碎」成像素。GPU 拿到三个顶点后,会判断哪些像素落在三角形内部,然后生成一堆「片元」。

你想想看,一个三角形可能覆盖 100 个像素,那光栅化阶段就会生成 100 个片元。每个片元都带着插值后的数据——颜色、深度、纹理坐标等等。

为什么会需要插值?因为顶点着色器只算了三个顶点的值,中间像素的值得靠算。比如三角形三个顶点的颜色分别是红、绿、蓝,那中间某个像素的颜色就是这三个颜色的加权平均。这个权重取决于像素离三个顶点的距离。

注意:光栅化阶段我们没法直接编程。但你可以通过顶点数据间接控制它。比如你把一个三角形拉得特别大,它覆盖的像素就多,片元着色器的调用次数就暴增。我曾经把一个全屏四边形扔给片元着色器做后处理,结果手机发烫——片元着色器跑了 200 多万次。

2.4 片元着色器

片元着色器是管线的「画师」。每个片元进来,它算出一个颜色,写到 gl_FragColor 里。

片元着色器能做的事太多了:纹理采样、光照计算、法线映射、阴影……基本上你看到的视觉效果,都是在这里算出来的。

看个最简单的:

// 片元着色器
precision mediump float;
uniform vec4 u_color;

void main() {
    gl_FragColor = u_color;
}

嗯,这里有个坑:片元着色器的精度。我刚开始写的时候,所有变量都用 highp,结果在低端手机上画面闪烁。后来改成 mediump,问题解决了。精度越高,性能越差,但画面更细腻。你得根据目标设备权衡。

精度限定符 适用场景 性能影响
lowp 颜色、简单光照 最快
mediump 纹理坐标、法线 适中
highp 位置、深度计算 最慢

避坑指南:我曾经在片元着色器里写了一个循环,遍历 100 个光源。结果在手机上直接卡死。后来我才知道,移动端 GPU 对循环特别敏感,尤其是循环次数不确定的情况。能展开就展开,能预计算就预计算。

2.5 逐片元操作

片元着色器算完颜色后,还没完。GPU 还要做一系列测试和操作,才能决定这个像素到底要不要显示。

主要步骤包括:

  • 裁剪测试——像素在不在裁剪窗口内
  • 深度测试——这个像素是不是被前面的物体挡住了
  • 模板测试——按模板遮罩决定是否丢弃
  • 混合——新颜色和已有颜色怎么叠加
  • 抖动——颜色精度不够时的伪随机处理

这里面最常用的是深度测试和混合。深度测试决定了「谁在前面谁在后面」,混合决定了「透明物体怎么叠加」。

我举个例子:你画一个半透明的玻璃杯,如果不开启混合,后面的物体就被完全挡住了。开启混合后,GPU 会把玻璃杯的颜色和背景颜色按透明度加权平均。

// 开启深度测试
gl.enable(gl.DEPTH_TEST);

// 开启混合
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

注意:深度测试和混合的顺序很重要。一般来说,先画不透明的物体,再画透明的物体。而且透明物体要从远到近画。我曾经偷懒没排序,结果透明物体渲染出来一片混乱——后面的玻璃杯盖住了前面的。

2.6 总结

图形管线其实不复杂。你记住一句话就行:顶点着色器定位置,光栅化拆像素,片元着色器算颜色,逐片元操作做筛选

我做了这么多年 WebGL,发现大部分渲染 bug 都出在这几个环节的衔接上。比如顶点着色器输出的坐标不对,光栅化出来的片元位置就全歪了。或者片元着色器忘了处理透明度,混合结果就一团糟。

嗯,下一章咱们会深入顶点着色器,聊聊矩阵变换的那些事。到时候我会分享一个我踩过的坑——坐标系的左右手问题,差点让我怀疑人生。