3. 顶点着色器:顶点变换、坐标空间与属性传递
好,咱们进入顶点着色器这个环节。说实话,这是整个图形管线里我最喜欢聊的部分之一。为什么?因为它直接决定了你屏幕上那个三角形到底长什么样、在哪儿、怎么动。
顶点着色器,说白了就是每个顶点都要执行一遍的小程序。你想想看,一个模型少说几千个顶点,多的上百万个。每个顶点都得经过它处理一遍。所以它的效率,直接决定了你的帧率。
3.1 顶点变换:从模型到屏幕的旅程
一个顶点从模型文件里读出来,到最终显示在屏幕上,要经历四次坐标变换。我习惯把这四步叫做「四重门」。
| 变换阶段 | 输入空间 | 输出空间 | 变换矩阵 |
|---|---|---|---|
| 模型变换 | 模型空间 | 世界空间 | 模型矩阵 (Model Matrix) |
| 视图变换 | 世界空间 | 视图空间 | 视图矩阵 (View Matrix) |
| 投影变换 | 视图空间 | 裁剪空间 | 投影矩阵 (Projection Matrix) |
| 视口变换 | 裁剪空间 | 屏幕空间 | 视口矩阵 (Viewport Matrix) |
嗯,这里要注意:视口变换通常是硬件自动完成的,我们在顶点着色器里只需要把顶点变换到裁剪空间就行。
3.2 模型空间 → 世界空间
模型空间,也叫局部空间。每个模型都有自己的原点。比如一个茶壶模型,它的原点可能在壶底中心。你加载模型时,所有顶点坐标都是相对于这个原点的。
但游戏世界里不可能所有茶壶都挤在原点吧?所以我们需要模型变换。这个变换矩阵通常包含平移、旋转、缩放。我在项目中遇到过一个问题:美术同学给的模型缩放是(1, 1, 1),但导入引擎后茶壶变得巨大无比。查了半天,原来是模型文件里有个隐藏的缩放因子。
核心公式:
// 模型变换
vec4 worldPos = modelMatrix * vec4(localPos, 1.0);
3.3 世界空间 → 视图空间
世界空间里,所有物体都在同一个坐标系下。但摄像机在哪儿?朝哪儿看?这就是视图变换要解决的问题。
视图变换的本质,就是把整个世界「摆到」摄像机面前。你可以想象成:摄像机不动,整个世界在动。实际上,视图矩阵就是摄像机的逆变换矩阵。
我曾经调试过一个阴影贴图的问题,阴影总是偏移。后来发现是视图矩阵的构建顺序搞反了——先平移后旋转,应该先旋转后平移。这种坑,踩过一次就记住了。
我的习惯:构建视图矩阵时,用 LookAt 函数。传入摄像机位置、目标点、上向量。简单、直观、不容易出错。
3.4 视图空间 → 裁剪空间
这是顶点变换中最关键的一步。投影变换有两种:透视投影和正交投影。
透视投影模拟人眼效果——近大远小。正交投影则保持物体大小不变,常用于UI、2D游戏或阴影贴图。
投影矩阵的输出是裁剪空间。为什么叫裁剪空间?因为在这个空间里,GPU会判断顶点是否在视锥体内。不在的顶点直接扔掉,省得浪费后续计算。
注意:投影矩阵的 w 分量会被赋值为 -z(视图空间的深度值)。这个 w 在后面透视除法时会用到。很多初学者会忽略 w 分量的意义,导致顶点位置计算错误。
// 顶点着色器核心代码
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoord;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
// 模型变换
vec4 worldPos = model * vec4(aPos, 1.0);
// 视图变换
vec4 viewPos = view * worldPos;
// 投影变换
gl_Position = projection * viewPos;
// 传递顶点属性到片元着色器
// ... 后面会讲
}
3.5 顶点属性传递:不仅仅是位置
顶点着色器不只是算位置。它还要把各种属性传递给片元着色器。这些属性包括:法线、纹理坐标、颜色、切线等等。
我见过不少新手把大量数据塞给片元着色器,结果导致带宽爆炸。你想想看,每个像素都要读取这些插值后的数据,数据量大了帧率直接腰斩。
属性传递的原则:
- 只传递片元着色器真正需要的数据
- 能用低精度就用低精度(比如法线用 half3 而不是 float3)
- 考虑用顶点属性压缩(比如法线用 snorm 格式)
法线变换是个容易出错的点。模型变换后,法线不能直接用模型矩阵变换。因为如果模型有非均匀缩放,法线方向会歪掉。正确的做法是用模型矩阵的逆转置矩阵。
// 法线变换的正确姿势
mat3 normalMatrix = transpose(inverse(mat3(model)));
vec3 worldNormal = normalize(normalMatrix * aNormal);
性能提示:如果确定模型只有均匀缩放或没有缩放,可以直接用模型矩阵的3x3部分变换法线,省掉求逆的开销。我在移动端项目里就是这么干的,性能提升很明显。
3.6 坐标空间的转换总结
咱们捋一下整个流程:
- 模型空间:顶点相对于模型原点
- 世界空间:所有模型在同一个大世界里
- 视图空间:以摄像机为原点
- 裁剪空间:准备裁剪和透视除法
- 屏幕空间:最终显示在屏幕上(硬件自动完成)
每个空间都有它的用途。模型空间方便美术创作,世界空间方便场景管理,视图空间方便裁剪和光照计算,裁剪空间方便GPU做视锥体裁剪。
我记得有一次优化一个VR项目,发现顶点着色器成了瓶颈。分析后发现,我们在顶点着色器里做了太多世界空间的计算。后来把一部分计算挪到CPU端预计算,或者延迟到片元着色器里做,帧率直接提升了30%。
避坑指南:
- 顶点着色器里尽量少做条件分支——GPU是SIMD架构,分支会导致所有线程走两条路
- 矩阵乘法顺序:从右往左读。比如 projection * view * model * vertex,先应用model,再view,最后projection
- 注意精度:深度缓冲的精度有限,投影矩阵的 near/far 平面不要设得太极端
好了,顶点着色器的核心内容就这些。下一章咱们聊聊片元着色器,看看那些插值后的属性是怎么变成屏幕上五彩斑斓的像素的。