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 都出在这几个环节的衔接上。比如顶点着色器输出的坐标不对,光栅化出来的片元位置就全歪了。或者片元着色器忘了处理透明度,混合结果就一团糟。
嗯,下一章咱们会深入顶点着色器,聊聊矩阵变换的那些事。到时候我会分享一个我踩过的坑——坐标系的左右手问题,差点让我怀疑人生。