两年前我把网上流传最广的那套 DirectX 12 入门教程从头到尾抄了一遍:编译通过,窗口弹出来,三角形也确实画出来了。但从那之后整整三个星期,我卡在同一堆 Validation Error 里出不来——改分辨率黑屏、贴图加载出来一片漆黑、偶尔整个设备直接掉线。问题不在于我手笨,而在于那批教程大多写于 2016 到 2018 年,用的还是老 SDK 的写法,配套的调试手段基本没讲。DX12 这套 API 的麻烦从来不是"怎么把三角形画出来",而是"出错之后你怎么知道错在哪"。这篇就把我自己重新整理的 25 集路线摊开讲,从 Device 初始化一路走到带贴图的三角形,中间那些报错、误判和绕路,全部按当时的真实情况写下来。
1. 为什么"照着老教程抄完就能跑"这个假设在 DX12 上不成立
老教程的代码本身没错,错的是它默认了一套已经变化的上下文。那批文章写作时,Windows SDK 里的 D3DX12 辅助头文件还标着实验性,没有 Agility SDK 这种把运行时分发和系统版本解耦的机制,资源屏障只有一种写法,着色器编译还在用 fxc。你今天拿同样的代码在新环境里跑,能编译不代表能跑对,跑对不代表跑得稳。
更关键的是信息完整度的问题。为了控制篇幅,老的入门文章往往把 SwapChain 创建、命令列表录制、屏障切换、Present 这几件事塞进一个函数里,中间几乎不做错误检查,也不打开调试层。这在"教学 demo"的语境下可以理解,但它给你留下的后遗症是:一旦出问题,你手上没有任何观测工具,只能靠猜。我当初就是靠猜,猜了两周。
1.1 我复现的三类典型翻车现场
第一类是"能跑但一改就崩"。最常见的触发点是窗口尺寸变化。老代码里 SwapChain 的 ResizeBuffers 之前忘记把所有引用了后台缓冲区的资源释放掉,或者没有把后台缓冲区切回 COMMON 状态,于是 Present 直接失败。还有一种是全屏切换之后 Device Removed,日志里只有一行 DXGI_ERROR_DEVICE_REMOVED,什么线索都没有。
第二类是"报错信息读不懂"。DX12 的调试层会输出大量信息,比如资源状态不匹配、描述符堆没有绑定、根签名与 PSO 不兼容。这些错误的字面意思其实很清楚,但如果教程里从来没提过资源状态机、没提过描述符堆的概念,你看到的就是一串无意义的英文。我当时甚至怀疑是显卡驱动的问题,重装了三次驱动。
第三类是"贴图加载出来颜色不对"。这个坑最深,因为它不报错。图片显示出来了,只是发灰、偏暗、或者边缘有明显的锯齿。背后通常是三个原因里的一到三个:sRGB 格式没配对、Mip 链没生成导致远处采样噪点、采样器用了点采样。老教程里贴图部分经常一笔带过,直接调用一个封装好的函数就完事。
1.2 25 集的路线图:按"最短能跑通"而不是按文档顺序排
我把这 25 集重新排了一遍,原则是先用最短路径把画面点亮,然后再回头补每一层的细节。顺序大致是这样:
| 集数区间 | 主题 | 这一集真正解决的问题 |
|---|---|---|
| 1 - 4 | 环境与工具链 | 统一 SDK、Agility SDK 分发、DXC 编译链,把"能编译"这件事先做扎实 |
| 5 - 8 | Adapter 与 Device | 选卡、Feature Level 取舍、打开调试层与 DRED |
| 9 - 12 | 队列、分配器与命令列表 | 理解三者生命周期,建立帧同步骨架 |
| 13 - 15 | 资源与屏障 | 把状态机变成肌肉记忆,处理上传与呈现 |
| 16 - 18 | 描述符堆与视图 | CBV/SRV/UAV/Sampler 的分配策略 |
| 19 - 21 | 根签名与 PSO | 把管线状态打包成对象,理解 64 DWORD 预算 |
| 22 - 24 | 贴图三角形 | 纹理上传、顶点布局、采样与色彩空间 |
| 25 | 调试与性能 | 用 DRED 和 GPU 抓帧工具定位真实故障 |
这张表的用法是:如果你已经会画纯色三角形,直接从第 13 集开始补;如果你连窗口都还没出来,老老实实从第 1 集走。别跳,DX12 的每一层都依赖上一层建立的概念,跳着看只会在后面付出双倍时间。
2. Device 这一层到底在管什么,初始化顺序为什么不能乱
很多人把 Device 当成"显卡句柄",创建完就丢在一边。实际上在 DX12 里,Device 是资源创建、内存分配、PSO 编译的统一入口,几乎所有对象都从它派生。它不负责提交命令,也不负责呈现,那是队列和交换链的事。把这个边界搞清楚,后面看代码会顺畅很多。
初始化的顺序不能乱,大致是:创建 Factory,枚举 Adapter,挑一个,创建 Device,检查能力支持,最后创建队列。中间的每一步都可能失败,而且失败原因差别很大。比如 Adapter 枚举为空,通常是运行环境没有可用的图形设备;Device 创建返回 E_INVALIDARG,多半是 Feature Level 参数给错了。
2.1 从 DXGI Factory 到 Adapter:选卡这件事比想象中麻烦
在多显卡机器上,系统默认给的不一定是你想要的那块。以前大家习惯用 EnumAdapters 挨个查显存大小,现在更省事的做法是用 EnumAdapterByGpuPreference 直接按性能偏好要一块:
ComPtr<IDXGIFactory6> factory; CreateDXGIFactory2(0, IID_PPV_ARGS(&factory)); ComPtr<IDXGIAdapter4> adapter; factory->EnumAdapterByGpuPreference( 0, DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE, IID_PPV_ARGS(&adapter));这里有个容易被忽略的点:EnumAdapterByGpuPreference 需要较新的系统支持,如果你的目标环境比较杂,还是要写一套回退逻辑,在失败时退回 EnumAdapters1 的循环。另外,如果你在做多显卡协同(比如集显渲染 UI、独显渲染场景),选卡策略要提前定,别等到后面发现资源跨卡创建失败再返工。
我自己的做法是:在枚举阶段就打印每块卡的名称、专用显存、是否为软件适配器,输出到日志里。这样部署到别人的机器上,出问题的第一手资料就直接有了。这一步大概花十行代码,但帮我省过好几次远程排查。
2.2 D3D12CreateDevice 与 Feature Level:别把 12_0 当成"只要能跑就行"的开关
最基础的一行是:
HRESULT hr = D3D12CreateDevice( adapter.Get(), D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&device));传 11_0 是最保守的选择,兼容性最好。但如果你打算用 DirectX 光线追踪、网格着色器、可变速率着色这些能力,就需要更高的特性等级。区别大概是:11_0/11_1 覆盖绝大部分老硬件的基础能力,12_0 引入更细的资源绑定和堆管理,12_1 带来保守光栅化等特性,12_2 才把光线追踪 1.1、网格着色器、采样器反馈这一整套收进来。
决定之前先查能力,别猜。用 CheckFeatureSupport 把几个关键项读出来,比如资源绑定层级、描述符堆的最大数量、是否支持增强屏障。这些值会直接影响你后面描述符堆的分配策略——如果你按层级 1 的容量去规划,跑在层级 3 的卡上就是浪费;反过来则可能直接创建失败。
D3D12_FEATURE_DATA_D3D12_OPTIONS options{}; device->CheckFeatureSupport( D3D12_FEATURE_D3D12_OPTIONS, &options, sizeof(options));我的经验是:把特性检测做成一个独立的启动阶段,结果缓存起来,所有后续模块都从这个缓存里读能力,而不是各自去查。这样将来要在低配机器上降级,只改一处。
2.3 Debug Layer 与 DRED:先装摄像头,再上路
这一步是整篇文章里我认为最重要的一条建议:在写第一行渲染代码之前,先把调试层和 DRED 打开。不是"等出问题再开",因为很多错误在调试层关闭时根本不会报出来,等你发现画面不对的时候,现场已经没了。
ComPtr<ID3D12Debug> debug; if (SUCCEEDED(D3D12GetDebugInterface(IID_PPV_ARGS(&debug)))) { debug->EnableDebugLayer(); }DRED 则是设备掉线之后唯一能给你留线索的东西。它分两块:自动面包屑,记录掉线前完成到哪个标记;页面错误,记录访问了哪块不该访问的内存。开启方式是在创建 Device 之前设置:
ComPtr<ID3D12DeviceRemovedExtendedDataSettings> dred; D3D12GetDebugInterface(IID_PPV_ARGS(&dred)); dred->SetAutoBreadcrumbsEnablement(D3D12_DRED_ENABLEMENT_FORCED_ON); dred->SetPageFaultEnablement(D3D12_DRED_ENABLEMENT_FORCED_ON);注意:调试层会显著降低运行速度,也会常驻一部分显存。发布构建里必须关掉,开发构建里必须开。我的做法是用两个不同的构建配置,靠预处理宏控制,而不是手工改代码。
还有一点,调试层的输出是有等级的,默认可能过滤掉一部分。把 Info Queue 的断点级别调成 CORRUPTION 和 ERROR,让它直接在弹错的地方断下来,比事后翻日志效率高得多。这个设置我在前几集里反复强调过,因为太多人是在画面全黑的时候才第一次打开调试层,那时候什么信息都拿不到。
3. 命令队列、同步与资源状态:DX12 真正的门槛不在画三角形
画三角形本身只有几十行代码,真正的认知成本在三个东西上:命令的生成方式、CPU 与 GPU 的同步方式、资源在 GPU 眼中的状态。这三件事在 DX11 里由驱动帮你处理,在 DX12 里全部交给你。这不是 API 设计者故意为难人,而是为了让你能做精细化控制——代价是你得先理解它。
先说命令的生成。DX12 走的是"录制-提交"模型:你先在 CPU 上把要做的事记录进一个命令列表,再整块提交给队列。这个模型的好处是可以并行录制多个列表,坏处是你不能中途改主意,所有状态必须在录制时就确定。
3.1 CommandQueue / Allocator / List 三件套的职责边界
三者的关系经常被搞混。队列是执行者,负责把提交上来的命令真正送给 GPU;分配器是内存池,命令列表录制时产生的数据存在它里面;命令列表是记录本,本身不含存储,只是往分配器上写。
由此推出三条硬规则:第一,分配器在被引用的命令列表执行完成之前不能重置,否则 GPU 还在读的内存被你改写了,后果是随机花屏或者直接掉线;第二,一条命令列表同一时间只能处于录制状态,不能重入;第三,一帧里如果有多个线程录制,每个线程要有自己独立的分配器和列表。
最常见的写法是每帧一个分配器,配一个围栏值。等在 CPU 上确认上一帧的围栏已经过了,才重置这一帧的分配器。这个模式在多帧并行时也会出问题,如果你的 CPU 跑得比 GPU 快很多,帧数一多,就会出现"第 N 帧重置了分配器,而第 N-2 帧的列表还在跑"的情况。所以围栏值一定要跟着帧资源走,不能只判断一次。
3.2 Fence 与帧同步:双缓冲、三缓冲到底选哪个
围栏是 CPU 侧唯一的同步手段。基本流程是:GPU 每完成一帧就 Signal 一个递增值,CPU 侧在需要等待时用一个事件对象挂上去。
const UINT64 fenceValue = ++m_fenceValue; m_queue->Signal(m_fence.Get(), fenceValue); if (m_fence->GetCompletedValue() < fenceValue) { m_fence->SetEventOnCompletion(fenceValue, m_fenceEvent); WaitForSingleObject(m_fenceEvent, INFINITE); }双缓冲还是三缓冲,取决于你的 CPU 帧时间和 GPU 帧时间谁更长。双缓冲意味着 CPU 在第 N+1 帧录制时必须等第 N 帧执行完,留给 CPU 的重叠空间只有一帧;三缓冲给两帧。如果你的 CPU 逻辑很重(比如有大量场景遍历、动画更新),三缓冲能明显减少卡顿。代价是多一份帧资源的内存,以及一帧的输入延迟。
提示:输入延迟在一帧左右普通人感觉不到,但在需要快速响应的场景里(比如拖拽、笔刷绘制)会很明显。这种场景下宁可退回双缓冲,或者把同步点做得更细,而不是无脑加缓冲数。
还有一个坑:不要在每帧的循环里都做一次完整的等待,那等于把并行度全部抹掉。正确的做法是"限流等待"——只在帧资源用尽时才等,也就是等到最老的那一帧完成。我的习惯是在帧开始时检查当前帧索引对应的围栏值是否已过期,只在过期时才阻塞。
3.3 Resource Barrier:一张必须背下来的状态表
资源屏障是 DX12 里出错最集中的地方,没有之一。核心规则很简单:GPU 访问资源时,资源的当前状态必须和这次访问的类型匹配。渲染目标写入前必须是 RENDER_TARGET,作为纹理采样前必须是 PIXEL_SHADER_RESOURCE(或更通用的 NON_PIXEL_SHADER_RESOURCE),拷贝的目标必须是 COPY_DEST。
我把最常用的几条转换整理成表,日常基本够用:
| 使用场景 | 转换前状态 | 转换后状态 |
|---|---|---|
| 上传数据到默认堆纹理 | COPY_DEST | PIXEL_SHADER_RESOURCE |
| 开始渲染到后台缓冲区 | PRESENT | RENDER_TARGET |
| 结束渲染准备呈现 | RENDER_TARGET | PRESENT |
| 顶点缓冲首次使用 | COMMON | VERTEX_AND_CONSTANT_BUFFER |
| 索引缓冲首次使用 | COMMON | INDEX_BUFFER |
| 作为着色器资源读取 | COMMON | NON_PIXEL_SHADER_RESOURCE |
几个容易错的细节:上传堆里的资源只能处于 GENERIC_READ 状态,不要对它做屏障转换,转了就报错;屏障要在同一条命令列表里、且在真正使用资源之前完成转换;同一批资源如果转换前后的状态相同,可以考虑合并成一批屏障提交,减少开销。
如果你用的是带有增强屏障能力的新运行时,屏障的语义从"状态"变成了"同步范围 + 访问类型 + 布局"三元组,表达力更强,但心智模型也变了。我建议先用传统屏障把整条流程跑通,理解了每个转换背后的"为什么要转",再迁移到增强屏障,否则你只是把一套不理解的规则换成另一套。
4. 描述符堆与资源视图:最容易被跳过的样板代码其实决定成败
描述符是 GPU 眼里资源的"身份证",它记录了这块资源在哪、什么格式、怎么访问。DX12 把描述符的存储管理权交给你,于是就有了描述符堆这个东西。老实说这部分代码写起来极其枯燥,全是模板式的样板,但它决定了你的渲染流程能不能扩展。
新手最常见的写法是每创建一个资源就新建一个堆,一个堆里只放一个描述符。这在小 demo 里没问题,一到大场景就是灾难:堆的数量有上限,频繁切换堆也会带来额外开销。正确的思路是先把资源视图分类,再按类别规划堆。
4.1 四类 Descriptor Heap 的差别与容量限制
DX12 里有四种描述符堆:CBV_SRV_UAV、SAMPLER、RTV、DSV。它们不能混用,每种堆的容量上限也不一样,CBV_SRV_UAV 和 SAMPLER 的容量由绑定层级决定,RTV 和 DSV 是另一套限制。
| 堆类型 | 存放内容 | 是否可以被着色器直接访问 |
|---|---|---|
| CBV_SRV_UAV | 常量缓冲视图、着色器资源视图、无序访问视图 | 是(仅 Shader-Visible 堆) |
| SAMPLER | 采样器状态 | 是(仅 Shader-Visible 堆) |
| RTV | 渲染目标视图 | 否 |
| DSV | 深度模板视图 | 否 |
只有标了 Shader-Visible 的堆才能被着色器直接索引,而且同一时间只能各绑定一个。RTV 和 DSV 堆不需要 Shader-Visible,它们只在命令列表设置渲染目标时用到。
我的规划方式是:RTV 和 DSV 各建一个常驻的非可见堆,按交换链缓冲数和渲染目标数量分配固定槽位;CBV_SRV_UAV 建一个大的可见堆,内部再用一个简单的线性分配器;采样器单独一个可见堆,因为静态采样器如果不走根签名里内嵌的方式,就必须从堆里取。
4.2 Shader-Visible 堆的两种流派:环形分配 vs 全量常驻
可见堆怎么用,社区里大致两种做法。第一种是环形分配:堆不算太大,每帧从头开始分配,帧结束时重置。好处是内存占用可控,坏处是每帧都要重新写描述符,而且如果一帧内需要绑定的资源数量超过堆容量,就得想办法分批。第二种是全量常驻:把所有资源的描述符一次性写进堆,之后只改变索引。
对小项目,环形分配更省心;对大项目、尤其是想做 GPU 驱动渲染的,全量常驻更合适,因为它为无绑定(bindless)访问铺平了道路。全量常驻的前提是你的资源在运行期基本稳定,或者你愿意为动态资源维护一套回收机制。
这里我要提醒一个特别隐蔽的坑:描述符堆必须通过 SetDescriptorHeaps 绑定之后才能用,而且这个调用在某些情况下会让命令列表的当前管线状态失效。我遇到过好几次画面突然变黑,最后发现是某处为了设置一个额外堆,重新调用了一次绑定,把之前设置好的根签名参数清掉了。绑堆的调用尽量集中在帧开始的固定位置,别散落在各处。
5. PSO 与 Root Signature:把整条渲染管线状态封成一个对象
DX11 时代,混合状态、光栅化状态、深度模板状态、着色器是分开设置的。DX12 把它们全部打包成一个管线状态对象(PSO),一次性创建,之后绑定即用。这个设计的好处是驱动可以在创建时就把所有状态验证并编译好,运行时几乎零开销。代价是创建 PSO 比较慢,而且状态组合爆炸,需要你自己做缓存。
和 PSO 配套的是根签名。它定义了着色器怎么从命令列表里拿参数——哪些走根常量、哪些走根描述符、哪些走描述符表。根签名和 PSO 必须兼容,否则绑定的时候会直接报错。
5.1 Root Signature 的 64 DWORD 预算怎么花
根签名有一个硬限制:64 个 DWORD,也就是 256 字节。这个预算要用在刀刃上。三种参数类型的成本差别很大:
- 根常量:每个 32 位值占一个 DWORD,最便宜,适合频繁变化的小参数(比如每帧的矩阵、时间、光照参数)。
- 根描述符:每个占两个 DWORD(缓冲区)或两个 DWORD 的等价,直接从根上取,省一次间接寻址,但只能指向一个固定资源。
- 描述符表:每个表占一个 DWORD,但表本身要指向描述符堆里的一段范围,灵活性最高,适合材质、纹理这类数量多、变化不频繁的参数。
我的分配习惯是:把每帧都会变的全局常量做成根常量(大约 16 到 20 个 DWORD 就够了),把每个物体变化的部分做成一个根常量块或者一个描述符表,剩下的预算全部留给描述符表。这样既能保证高频参数的低开销,又能容纳足够多的材质变化。
采样器我倾向于用静态采样器写进根签名,因为游戏里采样器的组合就那么几种(点采样、线性、各向异性配 clamp 或 wrap),静态化之后寄存器里直接有,省掉堆绑定。
5.2 PSO 创建、缓存与着色器编译链(DXC / SM 6.x)
着色器编译现在建议用 DXC,因为 fxc 已经停止更新,而且高级着色器模型 6.0 以后的特性只有在 DXC 上才有。DXC 支持直接编译成 DXIL 字节码,也能输出 SPIR-V 给别的后端用。
dxc -T vs_6_6 -E VSMain -Zi -Qembed_debug -Fo triangle_vs.dxil triangle.hlsl几个实际经验:编译时打开调试信息,这样 GPU 抓帧工具才能把机器码映射回源码行;把编译日志输出到文件,CI 里可以自动抓取警告;PSO 创建的耗时在冷启动时可能达到几百毫秒,值得做异步或者预编译缓存。
PSO 缓存这件事,早期可以用库对象把已编译的管线状态序列化到磁盘,第二次启动直接加载。后来更推荐的方法是配合着色器缓存工具链,在构建期就把常用的状态组合编译好。对个人项目来说,最实用的做法还是用一个哈希表,用状态描述结构作为键,命中就直接复用:
auto key = MakePSOKey(vs, ps, inputLayout, rtvFormat, dsvFormat); if (auto it = m_psoCache.find(key); it != m_psoCache.end()) return it->second;提示:缓存键一定要把渲染目标格式和深度格式算进去。我最早漏了深度格式,结果同一个 PSO 被用在深度格式不同的两个渲染通道上,画面偶尔出现深度测试失效,排查了一整天才定位到。
6. 贴图三角形:从 WIC 解码到 SRV 采样的完整链路
到这里终于能把纹理贴上去了。整条链路是:解码图片文件得到像素数据,创建一张默认堆上的纹理资源,通过上传堆把数据拷进去,为它创建着色器资源视图,再配一个采样器,最后在着色器里采样。
每一步都有细节,而且细节错了通常不报错,只是画面不对。这也是为什么我把贴图放在比较后面的位置——它需要前面所有基础设施都稳定了才好调试。
6.1 纹理上传:CopyQueue 与 Upload Heap 的正确姿势
上传的标准做法是:创建一个 UPLOAD 堆上的中间缓冲区,把 CPU 内存里的像素拷进去,然后录制一条从中间缓冲区到默认堆纹理的拷贝命令,最后把纹理的屏障从 COPY_DEST 转到 PIXEL_SHADER_RESOURCE。
// 中间缓冲区 CD3D12_RESOURCE_DESC uploadDesc = CD3DX12_RESOURCE_DESC::Buffer(rowPitch * height); device->CreateCommittedResource( &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD), D3D12_HEAP_FLAG_NONE, &uploadDesc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&uploadBuffer)); void* mapped = nullptr; uploadBuffer->Map(0, nullptr, &mapped); memcpy(mapped, pixels, rowPitch * height); uploadBuffer->Unmap(0, nullptr); // 保留映射也可以,视情况这里有两个坑。第一个是行间距(row pitch)。纹理的每一行在内存里可能有对齐填充,不是简单的宽度乘以像素字节数。如果按紧凑布局拷贝,宽高非 4 的倍数时画面会出现斜向错位。要用 GetCopyableFootprints 拿到正确的布局信息,或者至少保证行间距按 256 字节对齐。
第二个是缓冲区不能随意释放。虽然你 Unmap 了,但 GPU 可能还在读,所以上传缓冲区必须等到拷贝命令执行完才能回收。最简单的做法是攒到帧末统一释放,或者用围栏确认之后再释放。
如果你想做得更规范,可以把上传放到独立的拷贝队列上,和渲染队列用围栏做跨队列同步。这样纹理加载不会阻塞渲染线程。对加载时间敏感的项目,这个优化值得做;对学习阶段,先用同一个队列跑通再说。
6.2 顶点布局与 HLSL 对齐:Input Layout 报错为什么总是那几个
输入布局的问题几乎都是同一个来源:C++ 侧的语义名、格式、偏移,和 HLSL 侧的语义名、类型对不上。DX12 不做事后校验——你在创建 PSO 时会通过,但运行时数据是错位的,画面会呈现出各种奇怪的样子。
D3D12_INPUT_ELEMENT_DESC layout[] = { { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }, { "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }, };对应的 HLSL:
struct VSInput { float3 position : POSITION; float2 uv : TEXCOORD0; };我建议把顶点结构体定义成一份单一来源的手写结构,然后把偏移量用 offsetof 算出来,而不是硬编码数字。硬编码偏移在第一个人加了一个字段之后就会全线崩掉,而且不会报错。
还有一个细节:语义后面加序号(比如 TEXCOORD0 和 TEXCOORD1)在 HLSL 里可以不写,只要顺序一致;但 C++ 侧必须写,而且要和 HLSL 中隐含的序号对应。我见过不少人这里对不上,导致 UV 拿到的其实是法线数据。
6.3 颜色不对的三个最常见原因:sRGB、Mip、Sampler
贴图显示出来但颜色发灰、发暗、或者边缘粗糙,基本就是这三个原因。
第一个是 sRGB。图片文件里的颜色通常是 sRGB 编码的,也就是做过伽马校正。你在着色器里直接采样会拿到编码值,参与光照计算就会偏亮或者偏暗。正确的做法是给纹理视图使用带 _SRGB 后缀的格式,让采样时自动转成线性空间,然后在输出到后台缓冲区时再做一次编码。后台缓冲区的格式也要相应选择带 _SRGB 的版本。这里最忌讳的是两处都做转换或者都不做,效果是整体偏灰。
第二个是 Mip 链。没有生成 Mip 的话,远处纹理采样会产生严重的闪烁和噪点,因为一个像素要代表一大片区域,采样点却只有一个。要么在加载时用计算着色器生成 Mip,要么直接在资产管线里预生成好放进文件。我倾向于后者,运行时省事。
第三个是采样器。默认的采样模式是点采样,画面会出现明显的锯齿和块状感。把过滤模式设成线性或者各向异性,配合 clamp 或者 wrap 寻址模式,画面立刻就不一样了。各向异性过滤的等级建议给到 8 或者 16,代价很小,收益明显。
提示:把采样器状态写成几种固定组合(比如"UI 用点采样 + clamp"、"地形用各向异性 + wrap"),在根签名里静态定义,比每次创建采样器堆要省心得多。
7. 真实调试全记录:从 Validation Error 到 Device Removed
前面讲的都是"应该怎么做",这一段讲"做错了会看到什么"。我把调试层的输出按我实际遇到的频率排个序,每条附上我当时的真实原因。
7.1 我踩过的五条报错与它们的真实原因
第一条:资源状态不匹配。提示是某个资源在屏障转换时当前状态与预期不符。我当时的真实原因是同一个资源在两条不同的命令列表里都做了屏障转换,而这两条列表的执行顺序不确定。修正方式是把状态转换的责任收敛到单一位置,别在两个地方都转。
第二条:描述符堆未绑定。提示是在设置根描述符表之前必须先绑定描述符堆。真实原因是我在初始化路径里调用了设置根参数,但堆的绑定被写在了后面。把绑堆的调用统一挪到帧开始,问题消失。
第三条:命令列表未关闭。提示是向队列提交了一个还处于录制状态的列表。真实原因是我在某个提前返回的分支里忘了调用 Close。这种错误在打开调试层时立刻报出来,在关闭时可能表现为随机花屏,很难查。所以所有提前返回的路径都要保证列表被关闭或丢弃。
第四条:根签名与 PSO 不兼容。提示很直接,但不太容易理解。真实原因是我在根签名里用了描述符表,而 PSO 里对应的着色器阶段期望的是一个根描述符。两者必须一一对应,改一处就得同步改另一处。
第五条:设备被移除。这条最难。提示只有一行,没有任何上下文。大多数情况是三种原因:访问了已经释放的资源、屏障转换错误导致 GPU 挂了、或者着色器里出现了越界访问。开启 DRED 之后,你能看到掉线前最后一个完成的面包屑标记,从而把范围缩小到某一次绘制调用。
| 报错关键词 | 高概率原因 | 第一件该做的事 |
|---|---|---|
| 资源状态不匹配 | 转换时机或责任分散 | 检查转换是否在使用之前、是否重复转换 |
| 描述符堆未绑定 | 绑定顺序问题 | 把绑堆集中到帧开始 |
| 命令列表未关闭 | 提前返回路径遗漏 | 用 RAII 包装列表的生命周期 |
| 根签名不兼容 | 参数类型与 PSO 不匹配 | 对照两边的参数声明逐项核对 |
| 设备被移除 | 资源已释放 / 屏障错误 / 越界 | 打开 DRED,读面包屑与页面错误 |
7.2 GPU Hang / Device Removed 之后能做什么:DRED 与 PIX
设备被移除之后,Device 对象基本就废了,你得重建整套资源。但在重建之前,务必先把诊断信息读出来,否则下次还会掉。
DRED 的两块数据是这么读的:
ComPtr<ID3D12DeviceRemovedExtendedData1> dredData; if (SUCCEEDED(device->QueryInterface(IID_PPV_ARGS(&dredData)))) { D3D12_DRED_AUTO_BREADCRUMBS_OUTPUT breadcrumbs{}; dredData->GetAutoBreadcrumbsOutput(&breadcrumbs); D3D12_DRED_PAGE_FAULT_OUTPUT pageFault{}; dredData->GetPageFaultOutput(&pageFault); }面包屑会告诉你掉线时正在执行哪个绘制调用、哪个命令列表。页面错误会告诉你访问了哪块释放掉的显存。有了这两个坐标,问题范围就从"整个引擎"缩小到"某一次绘制"。
GPU 抓帧工具在排查渲染问题上同样关键。它能捕获一帧里所有的命令、资源和状态,让你逐条回放每一个绘制调用。我第一次用它抓到问题的时候,发现是某个常量缓冲区在拷贝时偏移算错了 16 字节,导致矩阵后半段是垃圾数据,画面表现为模型被拉扁。这个错误从画面完全看不出来原因,但抓帧之后一分钟就定位了。
注意:抓帧工具在捕获大场景时会占用大量显存和磁盘,建议限制单次捕获的帧数和资源范围。另外,抓帧时记得保留着色器的调试信息,否则你看到的只有汇编指令。
8. 跑完 25 集之后,我建议按这个顺序继续往下走
把带贴图的三角形跑通只是把基础设施搭好了,接下来有几个方向值得投入。第一个是 GPU 驱动渲染,也就是让 GPU 自己决定画什么,CPU 只负责提交和更新数据。这需要你前面把描述符堆做成全量常驻的形式,否则每帧的重新分配会成为瓶颈。第二个是间接绘制与批处理,把大量相似物体的绘制合并成少量调用,这一步的收益通常最直观。
如果你的目标平台支持,光线追踪和网格着色器是另外两条路。前者适合需要精确反射、阴影的场景,后者适合极高密度的几何。但我要给一个实在的建议:在基础设施没有稳定之前,不要碰这些。我见过太多人在连屏障都没弄清楚的情况下去写光线追踪,结果掉线的原因根本不在光线追踪代码里,而在底层资源管理。
最后说一个我在实际项目里反复用到的小技巧。在整个渲染流程里埋一些标记点,用调试层和抓帧工具可以按名字检索。这样一来,你抓帧之后不用盯着几百个匿名绘制调用发愣,一眼就能看到"这是阴影通道"、"这是后处理"。埋标记的成本很低,几行代码的事,但在排查复杂问题时,它是把时间从几小时压缩到几分钟的关键。
我个人在踩了这么多坑之后最大的体会是:DX12 的学习曲线之所以陡,不是因为它难,而是因为它把原本由驱动承担的决策全部暴露给了你。你得先接受"每一个选择都有后果"这件事,然后才会发现,正是这些选择让它在复杂场景下比前一代 API 有更大的优化空间。这个过程不舒服,但每一步踩过的坑都会变成后面做架构时的直觉。