4、几何阶段(二):曲面细分着色器与几何着色器

好,咱们接着聊几何阶段。上一章我们把顶点着色器聊透了,这一章要啃两块硬骨头——曲面细分着色器和几何着色器。说实话,这两个东西在早期的渲染管线里是没有的,是后来才加进来的。我刚开始接触它们的时候,也觉得有点绕,但用熟了之后会发现,它们能解决很多传统管线搞不定的问题。

曲面细分着色器(Tessellation Shader)

曲面细分,说白了就是“把粗糙的模型变精细”。你想想看,一个三角形只有三个顶点,但如果我想在它上面画出一个凹凸不平的山丘,光靠这三个点肯定不够。曲面细分就是干这个的——它把一个大三角形拆成很多个小三角形,然后让这些小三角形的顶点动起来,形成更复杂的形状。

三个阶段:Hull Shader、Tessellator、Domain Shader

曲面细分着色器其实不是单个着色器,而是三个阶段的组合。我个人习惯把它们理解成“决策者”、“执行者”和“修正者”。

阶段 名称 作用
第一阶段 Hull Shader(外壳着色器) 决定怎么细分,输出细分因子
第二阶段 Tessellator(固定功能单元) 根据细分因子,生成新的顶点坐标
第三阶段 Domain Shader(域着色器) 把新顶点映射到最终位置

嗯,这里要注意:Tessellator 是固定功能的硬件单元,你不能写代码控制它,只能通过 Hull Shader 告诉它“你要细分多少”。

Hull Shader 的输入输出

Hull Shader 的输入是控制点(Control Points),输出是细分因子(Tessellation Factors)。细分因子决定了每个三角形边被分成几段。

// 一个简单的 Hull Shader 示例
struct HullInput {
    float3 position : POSITION;
};

struct HullOutput {
    float3 position : POSITION;
};

// 细分因子输出
struct TessFactors {
    float edge[3] : SV_TessFactor;
    float inside : SV_InsideTessFactor;
};

// 主函数
TessFactors HullShaderMain(InputPatch<HullInput, 3> patch, uint patchID : SV_PrimitiveID) {
    TessFactors tf;
    tf.edge[0] = 4.0f;  // 第一条边分成4段
    tf.edge[1] = 4.0f;  // 第二条边分成4段
    tf.edge[2] = 4.0f;  // 第三条边分成4段
    tf.inside = 4.0f;   // 内部也分成4段
    return tf;
}

我在项目中遇到过一个问题:细分因子设得太大,结果一个三角形被拆成了几万个微小的三角形,帧率直接掉到个位数。后来我学乖了,根据相机距离动态调整细分因子——远处的物体细分少一点,近处的多一点。这样既保证了质量,又控制了性能。

Domain Shader 的典型用法

Domain Shader 接收 Tessellator 生成的新顶点坐标(通常是重心坐标),然后计算出这些顶点在三维空间中的实际位置。最常见的用法是做置换贴图(Displacement Mapping)。

// Domain Shader 示例:根据高度图做置换
struct DomainOutput {
    float4 position : SV_POSITION;
    float3 normal : NORMAL;
};

[domain("tri")]
DomainOutput DomainShaderMain(
    TessFactors tf,
    const OutputPatch<HullOutput, 3> patch,
    float3 bary : SV_DomainLocation) {
    
    // 重心坐标插值
    float3 pos = patch[0].position * bary.x + 
                 patch[1].position * bary.y + 
                 patch[2].position * bary.z;
    
    // 采样高度图,沿法线方向偏移
    float height = tex2D(heightMap, uv).r;
    pos += normal * height * 0.1f;
    
    DomainOutput output;
    output.position = mul(projMatrix, float4(pos, 1.0f));
    return output;
}
我的经验:置换贴图配合曲面细分,效果非常惊艳。但要注意,高度图的分辨率要和细分粒度匹配。如果高度图很精细但细分很粗糙,会出现锯齿;反过来,细分很精细但高度图模糊,那就是浪费性能。

几何着色器(Geometry Shader)

几何着色器,它比曲面细分更灵活。曲面细分只能把三角形拆成更小的三角形,但几何着色器可以干更多事——它可以增删顶点,甚至改变图元类型。说白了,它让你在渲染管线的中间阶段,对几何体做“手术”。

输入输出类型

几何着色器的输入是一个完整的图元(点、线、三角形),输出可以是零个或多个图元。这个“零个”很有意思——你可以用它来做剔除,比如把背面的三角形直接扔掉。

输入类型 输出类型 典型用途
point point / line / triangle 粒子系统、点精灵扩展
line line / triangle 毛发渲染、线条加粗
triangle point / line / triangle 爆炸效果、轮廓线提取

典型用法:法线可视化

我记得有一次调试一个复杂的模型,法线方向总是搞不对。用几何着色器把法线可视化出来,问题一目了然。

// 几何着色器:把法线画成线段
[maxvertexcount(6)]
void GS_Main(triangle VertexOutput input[3], inout LineStream<VertexOutput> stream) {
    // 计算三角形中心
    float3 center = (input[0].pos + input[1].pos + input[2].pos) / 3.0f;
    float3 normal = (input[0].normal + input[1].normal + input[2].normal) / 3.0f;
    
    VertexOutput v1, v2;
    v1.pos = mul(projMatrix, float4(center, 1.0f));
    v1.color = float4(0, 0, 1, 1);  // 蓝色起点
    
    v2.pos = mul(projMatrix, float4(center + normal * 0.5f, 1.0f));
    v2.color = float4(1, 0, 0, 1);  // 红色终点
    
    stream.Append(v1);
    stream.Append(v2);
}
性能警告:几何着色器的输出数量是有限制的。DirectX 11 中,最大顶点数通常是 1024 个。我曾经试图在一个几何着色器里输出几千个顶点,结果驱动直接崩溃了。所以,如果你需要大量生成顶点,考虑用计算着色器(Compute Shader)替代。

几何着色器的局限性

说实话,几何着色器在现在的渲染管线里有点“尴尬”。为什么?因为它的性能表现不太稳定。它在 CPU 和 GPU 之间引入了额外的同步点,而且输出数量有限。我个人的建议是:

  • 如果你只是想做简单的图元扩展(比如点变四边形),几何着色器很合适
  • 如果你需要大量生成几何体,用计算着色器 + 顶点着色器的方式更高效
  • 曲面细分和几何着色器不要混用,容易出奇怪的 bug

避坑指南:我曾经在一个项目里同时启用了曲面细分和几何着色器,结果发现几何着色器处理的是细分后的三角形,而不是原始三角形。这意味着我原本以为只处理 100 个三角形,实际上处理了 10000 个。性能直接崩了。所以,记住:几何着色器是在曲面细分之后执行的。

总结一下

曲面细分着色器和几何着色器,都是给开发者更多“几何控制权”的工具。曲面细分擅长把粗糙模型变精细,适合做地形、角色细节;几何着色器擅长改变图元结构,适合做特效、调试可视化。但两者都有性能代价,用之前一定要想清楚:我真的需要这个吗?

下一章,我们会进入光栅化阶段。那才是真正决定像素命运的地方。