1. 从零搭建 DirectX 12 框架前,先把这几个认知坑填平
很多人第一次接触 DirectX 12,脑子里想的都是"我要画一个三角形""我要写个炫酷的渲染器",结果打开 Visual Studio 新建了一个空项目,面对一片空白的 main 函数直接卡住——因为 D3D12 不像 D3D11 那样给你一个现成的 Device 和 SwapChain 就能跑,它把设备创建、命令队列、交换链、描述符堆、围栏同步这些东西全部摊开摆在你面前,你得自己一块一块拼起来。这个"拼框架"的过程,恰恰是劝退率最高的阶段。
我自己刚开始搞 D3D12 的时候,光是把一个窗口和 D3D12 设备关联起来就折腾了整整一个周末。问题不在于代码有多难写,而在于你根本不知道哪些东西是必须的、哪些东西的顺序不能颠倒、哪些地方少写一行就会在调试层里刷出几百条警告。所以这篇内容的核心目标很明确:带你从零搭出一个最小可运行的 D3D12 框架,包含窗口创建、设备初始化、命令队列、交换链和基本的渲染循环。它不涉及任何花哨的渲染效果,但它是后面所有 D3D12 学习的地基。
适合谁看?如果你已经写过一些 C++,对 Windows 编程有一点点概念(哪怕只是听说过 Win32 API),并且想学 D3D12 但被各种教程里动辄上千行的初始化代码吓到了,那这篇就是写给你的。我会尽量把每一步的"为什么"讲清楚,而不是甩一堆代码让你自己悟。
在正式开始之前,先明确几个关键认知,这些是我踩过坑之后总结出来的,能帮你少走很多弯路:
第一,D3D12 的初始化顺序不是随便排的。你必须先有窗口句柄(HWND),才能创建交换链;必须先有设备和命令队列,才能创建交换链;必须先有交换链,才能创建 RTV 描述符堆。这个依赖链条一旦搞反,轻则报错,重则程序直接崩。
第二,调试层是你最好的朋友,也是最吵的朋友。ID3D12Debug能帮你抓出大量潜在问题,但它同时会输出很多"信息性"的警告。你需要学会区分哪些是真正需要修的,哪些是可以暂时忽略的。
第三,别一上来就想着封装。很多教程会给你一个几百行的D3D12Renderer类,把所有东西都包进去。对于学习阶段来说,这反而增加了理解成本。我建议先把所有代码写在 main.cpp 里,跑通了再考虑怎么组织。
下面这张表是我总结的 D3D12 最小框架中各个组件的依赖关系和创建顺序,你可以先有个整体印象:
| 组件 | 依赖项 | 创建时机 | 是否可延迟创建 |
|---|---|---|---|
| Win32 窗口 | 无 | 最开头 | 否 |
| DXGI Factory | 无 | 窗口之后 | 否 |
| D3D12 Device | 无(但需要调试层先开) | Factory 之后 | 否 |
| 命令队列 | Device | Device 之后 | 否 |
| 交换链 | Factory + 命令队列 + HWND | 队列之后 | 否 |
| RTV 描述符堆 | Device | 交换链之后 | 可延迟 |
| 命令分配器 | Device | 队列之后 | 可延迟 |
| 命令列表 | 命令分配器 | 分配器之后 | 可延迟 |
| 围栏 | Device | 队列之后 | 可延迟 |
这个表格看起来简单,但每一条依赖关系背后都有它的道理。比如为什么交换链需要命令队列?因为交换链在 Present 的时候需要知道用哪个队列来执行翻转操作。为什么 RTV 描述符堆要在交换链之后?因为你需要先拿到交换链的后台缓冲区资源,才能为它创建渲染目标视图。这些逻辑在后面会逐一展开。
2. 窗口创建:Win32 那套东西在 D3D12 里有什么不一样
2.1 注册窗口类和创建窗口的标准流程
Win32 窗口创建这套东西,写过 Windows 程序的人应该都不陌生。基本流程就是:填充WNDCLASSEX结构体、调用RegisterClassEx注册窗口类、然后调用CreateWindowEx创建窗口、最后ShowWindow显示窗口。但在 D3D12 的语境下,有几个细节值得特别注意。
首先是窗口样式的选择。如果你只是做一个普通的窗口化应用,用WS_OVERLAPPEDWINDOW就够了。但如果你后面想支持全屏模式或者无边框窗口,那窗口样式的选择就会影响到交换链的配置。我个人的建议是,学习阶段就用标准的WS_OVERLAPPEDWINDOW,别给自己找麻烦。
WNDCLASSEX wc = {}; wc.cbSize = sizeof(WNDCLASSEX); wc.style = CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc = WindowProc; wc.hInstance = hInstance; wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.lpszClassName = L"D3D12WindowClass"; RegisterClassEx(&wc); HWND hwnd = CreateWindowEx( 0, L"D3D12WindowClass", L"D3D12 Learning - Window", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 1280, 720, nullptr, nullptr, hInstance, nullptr );这段代码本身没什么难度,但有一个坑我必须要提醒你:CS_HREDRAW | CS_VREDRAW这两个样式会导致窗口在水平或垂直方向被拉伸时触发完整的重绘。对于 D3D12 应用来说,窗口重绘是由我们自己的渲染循环驱动的,不需要依赖 WM_PAINT 消息。所以这两个样式其实可以不加,加了反而可能在窗口拉伸时产生一些不必要的消息。不过影响不大,加不加都能跑。
另一个需要注意的点是窗口的客户区大小。CreateWindowEx里传的宽度和高度是包含边框和标题栏的,如果你想让客户区精确等于 1280x720,需要用AdjustWindowRect来调整。对于学习阶段来说,这个精度不重要,但如果你后面要做像素级的渲染测试,就得注意了。
2.2 消息循环的设计:PeekMessage 还是 GetMessage
这是 D3D12 窗口和普通 Win32 窗口最大的区别之一。普通的 Windows 程序通常用GetMessage来阻塞等待消息,但 D3D12 应用需要一个持续运行的渲染循环,所以必须用PeekMessage来非阻塞地检查消息。
MSG msg = {}; while (msg.message != WM_QUIT) { if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); } else { // 渲染一帧 Render(); } }这个结构看起来简单,但它隐含了一个重要的设计决策:渲染和消息处理是在同一个线程里交替进行的。这意味着如果你的渲染一帧耗时太长,消息处理就会被延迟,窗口就会变得卡顿甚至无响应。对于学习阶段来说这不是问题,但如果你后面要做复杂的场景渲染,就需要考虑多线程渲染架构了。
还有一个细节:PeekMessage的最后一个参数用PM_REMOVE表示取出消息后从队列中移除。如果你写成PM_NOREMOVE,消息会一直留在队列里,导致死循环。这个坑很基础,但确实有人踩过。
2.3 窗口过程函数里必须处理的几个消息
WindowProc函数是窗口的消息处理中心。对于 D3D12 应用来说,有几个消息是必须处理的:
- WM_DESTROY:窗口销毁时调用
PostQuitMessage(0),让消息循环退出。 - WM_SIZE:窗口大小改变时,需要通知交换链调整后台缓冲区的大小。这个后面会详细讲。
- WM_KEYDOWN:如果你需要键盘输入来控制相机或者退出程序,在这里处理。
- WM_PAINT:虽然 D3D12 不依赖 WM_PAINT 来渲染,但如果你不处理这个消息,窗口在某些情况下可能会显示异常。最简单的处理方式是调用
ValidateRect把整个客户区标记为有效。
LRESULT CALLBACK WindowProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_DESTROY: PostQuitMessage(0); return 0; case WM_SIZE: // 后面会在这里通知交换链 ResizeBuffers return 0; case WM_KEYDOWN: if (wParam == VK_ESCAPE) PostQuitMessage(0); return 0; case WM_PAINT: ValidateRect(hwnd, nullptr); return 0; } return DefWindowProc(hwnd, msg, wParam, lParam); }这里有一个经验之谈:在 WM_SIZE 里不要直接调用交换链的 ResizeBuffers。因为 WM_SIZE 可能在交换链还没创建的时候就被触发(比如窗口刚创建时),这时候调用 ResizeBuffers 会直接崩溃。正确的做法是设置一个标志位,在渲染循环里检查这个标志位再执行调整。
3. D3D12 设备与命令队列:初始化顺序错了就等着崩
3.1 调试层:先开它,但别被它吓到
在创建 D3D12 设备之前,我强烈建议你先启用调试层。调试层能在你调用各种 D3D12 API 的时候检查参数合法性、资源状态、同步问题等等,是学习阶段不可或缺的工具。
#if defined(_DEBUG) ComPtr<ID3D12Debug> debugController; if (SUCCEEDED(D3D12GetDebugInterface(IID_PPV_ARGS(&debugController)))) { debugController->EnableDebugLayer(); } #endif注意这里用了#if defined(_DEBUG)条件编译,因为调试层在 Release 模式下不应该启用——它会显著降低性能。另外,D3D12GetDebugInterface可能失败,比如你的系统没有安装图形工具组件,所以要用SUCCEEDED检查返回值。
启用调试层之后,你会在 Visual Studio 的输出窗口里看到大量信息。有些是真正的错误(比如资源状态不对),有些只是提示(比如"你创建了一个资源但还没使用")。我的经验是:先关注 Error 级别的输出,Warning 级别的可以暂时忽略,但要在心里记一笔,后面遇到诡异问题时再回来看。
3.2 创建 DXGI Factory 和 D3D12 Device
DXGI Factory 是创建交换链的入口,D3D12 Device 是所有 D3D12 资源的创建者。这两个东西的创建顺序是:先 Factory,再 Device。
ComPtr<IDXGIFactory4> factory; CreateDXGIFactory2(0, IID_PPV_ARGS(&factory)); ComPtr<ID3D12Device> device; D3D12CreateDevice( nullptr, // 使用默认适配器 D3D_FEATURE_LEVEL_11_0, // 最低特性级别 IID_PPV_ARGS(&device) );这里有几个关键点需要解释。第一,D3D12CreateDevice的第一个参数是适配器指针,传nullptr表示使用系统默认的适配器。如果你有多个显卡(比如集成显卡加独立显卡),可能需要枚举适配器来选择性能更好的那个。第二,D3D_FEATURE_LEVEL_11_0是 D3D12 要求的最低特性级别,实际上所有支持 D3D12 的硬件都至少支持 11_0。第三,IID_PPV_ARGS是一个宏,它会自动推导接口的 IID 并转换指针类型,省去了手动写__uuidof的麻烦。
创建 Device 之后,你可以通过device->GetNodeCount()来检查设备有多少个节点(多显卡系统可能有多个节点),但对于学习阶段来说,单节点就够了。
3.3 命令队列:D3D12 的"任务调度中心"
命令队列是 D3D12 中一个非常重要的概念。你可以把它理解为一个任务队列,GPU 会按照你提交的顺序执行队列中的命令。D3D12 有三种类型的命令队列:
| 队列类型 | 用途 | 典型场景 |
|---|---|---|
| DIRECT | 执行所有类型的命令 | 渲染、计算、复制 |
| COMPUTE | 只执行计算和复制命令 | 通用计算任务 |
| COPY | 只执行复制命令 | 异步资源上传 |
对于学习阶段来说,创建一个 DIRECT 类型的队列就够了:
ComPtr<ID3D12CommandQueue> commandQueue; D3D12_COMMAND_QUEUE_DESC queueDesc = {}; queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT; queueDesc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE; device->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&commandQueue));这里有一个容易忽略的点:命令队列的优先级。D3D12_COMMAND_QUEUE_DESC里有一个Priority字段,默认值是 0。如果你有多个队列,可以通过设置不同的优先级来控制 GPU 的调度顺序。但学习阶段用默认值就行。
还有一个经验之谈:不要创建多个 DIRECT 队列。有些人觉得多创建几个队列就能并行执行命令,但实际上 DIRECT 队列在 GPU 上是串行执行的(除非你的显卡支持并行队列)。创建多个队列反而会增加同步的复杂度。
4. 交换链与描述符堆:把画面送到屏幕上
4.1 交换链的创建与双缓冲 vs 三缓冲
交换链是连接 D3D12 渲染结果和窗口显示的桥梁。它的核心作用是管理一组后台缓冲区(Back Buffer),GPU 渲染到其中一个缓冲区,然后通过 Present 操作把它"翻转"到屏幕上显示。
DXGI_SWAP_CHAIN_DESC1 swapChainDesc = {}; swapChainDesc.Width = 1280; swapChainDesc.Height = 720; swapChainDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; swapChainDesc.SampleDesc.Count = 1; swapChainDesc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; swapChainDesc.BufferCount = 2; swapChainDesc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD; swapChainDesc.AlphaMode = DXGI_ALPHA_MODE_UNSPECIFIED; ComPtr<IDXGISwapChain1> swapChain1; factory->CreateSwapChainForHwnd( commandQueue.Get(), hwnd, &swapChainDesc, nullptr, nullptr, &swapChain1 ); ComPtr<IDXGISwapChain3> swapChain; swapChain1.As(&swapChain);这里有几个关键参数需要解释。BufferCount = 2表示双缓冲,也就是两个后台缓冲区。双缓冲是最基本的配置,但它在某些情况下会导致帧率不稳定,因为 GPU 在等待垂直同步的时候没有额外的缓冲区可以渲染。三缓冲(BufferCount = 3)可以缓解这个问题,但会多占用一份显存。对于学习阶段来说,双缓冲就够了。
SwapEffect我选择了DXGI_SWAP_EFFECT_FLIP_DISCARD,这是 D3D12 推荐的交换效果。它比旧的DXGI_SWAP_EFFECT_DISCARD性能更好,但要求BufferCount至少为 2。还有一个DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL,它不会丢弃后台缓冲区的内容,适合需要读取后台缓冲区数据的场景。
Format我选择了DXGI_FORMAT_R8G8B8A8_UNORM,这是最通用的格式,几乎所有显卡都支持。如果你后面要做 HDR 渲染,可以改成DXGI_FORMAT_R10G10B10A2_UNORM或DXGI_FORMAT_R16G16B16A16_FLOAT。
4.2 RTV 描述符堆:为什么需要它
在 D3D11 里,你创建一个 RenderTargetView 只需要调用CreateRenderTargetView就行了。但在 D3D12 里,你需要先创建一个描述符堆(Descriptor Heap),然后在堆上分配描述符,最后把描述符写入堆中。这个设计看起来繁琐,但它的好处是:描述符堆可以被 GPU 直接访问,减少了 CPU 和 GPU 之间的同步开销。
D3D12_DESCRIPTOR_HEAP_DESC rtvHeapDesc = {}; rtvHeapDesc.NumDescriptors = 2; // 双缓冲,需要两个 RTV rtvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV; rtvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE; ComPtr<ID3D12DescriptorHeap> rtvHeap; device->CreateDescriptorHeap(&rtvHeapDesc, IID_PPV_ARGS(&rtvHeap)); UINT rtvDescriptorSize = device->GetDescriptorHandleIncrementSize( D3D12_DESCRIPTOR_HEAP_TYPE_RTV );注意NumDescriptors = 2,因为双缓冲有两个后台缓冲区,每个都需要一个 RTV。Flags设置为D3D12_DESCRIPTOR_HEAP_FLAG_NONE表示这个堆不会被着色器直接访问。RTV 堆通常不需要着色器访问,所以用 NONE 就行。
GetDescriptorHandleIncrementSize返回的是描述符堆中每个描述符占用的字节数。这个值在不同硬件上可能不同,所以你不能硬编码,必须通过这个函数获取。
4.3 为后台缓冲区创建 RTV
有了描述符堆之后,就可以为每个后台缓冲区创建 RTV 了:
D3D12_CPU_DESCRIPTOR_HANDLE rtvHandle = rtvHeap->GetCPUDescriptorHandleForHeapStart(); for (UINT i = 0; i < 2; i++) { ComPtr<ID3D12Resource> backBuffer; swapChain->GetBuffer(i, IID_PPV_ARGS(&backBuffer)); device->CreateRenderTargetView(backBuffer.Get(), nullptr, rtvHandle); rtvHandle.ptr += rtvDescriptorSize; }这段代码的逻辑很直接:遍历每个后台缓冲区,获取资源指针,然后创建 RTV。注意rtvHandle.ptr += rtvDescriptorSize这一行,它把句柄移动到堆中的下一个位置。这是 D3D12 中操作描述符堆的标准方式。
这里有一个坑:GetBuffer返回的资源指针需要保存下来,因为后面在渲染的时候你需要用它来设置渲染目标。如果你不保存,后面就得重新调用GetBuffer,虽然也能用,但多了一次不必要的调用。
5. 命令分配器、命令列表与围栏:让 GPU 真正动起来
5.1 命令分配器和命令列表的关系
命令分配器(Command Allocator)和命令列表(Command List)是 D3D12 中一对紧密配合的对象。你可以把命令分配器理解为一块内存池,命令列表则是记录命令的"录音机"。当你调用命令列表的录制方法时,命令实际上被记录在命令分配器的内存中。
ComPtr<ID3D12CommandAllocator> commandAllocator; device->CreateCommandAllocator( D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&commandAllocator) ); ComPtr<ID3D12GraphicsCommandList> commandList; device->CreateCommandList( 0, D3D12_COMMAND_LIST_TYPE_DIRECT, commandAllocator.Get(), nullptr, IID_PPV_ARGS(&commandList) );注意CreateCommandList的第四个参数是初始的 PipelineState,传nullptr表示没有默认的 PSO。命令列表创建后处于"打开"状态,你可以直接开始录制命令。如果你暂时不想录制,可以调用commandList->Close()关闭它。
这里有一个重要的规则:命令分配器在被命令列表使用时不能重置。也就是说,如果你正在录制命令,就不能调用commandAllocator->Reset()。你必须先关闭命令列表,执行完命令,等待 GPU 完成,然后才能重置分配器。
5.2 围栏:CPU 和 GPU 之间的红绿灯
围栏(Fence)是 D3D12 中用于 CPU-GPU 同步的核心机制。它的工作原理是:CPU 在命令队列中插入一个围栏信号,GPU 执行到这个信号时会更新围栏的值。CPU 可以通过检查围栏的值来判断 GPU 是否已经执行到了某个点。
ComPtr<ID3D12Fence> fence; device->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&fence)); HANDLE fenceEvent = CreateEvent(nullptr, FALSE, FALSE, nullptr); UINT64 fenceValue = 0; // 等待 GPU 完成 void WaitForGPU() { fenceValue++; commandQueue->Signal(fence.Get(), fenceValue); if (fence->GetCompletedValue() < fenceValue) { fence->SetEventOnCompletion(fenceValue, fenceEvent); WaitForSingleObject(fenceEvent, INFINITE); } }这段代码的逻辑是:每次等待 GPU 时,递增围栏值,然后在命令队列中插入一个信号。如果 GPU 还没有执行到这个信号,就设置一个事件,让 CPU 等待这个事件被触发。当 GPU 执行到信号时,它会更新围栏的完成值,触发事件,CPU 被唤醒。
这里有一个经验之谈:不要每帧都调用 WaitForGPU。如果你每帧都等待 GPU 完成,那就变成了 CPU 和 GPU 串行工作,性能会非常差。正确的做法是使用双缓冲或三缓冲的命令分配器,让 CPU 在 GPU 渲染上一帧的时候录制下一帧的命令。
5.3 完整的渲染循环
把上面所有东西串起来,一个最小的渲染循环大概长这样:
while (msg.message != WM_QUIT) { if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); } else { // 重置命令分配器和命令列表 commandAllocator->Reset(); commandList->Reset(commandAllocator.Get(), nullptr); // 设置渲染目标 D3D12_CPU_DESCRIPTOR_HANDLE rtvHandle = rtvHeap->GetCPUDescriptorHandleForHeapStart(); rtvHandle.ptr += swapChain->GetCurrentBackBufferIndex() * rtvDescriptorSize; // 转换资源状态 D3D12_RESOURCE_BARRIER barrier = {}; barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; barrier.Transition.pResource = backBuffers[swapChain->GetCurrentBackBufferIndex()].Get(); barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT; barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET; commandList->ResourceBarrier(1, &barrier); // 清除渲染目标 const float clearColor[] = { 0.2f, 0.3f, 0.4f, 1.0f }; commandList->ClearRenderTargetView(rtvHandle, clearColor, 0, nullptr); // 转换回 PRESENT 状态 barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET; barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PRESENT; commandList->ResourceBarrier(1, &barrier); // 关闭并执行命令列表 commandList->Close(); ID3D12CommandList* cmdLists[] = { commandList.Get() }; commandQueue->ExecuteCommandLists(1, cmdLists); // 呈现 swapChain->Present(1, 0); // 等待 GPU 完成(学习阶段简单处理) WaitForGPU(); } }这段代码虽然不长,但它包含了 D3D12 渲染的核心流程:重置分配器、录制命令、设置渲染目标、资源状态转换、清除渲染目标、执行命令、呈现。每一步都有它的必要性,缺一不可。
6. 那些调试层不会告诉你的实战经验
6.1 资源状态转换:最容易漏掉的一步
在上面的渲染循环里,你可能注意到了两次ResourceBarrier调用。这是 D3D12 相比 D3D11 最大的变化之一:D3D12 要求你显式地管理资源的状态。后台缓冲区在 Present 之后处于D3D12_RESOURCE_STATE_PRESENT状态,但你要把它作为渲染目标使用,就必须先转换到D3D12_RESOURCE_STATE_RENDER_TARGET状态。渲染完成后,又要转换回PRESENT状态,否则 Present 会失败。
这个机制的好处是让驱动可以更精确地优化资源的使用,但代价是开发者必须手动管理状态。我踩过的坑是:忘记转换回 PRESENT 状态,结果程序不报错,但屏幕就是黑的。调试层会给出警告,但如果你没开调试层,就会一脸懵逼。
6.2 窗口大小改变时的处理
当用户拉伸窗口时,交换链的后台缓冲区大小需要跟着调整。这个过程涉及几个步骤:等待 GPU 完成、释放所有后台缓冲区的引用、调用ResizeBuffers、重新创建 RTV。
void OnResize(UINT width, UINT height) { if (swapChain) { WaitForGPU(); // 释放后台缓冲区引用 for (auto& bb : backBuffers) bb.Reset(); // 调整交换链 swapChain->ResizeBuffers(0, width, height, DXGI_FORMAT_UNKNOWN, 0); // 重新创建 RTV // ... } }这里的关键点是:在调用 ResizeBuffers 之前,必须释放所有对后台缓冲区的引用。如果你还持有后台缓冲区的 ComPtr,ResizeBuffers 会失败并返回DXGI_ERROR_INVALID_CALL。这个坑我踩过不止一次,每次都是因为忘了释放某个地方的引用。
6.3 调试层报的那些"看不懂"的错误
调试层有时候会报一些让人摸不着头脑的错误。比如:
- "Resource barrier called on a resource that is not in the correct state":资源状态转换写错了,检查 StateBefore 和 StateAfter 是否匹配当前实际状态。
- "Command allocator is being reset while a command list is still open":命令列表还没关闭就重置了分配器,检查命令列表的生命周期。
- "Swap chain buffer count must be at least 2":BufferCount 设置成了 1,改成 2 或 3。
- "Descriptor heap is not shader visible":你试图在着色器中访问一个非着色器可见的堆,检查 Flags 设置。
这些错误信息其实都挺直白的,关键是你要知道去哪里找。Visual Studio 的输出窗口会显示调试层的信息,但有时候信息太多会被淹没。你可以用调试层的SetBreakOnSeverity来让它在遇到错误时直接断点:
ComPtr<ID3D12InfoQueue> infoQueue; device.As(&infoQueue); infoQueue->SetBreakOnSeverity(D3D12_MESSAGE_SEVERITY_ERROR, TRUE); infoQueue->SetBreakOnSeverity(D3D12_MESSAGE_SEVERITY_CORRUPTION, TRUE);这样一旦出现错误,程序会直接断在出错的地方,方便你定位问题。
6.4 关于 ComPtr 的使用建议
D3D12 的所有对象都使用 COM 接口,所以用Microsoft::WRL::ComPtr来管理引用计数是最方便的选择。但有几个地方需要注意:
- 不要用裸指针保存 D3D12 对象,除非你非常清楚引用计数的管理。
- ComPtr 的
As方法用于查询接口,比如从IDXGISwapChain1查询IDXGISwapChain3。 - ComPtr 的
Get方法返回裸指针,用于传递给 D3D12 API。 - ComPtr 的
Reset方法释放引用,在 ResizeBuffers 之前需要调用。
我个人的习惯是:所有 D3D12 对象都用 ComPtr 成员变量保存,在需要传递裸指针的地方用.Get()。这样既安全又方便。
7. 框架跑通之后,下一步该往哪里走
当你看到窗口里出现一片纯色的背景,并且拉伸窗口时画面能正常调整大小,恭喜你,D3D12 的最小框架已经跑通了。这个框架虽然简单,但它包含了 D3D12 的所有核心概念:设备、队列、交换链、描述符堆、命令分配器、命令列表、围栏、资源状态转换。
接下来你可以往几个方向扩展:
第一个方向是画一个三角形。这需要你创建根签名、编译着色器、创建管线状态对象(PSO)、创建顶点缓冲区、设置视口和裁剪矩形。这是 D3D12 学习的下一个里程碑,也是理解渲染管线的好机会。
第二个方向是封装框架。把窗口创建、设备初始化、交换链管理这些代码封装成类,让主函数变得干净。但我的建议是:先跑通再封装,不要一开始就想着设计一个完美的架构。
第三个方向是加入更多同步机制。比如使用多个命令分配器实现帧并行,或者使用围栏实现更精细的 GPU 同步。这些在后面的学习中会逐渐接触到。
我个人在实际操作中的体会是:D3D12 的学习曲线确实比 D3D11 陡峭很多,但它的设计逻辑是自洽的。每一个看似繁琐的步骤背后都有它的道理。当你理解了"为什么要这样做"之后,那些代码就不再是死记硬背的模板,而是你可以灵活运用的工具。
最后再分享一个小技巧:在调试阶段,把交换链的 Present 参数设为Present(0, 0)而不是Present(1, 0)。前者不等待垂直同步,可以让你看到实际的帧率;后者会锁定到显示器的刷新率。等你确认渲染循环没问题了,再改回Present(1, 0)避免画面撕裂。