
三年前我把一套 DX12 的示例代码从旧笔记里翻出来编译一把过窗口也正常弹出来但屏幕是纯黑的——不是那种闪烁一下就出来的黑是从头到尾一动不动。Debug Layer 一声不吭没有警告没有错误GPU 也没挂起。我当时用的是网上流传最广的一套教程发布于 DX12 刚出来那会儿里面的很多写法今天还能编译但跑起来就是不对。后来我把那套代码全部推倒重来从D3D12CreateDevice开始一集一集把 Device、命令队列、交换链、根签名、PSO、贴图采样这些环节重新梳理前后录了 25 集中间碰上过描述符堆类型搞反、屏障漏写、行间距对齐算错、sRGB 标志位设反等一堆问题。这篇就把整条链路摊开讲重点是那些教程里不写、但真正会让你卡住半天的地方。适合已经会写一点 C、想看图形 API 实战但被老教程劝退的人也适合照着抄过一遍却始终没跑出贴图三角形的朋友。1. 老教程还在教的东西为什么今天会直接把你带沟里1.1 三处最要命的过时假设老教程最大的问题不是代码错而是它默认了一些今天已经不成立的前提。第一处是适配器选择。早期教程基本都写EnumAdapters(0, adapter)取第 0 号适配器就用。这个写法在单显卡台式机上碰巧能用但在双显卡笔记本、外接显卡坞、或者带集显的机器上第 0 号很可能不是你跑游戏的那块卡。DXGI 后来提供了IDXGIFactory6::EnumAdapterByGpuPreference可以按高性能或低功耗偏好来挑这才是现在应该用的方式。第二处是调试层的开启时机。老教程经常把D3D12GetDebugInterface写在创建设备之后甚至写在某个初始化函数的末尾结果调试层根本没挂上去后面所有报错都静默。第三处是资源状态。早期资料里还有用D3D12_RESOURCE_STATE_COMMON混过去然后依赖隐式提升的写法显式状态下这个习惯会直接导致渲染结果错乱。这三处单独看都不算大但叠加起来就是编译通过、画面全黑、没有任何报错的经典症状。我第一次栽的就是这个组合拳第 0 号适配器 调试层没开 贴图资源状态没迁移。三个问题互相掩护排查的时候你改一个还是黑的很容易怀疑人生。1.2 系统不支持 DirectX 12这类报错多数时候跟硬件无关很多人在启动别人的程序时见过类似提示第一反应是我的显卡太老了。实际排查下来真正因为硬件不支持的比例没那么高。绝大多数情况落在三类显卡驱动版本过旧运行时组件缺失或版本不匹配以及程序自己选错了适配器。前两类是环境问题跟图形 API 的代码无关第三类才是开发者要负责的。如果你的程序在别人机器上报这个错而你本地跑得好好的先别急着让对方换电脑用CheckFeatureSupport把D3D12_FEATURE_FEATURE_LEVELS查一遍看看实际拿到的特性等级是什么再打印一下所有适配器的名字和显存大小问题通常一眼就出来了。这也是我在第 2 集里专门留了一节讲适配器枚举的原因——这个环节的代码量不到三十行但它决定了后面二十多集能不能顺利往下走。1.3 25 集是怎么切分的整套内容我没有按API 手册目录来组织而是按一条能被验证的渲染链路来切。前四集只做一件事把 Device、命令队列、交换链跑通屏幕上出现纯色清屏。第 5 到第 10 集引入根签名、PSO、顶点缓冲画出第一个没有贴图的三角形。第 11 到第 18 集处理贴图WIC 解码、行间距对齐、上传堆、SRV 描述符堆、采样器、sRGB 标志。第 19 到第 22 集做屏障管理和帧资源轮转。最后三集是调试专题把调试层、DRED、PIX/RenderDoc 三条工具线分别走一遍用真实故障做案例。这么切的理由很简单每一集都要有一个肉眼可见的产出。如果一集里面既讲根签名又讲贴图上传出了问题你根本不知道是哪一层而图形 API 最耗时间的就是这种定位。把粒度压到足够小每集的验收标准就是画面对了没有这是唯一可靠的进度信号。2. Device 不是创建一下就完事适配器、调试层、特性等级的先后手2.1 适配器枚举先把候选列表打出来不管后续怎么选我建议第一步永远是遍历所有适配器并打印信息。用IDXGIFactory6比老版本的IDXGIFactory多了一个能力可以按 GPU 偏好枚举。下面这段是我一直在用的写法控制台会输出每块卡的描述、厂商 ID、设备 ID 和专用显存。ComPtrIDXGIFactory6 factory; CreateDXGIFactory2(debugFlags, IID_PPV_ARGS(factory)); ComPtrIDXGIAdapter4 adapter; for (UINT i 0; factory-EnumAdapterByGpuPreference( i, DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE, IID_PPV_ARGS(adapter)) ! DXGI_ERROR_NOT_FOUND; i) { DXGI_ADAPTER_DESC3 desc{}; adapter-GetDesc3(desc); // 这里把 desc.Description、VendorId、DedicatedVideoMemory 打出来 }DedicatedVideoMemory这一项在双显卡机器上特别有用集显通常是个位数 MB 到几百 MB独显动辄几个 GB一眼就能分出来。挑到目标适配器之后再调D3D12CreateDevice并且把最低特性等级传成D3D_FEATURE_LEVEL_11_0—— 这是画一个贴图三角形的底线不要为了兼容性更好降到 10_x那会丢掉一批你现在就需要的能力。2.2 调试层必须抢在 CreateDevice 之前这是我踩过最亏的一个坑。调试层是一个进程级的开关一旦设备已经创建出来再开就没用了你得把设备销毁重来。所以顺序必须是先D3D12GetDebugInterface拿到ID3D12Debug调EnableDebugLayer()如果想要更强的 GPU 侧校验再拿ID3D12Debug3调SetEnableGPUBasedValidation(TRUE)然后才创建 DXGI 工厂、创建设备。#if defined(_DEBUG) ComPtrID3D12Debug debugController; if (SUCCEEDED(D3D12GetDebugInterface(IID_PPV_ARGS(debugController)))) { debugController-EnableDebugLayer(); ComPtrID3D12Debug3 debugController3; if (SUCCEEDED(debugController.As(debugController3))) { debugController3-SetEnableGPUBasedValidation(TRUE); } } #endifSetEnableGPUBasedValidation会让驱动在 GPU 侧做额外检查性能掉得很明显所以只在排查阶段开。另外创建工厂时记得带上DXGI_CREATE_FACTORY_DEBUG标志否则 DXGI 自己的问题不会报出来。还有一点值得单独说调D3D12GetDebugInterface需要系统里装了图形调试组件有些精简安装的环境没有这时它返回失败很多人直接忽略结果就是以为我开了调试层。加一行日志确认返回值能省掉后面两小时。2.3 特性等级和特性查询是两件事D3D12CreateDevice的第二个参数是最低特性等级它成立只代表设备创建成功不代表你需要的所有能力都在。真正要确认的是CheckFeatureSupport它的用法是按不同的D3D12_FEATURE_*枚举填不同的结构体。我在项目里固定查这几项查询项用途不查会怎样D3D12_FEATURE_FEATURE_LEVELS拿到实际支持的最高等级用了不支持的能力运行时才炸D3D12_FEATURE_D3D12_OPTIONS资源绑定层级、常量缓冲对齐描述符表设计选错方案D3D12_FEATURE_ARCHITECTURE描述符堆的对齐和步长手工算句柄时偏移出错D3D12_FEATURE_ROOT_SIGNATURE根签名版本支持情况用了 1.1 特性但在旧驱动上失败最后一行说的手工算句柄偏移是真实会踩的。描述符堆里每个描述符占多大不同硬件可能不同必须通过D3D12_FEATURE_ARCHITECTURE拿到的信息去算或者干脆用GetCPUDescriptorHandleForHeapStart配合GetDescriptorHandleIncrementSize来推导。我见过有人直接按固定大小加偏移在自家机器上跑得挺好换台机器描述符全错位画面就是一片花。3. 命令三件套的绑定关系决定了你能不能多帧并行3.1 Allocator 不能跨帧复用命令分配器ID3D12CommandAllocator保存着命令列表记录期间用到的内存。它的核心规则是只有当 GPU 已经执行完某条命令列表、且该列表已经关闭之后你才能重置这个分配器。最稳的做法是每帧一个分配器配合帧资源数组轮转。我一般开 2 到 3 帧帧数和交换链的后台缓冲数量对齐。这里有个容易忽略的细节分配器和命令列表是一对一的从属关系。一个命令列表在记录时绑定了一个分配器Reset的时候必须传同一个分配器否则调试层会直接报错。这条规则不难但因为它是静默的第一次遇到时容易想不通为什么什么都没改就报错了。3.2 Reset 的顺序错了会静默丢命令正确顺序是先等这一帧的 fence确认 GPU 用完了再allocator-Reset()再commandList-Reset(allocator, pso)然后开始记录最后Close()。很多人把Reset写在 fence 等待之前本地跑没事因为 GPU 恰好执行得比你记录得快一旦加上贴图上传这种耗时操作GPU 落后了分配器被提前重置结果就是这一帧的命令部分丢失或者干脆崩掉而且报错信息往往指向别的地方。命令列表还有一个特性Reset之后的初次状态是未定义所以每次都要重新设置所有的管线状态。别指望上一帧设过的SetPipelineState还在也别指望根签名还在。我习惯把设置视口、设置裁剪矩形、设置根签名、设置 PSO这四步固定写在每帧记录的开头宁可多写四行也不要想这次应该还用着。3.3 Fence 是唯一可靠的 CPU-GPU 同步原语ID3D12Fence的用法就三步Signal、SetEventOnCompletion、WaitForSingleObject。它比SetEventOnMultipleFenceCompletion之类的组合更常用也更不容易出错。我的做法是维护一个帧标记数组每帧提交完调queue-Signal(fence, frameValue)等到下一轮要复用这一帧资源时先判断fence-GetCompletedValue() frameValue[i]没到就等。if (fence-GetCompletedValue() frameFenceValues[frameIndex]) { fence-SetEventOnCompletion(frameFenceValues[frameIndex], fenceEvent); WaitForSingleObject(fenceEvent, INFINITE); }有一个坑必须点名INFINITE等待在 GPU 挂起时会永久卡死你的程序看起来卡在启动界面。所以我建议加一个超时值比如 2000 毫秒超时了就打印当前 fence 完成值并主动报错退出。这样你至少知道问题在同步上而不是对着一个无响应的窗口发呆。4. 交换链的节奏感Present、后台缓冲与撕裂的那点事4.1 BufferCount 和 FLIP 模型的选择现代交换链应该用DXGI_SWAP_EFFECT_FLIP_DISCARD。这个模型有两个实际影响第一它要求BufferCount至少是 2第二它不允许你再对后台缓冲做 GDI 操作渲染必须在交换链拿到的那个资源上做。我在教程里固定用 3 个缓冲理由是三缓冲可以在等待水平同步的时候多留一帧的余量帧率曲线更平。如果你要做可变刷新率下的无撕裂表现还需要DXGI_SWAP_CHAIN_FLAG_ALLOW_TEARING标志并且 Present 时传DXGI_PRESENT_ALLOW_TEARING同时关掉垂直同步。这几个标志位必须成对出现只设置一半是没有任何效果的。DXGI_SWAP_CHAIN_DESC1里还有一项Scaling窗口和缓冲尺寸不一致时会用到默认填DXGI_SCALING_STRETCH即可。我的习惯是把缓冲尺寸直接跟随窗口客户区大小窗口尺寸变化时走ResizeBuffers重建全部后台缓冲的 RTV而不是试图拉伸。4.2 每帧重绑 RTV 到底有没有必要老教程里常见一句每帧都要OMSetRenderTargets这个说法在 FLIP 模型下依然成立原因不是描述符失效而是当前后台缓冲的索引是变化的。用Present之后GetCurrentBackBufferIndex()拿到的索引会变你必须按索引去取对应的 RTV 描述符。一个小优化是如果这一帧的索引和上一帧相同理论上可以跳过重绑但我不建议这么做。第一收益微乎其微第二跳过之后如果某帧索引变了而你忘了重绑就会出现偶发画面跑到别的缓冲上的诡异现象排查成本远大于省下的那点开销。后台缓冲的 RTV 描述符我习惯在初始化时一次性全部创建好放在一个专门的 RTV 堆里后面只做索引切换。堆大小按BufferCount分配不用动态增长这样句柄也稳定调试时对照起来方便。5. Root Signature 与 PSO把状态烧录成一次提交5.1 根参数该怎么选根签名是着色器和资源之间的契约它规定了 GPU 在着色阶段能看到哪些东西。根参数有三类根常量、根描述符、描述符表。选择原则可以简化成一句话变化频率越高、越小的数据用根常量单个资源用根描述符成组资源用描述符表。我的贴图三角形项目里只有六个参数一个 32 位根常量用于传入变换矩阵的索引一个 CBV 指向常量缓冲一个 SRV 指向贴图再加一个静态采样器。这么设计是因为常量缓冲和贴图在每个绘制调用里都可能不同但对这个小项目来说总量就是一组没必要上描述符表增加复杂度。静态采样器我强烈建议写在根签名里而不是丢进采样器堆。好处有两个一是不用管理采样器堆和它的句柄复制二是在调试工具里看根签名的时候采样器状态一目了然。写法上就是填一个D3D12_STATIC_SAMPLER_DESC过滤方式选D3D12_FILTER_MIN_MAG_MIP_LINEAR寻址模式用D3D12_TEXTURE_ADDRESS_MODE_WRAP最大各向异性设 1比较函数关掉。5.2 PSO 的字段清单和验证成本D3D12_GRAPHICS_PIPELINE_STATE_DESC字段非常多第一次填很容易漏。我按逻辑分成四组来填着色器组VS、PS 字节码、输入布局组输入元素数组和InputLayout、光栅化组RasterizerState、SampleDesc、NodeMask、输出组RTV 格式、DSV 格式、BlendState、DepthStencilState、PrimitiveTopologyType。每组填完之后我会在调试层开着的情况下跑一次因为 PSO 是校验最严格的环节格式对不上会立刻报出来。关于 PSO 有一个实际经验把 PSO 创建放在初始化阶段完成后面绘制时只调SetPipelineState。很多人会想在每帧创建 PSO这在 DX12 里是很贵的操作驱动内部要做状态编译和缓存查找帧率会掉得非常明显。这也是 DX12 和老接口在设计思路上的根本差别——它把状态切换的压力前置到了初始化阶段。6. 贴图三角形顶点、UV、SRV 三件套的落地细节6.1 顶点缓冲与输入布局要对得上画三角形最少三个顶点每个顶点我带三个 float 的位置和两个 float 的 UV。顶点缓冲的创建走标准流程建一个D3D12_HEAP_TYPE_UPLOAD的资源用Map把数据写进去Unmap然后填D3D12_VERTEX_BUFFER_VIEW。这里有个细节值得说上传堆的资源在 CPU 侧可以持续访问但如果你在 GPU 可能读取的时候反复Map/Unmap会触发额外的同步开销。对于顶点数据这种初始化之后就不变的资源写一次就够了。输入布局里的SemanticName和SemanticIndex必须和 HLSL 里的语义完全对应Format必须和顶点结构体的成员类型匹配InputSlot、InputSlotClass、InstanceDataStepRate三个字段按默认值填分别是 0、D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA、0。最后一个字段容易忘忘了会报输入布局无效而错误信息不会直说就是这个字段的问题。6.2 用 WIC 解码 PNG 并处理行间距加载贴图我走的是 WICWindows Imaging Component解码到 32 位 RGBA然后上传。这一步最容易被低估的是行间距对齐。DX12 要求上传缓冲里每一行像素的起始地址必须按 256 字节对齐也就是D3D12_TEXTURE_DATA_PITCH_ALIGNMENT。如果图片宽度是 100 像素、每像素 4 字节一行实际是 400 字节但对齐后要占 512 字节。你必须按对齐后的行间距往上传缓冲里逐行拷贝而不是整块 memcpy。const UINT srcRowPitch width * 4; const UINT dstRowPitch (srcRowPitch D3D12_TEXTURE_DATA_PITCH_ALIGNMENT - 1) ~(D3D12_TEXTURE_DATA_PITCH_ALIGNMENT - 1); const UINT uploadSize dstRowPitch * height; BYTE* dst nullptr; uploadBuffer-Map(0, nullptr, reinterpret_castvoid**(dst)); for (UINT y 0; y height; y) { memcpy(dst y * dstRowPitch, src y * srcRowPitch, srcRowPitch); } uploadBuffer-Unmap(0, nullptr);我见过不少人用GetCopyableFootprints拿到Footprint.RowPitch之后仍然按原始行宽拷贝结果图片出现斜切或者半边错位。现象很有辨识度贴图整体看着像被推歪了。遇到这种画面第一反应就该去检查这一行代码。拷贝完成之后贴图资源要从COPY_DEST状态切到PIXEL_SHADER_RESOURCE这一步放到下一节细说。6.3 sRGB 不是顺手开个选项贴图发灰的根因在这里这是我在整个项目里被问得最多的问题之一表现形式是贴图在别的软件里看着颜色正常一进自己的渲染器就发灰、发亮、或者整体偏暗。根因在于颜色空间美术产出的图片一般是 sRGB 编码的而光照计算必须在线性空间里做。如果你的 SRV 用DXGI_FORMAT_R8G8B8A8_UNORM去采样一张 sRGB 图片采样回来的就是 sRGB 编码值直接拿去参与计算结果一定偏亮偏灰。正确做法是把 SRV 的格式设成DXGI_FORMAT_R8G8B8A8_UNORM_SRGB让硬件在采样时自动做解码。这里有个必须记住的边界法线贴图、粗糙度贴图、金属度贴图这类数据贴图绝对不能设成 sRGB 格式因为它们的数值代表的是几何或材质参数不是颜色一旦被伽马解码就全错了。画面上表现为法线方向乱、高光位置飘。我习惯在加载函数里显式传一个bool isColorTexture参数由调用方决定格式避免默认值把数据贴图带错。贴图类型SRV 格式说明漫反射、自发光_UNORM_SRGB硬件自动解码到线性法线、粗糙度、金属度_UNORM数据贴图不做伽马处理掩码、查找表_UNORM按数值语义使用另外提醒一句如果同一份贴图既要用作颜色又要用作数据不要共享同一个 SRV 描述符复制两份分别设格式。描述符本身很小不值得为省这一个而引入难以定位的颜色问题。6.4 描述符堆的类型一定不能搞反D3D12_DESCRIPTOR_HEAP_TYPE有 CBV/SRV/UAV、采样器、RTV、DSV 四种。最常见的错误是把贴图的 SRV 放进 RTV 堆或者把 RTV 放进 CBV/SRV/UAV 堆。这两个堆在驱动层面是完全不同的东西放错了调试层会明确报错但如果你没开调试层表现就是设备创建成功、绘制没反应。堆的标志位也要注意着色器读取的描述符堆必须设D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE而 RTV、DSV 堆不能设这个标志。这两个规则记反的情况我见过好几次。堆里的句柄获取方式是heap-GetCPUDescriptorHandleForHeapStart()拿到 CPU 侧起点heap-GetGPUDescriptorHandleForHeapStart()拿到 GPU 侧起点然后按GetDescriptorHandleIncrementSize算出的步长加偏移。CPU 句柄用于创建资源时写入描述符GPU 句柄用于SetGraphicsRootDescriptorTable或者SetGraphicsRootDescriptor。两边必须指向同一个描述符这是我核对时最常看的两个值。7. 资源屏障贴图三角形里最容易漏、也最难查的一步7.1 Barrier 的作用到底是什么屏障解决的是同一块资源被不同用途访问时的可见性和顺序问题。贴图从上传缓冲拷贝过来GPU 是在拷贝引擎上写的之后像素着色器要去读它这是在渲染阶段。这两个操作如果不加屏障GPU 完全可以并行执行或者读到的还是旧内容。屏障的语义就是告诉 GPU在这条屏障之前对这块资源的所有写入必须完成并对后续阶段可见之后才能按新的状态访问。我第一次写的时候觉得这一步看起来像仪式删掉之后在自家机器上跑得挺好因为驱动恰好按顺序执行了。换到另一台机器上画面就变成纯黑。这类本地能跑、别人机器不行的问题八成出在屏障上。7.2 从 COPY_DEST 到 PIXEL_SHADER_RESOURCE 的完整链条贴图的资源状态流转是固定的三步创建时声明为D3D12_RESOURCE_STATE_COPY_DEST拷贝完成之后转换成D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE然后才能在像素着色器里采样。转换的写法是填一个D3D12_RESOURCE_BARRIER类型选D3D12_RESOURCE_BARRIER_TYPE_TRANSITIONTransition.pResource指向贴图资源StateBefore和StateAfter分别填新旧状态Subresource填D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES。D3D12_RESOURCE_BARRIER barrier{}; barrier.Type D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; barrier.Flags D3D12_RESOURCE_BARRIER_FLAG_NONE; barrier.Transition.pResource texture.Get(); barrier.Transition.StateBefore D3D12_RESOURCE_STATE_COPY_DEST; barrier.Transition.StateAfter D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; barrier.Transition.Subresource D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; commandList-ResourceBarrier(1, barrier);一个很实用的技巧调试层开着的时候如果你忘了写这条屏障调试层通常会给一条资源处于错误状态的警告。但如果你把资源声明成了D3D12_RESOURCE_STATE_COMMON并依赖隐式提升这条警告就不会出现问题被藏起来了。所以我一直建议在调试阶段全部用显式状态把隐式提升当不存在。等完全跑通、性能剖析做完之后再考虑要不要用隐式提升省几条屏障。还有一个容易忘的地方后台缓冲在Present之前要转成D3D12_RESOURCE_STATE_PRESENT下一帧拿来当前 RTV 之前要转回D3D12_RESOURCE_STATE_RENDER_TARGET。这两条屏障是每个帧循环都要写的漏掉的话表现是画面偶尔闪烁或者第一帧正常后面全黑。我在帧循环里把这两条屏障写在固定的位置紧贴着OMSetRenderTargets前后这样结构上一眼就能看出有没有漏。8. 三个真实调试现场从黑屏到设备移除的完整排查链路8.1 现场一画面全黑但没有任何警告症状是窗口出来了、清屏颜色也不对连清屏色都看不到调试层没有任何输出fence 正常完成。排查链路我是这么走的第一步把清屏颜色改成亮品红如果连品红都没看到说明问题在OMSetRenderTargets或者 RTV 描述符本身跟三角形无关。结果确实是全黑说明卡在这一层。第二步检查 RTV 堆的创建标志发现我误设了SHADER_VISIBLE。这个标志本身不会导致失败所以没有报错。第三步检查OMSetRenderTargets传的句柄发现我用的是 CPU 句柄而不是 GPU 句柄。改回来之后品红出现了。第四步再往下走三角形还是没出现继续查顶点缓冲视图、PSO 的拓扑类型、视口的宽高。最后发现视口的MaxDepth传成了 0应该是 1深度裁剪把三角形全部裁掉了。这个过程说明一件事全黑类问题的排查必须分层先确认能不能清屏再确认三角形有没有提交最后确认三角形有没有被裁掉。用颜色做标记是最省事的手段比读代码快得多。8.2 现场二贴图采样结果全是纯黑或者纯白这类问题的排查我是按描述符 → 采样器 → 资源状态 → 格式的顺序来的。第一步用 PIX 抓一帧看 SRV 描述符指向的资源地址是不是空的。如果是空问题在描述符创建阶段通常是没在CreateShaderResourceView时传对资源指针。第二步检查采样器的寻址模式和过滤方式静态采样器写错了会让采样结果落在纹理外部返回边界色。第三步看资源状态如果贴图还在COPY_DEST状态采样结果是未定义的表现出来经常是纯黑。第四步检查格式的 sRGB 设置数据贴图被设成了_SRGB会整体偏暗颜色贴图没设_SRGB会整体偏亮。这四步走完我遇到的绝大多数贴图问题都能定位。顺带说一个真实场景里的类似现象有人在建模软件里看贴图完全正常导进引擎就报错。这类问题的根因往往不在渲染代码而在贴图本身——比如通道打包方式不一致、缺少 mip 链、尺寸不是 2 的幂、或者格式不被目标平台支持。图形 API 这一层能做的事是加载时统一检查尺寸、统一做通道转换、统一生成 mip。把这几个统一放在加载函数里做比在每个材质上单独处理可靠得多。8.3 现场三GPU 挂起与设备移除设备移除是最难查的一类因为报错信息往往只有一个DXGI_ERROR_DEVICE_REMOVED看不出原因。我的处理流程是三步。第一步第一时间调GetDeviceRemovedReason()把 HRESULT 打出来。第二步打开 DRED。需要做两件事在创建设备之前通过ID3D12DeviceRemovedExtendedDataSettings打开自动面包屑和页面错误调试然后在设备被移除之后通过QueryInterface拿到ID3D12DeviceRemovedExtendedData调GetAutoBreadcrumbsOutput和GetPageFaultAllocationOutput。前者告诉你最后一个完成的和第一个未完成的操作分别是什么后者告诉你哪块资源被访问了但是已经被释放。这两个信息基本能定位到具体的绘制调用。第三步是用事件标记缩小范围。BeginEvent和EndEvent这一对 API 在 PIX 里会显示成彩色区间把帧循环按阶段打上标记之后出问题的区间一眼可见。我习惯在每帧的固定位置打六到八个标记开始记录、设置管线状态、上传贴图、绘制、屏障、Present。数量不用多能把阶段分开就够了。DRED 的配置代码大致是这样ComPtrID3D12DeviceRemovedExtendedDataSettings1 dredSettings; if (SUCCEEDED(D3D12GetDebugInterface(IID_PPV_ARGS(dredSettings)))) { dredSettings-SetAutoBreadcrumbsEnablement(D3D12_DRED_ENABLEMENT_FORCED_ON); dredSettings-SetPageFaultEnablement(D3D12_DRED_ENABLEMENT_FORCED_ON); }注意这段必须在创建设备之前执行否则配置不生效这一点和调试层的开启时机是完全一样的道理。我一开始把 DRED 配置写在了设备创建之后抓了两次挂起都没有输出白白浪费了一个下午。9. 25 集的推进节奏和我自己踩过的几条经验9.1 每集的验收方式和节奏安排我的节奏是每集一个可验证产出验收标准分三级能编译、能跑起来不崩、画面对。三级的顺序不能颠倒因为图形程序最容易出现编译通过、跑起来不崩、画面全黑的情况如果一开始就盯着画面很容易在编译期问题上浪费精力。每集结束之后我会把代码打一个 tag这样后面哪一集引入的问题通过对比 tag 就能快速二分定位。这个习惯在我处理屏障问题时救了我不止一次——三次提交之间做二分比一行行读代码快得多。时间上前四集的 Device、命令队列、交换链大约需要两到三个晚上中间六集的管线状态和三角形大约要一周贴图部分因为涉及 WIC 和对齐时间会更长一些。如果你的时间有限我建议把贴图部分拆成两次做第一次只上传一张纯色棋盘格确认采样链路通了第二次再换成真实美术资源。棋盘格的好处是它的错位、缩放、颜色空间问题全部可见比真实贴图更容易看出异常。9.2 几条让我少走弯路的经验第一条调试层和 DRED 永远在创建设备之前打开这个是硬性顺序没有例外。第二条所有资源状态显式写不要依赖隐式提升哪怕多写几行屏障。第三条上传类资源在初始化阶段一次写完不要每帧Map。第四条描述符堆按用途分开创建别为了省堆数量混在一起类型混乱带来的问题非常难查。第五条fence 等待一定要加超时加超时的成本是两行代码收益是你能知道程序卡在哪。第六条加载贴图时把尺寸检查、通道转换、mip 生成这几件事统一放在一个函数里别散落在各处。第七条遇到本地能跑、别的机器不行的问题优先怀疑屏障和描述符句柄这两处它们最容易受驱动和硬件影响。至于工具的选择我的分工是调试层和 DRED 负责定位崩溃和设备移除PIX 负责看资源和绘制调用的细节RenderDoc 作为交叉验证的备用手段。三个工具不要同时用同时挂上去会互相干扰而且在 GPU 侧的开销叠加起来会让问题现象变形。我一般先用调试层缩小范围锁定到具体某一帧或某一个绘制调用之后再上 PIX 抓帧。25 集录完之后我把整套代码重新读了一遍感触最深的是DX12 难的地方不在于 API 数量多而在于它取消了所有的隐式约定。旧接口帮你做的适配器选择、状态管理、同步处理全部变成你自己要负责的事。好处是你对整条渲染链路的控制力前所未有地强代价是每一个环节都必须写对不能靠应该没问题。我现在回头看那套让我黑屏三天半的老教程倒也不觉得它是在误导人只是它写于一个前提还成立的年代而那些前提已经悄悄变了。