3、几何阶段(一):顶点着色器的工作原理、顶点变换与属性绑定

好,咱们进入几何阶段了。说实话,这是整个渲染管线里我最喜欢的一块——因为它足够底层,也足够有挑战性。顶点着色器,说白了就是每个顶点都要跑一遍的小程序。你想想看,一个模型少说几千个顶点,多的上百万,这玩意儿要是写不好,性能直接崩。

3.1 顶点着色器到底在干什么?

顶点着色器的核心任务就三个:

  • 计算顶点位置——把模型从本地空间挪到屏幕空间
  • 传递顶点数据——比如法线、纹理坐标、颜色这些
  • 做点“私活”——比如顶点动画、蒙皮、LOD选择

我个人习惯把顶点着色器看作一个“数据搬运工+坐标转换器”。它不生成新顶点,也不删除顶点,只是把每个顶点处理一遍,然后丢给下一个阶段。

关键点:顶点着色器是必须存在的阶段。你可以不写片元着色器(有默认的),但顶点着色器必须自己写。这是硬性要求。

3.2 顶点变换:从模型空间到屏幕空间

这部分我当年学的时候绕了不少弯路。其实顶点变换就是一连串矩阵乘法,把顶点从一个坐标系扔到另一个坐标系。流程是这样的:

  1. 模型空间 → 世界空间(模型矩阵)
  2. 世界空间 → 视图空间(视图矩阵)
  3. 视图空间 → 裁剪空间(投影矩阵)
  4. 裁剪空间 → 屏幕空间(视口变换,硬件自动做)

嗯,这里要注意:我们通常在顶点着色器里只做到裁剪空间。后面的视口变换是硬件帮你搞定的,你不需要操心。

3.2.1 模型矩阵(Model Matrix)

模型矩阵负责把顶点从模型自己的坐标系挪到世界坐标系。说白了就是平移、旋转、缩放。我在项目中遇到过一个问题:如果模型矩阵里不小心混入了非均匀缩放,法线变换就会出问题。这时候你得用逆转置矩阵来处理法线,不然光照就全错了。

3.2.2 视图矩阵(View Matrix)

视图矩阵模拟的是“摄像机”。它把世界坐标系的顶点转换到以摄像机为原点的坐标系里。我个人习惯把视图矩阵理解为“把摄像机挪到原点,同时把场景跟着挪”。

3.2.3 投影矩阵(Projection Matrix)

投影矩阵分两种:透视投影和正交投影。透视投影有近大远小效果,正交投影没有。这里有个坑——

我曾经踩过的坑:透视投影的远平面和近平面设置不当,会导致深度精度问题。尤其是远平面设得太大,近平面设得太小,你会发现远处的物体闪烁、抖动。这就是所谓的“z-fighting”。我建议近平面不要小于0.1,远平面不要超过1000,除非你真有特殊需求。

3.3 顶点属性与语义绑定

顶点着色器怎么拿到数据?靠的是顶点属性绑定。每个顶点可以携带多种数据,比如位置、法线、纹理坐标、颜色、切线等等。这些数据通过语义(Semantic)来绑定。

在HLSL里,常见的语义有:

语义 含义 数据类型
POSITION 顶点位置 float3/float4
NORMAL 法线方向 float3
TEXCOORD0 第一套纹理坐标 float2
TANGENT 切线方向 float4
COLOR 顶点颜色 float4

你想想看,如果没有语义绑定,GPU怎么知道哪个数据是位置、哪个是颜色?语义就是给GPU看的“标签”。

3.3.1 输入布局(Input Layout)

在DirectX里,你需要创建一个输入布局来描述顶点数据的排布。比如:

// C++ 端定义顶点结构
struct Vertex {
    float3 position;  // 偏移0字节
    float3 normal;    // 偏移12字节
    float2 texcoord;  // 偏移24字节
};

// 输入布局描述
D3D11_INPUT_ELEMENT_DESC layout[] = {
    {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0},
    {"NORMAL",   0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0},
    {"TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT,    0, 24, D3D11_INPUT_PER_VERTEX_DATA, 0},
};

对应的HLSL顶点着色器输入:

struct VSInput {
    float3 position : POSITION;
    float3 normal   : NORMAL;
    float2 texcoord : TEXCOORD0;
};

VSOutput main(VSInput input) {
    VSOutput output;
    output.position = mul(float4(input.position, 1.0f), worldViewProj);
    output.normal   = mul(input.normal, (float3x3)worldInverseTranspose);
    output.texcoord = input.texcoord;
    return output;
}

小技巧:我建议你把顶点结构的内存对齐搞好。比如float3后面跟float3,中间不要插float2,否则会有内存空洞,浪费带宽。我一般把顶点结构设计成16字节对齐,这样GPU读取最开心。

3.4 顶点着色器的性能调优

这部分是实战干货。顶点着色器虽然只处理顶点,但顶点数量多起来,性能压力也不小。我总结了几条经验:

  • 减少指令数——顶点着色器里别做太复杂的计算。比如光照计算,能放到片元着色器就放过去。顶点着色器只做必要的变换。
  • 避免动态分支——顶点着色器里的if-else会导致性能下降,因为GPU是SIMD架构,所有顶点走同一套指令。分支意味着有些顶点要等。
  • 合理使用常量缓冲区——把模型矩阵、视图矩阵、投影矩阵这些频繁变化的数据放到常量缓冲区,别用单个常量。
  • 注意带宽——顶点属性越多,每个顶点占的带宽越大。如果你不需要法线,就别传。我见过有人把没用的切线也传进去,白白浪费带宽。

避坑指南:我曾经接手过一个项目,顶点着色器里做了大量的蒙皮计算,结果帧率只有20帧。后来我把蒙皮计算挪到了计算着色器里,用预计算的方式处理,帧率直接翻倍。所以,顶点着色器不是万能的,该分流就分流。

3.5 小结

顶点着色器是整个渲染管线的第一道关卡。它负责把顶点从模型空间变换到裁剪空间,同时传递必要的顶点属性给下一阶段。语义绑定是连接CPU数据和GPU程序的桥梁,搞错了就全乱套。

下一章我们会聊几何阶段的另一个重要角色——曲面细分着色器。说实话,那个东西比顶点着色器有意思多了,但也更坑。到时候我给你们讲讲我踩过的那些坑。