Direct X 12 – Geometry Shader 几何着色器

假设我们并没有使用tessellation阶段,那么geometry shader就是一个位于顶点着色器和像素着色器之间的可选阶段。顶点着色器处理的是所有顶点,而geometry shader则处理所有的图元(primitives)。例如,如果我们正在绘制三角形列表,那么geometry shader就会处理列表中的每一个三角形T:

for(UINT i = 0; i < numTriangles; ++i)
    OutputPrimitiveList = GeometryShader(T[i].vertexList);

请注意每一个三角形的三个顶点会被传入geometry shader,而geometry shader将输出图元的列表。与顶点着色器不能够销毁或者创建顶点不同,geometry shader的主要作用就是创建或者销毁几何图元;这也让我们能够使用GPU实现一些有趣的效果。例如,输入到geometry shader的图元可以被延展为多个图元,或者geometry shader基于某些条件不去输出一些图元。请注意,输出的图元并不需要与输入的图元有着相同的类型;例如,geometry shader将一个点延展为一个平面(两个三角形)。

geometry shader所输出的图元由顶点列表定义。而离开geometry shader的顶点,其坐标位置必须已经被转换至齐次剪裁空间。在geometry shader之后,我们所拥有的顶点列表,其在齐次剪裁空间中定义了图元的类型。之后,这些顶点将被投影(透视除法),并被光栅化。

目标:

1. 学会如何编写geometry shader。

2. 学会如何使用geometry shader以更高效的方式绘制平面。

3. 学习自动生成的primitive ID与其相应的应用。

4. 学会如何创建并使用贴图数组并理解贴图数组的用处。

5. 理解alpha-to-coverage是如何缓解alpha cutout的问题

12.1 编写Geometry Shader

编写geometry shader与编写顶点着色器或者像素着色器很像,但仍有一些不同。以下结构展示了geometry shader的大体结构:

// HLSL
[maxvertexcount(N)]
void GeometryShader(
    PrimitiveType InputVertexType InputName[NumElements],
    inout StreamOutputObject<OutputVertexType> OutputName)
{
    // Geometry Shader...
}

我们必须确定geometry shader每一次被调用时,其将会输出的最大的顶点数量(geometry shader被调用的次数取决于图元的数量)。而最大的顶点数量由[maxvertexcount(N)]来确定,其中,N表示每一次调用geometry shader后所输出的最大顶点数。geometry shader每次被调用时输出的顶点数量可以不同,但不能超过我们定义的最大值。出于性能考虑,最大顶点数应该尽可能小;NVIDIA的GPU手册中提到了,当geometry shader输出量为1-20时,GPU的表现最佳;当输出量变为为27-40时,GPU的表现将降低50%。而我们所说的输出量取决于,最大顶点数以及输出的顶点类型(顶点结构体的复杂程度)。在编写着色器时还要考虑这个性能瓶颈无疑是很麻烦的一件事,所以我们只能接受GPU以较低的性能运行或者选择另一种方式而不使用geometry shader;但是,使用另一种方法也可能会产生新的问题和新的性能瓶颈,相比之下,geometry shader可能是个更好的选择。此外,NVIDIA的GPU手册出版于2008年,也是geometry shader第一次能被大家使用,现在其性能与开销应该已经有了很大的进步。

geometry shader有两个参数:一个为输入参数,另一个为输出参数(事实上,geometry shader可以包含多个参数,但这又是另外一个议题了,请参考12.2.4小节)。输入参数为顶点数组,其定义了图元——1个顶点代表1个点,或者2个顶点代表1条线段,或者3个顶点代表三角形,或者4个顶点代表相邻的线段,或者6个顶点代表相邻的三角形等等。输入参数中顶点的类型就是顶点着色器所返回的顶点的类型(例如,VertexOut)。同时,输入的参数必须有一个预定义的图元类型,其表示输入至geometry shader的图元类型,具体类型如下所示:

1. point:输入的图元为点。

2. line:输入的图元为线。

3. triangle:输入的图元为三角形。

4. lineadj:输入的图元为相邻的线段。

5. triangleadj:输入的图元为相邻的三角形。

geometry shader的输入图元都是一个完整的图元(例如,2个顶点构成的线段,3个顶点构成的三角形)。因此,geometry shader并不需要区分list和strip(list所构成的图元并不一定相连,strip构成的图元一定相连,请参考5.5.2)。如果我们正在绘制三角形strip,geometry shader仍然需要三角形strip中的每一个三角形,而每一个三角形的3个顶点都会传入geometry shader作为输入参数。这也会产生一些额外开销,由于strip中的顶点被多个图元所共享,geometry shader就会反复处理相同的顶点。

geometry shader的输出参数有inout标注。此外,输出的参数永远是stream类型。steam类型存储了顶点列表,其定义了geometry shader所输出的图元。geometry shader调用HLSL内置的函数Append将顶点添加到输出的stream顶点列表中:

// HLSL
void StreamOutputObject<OutputVertexType>::Append(OutputVertexType v);

stream类型类似于c++中的template类型,其可以通过参数OutpuVertexType来确定输出的顶点类型(例如,GeoOut)。一共有三种stream类型:

1. PointStream:定义了一组点。

2. LineStream:定义了一组线段strip。

3. TriangleStream:定义了一组三角形strip。

geometry shader输出的顶点形成了新的图元;新的图元的类型由stream类型决定(PointStream,LineStream,TriangleStream)。若新的图元类型为线段或者三角形,那么其永远是strip。然而,我们可以调用HLSL内置的函数RestartStrip来模拟线段或者三角形的list:

// HLSL
void StreamOutputObject<OutputVertexType>::RestartStrip();

如果你想要输出三角形list而非strip,那么每次在3个顶点被添加到stream后,都需要调用函数RestartStrip。
以下是一些geometry shader的例子

// HLSL

// 例子1:GS最多输出4个顶点。输入的图元为线段。
// 输出的图元为三角形strip
[maxvertexcount(4)]
void GS(line VertexOut gin[2],
    inout TriangleStream<GeoOut> triStream)
{
    // geometry shader
}

// 例子2:GS最多输出32个顶点。输入的图元为三角形。
// 输出的图元为三角形strip
[maxvertexcount(32)]
void GS(triangle VertexOut gin[3],
    inout TriangleStream<GeoOut> triStream)
{
    // geometry shader
}

// 例子3:GS最多输出4个顶点。输入的图元为点。
// 输出的图元为三角形strip
void GS(point VertexOut gin[1],
    inout TriangleStream<GeoOut> triStream)
{
    // geometry shader
}

下面的geometry shader调用了函数Append以及RestartStrip;其输入参数为1个三角形,在geometry shader中创建了3个位于原先三角形每条边中点的点。因此将原先的三角形分为四个部分,所以输出了4个子三角形,如下图所示:

// HLSL
struct VertexOut
{
    float3 PosL : POSITION;
    float3 Normal : NORMAL;
    float2 Tex : TEXCOORD;
};

struct GeoOut
{
    float4 PosH : SV_POSITION;
    float3 PosW : POSITION;
    float3 NormalW : NORMAL;
    float2 Tex : TEXCOORD;
    float FogLerp : FOG;
};

void Subdivide(VertexOut inVerts[3], out VertexOut outVerts[6])
{
    //      1     
    //       *     
    //     /  \     
    //    /    \     
    //  m0*-----*m1     
    //  /  \   / \     
    // /    \ /   \     
    // *-----*-----*     
    // 0    m2     2
    
    VertexOut m[3];

    // 计算三角形三边的中点
    m[0].PosL = 0.5f * (inVerts[0].PosL + inVerts[1].PosL);
    m[1].PosL = 0.5f * (inVerts[1].PosL + inVerts[2].PosL);
    m[2].PosL = 0.5f * (inVerts[2].PosL + inVerts[0].PosL);

    m[0].PosL = normalize(m[0].PosL);
    m[1].PosL = normalize(m[1].PosL);
    m[2].PosL = normalize(m[2].PosL);

    m[0].NormalL = m[0].PosL;
    m[1].NormalL = m[1].PosL;
    m[2].NormalL = m[2].PosL;

    // 计算贴图坐标
    m[0].Tex = 0.5f * (inVerts[0].Tex + inVerts[1].Tex);
    m[1].Tex = 0.5f * (inVerts[1].Tex + inVerts[2].Tex);
    m[2].Tex = 0.5f * (inVerts[2].Tex + inVerts[0].Tex);

    outVerts[0] = inVerts[0];
    outVerts[1] = m[0];
    outVerts[2] = m[2];
    outVerts[3] = m[1];
    outVerts[4] = inVerts[2];
    outVerts[5] = inVerts[1];
};

void OutputSubdivision(VertexOut v[6],
    inout TriangleStream<GeoOut> triStream)
{
    GeoOut gout[6];

    [unroll]
    for(int i = 0; i < 6; ++i)
    {
        // 转换为世界坐标系
        gout[i].PosW = mul(float4(v[i].PosL, 1.f), gWorld).xyz;
        gout[i].NormalW = mul(v[i].NormalL,
            (float3x3)gWorldInvTranspose);

        // 转换为齐次剪裁空间
        gout[i].PosH = mul(float4(v[i].PosL, 1.f), gWorldViewProj);
        gout[i].Tex = v[i].Tex;
    }

    //      1     
    //       *     
    //     /  \     
    //    /    \     
    //  m0*-----*m1     
    //  /  \   / \     
    // /    \ /   \     
    // *-----*-----*     
    // 0    m2     2
    // 我们可以分两步绘制子三角形
    // Strip 1: 底部的3个三角形
    // Strip 2: 顶部的1个三角形
    [unroll]
    for(int j = 0; j < 5; j++)
    {
        triStream.Append(gout[j]);
    }

    triStream.RestartStrip();
    triStream.Append(gout[1]);
    triStream.Append(gout[5]);
    triStream.Append(gout[3]);
}

[maxvertexcount(8)]
void GS(triangle VertexOut gin[3], 
    inout TriangleStream<GeoOut> triStream)
{
    VertexOut v[6];
    Subdivide(gin, v);
    OutputSubdivision(v, triStream);
}

geometry shader的编译与顶点着色器以及像素着色器的编译十分相似。假设geometry shader位于文件TreeSprite.hlsl中,且geometry shader的名称为GS,那么我们将着色器编译为bytecode的代码如下:

mShaders["treeSpriteGS"] = d3dUtil::CompileShader(
    L"Shaders\TreeSprite.hlsl", nullptr, "GS", "gs_5_0");

与顶点着色器以及像素着色器类似,geometry shader也需要被绑定至渲染管线并作为PSO的一部分:

D3D12_GRAPHICS_PIPELINE_STATE_DESC treeSpritePsoDesc = opaquePsoDesc;
...
treeSpritePsoDesc.GS = 
{
    reinterpret_cast<BYTE*>(mShaders["treeSpriteGS"]->GetBufferPointer()),
    mShaders["treeSpriteGS"]->GetBufferSize()
};

对于输入geometry shader的顶点,geometry shader也可以基于某些情况不输出这些顶点。换句话说,这部分顶点就被geometry shader“销毁”了。

如果我们在geometry shader中输出的图元不足以构成一个完整的图元,那么这部分不完整的图元将被整个舍弃。

留下评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据