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 坐标空间的转换总结

咱们捋一下整个流程:

  1. 模型空间:顶点相对于模型原点
  2. 世界空间:所有模型在同一个大世界里
  3. 视图空间:以摄像机为原点
  4. 裁剪空间:准备裁剪和透视除法
  5. 屏幕空间:最终显示在屏幕上(硬件自动完成)

每个空间都有它的用途。模型空间方便美术创作,世界空间方便场景管理,视图空间方便裁剪和光照计算,裁剪空间方便GPU做视锥体裁剪。

我记得有一次优化一个VR项目,发现顶点着色器成了瓶颈。分析后发现,我们在顶点着色器里做了太多世界空间的计算。后来把一部分计算挪到CPU端预计算,或者延迟到片元着色器里做,帧率直接提升了30%。

避坑指南:

  • 顶点着色器里尽量少做条件分支——GPU是SIMD架构,分支会导致所有线程走两条路
  • 矩阵乘法顺序:从右往左读。比如 projection * view * model * vertex,先应用model,再view,最后projection
  • 注意精度:深度缓冲的精度有限,投影矩阵的 near/far 平面不要设得太极端

好了,顶点着色器的核心内容就这些。下一章咱们聊聊片元着色器,看看那些插值后的属性是怎么变成屏幕上五彩斑斓的像素的。