Introduction to 3D Game Programming with DirectX 12 學習筆記之 --- 第十二章:幾何著色器(The Geometry Shader)
代碼工程地址:
https://github.com/jiabaodan/Direct12BookReadingNotes
假設我們沒有使用曲面細分階段,幾何著色器階段就是在頂點著色器和像素著色器之間的一個可選的階段。幾何著色器輸入的是基元,輸出的是一個基元列表;假如我們繪制的是三角形列表,那麽幾何著色器就是對每個三角形進行運算:
for(UINT i = 0; i < numTriangles; ++i)
OutputPrimitiveList = GeometryShader(T[i].vertexList );
幾何著色器的主要好處就是,它可以創建或刪除幾何體,所以它可以基於GPU實現一些很有意思的效果。比如可以將輸入的基元擴展為更多的基元輸出,或者根據某些條件不輸出部分基元。
值得註意的是幾何著色器輸出的基元類型不能和輸入的類型相同,所以常見的程序就是將一個頂點擴展成一個方塊。
輸出基元通過頂點列表來定義,頂點位置必須變換到其次裁切坐標系中。
學習目標
- 學習如何編寫幾何種色器程序;
- 學習公告牌效果如何被幾何著色器高效的實現;
- 識別自動輸出的基元ID和一些其它的應用;
- 學習如何創建和使用紋理數組,理解為什它們有用;
- 學習為什麽alpha-to-coverage可以幫助解決透明切口的抗鋸齒問題。
1 幾何著色器編程
幾何著色器編程很像頂點和像素著色器,但是有一些不同的地方。下面的代碼展示了它的基本形式:
[maxvertexcount(N)] void ShaderName (PrimitiveType InputVertexType InputName [NumElements], inout StreamOutputObject<OutputVertexType> OutputName) { // Geometry shader body… }
首先要聲明單詞調用時,輸出的頂點的最大數量;通過在函數聲明前添加下面語句:
[maxvertexcount(N)]
輸出的頂點數量在每次調用時是可變的,但是不能超過最大值。以優化為目標,最大值應該越小越好。[NVIDIA08]寫明當最大值在1 ~ 20時,運行效率最高;當值為27~40時,運行效率會降低50%。在實踐中基於上述限制條件來應用是比較困難的,但是[NVIDIA08]是在2008年發表的,所以現在情況會好一些。
幾何著色器有2個參數:輸入和輸出。輸入參數是一個用來定義基元的頂點列表。頂點類型是頂點著色器返回的頂點類型。輸入參數必須要有一個基元類型前綴,它可以是下面的值:
- point:輸入的是點;
- line:輸入的是線(lists or strips);
- triangle:三角形(lists or strips);
- lineadj:鄰接線(lists or strips);
- triangleadj:鄰接三角形(lists or strips)。
輸入到幾何著色器中的基元是完整的基元,所以不用關心是lists or strips。如果是strip代表頂點會被多個三角形共用,也就會被幾何著色器執行多次。
輸出參數會有inout修飾符。並且它是流類型,它保存了輸出的頂點列表。幾何著色器程序通過內置的Append方法添加頂點到輸出流列表中:
void StreamOutputObject<OutputVertexType>::Append(OutputVertexType v);
流類型是一個模板類型,模板參數用來指定輸出頂點的頂點類型,有三種可能的流類型:
- PointStream:頂點列表定義點列表;
- LineStream:頂點列表定義線strip;
- TriangleStream:頂點列表定義三角形strip。
對於線和三角形 它們是strip類型。對於線和三角形列表,可以通過調用內置的RestartStrip函數來模擬:
void StreamOutputObject<OutputVertexType>::RestartStrip();
比如如果你想輸出三角形列表,可以在每次添加3個頂點後,調用這個函數。
下面是一些定義幾何著色器簽名的例子:
// EXAMPLE 1: GS ouputs at most 4 vertices. The input primitive is a
// line.
// The output is a triangle strip.
//
[maxvertexcount(4)]
void GS(line VertexOut gin[2],
inout TriangleStream<GeoOut> triStream)
{
// Geometry shader body…
} //
// EXAMPLE 2: GS outputs at most 32 vertices. The input primitive is
// a triangle. The output is a triangle strip.
//
[maxvertexcount(32)]
void GS(triangle VertexOut gin[3],
inout TriangleStream<GeoOut> triStream)
{>>>>>>>>>>>>>>>>>>
// Geometry shader body…
} //
// EXAMPLE 3: GS outputs at most 4 vertices. The input primitive
// is a point. The output is a triangle strip.
//
[maxvertexcount(4)]
void GS(point VertexOut gin[1],
inout TriangleStream<GeoOut> triStream)
{
// Geometry shader body…
}
下面的幾何著色器舉例說明了Append和RestartStrip函數;它輸入三角形,細分和輸出了4個細分的三角形:
struct VertexOut
{
float3 PosL : POSITION;
float3 NormalL : 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];
// Compute edge midpoints.
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);
// Project onto unit sphere
m[0].PosL = normalize(m[0].PosL);
m[1].PosL = normalize(m[1].PosL);
m[2].PosL = normalize(m[2].PosL);
// Derive normals.
m[0].NormalL = m[0].PosL;
m[1].NormalL = m[1].PosL;
m[2].NormalL = m[2].PosL;
// Interpolate texture coordinates.
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)
{
// Transform to world space space.
gout[i].PosW = mul(float4(v[i].PosL, 1.0f), gWorld).xyz;
gout[i].NormalW = mul(v[i].NormalL, (float3x3)gWorldInvTranspose);
// Transform to homogeneous clip space.
gout[i].PosH = mul(float4(v[i].PosL, 1.0f), gWorldViewProj);
gout[i].Tex = v[i].Tex;
}
// We can draw the subdivision in two strips:
// Strip 1: bottom three triangles
// Strip 2: top triangle
[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>)
{
VertexOut v[6];
Subdivide(gin, v);
OutputSubdivision(v, triStream);
}
幾何著色器的編譯和頂點與像素著色器非常相似,假設在TreeSprite.hlsl文件中由一個GS幾何著色器:
mShaders["treeSpriteGS"] = d3dUtil::CompileShader(
L"Shaders\\TreeSprite.hlsl", nullptr, "GS",
"gs_5_0");
然後將它綁定到PSO中:
D3D12_GRAPHICS_PIPELINE_STATE_DESC treeSpritePsoDesc = opaquePsoDesc;
…
treeSpritePsoDesc.GS =
{
reinterpret_cast<BYTE*> (mShaders["treeSpriteGS"]->GetBufferPointer()),
mShaders["treeSpriteGS"]->GetBufferSize()
};
如果在幾何著色器中不輸出足夠的頂點構成指定的基元,那麽這個基元會被廢棄。
2 樹的公告牌Demo
2.1 概述
如果樹木很遠,使用公告牌技術可以提高性能,它使用一個2D圖片來代替3D樹,並且讓它一直對準相機。
所以給出中心點C,和相機位置E(世界坐標系),我們就可以描述和世界坐標系相關聯的公告牌局部坐標系:
再給出公告牌的尺寸,公告牌4個頂點就可以計算出來:
v[0] = float4(gin[0].CenterW + halfWidth*right - halfHeight*up, 1.0f);
v[1] = float4(gin[0].CenterW + halfWidth*right + halfHeight*up, 1.0f);
v[2] = float4(gin[0].CenterW - halfWidth*right - halfHeight*up, 1.0f);
v[3] = float4(gin[0].CenterW - halfWidth*right + halfHeight*up, 1.0f);
對於當前Demo我們會創建一個頂點基元列表(PrimitiveTopologyType為D3D12_PRIMITIVE_TOPOLOGY_TYPE_POINT的PSO和參數為D3D_PRIMITIVE_TOPOLOGY_POINTLIST的ID3D12GraphicsCommandList::IASetPrimitiveTopology)。這些頂點代表公告牌的中心點。
一種常用的公告板實現方法是基於CPU,使用4個頂點代表公告牌放到動態頂點緩沖中(upload heap)。然後當攝像機運動的時候更新頂點,然後memcpyed到GPU緩沖,這樣可以保證公告牌面向攝像機。這種方案需要在IA階段提交4個頂點,並且需要更新動態頂點緩沖(造成性能開銷)。但是使用幾何著色器,可以使用靜態頂點緩沖,並且內存拷貝就很小,我們只需要在IA階段拷貝1個頂點。
2.2 頂點結構
公告牌點我們使用下面的頂點結構:
struct TreeSpriteVertex
{
XMFLOAT3 Pos;
XMFLOAT2 Size;
};
mTreeSpriteInputLayout =
{
{
"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0
},
{
"SIZE", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 12,
D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0
},
};
2.3 HLSL文件
因為這個是我們的第一個幾何著色器Demo,所以展示完整的HLSL代碼。有一些結構SV_PrimitiveID和Texture2DArray目前沒有介紹過,這些會在下面的章節介紹。現在主要關註GS函數,擴展一個頂點到公告牌:
//****************************************************************************
// TreeSprite.hlsl by Frank Luna (C) 2015 All Rights Reserved.
//****************************************************************************
// Defaults for number of lights.
#ifndef NUM_DIR_LIGHTS
#define NUM_DIR_LIGHTS 3
#endif
#ifndef NUM_POINT_LIGHTS
#define NUM_POINT_LIGHTS 0
#endif
#ifndef NUM_SPOT_LIGHTS
#define NUM_SPOT_LIGHTS 0
#endif
// Include structures and functions for lighting.
#include "LightingUtil.hlsl"
Texture2DArray gTreeMapArray : register(t0);
SamplerState gsamPointWrap : register(s0);
SamplerState gsamPointClamp : register(s1);
SamplerState gsamLinearWrap : register(s2);
SamplerState gsamLinearClamp : register(s3);
SamplerState gsamAnisotropicWrap : register(s4);
SamplerState gsamAnisotropicClamp : register(s5);
// Constant data that varies per frame.
cbuffer cbPerObject : register(b0)
{
float4x4 gWorld;
float4x4 gTexTransform;
};
// Constant data that varies per material.
cbuffer cbPass : register(b1)
{
float4x4 gView;
float4x4 gInvView;
float4x4 gProj;
float4x4 gInvProj;
float4x4 gViewProj;
float4x4 gInvViewProj;
float3 gEyePosW;
float cbPerPassPad1;
float2 gRenderTargetSize;
float2 gInvRenderTargetSize;
float gNearZ;
float gFarZ;
float gTotalTime;
float gDeltaTime;
float4 gAmbientLight;
float4 gFogColor;
float gFogStart;
float gFogRange;
float2 cbPerPassPad2;
// Indices [0, NUM_DIR_LIGHTS) are directional lights;
// indices [NUM_DIR_LIGHTS, NUM_DIR_LIGHTS+NUM_POINT_LIGHTS) are point
// lights;
// indices [NUM_DIR_LIGHTS+NUM_POINT_LIGHTS,
// NUM_DIR_LIGHTS+NUM_POINT_LIGHT+NUM_SPOT_LIGHTS)
// are spot lights for a maximum of MaxLights per object.
Light gLights[MaxLights];
};
cbuffer cbMaterial : register(b2)
{
float4 gDiffuseAlbedo;
float3 gFresnelR0;
float gRoughness;
float4x4 gMatTransform;
};
struct VertexIn
{
float3 PosW : POSITION;
float2 SizeW : SIZE;
};
struct VertexOut
{
float3 CenterW : POSITION;
float2 SizeW : SIZE;
};
struct GeoOut
{
float4 PosH : SV_POSITION;
float3 PosW : POSITION;
float3 NormalW : NORMAL;
float2 TexC : TEXCOORD;
uint PrimID : SV_PrimitiveID;
};
VertexOut VS(VertexIn vin)
{
VertexOut vout;
// Just pass data over to geometry shader.
vout.CenterW = vin.PosW;
vout.SizeW = vin.SizeW;
return vout;
}
// We expand each point into a quad (4 vertices), so the maximum number of vertices
// we output per geometry shader invocation is 4.
[maxvertexcount(4)]
void GS(point VertexOut gin[1],
uint primID : SV_PrimitiveID,
inout TriangleStream<GeoOut> triStream)
{
//
// Compute the local coordinate system of the sprite relative to the world
// space such that the billboard is aligned with the y-axis and faces the eye.
//
float3 up = float3(0.0f, 1.0f, 0.0f);
float3 look = gEyePosW - gin[0].CenterW;
look.y = 0.0f; // y-axis aligned, so project to xz-plane
look = normalize(look);
float3 right = cross(up, look);
//
// Compute triangle strip vertices (quad) in world space.
//
float halfWidth = 0.5f*gin[0].SizeW.x;
float halfHeight = 0.5f*gin[0].SizeW.y;
float4 v[4];
v[0] = float4(gin[0].CenterW + halfWidth*right - halfHeight*up, 1.0f);
v[1] = float4(gin[0].CenterW + halfWidth*right + halfHeight*up, 1.0f);
v[2] = float4(gin[0].CenterW - halfWidth*right - halfHeight*up, 1.0f);
v[3] = float4(gin[0].CenterW - halfWidth*right + halfHeight*up, 1.0f);
//
// Transform quad vertices to world space and output
// them as a triangle strip.
//
float2 texC[4] =
{
float2(0.0f, 1.0f),
float2(0.0f, 0.0f),
float2(1.0f, 1.0f),
float2(1.0f, 0.0f)
};
GeoOut gout;
[unroll]
for(int i = 0; i < 4; ++i)
{
gout.PosH = mul(v[i], gViewProj);
gout.PosW = v[i].xyz;
gout.NormalW = look;
gout.TexC = texC[i];
gout.PrimID = primID;
triStream.Append(gout);
}
}
float4 PS(GeoOut pin) : SV_Target
{
float3 uvw = float3(pin.TexC, pin.PrimID%3);
float4 diffuseAlbedo = gTreeMapArray.Sample(gsamAnisotropicWrap, uvw) * gDiffuseAlbedo;
#ifdef ALPHA_TEST
// Discard pixel if texture alpha < 0.1. We do this test as soon
// as possible in the shader so that we can potentially exit the
// shader early, thereby skipping the rest of the shader code.
clip(diffuseAlbedo.a - 0.1f);
#endif
// Interpolating normal can unnormalize it, so renormalize it.
pin.NormalW = normalize(pin.NormalW);
// Vector from point being lit to eye.
float3 toEyeW = gEyePosW - pin.PosW;
float distToEye = length(toEyeW);
toEyeW /= distToEye; // normalize
// Light terms.
float4 ambient = gAmbientLight*diffuseAlbedo;
const float shininess = 1.0f - gRoughness;
Material mat = { diffuseAlbedo, gFresnelR0, shininess };
float3 shadowFactor = 1.0f;
float4 directLight = ComputeLighting(gLights, mat, pin.PosW,
pin.NormalW, toEyeW, shadowFactor);
float4 litColor = ambient + directLight;
#ifdef FOG
float fogAmount = saturate((distToEye - gFogStart) / gFogRange);
litColor = lerp(litColor, gFogColor, fogAmount);
#endif
// Common convention to take alpha from diffuse albedo.
litColor.a = diffuseAlbedo.a;
return litColor;
}
2.4 SV_PrimitiveID
在上面的例子中,有一個特殊的無符號整形參數帶有SV_PrimitiveID標識:
[maxvertexcount(4)]
void GS(point VertexOut gin[1],
**uint primID : SV_PrimitiveID,**
inout TriangleStream<GeoOut> triStream)
當指定了它的時候,IA階段會為每個基元自動創建一個基元ID。這個ID只在單個繪制調用中是唯一的。
如果沒有幾何著色器,SV_PrimitiveID可以添加到頂點著色器的參數列表中。
float4 PS(VertexOut pin, uint primID : SV_PrimitiveID) : SV_Target
{
// Pixel shader body…
}
如果有幾何著色器,那麽primitive ID只能添加到幾何著色器簽名中。
IA階段也可以創建SV_VertexID,通過在頂點著色器簽名中添加:
VertexOut VS(VertexIn vin, uint vertID : SV_VertexID)
{
// vertex shader body…
}
3 紋理數組
3.1 概述
紋理數組保存一個數組的紋理,在C++代碼中由ID3D12Resource接口表示。創建ID3D12Resource對象的時候,有一個DepthOrArraySize屬性定義紋理元素的數量。如果查看Common/DDSTextureLoader.cpp文件中的CreateD3DResources12,你可以看到如何使用它創建紋理數組和體積紋理。在HLSL中,Texture2DArray代表紋理數組:
Texture2DArray gTreeMapArray;
現在你可能會問,為什麽要使用紋理數組,為什麽不直接這麽做:
Texture2D TexArray[4];
…
float4 PS(GeoOut pin) : SV_Target
{
float4 c = TexArray[pin.PrimID%4].Sample(samLinear, pin.Tex);
在著色器模型5.1中,我們確實可以這麽做,但是在前一個D3D版本中,就不能這麽做了。並且這樣索引紋理會有些硬件上的開銷,所以本章使用紋理數組。
3.2 對紋理數組采樣
在上面的Demo中,我們這樣采樣:
float3 uvw = float3(pin.Tex, pin.PrimID%4);
float4 diffuseAlbedo = gTreeMapArray.Sample(gsamAnisotropicWrap, uvw) * gDiffuseAlbedo;
對紋理數組采樣的時候,前兩個坐標還是代表UV,第三個坐標代表紋理索引。
本章紋理數組資源:
紋理數組的另一個好處是,可以在一個繪制調用中對多個基元使用不同紋理進行繪制。而不使用紋理數組,通常情況下,我們只能設置不同的紋理:
SetTextureA();
DrawPrimitivesWithTextureA();
SetTextureB();
DrawPrimitivesWithTextureB();
…
SetTextureZ();
DrawPrimitivesWithTextureZ();
每一個設置和繪制調用都有開銷。而使用紋理數組,我們只需要設置一次:
SetTextureArray();
DrawPrimitivesWithTextureArray();
3.3 加載紋理數組
我們的Common/DDSTextureLoader.h/.cpp支持紋理數組的加載。主要問題是創建具有紋理數組的DDS文件。我們可以使用微軟提供的texassemble工具(https://archive.codeplex.com/?p=directxtex)。下面的命令是一個創建的例子:
texassemble -array -o treeArray.dds t0.dds t1.dds t2.dds t2.dds
使用texassemble創建DDS的時候,圖像只能有一個Mipmap等級。創建完成後可以使用texconv創建mipmap和修改像素格式(https://archive.codeplex.com/?p=directxtex)。
texconv -m 10 -f BC3_UNORM treeArray.dds
3.4 紋理子資源
下圖展示了具有多個紋理的紋理數組的例子:(具有3個紋理,每個紋理有3個mipmap等級)
給出一個紋理索引和Mipmap等級,可以訪問紋理數組的子資源。然而子資源也可以線性索引:
下面的函數用來通過給出的mip等級,數組索引和mipmap等級,計算子資源的線性索引:
inline UINT D3D12CalcSubresource( UINT MipSlice,
UINT ArraySlice,
UINT PlaneSlice, UINT MipLevels, UINT ArraySize)
{
return MipSlice + ArraySlice * MipLevels + PlaneSlice * MipLevels * ArraySize;
}
4 ALPHA-TO-COVERAGE
當我們運行樹公告牌Demo的時候,樹的一些邊緣會有塊狀的錯誤顯示。它是由clip方程導致的,它沒有光滑漸變。樹的距離越近的時候,這些塊狀效果就越大。
解決這個的一種方案是使用透明混合替代clip函數的調用,使用線性濾波器讓邊緣增加一些模糊,輸出光滑的過渡而解決塊狀效果的問題。但是透明混合需要排序和從後往前渲染;如果我們渲染的是一個森林,排序的開銷就非常大。並且從後往前渲染的開銷也是非常大(第十一章練習8)。
還有一個建議是MSAA(multisampling antialiasing),它是有用的,但是它是基於像素執行,在每個像素的中心,分享顏色到可見的子像素和覆蓋。所以主要問題在於,覆蓋是在物體等級上決定的。所以MSAA不檢測公告牌有alpha通道切割出來的邊緣。
當MSAA開啟,並且alpha-to-coverage也開啟(D3D12_BLEND_DESC::AlphaToCoverageEnable = true),硬件將會根據像素著色器返回的alpha值來決定覆蓋([NVIDIA05])。比如4X MSAA,如果像素著色器的alpha維0.5,那麽我們可以假設4個子像素中的2個是被覆蓋的,所以就會創建平滑的邊緣。
對於植物葉子或者圍欄,建議一直使用alpha-to-coverage。它需要MSAA是啟用的。
5 總結
- 假設不使用曲面細分階段,幾何著色器是頂點和像素著色器之間的一個可選的階段。它會在每個基元上被調用;
- 公告牌技術是在一個平面上繪制一個紋理,並將它一直面向攝像機來替代3D物體,提高性能;相對於傳統的基於CPU的實現方案,基於幾何著色器可以更高效的實現;
- SV_PrimitiveID(無符號整形)參數可以添加到幾何著色器參數列表中,添加後,在IA階段中會為每個基元創建一個ID,該ID只在每個繪制調用中是唯一的;
- IA階段也可以創建頂點ID,在頂點著色器參數列表中添加一個SV_VertexID的無符號整形;
- 紋理數組保存一個數組的紋理,在C++代碼中紋理數組由ID3D12Resource接口表示,通過DepthOrArraySize屬性設置紋理個數。在HLSL中由Texture2DArray類型表示,在采樣的時候,前兩個坐標表示UV,第三個表示索引。
- Alpha-to-coverage是硬件根據像素著色器返回的alpha值來決定子像素是否被覆蓋,然後來創建平滑的邊緣,它是由PAO中D3D12_BLEND_DESC::AlphaToCoverageEnable來控制。
6 練習
練習後續再做
Introduction to 3D Game Programming with DirectX 12 學習筆記之 --- 第十二章:幾何著色器(The Geometry Shader)