DirectX11 With Windows SDK--37 延遲渲染:光源剔除

X_Jun發表於2022-03-18

前言

在上一章,我們主要介紹瞭如何使用延遲渲染,以及如何對G-Buffer進行一系列優化。而在這一章裡,我們將從光源入手,討論如何對大量的動態光源進行剔除,從而獲得顯著的效能提升。

在此之前假定讀者已經讀過上一章,並熟悉瞭如下內容:

  • 計算著色器
  • 結構化緩衝區

DirectX11 With Windows SDK完整目錄

Github專案原始碼

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

基於分塊(Tile-Based)的光源剔除

為了剔除光源,一種最簡單的方法就是利用攝像機的視錐體與點光源照射範圍形成的球體做碰撞測試,和視錐體剔除的原理一致。當光源數目較大散佈較為均勻時,可以有效減少光源數量。但這樣做最明顯的問題在於剔除不夠精細,待繪製畫素可能會受到視錐體內其餘不能照射到當前畫素的光源影響。

在此基礎上,我們可以考慮將單個視錐體基於螢幕區域來劃分為多個塊,一個塊有一個對應的子視錐體。在我們的demo中,每個分塊的大小為16x16的解析度。然後對每個子視錐體都進行一次全域性光源的視錐體剔除,從而分別獲得各自受影響的光源列表,最後在著色時根據當前畫素所屬的分塊區域使用其對應的光源列表進行著色計算。而這項任務考慮到其重複計算的特性,可以交給GPU的計算著色器來完成。

帶著這樣的初步思路,我們直接從具體的程式碼來進行說明。

分塊子視錐體深度範圍的計算

考慮到原始的視錐體可以表示從**面到遠*面的深度範圍,但是這麼大的深度範圍內,待渲染幾何體的深度可能在一個比較小的範圍,為此可以進行一些小的優化。

以下圖為例,取黑色一行的分塊,對應右邊的一系列視錐體。對於區塊B,右邊的圖的黑色線條反映的是在深度緩衝區對應位置的幾何體深度資訊。可以看到幾何體的深度反映在一個比較小的範圍,我們可以遍歷該區塊的所有深度值然後算出對應的zmin和zmax作為當前視錐體的**面和遠*面,從而縮小視錐體的大小,並更有效地剔除光源。

// 確定分塊(tile)的大小用於光照去除和相關的權衡取捨 
#define COMPUTE_SHADER_TILE_GROUP_DIM 16
#define COMPUTE_SHADER_TILE_GROUP_SIZE (COMPUTE_SHADER_TILE_GROUP_DIM*COMPUTE_SHADER_TILE_GROUP_DIM)

groupshared uint s_MinZ;
groupshared uint s_MaxZ;

// 當前tile的光照列表
groupshared uint s_TileLightIndices[MAX_LIGHTS];
groupshared uint s_TileNumLights;

[numthreads(COMPUTE_SHADER_TILE_GROUP_DIM, COMPUTE_SHADER_TILE_GROUP_DIM, 1)]
void ComputeShaderTileDeferredCS(uint3 groupId : SV_GroupID,
                                 uint3 dispatchThreadId : SV_DispatchThreadID,
                                 uint3 groupThreadId : SV_GroupThreadID,
                                 uint groupIndex : SV_GroupIndex
                                 )
{
    //
    // 獲取表面資料,計算當前分塊的視錐體
    //
    
    uint2 globalCoords = dispatchThreadId.xy;
    
    SurfaceData surfaceSamples[MSAA_SAMPLES];
    ComputeSurfaceDataFromGBufferAllSamples(globalCoords, surfaceSamples);
        
    // 尋找所有采樣中的Z邊界
    float minZSample = g_CameraNearFar.y;
    float maxZSample = g_CameraNearFar.x;
    {
        [unroll]
        for (uint sample = 0; sample < MSAA_SAMPLES; ++sample)
        {
            // 避免對天空盒或其它非法畫素著色
            float viewSpaceZ = surfaceSamples[sample].posV.z;
            bool validPixel =
                 viewSpaceZ >= g_CameraNearFar.x &&
                 viewSpaceZ < g_CameraNearFar.y;
            [flatten]
            if (validPixel)
            {
                minZSample = min(minZSample, viewSpaceZ);
                maxZSample = max(maxZSample, viewSpaceZ);
            }
        }
    }
    
    // 初始化共享記憶體中的光照列表和Z邊界
    if (groupIndex == 0)
    {
        s_TileNumLights = 0;
        s_NumPerSamplePixels = 0;
        s_MinZ = 0x7F7FFFFF; // 最大浮點數
        s_MaxZ = 0;
    }

    GroupMemoryBarrierWithGroupSync();

    // 注意:這裡可以進行並行歸約(parallel reduction)的優化,但由於我們使用了MSAA並
    // 儲存了多重取樣的畫素在共享記憶體中,逐漸增加的共享記憶體壓力實際上**減小**核心的總
    // 體執行速度。因為即便是在最好的情況下,在目前具有典型分塊(tile)大小的的架構上,
    // 並行歸約的速度優勢也是不大的。
    // 只有少量實際合法樣本的畫素在其中。
    if (maxZSample >= minZSample)
    {
        InterlockedMin(s_MinZ, asuint(minZSample));
        InterlockedMax(s_MaxZ, asuint(maxZSample));
    }

    GroupMemoryBarrierWithGroupSync();
    
    float minTileZ = asfloat(s_MinZ);
    float maxTileZ = asfloat(s_MaxZ);
    float4 frustumPlanes[6];
    ConstructFrustumPlanes(groupId, minTileZ, maxTileZ, frustumPlanes);
    // ...
}

分塊視錐體矩陣的推導及*面的構建

推導分塊所屬視錐體

首先,我們需要根據當前分塊所屬的視錐體求出對應的投影矩陣。回想之前的透視投影矩陣(或者看過GAMES101的推導),實際上是可以拆分成:

\[\begin{align} P_{persp}&=P_{persp\rightarrow ortho} P_{ortho} \\ &= \begin{bmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & n + f & 1 \\ 0 & 0 & -fn & 0 \\ \end{bmatrix}\begin{bmatrix} \frac{1}{rn\cdot tan(\alpha/2)} & 0 & 0 & 0 \\ 0 & \frac{1}{n\cdot tan(\alpha/2)} & 0 & 0 \\ 0 & 0 & \frac{1}{f - n} & 0 \\ 0 & 0 & \frac{-n}{f - n} & 1 \\ \end{bmatrix} \\ &= \begin{bmatrix} \frac{1}{rtan(\alpha/2)} & 0 & 0 & 0 \\ 0 & \frac{1}{tan(\alpha/2)} & 0 & 0 \\ 0 & 0 & \frac{f}{f-n} & 1 \\ 0 & 0 & -\frac{nf}{f-n} & 0 \\ \end{bmatrix} \end{align} \]

即一個視錐體擠壓成立方體,再進行正交投影的變換,從而變換到齊次裁剪空間

為了取得立方體的其中一個切塊,我們需要通過對這個擠壓後的立方體進行縮放再*移。但同時因為變換後的點沒有做透視除法:

\[\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來實現目標子視錐體的構造:

\[\begin{align} [x,y,z,1]\begin{bmatrix} s_w\cdot m_{00} & 0 & 0 & 0 \\ 0 & s_h\cdot m_{11} & 0 & 0 \\ t_x & t_y & m_{22} & 1 \\ 0 & 0 & m_{32} & 0 \\ \end{bmatrix}&=[s_w(m_{00}x)+t_x z,\; s_h(m_{11}y)+t_y z,\;m_{22}z+m_{32},\;z]\\ &\Rightarrow[\frac{s_w(m_{00}x)}{z}+t_x\;,\frac{s_h(m_{11}y)}{z}+t_y\;,m_{22}+\frac{m_{32}}{z}\;, 1] \end{align}\\ \]

Gribb/Hartmann法提取視錐體*面

已知我們可以用向量(A, B, C, D)來表示*面Ax+By+Cz+D=0,同時向量(A, B, C)可以表示這個面的法線

已知視錐體對應的投影矩陣P,我們可以對其使用Gribb/Hartmann法提取視錐體對應的6個*面。在上述行矩陣的情況下,我們需要取它的列來計算:

\[right=col_3-col_0\\ left=col_3+col_0\\ top=col_3-col_1\\ bottom=col_3+col_1\\ near=(0, 0, 1, -minZ)\\ far=(0,0,-1,maxZ) \]

需要注意的是,這些面的法線是指向視錐體的內部的。並且對於這些*面需要進行法線的歸一化便於後續的計算。

此外,這種方法也支援觀察矩陣和世界矩陣的結合:

  • 如果待提取的矩陣為VP,提取出的視錐體*面位於世界空間
  • 如果待提取的矩陣為WVP,提取出的視錐體*面位於物體空間

下面給出從透視投影矩陣構建出視錐體*面的方法:

void ConstructFrustumPlanes(uint3 groupId, float minTileZ, float maxTileZ, out float4 frustumPlanes[6])
{
    
    // 注意:下面的計算每個分塊都是統一的(例如:不需要每個執行緒都執行),但代價低廉。
    // 我們可以只是先為每個分塊預計算視錐*面,然後將結果放到一個常量緩衝區中...
    // 只有當投影矩陣改變的時候才需要變化,因為我們是在觀察空間執行,
    // 然後我們就只需要計算*/遠*面來貼緊我們實際的幾何體。
    
    // 從[0, 1]中找出縮放/偏移
    float2 tileScale = float2(g_FramebufferDimensions.xy) * rcp(float(2 * COMPUTE_SHADER_TILE_GROUP_DIM));
    float2 tileBias = tileScale - float2(groupId.xy);

    // 計算當前分塊視錐體的投影矩陣
    float4 c1 = float4(g_Proj._11 * tileScale.x, 0.0f, tileBias.x, 0.0f);
    float4 c2 = float4(0.0f, -g_Proj._22 * tileScale.y, tileBias.y, 0.0f);
    float4 c4 = float4(0.0f, 0.0f, 1.0f, 0.0f);

    // Gribb/Hartmann法提取視錐體*面
    // 側面
    frustumPlanes[0] = c4 - c1; // 右裁剪*面 
    frustumPlanes[1] = c4 + c1; // 左裁剪*面
    frustumPlanes[2] = c4 - c2; // 上裁剪*面
    frustumPlanes[3] = c4 + c2; // 下裁剪*面
    // */遠*面
    frustumPlanes[4] = float4(0.0f, 0.0f, 1.0f, -minTileZ);
    frustumPlanes[5] = float4(0.0f, 0.0f, -1.0f, maxTileZ);
    
    // 標準化視錐體*面(*/遠*面已經標準化)
    [unroll]
    for (uint i = 0; i < 4; ++i)
    {
        frustumPlanes[i] *= rcp(length(frustumPlanes[i].xyz));
    }

    // ...

光源剔除

在算出視錐體*面後,我們可以對這些光源的球包圍盒進行相交測試,來決定當前光源是否保留。由於一個分塊內含16x16個執行緒,大量光源的碰撞檢測可以分散給這些執行緒進行並行運算,然後將位於子視錐體內的光源加入到列表中:

    //
    // 對當前分塊(tile)進行光照剔除
    //
    
    uint totalLights, dummy;
    g_Light.GetDimensions(totalLights, dummy);

    // 組內每個執行緒承擔一部分光源的碰撞檢測計算
    for (uint lightIndex = groupIndex; lightIndex < totalLights; lightIndex += COMPUTE_SHADER_TILE_GROUP_SIZE)
    {
        PointLight light = g_Light[lightIndex];
                
        // 點光源球體與tile視錐體*面的相交測試
        // 當球心位於*面外側且距離超過r,則沒有相交
        bool inFrustum = true;
        [unroll]
        for (uint i = 0; i < 6; ++i)
        {
            float d = dot(frustumPlanes[i], float4(light.posV, 1.0f));
            inFrustum = inFrustum && (d >= -light.attenuationEnd);
        }

        [branch]
        if (inFrustum)
        {
            // 將光照追加到列表中
            uint listIndex;
            InterlockedAdd(s_TileNumLights, 1, listIndex);
            s_TileLightIndices[listIndex] = lightIndex;
        }
    }

    GroupMemoryBarrierWithGroupSync();

在計算著色器完成光照階段

現在我們可以直接利用前面計算到的資訊來完成光照階段的計算,這裡只提取出實際參與計算的程式碼部分(原始碼中DEFER_PER_SAMPLE應總是為1,下面的程式碼略去該巨集及無關的程式碼部分):

RWStructuredBuffer<uint2> g_Framebuffer : register(u1);

// ...

// 當前tile中需要逐樣本著色的畫素列表
// 我們將兩個16位x/y座標編碼進一個uint來節省共享記憶體空間
groupshared uint s_PerSamplePixels[COMPUTE_SHADER_TILE_GROUP_SIZE];
groupshared uint s_NumPerSamplePixels;

//--------------------------------------------------------------------------------------
// 用於寫入我們的1D MSAA UAV
void WriteSample(uint2 coords, uint sampleIndex, float4 value)
{
    g_Framebuffer[GetFramebufferSampleAddress(coords, sampleIndex)] = PackRGBA16(value);
}

// 將兩個<=16位的座標值打包進單個uint
uint PackCoords(uint2 coords)
{
    return coords.y << 16 | coords.x;
}
// 將單個uint解包成兩個<=16位的座標值
uint2 UnpackCoords(uint coords)
{
    return uint2(coords & 0xFFFF, coords >> 16);
}

[numthreads(COMPUTE_SHADER_TILE_GROUP_DIM, COMPUTE_SHADER_TILE_GROUP_DIM, 1)]
void ComputeShaderTileDeferredCS(uint3 groupId : SV_GroupID,
                                 uint3 dispatchThreadId : SV_DispatchThreadID,
                                 uint3 groupThreadId : SV_GroupThreadID,
                                 uint groupIndex : SV_GroupIndex
                                 )
{
    
    // ...
    
    //
    // 光照階段。只處理在螢幕區域的畫素(單個分塊可能超出螢幕邊緣)
    // 
    uint numLights = s_TileNumLights;
    if (all(globalCoords < g_FramebufferDimensions.xy))
    {
        [branch]
        if (numLights > 0)
        {
            bool perSampleShading = RequiresPerSampleShading(surfaceSamples);
            
            float3 lit = float3(0.0f, 0.0f, 0.0f);
            for (uint tileLightIndex = 0; tileLightIndex < numLights; ++tileLightIndex)
            {
                PointLight light = g_Light[s_TileLightIndices[tileLightIndex]];
                AccumulateColor(surfaceSamples[0], light, lit);
            }

            // 計算樣本0的結果
            WriteSample(globalCoords, 0, float4(lit, 1.0f));
            
            [branch]
            if (perSampleShading)
            {
                // 建立需要進行逐樣本著色的畫素列表,延遲其餘樣本的著色
                uint listIndex;
                InterlockedAdd(s_NumPerSamplePixels, 1, listIndex);
                s_PerSamplePixels[listIndex] = PackCoords(globalCoords);
            }
            else
            {
                // 否則進行逐畫素著色,將樣本0的結果也複製到其它樣本上
                [unroll]
                for (uint sample = 1; sample < MSAA_SAMPLES; ++sample)
                {
                    WriteSample(globalCoords, sample, float4(lit, 1.0f));
                }
            }
        }
        else
        {
            // 沒有光照的影響,清空所有樣本
            [unroll]
            for (uint sample = 0; sample < MSAA_SAMPLES; ++sample)
            {
                WriteSample(globalCoords, sample, float4(0.0f, 0.0f, 0.0f, 0.0f));
            }
        }
    }

#if MSAA_SAMPLES > 1
    GroupMemoryBarrierWithGroupSync();

    // 現在處理那些需要逐樣本著色的畫素
    // 注意:每個畫素需要額外的MSAA_SAMPLES - 1次著色passes
    const uint shadingPassesPerPixel = MSAA_SAMPLES - 1;
    uint globalSamples = s_NumPerSamplePixels * shadingPassesPerPixel;

    for (uint globalSample = groupIndex; globalSample < globalSamples; globalSample += COMPUTE_SHADER_TILE_GROUP_SIZE) {
        uint listIndex = globalSample / shadingPassesPerPixel;
        uint sampleIndex = globalSample % shadingPassesPerPixel + 1;        // 樣本0已經被處理過了 

        uint2 sampleCoords = UnpackCoords(s_PerSamplePixels[listIndex]);
        SurfaceData surface = ComputeSurfaceDataFromGBufferSample(sampleCoords, sampleIndex);

        float3 lit = float3(0.0f, 0.0f, 0.0f);
        for (uint tileLightIndex = 0; tileLightIndex < numLights; ++tileLightIndex) {
            PointLight light = g_Light[s_TileLightIndices[tileLightIndex]];
            AccumulateColor(surface, light, lit);
        }
        WriteSample(sampleCoords, sampleIndex, float4(lit, 1.0f));
    }
#endif
}

分塊光源剔除的優缺點

優點:

  • 對含有大量動態光源的場景,能夠有效減少相當部分無關光源的計算
  • 對延遲渲染而言,光源的剔除和計算可以同時在計算著色器中進行

缺點:

  • tile的光源列表資訊受動態光源、攝像機變換的影響,需要每幀都重新計算一次,帶來一定的開銷
  • 基於螢幕空間tile的劃分仍不夠精細,沒有考慮到深度值的劃分
  • 需要支援計算著色器

對於缺點2,空間的進一步精細劃分有下述兩種方法:

  • 分塊2.5D光源剔除
  • Cluster Light Culling(分簇光源剔除)

由於篇幅有限,接下來我們只討論第一種方式,並且留作練習。第二種方式讀者可以自行尋找材料閱讀。

分塊2.5D光源剔除

我們直接從下圖開始:

其中藍色表示分片的子視錐體,黑色表示幾何體的資訊,黃色為點光源。在確定了當前子視錐體觀察空間的zmin和zmax後,我們可以在這一深度範圍內進一步均分成n個單元。右邊表示的是我們對當前深度範圍均分成了8份,如果當前光源位於某一份範圍內,則把該光源對應的8位光源掩碼的對應位置為1;同樣如果當前深度範圍內的所有幾何體(以畫素中的深度集合表示)位於某一份範圍內,則把8位幾何體掩碼的對應位置置為1。如果光照掩碼和幾何體掩碼的按位與運算為0,則表示對於該切片,當前光源不會產生任何光照計算從而應該剔除掉。

為了提高效率,n設定為32,然後對所有的燈光進行迭代,為每一個通過視錐體碰撞測試的燈光建立一個32位光照掩碼。

2.5D光源剔除的具體做法為:

  • 使用16x16大小的Tile先進行子視錐體與光源的碰撞測試,生成一個初步的光源列表
  • 然後以64個執行緒為一組,每個執行緒對Tile中的4個畫素與光源列表的光進行比較,進一步剔除這個列表。如果當前Tile中沒有一個畫素的位置在點光源內,那麼該燈光就可以從列表中剔除。由此產生的燈光集可以算是相當準確的,因為只有那些保證至少影響一個畫素的燈光被儲存。

Forward+

考慮前面分塊剔除光源的過程,實際上也可以應用到前向渲染當中。這種做法的前向渲染可以稱之為Forward+ Rendering,具體流程如下:

  • Pre-Z Pass,因為在光源剔除階段我們需要利用深度資訊,所以記錄場景深度資訊到深度緩衝區中
  • 執行Tile-Based Light Culling
  • 前向渲染,僅繪製和深度緩衝區深度值相等的畫素,並利用所在tile的光源列表來計算顏色

由於光源剔除和光照被分拆了,我們需要儲存光照剔除的結果給下一階段使用。其中光源剔除的shader如下:

struct TileInfo
{
    uint tileNumLights;
    uint tileLightIndices[MAX_LIGHT_INDICES];
};

RWStructuredBuffer<TileInfo> g_TilebufferRW : register(u0);

groupshared uint s_MinZ;
groupshared uint s_MaxZ;

// 當前tile的光照列表
groupshared uint s_TileLightIndices[MAX_LIGHTS >> 3];
groupshared uint s_TileNumLights;

// 當前tile中需要逐樣本著色的畫素列表
// 我們將兩個16位x/y座標編碼進一個uint來節省共享記憶體空間
groupshared uint s_PerSamplePixels[COMPUTE_SHADER_TILE_GROUP_SIZE];
groupshared uint s_NumPerSamplePixels;

[numthreads(COMPUTE_SHADER_TILE_GROUP_DIM, COMPUTE_SHADER_TILE_GROUP_DIM, 1)]
void ComputeShaderTileForwardCS(uint3 groupId : SV_GroupID,
                                uint3 dispatchThreadId : SV_DispatchThreadID,
                                uint3 groupThreadId : SV_GroupThreadID,
                                uint groupIndex : SV_GroupIndex
                                )
{
    //
    // 獲取深度資料,計算當前分塊的視錐體
    //
    
    uint2 globalCoords = dispatchThreadId.xy;
    
    // 尋找所有采樣中的Z邊界
    float minZSample = g_CameraNearFar.y;
    float maxZSample = g_CameraNearFar.x;
    {
        [unroll]
        for (uint sample = 0; sample < MSAA_SAMPLES; ++sample)
        {
            // 這裡取的是深度緩衝區的Z值
            float zBuffer = g_GBufferTextures[3].Load(globalCoords, sample);
            float viewSpaceZ = g_Proj._m32 / (zBuffer - g_Proj._m22);
            
            // 避免對天空盒或其它非法畫素著色
            bool validPixel =
                 viewSpaceZ >= g_CameraNearFar.x &&
                 viewSpaceZ < g_CameraNearFar.y;
            [flatten]
            if (validPixel)
            {
                minZSample = min(minZSample, viewSpaceZ);
                maxZSample = max(maxZSample, viewSpaceZ);
            }
        }
    }
    
    // 初始化共享記憶體中的光照列表和Z邊界
    if (groupIndex == 0)
    {
        s_TileNumLights = 0;
        s_NumPerSamplePixels = 0;
        s_MinZ = 0x7F7FFFFF; // 最大浮點數
        s_MaxZ = 0;
    }

    GroupMemoryBarrierWithGroupSync();
    
    // 注意:這裡可以進行並行歸約(parallel reduction)的優化,但由於我們使用了MSAA並
    // 儲存了多重取樣的畫素在共享記憶體中,逐漸增加的共享記憶體壓力實際上**減小**核心的總
    // 體執行速度。因為即便是在最好的情況下,在目前具有典型分塊(tile)大小的的架構上,
    // 並行歸約的速度優勢也是不大的。
    // 只有少量實際合法樣本的畫素在其中。
    if (maxZSample >= minZSample)
    {
        InterlockedMin(s_MinZ, asuint(minZSample));
        InterlockedMax(s_MaxZ, asuint(maxZSample));
    }

    GroupMemoryBarrierWithGroupSync();

    float minTileZ = asfloat(s_MinZ);
    float maxTileZ = asfloat(s_MaxZ);
    float4 frustumPlanes[6];
    ConstructFrustumPlanes(groupId, minTileZ, maxTileZ, frustumPlanes);
    
    //
    // 對當前分塊(tile)進行光照剔除
    //
    
    uint totalLights, dummy;
    g_Light.GetDimensions(totalLights, dummy);

    // 計算當前tile在光照索引緩衝區中的位置
    uint2 dispatchWidth = (g_FramebufferDimensions.x + COMPUTE_SHADER_TILE_GROUP_DIM - 1) / COMPUTE_SHADER_TILE_GROUP_DIM;
    uint tilebufferIndex = groupId.y * dispatchWidth + groupId.x;
    
    // 組內每個執行緒承擔一部分光源的碰撞檢測計算
    [loop]
    for (uint lightIndex = groupIndex; lightIndex < totalLights; lightIndex += COMPUTE_SHADER_TILE_GROUP_SIZE)
    {
        PointLight light = g_Light[lightIndex];
                
        // 點光源球體與tile視錐體的碰撞檢測
        bool inFrustum = true;
        [unroll]
        for (uint i = 0; i < 6; ++i)
        {
            float d = dot(frustumPlanes[i], float4(light.posV, 1.0f));
            inFrustum = inFrustum && (d >= -light.attenuationEnd);
        }

        [branch]
        if (inFrustum)
        {
            // 將光照追加到列表中
            uint listIndex;
            InterlockedAdd(s_TileNumLights, 1, listIndex);
            g_TilebufferRW[tilebufferIndex].tileLightIndices[listIndex] = lightIndex;
        }
    }
    
    GroupMemoryBarrierWithGroupSync();
    
    if (groupIndex == 0)
    {
        g_TilebufferRW[tilebufferIndex].tileNumLights = s_TileNumLights;
    }
}

最終的前向渲染著色器如下:

StructuredBuffer<TileInfo> g_Tilebuffer : register(t9);
//--------------------------------------------------------------------------------------
// 計算點光源著色 
float4 ForwardPlusPS(VertexPosHVNormalVTex input) : SV_Target
{
    // 計算當前畫素所屬的tile在光照索引緩衝區中的位置
    uint dispatchWidth = (g_FramebufferDimensions.x + COMPUTE_SHADER_TILE_GROUP_DIM - 1) / COMPUTE_SHADER_TILE_GROUP_DIM;
    uint tilebufferIndex = (uint) input.posH.y / COMPUTE_SHADER_TILE_GROUP_DIM * dispatchWidth + 
                           (uint) input.posH.x / COMPUTE_SHADER_TILE_GROUP_DIM;
    
    float3 litColor = float3(0.0f, 0.0f, 0.0f);
    uint numLights = g_Tilebuffer[tilebufferIndex].tileNumLights;
    [branch]
    if (g_VisualizeLightCount)
    {
        litColor = (float(numLights) * rcp(255.0f)).xxx;
    }
    else
    {
        SurfaceData surface = ComputeSurfaceDataFromGeometry(input);
        for (uint lightIndex = 0; lightIndex < numLights; ++lightIndex)
        {
            PointLight light = g_Light[g_Tilebuffer[tilebufferIndex].tileLightIndices[lightIndex]];
            AccumulateColor(surface, light, litColor);
        }
    }

    return float4(litColor, 1.0f);
}

Forward+的優缺點

優點:

  • 由於結合了分塊光源剔除,使得前向渲染的效率能夠得到有效提升
  • 強制Pre-Z Pass可以過濾掉大量不需要執行的畫素片元
  • 前向渲染對材質的支援也比較簡單
  • 降低了頻寬佔用
  • 支援透明物體繪製
  • 支援硬體MSAA

缺點:

  • 相比於延遲渲染的執行效率還是會慢一些
  • 需要支援計算著色器

效能對比

下面使用RTX 3080 Ti 對6種不同的渲染方式及4種MSAA等級的組合進行了幀數測試,結果如下:

其中TBDR(Defer Per Sample)是我們目前使用的方法,與TBDR的區別在於:

  • 將分支中非0樣本著色的過程推遲,先將哪些需要逐樣本著色的畫素新增到畫素列表中
  • 完成0號樣本的著色後,Tile中所有執行緒分擔這些需要逐樣本計算的畫素著色

注意:要測試以前的TBDR,需要到ShaderDefines.hDEFER_PER_SAMPLE設為0,然後執行程式即可。

可以發現,TBDR(Defer Per Sample)的方法在4x MSAA前都有碾壓性的優勢,在8x MSAA不及Forward+。由於延遲渲染對MSAA的支援比較麻煩,隨著取樣等級的變大,幀數下降的越明顯;而前向渲染由於直接支援硬體MSAA,提升MSAA的等級對效能下降的影響比較小。

此外,由於TBDR在Tile為16x16畫素大小時,一次可以同時處理256個光源的碰撞檢測,或者256個逐樣本著色的畫素著色,可能要在燈光數>256的時候才會有比較明顯的效能影響。

演示

由於現在解析度大了,GIF錄起來很難壓到10M內,這裡就只放幾張演示圖跟操作說明

  • 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的梯度

下圖展示了每個分塊的燈光數目(越亮表示此處產生影響的燈光數越多):

下圖展示了需要進行逐樣本著色的區域(紅色邊緣區域):

練習題

  1. 嘗試實現分塊2.5D光源剔除

補充&參考

Deferred Rendering for Current and Future Rendering Pipelines

Fast Extraction of Viewing Frustum Planes from the World-View-Projection Matrix


DirectX11 With Windows SDK完整目錄

Github專案原始碼

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

相關文章