從零開始手敲次世代遊戲引擎(五十六)

從零開始手敲次世代遊戲引擎(五十六)

來自專欄高品質遊戲開發

很久沒更新了,非常抱歉。

最近工作上要準備好幾個開發會上的演講稿,個人生活上則隨著二寶的降生也進入了夜不能寐的狀況。

但是,雖然很難保證定期的更新,我會堅持下去的。

本篇我們來看一下Direct X 12環境下MSAA的實現。題圖展現了沒有開啟MSAA和開啟了4倍MSAA的畫面對比,包括局部放大對比。

在OpenGL當中,MSAA的處理被作為固定的管線功能進行封裝,我們所需要做的只是打開相應的設置(管線狀態)即可。其實在DX11之前的DX系API當中,也是類似的。然而在DX12當中,由於嚮應用直接開放了內存和命令隊列的管理,所以MSAA的實現就需要我們自己手動進行了。

在之前,我們可能只需要知道MSAA這個名詞,以及打開它的方法就好了。但是現在我們需要自己實現它,就需要掌握MSAA的實質了。因此,讓我們首先來回顧一下AA以及MSAA的概念。

AA與MSAA理論回顧

AA是採樣理論當中的一個概念。在採樣理論當中,有一個稱為奈奎斯特定理(也稱為採樣定理)的東西,說的是在將連續信號離散化的時候,採樣的頻率不能低於連續信號當中所包括的最高頻率分量信號的頻率的2倍。只有這樣,我們才能從所得的樣本還原出原始信號的所包含的所有頻率的分量。

換句話說,如果我們的採樣頻率(就是每單位時間採樣的個數)為f,那麼我們最多只能還原出頻率為f/2的信號,而無法還原出高於f/2的信號。

對於圖形渲染來說,我們最終得到的是一幅由像素組成的2D平面圖像,這個圖像就是對場景的一次離散採樣。如果記圖片的尺寸為WxH,那麼這個圖像的橫向採樣頻率可以看作W,而縱向採樣頻率就可以看作H。

那麼,根據奈奎斯特定理,我們無法在這個圖像當中還原任何橫向變化頻率大於W/2的東西,同時在縱向無法還原任何縱向變化頻率大於H/2的東西。當圖像所代表的場景當中存在這樣的變化頻率的東西的時候,圖像當中就會出現實際並不存在的圖形,這個就叫做走樣(Aliasing),參見下圖:

圖片的上半部發生了走樣(Aliasing)

而解決這個問題的技術,就被稱為是反走樣(Anti-Aliasing)技術,簡稱為AA技術。

現在再來看圖形學當中的AA技術。在從零開始手敲次世代遊戲引擎(五十)以及從零開始手敲次世代遊戲引擎(五十一)當中,我們介紹了直線和三角形像素化的基本演算法。我們最後得到的結果是下面這個樣子的:

直線像素化之後產生的鋸齒,也就是走樣

General Triangle: * ****** ************* ****************** *********************** ******************* ************** ********** ****** *

可以看到在圖形的邊緣,發生了鋸齒狀的起伏,而不是理論上的光滑直線。造成這個現象的原因就是因為我們能夠使用的像素數(相當於採樣率)是有限的,而一根光滑的直線,根據其角度,在傅里葉分解之後可能含有頻率為無窮大的周期信號分量,這是用有限的像素數無法完全還原的。

這也就是說這個問題從根本上來說是無解的。但是基於下面這兩個事實:

  1. 人眼的解析度也是有限的。所以人眼看到的圖像本身也是走樣的。只不過,人眼+腦可以通過一些像素的插值來腦補缺失的部分。也就是說,如果一個像素是白色的代表沒有物體(信息),黑色的代表有物體(信息),那麼如果在黑色像素和白色像素之間顯示一個灰色的像素會讓人覺得這個地方包括一部分物體(信息)
  2. 我們可以用比圖像實際尺寸更高的採樣頻率進行採樣之後,再按照一定的方法將樣本合併,將樣本數減少到圖像實際支持的數目。合併的結果使得我們的每個像素值包含了臨近多個採樣的信息。根據1,人眼在一定程度上可以從這個合併的結果以及周圍的像素信息腦補(插值出)額外的樣本信息,從而「看到」更為平滑的圖像

這就是圖形學當中的AA技術的本質。根據上面的事實,首先可以想到的就是以高於圖像解析度的採樣頻率進行採樣,這種技術被稱為SSAA(Super-Sampling AA)。比如我們有的時候會覺得在手機上看片比在電視上看片更清晰,即使是同樣的片源。這就是因為手機頻幕更小,即使解析度相同,但是單位物理長度上相當於有更高的採樣頻率。

可以很容易想到,如果要實現SSAA,那麼就是先以一個大於實際屏幕解析度的設置進行場景渲染,然後將渲染結果縮小到屏幕尺寸。

事實上,提高採樣頻率是唯一的真正解決Aliasing的方法。然而在圖形渲染當中,這種方法就意味著我們要進行更多的計算,而且是與圖像面積成正比(也就是與長/寬成平方比)的關係,對於具備軟實時要求的遊戲畫面渲染來說,很多時候過於昂貴。

為了縮減AA的計算成本,同時提供「看起來良好」的圖像,各種各樣的AA演算法被提出,其中就包括MSAA。根據實際是否有進行超採樣,我們可以將AA演算法分為兩類:進行了超採樣的AA(比如MSAA)和沒有進行超採樣的AA(比如FXAA,TAA)。前者可以看作是對SSAA的演算法近似,而後者則屬於圖像處理技術,事實上是進一步減少了有效(正確的)樣本數,但是讓圖像看起來符合人的習慣。

MSAA相對於SSAA的改變在於,利用人眼對於物體邊緣的走樣更為敏感的特點,只對圖像當中的場景物體邊緣進行超採樣,而不對場景物體內部進行超採樣的計算。然而,GPU所能看到的是一連串的三角形,並不存在「場景物體」的概念,所以它是如何檢測這個「邊緣」的呢?

其實答案就在於深度測試當中。嚴格來說,MSAA當中的「物體」並不完全等同於場景當中的「場景物體」,而是指在深度連續變化的部分。而那些在深度上不連續變化的部分,被當作「物體」的「邊緣」進行超採樣處理。

色彩緩衝區與深度緩衝區當中的場景表示。MSAA只是對深度緩衝區當中數值發生突變的部分(邊緣)進行AA計算操作。

DX12當中的MSAA實現

由於DX12提供了更為接近硬體的介面,所以在DX12當中我們不再能夠使用支持MSAA的SwapChain。也就是說,在DX12之前,我們只需要創建一塊支持MSAA的RenderingTarget,然後就像沒有開啟MSAA一樣進行渲染,然後將這個RenderingTarget交給圖形驅動去顯示就好了;但是這個方法在DX12當中不能夠再用了,因為在DX12當中圖形驅動只負責將RenderingTarget當中的色彩圖像拷貝到屏幕緩衝區進行顯示(根據設置可以提供比如拉伸等操作),但是不再負責將一塊支持MSAA的RenderingTarget(樣本數大於屏幕實際可以渲染像素數)變換成一塊非MSAA的RenderingTarget(樣本數與屏幕實際可以渲染像素數相同)了。這個過程(稱為Resolve過程)需要我們自己來完成。

自己完成這個Resolve過程有下面這兩種方法:

  1. 使用ID3D12GraphicsCommandList::ResolveSubresource方法。這個方法為我們提供了類似於DX11之前的預設的Resolve方法,就是對MSAA的不同樣本採用取平均值的方法得到最終像素顏色;
  2. 創建額外的PSO,在場景渲染完成之後,通過一個額外的全屏Quad的渲染啟動一個用於計算MSAA Resolve的PS Shader,將MSAA RT的色彩緩衝區作為貼圖輸入(MSAA各個Sample的值會存儲在貼圖不同的MIP等級上),然後求所有MIP等級值的平均(當然也可以不是求平均,這裡Resolve的演算法可以根據需要任意定製)作為像素的最終顏色輸出到non-MSAA RT。

修改PSO打開MSAA

我們首先需要通知Rasterizer我們希望使用MSAA,也就是在三角形像素化的時候應該為每個像素生成多個Sample。我們這裡假設使用4倍MSAA,注意psod.SampleDesc的變化:

// describe and create the graphics pipeline state object (PSO) D3D12_GRAPHICS_PIPELINE_STATE_DESC psod = {}; psod.pRootSignature = m_pRootSignature; psod.VS = vertexShaderByteCode; psod.PS = pixelShaderByteCode; psod.BlendState = bld; psod.SampleMask = UINT_MAX; psod.RasterizerState= rsd; psod.DepthStencilState = dsd; psod.InputLayout = { ied, _countof(ied) }; psod.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE; psod.NumRenderTargets = 1; psod.RTVFormats[0] = DXGI_FORMAT_R8G8B8A8_UNORM; psod.DSVFormat = DXGI_FORMAT_D32_FLOAT; psod.SampleDesc.Count = 4; // 4X MSAA psod.SampleDesc.Quality = DXGI_STANDARD_MULTISAMPLE_QUALITY_PATTERN; if (FAILED(hr = m_pDev->CreateGraphicsPipelineState(&psod, IID_PPV_ARGS(&m_pPipelineState)))) { return false; }

創建支持MSAA的RT

如上所述 ,為了實現MSAA,我們首先追加一個支持為每個像素存儲多個Sample值的RenderingTarget。這是通過如下的代碼實現的:

D3d12GraphicsManager.hpp

ID3D12Resource* m_pMsaaRenderTarget; // the pointer to the MSAA rendering target

D3d12GraphicsManager.cpp

D3D12_RESOURCE_DESC textureDesc = {}; textureDesc.MipLevels = 1; textureDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; textureDesc.Width = g_pApp->GetConfiguration().screenWidth; textureDesc.Height = g_pApp->GetConfiguration().screenHeight; textureDesc.Flags = D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGET; textureDesc.DepthOrArraySize = 1; textureDesc.SampleDesc.Count = 4; textureDesc.SampleDesc.Quality = DXGI_STANDARD_MULTISAMPLE_QUALITY_PATTERN; textureDesc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D; if (FAILED(hr = m_pDev->CreateCommittedResource( &prop, D3D12_HEAP_FLAG_NONE, &textureDesc, D3D12_RESOURCE_STATE_RENDER_TARGET, &optimizedClearValue, IID_PPV_ARGS(&m_pMsaaRenderTarget) ))) { return hr; } // Describe and create a SRV for the texture. D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc = {}; srvDesc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING; srvDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; srvDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2DMS; srvDesc.Texture2D.MipLevels = 4; srvDesc.Texture2D.MostDetailedMip = 0; D3D12_CPU_DESCRIPTOR_HANDLE srvHandle; size_t texture_id = static_cast<uint32_t>(m_TextureIndex.size()); srvHandle.ptr = m_pCbvHeap->GetCPUDescriptorHandleForHeapStart().ptr + (kTextureDescStartIndex + texture_id) * m_nCbvSrvDescriptorSize; m_pDev->CreateShaderResourceView(m_pMsaaRenderTarget, &srvDesc, srvHandle); m_TextureIndex["MSAA"] = texture_id; m_pDev->CreateRenderTargetView(m_pMsaaRenderTarget, &renderTargetDesc, rtvHandle);

然後,我們在實際錄製任何繪圖命令之前,綁定MSAA RT

// bind the MSAA buffer rtvHandle.ptr = m_pRtvHeap->GetCPUDescriptorHandleForHeapStart().ptr + kFrameCount * m_nRtvDescriptorSize; D3D12_CPU_DESCRIPTOR_HANDLE dsvHandle; dsvHandle = m_pDsvHeap->GetCPUDescriptorHandleForHeapStart(); m_pCommandList->OMSetRenderTargets(1, &rtvHandle, FALSE, &dsvHandle);

然後如同以前一樣地進行繪製命令的錄製。

使用DX12內建的Resolve方法

在所有繪製命令完成之後,調用ID3D12GraphicsCommandList::ResolveSubresource將MSAA RT解決為non-MSAA RT:

m_pCommandList->ResolveSubresource(m_pRenderTargets[m_nFrameIndex], 0, m_pMsaaRenderTarget, 0, DXGI_FORMAT_R8G8B8A8_UNORM);

使用PS Shader來進行MSAA Resolve

我們也可以通過額外的繪製命令+特別的PS Shader來完成MSAA的Resolve。為了實現這個方法,我們首先需要創建第二個PSO(渲染管道狀態),綁定MSAA Resolve用的Shader,並且關閉Rasterizor的MSAA(注意psod.SampleDesc的變化):

// resolve pass { const char* vsFilename = "Shaders/msaa_resolver_vs.cso"; const char* fsFilename = "Shaders/msaa_resolver_ps.cso"; // load the shaders Buffer vertexShader = g_pAssetLoader->SyncOpenAndReadBinary(vsFilename); Buffer pixelShader = g_pAssetLoader->SyncOpenAndReadBinary(fsFilename); D3D12_SHADER_BYTECODE vertexShaderByteCode; vertexShaderByteCode.pShaderBytecode = vertexShader.GetData(); vertexShaderByteCode.BytecodeLength = vertexShader.GetDataSize(); D3D12_SHADER_BYTECODE pixelShaderByteCode; pixelShaderByteCode.pShaderBytecode = pixelShader.GetData(); pixelShaderByteCode.BytecodeLength = pixelShader.GetDataSize(); // create the input layout object D3D12_INPUT_ELEMENT_DESC ied[] = { {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0}, {"TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0}, }; D3D12_RASTERIZER_DESC rsd = { D3D12_FILL_MODE_SOLID, D3D12_CULL_MODE_BACK, TRUE, D3D12_DEFAULT_DEPTH_BIAS, D3D12_DEFAULT_DEPTH_BIAS_CLAMP, TRUE, FALSE, FALSE, 0, D3D12_CONSERVATIVE_RASTERIZATION_MODE_OFF }; const D3D12_RENDER_TARGET_BLEND_DESC defaultRenderTargetBlend = { FALSE, FALSE, D3D12_BLEND_ONE, D3D12_BLEND_ZERO, D3D12_BLEND_OP_ADD, D3D12_BLEND_ONE, D3D12_BLEND_ZERO, D3D12_BLEND_OP_ADD, D3D12_LOGIC_OP_NOOP, D3D12_COLOR_WRITE_ENABLE_ALL }; D3D12_BLEND_DESC bld = { FALSE, FALSE, { defaultRenderTargetBlend, defaultRenderTargetBlend, defaultRenderTargetBlend, defaultRenderTargetBlend, defaultRenderTargetBlend, defaultRenderTargetBlend, defaultRenderTargetBlend, } }; const D3D12_DEPTH_STENCILOP_DESC defaultStencilOp = { D3D12_STENCIL_OP_KEEP, D3D12_STENCIL_OP_KEEP, D3D12_STENCIL_OP_KEEP, D3D12_COMPARISON_FUNC_ALWAYS }; D3D12_DEPTH_STENCIL_DESC dsd = { TRUE, D3D12_DEPTH_WRITE_MASK_ALL, D3D12_COMPARISON_FUNC_LESS, FALSE, D3D12_DEFAULT_STENCIL_READ_MASK, D3D12_DEFAULT_STENCIL_WRITE_MASK, defaultStencilOp, defaultStencilOp }; // describe and create the graphics pipeline state object (PSO) D3D12_GRAPHICS_PIPELINE_STATE_DESC psod = {}; psod.pRootSignature = m_pRootSignatureResolve; psod.VS = vertexShaderByteCode; psod.PS = pixelShaderByteCode; psod.BlendState = bld; psod.SampleMask = UINT_MAX; psod.RasterizerState= rsd; psod.DepthStencilState = dsd; psod.InputLayout = { ied, _countof(ied) }; psod.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE; psod.NumRenderTargets = 1; psod.RTVFormats[0] = DXGI_FORMAT_R8G8B8A8_UNORM; psod.DSVFormat = DXGI_FORMAT_UNKNOWN; psod.SampleDesc.Count = 1; // no MSAA psod.SampleDesc.Quality = 0; // no MSAA if (FAILED(hr = m_pDev->CreateGraphicsPipelineState(&psod, IID_PPV_ARGS(&m_pPipelineStateResolve)))) { return false; } }

然後同樣是在所有繪圖命令錄製完成的地方,我們加入如下額外的繪圖命令(MSAA Resolve Pass):

// MSAA resolve pass barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; barrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE; barrier.Transition.pResource = m_pMsaaRenderTarget; barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET; barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; m_pCommandList->ResourceBarrier(1, &barrier); m_pCommandList->SetPipelineState(m_pPipelineStateResolve); // Indicate that the back buffer will be used as a render target. barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; barrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE; barrier.Transition.pResource = m_pRenderTargets[m_nFrameIndex]; barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT; barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET; barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; m_pCommandList->ResourceBarrier(1, &barrier); rtvHandle.ptr = m_pRtvHeap->GetCPUDescriptorHandleForHeapStart().ptr + m_nFrameIndex * m_nRtvDescriptorSize; m_pCommandList->OMSetRenderTargets(1, &rtvHandle, FALSE, nullptr); // Set necessary state. m_pCommandList->SetGraphicsRootSignature(m_pRootSignatureResolve); // Set CBV m_pCommandList->SetGraphicsRoot32BitConstants(1, 1, &g_pApp->GetConfiguration().screenWidth, 0); m_pCommandList->SetGraphicsRoot32BitConstants(1, 1, &g_pApp->GetConfiguration().screenHeight, 1); // Set SRV auto texture_index = m_TextureIndex["MSAA"]; D3D12_GPU_DESCRIPTOR_HANDLE srvHandle; srvHandle.ptr = m_pCbvHeap->GetGPUDescriptorHandleForHeapStart().ptr + (kTextureDescStartIndex + texture_index) * m_nCbvSrvDescriptorSize; m_pCommandList->SetGraphicsRootDescriptorTable(0, srvHandle); m_pCommandList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP); m_pCommandList->IASetVertexBuffers(0, 1, &m_VertexBufferViewResolve); m_pCommandList->DrawInstanced(4, 1, 0, 0); barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; barrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE; barrier.Transition.pResource = m_pRenderTargets[m_nFrameIndex]; barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET; barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PRESENT; barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; m_pCommandList->ResourceBarrier(1, &barrier);

實際上就是使用新的PSO,畫一個全屏的矩形。這樣畫面上的每個像素都會執行一次我們的MSAA Resolver (PS Shader)。這個Shader是這麼個樣子(包括VS Shader)

#define SamplesMSAA 4Texture2DMS<float4, SamplesMSAA> msaaTexture : register(t0); cbuffer msaaDesc : register(b0){ uint2 dimensions;} struct v2p{ float4 position : SV_POSITION; float2 texCoord : TEXCOORD0;};v2p VSMain(uint id : SV_VertexID, float3 position : POSITION, float2 texCoord : TEXCOORD0){ v2p result; result.position = float4(position, 1.0f); result.texCoord = texCoord; return result;}float4 PSMain(v2p input) : SV_TARGET{ uint2 coord = uint2(input.texCoord.x * dimensions.x, input.texCoord.y * dimensions.y); float4 tex = float4(0.0f, 0.0f, 0.0f, 0.0f); #if SamplesMSAA == 1 tex = msaaTexture.Load(coord, 0);#else for (uint i = 0; i < SamplesMSAA; i++) { tex += msaaTexture.Load(coord, i); } tex *= 1.0f / SamplesMSAA;#endif return tex;}

效果和使用ID3D12GraphicsCommandList::ResolveSubresource是一樣的。只不過如果顯卡有硬體的MSAA Resolver,這個方法不會使用它(是一種軟Resolver)。但是使用這種方法的好處是,我們可以自由地編寫不同的Resolve方法。

參考引用

Aliasing?

graphics.wikia.com

Multisample anti-aliasing?

en.wikipedia.org

Z-buffering - Wikipedia?

en.wikipedia.org圖標MSAA in DX12??

www.gamedev.net圖標ID3D12GraphicsCommandList::ResolveSubresource method?

msdn.microsoft.com


推薦閱讀:

Unity的立體幾何問題
【GDC2016】Lighting The City of Glass - Rendering 「Mirror『s Edge Catalyst」
計算機圖形學常用術語整理
Tech Art學習筆記3-Path Tracing
圖靈宇宙漫遊指南

TAG:遊戲引擎 | DirectX12 | 計算機圖形學 |