ARTICLE DETAIL

资讯详情

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

DX12实战避坑:从Device到贴图,黑屏花屏与资源状态排查

DX12实战避坑:从Device到贴图,黑屏花屏与资源状态排查 第一次照着某本 2019 年前后的 DX12 教程敲完 Device 创建那段代码我信心满满按下 F5窗口出来了画面是纯黑控制台干干净净一个错误都没有。那种感觉比直接报错还难受——报错至少告诉你哪错了黑屏只会让你怀疑人生。后来我才慢慢意识到问题根本不在我抄错的某一行而在于那些老教程的默认前提已经变了它们假设你已经装好了特定版本的 SDK假设你的显卡驱动支持特定特性等级假设你用的是某个特定的 PIX 版本而这些前提一个字都没写在你看到的那一页上。这篇就是我一路从 Device 创建、命令队列、交换链、PSO一直到把一张贴图贴到三角形上并且真的踩了三次大坑之后整理出来的实战记录。图形学、图形 API 这条路上DX12 算是门槛比较陡的一档它的核心思路是显式控制——把以前驱动替你做的事全部摊开交到你手上。好处是性能上限高、可控性强坏处是任何一个环节漏写结果就是黑屏或者花屏且不给你任何提示。如果你正在啃 DX12或者照着教程跑不通、贴图贴不上去、不知道从哪排查这篇应该能帮你少走几天弯路。适合有一点点 C 基础、想系统过一遍 DX12 图形管线的人也适合卡在三角形会画了但贴图不显示这一步的同学。1. 为什么2019年前后的DX12教程越啃越糊1.1 隐式驱动到显式控制职责被重新切分了DX11 时代你创建一个 buffer扔给驱动驱动帮你管理显存、帮你同步、帮你处理资源状态的切换。你在 CPU 端写的代码和 GPU 实际执行的东西之间隔着一层厚厚的抽象。DX12 把这层抽象拆掉了暴露出来的是命令队列、命令分配器、命令列表、围栏、资源状态、描述符堆这一整套东西。教程里讲这些概念的时候往往只告诉你要这么写不告诉你为什么必须这么写。我举个最典型的例子。DX11 里你画完一帧调用 Present 就完事了。DX12 里你在 Present 之前必须把后缓冲从 RENDER_TARGET 状态转换成 PRESENT 状态否则调试层会报一个资源状态冲突的警告而如果你没开调试层它不报错画面可能在某些驱动上正常、在某些驱动上直接黑掉。这就是隐式变显式的代价——驱动以前默默帮你的默认行为现在变成了你必须显式声明的义务。老教程如果不强调这一点你抄得一模一样也可能在别的机器上翻车。1.2 教程能跑通的前提往往没写在你看到的那一页我复盘过好几种照着抄跑不通的情况根源基本集中在几类前提上。第一类是系统版本DX12 的某些特性比如光线追踪相关的接口需要比较新的系统版本才支持你系统太老编译能过运行直接失败。第二类是 SDK 版本Windows SDK 里带的 d3d12.h 和 d3d12.lib 版本要和你的编译器、和你的驱动对齐SDK 太新或太旧都可能导致某些字段对不上。第三类是显卡特性等级老教程默认你能用比较高的 feature level但如果是集成显卡或者比较老的独显你可能只能拿到 11_0 甚至更低的等级一些资源格式和功能会不可用。所以我在开头就强调一件事先对齐环境再动代码。把 SDK 版本、显卡型号、驱动版本、系统版本这四个东西列清楚比闷头改代码有用得多。后面第二、第三节我会把环境这块单独展开讲因为这是最多人栽跟头的地方。2. 开工前的环境对齐SDK、驱动与调试层2.1 版本矩阵为什么你的代码在别人机器上就是黑的我先说结论DX12 开发最容易被忽视的不是代码是版本匹配。我整理了一张实际项目中反复用到的对照表帮你快速定位自己卡在哪一档。项目建议配置不匹配时的典型症状操作系统较新的 Windows 10/11 正式版特性等级上不去高阶接口不可用Windows SDK与 VS 安装器推荐版本一致d3d12.h 字段对不上编译报错或链接失败显卡特性等级独立显卡通常 12_0 以上只能拿到 11_0/11_1部分格式不支持显卡驱动官方最新正式版调试层报奇怪的错误甚至创建 Device 失败调试工具与 SDK 配套的抓帧工具抓帧失败或解析不出管线状态这张表看起来朴素但每一条我都见过真实翻车案例。特别是驱动版本这一栏很多人装的是厂商随手给的老驱动创建 Device 都能过但一到创建 PSO 就失败因为没有开调试层你就只看到一个 E_INVALIDARG完全摸不着头脑。而如果你系统不满足某个接口的最低要求运行时会直接告诉你当前系统不支持 DX12这种时候不是代码问题是环境问题改代码改到天亮也没用。2.2 第一件事不是写Device而是把调试层打开我强烈建议在你写下第一行 D3D12 代码之前先把调试层打开。这一步很多人跳过因为它不影响程序跑起来但它决定了你后面排查问题的效率。调试层是 DX12 自带的校验器它会在你调用 API 的时候检查参数合法性、资源状态是否正确、命令列表有没有配对释放等等然后把问题写成一句人话扔到你的调试输出里。开启调试层的基本流程是这样的先调用 D3D12GetDebugInterface 拿到调试接口再调用 EnableDebugLayer 打开它然后你后面创建的所有 Device 都会带上这个校验器。在 IDE 里还要把调试输出窗口打开否则调试层报的信息你看不到。我见过有人开了调试层但没看输出窗口以为调试层没用其实是信息被吞了。// 环境准备阶段打开调试层 ID3D12Debug* debugController nullptr; if (SUCCEEDED(D3D12GetDebugInterface(IID_PPV_ARGS(debugController)))) { debugController-EnableDebugLayer(); // 需要更严格校验时可以再开 GPU 校验 // debugController-SetEnableGPUBasedValidation(TRUE); }注意一个坑调试层打开后性能会下降而且部分校验只在调试层开启时生效。这意味着你发布版本里即使有资源状态问题调试层也不会告诉你运行时照样黑屏。所以我的做法是开发阶段一直开着遇到性能瓶颈再临时关掉绝不长期关闭。3. Device与Adapter第一块基石怎么选才不返工3.1 枚举适配器时那些被教程跳过的字段创建 Device 之前你得先选适配器。老教程通常一句 D3D12CreateDevice(nullptr, ...) 用默认适配器就过去了但在多显卡机器上这个默认选的可能不是你想用的那块。我会走 IDXGIFactory 的 EnumAdapters 流程逐个看适配器的描述信息显卡名字、专用显存大小、是不是软件适配器。软件适配器比如 WARP调试时有用能帮你在没有独立显卡的机器上跑起来但性能很低正式开发要排除掉。选适配器的时候还有个字段容易被忽略feature level。我会显式传一个我需要的等级比如 D3D_FEATURE_LEVEL_12_0如果这块卡达不到创建就会失败这样能第一时间发现硬件不满足要求而不是等到后面某个资源格式用不了才回头查。这一步看起来啰嗦但它把硬件能力这件事在程序启动最开始就摊清楚了避免后面莫名其妙的问题。3.2 用InfoQueue把HRESULT翻译成人话DX12 的 API 几乎全部返回 HRESULT但 HRESULT 本身信息量很低一个 E_INVALIDARG 你能猜到天亮。调试层开启后你可以拿到 ID3D12InfoQueue 接口它会把每一条校验信息都存起来包括严重程度、ID、描述文字。我习惯在关键调用之后手动查一次队列里有没有新增的错误或者干脆在帧循环的末尾统一 dump 一遍。// 把调试层的消息读出来别让它烂在队列里 ID3D12InfoQueue* infoQueue nullptr; if (SUCCEEDED(device-QueryInterface(IID_PPV_ARGS(infoQueue)))) { UINT64 numMessages infoQueue-GetNumStoredMessages(); for (UINT64 i 0; i numMessages; i) { SIZE_T length 0; infoQueue-GetMessage(i, nullptr, length); std::vectorchar buffer(length); D3D12_MESSAGE* msg reinterpret_castD3D12_MESSAGE*(buffer.data()); infoQueue-GetMessage(i, msg, length); // msg-Severity / msg-Description 就是你想要的信息 } infoQueue-ClearStoredMessages(); }这套东西用顺了之后你会发现 DX12 其实没那么玄学——它只是把错误藏得比较深。调试层配合 InfoQueue相当于给这头猛兽装了个翻译器你的排查效率会直接上一个台阶。我个人经验是把上面这段封装成一个 debug 工具函数在每次创建资源、每次提交命令列表之后都调一次前期虽然烦但能省掉大量盲猜的时间。4. 命令队列、命令列表与围栏帧循环的骨架4.1 命令分配器与命令列表的重置顺序DX12 里你往 GPU 下发命令不是直接调而是先把命令录进命令列表再提交给命令队列。命令列表本身是从命令分配器上切出来的一块内存。这里有个必须记住的顺序每帧开始时你要先 Reset 命令分配器再 Reset 命令列表然后把两者关联起来之后才开始录命令。顺序反过来调试层会直接报错。更关键的是生命周期。命令分配器在 GPU 还在执行上一帧命令的时候是不能被复用的。所以如果你用双缓冲就得准备至少两个命令分配器每帧轮换。我一开始图省事只建了一个结果跑起来偶尔闪一下、偶尔卡一下查了半天才发现是 CPU 提前复用了 GPU 还在读的内存。这个坑非常典型老教程有时候也讲得不清楚因为它在一个简单例子里确实看不出问题。一个稳妥的帧资源结构大致是这样struct FrameResource { ID3D12CommandAllocator* allocator; UINT64 fenceValue; // 这帧提交时 GPU 会写回的值 }; // 通常准备 2~3 个和交换链的后缓冲数量对应提示命令分配器的数量至少等于后缓冲数量三缓冲就配三个别偷懒用一个去轮所有帧。4.2 一个fence值同步多帧的原理围栏Fence是 CPU 和 GPU 之间的同步点。它的用法是CPU 提交完一帧命令后让 GPU 在完成这帧时把围栏的值更新一下下一帧开始前CPU 检查这个值有没有达到没达到就等。围栏本身只是个 UINT64 计数器GPU 在命令队列执行到某个点时把指定值写进去CPU 通过一个事件句柄等待这个值。我早期最容易犯的错是只用一个全局围栏值导致 CPU 等得太保守帧率上不去。正确的做法是每帧用一个独立的围栏值记录在帧资源里这样 CPU 最多只需要等待最老的那一帧完成而不是每帧都等最新值。这个思路听起来绕但实现很简单提交时给围栏值加一把它记到当前帧资源上等到要复用某个帧资源时才去等那个帧资源对应的围栏值。我实测下来这套每帧一个围栏值的结构跑起来很稳而且几乎感觉不到额外的同步开销。反倒是那种一帧都不等的写法看似快实际会在某些驱动上出现画面撕裂或者资源读写冲突调试层会给你一堆警告。所以别怕等等对了地方性能不会差。5. 交换链到RTV把画面从黑变亮5.1 后缓冲的创建与RTV描述符堆交换链负责把渲染结果送到屏幕上。创建交换链需要先有命令队列它会把 Present 这个操作也当成一条命令。交换链有多个后缓冲常见的是双缓冲或三缓冲每个后缓冲对应一张纹理你要为每张纹理创建一个渲染目标视图RTV。RTV 不是随便存的它要放在描述符堆里。描述符堆是一块 GPU 可见的描述符数组你按顺序往里放 RTV每个 RTV 在堆里有个索引。渲染时你告诉命令列表用堆里第 N 个 RTV 当渲染目标。这里的坑在于描述符堆的容量是创建时定好的你后面想多放一个就得重建整个堆。所以一开始就把容量算宽裕点别卡着用。D3D12_DESCRIPTOR_HEAP_DESC rtvHeapDesc{}; rtvHeapDesc.NumDescriptors SWAP_CHAIN_BUFFER_COUNT; // 例如 3 rtvHeapDesc.Type D3D12_DESCRIPTOR_HEAP_TYPE_RTV; rtvHeapDesc.Flags D3D12_DESCRIPTOR_HEAP_FLAG_NONE; // RTV堆不需要绑定到管线 device-CreateDescriptorHeap(rtvHeapDesc, IID_PPV_ARGS(rtvHeap));注意 RTV 堆不需要设置 SHADER_VISIBLE 标志因为 RTV 是给输出合并阶段用的不是给着色器读的。这一点如果搞混会浪费描述符堆的类型或者报错。5.2 资源状态转换最容易被忽略的性能与正确性开关资源状态是 DX12 里我最想强调的一个概念。每张资源在任何时刻都有一个状态比如 COMMON、RENDER_TARGET、PRESENT、COPY_DEST、PIXEL_SHADER_RESOURCE。渲染之前你得把后缓冲从 PRESENT 转成 RENDER_TARGET渲染完再转回 PRESENT。这个转换通过资源屏障Resource Barrier完成放在命令列表里执行。为什么这个必须显式写因为 GPU 内部对不同状态的资源有不同的处理方式比如压缩格式的存储、缓存策略。状态不对轻则调试层报警告重则画面直接错乱。我整理了一个常用状态对照贴在墙上提醒自己用途目标状态后缓冲作为渲染输出D3D12_RESOURCE_STATE_RENDER_TARGET后缓冲准备显示D3D12_RESOURCE_STATE_PRESENT纹理准备采样D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE上传堆拷贝目的地D3D12_RESOURCE_STATE_COPY_DEST常量缓冲读取D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER我踩过一次很隐蔽的坑把纹理状态写成 GENERIC_READ本意是想省事结果在某些驱动上确实能跑在另一台机器上直接黑屏。后来才知道 GENERIC_READ 这种泛化状态会带来额外的性能开销而且不保证在所有场景都正确。自此之后我老老实实按用途写具体状态再没出过这类问题。6. 根签名与PSO着色器怎么跑起来6.1 根签名的三种参数为什么能省就省根签名描述的是着色器能访问哪些资源、怎么访问。它有三种参数根常量、根描述符、描述符表。根常量最快但容量小适合放几个频繁变化的小参数比如矩阵用的索引、光照参数根描述符能直接指向一张资源但会在根签名里占一大块空间描述符表最灵活能放一组资源但要经过一级间接寻址。我的选择逻辑是能用根常量就用根常量需要临时换的纹理用描述符表资源多的时候用描述符表统一管理。根签名里的空间是有限的2048 字节放太多根描述符会吃满导致后面的参数塞不下。新手常见的做法是全都用根描述符简单直接但一旦资源多了就会撞上限而且性能也不是最优的。// 一个同时描述一张纹理 一个采样器的根签名示例HLSL // 根签名里通常把采样器做成静态采样器 Texture2D gTexture : register(t0); SamplerState gSampler : register(s0);静态采样器是个好东西如果采样器的参数过滤方式、寻址模式在整帧里不变直接编进根签名省掉一个描述符槽位也省一次绑定操作。我大部分时候都用静态采样器只有需要动态切换过滤方式的特殊效果才用动态采样器。6.2 PSO创建失败时该看哪几个字段PSO管线状态对象把所有渲染状态打包成一个对象包括着色器字节码、根签名、输入布局、光栅化状态、混合状态、深度模板状态、渲染目标格式等等。创建 PSO 失败是很常见的因为它校验的字段特别多。我总结了几类最容易出问题的字段第一类是输入布局和顶点着色器输入签名对不上比如着色器里写的是 POSITION输入布局里写成 POSITION0对不上就失败。第二类是渲染目标格式和你 RTV 的格式不一致你把后缓冲设成一种格式PSO 里写的却是另一种必失败。第三类是根签名的可见性或者类型不匹配比如 PSO 期望的根签名和你传的不一致。我的做法是创建 PSO 之后立刻检查 HRESULT配合调试层输出基本一眼就能看出是哪个字段的问题。调试层在 PSO 校验上做得相当细致它会直接告诉你输入布局的某个语义和着色器不匹配这种具体信息。前提还是那句老话——调试层要开着。7. 贴图三角形纹理资源、上传堆与采样器7.1 纹理进入显存的两段式搬运在 DX12 里纹理数据不能直接写进显存。你得先在默认堆里创建一张纹理资源这张资源在显存里但初始内容是未定义的然后在上传堆里准备一块线性内存把 CPU 端的像素数据拷进去最后用命令列表把上传堆的数据拷到默认堆的纹理里。这个过程叫两段式搬运是 DX12 明文要求的绕不过去。这里有个非常容易踩的坑上传堆里的每行像素数据必须按 256 字节对齐D3D12_TEXTURE_DATA_PITCH_ALIGNMENT。也就是说如果你一张纹理宽是 100 像素、每像素 4 字节一行本来是 400 字节但上传堆里这一行要补齐到 512 字节256 的倍数多余的字节是填充拷贝的时候要用正确的行距参数告诉 API。// 用 GetCopyableFootprints 拿到正确的行距和对齐布局 D3D12_PLACED_SUBRESOURCE_FOOTPRINT layout; UINT numRows 0; UINT64 rowSizeInBytes 0; UINT64 totalBytes 0; device-GetCopyableFootprints(texDesc, 0, 1, 0, layout, numRows, rowSizeInBytes, totalBytes); // 按 layout.Footprint.RowPitch 把数据逐行拷进上传堆我见过很多人贴图全黑或者花屏问题就出在这一步要么忘了对齐要么拷贝时行距算错要么拷贝完之后没有把纹理资源从 COPY_DEST 转成 PIXEL_SHADER_RESOURCE。这三个原因占了贴图出问题的绝大多数。7.2 采样器与过滤贴图糊、闪、灰的根源贴图显示出来之后下一个问题往往是图糊了在远处一直闪颜色发灰。这三个现象基本都能从采样器设置里找到答案。图糊通常是没有开 mipmap或者采样器的过滤模式设成了点采样远处闪是 mipmap 生成不完整或者各向异性过滤没开颜色发灰则大概率是色彩空间的问题——你把 sRGB 格式的贴图用线性格式的采样器去读或者反过来。我整理了一张常见现象对照表方便你对照排查现象可能原因处理方向贴图全黑状态没转成 PIXEL_SHADER_RESOURCE检查资源屏障图像错位/斜切行距对齐算错用 GetCopyableFootprints 的行距图糊没生成 mipmap 或过滤设成点采样开启线性过滤生成 mip 链远处闪烁mipmap 缺失或各向异性没开补全 mip 链开各向异性过滤颜色发灰sRGB 与线性格式混用统一贴图与采样格式这些现象我都真实遇到过特别是颜色发灰那个我一度以为是显示器的锅后来才发现是格式不匹配。这类问题调试层不一定报错因为从 API 角度看你的调用完全合法只是结果不对。所以贴图这块除了看报错还得靠肉眼和经验对照表去定位。7.3 贴图显示异常的高频原因对照表这一节我把上面讲的串起来给你一个可以直接照着查的清单。当你贴图贴不上去不要一上来就怀疑着色器先从资源状态查起再查数据搬运最后查采样器和格式。这个顺序基本是从最容易错到最不容易错排的。排查顺序建议第一步确认纹理资源状态在采样前已经被转成了 PIXEL_SHADER_RESOURCE第二步确认上传堆的行距用了 GetCopyableFootprints 返回的值没有自己手算第三步确认拷贝命令在采样命令之前提交且用了正确的拷贝方法第四步确认 SRV 创建时格式和纹理本身格式一致第五步确认采样器绑定的寄存器和着色器里的寄存器对得上第六步确认 UV 坐标没反、采样范围没问题。8. 真实调试全记录三次翻车与排查链路8.1 全黑无报错命令列表没提交还是围栏卡死第一次翻车是画面全黑控制台没有任何错误。我的排查链路是这样的先确认窗口确实创建成功、交换链确实创建成功然后确认每帧命令列表确实被执行了——我在提交命令列表前后打了日志发现日志有输出说明录命令、提交都做了接着我怀疑是围栏等待卡住了把围栏等待逻辑临时改成不等待画面依然黑说明不是同步问题最后我把注意力放到资源状态上发现我渲染完之后忘了把后缓冲从 RENDER_TARGET 转回 PRESENT导致 Present 拿到的是一个状态不对的后缓冲某些驱动上直接黑屏。这次之后我形成了一个习惯只要画面异常先查资源状态再查同步最后查着色器逻辑。因为资源状态和同步的问题往往不报错、不崩溃只是结果不对最消耗时间。而着色器逻辑的问题反而最好查因为画面通常会呈现有规律的花纹而不是纯黑。8.2 贴图全黑状态转换漏了一步第二次翻车是三角形能画出来了但贴图是黑的物体就是一个纯色的三角形。用过之后我发现我把纹理资源和后缓冲用的是同一套状态转换逻辑但漏了对纹理所做的状态转换。后缓冲我转成 RENDER_TARGET 了但纹理资源从头到尾还停在 COPY_DEST 状态采样器读它自然读不到东西。我修的方式是在拷贝完成之后加一道资源屏障把纹理从 COPY_DEST 转成 PIXEL_SHADER_RESOURCE然后才开始绘制。转换点很重要必须在拷贝命令之后、绘制命令之前。如果放在绘制之后或者干脆放在别的命令列表里没同步同样读不到。这次教训让我明白资源状态不是设一次就完事它是一条贯穿整个帧的状态链每一步都得对得上。8.3 花屏与错位行距对齐和UV的问题第三次翻车最像显卡坏了——贴图能显示但是整体斜切、有彩色条纹。我一开始以为是驱动问题换了驱动还是一样才回头查数据搬运。发现我在把像素数据拷进上传堆的时候自己手算了一个行距结果没做 256 字节对齐行与行之间错位采样出来就是斜切和条纹。修复的方式就是用 GetCopyableFootprints 返回的行距逐行拷贝每行拷完跳到正确的行距偏移。改完之后画面立刻正常。这件事给我的最大启示是DX12 里凡是涉及对齐行距偏移的量永远不要手算交给 API 帮你算。手算对齐是新手重灾区因为偏移算错的表现形式千奇百怪很容易误导你往错误方向排查。9. 25集的学习节奏与阶段性自测清单9.1 把25集拆成五个阶段如果把这套内容拆成 25 集我的建议是分成五个阶段来消化每个阶段都要有能跑起来的小成果不要一口气看完再动手。第一阶段是环境与 Device大概第 1 到 4 集目标是能创建出 Device 和调试层能枚举适配器并打印信息。第二阶段是命令队列与同步第 5 到 9 集目标是搭出一个稳定的帧循环骨架能清屏并看到颜色变化。第三阶段是交换链与资源状态第 10 到 14 集目标是能画出第一个三角形。第四阶段是根签名与 PSO第 15 到 19 集目标是把三角形加上变换和颜色。第五阶段是贴图第 20 到 25 集目标是给三角形贴上纹理并处理采样器和格式问题。每个阶段结束都做一次自测别跳过因为后面的内容全都建立在前面的骨架上。9.2 每阶段的自测题第一阶段自测关掉调试层程序还能跑吗重新打开后调试层有没有报警告第二阶段自测把帧数提高有没有出现闪屏或者崩溃围栏等待逻辑改错会不会复现问题第三阶段自测把后缓冲数量从 2 改成 3程序还能正常跑吗资源屏障漏一个会怎样第四阶段自测故意把 PSO 的渲染目标格式写错调试层能不能明确指出问题第五阶段自测换一张尺寸不是 256 倍数的贴图还能正常显示吗mipmap 开了之后远处的闪烁还在吗这些自测题我自己都做过做的时候会暴露很多我以为我懂了但实际没懂的点。比如把后缓冲数量改成 3这个测试我原本只准备了两套帧资源一改就崩逼着我把帧资源管理改成和后缓冲数量挂钩。我个人在实际操作中的体会是DX12 这东西最怕看得懂但写不对。教程讲资源状态你也懂讲围栏你也懂但真正写的时候顺序错一步就黑屏。所以别贪多把每个阶段的骨架亲手敲一遍、跑一遍、故意改错一遍你才算是真的把这个知识点吃进去了。最后再分享一个小技巧把每个阶段能跑通的代码存一个版本后面出问题时能快速回退对比比在脑子里回忆哪里改过要可靠得多。
返回列表