下载 PDF[PDF 612KB]
5.0 前面章节的链接
第 1 章: DirectX* 12 概述
第 2 章: DirectX 12 工具
第 3 章: 从 DirectX 11 迁移到 DirectX 12
第 4 章: DirectX 12 特性
5.1 DirectX 12 多线程的基本知识
5.1.1 简介
图形渲染是现代 3D 游戏的一项主要任务。从技术上讲,在 DirectX 9/10 中,所有渲染 API 都必须在一个线程中调用。DirectX 11 增强了多线程支持,但各个线程的负载极不平衡。与渲染相关的负载主要在游戏的主渲染线程和图形驱动程序中完成,这就让渲染任务充分利用现代多核处理器的功能充满挑战性,因而往往成为游戏渲染管道的一个主要性能瓶颈。
要提高图形渲染的效率,多线程已在 DirectX 12 中获得了前所未有的支持。在重新设计的 DirectX 12 中,为了让应用的图形渲染在使用多核 CPU 的过程中获得最高效率:一方面,DirectX 12 尽可能地预处理和重新使用渲染命令,以降低切换渲染状态的成本,并提升在 CPU 和 GPU 上处理渲染 API 的效率;另一方面,DirectX 12 提供了更高效的多线程渲染机制,允许应用最大限度地利用多任务并提高性能。多线程的采用可以降低 CPU 端的图形驱动程序的成本并显著提高 GPU 的效率。DX12 的多线程机制不仅可以让渲染任务以更加平衡的方式在不同的并行处理器核心上运行,而且也可以降低 CPU 功耗,这对移动平台上运行的游戏而言非常重要。
英特尔在 SIGGRAPH 2014 上展示了与 DirectX 11 和 DirectX 12 一起开发的太空战机 (Asteroids Demo)。在此程序中,用户可以在运行时切换到 DirectX 11 或 DirectX 12 进行渲染。在渲染一帧期间,需要绘制 5 万个太空战机,这意味着绘制调用在 CPU 端提交了 5 万次;同时,由于大量不同纹理、模型和其他数据的随机组合,该演示可以反映在驱动程序层效率方面,两代图形 API 之间的差异。凭借多线程等技术,与 DirectX 11 相比,DirectX 12 在帧速率和功耗方面表现出极大的优势。有关详细信息,请参阅 DirectX 开发博客:http://blogs.msdn.com/b/directx/archive/2014/08/13/directx-12-high-performance-and-high-power-savings.aspx
5.1.2 关键基础架构
(1) 命令列表和命令队列
在 DirectX 12 多线程编程方面,命令列表和命令队列是关键基础架构。下面,我们将首先简单比较 DirectX 12、DirectX 9 和 DirectX 11 在渲染命令方面的差异。
在 DirectX 9 中,大部分渲染命令由设备接口调用,如 BeginScene、Clear 和 DrawIndexedPrimitive;而渲染状态则由 Device SetRenderState 解决。在 DirectX 11 中,渲染命令大多数是通过调用上下文的相关接口来执行。但是,在 DirectX 12 中,要尽可能地预处理单线程,同时增加多个线程并行运行的可能性,我们需要使用命令列表对象。大多数上述渲染命令可通过调用命令列表上的接口(对于每个接口的特定定义,请参照 DirectX 12 SDK 的 d3d12.h 头文件,了解 ID3D12GraphicsCommandList 接口声明)来执行。要向 GPU 提交命令列表进行执行,我们需要使用命令队列对象。此处,命令队列主要负责提交命令列表并同步它的执行流程。以下代码演示了如何创建命令列表,并将其用于记录由命令队列最终提交的渲染命令。
代码如下:
表 5.1:命令列表和命令队列的用法
// Command Allocator is responsible for Command List related memory allocation // The D3D12_COMMAND_LIST_TYPE_DIRECT parameter indicates that this allocator is used for the directy type of Command List ComPtr<ID3D12CommandAllocator>pCommandAllocator; pDevice->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(pCommandAllocator))); // Create the Command List ComPtr<ID3D12GraphicsCommandList>pCommandList; pDevice->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, pCommandAllocator, pPipelineState, IID_PPV_ARGS(&pCommandList))); // Description of Command Queue // Type = D3D12_COMMAND_LIST_TYPE_DIRECT specifies that this Command Queue is well suited to submit the Command List D3D12_COMMAND_QUEUE_DESCqueueDesc = {}; queueDesc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE; queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT; // Create a Command Queue ComPtr<ID3D12CommandQueue>pCommandQueue; pDevice->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&pCommandQueue))); // Invoke the rendering related interfaces through the Command List // For illustration, here we just name a few. Please refer to the ID3D12GraphicsCommandList interface declaration for other interfaces pCommandList->ClearRenderTargetView(rtvDescriptor, clearColor, 0, nullptr); pCommandList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); pCommandList->IASetVertexBuffers(0, 1, &pVertexBufferView); pCommandList->DrawInstanced(3, 1, 0, 0); // Execute the Command List through the Command Queue which can submit multiple Command Lists at a time ID3D12CommandList* ppCommandLists[] = { pCommandList.Get() }; pCommandQueue->ExecuteCommandLists(_countof(ppCommandLists), ppCommandLists);
值得注意的是,DirectX 12 设备上所有接口都是线程自由的,除了命令列表是单线程。要更出色地并行化跨 CPU 内核的渲染工作,我们使用多个命令列表来拆分渲染任务、向不同的命令列表分配渲染命令以及最终向命令队列提交命令列表上的渲染命令实现 GPU 执行。通过准备多个命令列表,我们可以独立调用在不同线程中单独维护的命令列表的渲染命令接口。同时,命令队列是线程自由的,应用的不同线程可以在命令队列上以任何顺序执行各种命令列表。
(2) 捆绑包和管线状态对象
为了优化单个线程中驱动程序的效率,DirectX 12 进一步引入了命令列表的第二级,即捆绑包。该对象的目的是允许应用事先创建一组 API 命令(“记录”),以便稍后重复使用。创建捆绑包时,显示驱动程序能尽可能地预处理这些命令,以便最大限度地优化这组 API 命令。更新和维护渲染状态一直招致图形驱动程序产生很多性能开销。DirectX 12 将这部分状态抽象成管线状态对象 (PSO),从而更好地将其映射到图形硬件的当前状态,从而减少切换和管理成本。
(3) 资源屏障
在 DirectX 12 中,对各个资源状态的管理已由图形驱动程序转移到应用,这就大大降低了驱动程序跟踪和维护资源状态的成本。此时,我们需要使用资源屏障机制。这种所谓的“资源屏障”的使用情形很常见。例如,地图既可以在渲染时作为引用的地图资源(着色器资源视图,SRV),也可以视为渲染目标(渲染目标视图,RTV)。让我们来看看一个真实的例子:当我们需要一个阴影图时,需要提前将场景深度渲染到这个地图资源,在这种情况下,资源是 RTV;当渲染具备阴影效果的场景时,这张图将用作 SRV。现在,这些都需要使用资源屏障由应用自行处理,以告知 GPU 资源状态。
代码如下:
表 5.2:资源屏障的用法
// The shadow map transitions from the Common state to the Depth Write state to render the scene depth into it pCommandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(pShadowTexture, D3D12_RESOURCE_STATE_COMMON, D3D12_RESOURCE_STATE_DEPTH_WRITE)); // The shadow map will be used as the Shader Resource of pixel shader. When rendering the scene, the shadow map will be sampled. pCommandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(pShadowTexture, D3D12_RESOURCE_STATE_DEPTH_WRITE, D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE)); // The shadow map restores to the Common state pCommandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(pShadowTexture, D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE, D3D12_RESOURCE_STATE_COMMON));
(4) 围栏
DirectX 12 引入了围栏对象来实现 GPU-CPU 和 GPU-GPU 同步。围栏是符合轻量级同步原语要求的无锁同步机制。大致说来,通信是关于整数变量。
对于 GPU-CPU 同步,代码如下:
表 5.3:创建围栏对象
// Create a Fence, where the initial value is fenceValue ComPtr<ID3D12Fence>pFence; pDevice->CreateFence(fenceValue, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&pFence)));
两类 GPU-CPU 同步通过围栏启用。第一类是,CPU 端的线程查询围栏的当前值来获得 GPU 端的任务进度:
表 5.4:通过查询围栏值进行同步
pCommandQueue->Signal(pFence.Get(), fenceValue); // Query the completion value (progress) on Fence on the CPU side // If the value is smaller than fenceValue, call DoOtherWork. if (pFence->GetCompletedValue() <fenceValue) { DoOtherWork(); }
另一类是,当护栏值达到指定值时,CPU 端的线程会要求 GPU 将其唤醒进行同步,并与其他 Win32 API 进行协调,以满足大量的同步要求。
代码如下:
表 5.5:通过指定围栏值进行同步
if (pFence->GetCompletedValue() <fenceValue) { pFence->SetEventOnCompletion(fenceValue, hEvent); WaitForSingleObject(hEvent, INFINITE); }
5.1.3 多线程渲染示例
现在,我们尝试通过一个简单的例子来说明如何使用 DirectX 12 多线程,以及如何拆分渲染任务,以便显著地提高渲染效率。为了方便说明并尽可能让其简单、易于理解,我们将结合使用伪代码,同时,我们要省略函数中的某些参数,但这不会影响您的理解。
在我们的例子中,OnRender 是 DirectX 12 的标准单线程渲染函数。它被用于渲染游戏场景中的一帧。在此函数中,我们使用命令列表来记录所有渲染命令,其中包括用于设置后台缓冲区的资源屏障状态、清除颜色及绘制每个网格的命令,然后使用命令队列来执行命令列表,最后通过交换链演示全貌。
渲染函数的代码如下所示:
表 5.6:原始单线程渲染函数
voidOnRender() { // Reset the Command List pCommandList->Reset(...); // Set barrier for the back buffer, change the barrier state from Present to Render Target pCommandList->ResourceBarrier(1, (..., D3D12_RESOURCE_STATE_PRESENT, D3D12_RESOURCE_STATE_RENDER_TARGET)); // Set the render target pCommandList->OMSetRenderTargets(...); // Clear the render target pCommandList->ClearRenderTargetView(...); // Set the primitive/topology type pCommandList->IASetPrimitiveTopology(...); // Other operations on the Command List // ... // Draw each mesh foreachMeshinMeshes { pCommandList->DrawInstanced(...); } // Set barrier for the back buffer, change the barrier state from Render Target to Present pCommandList->ResourceBarrier(1, (..., D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PRESENT)); // Close the Command List pCommandList->Close(); // Execute the Command List on the Command Queue pCommandQueue->ExecuteCommandLists(...); // Present using SwapChain pSwapChain->Present(...); }
接下来,我们将通过使用 DirectX 12 多线程来修改程序,实现并行化渲染函数。在程序初始化阶段,我们创建了许多工作线程来处理场景中大量对象的渲染命令。对于每个工作线程,我们平均分配场景中相同数量的网格。与此同时,我们为每个工作线程创建了多个命令列表。每个命令列表负责记录子线程的某些渲染任务。通常,每个子线程只需管理一个命令列表。以下是为每个工作线程创建多个命令列表(子任务)的好处:当工作线程被分配许多任务时,它可以通知主线程向 GPU 提交渲染命令,而无需完成所有任务,从而改进了 CPU/GPU 的并行度。Win32’ssemaphore 和等待 API 用于实现主线程和工作线程之间的同步。
主线程渲染函数的代码如下所示:
表 5.7:多线程主线程渲染函数
voidOnRender_MainThread() { // Notify each child rendering thread to begin rendering forworkerIdinworkerIdList { SetEvent(BeginRendering_Events[workerId]); } // Pre Command List is used to prepare the rendering // Reset the Pre Command List pPreCommandList->Reset(...); // Set barrier between Presentation state to Rendering Target for the back buffer pPreCommandList->ResourceBarrier(1, (..., D3D12_RESOURCE_STATE_PRESENT, D3D12_RESOURCE_STATE_RENDER_TARGET)); // Clear the color of back buffer pPreCommandList->ClearRenderTargetView(...); // Clear the depth/template of back buffer pPreCommandList->ClearDepthStencilView(...); // Other operations on the Pre Command List // ... // Close the Pre Command List pPreCommandList->Close(); // Post Command List is used for finishing touches after the rendering // Set barrier between Presentation state to Rendering Target for the back buffer pPostCommandList->ResourceBarrier(1, (..., D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PRESENT)); // Other operations on the Post Command List // ... // Close the Post Command List pPostCommandList->Close(); // Submit the Pre Command List pCommandQueue->ExecuteCommandLists(..., pPreCommandList); // Wait for all worker threads to complete Task1 WaitForMultipleObjects(Task1_Events); // Submit the Command Lists for Task1 on all the worker threads pCommandQueue->ExecuteCommandLists(...,pCommandListsForTask1); // Wait for all worker threads to complete Task2 WaitForMultipleObjects(Task2_Events); // Submit the completed rendering commands (Command Lists for Task2 on all the worker threads) pCommandQueue->ExecuteCommandLists(..., pCommandListsForTask2); // ... // Wait for all worker threads to complete TaskN WaitForMultipleObjects(TaskN_Events); // Submit the completed rendering commands (Command Lists for TaskN on all the worker threads) pCommandQueue->ExecuteCommandLists(..., pCommandListsForTaskN); // Submit the remaining Command Lists (pPostCommandList) pCommandQueue->ExecuteCommandLists(..., pPostCommandList); // using SwapChain presentation pSwapChain->Present(...); }
工作线程函数的代码如下所示:
表 5.8:多线程子线程渲染函数
voidOnRender_WorkerThread(workerId) { // Each loop represents rendering of one frame on child threads while (running) { // Wait for event notification from the main thread to begin rendering one frame WaitForSingleObject(BeginRendering_Events[workerId]); // Rendering subtask1 { pCommandList1->SetGraphicsRootSignature(...); pCommandList1->IASetVertexBuffers(...); pCommandList1->IASetIndexBuffer(...); //... pCommandList1->DrawIndexedInstanced(...); pCommandList1->Close(); // Notify the main thread that the rendering subtask1 on the current worker thread completes SetEvent(Task1_Events[workerId]); } // Rendering subtask2 { pCommandList2->SetGraphicsRootSignature(...); pCommandList2->IASetVertexBuffers(...); pCommandList2->IASetIndexBuffer(...); //... pCommandList2->DrawIndexedInstanced(...); pCommandList2->Close(); // Notify the main thread that the rendering subtask2 on the current worker thread completes SetEvent(Task2_Events[workerId]); } // More rendering subtasks //... // Rendering subtaskN { pCommandListN->SetGraphicsRootSignature(...); pCommandListN->IASetVertexBuffers(...); pCommandListN->IASetIndexBuffer(...); //... pCommandListN->DrawIndexedInstanced(...); pCommandListN->Close(); // Notify the main thread that the rendering subtaskN on the current worker thread completes SetEvent(TaskN_Events[workerId]); } } }
这样一来,我们成功地向子线程分配了任务,同时让主线程专注于准备和渲染后的收尾等任务。子线程只需及时地通知主线程其工作,并使用多个命令列表在不中断的情况下完成一帧的渲染命令。同时,主线程也可以专注于自己的工作,适当时等待子线程完成阶段性工作,并通过命令队列向 GPU 提交子线程中的相关命令列表。当然,只要子线程可以确保以正确的顺序完成渲染,也可以通过命令队列提交命令列表上的命令。为方便说明,此处我们将通过命令队列提交命令列表的操作放在主线程上。另外,现代 3D 游戏存在大量的后期处理任务。我们可以将后期处理等任务放在主线程上,或者放在一个或多个子线程上。鉴于篇幅有限,我们没有在示例代码中介绍这部分实施情况。
5.1.4 总结
作为 DirectX 12 设计目标的重要组成部分,多线程是一款出色的优化解决方案,适合所有受 CPU 限制且可以在多个线程间并行化 CPU 工作负载的应用。DirectX 12 API 提供了出色的多线程支持。通过适当的迁移,单线程应用可进行并行化,以便充分利用硬件性能及大幅提高渲染效率。