ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

DirectX 12入门:从报错排查到清屏实现全解析

DirectX 12入门:从报错排查到清屏实现全解析 1. 那句not supported的报错藏着一张三层的检查清单大概半年前我在某个游戏的启动参数里加了-dx12满心期待地按下启动键。结果弹出一个黑框上面写着directx 12 is not supported on your system. try running without the -dx12 or后面还有半句话我已经没心情看了因为当时的我第一反应是我的显卡虽然不算旗舰但也不至于连DX12都跑不了吧接下来就是标准的玩家式操作——搜报错、翻论坛、试各种偏方。折腾了大半夜游戏倒是能跑了把-dx12去掉就行但这个报错像一根刺一样扎在心里。后来我不打算继续当那个只会复制报错去搜索的人了决定自己把DX12从头到尾学一遍。这篇就是我的学习系列第一篇面向有C基础、但完全没碰过DX12的朋友。我会把概念层面的思维切换、环境准备、第一段能跑起来的初始化代码、以及我在这个过程中踩过的坑全部写出来争取比官方文档好懂一个量级。先把这个报错本身掰开揉碎。一条渲染指令从你的代码跑到GPU上真正执行中间隔着三层每一层都可能掉链子操作系统层DX12在Windows 10上需要1809或更高版本才有完整支持。旧版本系统不是完全不能用但很多新特性缺失有些游戏检测到系统版本不够就直接拒绝启动。驱动层DX12要求WDDM 2.0及以上的驱动模型。如果你的显卡驱动还停留在WDDM 1.x时代比如一些特别老的Intel核显DX12就跑不起来。硬件层GPU必须支持至少Feature Level 11_0功能级别。注意支持FL 11_0的显卡可以运行DX12只是用不了11_1、12_0以上的高阶特性而已。排查这个报错我建议按这个顺序来先按Win R打开dxdiag在显示标签页看驱动程序模型那一行确认是不是WDDM 2.0以上然后再看功能级别确认硬件支持最后把Windows更新和显卡驱动全部升到最新重新跑一次。这里有个特别容易误判的点很多游戏加了-dx12参数后会先去枚举GPU能力如果系统版本不够哪怕你的显卡本身支持DX12它依然会给你弹这个报错。所以这个报错真不一定是在骂你的显卡更多时候是在骂你的系统环境。我后来就是更新了Windows版本报错直接消失游戏再用-dx12启动就一切正常了。2. 看懂DX12要先换脑子从自动挡到手动挡的三个关键差异搞明白报错来源之后我开始正经看DX12的教程。看了不到两个小时就意识到一个事实DX12根本不是DX11的升级版它是一套完全不同的心智模型。用开车的比喻来说DX11是自动挡DX12是手动挡。DX11时代驱动就像自动挡的老司机。你只需要设置好渲染状态深度测试开不开、混合模式是什么、顶点布局长什么样然后调用DrawIndexed驱动会在背后帮你完成状态校验、资源绑定、内存分配这一大堆工作。开发者踩油门就行剩下的交给变速箱。DX12把方向盘和离合器都塞到你手里了。它给你的是近乎裸金属的控制权驱动基本不帮你做任何兜底。具体来说有三个思维切换最核心我一个个说。第一个切换从随时改状态变成提前打包PSODX11里你随时可以切换深度状态、混合状态、光栅状态改完下一次Draw就生效。DX12把这些全部固化成了管线状态对象Pipeline State ObjectPSO。一个PSO就是一份完整的渲染配置包顶点着色器、像素着色器、输入布局、光栅化配置、混合配置、深度模板配置全部提前编译好。你想换状态直接切换PSO。这个设计的收益是驱动在运行时不需要做任何状态解析代价是你要提前规划好程序里可能需要哪些状态的组合。第二个切换从驱动帮你绑定资源变成自己管理描述符DX11里你调用PSSetShaderResources把资源绑定到着色器的某个槽位驱动帮你翻译这一层关系。DX12里你得自己创建描述符堆Descriptor Heap把资源的描述符放进去然后在命令列表里告诉GPU我的第0号描述符对应像素着色器的第1号槽位。驱动完全不帮你翻译因为你给它的已经是最底层的指令。第三个切换从隐式内存管理变成显式内存规划DX11里创建纹理、缓冲区驱动自动帮你安排显存位置你完全感知不到。DX12里资源要明确放在哪类堆上是默认堆GPU可读可写、上传堆CPU写入GPU读取还是回读堆GPU写入CPU读取你需要自己决定。而且资源还有常驻resident和驻留made resident的概念大数据量场景下你得自己管理哪些资源常驻显存、哪些随时换入换出。这三个切换总结起来就是一句话DX12把控制权还给了开发者同时把责任也一起给了。自动挡虽然舒服但你要做高性能渲染比如细粒度的多线程命令录制、海量物体剔除、跨GPU显存规划就必须自己挂挡踩离合。驱动不再做那层CPU翻译这就是性能空间。3. 动手前把环境捋顺系统、SDK、显卡、调试器一个都不能少学DX12不是打开Visual Studio建个空项目就能开始的环境准备这一步能劝退不少人。我把自己的搭建过程完整记录下来每一步都会解释为什么。系统要求我建议直接用Windows 11或者Windows 10 1809以上版本。理由不只是前面说的游戏报错而是DX12的调试层Debug Layer和若干特性在旧版本上表现不稳定排查问题非常痛苦。如果你只是在做老系统兼容性测试另说正常学习新系统是前提。Visual Studio与Windows SDK版本上VS2019、VS2022都可以关键是安装时要勾选使用C的桌面开发。Windows SDK建议装最新稳定版DX12头文件d3d12.h、dxgi1_4.h等都跟着SDK走。装完之后在项目属性里能看到Windows SDK版本号一般默认就是最新的不用改。显卡与功能级别最低要求是Feature Level 11_0。怎么确认你的显卡支持到哪个功能级别dxdiag的显示页面看或者写一小段代码调用D3D12CreateDevice枚举。这里有个值得知道的细节DX12的设备创建函数D3D12CreateDevice接受的第二个参数是最低功能级别它内部会自动选择你的显卡和驱动实际支持的最高功能级别。所以哪怕你传D3D_FEATURE_LEVEL_11_0如果你的GPU支持12_0拿到的设备也是12_0的。调试层必须开DX12有个杀手级功能叫Debug Layer。它在驱动层之上加了一层全面的参数校验能捕获非常多的错误调用——比如你用未初始化的描述符、错误的状态切换、越界访问等。开发阶段务必开启发布版本再关掉。开启方法很简单ComPtrID3D12Debug debugController; if (SUCCEEDED(D3D12GetDebugInterface(IID_PPV_ARGS(debugController)))) { debugController-EnableDebugLayer(); }注意必须在创建ID3D12Device之前调用。你要是先创建设备再开调试层调试层根本不生效。这个顺序问题我踩过后面会再提。GPU调试工具学DX12强烈建议装PIX on Windows微软官方的GPU调试工具。它可以抓帧、看性能计数器、检查资源内容、看命令列表执行情况。虽然第一篇还没到需要PIX的程度但建议提前装好等后面上了复杂渲染管线没有PIX几乎无法高效排查问题。创建项目新建一个C空项目然后做三件事项目属性 - 链接器 - 输入 - 附加依赖项里加上d3d12.lib和dxgi.lib配置里把字符集设为Unicode确保_DEBUG宏在Debug配置下被定义VS默认会定义。搞定这些环境就绪。4. 地基三件套的创建代码设备、命令队列和交换链环境准备好之后我开始写第一段代码。DX12程序的地基由三样东西组成设备Device、命令队列Command Queue和交换链Swap Chain。这三样关系是设备是整个GPU逻辑对象的工厂命令队列是CPU往GPU提交任务的管道交换链是双缓冲/三缓冲机制的管理者。创建设备UINT dxgiFactoryFlags 0; #ifdef _DEBUG ComPtrID3D12Debug debugController; if (SUCCEEDED(D3D12GetDebugInterface(IID_PPV_ARGS(debugController)))) { debugController-EnableDebugLayer(); dxgiFactoryFlags | DXGI_CREATE_FACTORY_DEBUG; } #endif ComPtrIDXGIFactory4 factory; CreateDXGIFactory2(dxgiFactoryFlags, IID_PPV_ARGS(factory)); ComPtrID3D12Device device; HRESULT hr D3D12CreateDevice(nullptr, D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(device));这里有一个比较微妙的地方DXGI_CREATE_FACTORY_DEBUG这个标志只在Debug Layer开启时传否则DXGI工厂的创建会失败。所以正常情况下是#ifdef _DEBUG包住的或者你用ComPtr来判断是否成功。为什么设备创建是DX12的第一个动作因为后续所有对象——命令队列、命令分配器、命令列表、描述符堆、管线状态对象——全都是ID3D12Device创建的。设备相当于GPU驱动在应用层的代言人。创建命令队列命令队列是CPU侧提交命令的入口就像寄快递的柜台D3D12_COMMAND_QUEUE_DESC queueDesc {}; queueDesc.Type D3D12_COMMAND_LIST_TYPE_DIRECT; queueDesc.Flags D3D12_COMMAND_QUEUE_FLAG_NONE; ComPtrID3D12CommandQueue commandQueue; device-CreateCommandQueue(queueDesc, IID_PPV_ARGS(commandQueue));Type有 DIRECT、COMPUTE、COPY三种。DIRECT能执行所有类型的命令COMPUTE只能执行计算着色器COPY只能做拷贝操作。第一篇阶段我们只用DIRECT就好。创建交换链这里要注意DX12的交换链创建方式和DX11有个重大区别CreateSwapChainForHwnd的第一个参数传的是命令队列指针而不是设备指针。因为DX12的呈现模型是异步的交换链需要知道命令队列在哪里才能正确同步呈现操作。DXGI_SWAP_CHAIN_DESC1 swapChainDesc {}; swapChainDesc.BufferCount 2; swapChainDesc.Width width; swapChainDesc.Height height; swapChainDesc.Format DXGI_FORMAT_R8G8B8A8_UNORM; swapChainDesc.BufferUsage DXGI_USAGE_RENDER_TARGET_OUTPUT; swapChainDesc.SwapEffect DXGI_SWAP_EFFECT_FLIP_DISCARD; ComPtrIDXGISwapChain1 swapChain; factory-CreateSwapChainForHwnd(commandQueue.Get(), hwnd, swapChainDesc, nullptr, nullptr, swapChain);SwapEffect用DXGI_SWAP_EFFECT_FLIP_DISCARD是DX12的主流方式。FLIP模型意味着交换链的操作是翻转flip而不是拷贝blit效率更高且支持UWP和全屏优化。BufferCount 2就是双缓冲游戏一般建议三缓冲减少画面撕裂。创建完交换链之后还要调用GetCurrentBackBufferIndex()拿到当前的后台缓冲索引这个索引在每次Present之后可能变化不能缓存起来永久使用。创建RTV描述符堆和后台缓冲视图交换链自己管理后台缓冲但你要把后台缓冲用作渲染目标必须为它创建渲染目标视图RTV。这里就涉及DX12最核心的描述符堆概念D3D12_DESCRIPTOR_HEAP_DESC rtvHeapDesc {}; rtvHeapDesc.NumDescriptors 2; rtvHeapDesc.Type D3D12_DESCRIPTOR_HEAP_TYPE_RTV; ComPtrID3D12DescriptorHeap rtvHeap; device-CreateDescriptorHeap(rtvHeapDesc, IID_PPV_ARGS(rtvHeap)); UINT rtvDescriptorSize device-GetDescriptorHandleIncrementSize( D3D12_DESCRIPTOR_HEAP_TYPE_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; }描述符就是一个固定大小的数据结构描述了资源的格式、维度、用途等信息。GPU着色器通过描述符来访问资源而不是直接访问资源指针。GetDescriptorHandleIncrementSize返回单个描述符占用的字节数不同厂商的GPU驱动对这个值的实现可能不同所以不能写死必须运行时查询。我一开始图省事写死64字节换了一台N卡跑就崩了这就是教训。5. 让屏幕第一次变色命令列表、屏障、清屏与Fence等待地基打好了接下来要做的事情是整个DX12跑通的第一步清屏。别小看这一步它串联了DX12的所有核心机制命令列表、资源屏障、命令分配器、Fence同步。命令分配器和命令列表DX12里CPU不能直接往命令队列扔指令你要先在命令列表Command List里记录指令记录完成之后把命令列表提交给命令队列。而命令列表本身不是无限记录的它需要命令分配器Command Allocator来管理底层内存。ComPtrID3D12CommandAllocator cmdAllocator; device-CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(cmdAllocator)); ComPtrID3D12GraphicsCommandList cmdList; device-CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, cmdAllocator.Get(), nullptr, IID_PPV_ARGS(cmdList)); cmdList-Close();注意CreateCommandList创建出来的命令列表默认是打开状态所以你马上要Close()。之后每一帧的模式是重置分配器 - 重置命令列表 - 记录指令 - Close - 提交。有个非常关键的点Reset命令分配器之前必须确保GPU不再使用这个分配器里的老命令。否则你是在释放GPU还在读的内存轻则花屏重则驱动崩溃。这个约束引出了DX12里最绕不过去的东西——Fence。创建FenceFence是DX12的同步原语用来协调CPU和GPU之间的执行顺序。你可以把它理解成一个计数器GPU执行完你提交的命令之后会把Fence的值设置成你指定的值。CPU可以阻塞等待这个值被写入。ComPtrID3D12Fence fence; device-CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(fence)); HANDLE fenceEvent CreateEvent(nullptr, FALSE, FALSE, nullptr); UINT64 fenceValue 1;整个同步流程是这样组织的每帧用一个递增的fenceValue提交命令后让GPU执行到某个点时Signal(fence, fenceValue)然后CPU端SetEventOnCompletionWaitForSingleObject等到这个值达成。完整的一帧清屏提交我把一个完整的清屏帧写出来注释里说明每一步在干什么void RenderFrame() { UINT frameIndex swapChain-GetCurrentBackBufferIndex(); // 重置分配器和命令列表 cmdAllocator-Reset(); cmdList-Reset(cmdAllocator.Get(), nullptr); // 资源屏障把后台缓冲从呈现状态切换到渲染目标状态 D3D12_RESOURCE_BARRIER barrier CD3DX12_RESOURCE_BARRIER::Transition( backBuffers[frameIndex].Get(), D3D12_RESOURCE_STATE_PRESENT, D3D12_RESOURCE_STATE_RENDER_TARGET ); cmdList-ResourceBarrier(1, barrier); // 获取对应的RTV描述符句柄 D3D12_CPU_DESCRIPTOR_HANDLE rtvHandle( rtvHeap-GetCPUDescriptorHandleForHeapStart()); rtvHandle.ptr frameIndex * rtvDescriptorSize; // 清屏 float clearColor[] { 0.1f, 0.2f, 0.4f, 1.0f }; cmdList-ClearRenderTargetView(rtvHandle, clearColor, 0, nullptr); // 资源屏障呈现前切回呈现状态 D3D12_RESOURCE_BARRIER barrierPresent CD3DX12_RESOURCE_BARRIER::Transition( backBuffers[frameIndex].Get(), D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PRESENT ); cmdList-ResourceBarrier(1, barrierPresent); // 结束记录 cmdList-Close(); // 提交到命令队列 ID3D12CommandList* ppCommandLists[] { cmdList.Get() }; commandQueue-ExecuteCommandLists(1, ppCommandLists); // 同步等待GPU执行完这一帧 commandQueue-Signal(fence.Get(), fenceValue); if (fence-GetCompletedValue() fenceValue) { fence-SetEventOnCompletion(fenceValue, fenceEvent); WaitForSingleObject(fenceEvent, INFINITE); } fenceValue; // 呈现 swapChain-Present(1, 0); }这里最反直觉的是资源屏障Resource Barrier。为什么清屏前要把资源从PRESENT状态切到RENDER_TARGET状态为什么不直接画原因是GPU对资源的访问是有隐式约束的资源在不同阶段使用时数据布局和缓存策略可能不同。比如呈现阶段后台缓冲的数据要交给显示引擎读取需要保证写入全部完成渲染目标阶段需要保证SM写入对ROP可见。驱动在DX11里自动帮你做这些同步DX12里你必须显式声明资源状态切换的意图驱动才知道该在哪里插入同步指令。如果你忘记了屏障或者把状态写错了结果通常是黑屏、花屏或者调试层报错。调试层会告诉你Resource state is invalid之类的信息看到这种报错第一反应就去看屏障。等待GPU的这个Wait是不是太蠢了你会发现上面这个例子每帧都要CPU阻塞等待GPU完成。这完全违背了CPU和GPU并行工作的初衷对不对没错这只是一个让逻辑简化到极致的学习版本。实际游戏里会用多帧在飞multiple frames in flight的技术让CPU提前执行几帧GPU在后面追赶中间靠Fence做滑动窗口式的同步。这个留到后续篇章详细讲这里先把WaitForSingleObject理解透彻它是后面所有高级同步技巧的地基。6. 我在第一篇里踩过的坑和总结的检查清单最后这一部分我把这个阶段踩过的坑整理出来。每个坑都是真实经历写出来是希望大家别走一遍弯路。坑一先创建了设备再开启Debug Layer如前面提的调试层必须在设备创建之前开启。我一开始图省事直接从示例代码里复制了开启调试层的代码块放在了设备创建后面。结果是调试层静默失效——代码还能跑但所有的参数校验都不做了出错了只能对着黑屏猜。检查方法很简单设备创建成功后调用device-GetDebugInterface()看能否获取到调试接口。拿不到就说明没开成功。坑二RTV描述符大小写死这个前面已经提过。GetDescriptorHandleIncrementSize返回的值在不同GPU驱动上可能是不同的千万不要假设它是64字节或者别的什么固定值。所有描述符堆遍历都必须用这个运行时查询值做步长。我把这个教训记在代码注释的最显眼处。坑三命令分配器Reset前没等待GPU这是同步问题里最常见的翻车点。在一个简单清屏程序里因为每帧都等待GPU完成所以不会暴露。但如果你做了一个优化去掉帧间等待马上就会遇到随机闪烁或者黑屏。根因就是分配器被重置时上一帧的命令还没执行完。理解这个逻辑后你会明白DX12的每一处限制其实都在提醒你底层同步的真实状态。坑四交换链创建参数里把Width和Height写0DXGI_SWAP_CHAIN_DESC1的Width和Height设置为0时DXGI会自动使用窗口客户区的大小。这个特性在窗口初始化完成后使用没问题但如果你在窗口还没有完全创建好时就调用拿到的Width和Height可能是0交换链创建会报无效参数。一个基本检查清单每次程序跑不起来我建议按这个顺序排查打开VS的输出窗口看调试层有没有输出报错信息。DX12的调试信息非常明确90%的问题在这一步就能定位。检查所有HRESULT返回值。DX12的API几乎每个都会返回HRESULT用SUCCEEDED宏包裹检查不要忽略任何一个。我用一个简单的宏ThrowIfFailed包住所有调用失败就直接断点。确认代码中的所有资源状态切换和实际使用方式一致。特别是屏障的写状态PRESENT和RENDER_TARGET写反了是经典错误。确认Fence值在每帧使用前是递增的且没有重复使用同一个Fence值。这一篇的终点到此一个DX12窗口程序已经能创建后台缓冲、录制命令并清屏了。屏幕上会出现一个深蓝色就是clearColor那个颜色的窗口这听起来很简单但它证明了你已经打通了DX12最核心的执行链路CPU创建资源 - 录制命令 - 提交队列 - GPU执行 - 同步 - 呈现。我自己走到这一步的时候才真正体会到为什么大家说DX12学习曲线陡峭——不是代码难写而是每个概念都在强迫你理解底层发生了什么。清屏这个动作在DX11里可能几行代码就搞定了在DX12里你得亲手走完一整条流水线。下一篇我会在这条流水线的基础上把顶点缓冲、输入布局、顶点着色器和像素着色器接进来让第一个三角形出现在屏幕上。那一步才是DX12真正开始好玩的地方。
返回列表