对于DirectX程序开发者来说,学会使用Visual Studio Graphics Debugger(图形调试器)可以帮助你全面了解渲染管线绑定的资源和运行状态,从而确认问题所在。现在就以我所掌握的图形调试经验来进行展开描述。
下面的教程基于Visual Studio 2017 Community进行.
同时推荐大家了解一下我的DirectX 11教程,讲述了如何脱离DirectX SDK及Effects11,使用HLSL编译器/D3DCompiler和Windows SDK来开发DirectX 11应用程序:
DirectX11 With Windows SDK完整目录
Github项目源码
首先确定是否安装了DirectX图形调试器,需要在Visual Studio Installer中确定是否已经勾选了该项内容。
安装好并进入项目,在调试之前需要将项目配置成Debug模式
然后观察着色器的编译选项,如果使用的是HLSL编译器,则要重点关注Debug模式下所有着色器是否都禁用了优化,并启用了调试信息。
然后在Debug配置下,选择HLSL编译器-所有选项,禁用优化并启用调试信息
如果使用的是D3DCompiler,在代码层(运行时)编译着色器,则需要在Debug模式下给D3DComplieFromFile
函数添加D3DCOMPILE_DEBUG
和D3DCOMPILE_SKIP_OPTIMIZATION
的Flag以开启着色器调试并关闭优化,否则在调试着色器的时候只能看到汇编代码:
HRESULT CreateShaderFromFile(const WCHAR * csoFileNameInOut, const WCHAR * hlslFileName,
LPCSTR entryPoint, LPCSTR shaderModel, ID3DBlob ** ppBlobOut)
{
HRESULT hr = S_OK;
// 寻找是否有已经编译好的顶点着色器
if (csoFileNameInOut && filesystem::exists(csoFileNameInOut))
{
return D3DReadFileToBlob(csoFileNameInOut, ppBlobOut);
}
else
{
DWORD dwShaderFlags = D3DCOMPILE_ENABLE_STRICTNESS;
#ifdef _DEBUG
// 设置 D3DCOMPILE_DEBUG 标志用于获取着色器调试信息。该标志可以提升调试体验,
// 但仍然允许着色器进行优化操作
dwShaderFlags |= D3DCOMPILE_DEBUG;
// 在Debug环境下禁用优化以避免出现一些不合理的情况
dwShaderFlags |= D3DCOMPILE_SKIP_OPTIMIZATION;
#endif
ID3DBlob* errorBlob = nullptr;
hr = D3DCompileFromFile(hlslFileName, nullptr, D3D_COMPILE_STANDARD_FILE_INCLUDE, entryPoint, shaderModel,
dwShaderFlags, 0, ppBlobOut, &errorBlob);
if (FAILED(hr))
{
if (errorBlob != nullptr)
{
OutputDebugStringA(reinterpret_cast<const char*>(errorBlob->GetBufferPointer()));
}
SAFE_RELEASE(errorBlob);
return hr;
}
// 若指定了输出文件名,则将着色器二进制信息输出
if (csoFileNameInOut)
{
return D3DWriteBlobToFile(*ppBlobOut, csoFileNameInOut, FALSE);
}
}
return hr;
}
图形调试器的调试通常是针对某一帧的画面进行的。完成了上面的配置后,第一步我们需要打开图形调试器去截取一帧认为有问题的画面来进行调试。
运行图形调试之前请先确保没有能够导致触发断点异常的问题,如果有的话请先通过普通的调试器解决问题。毕竟图形调试器是要解决图形显示异常,普通调试无法查出来的问题,而要对GPU进行调试。除此之外,还需要撤掉之前在图形绘制阶段的所有断点。
有两种方式打开图形调试器,第一种是快捷键Alt+F5启动,如果没有反应,则可以通过第二种方式启动并确认快捷键。
第二种是VS界面选择调试-图形-启动图形调试。
在进入程序后,按下Print Screen(PrtSc)键截取一帧有问题的画面,然后就可以看到红色方框区域就是你刚截下的一帧画面
实际上生成的是一个图形日志文档(.vsglog),我们需要通过他来进行图形调试。
你可以在一次调试截取多帧画面,但基本上目前我们只需要截取一帧画面就可以退出程序了。关闭程序后,我们可以点击蓝色部分的字:帧XXXX 或者双击画面来打开Visual Studio图形分析器。
下面是图形调试器的主界面
事件列表展示了DirectX的一些接口类对象的重要调用。当前查看的是GPU工作,可以观察到D3D设备上下文关于绘制和内部绑定的GPU数据更新的所有操作。若更改为时间线,则可以观察更多有关D3D设备上下文的详细调用操作,可以看到各个阶段都有哪些资源被绑定,哪些状态被改变,以及调用了绘制。
其中带笔刷的调用说明这是一个绘制调用,可以点击它观察直到这个方法被调用后的绘制状态。
看到上面的几张图片,虽然我们可以推测出来对象: 2
就是m_pd3dImmediateContext
,但是也仅限少数的几个固定对象名我们能直接推测出是什么对象。等对象一多,我们就难以判别管线所绑定的对象是否正确。因此我们可以在C++代码来为对象指定名称。
在d3dUtil.h
中提供了两个函数,一个用于D3D设备创建出来的对象,一个用于DXGI对象。通过SetPrivateData
方法,并使用WKPDID_D3DDebugObjectName
的GUID
使得我们可以为其设置图形调试器下的名称:
// ------------------------------
// D3D11SetDebugObjectName函数
// ------------------------------
// 为D3D设备创建出来的对象在图形调试器中设置对象名
// [In]ID3D11DeviceChild D3D11设备创建出的对象
// [In]name 对象名
template<UINT TNameLength>
inline void D3D11SetDebugObjectName(_In_ ID3D11DeviceChild* resource, _In_ const char(&name)[TNameLength])
{
#if (defined(DEBUG) || defined(_DEBUG)) && (GRAPHICS_DEBUGGER_OBJECT_NAME)
resource->SetPrivateData(WKPDID_D3DDebugObjectName, TNameLength - 1, name);
#else
UNREFERENCED_PARAMETER(resource);
UNREFERENCED_PARAMETER(name);
#endif
}
// ------------------------------
// DXGISetDebugObjectName函数
// ------------------------------
// 为DXGI对象在图形调试器中设置对象名
// [In]IDXGIObject DXGI对象
// [In]name 对象名
template<UINT TNameLength>
inline void DXGISetDebugObjectName(_In_ IDXGIObject* resource, _In_ const char(&name)[TNameLength])
{
#if (defined(DEBUG) || defined(_DEBUG)) && (GRAPHICS_DEBUGGER_OBJECT_NAME)
resource->SetPrivateData(WKPDID_D3DDebugObjectName, TNameLength - 1, name);
#else
UNREFERENCED_PARAMETER(resource);
UNREFERENCED_PARAMETER(name);
#endif
}
此外,GameObject
类、Model
类、TextureRender
类、SkyRender
类和DynamicSkyRender
类都添加了SetDebugObjectName
方法来为对象设置调试自定义名称。
现在打开图形调试器查看,类似效果如下:
对象具名化后可以十分方便地确认自己有没有正确绑定所需资源。
如果你不希望使用调试器对象具名化,可以在d3dUtil.h
的开头找到这样的宏:
// 默认开启图形调试器具名化
// 如果不需要该项功能,可通过全局文本替换将其值设置为0
#ifndef GRAPHICS_DEBUGGER_OBJECT_NAME
#define GRAPHICS_DEBUGGER_OBJECT_NAME (1)
#endif
将其修改后只会剩下默认的DDSTextureLoader
和WICTextureLoader
的对象具名化。
注意:在你的Release版本应用程序应该避免出现对调试对象名称的设置。你可以将相关代码移出项目。
我们可以在图形调试器查看顶点缓冲区,索引缓冲区和常量缓冲区。
在上面的事件列表中,我们可以看到很多蓝色字体的对象,这些都可以点进去观察。这里我们以某个绘制事件绑定的顶点缓冲区为例
我们可以观察到缓冲区的字节数、使用情况、绑定标签、CPU访问权限等。其中观察到的数据取决于我们设置的格式。
图形调试器支持观察的基本类型如下:
大类 | 基本类型 |
---|---|
有符号字节类型 | byte(sbyte) 2byte 4byte 8byte |
无符号字节类型 | ubyte u2byte u4byte u8byte |
十六进制字节类型 | xbyte x2byte x4byte x8byte |
有符号整型 | short int int64(long) |
无符号整型 | ushort uint uint64(ulong) |
十六进制整型 | xshort xint xint64(xlong) |
半精度浮点型 | half half2 half3 half4 |
单精度浮点型 | float float2 float3 float4 |
双精度浮点型 | double |
除此之外,格式栏允许我们输入以支持不同基本类型的组合。比如说现在传入的顶点包含位置、法向量和纹理坐标,那我们可以在格式栏输入float3 float3 float2
来将输入的数据重新解释成我们传入的顶点信息:
同样,对于索引缓冲区,我们可以在格式栏输入short short short
或int int int
来观察三个索引组装一个图元的索引数组:
而对于常量缓冲区来说,一个着色器阶段可能会绑定多个常量缓冲区,传入的数据取决于你调用的ID3D11DeviceContext::*SSetConstantBuffers
方法绑定的常量缓冲区以及最近一次ID3D11DeviceContext::UpdateSubresource
方法更新的数据,而使用的缓冲区取决于你在着色器写的代码。比如有下面这个常量缓冲区块:
// 物体表面材质
struct Material
{
float4 Ambient;
float4 Diffuse;
float4 Specular; // w = SpecPower
float4 Reflect;
};
cbuffer CBChangesOnResize : register(b3)
{
matrix g_Proj;
}
我们使用float4
格式就可以观察信息。其中每个矩阵占了4行:
因为着色器资源视图中可以绑定一张纹理,也可以绑定一个纹理数组。这里我以另一个程序的图形调试作为实例,演示如何观察绑定到渲染管线上的纹理资源。
点击PS着色器资源的蓝字部分(Grass.dds),可以查看着色器资源的状态
现在我们要查看着色器资源绑定的内容,点击资源对应的蓝字(DDSTextureLoader)就可以查看绑定的纹理资源。
这里我们可以观察到加载的纹理格式。在经过DDSTextureLoader
或WICTextureLoader
加载的纹理会自动生成MipMap链,现在加载的是一张512x512的纹理,它有10张子资源,选择Mip切片可以查看其余子资源纹理。随着Mip切片等级增大,宽度和高度逐渐是原来上一级的1/2.
而在通道直方图中,默认观察的是纹理RGB通道颜色的组合,你可以取消勾选来关闭某一通道的颜色,或者修改范围来选择颜色的可视范围。若选择Alpha通道,则只会单独观察该通道的颜色。下面是原来用的篱笆盒Alpha通道的情况(白色为Alpha值1, 黑色为Alpha值0):
接下来是纹理数组的观察,其实和之前的操作差不多,但有时候我们在绘制过程可能找不到之前绑定上的纹理,我们可以通过下面的对象表来寻找。对象表已经包含了由D3D设备创建出来的绝大多数资源或对象。
这里用的是公告板的例子,比如我现在要寻找纹理资源,在搜索栏输入Texture
来根据类型进行查找:
纹理数组加载了4张纹理,它的字节大小也应该是最大的,双击它就可以看到树的纹理了:
当然,如果给对象具名化,在这里面找对象会更加容易一些:
细心的话可以发现有些资源是有个时间标志的,点击它可以查看该资源的历史变更情况,即有哪些方法对该资源进行了变更。
比如说我点击了PS着色器资源:Grass.dds
右边的时间标志(VS2015不支持)
就可以在右边看到资源的读取和写入情况:
然后点击查看就可以看到该资源当时的具体情况了。
选择一个绘制事件,然后在下面的状态栏就可以看到跟上一绘制事件相比,有哪些阶段发生了变化。变化的部分会有红色高亮显示。在该状态可以查看当前绘制已经绑定的所有资源、着色器和状态,相比对象表查找起来会更清晰一些。
同样是要先选择一个绘制事件,然后在下面的状态栏选择管道阶段,就可以看到当前运行的各个着色阶段,以及是否存在从某个阶段开始就没有输入/输出或者没有执行的问题。
对于3D模型,你可以点击输入装配器进入预览网格界面来观察加载出来的网格。
要对场景进行操作,必须要选择上行的其中一个工具才能对场景操作。
若要对物体进行操作,则必须要选择左边列的其中一个工具来对其操作。
此外,你可以观察物体的法向量或面向量
你也可以通过上图右边的属性栏修改物体的基本属性。至于其余功能你可以自行探索。
对于可编程的顶点着色器阶段来说,我们可以看到视图:输入/输出栏有 输入/输出的每个顶点的值和对应语义。其中SV_POSITION
的值是未经过透视除法的,我们可以将(x, y, z, w)
的每个分量除以w
,变成(x/w, y/w, z/w, 1)
来观察它是否位于NDC坐标系(齐次裁剪坐标系)内,若不在则该顶点不会传递给下一阶段。每个顶点都可以单独进行着色器调试。
注意:在像素着色器中,SV_POSITION
的x
分量和y
分量都已经经过视口变换成为最终的屏幕坐标,且带有小数点0.5
,这是因为要取到像素的中心位置,即对于800x600的视口区域,实际上的屏幕坐标取值范围为[0.5, 800.5]x[0.5, 600.5]
,z
分量取值范围为[0, 1]
。这一点读者可以修改像素着色器使得SV_POSITION
与像素颜色结果有关联,然后进入调试以验证。
将视图:输入/输出切换成绑定的资源,同样也能看到在该着色器阶段绑定了哪些资源可供使用。
切换到像素着色器有可能是看不到任何的输入和输出的,但可以通过另一种方式,指定像素来观察该像素经历的像素着色器阶段。这里在下面会讲到。
最后是输出合并器,切换到绑定的资源,可以看到输出合并阶段绑定的深度/模板缓冲区和后备缓冲区的状态。
紧接着刚才所讲的内容,点击左边的深度/模板缓冲区,我们就可以看到一张以红色为背景,黑色代表深度值的纹理。黑色越深,深度值越小。
因为这张图没有模板值的变更,我再选择一张带有模板和深度值的输出来演示。
实际上在这里,包含有模板值的区域应当是绿色,但是连同深度缓冲区的红色混在一起就变成了黄色,我们可以关闭深度部分来观察只包含模板值的绿色部分。
另一种方式就是更改查看方式。如DXGI_FORMAT_D24_UNORM_S8_UINT
同时包含了模板值和深度值,那DXGI_FORMAT_R24_UNORM_X8_TYPELESS
就只包含了深度值,DXGI_FORMAT_X24_TYPELESS_G8_UINT
则只包含了模板值。
点击加载的报告XX-XX.vsglog,然后选择要观察的某一个像素,就可以看到该像素从开始到结束都经历了哪些绘制步骤,在某一个绘制事件还可以看到它属于顶点/几何着色器的哪一个图元内,以及像素着色器、输出合并器的经历。
接下来就开始进入到重点部分了,使用图形调试器的核心目的还是要观察着色器运行的时候遇到了哪些问题。当然有时候甚至会遇到该有的着色器却被跳过不执行的情况,这时候就先要去前面排查该绑定的资源、状态、着色器、输入是否都OK了,然后才是对上一个正常运行的着色器进行调试。
回到管线阶段或者在像素的绘制历史,指定某一个着色器阶段,选择一个元素,点击一个类似播放的按钮就可以开始进入着色器调试。
然后就会在着色器代码实际可执行的第一行暂停停住。你可以设置断点,也可以单步调试,像之前在VS调试那样来调试。此时首先你需要优先关注局部变量中各个会被用到的常量、输入值是否都是正常的,如果出现常量缓冲区中的值全0或者乱值的情况,说明常量缓冲区可能没有被更新。若常量缓冲区的值在从C++端传入到这里出现问题,你还需要去观察常量缓冲区的打包是否出现了问题。
关于HLSL的打包规则,可以查看这里:
深入理解HLSL常量缓冲区打包规则
若出现局部变量有未使用的说明,有可能在这个调试器的确根本不会用到这个值,又或者你忘记将该常量缓冲区绑定到该着色器阶段了。
而局部变量出现在作用域内的说明,则可能是该变量还没被声明出来或者没被赋值,需要继续执行才能看到。
一般来说我们看着色器的反汇编不主要是为了看汇编指令,而是它还附带了一些额外的信息,如该着色器使用了哪些常量缓冲区、结构体,输入/输出签名如何,这些常量缓冲区经过打包后各个元素所处的字节偏移量如何。
有的同学在还没开始进行GPU调试的时候点击了管道阶段的蓝字,然后看到编译器输出那栏字,以为反汇编没有开启。实际上是你的打开方式不对。
进入着色器调试后,对着色器代码右键,选择 转到反汇编,就可以看到反汇编指令,又或者是点击上方的反汇编窗口切换:
然后一路往上滚,滚到开头就可以看到上述所说的内容:
在某些特殊情况下,你可以需要用到编程捕获的方法:
DirectX 11.2 API需要Windows 8.1及更高版本系统的支持。接下来你需要完成下面的任务:
IDXGraphicsAnalysis
接口注意:以前的编程捕获的实现依赖于Visual Studio远程工具提供的捕获功能,但从Windows 8.1起可以直接通过Direct3D 11.2来支持捕获功能。因此,你不需要在Windows 8.1上安装用于编程捕获的远程工具。
首先你需要包含下面的这些头文件:
#include
#include
#include
#include
需要注意的是,这些头文件无法与头文件vsgcapture.h
兼容,因为它不能与DirectX 11.2兼容。如果在d3d11_2.h
后面包含该头文件,编译器将会发出警告;而如果在d3d11_2.h
前面包含该头文件,应用程序将不会启动。
此外,如果你的电脑安装了DirectX SDK(June 2010),并且你的项目包含路径包括了%(DXSDK_DIR)Include\
,请将它移到包含路径的最末端或者去掉。
然后你需要添加下面代码以获取DXGI调试接口IDXGraphicsAnalysis
:
IDXGraphicsAnalysis* pGraphicsAnalysis;
HRESULT getAnalysis = DXGIGetDebugInterface1(0, __uuidof(pGraphicsAnalysis), reinterpret_cast<void**>(&pGraphicsAnalysis));
if (FAILED(getAnalysis))
{
// 终止你的应用程序
}
如果你没有以图形调试形式启动程序,DXGIGetDebugInterface1
将返回E_NOINTERFACE
现在假定你已经获取了一个能用的IDXGraphicsAnalysis
接口,你可以使用BeginCapture
和EndCapture
方法捕获图形信息:
pGraphicsAnalysis->BeginCapture();
// ...这部分管线命令都将被捕获到
pGraphicsAnalysis->EndCapture();
现在,你应该可以看到计算着色器的调试了:
如果你的系统不支持DirectX 11.2及以上版本的API,则可以使用该旧版图形捕获方法。这种方法适用于任意DirectX 11.X版本API中使用。
首先你需要包含头文件vsgcapture.h
,然后创建VsgDbg
对象。关于VsgDbg
类,目前你只需要了解这些方法:
方法名 | 描述 |
---|---|
构造函数 | 形参指定为true 时,将默认产生临时的vsglog文件 |
BeginCapture | 从该语句执行起捕获所有的GPU事件 |
EndCapture | 结束以BeginCature开始的捕获事件 |
CaptureCurrentFrame | 捕获从当前语句到这一帧结束的所有GPU事件 |
通常情况下我们可以构造函数的形参指定为true
,然后可以开始捕获图形信息:
pVSGraphicsDebugger->BeginCapture();
// ...这部分管线命令都将被捕获到
pVSGraphicsDebugger->EndCapture();
要想了解更多的信息,可以查阅MSDN文档(编程捕获)
调试技巧需要经常使用才能够熟练掌握,相比普通调试来说,图形调试会更加复杂。**在初学DX的阶段容易在资源管理上出问题,因此重点是要先确认在绘制之前,绑定到渲染管线的各种资源是否正常,然后才是对着色器代码进行调试。**所以前期准备工作的出错一般占很大的一部分,而着色器代码引发的错误可能只是占较小的一部分。**等到了渲染管线的资源绑定管理体系逐渐稳定以后,使用图形调试的重心才会逐渐转移到以着色器代码的调试为主。**有时候图形调试器解决不了的问题,还需要仔细观察普通调试下的输出窗口是否有渲染管线绘制事件执行时输出的报错信息。
当然里面还有很多强大的功能没有挖掘出来,或者现在还不是比较常用而没列出来。有兴趣的读者可以查看微软的官方中文文档了解一下:
Visual Studio 图形诊断概述
这篇博客在后续还会有所变动,因为后续个人的学习会引发新的调试需求而变动。
DirectX11 With Windows SDK完整目录
Github项目源码