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

X_Jun發表於2022-03-04

前言

隨著圖形硬體變得越來越通用和可程式設計化,採用實時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);
}

這些著色器不是最終的版本。

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

此外,回顧之前的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{B}{m_{32}}, 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應對逐漸複雜的選項控制
  • 新增Texture2D、Depth2D和StructuredBuffer類

具體的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,以及有什麼問題也可以在這裡彙報。

相關文章