DirectX11 With Windows SDK--36 延遲渲染基礎

前言

隨着圖形硬件變得越來越通用和可編程化,採用實時3D圖形渲染的應用程序已經開始探索傳統渲染管線的替代方案,以避免其缺點。其中一項最流行的技術就是所謂的延遲渲染。這項技術主要是爲了支持大量的動態燈光,而不需要一套複雜的着色器程序。

爲了迎接這一章的項目,會對原來的代碼有許多改動,並且會有許多引申的內容。

DirectX11 With Windows SDK完整目錄

Github項目源碼

歡迎加入QQ羣: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裏彙報。

前向渲染(Forward Rendering)的問題

在設計渲染器時,最重要的一個方面是確定如何處理光照。這非常重要,因爲它需要進行涉及到光照強度以及幾何體表面一點的反射顏色的計算。通常它包含如下步驟:

  1. 基於光照類型和衰弱屬性,確定哪些光照需要應用到當前的特定像素上
  2. 使用材質、表面屬性以及剛纔確定需要用到的光照來爲每個像素計算顏色
  3. 確定這些像素是否在陰影中,即光照是否能打到該像素。

這種傳統的處理方式通常叫做前向渲染。通過前向渲染,幾何體使用大量存儲在頂點屬性數據、紋理、常量緩衝區中的表面屬性來進行渲染。然後,根據輸入的幾何體對每個像素進行光柵化操作,結合材質數據對一個或多個燈光進行光照計算。最終的計算結果將輸出到每個像素中,並且可能與之前存在渲染目標的結果進行累加。這種方式是直接且直觀的,在可編程圖形硬件出現之前,它是實時3D圖形渲染的絕大部分方式。早期固定管線的GPU支持三種不同的光照類型:點光源、聚光燈、方向光

而隨着實時渲染引擎的發展以適應可編程硬件和更復雜的場景,這些基本的光照類型由於其靈活性和普遍性,大部分都被保留了下來。然而,它們的使用方式已經開始顯示出它們的年邁。這是因爲在現代硬件和API下,當它們開始支持大量的動態光源時,前向渲染暴露出幾項關鍵的弱點:

第一個缺點是,光源的使用與場景中繪製的幾何體的粒度(對象)掛鉤。換句話說,當我們開啓一個點光源,我們必須將其應用到在任何特定的繪製調用期間經過光柵化後的所有幾何體當中。一開始我們可能注意不到這會帶來什麼樣的問題,尤其是我們在場景中只是添加了少數幾盞燈光。然而隨着光源數目的增長,着色器的計算量開始劇增,選擇性地應用光源就開始變得重要了,這麼做是爲了減少像素着色器中光照計算的執行次數。但因爲我們只能在每次繪製之間完成光照的修改,導致我們在剔除不需要的燈光時受到了限制。那些燈光本應該只會影響到光柵化中的一小部分幾何體的情況,我們卻又不得不將那盞燈光應用到所有的幾何體。雖然我們可以將場景中的網格分割成更小的部分來提升粒度,但這增加了當前幀的繪製調用的數目(從而導致CPU開銷的提升)。這還增加了需要確定光照是否影響網格的相交測試的數目,給CPU帶來了更大的負擔。另一個相關的後果是我們在單批次渲染多個幾何體實例的能力將會降低,因爲單批次中所有幾何體實例所使用的燈光數目必須是相同的,這些實例可能會出現在場景中的不同位置,它們本應該會受到不同燈光組合的影響。

另一個前向渲染的主要缺點是着色器程序的複雜性問題。爲了控制大量的燈光和各種材質變化的燈光類型,可能會導致所需的着色器排列組合產生的數量激增。大量的着色器排列組合不是我們所期望的,因爲這會增加內存的使用,以及着色器程序之間切換的開銷。由於依賴於這些組合怎麼被編譯,它們還會顯著增加編譯的次數。使用多着色器程序的另一種選擇是使用動態流控制,這將影響GPU的性能。另一種方法是一次只渲染一個燈光,並將結果添加到渲染目標中,這稱爲多通道渲染multi-pass rendering)。然而,這種方法需要爲多次變換和光柵化幾何體付出代價。即便忽略掉與許多排列組合相關的問題,生成的像素着色器程序本身也可能變得非常昂貴和複雜。這是因爲需要評估材質屬性並對所有活動的光源執行必要的照明和陰影計算。這會使得着色器程序難以編寫、維護和優化。它們的性能還與場景的過度繪製有關,因爲過度繪製會產生甚至不可見的着色像素。一個只使用z深度的預處理可以顯著減少過度繪製,但它的效率受到硬件中Hi-Z單元的實現的限制。着色器執行也會被浪費,因爲像素着色器必須在至少2x2個四邊形中進行,這對於具有許多小三角形的高度細分場景尤其不利

這些缺陷共同導致很難在場景中擴展動態光照的數目,同時爲實時應用保持足夠的性能。然而如果你非常仔細地去看這些描述,你將會注意到所有這些缺陷根源自前向渲染固有的一個主要問題:光照場景幾何圖形的光柵化緊密耦合。如果我們將這兩個步驟進行解耦,我們就有可能限制或完全繞過其中的一些缺點。仔細觀察光照過程中的第二步,我們可以發現爲了計算光照,需要利用到材質屬性幾何體表面屬性。這意味着如果我們有這樣一個步驟,在渲染過程中將所有需要的這些屬性放入一個緩衝區中(我們通常叫它爲幾何緩衝區,或G-Buffer),緊接着我們就可以對所有場景中的燈光進行遍歷,併爲每個像素計算出光照相關的值,這正是延遲渲染的前提。

使用RTX 2070 Mobile渲染帶1024點光源的Sponza場景,即便是開啓了預視錐體剔除,總體*均幀數僅有40幀:

Pre-Z Pass/Depth Prepass

前向渲染也是有比較大的優化空間的,但現階段我們只講能夠很快實現的部分。

在繪製一個複雜場景的時候,可能會出現某些區域三角形反覆覆蓋繪製的情況,增加了很多不必要的繪製。爲此我們可以在進行正式渲染前,先對場景中的物體只做深度測試,不執行像素着色階段;然後第二遍場景繪製的時候只繪製和深度緩衝區中深度值相等的像素。這樣做可以有效減少像素着色器的執行次數。

Pre-Z Pass在C++端的代碼實現也十分簡單,具體可以參考項目中的代碼。

注意:在測試幀數差距時務必使用Release模式。

延遲渲染管線

延遲渲染管線的第一步是幾何階段:渲染所有的場景幾何體到G-Buffer當中。這些緩衝區通常由一些渲染目標紋理所組成,但它們的各個通道用於逐像素存儲幾何體表面信息,例如表面法向量材質屬性等。

第二步是光照階段。在這一步中,可以將渲染表示爲當前光照影響屏幕區域的幾何體的過程。對於每個待着色的像素片元,將從G-Buffer中採樣幾何體的信息,然後與光照的屬性結合來確定對於該像素產生的光照貢獻值,再與所有其它影響那一像素的光源的貢獻值累加到一起,來確定最終表面的發光顏色。

因爲延遲渲染避免了前向渲染的主要缺陷(光照與幾何體緊密耦合),它擁有下面這些優勢:

  • 着色器的組合數目將大幅減少,因爲光照和陰影的計算可以移動到獨立的着色器程序中進行。
  • 可以更加頻繁地批處理網格實例,因爲我們在渲染場景幾何體的時候不再需要光照參數。所以,對於網格的所有實例,活動的光源可以不需要相同。
  • 不再需要在CPU執行工作來確定哪些燈光影響屏幕的不同區域。取而代之的是,碰撞體或屏幕空間四邊形可以光柵化在燈光影響的屏幕部分上。深度和模板測試也可以用於進一步減少着色器執行的次數。
  • 總共需要的着色器和渲染框架體系可以被簡化,因爲光照和幾何體已經被解耦了。

這些優勢使得延遲渲染在渲染引擎中非常流行。不幸的是,這種方法也會產生一些缺陷:

  • 必須有大量的內存和帶寬專門用於G-Buffer的生成和採樣,因爲它需要存儲計算該像素所需的任何信息。
  • 使用硬件MSAA變得困難,不是說使用延遲渲染就不能使用MSAA了。開啓MSAA可能意味着連同G-Buffer的佔用空間也會成倍的提升,而且可能帶來顯著提升的計算負擔。
  • 透明幾何體的處理不能以不透明幾何體處理的方式進行,因爲它不能被渲染進G-Buffer。又因爲G-Buffer僅僅能夠保存單個表面的屬性,且渲染透明幾何體需要計算爲多個重疊的逐像素光照,並將計算顏色的結果結合起來。
  • 如果使用BRDF材質模型的話,這種方式將變得不再簡單,因爲計算像素最終的顏色已經被移到光照pass了

渲染相關的着色器

現假定我們的場景中只包含一系列的點光源,並且使用的是Phong光照模型,不引入陰影和SSAO,沒有透明物體。對於材質,我們只用到漫反射貼圖。

在幾何階段,有:

// ConstantBuffers.hlsl
cbuffer CBChangesEveryInstanceDrawing : register(b0)
{
    matrix g_WorldInvTransposeView;
    matrix g_WorldView;
    matrix g_ViewProj;
    matrix g_Proj;
    matrix g_WorldViewProj;
}

cbuffer CBPerFrame : register(b1)
{
    float4 g_CameraNearFar;
    uint g_LightingOnly;
    uint g_FaceNormals;
    uint g_VisualizeLightCount;
    uint g_VisualizePerSampleShading;
}

// Rendering.hlsl
//--------------------------------------------------------------------------------------
// 幾何階段 
//--------------------------------------------------------------------------------------
Texture2D g_TextureDiffuse : register(t0);
SamplerState g_SamplerDiffuse : register(s0);

struct VertexPosNormalTex
{
    float3 posL : POSITION;
    float3 normalL : NORMAL;
    float2 texCoord : TEXCOORD;
};

struct VertexPosHVNormalVTex
{
    float4 posH : SV_POSITION;
    float3 posV : POSITION;
    float3 normalV : NORMAL;
    float2 texCoord : TEXCOORD;
};

VertexPosHVNormalVTex GeometryVS(VertexPosNormalTex input)
{
    VertexPosHVNormalVTex output;

    output.posH = mul(float4(input.posL, 1.0f), g_WorldViewProj);
    output.posV = mul(float4(input.posL, 1.0f), g_WorldView).xyz;
    output.normalV = mul(float4(input.normalL, 0.0f), g_WorldInvTransposeView).xyz;
    output.texCoord = input.texCoord;
    
    return output;
}

對光照階段,有:

// Rendering.hlsl
float3 ComputeFaceNormal(float3 pos)
{
    return cross(ddx_coarse(pos), ddy_coarse(pos));
}

struct SurfaceData
{
    float3 posV;
    float3 posV_DX;
    float3 posV_DY;
    float3 normalV;
    float4 albedo;
    float specularAmount;
    float specularPower;
};

SurfaceData ComputeSurfaceDataFromGeometry(VertexPosHVNormalVTex input)
{
    SurfaceData surface;
    surface.posV = input.posV;
    
    // 右/下相鄰像素與當前像素的位置差
    surface.posV_DX = ddx_coarse(surface.posV);
    surface.posV_DY = ddy_coarse(surface.posV);
    
    // 該表面法線可用於替代提供的法線
    float3 faceNormal = ComputeFaceNormal(input.posV);
    surface.normalV = normalize(g_FaceNormals ? faceNormal : input.normalV);
    
    surface.albedo = g_TextureDiffuse.Sample(g_SamplerDiffuse, input.texCoord);
    surface.albedo.rgb = g_LightingOnly ? float3(1.0f, 1.0f, 1.0f) : surface.albedo.rgb;
    
    // 將空漫反射紋理映射爲白色
    uint2 textureDim;
    g_TextureDiffuse.GetDimensions(textureDim.x, textureDim.y);
    surface.albedo = (textureDim.x == 0U ? float4(1.0f, 1.0f, 1.0f, 1.0f) : surface.albedo);
    
    // 我們沒有藝術資產相關的值來設置下面這些,現在暫且設置成看起來比較合理的值
    surface.specularAmount = 0.9f;
    surface.specularPower = 25.0f;
    
    return surface;
}

//--------------------------------------------------------------------------------------
// 光照階段 
//--------------------------------------------------------------------------------------
struct PointLight
{
    float3 posV;
    float attenuationBegin;
    float3 color;
    float attenuationEnd;
};

// 大量的動態點光源
StructuredBuffer<PointLight> g_Light : register(t5);

// 這裏分成diffuse/specular項方便後續延遲光照使用
void AccumulatePhong(float3 normal, float3 lightDir, float3 viewDir, float3 lightContrib, float specularPower,
                     inout float3 litDiffuse, inout float3 litSpecular)
{
    float NdotL = dot(normal, lightDir);
    [flatten]
    if (NdotL > 0.0f)
    {
        float3 r = reflect(lightDir, normal);
        float RdotV = max(0.0f, dot(r, viewDir));
        float specular = pow(RdotV, specularPower);
        
        litDiffuse += lightContrib * NdotL;
        litSpecular += lightContrib * specular;
    }
}

void AccumulateDiffuseSpecular(SurfaceData surface, PointLight light,
                               inout float3 litDiffuse, inout float3 litSpecular)
{
    float3 dirToLight = light.posV - surface.posV;
    float distToLight = length(dirToLight);
    
    [branch]
    if (distToLight < light.attenuationEnd)
    {
        float attenuation = linstep(light.attenuationEnd, light.attenuationBegin, distToLight);
        dirToLight *= rcp(distToLight);
        AccumulatePhong(surface.normalV, dirToLight, normalize(surface.posV),
            attenuation * light.color, surface.specularPower, litDiffuse, litSpecular);
    }
    
}

void AccumulateColor(SurfaceData surface, PointLight light,
                     inout float3 litColor)
{
    float3 dirToLight = light.posV - surface.posV;
    float distToLight = length(dirToLight);
    
    [branch]
    if (distToLight < light.attenuationEnd)
    {
        float attenuation = linstep(light.attenuationEnd, light.attenuationBegin, distToLight);
        dirToLight *= rcp(distToLight);
        
        float3 litDiffuse = float3(0.0f, 0.0f, 0.0f);
        float3 litSpecular = float3(0.0f, 0.0f, 0.0f);
        AccumulatePhong(surface.normalV, dirToLight, normalize(surface.posV),
            attenuation * light.color, surface.specularPower, litDiffuse, litSpecular);
        litColor += surface.albedo.rgb * (litDiffuse + surface.specularAmount * litSpecular);
    }
}

// Forward.hlsl

//--------------------------------------------------------------------------------------
// 計算點光源着色 
float4 ForwardPS(VertexPosHVNormalVTex input) : SV_Target
{
    uint totalLights, dummy;
    g_Light.GetDimensions(totalLights, dummy);

    float3 litColor = float3(0.0f, 0.0f, 0.0f);

    [branch]
    // 用灰度表示當前像素接受的燈光數目
    if (g_VisualizeLightCount)
    {
        litColor = (float(totalLights) * rcp(255.0f)).xxx;
    }
    else
    {
        SurfaceData surface = ComputeSurfaceDataFromGeometry(input);
        for (uint lightIndex = 0; lightIndex < totalLights; ++lightIndex)
        {
            PointLight light = g_Light[lightIndex];
            AccumulateColor(surface, light, litColor);
        }
    }

    return float4(litColor, 1.0f);
}

ddx和ddy

對於ddx和ddy,在光柵化過程中,GPU會在同一時刻並行運行很多像素着色器(如32-64個一組)而不是逐像素來執行,然後這些像素將組織在2x2一組的像素分塊中。

這裏的偏導數對應的是這一像素塊中的變化率。ddx就是拿右邊像素的值減去左邊像素的值,ddy則是拿下面像素的值減去上面像素的值。x和y爲屏幕的座標。

而ddx和ddy系列的函數只能用於像素着色器,是因爲它需要依賴的是像素片元中的輸入數據來求偏導。

例如,若傳遞的是posH,則有:

\[dFdx(posH(x,y))=posH(x+1,y)-posH(x,y)==(1,0,z(x+1,y)-z(x,y),0) \]

我們可以通過對位置求偏導,來求出該像素點處所屬三角表面的法線。

float3 ComputeFaceNormal(float3 pos)
{
    return cross(ddx_coarse(pos), ddy_coarse(pos));
}

需要注意的是,通過這種方式求出來的法線,會和它對應三角面的所有像素用這種方式求出來的法線是*似相同的,但它不要求傳入的頂點有法線數據,因此它適用於頂點沒有法線屬性的情況。在有頂點法線時,三角面內的像素法線是通過插值得到到的,看起來表面就會比較光滑。

此外ddx/ddy還有精度較低的ddx_coarse/ddy_coarse和精度較高的ddx_fine/ddx_fine版本

實際上,在對紋理進行採樣時,選用哪一級的mipmap正是依賴於ddx和ddy的信息。屏幕空間的貼圖uv偏導數過大表示貼圖理我們過遠,就會選擇mipLevel更高的子紋理。

經典延遲渲染

多渲染目標(Multiple Render Targets)

要使用延遲渲染,當前的圖形庫必須要能支持多渲染目標。在C++端,這是通過ID3D11DeviceContext::OMSetRenderTargets所提供的:

void ID3D11DeviceContext::OMSetRenderTargets(
    UINT NumViews,
    ID3D11RenderTargetView * const *ppRenderTargetViews,
    ID3D11DepthStencilView *pDepthStencilView
);

對Direct3D 11來說,我們最多能夠同時設置八個RTV,即像素着色器最多能夠同時向八個紋理輸出數據。

在HLSL,我們需要通過SV_Target[n]來指定當前變量輸出到哪個渲染目標。例如:

struct PixelOut
{
    float4 Normal_Specular : SV_Target0;            
    float4 Diffuse : SV_Target1;  
};

PixelOut PS(PixelIn input)
{
    // ...
}

G-Buffer佈局的初設計

回看前面的前向渲染的着色器代碼,我們可以得知漫反射顏色、鏡面反射顏色的計算公式爲:

\[Diffuse=Albedo\times lightContrib \times (N\cdot L)\\ Specular= specAmount\times Albedo \times lightContrib \times (R\cdot V)^{specPow}\\ R=reflect(L, N) \]

這裏只以物體的漫反射貼圖作爲Albedo的貢獻部分。lightContrib受光照強度和衰弱的影響,而衰弱的計算需要用到光源位置與表面位置。在不考慮優化的情況下,G-Buffer需要存放的信息有:

  • 表面法線,3個float
  • 漫反射係數Albedo,4個float
  • 鏡面反射強度係數和材質光滑程度SpecPower,2個float
  • 像素點所處的世界座標,3個float

爲了讓我們的G-Buffer包含所有需要用於計算上面公式的值,我們需要存儲12個不同的浮點數到我們的G-Buffer渲染目標當中。因爲在一張紋理中,每個像素最多存儲4個值,我們使用3個G-Buffer就可以將它們全部存入。我們可以設計出如下的佈局:

//      |   x   |   y   |   z   |      w     |
// RT0: |         Normal        |  SpecPower | float4
// RT1: |             Albedo                 | float4
// RT2: |         PositionV     | SpecAmount | float4
// 注意:該版本的GBuffer與項目中的有差別
struct GBuffer
{
    float4 normal_specPow : SV_Target0;
    float4 albedo : SV_Target1;
    float4 posV_specAmount : SV_Target2;
}

由於表面指向觀察點的向量是通過eyePosW - surface.posW求得的,轉換到觀察空間後,觀察點的座標爲原點,這樣只需要傳入surface.posV而不需要在常量緩衝區提供攝像機的eyePosW,但這樣光源需要傳入的也是light.posV,在光照計算時統一在觀察空間進行。

渲染過程

幾何階段,我們使用GeometryVS着色器處理頂點,使用下面基礎版本的像素着色器將幾何信息寫入到GBuffer:

// GBuffer.hlsl

//--------------------------------------------------------------------------------------
// G-buffer 渲染
//--------------------------------------------------------------------------------------
void GBufferPS(VertexPosHVNormalVTex input, out GBuffer outputGBuffer)
{
    SurfaceData surface = ComputeSurfaceDataFromGeometry(input);
    outputGBuffer.normal_specPow = float4(surface.normalV, surface.specularPower);
    outputGBuffer.albedo = surface.albedo;
    outputGBuffer.posV_specAmount = float4(surface.posV, surface.specularAmount);
}

然後在光照階段,我們的頂點着色器要能夠覆蓋全屏。可以使用如下的代碼:

// FullScreenTriangle

// 使用一個三角形覆蓋NDC空間 
// (-1, 1)________ (3, 1)
//        |   |  /
// (-1,-1)|___|/ (1, -1)   
//        |  /
// (-1,-3)|/      
float4 FullScreenTriangleVS(uint vertexID : SV_VertexID) : SV_Position
{
    float4 output;
    
    float2 grid = float2((vertexID << 1) & 2, vertexID & 2);
    float2 xy = grid * float2(2.0f, -2.0f) + float2(-1.0f, 1.0f);
    return float4(xy, 1.0f, 1.0f);
}

這樣通過一個超大的三角形覆蓋NDC空間,並且不需要提供輸入佈局和頂點緩衝區數據:

deviceContext->IASetInputLayout(nullptr);
deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
deviceContext->IASetVertexBuffers(0, 0, nullptr, nullptr, nullptr);
// ...
deviceContext->Draw(3, 0);

像素着色器的基礎版本如下:

// Rendering.hlsl
struct SurfaceData
{
    float3 posV;
    float3 posV_DX;  // 忽略
    float3 posV_DY;  // 忽略
    float3 normalV;
    float4 albedo;
    float specularAmount;
    float specularPower;
};

// GBuffer.hlsl
SurfaceData ComputeSurfaceDataFromGBuffer(uint2 posViewport)
{
    // 從GBuffer讀取數據
    GBuffer rawData;
    rawData.normal_specPow = g_GBufferTextures[0].Load(posViewport.xy).xyzw;
    rawData.albedo = g_GBufferTextures[1].Load(posViewport.xy).xyzw;
    rawData.posV_specAmount = g_GBufferTextures[2].Load(posViewport.xy).xyzw;
      
    // 解碼到合適的輸出
    SurfaceData data;
    data.posV = rawData.posV_specAmount.xyz;
    data.specularPower = rawData.posV_specAmount.w;
    data.normalV = rawData.normal_specPow.xyz;
    data.specularAmount = rawData.normal_specPow.w;
    data.albedo = rawData.albedo;
    
    return data;
}

// BasicDeferred.hlsl
// posViewport表示的是(屏幕座標xy + 0.5偏移, NDC深度, 1)
float4 BasicDeferredPS(float4 posViewport : SV_Position) : SV_Target
{
    uint totalLights, dummy;
    g_Light.GetDimensions(totalLights, dummy);
    
    float3 lit = float3(0.0f, 0.0f, 0.0f);

    [branch]
    if (g_VisualizeLightCount)
    {
        // 用亮度表示該像素會被多少燈光處理
        lit = (float(totalLights) * rcp(255.0f)).xxx;
    }
    else
    {
        SurfaceData surface = ComputeSurfaceDataFromGBuffer(uint2(posViewport.xy));

        // 避免對天空盒/背景像素着色
        if (surface.posV.z < g_CameraNearFar.y)
        {
            for (uint lightIndex = 0; lightIndex < totalLights; ++lightIndex)
            {
                PointLight light = g_Light[lightIndex];
                AccumulateColor(surface, light, lit);
            }
        }
    }

    return float4(lit, 1.0f);
}

渲染完場景後,還需要進行天空盒的渲染。現在的天空盒着色器代碼基礎版本爲:

// SkyboxToneMap.hlsl

//--------------------------------------------------------------------------------------
// 使用天空盒幾何體渲染
//--------------------------------------------------------------------------------------
TextureCube<float4> g_SkyboxTexture : register(t5);
Texture2D<float> g_DepthTexture : register(t6);

// 場景渲染的紋理
Texture2D<float4> g_LitTexture : register(t7);

struct SkyboxVSOut
{
    float4 posViewport : SV_Position;
    float3 skyboxCoord : skyboxCoord;
};

SkyboxVSOut SkyboxVS(VertexPosNormalTex input)
{
    SkyboxVSOut output;
    
    // 注意:不要移動天空盒並確保深度值爲1(避免裁剪)
    output.posViewport = mul(float4(input.posL, 0.0f), g_ViewProj).xyww;
    output.skyboxCoord = input.posL;
    
    return output;
}

float4 SkyboxPS(SkyboxVSOut input) : SV_Target
{
    uint2 coords = input.posViewport.xy;

    float3 lit = float3(0.0f, 0.0f, 0.0f);
    float depth = g_DepthTexture.Load(coords);

    // 檢查天空盒的狀態(注意:反轉Z!)
    // 如果不使用反轉Z,則爲depth >= 1.0f
    [branch]
    if (depth <= 0.0f && !g_VisualizeLightCount)
        lit += g_SkyboxTexture.Sample(g_SamplerDiffuse, input.skyboxCoord).xyz;
    else
        lit += sampleLit;

    return float4(lit, 1.0f);
}

除了FullScreenTriangleVS,上述着色器不是最終的版本。

由於渲染一幀的工作量主要來源於像素片元的執行數量每個像素片元計算用到的光源數量,目前版本的延遲渲染相比前向渲染已經能夠減少像素片元的數量,但即便如此,基礎的延遲着色法的運行效率依然是低效的,因爲目前我們渲染的每個像素仍然與場景中的所有光源所綁定,提升燈光數量都會引起渲染一幀工作量的顯著增加。

此外,回顧之前的G-Buffer,如果這些元素都使用float4來存儲的話,那麼每渲染一個像素就需要駐留48字節。渲染1280x720分辨率需要佔用42.2MB顯存,而渲染1920x1080分辨率需要佔用94.9MB顯存。每幀都要駐留差不多100M的顯存用於主要渲染,佔用了比較多的顯存帶寬,對性能也會有一定的影響。當然,後續我們可以想辦法對這些數據想辦法進行一些處理,降低顯存帶寬的佔用。

這樣我們對延遲渲染的優化有兩個確定的方向:

  • 降低G-Buffer的佔用空間及減少像素着色器的輸出量
  • 儘可能去掉對當前像素片元沒有影響的光源以減少運算量

在這一章我們將討論前者,下一章則對後者進行討論。

G-Buffer相關的優化

由於延遲渲染需要同時對一到多個渲染目標進行輸出,這會佔用較大的顯存帶寬。如果我們能降低輸出的數目,這意味着能減小顯存帶寬的佔用,以及G-Buffer所佔用的顯存空間。 爲了降低G-Buffer的空間,我們可能會採用一些數學方法進行壓縮/解壓處理,即便這帶來了更多的運算量,得益於現代GPU龐大的運算單元,使得給GPU進行更多運算帶來的收益比增加顯存帶寬的佔用要大得多。基於這個思想,我們將對存儲在G-Buffer中的數據,基於他們的值域、精度需求、打包/解包的開銷來尋找合適的存儲類型。

法向量

對於法線的壓縮,我們有兩種方向:

  • 和之前的法線貼圖有點類似,我們可以將其用有符號8位整數來存儲(如DXGI_FORMAT_R8G8B8A8_SNORM)
  • 考慮到法線是單位長度,我們可以考慮只用2個float來存儲

在這裏由於我們有SpecPowerSpecAmount兩個float,選擇第二種方式似乎是一個不錯的選擇,可以構成float4。並且我們實際上可以用DXGI_FORMAT_R16G16B16A16_FLOAT來減少8字節的佔用。

考慮到法線的性質,我們可以用球面座標系來表示一個法線。壓縮/解壓方式如下:

float2 CartesianToSpherical(float3 normal)
{
    float2 s;
    s.x = atan2(normal.y, normal.x) / 3.14159f;
    s.y = normal.z;
    return s;
}

float3 SphericalToCartesian(float2 p)
{
    float2 sinCosTheta, sinCosPhi;
    sincos(p.x * 3.14159f, sinCosTheta.x, sinCosTheta.y);
    sinCosPhi = float2(sqrt(1.0f - p.y * p.y), p.y);
    return float3(sinCosTheta.y * sinCosPhi.x,
                  sinCosTheta.x * sinCosPhi.x,
                  sinCosPhi.y
    );
}

這種方式的主要缺點在於它需要使用三角函數(sin,cos,atan2),這些三角函數對運算單元的負擔會比較大。如果能有其它的方法避免三角函數的話會更好一些

另一種辦法是使用spheremap transformation。這種變換原來是用於將反射向量映射到[0, 1]範圍,但它也適用於法向量。它的工作原理是存儲在映射上的二維位置,每個位置對應球上的一個法線。具體做法如下:

float2 EncodeSphereMap(float3 normal)
{
    return normalize(normal.xy) * (sqrt(-normal.z * 0.5f + 0.5f));
}

float3 DecodeSphereMap(float2 encoded)
{
    float4 nn = float4(encoded, 1, -1);
    float l = dot(nn.xyz, -nn.xyw);
    nn.z = l;
    nn.xy *= sqrt(l);
    return nn.xyz * 2 + float3(0, 0, -1);
}

編碼過程:

\[x'=\frac{x}{\sqrt{x^2+y^2}}\cdot\sqrt{0.5(1-z)}\\ y'=\frac{y}{\sqrt{x^2+y^2}}\cdot\sqrt{0.5(1-z)}\\ \]

解碼過程:

\[l=1-(x')^2-(y')^2=1-0.5(1-z)=0.5(1+z)\\ \sqrt{1-z^2}=\sqrt{x^2+y^2}\\ 2x'\sqrt{l}=\frac{2x}{\sqrt{x^2+y^2}}\cdot\sqrt{0.5(1-z)\cdot0.5(1+z)}=x\\ 2y'\sqrt{l}=\frac{2y}{\sqrt{x^2+y^2}}\cdot\sqrt{0.5(1-z)\cdot0.5(1+z)}=y\\ 2\cdot l-1=z \]

整個運算過程最多就再用到開方,總體效率也比較理想。

漫反射顏色

由於顏色的值域通常在[0, 1],這意味着我們可以使用無符號規格化的8位整數格式DXGI_FORMAT_R8G8B8A8_UNORM來存儲。當然也可以將顏色值保存在sRGB空間,因爲這通常用於漫反射紋理的存儲格式。使用DXGI_FORMAT_R8G8B8A8_UNORM_SRGB會讓硬件對像素着色器的輸出執行sRGB轉換。還有就是如果alpha通道不怎麼需要用到的話也可以用DXGI_FORMAT_R10G10B16A2_UNORM來提升RGB的精度

注意:sRGB指的是標準的RGB顏色空間,通常用於圖像文件和顯示設備。使用sRGB使更多的精度用於較暗的顏色值,符合人類眼睛對這些顏色區域的自然敏感性

位置

位置通常需要高精度,且它要用於高頻陰影計算/光照計算中。爲了存儲世界空間或觀察空間中的座標,甚至16位的浮點數格式通常不足以避免Artifacts。幸運的是,我們還有別的方式存儲完整的XYZ值。在執行像素着色器的時候,屏幕空間座標XY是可以通過SV_Position語義取得的,然後在渲染場景幾何體的時候,通過用到的觀察變換和投影變換矩陣是有可能恢復出觀察空間或世界空間的座標的。

對屏幕中的任意一個像素,都有一個表示從攝像機到像素位置對應遠*面一點的方向向量。我們可以利用屏幕空間XY座標來根據視錐體遠*面的四個角點進行線性插值來構建方向向量,然後標準化(如果你在觀察空間做這件事的話,就不需要再減去攝像機的位置)。如果幾何體光柵化在當前像素位置,這意味着當前表面通過之前構建的方向向量從攝像機方向移動是可以接觸到的。我們只要再有攝像機到表面的距離就可以構建出該表面的座標了:

// 重建位置的過程,當前GBuffer需要提供一個float存儲攝像機到表面的距離

// GBuffer頂點着色器
vOut.posV = mul(PosL, g_WorldView).xyz;
// GBuffer像素着色器
gBuffer.distance.x = length(gBuffer.posV);
// 光照階段頂點着色器
vOut.ViewRay = posW - g_CamPosW;
// 光照階段像素着色器
float3 viewRay = normalize(pIn.viewRay);
float viewDistance = g_TextureDistance.Sample(g_Sam, pIn.tex);
float3 posW = g_CamPosW + viewRay * viewDistance;

這爲我們提供了一種靈活而又相當有效的方法,可以從存儲在G-Buffer中的單一高精度值中重建位置。

然而如果我們只考慮觀察空間的話,仍可以做進一步的優化:我們有辦法避免存儲多一個值,即通過透視投影的參數,我們可以從屏幕空間反推到觀察空間。仔細觀察下面的透視投影變換:

\[\begin{align} [x,y,z,1]\begin{bmatrix} m_{00} & 0 & 0 & 0 \\ 0 & m_{11} & 0 & 0 \\ 0 & 0 & m_{22} & 1 \\ 0 & 0 & m_{32} & 0 \\ \end{bmatrix}&=[m_{00}x,m_{11}y,m_{22}z+m_{32},z]\\ &\Rightarrow[\frac{m_{00}x}{z},\frac{m_{11}y}{z},m_{22}+\frac{m_{32}}{z}, 1] \end{align}\\ z_{ndc}=m_{22}+\frac{m_{32}}{z} \]

其中ndc座標我們可以通過像素着色器輸入的屏幕座標和屏幕寬高的信息求出來,這樣逆變換的條件也足夠了:

\[z=\frac{m_{32}}{z_{ndc}-m_{22}}\\ x=\frac{x_{ndc}\cdot z}{m_{00}}\\ y=\frac{y_{ndc}\cdot z}{m_{11}} \]

在着色器代碼中,重建觀察空間座標的方式如下:

float3 ComputePositionViewFromZ(float2 posNdc, float viewSpaceZ)
{
    float2 screenSpaceRay = float2(posNdc.x / g_Proj._m00,
                                   posNdc.y / g_Proj._m11);
    
    float3 posV;
    posV.z = viewSpaceZ;
    posV.xy = screenSpaceRay.xy * posV.z;
    
    return posV;
}

// ...
float2 gbufferDim;
uint dummy;
g_GBufferTextures[0].GetDimensions(gbufferDim.x, gbufferDim.y, dummy);

float2 screenPixelOffset = float2(2.0f, -2.0f) / gbufferDim;
float2 posNdc = (float2(posViewport.xy) + 0.5f) * screenPixelOffset.xy + float2(-1.0f, 1.0f);
// ndcZ = A + B/z => z = B/(ndcZ - A)
float viewSpaceZ = g_Proj._m32 / (ndcZ - g_Proj._m22);
float3 posV = ComputePositionViewFromZ(posNdc, viewSpaceZ);

反向Z(Reversed-Z)

然後是深度值的問題。觀察空間的深度值在經過透視投影后變爲:

\[d=a\frac{1}{z}+b \]

其中a,b與*/遠*面的設置關聯。把重映射後的深度值等距採樣,在該函數的圖像如下:

可以看到d的大部分區域落在了**面上。然而考慮浮點數的精度問題,它的數值分佈大部分也是落在0上,如果選出浮點數能表示的0-1範圍的所有d值,對應的圖如下:

經過映射後發現這種現象被加劇了,絕大部分的點都被映射到**面上,這是一種浪費。且靠* 遠*面 的z值,在經過映射後得到的d,受到精度的限制變得會更加難以區分,使得多個相*的深度值被映射到同一個d值的可能性被放大,導致出現Z-Fighting的問題。

一種廣爲人知的方法爲反向Z,讓**面映射到d=1,遠*面映射到d=0:

可以看到,反向Z可以利用浮點數的特性,讓其在靠* 遠*面 的分佈也能變得較爲均勻,從而較爲良好地抵消掉重映射所帶來的影響。這可以使Z-Fighting出現問題的可能性大幅降低。

我們只需要:

  • 在C++代碼中將視錐體的*/遠*面對調:
m_pCamera->SetFrustum(XM_PI / 3, AspectRatio(), 300.0f, 0.5f);
  • 在清空深度緩衝區時用0清空:
m_pd3dImmediateContext->ClearDepthStencilView(m_pDepthBuffer->GetDepthStencil(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 0.0f, 0);
  • 在深度測試時使用>=比較方式(使用>=是爲了兼容Forward PreZ-Pass,在實際繪製時能同時處理>和=的情況):
m_pd3dImmediateContext->SetDepthStencilState(RenderStates::DSSGreaterEqual.Get(), 0);
  • 但是在構造碰撞視錐體的時候,別忘了把*/遠*面交換回來:
BoundingFrustum::CreateFromMatrix(frustum, DirectX::XMMatrixPerspectiveFovLH(m_pCamera->GetFovY(), 
		m_pCamera->GetAspectRatio(), m_pCamera->GetFarZ(), m_pCamera->GetNearZ()));

多重採樣

首先複習一下超採樣(SSAA)。現渲染WxH大小的屏幕,爲了降低走樣/鋸齒的影響,可以使用2倍寬高的後備緩衝區及深度緩衝區,光柵化的操作量增大到了4倍左右,同樣像素着色器的執行次數也增大到了原來的4倍左右。每個2x2的像素塊對應原來的1個像素,通過對這2x2的像素塊求*均值得到目標像素的顏色。

多重採樣(MSAA)採用的是一種較爲折中的方案。以4xMSAA爲例,指的是單個像素需要按特定的模式對周圍採樣4次:

假定當前像素位置爲(x+0.5,y+0.5),在D3D11定義的採樣模式下,這四個採樣點的位置爲:(x+0.375, y+0.125)、(x+0.875, y+0.375)、(x+0.125, y+0.625)、(x+0.625, y+0.875)

在光柵化階段,在一個像素區域內使用4個子採樣點,但每個像素仍只執行1次像素着色器的計算。這4個子採樣點都會計算自己的深度值,然後根據深度測試和三角形覆蓋性測試來決定是否複製該像素的計算結果。爲此深度緩衝區渲染目標需要的空間依然爲原來的4倍

假設三角形1覆蓋了2-3號採樣,且通過了深度測試,則2-3號採樣複製像素着色器得到的紅色。此時渲染三角形2覆蓋了1號採樣,且通過了深度測試,則1號採樣複製像素着色器得到的藍色。在渲染完三角形後,對這四個採樣點的顏色進行一個resolve操作,可以得到當前像素最終的顏色。這裏我們可以認爲是對這四個採樣點的顏色求*均值。

ID3D11DeviceContext::ResolveSubresource方法--將多重採樣的資源複製到非多重採樣的資源

void ID3D11DeviceContext::ResolveSubresource(
    ID3D11Resource *pDstResource,   // [In]目標非多重採樣資源
    UINT           DstSubresource,  // [In]目標子資源索引
    ID3D11Resource *pSrcResource,   // [In]源多重採樣資源
    UINT           SrcSubresource,  // [In]源子資源索引
    DXGI_FORMAT    Format           // [In]解析成非多重採樣資源的格式
);

該API最常用於需要將當前pass的渲染目標結果作爲下一個pass的輸入。源/目標資源必須擁有相同的資源類型和相同的維度。此外,它們必須擁有兼容的格式:

  • 已經確定的類型則要求必須相同:比如兩個資源都是DXGI_FORMAT_R8G8B8A8_UNORM,在Format中一樣要填上
  • 若有一個類型確定但另一個類型不確定:比如兩個資源分別是DXGI_FORMAT_R32_FLOATDXGI_FORMAT_R32_TYPELESS,在Format參數中必須指定DXGI_FORMAT_R32_FLOAT
  • 兩個不確定類型要求必須相同:如DXGI_FORMAT_R32_TYPELESS,那麼在Format參數中可以指定DXGI_FORMAT_R32_FLOATDXGI_FORMAT_R32_UINT

若後備緩衝區創建的時候指定了多重採樣,正常渲染完後到呈現(Present)時會自動調用ResolveSubresource方法得到用於顯示的非多重採樣紋理。當然這僅限於交換鏈使用的是BLIT模型而非FLIP模型,因爲翻轉模型的後備緩衝區不能使用多重採樣紋理,要使用MSAA我們需要另外新建一個等寬高的MSAA紋理先渲染到此處,然後ResolveSubresource到後備緩衝區。

讀取多重採樣資源

前面我們已經知道了如果創建多重採樣資源並作爲渲染目標使用。而爲了能夠在着色器使用多重採樣紋理,需要在HLSL聲明如下類型的資源:

Texture2DMS<type, sampleCount> g_Texture : register(t*)

非多重採樣的紋理設置的sampleCount是1,除了Texture2D類型,還可以傳入到Texture2DMS<float4, 1>中使用

Texture2DMS類型只支持讀取,不支持採樣:

T Texture2DMS<T>::Load(
    in int2 coord,
    in int sampleIndex
);

延遲着色+MSAA

延遲着色與多重採樣的兼容性並不是很好,但不代表延遲着色不能使用多重採樣。當然使用FXAA/TAA這些後處理會更好都是後話。現在需要補充多重採樣與Direct3D 11相關的應用內容。

給延遲着色添加多重採樣實際上是在幾何階段進行的,光照階段完成的相當於是Resolve的操作。渲染流程如下:

  • 幾何階段

    • 創建同等MSAA採樣頻率的G-Buffer,然後將幾何信息渲染到G-Buffer中
  • 光照階段

    • 通過模板寫入,標記出需要應用逐樣本着色的區域(Per-Sample Shading),因爲這是一件開銷較大的事情
    • 利用模板測試,在不需要逐樣本着色的區域,我們使用樣本0進行像素着色
    • 同樣利用模板測試,在需要逐樣本着色的區域,對每個樣本都調用像素着色
    • 完成光照MSAA緩衝區的渲染後,我們在繪製天空盒時,對處於遠*面的像素繪製天空盒的顏色;否則讀取MSAA緩衝區在當前像素位置所有樣本的顏色來完成Resolve。

逐樣本着色(Per-Sample Shading)

如果我們對全屏區域進行逐樣本着色,那本質上進行的就是SSAA(超採樣)了。而真正需要逐樣本着色的地方通常是位於物體邊界的情況。逐樣本着色的像素着色器需要添加SV_SampleIndex

float4 PS(float4 posViewPort : SV_Position, uint sampleIndex : SV_SampleIndex) : SV_Target
{
    // ...
}

G-Buffer佈局

目前的GBuffer佈局如下:

struct GBuffer
{
    float4 normal_specular : SV_Target0;  // R16G16B16A16_FLOAT
    float4 albedo : SV_Target1;           // R8G8B8A8_UNORM
    float2 posZGrad : SV_Target2;         // R16G16_FLOAT
};

其中新增的posZGrad用於後面的逐樣本繪製需求檢測。目前這套G-Buffer每個像素只需要佔據16字節,相比原來的48字節已經少了2/3的顯存佔用。當然,如果使用了4xMSAA又要開多4倍的顯存。

幾何階段

幾何階段的着色器如下:

//
// 源自Rendering.hlsl
//

VertexPosHVNormalVTex GeometryVS(VertexPosNormalTex input)
{
    VertexPosHVNormalVTex output;

    output.posH = mul(float4(input.posL, 1.0f), g_WorldViewProj);
    output.posV = mul(float4(input.posL, 1.0f), g_WorldView).xyz;
    output.normalV = mul(float4(input.normalL, 0.0f), g_WorldInvTransposeView).xyz;
    output.texCoord = input.texCoord;
    
    return output;
}

//
// GBuffer.hlsl
//
struct GBuffer
{
    float4 normal_specular : SV_Target0;
    float4 albedo : SV_Target1;
    float2 posZGrad : SV_Target2;         // ( d(x+1,y)-d(x,y), d(x,y+1)-d(x,y) )
};

// 法線編碼
float2 EncodeSphereMap(float3 normal)
{
    return normalize(normal.xy) * (sqrt(-normal.z * 0.5f + 0.5f));
}


//--------------------------------------------------------------------------------------
// G-buffer 渲染
//--------------------------------------------------------------------------------------
void GBufferPS(VertexPosHVNormalVTex input, out GBuffer outputGBuffer)
{
    SurfaceData surface = ComputeSurfaceDataFromGeometry(input);
    outputGBuffer.normal_specular = float4(EncodeSphereMap(surface.normalV),
                                           surface.specularAmount,
                                           surface.specularPower);
    outputGBuffer.albedo = surface.albedo;
    outputGBuffer.posZGrad = float2(ddx_coarse(surface.posV.z),
                                    ddy_coarse(surface.posV.z));
}

頂點着色器使用GeometryVS,像素着色器使用GBufferPS,然後開啓>或>=深度測試比較

光照階段

使用模板標記出需要應用逐樣本着色的區域

// GBuffer.hlsl

struct GBuffer
{
    float4 normal_specular : SV_Target0;
    float4 albedo : SV_Target1;
    float2 posZGrad : SV_Target2;  // ( d(x+1,y)-d(x,y), d(x,y+1)-d(x,y) )
};

// 上述GBuffer加上深度緩衝區(最後一個元素)  t1-t4
Texture2DMS<float4, MSAA_SAMPLES> g_GBufferTextures[4] : register(t1);

// 檢查一個給定的像素是否需要進行逐樣本着色
bool RequiresPerSampleShading(SurfaceData surface[MSAA_SAMPLES])
{
    bool perSample = false;

    const float maxZDelta = abs(surface[0].posV_DX.z) + abs(surface[0].posV_DY.z);
    const float minNormalDot = 0.99f; // 允許大約8度的法線角度差異

    [unroll]
    for (uint i = 1; i < MSAA_SAMPLES; ++i)
    {
        // 使用三角形的位置偏移,如果所有的採樣深度存在差異較大的情況,則有可能屬於邊界
        perSample = perSample ||
            abs(surface[i].posV.z - surface[0].posV.z) > maxZDelta;

        // 若法線角度差異較大,則有可能來自不同的三角形/表面
        perSample = perSample ||
            dot(surface[i].normalV, surface[0].normalV) < minNormalDot;
    }

    return perSample;
}

// 使用逐採樣(1)/逐像素(0)標誌來初始化模板掩碼值
void RequiresPerSampleShadingPS(float4 posViewport : SV_Position)
{
    SurfaceData surfaceSamples[MSAA_SAMPLES];
    ComputeSurfaceDataFromGBufferAllSamples(uint2(posViewport.xy), surfaceSamples);
    bool perSample = RequiresPerSampleShading(surfaceSamples);

    // 如果我們不需要逐採樣着色,拋棄該像素片元(例如:不寫入模板)
    [flatten]
    if (!perSample)
    {
        discard;
    }
}

頂點着色器使用全屏三角形繪製,像素着色器使用RequiresPerSampleShadingPS,然後模板寫入,對需要逐採樣着色的像素標記爲1,其餘爲0的默認進行逐像素着色

通過模板測試來繪製逐像素着色的區域

float3 DecodeSphereMap(float2 encoded)
{
    float4 nn = float4(encoded, 1, -1);
    float l = dot(nn.xyz, -nn.xyw);
    nn.z = l;
    nn.xy *= sqrt(l);
    return nn.xyz * 2 + float3(0, 0, -1);
}

SurfaceData ComputeSurfaceDataFromGBufferSample(uint2 posViewport, uint sampleIndex)
{
    // 從GBuffer讀取數據
    GBuffer rawData;
    rawData.normal_specular = g_GBufferTextures[0].Load(posViewport.xy, sampleIndex).xyzw;
    rawData.albedo = g_GBufferTextures[1].Load(posViewport.xy, sampleIndex).xyzw;
    rawData.posZGrad = g_GBufferTextures[2].Load(posViewport.xy, sampleIndex).xy;
    float zBuffer = g_GBufferTextures[3].Load(posViewport.xy, sampleIndex).x;
    
    float2 gbufferDim;
    uint dummy;
    g_GBufferTextures[0].GetDimensions(gbufferDim.x, gbufferDim.y, dummy);
    
    // 計算屏幕/裁剪空間座標和相鄰的位置
    // 注意:需要留意DX11的視口變換和像素中心位於(x+0.5, y+0.5)位置
    // 注意:該偏移實際上可以在CPU預計算但將它放到常量緩衝區讀取實際上比在這裏重新計算更慢一些
    float2 screenPixelOffset = float2(2.0f, -2.0f) / gbufferDim;
    float2 posNdc = (float2(posViewport.xy) + 0.5f) * screenPixelOffset.xy + float2(-1.0f, 1.0f);
    float2 posNdcX = posNdc + float2(screenPixelOffset.x, 0.0f);
    float2 posNdcY = posNdc + float2(0.0f, screenPixelOffset.y);
        
    // 解碼到合適的輸出
    SurfaceData data;
        
    // 反投影深度緩衝Z值到觀察空間
    float viewSpaceZ = g_Proj._m32 / (zBuffer - g_Proj._m22);

    data.posV = ComputePositionViewFromZ(posNdc, viewSpaceZ);
    data.posV_DX = ComputePositionViewFromZ(posNdcX, viewSpaceZ + rawData.posZGrad.x) - data.posV;
    data.posV_DY = ComputePositionViewFromZ(posNdcY, viewSpaceZ + rawData.posZGrad.y) - data.posV;

    data.normalV = DecodeSphereMap(rawData.normal_specular.xy);
    data.albedo = rawData.albedo;

    data.specularAmount = rawData.normal_specular.z;
    data.specularPower = rawData.normal_specular.w;
    
    return data;
}

float4 BasicDeferred(float4 posViewport, uint sampleIndex)
{
    uint totalLights, dummy;
    g_Light.GetDimensions(totalLights, dummy);
    
    float3 lit = float3(0.0f, 0.0f, 0.0f);

    [branch]
    if (g_VisualizeLightCount)
    {
        // 用亮度表示該像素會被多少燈光處理
        lit = (float(totalLights) * rcp(255.0f)).xxx;
    }
    else
    {
        SurfaceData surface = ComputeSurfaceDataFromGBufferSample(uint2(posViewport.xy), sampleIndex);

        // 避免對天空盒/背景像素着色
        if (surface.posV.z < g_CameraNearFar.y)
        {
            for (uint lightIndex = 0; lightIndex < totalLights; ++lightIndex)
            {
                PointLight light = g_Light[lightIndex];
                AccumulateColor(surface, light, lit);
            }
        }
    }

    return float4(lit, 1.0f);
}

float4 BasicDeferredPS(float4 posViewport : SV_Position) : SV_Target
{
    return BasicDeferred(posViewport, 0);
}

頂點着色器使用全屏三角形繪製,像素着色器使用BasicDeferredPS,然後模板測試要求模板值爲0才能進行着色,混合採用的是加法混合。由於在逐像素繪製的區域中,G-Buffer的4個子採樣存的內容是相同的(某種意義上這也是一種浪費),我們可以直接取子採樣0的信息進行着色。

通過模板測試來繪製逐樣本着色的區域

float4 BasicDeferredPerSamplePS(float4 posViewport : SV_Position,
                            uint sampleIndex : SV_SampleIndex) : SV_Target
{
    float4 result;
    if (g_VisualizePerSampleShading)
    {
        result = float4(1, 0, 0, 1);
    }
    else
    {
        result = BasicDeferred(posViewport, sampleIndex);
    }
    return result;
}

頂點着色器使用全屏三角形繪製,像素着色器使用BasicDeferredPerSamplePS,然後模板測試要求模板值爲1才能進行着色,混合採用的是加法混合。

天空盒與場景的同時繪製

// SkyboxToneMap.hlsl

//--------------------------------------------------------------------------------------
// 後處理, 天空盒等
// 使用天空盒幾何體渲染
//--------------------------------------------------------------------------------------
TextureCube<float4> g_SkyboxTexture : register(t5);
Texture2DMS<float, MSAA_SAMPLES> g_DepthTexture : register(t6);

// 常規多重採樣的場景渲染的紋理
Texture2DMS<float4, MSAA_SAMPLES> g_LitTexture : register(t7);

struct SkyboxVSOut
{
    float4 posViewport : SV_Position;
    float3 skyboxCoord : skyboxCoord;
};

SkyboxVSOut SkyboxVS(VertexPosNormalTex input)
{
    SkyboxVSOut output;
    
    // 注意:不要移動天空盒並確保深度值爲1(避免裁剪)
    output.posViewport = mul(float4(input.posL, 0.0f), g_ViewProj).xyww;
    output.skyboxCoord = input.posL;
    
    return output;
}

float4 SkyboxPS(SkyboxVSOut input) : SV_Target
{
    uint2 coords = input.posViewport.xy;

    float3 lit = float3(0.0f, 0.0f, 0.0f);
    float skyboxSamples = 0.0f;
#if MSAA_SAMPLES <= 1
    [unroll]
#endif
    for (unsigned int sampleIndex = 0; sampleIndex < MSAA_SAMPLES; ++sampleIndex)
    {
        float depth = g_DepthTexture.Load(coords, sampleIndex);

        // 檢查天空盒的狀態(注意:反向Z!)  
        if (depth <= 0.0f && !g_VisualizeLightCount)
        {
            ++skyboxSamples;
        }
        else
        {
            lit += g_LitTexture.Load(coords, sampleIndex).xyz;
        }
    }

    // 如果這裏沒有場景渲染,則渲染天空盒
    [branch]
    if (skyboxSamples > 0)
    {
        float3 skybox = g_SkyboxTexture.Sample(g_SamplerDiffuse, input.skyboxCoord).xyz;
        lit += skyboxSamples * skybox;
    }
    
    // Resolve 多重採樣(簡單盒型濾波)
    return float4(lit * rcp(MSAA_SAMPLES), 1.0f);
}

頂點着色器使用SkyboxVS,像素着色器使用SkyboxPS,光柵化取消背面裁剪。

C++端的變化

目前對原來的代碼做了比較多的改動,比較重要的包括:

  • Model的ObjReader改爲使用tinyobj的ObjReader,以能夠導入Sponza模型,並對Model支持子模型級別的視錐體裁剪。
  • 添加了TextureManager類以避免紋理的重複讀取,內部使用DDSTextureLoader和stb_image讀取紋理
  • 使用DirectX-Graphics-Samples中MiniEngine的CameraController實現對FirstPersonCamera的*滑移動
  • 添加ImGui應對逐漸複雜的選項控制
  • 添加Texture2DDepth2DStructuredBuffer
  • 修改CreateShaderFromFile以支持傳入宏定義

具體的C++代碼細節建議直接閱讀源碼。

演示

由於可改變的選項很多,演示起來也比較麻煩,這裏只放出截屏,然後具體描述各自的功能

  • MSAA:默認關閉,可選2x、4x、8x
  • 光照裁剪模式:默認開啓延遲渲染+無光照裁剪,可選前向渲染、帶Pre-Z Pass的前向渲染
  • Animate Lights:燈光的移動
  • Face Normals:使用面法線
  • Clear G-Buffer:默認不需要清除G-Buffer再來繪製,該選項開啓便於觀察G-Buffer中的圖
  • Visualize Light Count:可視化每個像素渲染的光照數,最大255。目前沒有光照裁剪
  • Visualize Shading Freq:在開啓MSAA後,紅色高亮的區域表示該像素使用的是逐樣本着色
  • Light Height Scale:將所有的燈光按一定比例擡高
  • Lights:燈光數,2的指數冪
  • Normal圖:展示了從Normal_Specular G-Buffer還原出的世界座標下的法線,經[-1, 1]到[0, 1]的映射
  • Albedo圖:展示了Albedo G-Buffer
  • PosZGrad圖:展示了觀察空間下的PosZ的梯度

在下一章我們將會重點討論光照裁剪技術。

補充&參考

Depth Precision Visualized

Deferred Rendering for Current and Future Rendering Pipelines

Compact Normal Storage for small G-Buffers · Aras' website (aras-p.info)


DirectX11 With Windows SDK完整目錄

Github項目源碼

歡迎加入QQ羣: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裏彙報。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章