1. 老教程一上手就卡住,问题多半出在环境判断上
如果你最近在搜索引擎里敲过DX12入门,八成会翻到那批2016年前后写的老文章。它们有个共同特点:代码框架看着完整,但照着敲下来,你会在D3D12CreateDevice这一步就收到一个E_FAIL,调试层还一声不吭。我第一次遇到这个情况时,怀疑过VS版本、怀疑过Windows SDK、甚至重装过系统,最后才发现是枚举适配器时把核显当成了默认设备,而那块核显根本不支持DX12的feature level。
图形API实战这类内容,最怕的就是"代码给全了、场景没交代"。DX12本身没有错,它只是比DX11更"诚实"——它不会再帮你兜底,所有该你选的、该你管的东西,它一律不替你做主。所以这套从Device到贴图三角形的25集,我想按真实动手的顺序重写一遍,把每一步"为什么这么写"和"卡住时怎么查"都放进去,而不是把官方文档翻译成中文了事。
1.1 老教程的代码在新环境里为什么会崩
最典型的一个差异是适配器选择。老教程里常见这么一句:
ComPtr<IDXGIFactory4> factory; CreateDXGIFactory1(IID_PPV_ARGS(&factory)); ComPtr<IDXGIAdapter> adapter; factory->EnumAdapters(0, &adapter); // 直接取第0个 D3D12CreateDevice(adapter.Get(), D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&device));EnumAdapters(0)取的是系统枚举的第一个适配器,而在很多笔记本上,第一个往往是Intel核显。核显对DX12的支持程度取决于型号和驱动,部分老核显只到feature level 11_0甚至更低,D3D12CreateDevice自然返回失败。正确的做法是用EnumAdapters1遍历,跳过DXGI_ADAPTER_FLAG_SOFTWARE,再用一次"探测式调用"确认它能不能创建device:
ComPtr<IDXGIFactory6> factory; CreateDXGIFactory2(0, IID_PPV_ARGS(&factory)); ComPtr<IDXGIAdapter1> adapter; for (UINT i = 0; factory->EnumAdapterByGpuPreference(i, DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE, IID_PPV_ARGS(&adapter)) != DXGI_ERROR_NOT_FOUND; ++i) { DXGI_ADAPTER_DESC1 desc; adapter->GetDesc1(&desc); if (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) continue; if (SUCCEEDED(D3D12CreateDevice(adapter.Get(), D3D_FEATURE_LEVEL_11_0, __uuidof(ID3D12Device), nullptr))) { break; // 找到了能用的硬件适配器 } }注意最后那个D3D12CreateDevice的第三个参数传的是__uuidof(ID3D12Device),ppDevice传nullptr。这是一种"我只想问问它行不行,不真的创建设备"的探测写法,探测成功之后再正式创建。EnumAdapterByGpuPreference是IDXGIFactory6的新接口,它能按"高性能优先"帮你把独显排前面,比手写一堆判断省事得多,前提是系统的DXGI版本够新。
1.2 判断你的机器到底支持哪个feature level
D3D12CreateDevice的第二个参数是D3D_FEATURE_LEVEL,老教程一律写D3D_FEATURE_LEVEL_11_0。这没错,DX12的最低门槛就是11_0,但如果你想用12_1、12_2里的特性(比如光线追踪、网格着色器),就得先确认设备支不支持。稳妥的写法是先探测,再决定降级策略:
D3D_FEATURE_LEVEL levels[] = { D3D_FEATURE_LEVEL_12_2, D3D_FEATURE_LEVEL_12_1, D3D_FEATURE_LEVEL_12_0, D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0 }; D3D_FEATURE_LEVEL selected = D3D_FEATURE_LEVEL_11_0; for (auto lv : levels) { if (SUCCEEDED(D3D12CreateDevice(adapter.Get(), lv, __uuidof(ID3D12Device), nullptr))) { selected = lv; break; } }我实测在几台不同机器上跑过,2018年以后的独显基本都能到12_1,核显则经常停在11_0。把这个feature level打印出来(或者在窗口标题里显示),能省掉大量"为什么某个API调用返回E_NOTIMPL"的困惑。一个我踩过的坑是:有些教程在创建device时用了12_1,但后面又调用了需要12_2的接口,运行时才崩,日志还藏在调试层里。养成先探测、再选最低够用级别的习惯,能避开一大半兼容性问题。
1.3 这25集按什么节奏推进
我给自己定的原则是:每集只引入一个新的、必须理解的核心对象,绝不一口气塞五个。顺序大致是:Device和Adapter(起步)→ 调试层和验证(贯穿全程)→ 命令队列/分配器/列表三件套 → 围栏同步 → 交换链与第一帧清屏 → 根签名和PSO → 画三角形 → 上传堆与纹理 → 贴图三角形 → 各种调试工具实操。看起来慢,但每一集都能真的跑出画面,而不是攒了二十集的代码最后一次性编译。
2. Device创建:一行调用背后藏着三种典型故障
D3D12CreateDevice长得人畜无害,但它是我调试时间花得最多的一步。把它单独拎出来讲一节,是因为后面所有东西都建立在Device句柄之上,这里出问题,后面全白搭。
2.1 调试层必须一开始就开
这是我最想强调的一点,也是老教程最容易漏的一步。调试层(Debug Layer)要在创建device之前启用,一旦device创建完成再想开,就得重新创建:
#if defined(_DEBUG) ComPtr<ID3D12Debug> debugController; if (SUCCEEDED(D3D12GetDebugInterface(IID_PPV_ARGS(&debugController)))) { debugController->EnableDebugLayer(); // 进一步开启GPU端验证(可选,性能有损耗) ComPtr<ID3D12Debug1> debug1; if (SUCCEEDED(debugController.As(&debug1))) { debug1->SetEnableGPUBasedValidation(TRUE); } } #endifSetEnableGPUBasedValidation会带来明显的性能下降,所以我在正式跑帧率测试时会关掉它,但调试阶段一定开着。它会捕获资源状态转换错误、描述符越界、命令列表未关闭就执行等一堆问题,这些错误在没有调试层的时候表现为随机黑屏或者设备移除,极难定位。
2.2 适配器选择里最容易忽略的软件适配器
上一节提到的DXGI_ADAPTER_FLAG_SOFTWARE判断,很多人会漏。Windows自带一个叫WARP的软件光栅化器,它确实支持DX12,如果你不加判断地取了枚举列表里的最后一个,有可能拿到它。WARP适合跑自动化测试,但帧率低得离谱,拿它做实时渲染你会发现画面能出,就是卡得像幻灯片。实测经验是:如果程序在一个没有独显的环境里跑得特别慢,先打印适配器名字,看看是不是WARP。
2.3 创建返回E_FAIL时的排查顺序
D3D12CreateDevice返回E_FAIL,我不会上来就改代码,而是按这个顺序排:
| 排查项 | 检查方法 | 常见结论 |
|---|---|---|
| 适配器是否选错 | 打印DXGI_ADAPTER_DESC1.Description | 选到了核显/软件适配器 |
| feature level要求过高 | 从11_0开始逐级往上探测 | 设备不支持12_1及以上 |
| 调试层是否可用 | D3D12GetDebugInterface是否成功 | 系统未装图形工具组件 |
| SDK版本 | 检查Windows SDK安装 | 头文件与运行库不匹配 |
| 驱动问题 | 更新显卡驱动 | 老驱动对DX12支持不全 |
这张表是我自己踩出来的。有一次E_FAIL查了半天,最后发现是系统没装"图形工具"这个可选组件,调试层接口拿不到,而代码里又ASSERT了调试层必须成功,直接崩了。把调试层的启用加上容错判断,是新手最容易忽略的自保措施。
2.4 设备对象创建之后立刻做的事
device拿到手,我通常会做三件事:查询能力(CheckFeatureSupport)、设置调试对象名(SetName,方便抓帧工具里辨认)、以及获取描述符大小(GetDescriptorHandleIncrementSize)。第三件事在D3D12里尤其重要,因为描述符堆是手动管理的,RTV、CBV、SRV的大小可能不同,提前存下来能避免后面反复查询:
UINT rtvSize = device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV); UINT cbvSrvSize = device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);SetName虽然不影响运行,但在用抓帧工具时,你能在资源列表里看到"BackBuffer0""MainDepthBuffer"这类有意义的名字,而不是一堆Resource 0x000001...,调试效率差好几倍。
3. 命令对象三件套:队列、分配器、列表的分工逻辑
DX12把"命令的录制"和"命令的执行"彻底分开了,这是它和DX11最大的区别。理解这个分离,才能理解为什么它要这么啰嗦地给你三个对象。
3.1 命令队列是GPU的工作台
命令队列(ID3D12CommandQueue)代表一条GPU执行通道。D3D12里有三种类型:DIRECT(全能,图形+计算+拷贝)、COMPUTE(只做计算)、COPY(只做数据拷贝)。初学者用DIRECT一条就够。创建时注意一点,队列的Priority和Flags在起步阶段保持默认即可,别照抄那些用D3D12_COMMAND_QUEUE_FLAG_DISABLE_GPU_TIMEOUT的代码,那玩意关掉后一旦GPU卡死,系统不会再帮你恢复,整台机器可能都得重启。
3.2 分配器不能一边录制一边重置
命令分配器(ID3D12CommandAllocator)管理的是命令列表的内存。它有个铁律:只要命令列表还没执行完,对应的分配器就不能重置。很多人第一次写出"每帧allocator->Reset()然后list->Reset()再录制"的循环,跑起来看着正常,直到某一帧GPU还没消化完上一帧的命令,分配器被重置,调试层立刻报错,或者直接设备移除。
正确做法是每帧用不同的分配器,或者等围栏确认GPU执行完毕后再重置。三缓冲时准备三个分配器、三个命令列表是常见配置,我在第4集的同步部分会展开。
3.3 命令列表录制与关闭的配对
命令列表(ID3D12GraphicsCommandList)的用法遵循一个固定节奏:Reset→ 一系列SetXXX和DrawXXX→Close。忘了Close就调用ExecuteCommandLists,调试层会报"命令列表未关闭"。反过来,Close之后再调用任何录制命令,也会报错。这个配对关系我建议直接封装成一个函数,避免手滑。
3.4 围栏:CPU和GPU之间的信号灯
围栏(ID3D12Fence)是同步的唯一可靠手段。它的逻辑是:CPU往队列里塞一个Signal,围栏值加一;然后CPU可以在任意时刻查询围栏的GetCompletedValue,看GPU执行到哪了。要等GPU完成,就用SetEventOnCompletion加一个事件对象:
const UINT64 currentFenceValue = ++m_fenceValue; m_commandQueue->Signal(m_fence.Get(), currentFenceValue); if (m_fence->GetCompletedValue() < currentFenceValue) { m_fence->SetEventOnCompletion(currentFenceValue, m_fenceEvent); WaitForSingleObject(m_fenceEvent, INFINITE); }这是退出程序前等待GPU的典型写法。我在实践中见过的最隐蔽的bug,是围栏值用了UINT而不是UINT64,程序连续跑几个小时之后溢出,同步逻辑突然错乱。DX12的围栏值就是UINT64,别省这个位宽。
4. 交换链与第一帧:把清出来的颜色真正显示到屏幕上
从零到"屏幕上出现一块纯色",中间要跨过交换链、RTV描述符堆、资源状态转换三道门槛。这一节讲的是很多人第一次看到自己程序输出的时刻。
4.1 交换链创建参数逐个说清楚
交换链描述里的字段不多,但每个都有讲究:
| 参数 | 建议值 | 说明 |
|---|---|---|
BufferCount | 2或3 | 双缓冲/三缓冲,三缓冲更平滑 |
Format | DXGI_FORMAT_R8G8B8A8_UNORM | 兼容性最好 |
SwapEffect | DXGI_SWAP_EFFECT_FLIP_DISCARD | 现代写法必须用FLIP系列 |
SampleDesc.Count | 1 | FLIP模式下必须为1 |
BufferUsage | DXGI_USAGE_RENDER_TARGET_OUTPUT | 作为渲染目标 |
DXGI_SWAP_EFFECT_FLIP_DISCARD是重点。老教程里大量用的是DISCARD,但那个模式在新系统上已经不被推荐,而且FLIP系列才能配合IDXGISwapChain3的GetCurrentBackBufferIndex使用。用FLIP模式创建的交换链,不能直接当渲染目标用,必须先拿到实际的后台缓冲资源,这一点和旧模式一致,但很多人卡在"为什么Present之后画面撕裂或花屏"——八成因是后台缓冲索引取错了。
4.2 RTV描述符堆怎么分配
D3D12里渲染目标视图(RTV)必须放在描述符堆里,堆本身是不可直接被shader访问的,它只是一个描述符的存放区域。给每个后台缓冲创建一个RTV:
D3D12_DESCRIPTOR_HEAP_DESC rtvHeapDesc = {}; rtvHeapDesc.NumDescriptors = FRAME_COUNT; rtvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV; rtvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE; // RTV堆不能是shader可见的 m_device->CreateDescriptorHeap(&rtvHeapDesc, IID_PPV_ARGS(&m_rtvHeap));FLAG_NONE是必须的,RTV堆不允许shader可见,写错了直接创建失败。然后遍历交换链的后台缓冲,逐个创建RTV:
CD3DX12_CPU_DESCRIPTOR_HANDLE rtvHandle(m_rtvHeap->GetCPUDescriptorHandleForHeapStart()); for (UINT i = 0; i < FRAME_COUNT; i++) { m_swapChain->GetBuffer(i, IID_PPV_ARGS(&m_renderTargets[i])); m_device->CreateRenderTargetView(m_renderTargets[i].Get(), nullptr, rtvHandle); rtvHandle.Offset(1, m_rtvDescriptorSize); }这里Offset(1, m_rtvDescriptorSize)就是前面存下来的描述符大小,它让句柄指向堆里的下一个格子。忘了Offset,所有RTV都会写到同一个描述符上,现象是画面只有一个缓冲的颜色对,切换时闪一下。
4.3 清屏、转换状态、Present 的完整一帧
一帧的最简流程是这样的:
auto* cmdList = m_commandList.Get(); cmdList->Reset(m_commandAllocator.Get(), nullptr); // 把后台缓冲从PRESENT状态转到RENDER_TARGET auto barrier = CD3DX12_RESOURCE_BARRIER::Transition( m_renderTargets[m_frameIndex].Get(), D3D12_RESOURCE_STATE_PRESENT, D3D12_RESOURCE_STATE_RENDER_TARGET); cmdList->ResourceBarrier(1, &barrier); CD3DX12_CPU_DESCRIPTOR_HANDLE rtvHandle(m_rtvHeap->GetCPUDescriptorHandleForHeapStart(), m_frameIndex, m_rtvDescriptorSize); const float clearColor[] = { 0.1f, 0.2f, 0.3f, 1.0f }; cmdList->ClearRenderTargetView(rtvHandle, clearColor, 0, nullptr); // 转回PRESENT barrier = CD3DX12_RESOURCE_BARRIER::Transition( m_renderTargets[m_frameIndex].Get(), D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PRESENT); cmdList->ResourceBarrier(1, &barrier); cmdList->Close(); ID3D12CommandList* lists[] = { cmdList }; m_commandQueue->ExecuteCommandLists(1, lists); m_swapChain->Present(1, 0);资源状态转换(Resource Barrier)是DX12的必修课,也是调试层报错的重灾区。后台缓冲在Present时必须是PRESENT状态,作为渲染目标时必须是RENDER_TARGET状态,来回切换全靠barrier。我一开始嫌麻烦,把barrier全注释掉了,结果调试层疯狂刷RESOURCE_BARRIER相关的警告,画面偶尔正确、偶尔花屏,运气成分极大。把状态转换当成呼吸一样自然,是DX12思维转变的关键一步。
Present(1, 0)的第一个参数是同步间隔,1表示开启垂直同步。如果帧率测试时想跑满,可以传0,但会看到画面撕裂。第二个参数是标志位,入门阶段传0即可。
5. 让三角形浮出水面:根签名、着色器与PSO
清屏只是热身,真正画出三角形,得把渲染管线固定下来。这一节的核心是三个对象:根签名、着色器、管线状态对象。
5.1 顶点数据与输入布局
画三角形至少要三个顶点。顶点结构体要和输入布局一一对应:
struct Vertex { DirectX::XMFLOAT3 position; DirectX::XMFLOAT4 color; };对应的输入布局:
D3D12_INPUT_ELEMENT_DESC inputLayout[] = { { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }, { "COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 } };第二个元素里的offset是12,因为XMFLOAT3占12字节。这个偏移量手算很容易错,我建议用offsetof(Vertex, color)代替硬编码,结构体一改就不用手改偏移。
顶点数据要从上传堆拷到默认堆(D3D12_HEAP_TYPE_UPLOAD→D3D12_HEAP_TYPE_DEFAULT),或者为了省事,直接放在上传堆里用,因为顶点数据量小、每帧不变。第6集讲纹理上传时会把这两种堆的分工讲透。
5.2 根签名的设计思路
根签名(Root Signature)定义shader能拿到哪些资源,以及它们的绑定方式。它介于"灵活但要手动填描述符表"和"高效但参数有限"之间。入门阶段,一个只带顶点缓冲(通过D3D12_ROOT_PARAMETER_TYPE_VERTEX_BUFFER_VIEW)的根签名就够:
CD3DX12_ROOT_PARAMETER param[1]; param[0].InitAsConstantBufferView(0); // 常量缓冲,放MVP矩阵 CD3DX12_ROOT_SIGNATURE_DESC rootSigDesc; rootSigDesc.Init(1, param, 0, nullptr, D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT这个标志不加,管线创建时会报错,因为默认根签名不允许输入装配阶段读取顶点数据。我第一次看到这个报错时完全不知道为什么,查了半天文档才反应过来。根签名设计的原则是:把最频繁变化的参数放进根参数(直接存在命令里,快但占空间),把大量描述符放进描述符表(走堆,灵活但有额外开销)。
5.3 着色器编译与PSO失败排查
着色器用HLSL写,运行时用D3DCompileFromFile编译(调试期),或者用离线编译好的字节码(发布期)。PSO(ID3D12PipelineState)把着色器、输入布局、根签名、光栅化状态、混合状态等打包成一个不可变对象。PSO创建失败时,错误信息往往比较笼统,常用的排查方法:
| 报错方向 | 可能原因 | 检查点 |
|---|---|---|
| 着色器编译失败 | HLSL语法错 | 看编译回调的error blob |
| 输入布局不匹配 | 语义名、格式对不上 | 顶点结构体与布局逐字段比对 |
| 根签名不匹配 | 参数量/类型不一致 | 着色器里cbuffer的binding |
| 渲染目标格式不符 | PSO的RTV格式和交换链不一致 | 两处格式必须相同 |
最后一条我踩过。交换链用了R8G8B8A8_UNORM,PSO里却写了B8G8R8A8_UNORM,创建的PSO在绑定时就会出问题,画面一片黑。把这两处的格式用一个常量统一管理,能根治这类错误。
5.4 画三角形时黑屏的排查链路
这是很多人第一次的"至暗时刻"。黑屏排查我会按这个链路走:
- 先确认清屏颜色是否正常显示。如果清屏色都看不到,问题在交换链或RTV。
- 确认顶点数据是否真的被上传(用调试层看资源内容,或者临时把清屏色换成红色,确认渲染循环在跑)。
- 检查视口(
RSSetViewports)和裁剪矩形(RSSetScissorRects)有没有设置。D3D12不设视口,默认是空的,三角形直接被裁掉。 - 检查根签名的标志和PSO的flag是否匹配。
- 检查顶点着色器输出的齐次坐标有没有问题,比如z值为0或者w为负,三角形会在裁剪空间外。
第3条是我最常忘的。D3D12不像D3D11那样有隐式视口,RSSetViewports必须显式调用,而且要在OMSetRenderTargets之后、DrawInstanced之前。忘了这一步,画面能清屏,但三角形永远不出现。
6. 从纯色到贴图三角形:纹理资源的完整旅程
前面的三角形是顶点颜色插值出来的,升级到贴图,本质是把一张图的像素传到GPU,并让shader按UV采样。这一步涉及上传堆、资源状态、SRV、采样器四样东西,是D3D12资源管理的集中体现。
6.1 上传堆与默认堆的分工
这是理解D3D12资源管理的核心。GPU访问的显存分两大类:默认堆(D3D12_HEAP_TYPE_DEFAULT)只有GPU能访问,速度最快;上传堆(D3D12_HEAP_TYPE_UPLOAD)CPU能写,适合放临时数据;回读堆(D3D12_HEAP_TYPE_READBACK)让CPU能读GPU结果,常用于做GPU读回。
纹理的正确姿势是:先在默认堆创建纹理资源,再在上传堆创建一块同大小的缓冲,把纹理数据memcpy进去,最后用CopyTextureRegion从上传缓冲拷到默认堆纹理。这个"中转"过程看似多此一举,但它是D3D12的设计哲学——CPU和GPU通过明确的数据流向交互,而不是隐式的。
6.2 纹理上传的完整步骤
第一步,计算上传缓冲的大小,用GetRequiredIntermediateSize,别自己算,行对齐规则很容易错:
const UINT64 uploadSize = GetRequiredIntermediateSize(texture.Get(), 0, 1); CD3DX12_HEAP_PROPERTIES uploadHeapProps(D3D12_HEAP_TYPE_UPLOAD); CD3DX12_RESOURCE_DESC bufDesc = CD3DX12_RESOURCE_DESC::Buffer(uploadSize); device->CreateCommittedResource(&uploadHeapProps, D3D12_HEAP_FLAG_NONE, &bufDesc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&textureUploadHeap));第二步,用UpdateSubresources把数据搬进去,它会自动处理行间距和子资源偏移:
D3D12_SUBRESOURCE_DATA subData = {}; subData.pData = imageData; subData.RowPitch = width * 4; // 假设RGBA8 subData.SlicePitch = subData.RowPitch * height; UpdateSubresources(commandList, texture.Get(), textureUploadHeap.Get(), 0, 0, 1, &subData);RowPitch这里可以是原始数据的行字节数,UpdateSubresources会按目标纹理的GetCopyableFootprints规则补齐。
第三步,做资源状态转换。纹理在拷贝前必须是COPY_DEST状态,拷完转成PIXEL_SHADER_RESOURCE,才能被shader采样。这两次barrier漏一个,调试层立刻报资源状态不匹配。
6.3 SRV和采样器
纹理要被shader读到,得创建一个SRV描述符,放在CBV/SRV/UAV堆里,类型为D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV,并且这个堆必须是shader可见的(D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE)。创建SRV:
D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc = {}; srvDesc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING; srvDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; srvDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D; srvDesc.Texture2D.MipLevels = 1; device->CreateShaderResourceView(texture.Get(), &srvDesc, srvHandle);采样器可以在根签名里用静态采样器(D3D12_STATIC_SAMPLER_DESC)指定,避免额外描述符堆。入门阶段用静态采样器最省事,过滤模式选MIN_MAG_MIP_LINEAR,寻址模式选WRAP,基本能覆盖大部分贴图场景。
6.4 从纯色三角形到贴图三角形的最小改动清单
把前面的东西串起来,改动其实不多:
- 顶点结构体加一个UV字段,输入布局加一条
TEXCOORD语义。 - 根签名加一个
D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE,指向SRV堆,并附上一个静态采样器。 - 像素着色器里加一行
Texture2D tex : register(t0); SamplerState smp : register(s0);然后tex.Sample(smp, uv)。 - 绘制前
SetGraphicsRootDescriptorTable绑定SRV,并SetDescriptorHeaps把SRV堆设进去。
顺序上有个坑:SetDescriptorHeaps必须在SetGraphicsRootDescriptorTable之前调用,否则调试层会抱怨你绑定了一张没被设进去的堆。这个顺序我见不少人写反,报错信息也不直观。
7. 真实调试记录:教程里不会写的那些报错
前面讲的是顺利路径,真正耗时间的是调试。这一节放几个我实际遇到过、并且在很多教程里找不到答案的问题。
7.1 设备移除(device removed):先看调试层的最后一条
设备移除是D3D12里最让人头疼的错误,它表现为GPU驱动重置,所有后续命令全部失败。触发原因很多:越界访问、死循环的shader、GPU超时。我的排查习惯是:打开调试层和GPU验证,重现时盯着调试层输出的最后几条。如果调试层没报,基本可以判断是shader里的逻辑问题,比如一个永远不满足条件的循环,GPU跑了几秒被系统判定为挂起。
D3D12_COMMAND_QUEUE_FLAG_DISABLE_GPU_TIMEOUT这个标志能暂时阻止系统把挂起的GPU重置,方便抓帧定位,但用完一定要去掉,否则下一个问题就是整机卡死。
7.2 资源状态不匹配的刷屏
调试层最吵的一类报错是资源状态不匹配,比如"Resource is in state D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE but expected D3D12_RESOURCE_STATE_RENDER_TARGET"。这类报错的根因通常是漏了一次barrier,或者两次barrier之间资源被别的命令用了。解决它的思路不是"哪报错改哪",而是把每一帧每个资源的生命周期画出来,确认状态转换是闭合的。我养成的习惯是:每创建一个资源,就在注释里写下它可能经历的状态序列,改代码时对照检查。
7.3 抓帧工具的实际用法
调试层管逻辑错误,抓帧工具管"画面为什么不对"。抓帧工具能抓一整帧的所有命令,逐draw call看绑定了什么资源、shader输入输出是什么。我第一次用它定位的问题是:贴图显示成纯黑,抓帧发现SRV绑的是空描述符,根因是SetDescriptorHeaps的调用顺序反了。没有抓帧工具,光看代码是看不出来的,因为代码逻辑"看起来"完全正确。入门阶段建议抓一帧就分析一帧,别攒着。
7.4 贴图异常的几种典型表现
- 全黑:SRV没绑定,或者纹理没上传成功。
- 全白:采样器过滤模式配成了
POINT且UV超出范围,或者采样器根本没设。 - 上下颠倒:图片坐标系和纹理坐标系Y轴方向相反,需要在UV上做
1.0 - v,或者加载时翻转图片。 - 颜色错乱:格式对不上,比如数据是BGRA却按RGBA读。
最后一条我调了半天。图片加载库输出的格式和SRV的Format不一致,画面颜色红蓝对调,肉眼很难第一眼看出,拿调色板的图片一试就现原形。写代码时把"数据格式"和"资源格式"两个概念分开记录,能省很多事。
8. 往下走:把25集练完之后的几个方向
这套25集跑完,你应该能从零搭出一个带贴图的三角形,并且会用调试层和抓帧工具定位问题。再往下,我个人的建议是先别急着上PBR或阴影,而是把"深度缓冲"和"多物体渲染"这两件事做扎实。深度缓冲涉及深度资源的状态转换和DSV的创建,是后面所有3D场景的基础;多物体渲染则逼你面对常量缓冲的动态更新和根参数的分配策略,这两个问题在单三角形阶段完全可以绕过去,但一旦场景里有第二个物体,就绕不开了。
我自己的习惯是每学完一个阶段,就把代码删掉重写一遍,不带参考。第一次写命令队列同步时我照着教程抄,感觉懂了;第二遍不看教程,卡在围栏值的递增逻辑上,才发现自己根本没理解"信号是先加值再等"。这种"删掉重写"的笨办法,比反复看教程管用得多。图形API这东西,代码里的每一行都对应一个真实发生在GPU上的动作,糊弄不过去,也正因如此,跑通一个三角形的成就感,比想象中足。