三年前我把一套 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 和专用显存。
ComPtr<IDXGIFactory6> factory; CreateDXGIFactory2(debugFlags, IID_PPV_ARGS(&factory)); ComPtr<IDXGIAdapter4> 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) ComPtr<ID3D12Debug> debugController; if (SUCCEEDED(D3D12GetDebugInterface(IID_PPV_ARGS(&debugController)))) { debugController->EnableDebugLayer(); ComPtr<ID3D12Debug3> 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 并处理行间距
加载贴图我走的是 WIC(Windows 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_cast<void**>(&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_TRANSITION,Transition.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 的配置代码大致是这样:
ComPtr<ID3D12DeviceRemovedExtendedDataSettings1> 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 数量多,而在于它取消了所有的隐式约定。旧接口帮你做的适配器选择、状态管理、同步处理,全部变成你自己要负责的事。好处是你对整条渲染链路的控制力前所未有地强,代价是每一个环节都必须写对,不能靠"应该没问题"。我现在回头看那套让我黑屏三天半的老教程,倒也不觉得它是在误导人,只是它写于一个前提还成立的年代,而那些前提已经悄悄变了。