在 Direct3D 中,着色器程序必须先被编译为一种可移植的字节码。接下来,图形驱动程序将获取这些字节码,并将其重新编译为针对当前系统 GPU 所优化的本地指令 [ATI1]。我们可以在运行期间用下列函数对着色器进行编译。
cpp
HRESULT D3DCompileFromFile(
LPCWSTR pFileName,
const D3D_SHADER_MACRO *pDefines,
ID3DInclude *pInclude,
LPCSTR pEntrypoint,
LPCSTR pTarget,
UINT Flags1,
UINT Flags2,
ID3DBlob **ppCode,
ID3DBlob **ppErrorMsgs);
-
pFileName:我们希望编译的以 .hlsl 作为扩展名的 HLSL 源代码文件。
-
pDefines:在本书中,我们并不使用这个高级选项,因此总是将它指定为空指针。关于此参数的详细信息可参见 SDK 文档。
-
pInclude:在本书中,我们并不使用这个高级选项,因而总是将它指定为空指针。关于此参数的详细信息可详见 SDK 文档。
-
pEntrypoint:着色器的入口点函数名。一个 .hlsl 文件可能存有多个着色器程序(例如,一个顶点着色器和一个像素着色器),所以我们需要为待编译的着色器指定入口点。
-
pTarget:指定所用着色器类型和版本的字符串。在本书中,我们采用的着色器模型版本是 5.0 和 5.1。
a) vs_5_0 与 vs_5_1:表示版本分别为 5.0 和 5.1 的顶点着色器(vertex shader)。
b) hs_5_0 与 hs_5_1:表示版本分别为 5.0 和 5.1 的外壳着色器(hull shader)。
c) ds_5_0 与 ds_5_1:表示版本分别为 5.0 和 5.1 的域着色器(domain shader)。
d) gs_5_0 与 gs_5_1:表示版本分别为 5.0 和 5.1 的几何着色器(geometry shader)。
e) ps_5_0 与 ps_5_1:表示版本分别为 5.0 和 5.1 的像素着色器(pixel shader)。
f) cs_5_0 与 cs_5_1:表示版本分别为 5.0 和 5.1 的计算着色器(compute shader)。
- Flags1:指示对着色器代码应当如何编译的标志。在 SDK 文档里,这些标志列出得不少,但是此书中我们仅用两种。
a) D3DCOMPILE_DEBUG:用调试模式来编译着色器。
b) D3DCOMPILE_SKIP_OPTIMIZATION:指示编译器跳过优化阶段(对调试很有用处)。
-
Flags2:我们不会用到处理效果文件的高级编译选项,关于它的信息请参见 SDK 文档。
-
ppCode:返回一个指向 ID3DBlob 数据结构的指针,它存储着编译好的着色器对象字节码。
-
ppErrorMsgs:返回一个指向 ID3DBlob 数据结构的指针。如果在编译过程中发生了错误,它便会储存报错的字符串。
ID3DBlob 类型描述的其实就是一段普通的内存块,这是该接口的两个方法:
a) LPVOID GetBufferPointer:返回指向 ID3DBlob 对象中数据的 void* 类型的指针。由此可见,在使用此数据之前务必先要将它转换为适当的类型(参考下面的示例)。
b) SIZE_T GetBufferSize:返回缓冲区的字节大小(即该对象中的数据大小)。
为了能够输出错误信息,我们在 d3dUtil.h/.cpp 文件中实现了下列辅助函数在运行时编译着色器:
cpp
// d3dUtil.cpp 第90行
ComPtr<ID3DBlob> d3dUtil::CompileShader(
const std::wstring& filename,
const D3D_SHADER_MACRO* defines,
const std::string& entrypoint,
const std::string& target)
{
// 若处于调试模式,则使用调试标志
UINT compileFlags = 0;
#if defined(DEBUG) || defined(_DEBUG)
compileFlags = D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION;
#endif
HRESULT hr = S_OK;
ComPtr<ID3DBlob> byteCode = nullptr;
ComPtr<ID3DBlob> errors;
hr = D3DCompileFromFile(filename.c_str(), defines, D3D_COMPILE_STANDARD_FILE_INCLUDE,
entrypoint.c_str(), target.c_str(), compileFlags, 0, &byteCode, &errors);
// 将错误信息输出到调试窗口
if(errors != nullptr)
OutputDebugStringA((char*)errors->GetBufferPointer());
ThrowIfFailed(hr);
return byteCode;
}
以下是一个调用此函数的示例:
cpp
ComPtr<ID3DBlob> mvsByteCode = nullptr; // BoxApp.cpp 第65行
ComPtr<ID3DBlob> mpsByteCode = nullptr; // BoxApp.cpp 第66行
// BoxApp.cpp 第354行
mvsByteCode = d3dUtil::CompileShader(L"Shaders\\color.hlsl", nullptr, "VS", "vs_5_0");
mpsByteCode = d3dUtil::CompileShader(L"Shaders\\color.hlsl", nullptr, "PS", "ps_5_0");
HLSL 的错误和警告消息将通过 ppErrorMsgs 参数返回。比方说,如果不小心把 mul 函数拼写错误,那么我们便会从调试窗口得到类似于下列的错误输出:
仅对着色器进行编译并不会使它与渲染流水线相绑定以供其使用。
1. 离线编译
我们不仅可以在运行期间编译着色器,还能够以单独的步骤(例如,将其作为构建整个工程过程中的一个独立环节,或是将其视为资源内容流水线(asset content pipeline)流程的一部分)离线地(offline)编译着色器。这样做有原因若干:
-
对于复杂的着色器来说,其编译过程可能耗时较长。因此,借助离线编译即可缩短应用程序的加载时间。
-
以便在早于运行时的构建处理期间提前发现编译错误。
-
对于 Windows 8 应用商店中的应用而言,必须采用离线编译这种方式。
我们通常用 .cso(即 compiled shader object,已编译的着色器对象)作为已编译着色器的扩展名。
为了以离线的方式编译着色器,我们将使用 DirectX 自带的 FXC 命令行编译工具。为了将 color.hlsl 文件中分别以 VS 和 PS 作为入口点的顶点着色器和像素着色器编译为调试版本的字节码,我们可以输入以下命令:
cpp
fxc "color.hlsl" /Od /Zi /T vs_5_0 /E "VS" /Fo "color_vs.cso" /Fc "color_vs.asm"
fxc "color.hlsl" /Od /Zi /T ps_5_0 /E "PS" /Fo "color_ps.cso" /Fc "color_ps.asm"
为了将 color.hlsl 文件中分别以 VS 和 PS 作为入口点的顶点着色器和像素着色器编译为发行版本的字节码,则可以输入以下命令:
cpp
fxc "color.hlsl" /T vs_5_0 /E "VS" /Fo "color_vs.cso" /Fc "color_vs.asm"
fxc "color.hlsl" /T ps_5_0 /E "PS" /Fo "color_ps.cso" /Fc "color_ps.asm"
|----------------|----------------------------------------------|
| 参数 | 描述 |
| /Od | 禁用优化(对于调试十分有用) |
| /Zi | 开启调试信息 |
| /T <string> | 着色器类型和着色器模型的版本 |
| /E <string> | 着色器入口点 |
| /Fo <string> | 经过编译的着色器对象字节码 |
| /Fc <string> | 输出一个着色器的汇编文件清单(对于调试、检验指令数量、查阅生成的代码细节都是很有帮助的) |
如果试图编译一个有语法错误的着色器,则 FXC 会将错误/警告消息输出到命令窗口。
既然已经按离线的方式把顶点着色器和像素着色器编译到 .cso 文件里,也就不需要在运行时对其进行编译(即,无须再调用 D3DCompileFromFile 方法)。但是,我们仍要将 .cso 文件中已编译好的着色器对象字节码加载到应用程序中,这可以由 C++ 的标准文件输入机制来加以实现,如:
cpp
// d3dUtil.cpp 第21行
ComPtr<ID3DBlob> d3dUtil::LoadBinary(const std::wstring& filename)
{
std::ifstream fin(filename, std::ios::binary);
fin.seekg(0, std::ios_base::end);
std::ifstream::pos_type size = (int)fin.tellg();
fin.seekg(0, std::ios_base::beg);
ComPtr<ID3DBlob> blob;
ThrowIfFailed(D3DCreateBlob(size, blob.GetAddressOf()));
fin.read((char*)blob->GetBufferPointer(), size);
fin.close();
return blob;
}
...
ComPtr<ID3DBlob> mvsByteCode = d3dUtil::LoadBinary(L"Shaders\\color_vs.cso");
ComPtr<ID3DBlob> mpsByteCode = d3dUtil::LoadBinary(L"Shaders\\color_ps.cso");
2. 生成着色器汇编代码
FXC 程序根据可选参数 /Fc 来生成可移植的着色器汇编代码。通过查阅着色器的汇编代码,既可核对着色器的指令数量,也能了解生成的代码细节------这是为了验证编译器所生成的代码与我们预想的是否一致。例如,如果我们在 HLSL 代码中写了一个条件语句,那么可能会认为汇编代码中将存在一条与之对应的分支指令。在可编程 GPU 发展的初期阶段中,在着色器里使用分支指令的代价是比较高昂的。因此,编译器时常会通过对两个分支展开求值,再对求值结果进行插值来整理条件语句,以避免采用分支指令并计算出正确的结果。例如,下列两组代码是等价的:
|-----------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
| 条件语句 | 整理后 |
| float x = 0; // s == 1 (true) or s == 0 (false) if(s) x = sqrt(y); else x = 2*y; | float a = 2*y; float b = sqrt(y); float x = a + s*(b-a); // s == 1: x = a + b - a = b = sqrt(y) // s == 0: x = a + 0*(b - a) = a = 2*y |
因此,若采用这种展开整理方法,我们将得到没有任何分支语句而效果却又与整理前相同的代码。但是,在不查阅着色器汇编代码的情况下,我们无法知道此展开过程是否发生,甚至不能验证生成的分支指令是否正确。有时,查看着色器汇编代码的目的是为了弄清它到底做了什么。下面就是一个由 color.hlsl 文件中顶点着色器生成的汇编代码示例:
cpp
//
// 生成自微软(R) HLSL着色器编译器 6.4.9844.0
//
//
// 缓冲区定义
//
// cbuffer cbPerObject
// {
//
// float4x4 gWorldViewProj; // 偏移量: 0 大小: 64
//
// }
//
//
// 资源绑定
//
// 名称 类型 格式 维度 槽 元素
// ------------ -------- ------ ----- --- ------- -----------
// cbPerObject cbuffer NA NA 0 1
//
//
//
// 输入签名
//
// 名称 索引 掩码 寄存器 系统值 格式 使用情况
// -------- ---------- ----- ------ -------- -------- ---------
// POSITION 0 xyz 0 NONE float xyz
// COLOR 0 xyzw 1 NONE float xyzw
//
//
// 输出签名
//
// 名称 索引 掩码 寄存器 系统值 格式 使用情况
// -------- ----------- ----- ------ -------- -------- -------
// SV_POSITION 0 xyzw 0 POS float xyzw
// COLOR 0 xyzw 1 NONE float xyzw
//
vs_5_0
dcl_globalFlags refactoringAllowed | skipOptimization
dcl_constantbuffer cb0[4], immediateIndexed
dcl_input v0.xyz
dcl_input v1.xyzw
dcl_output_siv o0.xyzw, position
dcl_output o1.xyzw
dcl_temps 2
//
// 初始化变量关系
// v0.x <- vin.PosL.x; v0.y <- vin.PosL.y; v0.z <- vin.PosL.z;
// v1.x <- vin.Color.x; v1.y <- vin.Color.y; v1.z <- vin.Color.z; v1.w <- vin.Color.w;
// o1.x <- <VS return value>.Color.x;
// o1.y <- <VS return value>.Color.y;
// o1.z <- <VS return value>.Color.z;
// o1.w <- <VS return value>.Color.w;
// o0.x <- <VS return value>.PosH.x;
// o0.y <- <VS return value>.PosH.y;
// o0.z <- <VS return value>.PosH.z;
// o0.w <- <VS return value>.PosH.w
//
#第29行"color.hlsl"
mov r0.xyz, v0.xyzx
mov r0.w, l(1.000000)
dp4 r1.x, r0.xyzw, cb0[0].xyzw // r1.x <- vout.PosH.x
dp4 r1.y, r0.xyzw, cb0[1].xyzw // r1.y <- vout.PosH.y
dp4 r1.z, r0.xyzw, cb0[2].xyzw // r1.z <- vout.PosH.z
dp4 r1.w, r0.xyzw, cb0[3].xyzw // r1.w <- vout.PosH.w
#第32行
mov r0.xyzw, v1.xyzw // r0.x <- vout.Color.x; r0.y <- vout.Color.y;
// r0.z <- vout.Color.z; r0.w <- vout.Color.w
mov o0.xyzw, r1.xyzw
mov o1.xyzw, r0.xyzw
ret
// 大约使用了10个指令槽
3. 利用 Visual Studio 离线编译着色器
我们可以向工程内添加 .hlsl 文件,而 Visual Studio 会识别它们并提供编译的选项。这些在 UI 中配置的选项就是 FXC 程序的参数。在向 VS 工程中添加 HLSL 文件后,它将成为构建流程的一部分,而着色器也将会被 FXC 程序所编译。
但是,使用 VS 集成的 HLSL 工具却有一个缺点,即它只允许每个文件中仅有一个着色器程序。因此,这条限制将令顶点着色器和像素着色器不能共存于一个文件里。此外,我们有时希望以不同的预处理指令(preprocessor directives)编译同一个着色器程序,从而获取同一着色器的不同编译结果。同样地,如果使用集成的 VS 工具就不可能做到这一点,因为每输入一个 .hlsl 文件则只能输出一个 .cso 文件。