DirectX 12 开发:3D 游戏编程基础教程
欢迎踏上激动人心的 DirectX 12 3D 游戏编程之旅!DirectX 12 是微软推出的一套功能强大的图形和多媒体 API 集合,特别是在图形领域,它为开发者提供了前所未有的低级控制能力,能够更直接地与现代 GPU 硬件交互,从而榨取更高的性能和效率。
本篇文章旨在为初学者提供 DirectX 12 3D 游戏编程的基础入门指南。我们将从理解 DX12 的核心概念开始,逐步深入到环境搭建、应用程序框架构建、关键对象介绍,以及一个基础渲染流程的概览。请注意,DirectX 12 的复杂性远超其前任 DirectX 11,它要求开发者对 GPU 工作原理有更深的理解。因此,本文是一个基础教程,为你打开这扇大门,更深入的学习需要持续的实践和探索。
目标读者:
- 对 3D 图形编程有兴趣,希望学习现代图形 API 的开发者。
- 有 C++ 编程基础,了解 Win32 窗口程序基本概念的开发者。
- 对 DirectX 11 或其他图形 API 有一定了解,想过渡到 DX12 的开发者。
你需要具备的知识:
- 熟练掌握 C++ 语言。
- 了解 Windows 操作系统及基本的 Win32 API。
- 对线性代数和 3D 图形学基础概念(如向量、矩阵、坐标系、顶点、三角形等)有初步认识。
第一章:理解 DirectX 12 – 为何选择它?
在深入代码之前,我们先来理解 DirectX 12 的定位和优势。
DirectX 的演进:
从早期的固定功能管线到可编程管线,DirectX 一直在发展。DirectX 11 引入了更灵活的着色器模型和更统一的 API,大大简化了开发。然而,随着硬件的发展,DX11 的抽象层有时会限制开发者充分发挥多核 CPU 和并行 GPU 的潜力。
DirectX 12 的核心理念:更低的抽象层,更高的控制力
DirectX 12 最显著的特点是降低了抽象层,将更多底层硬件控制权交给了开发者。这意味着:
- 更少的驱动程序开销 (Less Driver Overhead): 在 DX11 中,许多状态管理和资源同步是由驱动程序完成的。在 DX12 中,这部分工作被暴露给应用程序。开发者可以更精细地控制状态切换和资源驻留,显著减少驱动程序在 CPU 上的负担,尤其是在提交大量绘制命令时。这对于现代多核 CPU 环境至关重要。
- 改进的多线程支持 (Improved Multithreading): DX12 API 设计之初就考虑了多线程。应用程序可以在多个线程上并行地构建命令列表 (Command Lists),然后一次性提交给命令队列 (Command Queue),从而更有效地利用多核 CPU 来准备渲染数据。
- 显式的资源管理和同步 (Explicit Resource Management and Synchronization): DX12 要求开发者更清楚地管理 GPU 资源的状态和转换(例如,将一个纹理从渲染目标状态转换为着色器资源状态)。同时,GPU 和 CPU 之间的同步(使用 Fence 对象)也变得更加显式。虽然这增加了复杂度,但也消除了驱动程序猜测或过度同步的需求,带来性能提升。
- 管道状态对象 (Pipeline State Object – PSO): DX12 将渲染管线的大部分固定状态(如混合模式、深度/模板状态、光栅化状态、顶点格式、着色器等)打包到一个不可变的 PSO 对象中。PSO 的创建成本较高,但绑定成本很低,鼓励开发者提前创建和缓存 PSO,减少渲染循环中的状态切换开销。
- 描述符和描述符堆 (Descriptors and Descriptor Heaps): 资源(纹理、缓冲区)不再直接绑定到管线阶段,而是通过描述符 (Descriptor) 来引用。描述符存储在描述符堆 (Descriptor Heap) 中。在绘制时,通过根签名 (Root Signature) 将描述符堆中的描述符索引或表绑定到着色器。这种机制提供了更大的灵活性和效率。
总结:
选择 DirectX 12 通常是为了追求极致的性能和对硬件更细粒度的控制,这在大型、高性能的 3D 应用程序(如现代游戏)中尤为重要。但与之相伴的是更高的学习曲线和开发复杂度。
第二章:前期准备与环境搭建
开始 DX12 开发前,确保你的开发环境已准备就绪。
硬件要求:
- 支持 DirectX 12 的显卡。大多数现代独立显卡(AMD Radeon HD 7000 系列及更新,NVIDIA GeForce GTX 600 系列及更新)和部分集成显卡都支持 DX12。
- 足够的内存和存储空间。
软件要求:
- Windows 10 或更高版本: DirectX 12 是 Windows 10 及更高版本的原生 API。确保你的操作系统是最新的。
- Visual Studio 2019/2022 或更高版本: 推荐使用最新版本的 Visual Studio,它集成了 Windows SDK 和必要的开发工具。
- 安装时请选择 “使用 C++ 的桌面开发” 工作负载。
- 确保包含 “Windows 10/11 SDK” 组件(通常默认会选中最新版本)。
- 可选但强烈推荐:
- DirectX 12 示例代码: 微软提供了许多官方示例,是学习的好资源。可以从 GitHub 下载
DirectX-Graphics-Samples
。 - PIX on Windows: 一个强大的图形调试工具,可以捕获和分析 DX12 帧,查看命令列表、资源状态、管线状态等,对于调试至关重要。可以从 Microsoft Store 或官网下载。
- DirectX 12 示例代码: 微软提供了许多官方示例,是学习的好资源。可以从 GitHub 下载
项目搭建:
一个基本的 DirectX 12 应用程序通常从一个标准的 Win32 桌面应用程序模板开始。
- 在 Visual Studio 中创建一个新的 C++ 项目。
- 选择 “Windows 桌面应用程序” 模板(或者一个空的 C++ 项目并手动添加 Win32 入口点
WinMain
)。 - 确保项目配置为使用最新的 Windows SDK。在项目属性 -> 配置属性 -> 常规 -> Windows SDK 版本中选择安装的最新版本。
- 确保项目配置为使用 C++14 或更高标准(推荐 C++17/C++20)。在项目属性 -> 配置属性 -> C/C++ -> 语言 -> C++ 语言标准中设置。
- 在项目属性 -> 配置属性 -> 链接器 -> 输入 -> 附加依赖项中添加必要的 DirectX 库文件:
d3d12.lib
和dxgi.lib
。
第三章:DX12 核心概念概览
在开始编写代码之前,理解 DX12 的一些核心对象和概念至关重要。它们构成了 DX12 的基础框架。
- IDXGIFactory4/5/6… (DXGI Factory): DXGI (DirectX Graphics Infrastructure) 是一套独立的 API,用于枚举图形适配器(显卡)、创建交换链 (Swap Chain) 和处理全屏模式转换等。
IDXGIFactory
是 DXGI 的入口点,用于创建其他 DXGI 对象。 - IDXGIAdapter1 (Adapter): 代表系统中的一个图形适配器(显卡)。你可以枚举多个适配器,选择一个用于渲染。
- ID3D12Device (Device): 这是 DX12 API 的核心对象,代表了 GPU 的虚拟设备。通过 Device 对象,你可以创建几乎所有的其他 DX12 资源和对象(如命令队列、资源、描述符堆、PSO 等)。设备也负责 GPU 错误处理。
- ID3D12CommandQueue (Command Queue): 命令队列是 CPU 向 GPU 提交命令的地方。它是 CPU 和 GPU 之间的主要接口。有不同类型的队列:
D3D12_COMMAND_LIST_TYPE_DIRECT
: 用于图形、计算和拷贝命令。这是最常用的类型。D3D12_COMMAND_LIST_TYPE_COMPUTE
: 仅用于计算和拷贝命令。D3D12_COMMAND_LIST_TYPE_COPY
: 仅用于拷贝命令。D3D12_COMMAND_LIST_TYPE_BUNDLE
: 一种特殊的命令列表,可以多次执行,用于减少小命令列表的提交开销。
- ID3D12CommandAllocator (Command Allocator): 命令分配器管理用于记录命令列表的底层内存。在记录新的一帧命令之前,通常需要重置命令分配器。
- ID3D12GraphicsCommandList (Command List): 命令列表用于记录一系列 GPU 命令(如设置管线状态、绑定资源、绘制调用、拷贝数据等)。你可以从命令分配器中创建命令列表,并在记录完命令后关闭它,然后将其提交到命令队列执行。这是 DX12 多线程的关键:可以在不同线程上记录不同的命令列表。
- IDXGISwapChain3 (Swap Chain): 交换链负责管理用于呈现给用户的一系列图像缓冲区(称为“后台缓冲区”)。渲染通常在其中一个后台缓冲区中进行,完成后,交换链会将该缓冲区与前台缓冲区交换(或复制),使其显示在屏幕上。
- ID3D12Resource (Resource): 这是 GPU 内存中存储数据的抽象,可以是缓冲区(如顶点缓冲区、索引缓冲区、常量缓冲区)、纹理或深度/模板缓冲区。资源有不同的使用状态(例如,作为渲染目标、着色器资源、拷贝源、拷贝目标等),这些状态需要在不同操作之间使用资源屏障 (Resource Barrier) 进行转换。
- D3D12_CPU_DESCRIPTOR_HANDLE / D3D12_GPU_DESCRIPTOR_HANDLE (Descriptor Handle): 描述符的引用方式。CPU 句柄用于在 CPU 端操作描述符堆(如创建描述符),GPU 句柄用于在命令列表或根签名中引用描述符,供 GPU 使用。
- ID3D12DescriptorHeap (Descriptor Heap): 描述符的集合,就像一个描述符的数组。不同类型的描述符需要放在不同类型的描述符堆中(如 RTV/DSV 堆、CBV/SRV/UAV 堆、Sampler 堆)。GPU 通过描述符堆和描述符句柄来查找资源。
- ID3D12RootSignature (Root Signature): 根签名定义了图形管线(尤其是着色器)如何访问资源(描述符、常量)。它是一个契约,连接了着色器输入(寄存器)和描述符堆中的数据。根签名必须与着色器兼容。设置根签名是命令列表中的一个重要步骤。
- ID3D12PipelineState (Pipeline State Object – PSO): 如前所述,PSO 封装了渲染管线的几乎所有固定和可编程状态。它包括顶点输入布局、图元拓扑类型、顶点着色器、像素着色器(或其他着色器阶段)、混合状态、光栅化状态、深度/模板状态、渲染目标格式等。创建 PSO 需要一些时间,但绑定它非常快。
- ID3D12Fence (Fence): 用于 CPU 和 GPU 之间的同步。CPU 可以向命令队列插入一个 Fence 值,然后继续执行其他任务。当 GPU 完成命令队列中包含该 Fence 值之前的所有工作时,Fence 会被信号化。CPU 可以等待 Fence 被信号化,或者查询其状态,从而知道 GPU 工作何时完成。这对于管理资源上传、帧同步等非常重要。
第四章:构建你的第一个 DX12 窗口应用程序框架
让我们构建一个简单的 Win32 窗口,并初始化 DX12 框架所需的基本对象。
一个 DX12 应用程序的生命周期大致如下:
- 创建 Win32 窗口。
- 初始化 DXGI 框架并选择适配器。
- 创建 D3D12 设备。
- 创建命令队列。
- 创建交换链。
- 为交换链的每个后台缓冲区创建渲染目标视图 (Render Target View – RTV)。
- 创建命令分配器和命令列表。
- 创建一个 Fence 对象用于同步。
- 进入主循环(消息处理和渲染)。
- 在渲染循环中,执行以下步骤(每帧):
- 等待上一帧的 GPU 工作完成(使用 Fence)。
- 重置命令分配器和命令列表。
- 记录渲染命令(清除后台缓冲区,设置管线状态,绘制几何体等)。这包括设置资源屏障,将后台缓冲区从呈现状态转换为渲染目标状态。
- 关闭命令列表。
- 提交命令列表到命令队列执行。
- 将后台缓冲区从渲染目标状态转换回呈现状态(使用资源屏障)。
- 调用 Present() 显示后台缓冲区。
- 更新 Fence 值并通知 GPU 完成工作后信号化 Fence。
- 应用程序退出时,清理所有 DX12 对象。
以下是简化后的代码框架和步骤说明(不包含完整的 Win32 消息循环和错误处理,重点在于 DX12 部分):
“`cpp
include
include // For DXGI 1.6+ features like adapters enumeration
include // For Microsoft::WRL::ComPtr
// Use ComPtr for automatic COM object lifetime management
using Microsoft::WRL::ComPtr;
// Global objects (simplify for example)
ComPtr
ComPtr
ComPtr
ComPtr
ComPtr
ComPtr
UINT g_fenceValue = 0;
HANDLE g_fenceEvent;
// Render target view descriptor heap
ComPtr
UINT g_rtvDescriptorSize;
// Back buffer resources and their RTV handles
std::vector
std::vector
UINT g_backBufferIndex; // Current back buffer index
// Constants
const UINT FrameCount = 2; // Use 2 back buffers (double buffering)
// Window handle (obtained from WinMain)
HWND g_hwnd;
UINT g_width;
UINT g_height;
// — Initialization —
void InitializeDirectX(HWND hwnd, UINT width, UINT height)
{
g_hwnd = hwnd;
g_width = width;
g_height = height;
// 1. Create DXGI Factory
ComPtr<IDXGIFactory4> factory;
// Enable debug layer if in debug configuration
if defined(_DEBUG)
ComPtr<ID3D12Debug> debugController;
if (SUCCEEDED(D3D12GetDebugInterface(IID_PPV_ARGS(&debugController))))
{
debugController->EnableDebugLayer();
}
// Optional: Enable GPU-based validation
// ComPtr<ID3D12Debug1> debugController1;
// if (SUCCEEDED(debugController->QueryInterface(IID_PPV_ARGS(&debugController1))))
// {
// debugController1->SetEnableGPUBasedValidation(TRUE);
// }
endif
CreateDXGIFactory1(IID_PPV_ARGS(&factory));
// 2. Select Adapter (simplified: use the default adapter)
ComPtr<IDXGIAdapter1> adapter;
factory->EnumAdapters1(0, &adapter); // Get the first adapter
// 3. Create Device
D3D12CreateDevice(
adapter.Get(), // Adapter to use
D3D_FEATURE_LEVEL_11_0, // Minimum feature level
IID_PPV_ARGS(&g_device) // Output device
);
// 4. Create Command Queue
D3D12_COMMAND_QUEUE_DESC queueDesc = {};
queueDesc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;
queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
g_device->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&g_commandQueue));
// 5. Create Swap Chain
DXGI_SWAP_CHAIN_DESC1 swapChainDesc = {};
swapChainDesc.BufferCount = FrameCount;
swapChainDesc.Width = width;
swapChainDesc.Height = height;
swapChainDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; // Standard format
swapChainDesc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
swapChainDesc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD; // Recommended for modern apps
swapChainDesc.SampleDesc.Count = 1; // No multisampling
swapChainDesc.AlphaMode = DXGI_ALPHA_MODE_IGNORE;
swapChainDesc.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_TEARING; // Optional, for VSync off
ComPtr<IDXGISwapChain1> swapChain;
factory->CreateSwapChainForHwnd(
g_commandQueue.Get(), // Swap chain needs a queue to flush commands
g_hwnd,
&swapChainDesc,
nullptr, // No fullscreen descriptor
nullptr, // No output restrictor
IID_PPV_ARGS(&swapChain)
);
// Cast to SwapChain3 for newer methods like GetCurrentBackBufferIndex
swapChain.As(&g_swapChain);
// Disable Alt+Enter fullscreen transition handling by the OS
factory->MakeWindowAssociation(g_hwnd, DXGI_MWA_NO_ALT_ENTER);
// 6. Create RTV Descriptor Heap
D3D12_DESCRIPTOR_HEAP_DESC rtvHeapDesc = {};
rtvHeapDesc.NumDescriptors = FrameCount;
rtvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;
rtvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE; // RTV/DSV heaps are not shader visible
g_device->CreateDescriptorHeap(&rtvHeapDesc, IID_PPV_ARGS(&g_rtvHeap));
g_rtvDescriptorSize = g_device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV);
// 7. Create Render Target Views (RTVs) for each back buffer
g_renderTargets.resize(FrameCount);
g_rtvHandles.resize(FrameCount);
D3D12_CPU_DESCRIPTOR_HANDLE rtvHandle = g_rtvHeap->GetCPUDescriptorHandleForHeapStart();
for (UINT i = 0; i < FrameCount; ++i)
{
g_swapChain->GetBuffer(i, IID_PPV_ARGS(&g_renderTargets[i]));
g_device->CreateRenderTargetView(g_renderTargets[i].Get(), nullptr, rtvHandle); // nullptr means default RTV for resource
g_rtvHandles[i] = rtvHandle;
rtvHandle.ptr += g_rtvDescriptorSize; // Move to the next descriptor
}
// 8. Create Command Allocator
g_device->CreateCommandAllocator(
D3D12_COMMAND_LIST_TYPE_DIRECT,
IID_PPV_ARGS(&g_commandAllocator)
);
// 9. Create Command List (closed state initially)
g_device->CreateCommandList(
0, // Node mask (for multi-adapter scenarios)
D3D12_COMMAND_LIST_TYPE_DIRECT,
g_commandAllocator.Get(), // Associated allocator
nullptr, // Initial pipeline state object (can be null)
IID_PPV_ARGS(&g_commandList)
);
g_commandList->Close(); // Command lists are created in a recording state
// 10. Create Fence and Fence Event for synchronization
g_device->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&g_fence));
g_fenceEvent = CreateEvent(nullptr, FALSE, FALSE, nullptr); // Manual reset=false, initial state=non-signaled
// Get initial back buffer index
g_backBufferIndex = g_swapChain->GetCurrentBackBufferIndex();
// At this point, basic DX12 objects are initialized.
// You would then create PSO, Root Signature, vertex buffers, etc.
}
// — Rendering (Simplified frame loop) —
void Render()
{
// 1. Wait for the previous frame to complete
const UINT currentFenceValue = g_fenceValue;
g_commandQueue->Signal(g_fence.Get(), currentFenceValue); // Signal queue with current fence value
g_fenceValue++;
// Wait if the GPU is still working on the previous frame (for synchronization)
if (g_fence->GetCompletedValue() < currentFenceValue)
{
g_fence->SetEventOnCompletion(currentFenceValue, g_fenceEvent);
WaitForSingleObject(g_fenceEvent, INFINITE);
}
// Get the index of the current back buffer to render into
g_backBufferIndex = g_swapChain->GetCurrentBackBufferIndex();
// 2. Reset Command Allocator and Command List for the new frame
g_commandAllocator->Reset(); // Reset the allocator first
g_commandList->Reset(g_commandAllocator.Get(), nullptr); // Reset command list (can provide an initial PSO)
// 3. Record Rendering Commands
// Resource Barrier: Transition the current back buffer from present to render target state
D3D12_RESOURCE_BARRIER barrierDesc = {};
barrierDesc.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
barrierDesc.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE;
barrierDesc.Transition.pResource = g_renderTargets[g_backBufferIndex].Get();
barrierDesc.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
barrierDesc.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT;
barrierDesc.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET;
g_commandList->ResourceBarrier(1, &barrierDesc);
// Set render targets and clear the current back buffer
D3D12_CPU_DESCRIPTOR_HANDLE currentRtvHandle = g_rtvHandles[g_backBufferIndex];
g_commandList->OMSetRenderTargets(1, ¤tRtvHandle, FALSE, nullptr); // No depth/stencil
const float clearColor[] = { 0.0f, 0.2f, 0.4f, 1.0f }; // Blueish clear color
g_commandList->ClearRenderTargetView(currentRtvHandle, clearColor, 0, nullptr);
// Set viewport and scissor rect (essential!)
D3D12_VIEWPORT viewport = { 0.0f, 0.0f, (float)g_width, (float)g_height, 0.0f, 1.0f };
D3D12_RECT scissorRect = { 0, 0, (LONG)g_width, (LONG)g_height };
g_commandList->RSSetViewports(1, &viewport);
g_commandList->RSSetScissorRects(1, &scissorRect);
// --- Placeholder for drawing actual geometry ---
// In a real app, you would:
// - Set Root Signature
// - Set Pipeline State Object (PSO)
// - Set vertex/index buffers (IASetVertexBuffers, IASetIndexBuffer)
// - Set primitive topology (IASetPrimitiveTopology)
// - Set descriptor heaps and bind resources (SetDescriptorHeaps, SetGraphicsRootDescriptorTable/ConstantBuffer/ShaderResourceView etc.)
// - Issue drawing commands (DrawInstanced, DrawIndexedInstanced)
// --- End Placeholder ---
// Resource Barrier: Transition the current back buffer from render target to present state
barrierDesc.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET;
barrierDesc.Transition.StateAfter = D3D12_RESOURCE_STATE_PRESENT;
g_commandList->ResourceBarrier(1, &barrierDesc);
// 4. Close the command list
g_commandList->Close();
// 5. Execute the command list
ID3D12CommandList* ppCommandLists[] = { g_commandList.Get() };
g_commandQueue->ExecuteCommandLists(_countof(ppCommandLists), ppCommandLists);
// 6. Present the frame
g_swapChain->Present(1, 0); // Present interval 1 (VSync on), Flags 0
// The Render function returns. The main loop will call it again for the next frame.
}
// — Cleanup —
void CleanupDirectX()
{
// Wait for the GPU to finish any pending work before releasing resources
const UINT lastFenceValue = g_fenceValue;
g_commandQueue->Signal(g_fence.Get(), lastFenceValue);
g_fenceValue++;
if (g_fence->GetCompletedValue() < lastFenceValue)
{
g_fence->SetEventOnCompletion(lastFenceValue, g_fenceEvent);
WaitForSingleObject(g_fenceEvent, INFINITE);
}
CloseHandle(g_fenceEvent);
// ComPtr will automatically release the underlying objects
// g_swapChain, g_commandQueue, g_commandAllocator, g_commandList, g_fence
// g_rtvHeap, g_renderTargets (vector elements)
// g_device
}
“`
代码解释:
ComPtr
: 使用 WRL (Windows Runtime C++ Template Library) 提供的ComPtr
是管理 COM 接口(DirectX 对象)生命周期的标准做法,它会自动处理引用计数,防止内存泄漏。- 初始化: 创建 DXGI 工厂、选择适配器、创建设备、命令队列和交换链是必经之路。接着为交换链的后台缓冲区创建 RTV 堆和 RTV,以便将它们设置为渲染目标。命令分配器和命令列表用于记录渲染命令。Fence 用于同步 CPU 和 GPU。
- 渲染循环 (
Render
函数):- 同步:
g_commandQueue->Signal
发送一个信号 Fence 的命令。g_fence->GetCompletedValue()
检查 Fence 的当前值。如果 GPU 还没处理到我们刚刚发送的 Fence 值,说明前一帧的命令还没执行完,WaitForSingleObject
就会阻塞 CPU 直到 GPU 信号化 Fence。这是确保我们不会在 GPU 还在使用资源时修改它,或者确保后台缓冲区在 Present 前渲染完成。 - 重置命令列表: 每帧开始时,需要重置命令分配器和命令列表,以便重新记录本帧的命令。
- 资源屏障 (Resource Barrier): 这是 DX12 中最重要的新概念之一。资源(如后台缓冲区)在使用时必须处于特定的状态。在渲染前,它必须处于
D3D12_RESOURCE_STATE_RENDER_TARGET
状态;在Present()
调用前,它必须处于D3D12_RESOURCE_STATE_PRESENT
状态。ResourceBarrier
命令告诉 GPU 改变资源的状态。遗漏或错误的资源屏障是 DX12 常见的错误源,可能导致渲染异常或 GPU 崩溃。 - 设置渲染目标和清除:
OMSetRenderTargets
设置当前帧要渲染到的目标(这里是当前的后台缓冲区)。ClearRenderTargetView
用指定的颜色清除渲染目标。 - 设置视口和剪裁矩形:
RSSetViewports
和RSSetScissorRects
是必不可少的步骤,它们定义了渲染的区域。 - 绘制几何体(Placeholder): 这是真正进行 3D 绘制的地方,涉及设置 PSO、根签名、绑定顶点/索引缓冲区、设置常量缓冲区、绑定纹理等复杂步骤,最后调用绘制命令。这些将在后续章节或更深入的教程中讲解。
- 提交和呈现:
Close
命令列表使其进入可执行状态。ExecuteCommandLists
将一个或多个命令列表提交到命令队列,GPU 会异步地开始执行它们。Present
将当前后台缓冲区的内容显示到屏幕上。
- 同步:
- 清理: 在应用程序退出前,需要等待 GPU 完成所有待处理的命令(再次使用 Fence),然后由
ComPtr
自动释放所有 DX12 对象。
至此,你已经拥有一个能够创建 DX12 设备、交换链,并在窗口中以纯色背景清除每一帧的基础框架。
第五章:渲染管线的初步探索:PSO 与 Shader
要绘制实际的 3D 内容,我们需要深入理解 DX12 的渲染管线以及如何配置它。
渲染管线 (Rendering Pipeline):
渲染管线是一系列阶段组成的流程,用于将 3D 几何体数据转换为屏幕上的 2D 像素图像。DX12 的管线包括可编程阶段(如顶点着色器、像素着色器)和固定功能阶段(如输入汇编器、光栅化、深度/模板测试、混合)。
Pipeline State Object (PSO):
在 DX12 中,管线的大部分状态被打包进一个 ID3D12PipelineState
对象。创建一个 PSO 需要提供一个描述符 D3D12_GRAPHICS_PIPELINE_STATE_DESC
,其中包含了:
- 根签名 (Root Signature): 管线如何访问外部资源(缓冲区、纹理、采样器)。
- 输入布局 (Input Layout): 描述顶点缓冲区中数据的格式(位置、颜色、纹理坐标等)。
- 图元拓扑类型 (Primitive Topology): 如何解释输入的顶点数据(点、线段、三角形列表/带/扇)。
- 顶点着色器 (VS): 处理每个顶点,进行模型-视图-投影变换等。
- 几何着色器 (GS, 可选): 处理图元(如三角形),可以生成或删除几何体。
- 域着色器 (DS) 和船体着色器 (HS, 可选): 用于曲面细分。
- 像素着色器 (PS): 处理每个像素(或称片段),计算其最终颜色。
- 混合状态 (Blend State): 如何将当前像素颜色与渲染目标中已有的颜色混合。
- 光栅化状态 (Rasterizer State): 控制多边形填充模式、剔除模式、深度偏置等。
- 深度/模板状态 (Depth/Stencil State): 控制深度测试和模板测试行为。
- 渲染目标格式 (Render Target Formats): 渲染目标的颜色格式。
- 样本描述 (Sample Description): 控制多重采样。
创建 PSO 的代码大致如下:
cpp
ComPtr<ID3D12PipelineState> g_pipelineState;
// D3D12_GRAPHICS_PIPELINE_STATE_DESC psoDesc = {};
// Fill psoDesc with all the required state...
// g_device->CreateGraphicsPipelineState(&psoDesc, IID_PPV_ARGS(&g_pipelineState));
在渲染循环中,使用 g_commandList->SetPipelineState(g_pipelineState.Get());
来激活这个状态。
Shaders (着色器) 与 HLSL:
着色器是在 GPU 上运行的小程序,用于执行图形管线的可编程阶段。DirectX 使用 HLSL (High-Level Shading Language) 编写着色器。
- 顶点着色器 (Vertex Shader – VS): 接收每个顶点的输入数据,通常用于执行坐标变换(模型空间 -> 世界空间 -> 视图空间 -> 投影空间),计算光照相关数据等,并输出到下一个管线阶段。
- 像素着色器 (Pixel Shader – PS): 接收光栅化后的每个像素信息(如插值后的顶点输出),计算该像素的最终颜色。
HLSL 代码示例(非常基础):
“`hlsl
// Vertex Shader input structure
struct VS_INPUT
{
float3 pos : POSITION; // Vertex position in object space
float4 color : COLOR; // Vertex color
};
// Vertex Shader output structure (also Pixel Shader input)
struct PS_INPUT
{
float4 pos : SV_POSITION; // Vertex position in homogeneous clip space (Semantic SV_POSITION is required)
float4 color : COLOR; // Interpolated color
};
// Vertex Shader entry point
PS_INPUT VSMain(VS_INPUT input)
{
PS_INPUT output;
output.pos = float4(input.pos, 1.0f); // Pass position directly (no transformation for simplicity)
output.color = input.color; // Pass color directly
return output;
}
// Pixel Shader entry point
float4 PSMain(PS_INPUT input) : SV_TARGET // SV_TARGET is required for the final pixel color output
{
return input.color; // Output the interpolated color
}
“`
你需要将 HLSL 代码编译成字节码 (.cso 文件或内存中的字节数组),然后在创建 PSO 时提供这些字节码。通常使用 FXC 或 D3DCompile API 来完成编译。
第六章:绘制几何体:顶点与缓冲区
3D 模型由顶点组成,每个顶点包含位置、颜色、纹理坐标、法线等数据。这些数据需要存储在 GPU 可访问的内存中,即缓冲区 (Buffers)。
- 顶点缓冲区 (Vertex Buffer): 存储模型的顶点数据数组。
- 索引缓冲区 (Index Buffer, 可选): 存储一个整数数组,这些整数是顶点缓冲区中的索引。使用索引缓冲区可以重复利用顶点数据,减少数据量,提高效率。
创建和上传缓冲区数据:
- 定义顶点结构:
cpp
struct Vertex
{
DirectX::XMFLOAT3 position; // Position (x, y, z)
DirectX::XMFLOAT4 color; // Color (r, g, b, a)
};
(使用 DirectXMath 库来处理数学类型) - 创建顶点数据数组:
cpp
Vertex triangleVertices[] = {
{ { 0.0f, 0.5f, 0.0f }, { 1.0f, 0.0f, 0.0f, 1.0f } }, // Top vertex (Red)
{ { 0.5f, -0.5f, 0.0f }, { 0.0f, 1.0f, 0.0f, 1.0f } }, // Right vertex (Green)
{ { -0.5f, -0.5f, 0.0f }, { 0.0f, 0.0f, 1.0f, 1.0f } } // Left vertex (Blue)
};
const UINT vertexBufferByteSize = sizeof(triangleVertices); -
创建上传堆资源 (Upload Heap Resource): GPU 通常无法直接访问 CPU 内存。数据需要先复制到一个特殊的 GPU 内存区域,称为上传堆。
“`cpp
ComPtrvertexBufferUploadHeap;
D3D12_HEAP_PROPERTIES uploadHeapProps = { D3D12_HEAP_TYPE_UPLOAD, D3D12_CPU_PAGE_PROPERTY_UNKNOWN, D3D12_MEMORY_POOL_UNKNOWN, 0, 0 };
D3D12_RESOURCE_DESC bufferDesc = D3D12_RESOURCE_DESC::Buffer(vertexBufferByteSize);g_device->CreateCommittedResource(
&uploadHeapProps,
D3D12_HEAP_FLAG_NONE,
&bufferDesc,
D3D12_RESOURCE_STATE_GENERIC_READ, // Upload heaps start in GENERIC_READ state
nullptr,
IID_PPV_ARGS(&vertexBufferUploadHeap)
);
4. **将 CPU 数据拷贝到上传堆:** 使用 Map/Unmap 或 memcpy。
cpp
UINT8* pVertexDataBegin;
D3D12_RANGE readRange = { 0, 0 }; // We do not intend to read from this resource on the CPU.
vertexBufferUploadHeap->Map(0, &readRange, reinterpret_cast(&pVertexDataBegin));
memcpy(pVertexDataBegin, triangleVertices, vertexBufferByteSize);
vertexBufferUploadHeap->Unmap(0, nullptr);
5. **创建默认堆资源 (Default Heap Resource):** 这是 GPU 主要用于渲染的内存区域,访问速度最快。
cpp
ComPtrvertexBuffer;
D3D12_HEAP_PROPERTIES defaultHeapProps = { D3D12_HEAP_TYPE_DEFAULT, D3D12_CPU_PAGE_PROPERTY_UNKNOWN, D3D12_MEMORY_POOL_UNKNOWN, 0, 0 };g_device->CreateCommittedResource(
&defaultHeapProps,
D3D12_HEAP_FLAG_NONE,
&bufferDesc, // Same size and dimensions
D3D12_RESOURCE_STATE_COPY_DEST, // Start in a state suitable for copying into
nullptr,
IID_PPV_ARGS(&vertexBuffer)
);
6. **将数据从上传堆复制到默认堆:** 这需要在命令列表中完成。
cpp
// Assume commandList is in recording state
g_commandList->CopyResource(vertexBuffer.Get(), vertexBufferUploadHeap.Get());
// Need a barrier after copy to transition the default buffer to vertex buffer state
D3D12_RESOURCE_BARRIER barrier;
barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
barrier.Transition.pResource = vertexBuffer.Get();
barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_COPY_DEST;
barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER; // Or COMMON
g_commandList->ResourceBarrier(1, &barrier);// The upload heap resource can potentially be released after the copy command is executed on the GPU.
// You need a fence to ensure the copy is complete before releasing uploadHeap.
7. **创建顶点缓冲区视图 (Vertex Buffer View):** 告诉 DX12 如何解释默认堆中的顶点数据。
cpp
D3D12_VERTEX_BUFFER_VIEW vertexBufferView = {};
vertexBufferView.BufferLocation = vertexBuffer->GetGPUVirtualAddress();
vertexBufferView.StrideInBytes = sizeof(Vertex);
vertexBufferView.SizeInBytes = vertexBufferByteSize;
“`
在渲染循环中,在绘制调用之前,需要使用 g_commandList->IASetVertexBuffers(0, 1, &vertexBufferView);
将顶点缓冲区视图绑定到输入汇编器阶段。
第七章:基础渲染流程示例(概念描述)
结合前面介绍的概念,一个基础的单帧渲染流程(绘制一个简单的三角形)在命令列表中的顺序大致如下:
- 等待同步 (CPU): 使用 Fence 确保上一帧的 GPU 工作已完成。
- 重置命令分配器和命令列表 (CPU): 为新的一帧准备好记录命令。
- 开始记录命令 (GPU):
- 资源屏障: 将当前后台缓冲区从
D3D12_RESOURCE_STATE_PRESENT
转换为D3D12_RESOURCE_STATE_RENDER_TARGET
。 - 设置渲染目标:
OMSetRenderTargets
绑定当前后台缓冲区的 RTV。 - 清除渲染目标:
ClearRenderTargetView
清除背景颜色。 - 设置视口和剪裁矩形:
RSSetViewports
,RSSetScissorRects
. - 设置根签名:
SetGraphicsRootSignature
绑定用于本帧渲染的根签名。 - 设置管线状态对象 (PSO):
SetPipelineState
绑定包含着色器、混合、光栅化等所有状态的 PSO。 - 设置图元拓扑类型:
IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
告诉 GPU 将顶点解释为三角形。 - 绑定顶点缓冲区:
IASetVertexBuffers
将顶点缓冲区视图绑定到输入汇编器。 - 绑定描述符堆 (如果需要):
SetDescriptorHeaps
绑定包含纹理、常量缓冲区等描述符的堆。 - 绑定根描述符/表:
SetGraphicsRootDescriptorTable
,SetGraphicsRootConstantBufferView
等命令,根据根签名的定义,将描述符堆中的特定描述符或范围绑定到着色器可见的寄存器槽位。 - 绘制命令:
DrawInstanced(3, 1, 0, 0);
(绘制 3 个顶点,1 个实例,从顶点偏移 0 开始,从实例偏移 0 开始)。 - 资源屏障: 将当前后台缓冲区从
D3D12_RESOURCE_STATE_RENDER_TARGET
转换回D3D12_RESOURCE_STATE_PRESENT
。
- 资源屏障: 将当前后台缓冲区从
- 关闭命令列表 (CPU): 完成命令记录。
- 执行命令列表 (CPU):
ExecuteCommandLists
将命令列表提交给命令队列。 - 呈现 (CPU):
Present
向 DXGI 发出呈现请求。 - 更新 Fence 值和信号 (CPU): 更新 Fence 值,并让命令队列在执行完本帧命令后信号化 Fence。
第八章:进阶之路与学习方向
掌握了上述基础知识后,你已经迈出了 DX12 开发的第一步。但 3D 游戏编程还有很多内容需要学习:
- 索引缓冲区: 优化重复顶点的绘制。
- 常量缓冲区 (Constant Buffers – CBV): 向着色器传递频繁更新的数据,如模型变换矩阵、视图矩阵、投影矩阵、光源位置等。这需要创建 CBV 描述符和在根签名中绑定 CBV。
- 纹理 (Textures) 与采样器 (Samplers): 将图像应用到 3D 模型表面。需要创建纹理资源、Shader Resource View (SRV) 描述符、Sampler 描述符,并在根签名中绑定 SRV 和 Sampler。
- 深度缓冲 (Depth Buffering): 解决多边形遮挡问题,确保离摄像机近的物体遮挡远的物体。需要创建深度/模板缓冲区资源和 Depth Stencil View (DSV) 描述符,并在
OMSetRenderTargets
中绑定 DSV,配置 PSO 中的深度状态。 - 输入处理: 响应键盘、鼠标、游戏控制器输入。
- 摄像机控制: 实现第一人称或第三人称摄像机移动和旋转。
- 光照模型: 实现基本的光照效果(环境光、漫反射、镜面反射)。
- 模型加载: 从文件(如 .obj, .fbx)加载更复杂的 3D 模型。
- 纹理加载: 从图像文件(如 .bmp, .png, .jpg)加载纹理。通常需要使用 WIC (Windows Imaging Component) 或 DirectXTex 等库。
- 资源管理: 更高效地管理 GPU 资源(上传、驻留、释放)。
- 多线程渲染: 如何在多个 CPU 线程中构建命令列表,并行地提交渲染工作。
- 计算着色器 (Compute Shaders): 利用 GPU 进行通用计算(例如物理模拟、AI 计算、后处理效果)。
- 后期处理 (Post-processing): 在整个场景渲染完成后,对渲染结果进行全屏效果处理(如模糊、颜色校正、抗锯齿)。
- 调试与性能优化: 熟练使用 PIX 等工具分析 GPU 工作负载、找出性能瓶颈和渲染错误。理解 GPU 性能计数器。
- 错误处理: 完善 DX12 API 调用中的错误检查和处理。
结论
DirectX 12 提供了强大的性能潜力,但也带来了显著的复杂性。本教程为你介绍了 DX12 的核心理念、环境搭建、基本框架和关键对象。理解设备、命令队列、命令列表、交换链、资源、描述符、根签名、PSO、资源屏障和 Fence 是入门的关键。
从零开始构建一个完整的 DX12 渲染引擎是一项庞大的工程。对于初学者,建议从本教程提供的基础框架出发,逐步添加绘制简单几何体、应用基本变换、加载纹理等功能。参考微软官方示例、阅读文档、使用 PIX 进行调试将是学习过程中不可或缺的手段。
DirectX 12 的学习曲线虽然陡峭,但掌握它将为你打开高性能 3D 图形世界的大门。坚持实践,不断探索,你将能够利用现代 GPU 的强大能力创造令人惊叹的视觉效果。祝你的 DX12 开发之旅顺利!