在 Direct3D 中,着色器程序必须先被编译为一种可移植的字节码。接下来,图形驱动程序将获取这些字节码,并将其重新编译为针对当前系统 GPU 所优化的本地指令 [ATI1]。我们可以在运行期间用下列函数对着色器进行编译。

HRESULT D3DCompileFromFile(
  LPCWSTR pFileName,
  const D3D_SHADER_MACRO *pDefines,
  ID3DInclude *pInclude,
  LPCSTR pEntrypoint,
  LPCSTR pTarget,
  UINT Flags1,
  UINT Flags2,
  ID3DBlob **ppCode,
  ID3DBlob **ppErrorMsgs);

1. pFileName:我们希望编译的以 .hlsl 作为扩展名的 HLSL 源代码文件。

2. pDefines:在本书中,我们并不使用这个高级选项,因此总是将它指定为空指针。关于此参数的详细信息可参见 SDK 文档。

3. pInclude:在本书中,我们并不使用这个高级选项,因而总是将它指定为空指针。关于此参数的详细信息可详见 SDK 文档。

4. pEntrypoint:着色器的入口点函数名。一个 .hlsl 文件可能存有多个着色器程序(例如,一个顶点着色器和一个像素着色器),所以我们需要为待编译的着色器指定入口点。

5. 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)。

6. Flags1:指示对着色器代码应当如何编译的标志。在 SDK 文档里,这些标志列出得不少,但是此书中我们仅用两种。

        a) D3DCOMPILE_DEBUG:用调试模式来编译着色器。

        b) D3DCOMPILE_SKIP_OPTIMIZATION:指示编译器跳过优化阶段(对调试很有用处)。

7. Flags2:我们不会用到处理效果文件的高级编译选项,关于它的信息请参见 SDK 文档。

8. ppCode:返回一个指向 ID3DBlob 数据结构的指针,它存储着编译好的着色器对象字节码。

9. ppErrorMsgs:返回一个指向 ID3DBlob 数据结构的指针。如果在编译过程中发生了错误,它便会储存报错的字符串。

ID3DBlob 类型描述的其实就是一段普通的内存块,这是该接口的两个方法:

        a) LPVOID GetBufferPointer:返回指向 ID3DBlob 对象中数据的 void* 类型的指针。由此可见,在使用此数据之前务必先要将它转换为适当的类型(参考下面的示例)。

        b) SIZE_T GetBufferSize:返回缓冲区的字节大小(即该对象中的数据大小)。

为了能够输出错误信息,我们在 d3dUtil.h/.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;
}

以下是一个调用此函数的示例:

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)编译着色器。这样做有原因若干:

1. 对于复杂的着色器来说,其编译过程可能耗时较长。因此,借助离线编译即可缩短应用程序的加载时间。

2. 以便在早于运行时的构建处理期间提前发现编译错误。

3. 对于 Windows 8 应用商店中的应用而言,必须采用离线编译这种方式。

我们通常用 .cso(即 compiled shader object,已编译的着色器对象)作为已编译着色器的扩展名。

为了以离线的方式编译着色器,我们将使用 DirectX 自带的 FXC 命令行编译工具。为了将 color.hlsl 文件中分别以 VS 和 PS 作为入口点的顶点着色器和像素着色器编译为调试版本的字节码,我们可以输入以下命令:

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 作为入口点的顶点着色器和像素着色器编译为发行版本的字节码,则可以输入以下命令:

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++ 的标准文件输入机制来加以实现,如:

// 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 文件中顶点着色器生成的汇编代码示例:

//
// 生成自微软(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 文件。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部