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 之后否命令队列DeviceDevice 之后否交换链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 LD3D12WindowClass; RegisterClassEx(wc); HWND hwnd CreateWindowEx( 0, LD3D12WindowClass, LD3D12 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) ComPtrID3D12Debug 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 DeviceDXGI Factory 是创建交换链的入口D3D12 Device 是所有 D3D12 资源的创建者。这两个东西的创建顺序是先 Factory再 Device。ComPtrIDXGIFactory4 factory; CreateDXGIFactory2(0, IID_PPV_ARGS(factory)); ComPtrID3D12Device 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 类型的队列就够了ComPtrID3D12CommandQueue 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 BufferGPU 渲染到其中一个缓冲区然后通过 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; ComPtrIDXGISwapChain1 swapChain1; factory-CreateSwapChainForHwnd( commandQueue.Get(), hwnd, swapChainDesc, nullptr, nullptr, swapChain1 ); ComPtrIDXGISwapChain3 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; ComPtrID3D12DescriptorHeap 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) { ComPtrID3D12Resource 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 中一对紧密配合的对象。你可以把命令分配器理解为一块内存池命令列表则是记录命令的录音机。当你调用命令列表的录制方法时命令实际上被记录在命令分配器的内存中。ComPtrID3D12CommandAllocator commandAllocator; device-CreateCommandAllocator( D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(commandAllocator) ); ComPtrID3D12GraphicsCommandList 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 是否已经执行到了某个点。ComPtrID3D12Fence 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 之前必须释放所有对后台缓冲区的引用。如果你还持有后台缓冲区的 ComPtrResizeBuffers 会失败并返回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 2BufferCount 设置成了 1改成 2 或 3。Descriptor heap is not shader visible你试图在着色器中访问一个非着色器可见的堆检查 Flags 设置。这些错误信息其实都挺直白的关键是你要知道去哪里找。Visual Studio 的输出窗口会显示调试层的信息但有时候信息太多会被淹没。你可以用调试层的SetBreakOnSeverity来让它在遇到错误时直接断点ComPtrID3D12InfoQueue 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)避免画面撕裂。
阅读完成 · 觉得有帮助?