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

前言

在上一章,我們主要介紹瞭如何使用延遲渲染,以及如何對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,以及有什麼問題也可以在這裏彙報。

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