DirectX12 3D 游戏开发与实战第十二章内容

12、几何着色器

如果不启用曲面细分,那么几何着色器这个可选阶段将会在位于顶点着色器和像素着色器之间。顶点着色器以顶点作为输入数据,而几何着色器以完整的图元为输入数据。与顶点着色器不同的是,顶点着色器不可以销毁或者创建图元,几何着色器可以销毁或者创建几何图形,我们可以借助这个特点将输入的图元扩展成一个或更多类型的图元,或者是不输出图元。

几何着色器所输出的图元由顶点列表定义而成,在退出几何着色器的时候,必须将顶点的位置变换到齐次裁剪空间。

学习目标

  1. 学习如何编写几何着色器
  2. 探究如何通过几何着色器来高效的实现公告牌技术(billboard)
  3. 了解自动生成图元ID及其相关的应用
  4. 研究如何创建和使用纹理数组,并认识到他们为何如此使用
  5. 理解如何运用alpha-to-coverage技术来辅助解决alpha裁剪失真的问题

12.1 、编写几何着色器

几何着色器的编写方式比较接近于顶点着色器和像素着色器,下面的代码展示了几何着色器的一般编写格式:

[maxvertexcount(N)]
void ShaderName(PrimitiveType InputVertexType InputName[NumElements],inout StreamOutputObject<OutputVertexType> OutputName)
{
    // 几何着色器的具体实现
}

我们必须先指定几何着色器单词调用所输出的最大顶点数量(每个图元都会调用一次几何着色器,走一遍其中的处理流程),对此,我们可以使用下列属性语法来设置着色器定义之前的最大顶点数量。

[maxvertexcount(N)]

N是几何着色器单次调用所输出的顶点数量最大值,几何着色器每次输出的顶点数量可能各不相同,但是都不可以超过N。同时处于对性能的考虑,N值应该尽可能的小。

几何着色器有输入、输出两个参数,输入参数必须是一个定义有特定图元的顶点数组——点应该属于一个顶点,线段应该输入两个顶点,三角形需要输入三个顶点,线及其邻接图元为四个顶点,三角形及其邻接图元为六个顶点。几何着色器的输入顶点类型便是输出的顶点类型,输入参数一定要以图元类型为前缀,用以描述输入到几何着色器的具体图元类型。该前缀可以是以下几种类型之一:

名称 含义
point 输入的图元为点
line 输入的图元为线段
triangle 输入的图元为三角形列表或三角形带
lineadj 输入的图元为线列表及其邻接图元或线条带及其邻接图元
triangleadj 输入的为三角形列表及其邻接图元或三角形带及其邻接图元

输出参数一定要标有inout修饰符,而且一定要是一种流类型(stream type,即某种类型的流输出对象)。几何着色器可以通过内置方法Append向输出流列表添加单个顶点:

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

流类型本质是一种模板类型(template type),其模板参数用于指定输出顶点的具体类型,流类型有以下3种:

名称 含义
PointStream 一系列顶点所定义的店列表
LineStream 一系列顶点所定义的线条带
TriangleStream 一系列顶点所定义的三角形带

几何着色器输出的多个顶点会构成图元,图元的输出类型由流类型来指定,对于线条和三角形来说,几何着色器输出的对应图元一定是线条带和三角形带。而线条列表和三角形列表可以使用内置函数RestartStrip来实现:

void StrameOutputObject<OutputVertexType>::RestartStrip();

比如:如果希望输出三角形列表,则需要每次向输出流追加三个顶点之后调用RestartStrip。

以下是一些几何着色器签名的具体用例:

// 示例1:GS最多输出4个顶点,输入的图元一根是线条,输出的是一个三角形带
[maxvertexcount(4)
void GS(line VertexOut gin[2],inout TriangleStream<GeoOUt> triStream)
{
    // 几何着色器的实现
}

// 示例二:GS最多输出32个顶点,输入的图元是一个三角形,输出的是一个三角形带
[maxvertexcount(32)]
void GS(triangle VertexOut gin[3],inout TriangleStream<GeoOut> triStream)
{
    // 几何着色器的具体实现 
}
 
// 示例三:GS最多输出4个顶点,输入的图元是一个店,输出的是一个三角形带
[maxvertexcount[4]]
void GS(point VertexOut gin[1],inout TriangleStream<GeoOut> triStream)
{
    // 几何着色器的具体实现
}

下列几何着色器详细的展示了Append和RestartStrip方法的调用过程,此示例会将输入的三角形细分成四个小三角形输出:

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

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

void Subdivide(VertexOut inVerts[3],out VertexOut outVerts[6])
{
    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.0f),gWorld).xyz;
        gout[i].NormalW = mul(v[i].Normal,(float3x3)gWorldInvTranspose);
        
        // 将顶点变换到齐次裁剪空间
        gout[i].PosH = mul(float4(v[i].PosL,1.0f),gWorldViewProj);
        gout[i].Tex = v[i].Tex;
    }
    
    // 我们可以将细分的小三角形绘制到两个三角形带中
    // 三角形带1:底部的三个三角形
    // 三角形带2:顶部的一个三角形
    [unroll]
    for(int i=0;i<5;++i)
    {
        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>)
{
    VertexOut v[6];
    Subdivide(gin,v);
    OutputSubdivision(v,triStream);
}

几何着色器的编译过程和顶点着色器、像素着色器一样,我们可以使用下列方法将几何着色器编译为字节码(TreeSprite.hlsl文件中有几何着色器)

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

然后我们要将几何着色器作为流水线状态对象的一部分,以此将它绑定到渲染流水线上

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

12.2、以公告牌技术实现森林效果

12.2.1、概述

当树与树之间的距离较远时,我们便可以使用公告牌技术来实现森林效果。即以绘制3D树木土拍你的四边形来替代对整棵3D树木的渲染,从远处看,公告牌技术可以以假乱真。同时,我们还会使公告牌总是面向摄像机,以避免露馅。

在世界空间中,如果给定一个公告牌的中心点的位置(即四边形的中心)为G(C1,C2,C3)。而摄像机的位置为E = (E1,E2,E3),那么我们便可以表示出该公告牌局部坐标与世界空间的相对关系。

\[w = (E1 - C1,0,E3 - C3)/|E1 - C1,0,E3 - C3|; \]

\[v = (0,1,0); \]

\[w = v叉乘w \]

给出公告牌局部坐标系与世界空间的相对关系和公告牌在世界空间中的大小,我们便可以通过下列代码来获取公告牌四边形的4个顶点坐标:

v[0] = float4(gin[0].CenterW + halfWidth*right - haleHeight*up,1.0f);
v[1] = float4(gin[0].CenterW + halfWidth*right + haleHeight*up,1.0f);
v[2] = float4(gin[0].CenterW - halfWidth*right - haleHeight*up,1.0f);
v[3] = float4(gin[0].CenterW - halfWidth*right + haleHeight*up,1.0f);

注意点:由于公告牌的局部坐标系都不相同,所以每一个公告牌的四边形都要分别计算。

对于这个演示程序而言,我们将会构造一系列距离陆地表面有着特定距离的点图元,这些点表示的是公告牌的中心点。在几何着色器中,我们会将这些点扩展成四边形,同时计算公告牌的世界矩阵。

构造点图元的方法:将PSO中的PrimitiveTopology成员指定为D3D12_PRIMITIVE_TOPOLOGY_POINT,并把ID3D12GraphicsCommandList::IASetPrimitiveTopology函数的参数指定为D3D_PRIMITIVE_TOPOLOGY_POINTLIST.

12.2.2、顶点结构体

我们用下列顶点结构体来描述公告牌:

struct TreeSpriteVertex
{
    XMFLAOT3 Pos;
    XMFLOAT2 Size;
}

mTreeSpriteInputLayout = 
{
    {"POSITION",0,DXGI_FORMAT_R32G32B32_FLOAT,0,0,D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA,0},
    {"SIZE",0,DXIG_FORMAT_R32G32_FLOAT,0,12,D3D12_INPUT_CALSSFICATION_PER_VERTEX_DATA,0},
}

顶点结构体中存储Pos变量是公告牌的中心位置,Size变量是公告牌的大小。通过Size变量,几何着色器可以知晓扩展后的公告牌会有多大,通过调整每一个顶点结构体中Size成员变量,我们就可以构造出不同尺寸的公告牌。

12.2.3、HLSL文件

这是第一次展示几何着色器示例,所以我们将会在此列出完整的HLSL代码,使我们可以更加便于了解几何着色器、顶点着色器和像素着色器这三者的结合使用方法。该HLSL文件也引入了一些之前没有介绍过的讨论过的新对象(SV_PrimitiveID和Texture2DArray),我们会在后续小节对此进行介绍。

12.2.4、SV_PrimitiveID语义

在HLSL文件中,几何着色器有一个使用SV_PrimitiveID语义的特殊无符号整数参数:

[maxvertexcount[4]]
void GS(point VertexOut gin[1],uint primID:SV_PrimitiveID,inout TriangleStream<GeoOut>)

若指定了该语义,则输入装配器阶段会自动为每个图元生成图元ID(第一个图元被标记为0,第二个图元被标记为1……),对于单词绘制调用来说,其中的图元ID都是唯一的。在公告牌示例中,几何着色器并不会使用到图元ID。

注意点1:像素着色器也可以获得图元ID,但是若存在几何着色器,则几何着色器首先获得图元ID,若不存在几何着色器,则像素着色器首先获得图元ID。即二者谁离图元装配器阶段近且被开启,则首先获得图元ID。

注意点2:输入装配器也可以生成顶点ID。不过我们需要在顶点着色器额外添加一个有语义SV_VertexID修饰的uint类型的参数:

VertexOut VS(VertexIn vin,uint vertID : SV_VertexID)
{
    // 顶点着色器主体
}

12.3、纹理数组

12.3.1、概述

纹理数组即存放纹理的数组,和其他资源一样,在C++代码中,纹理数组也由ID3D12Resource接口来表示。创建ID3D12Resource对象时,我们可以通过设置DepthArraySize属性来指定纹理数组所存放的元素个数(对于3D纹理来说,此项设定则为深度值)。在HLSL文件(效果文件)中,纹理数组是通过Texture2DArray类型来表示的。

12.3.2、对纹理数组进行采样

在公告牌示例中,我们用以下代码对纹理数组进行采样:

float3 uvw = float3(pin.TexC,pin.PrimID%4);
float4 diffuseAlbedo = gTreeMapArray.Sample(gsamAnisotropicWrap,uvw)*gDiffuseAlbedo;

使用纹理数组一共需要三个坐标值:前两个坐标为普通的2D纹理坐标,第三个坐标为纹理数组的索引。

在公告牌示例中,我们采用的是一个含有4个纹理资源的纹理数组,每个纹理元素为一种树木。但是,由于我们要绘制的树木多余四颗,所以最终的图元ID也会大于3。因此我们才会对图元ID进行模4运算。每次设置纹理或者绘制调用的时候都会产生相应的开销,使用纹理数组之后我们可以吧设置纹理与绘制调用的过程减少到一次。

12.3.3、加载纹理数组

在Common/DDSTextureLoader.h文件中,有加载存有纹理数组的DDS文件的方法。因此,我们只需要创建含有纹理数组的DDS文件即可。通过微软公司所提供的texassemble工具,然后通过下列语法便可以将dds图像合并成一个纹理数组。

比如:将t0.dds t1.dds t2.dds t3.dds合并成一个名为treeArray的纹理数组:

texassemble -array -o treeArtessray.dds t0.dds t1.dds t2.dds t3.dds

注意点:使用texassemble程序创建纹理数组时,每一个图像只允许有一种mipmap层级。但是我们可以用texconv工具对生成的纹理数组进行处理,以让其生成多种mipmap层级或者改变纹理的格式。

texconv -m 10 -f BC3_UNORM treeArray.dds

12.3.4、纹理子资源

下图展示了一个拥有四个纹理的纹理数组,其中的纹理都有各自的mipmap链。Direct3D中使用术语数组切片来表示纹理数组中某个纹理以及其mipmap链。又使用属于mip切片来表示纹理数组中特定层级的所有mipmap。子资源即是指纹理数组中某个纹理的单个mipmap层级。

这就是说,若给出纹理数组的索引以及mipmap层级,我们就可以访问纹理数组中相应的子资源。字子元也是由线性索引来标记的,而Direct3D所使用的线性索引规划如下图所示:

通过下面的工具函数,我们可以根据给出的mip切片索引、数组切片索引、平面切片索引、mipmap层级以及纹理数组的大小,直接计算出子资源的线性索引。

inline UINT D3D12CalcSubresource(UINT MipSlice, UINT ArraySlice,UINT PlaneSlice,UINT MipLevels,UINT ArraySize)
{
    return MipSlice + ArraySlice + PlaneSlice + MipLevels + ArraySize;
}

12.4、alpha-to-coverage技术

在运行演示程序,如果以特定距离观察树木公告牌,我们会发现边缘部分呈锯齿状。这个问题出在clip函数上,我们用它来遮罩不属于树木纹理的像素,因此树木边缘的过度并不平滑,同时加上观察者与公告牌距离过近会引起纹理放大的情况发生,继而使块状失真更加明显。而且近距离也会导致低分辨率mipmap的启用

解决这个问题的方法之一使使用透明混合代替alpha测试。通过线性纹理过滤,使边缘像素稍显模糊,从而使由不透明的像素到被遮罩的像素之间的过渡更为平滑。即透明混合可以让公告牌图像边缘的不透明像素到被遮罩像素之间实现平滑的渐变。但是使用透明缓和技术需要将场景中的物体按从后到前的顺序进行渲染,对于大量公告牌来说,在每一帧中进行排序是需要耗费大量性能的。所以这个方法 并不适合大量公告牌。

这个时候,我们可以采用MSAA(mutisampling antialiasing,多重采样抗锯齿技术)。使用MSAA可以解决块状失真的问题,但是也会出现一些问题。MSAA会给每一个像素执行一次像素着色器,使像素着色器在像素的中心采样,同时基于可视性和覆盖情况,将颜色信息共享给他的子像素。但关键是,覆盖情况使在多边形层级上确定下来的。因此MSAA并不会检测alpha通道所定义的树木公告牌的裁剪边缘,而是关注纹理所映射到的四边形的边缘。为了解决这个问题,我们就要使用alpha-to-coverage技术,使MSAA在计算覆盖情况的时候考虑alpha通道这个因素的影响。

问题:什么是可视性,什么是覆盖情况?

可视性:每个子像素所执行的深度/模板测试结构

覆盖情况:子像素的中心是位于多边形的内部还是外部

在开启MSAA和alpha-to-coverage(令成员D3D12_BLEND_DESC::AlphaToCoverageEnable = true)之后,硬件会检测像素着色器所返回的alpha值,用以确定覆盖情况。例如:在使用4x MSAA时,如果像素着色器返回的alpha值为0.5,那么即认为该像素里4个子像素中有2个是在四边形之外,并据此创建平缓的图像边缘。

一般来说,用alpha遮罩的方式来裁剪树叶和围栏这类纹理时,我们通常会使用alpha-to-coverage技术。

12.5、程序示例效果

12.5、小结

1、假设我们不使用曲面细分阶段,那么几何着色器这个可选阶段便会位于顶点着色器和像素着色器之间。几何着色器会对输入装配器传入的每一个图元进行处理,通过配置几何着色器,它可以不输出图元,输出一个图元,输出多个图元,而且输出的图元类型也可能和输入图元的类型不同。在输出的图元顶点离开几何着色器之前,我们应当将顶点变换到齐次裁剪空间中。几何着色器输出的图元,会进入渲染流水线的光栅化阶段

2、公告牌技术采用的是附有图像的四边形对象,我们可以将它作为真实3D模型的替代品。对于远处的物体来说,公告牌可以以假乱真。公告牌技术的优点是可以节省GPU渲染整个3D对象的处理时间。为了使公告牌技术更加逼真,我们一定要使公告牌总是面向摄像机。同时,在几何着色器中实现公告牌技术往往使最合适的。

3、我们可以把一种由语义SV_PrimitiveID修饰的特殊uint类型参数加入到几何着色器的参数列表之中,其示例代码如下:

[maxvertexcount(4)]
void GS(point VertexOut gin[1],uint primID : SV_PrimitiveID,inout TriangleStream<GeoOut> triStream)

该语义会告知输入装配器环节自动为每一个图元生成一个图元ID,使第一个图元标记为0,第二个图元标记为1……。如果用户没有使用几何着色器,则可以将图元ID参数添加到像素着色器的参数列表中,如果使用了几何着色器,则一定要将图元ID参数添加到几何着色器中。图元ID可以给几何着色器使用,也可以传递到像素着色器中供像素着色器使用

4、输入装配其可以为每一个图元生成图元ID,也可以为每一个顶点生成顶点ID。

5、纹理数组即存放纹理的数组,在c++中,纹理数组和其他的资源一样,用ID3DResource接口表示,创建ID3DResouce对象的时候,DepthOrArraySize就是用于指定纹理数组中元素个数的属性(如果是3D纹理,则该成员指定的是资源的深度值)。在HLSL中,纹理数组由TextureArray表示,使用纹理数组的时候,需要三个纹理坐标值,前两个坐标值为普通的2D纹理坐标,第三个值为纹理数组中的索引(从0开始)。使用纹理数组的优点之一:我们可以在单次绘制调用过程中,以多种不同的纹理渲染出一系列图元,而每一个图元都有一个指向纹理数组的索引,它指定了图元所应用的纹理。

6、在确定子像素的覆盖情况时,alpha-to-coverage可以让硬件检查像素着色器所返回的alpha数据,开启这项技术可以让树叶和围栏这样的使用alpha遮罩裁剪纹理具有平滑的边缘。

posted @ 2020-03-24 13:12  风雅yaya  阅读(336)  评论(0编辑  收藏  举报