最爱午后红茶

BoxApp 的初始化

日期图标
2023-06-10

BoxApp 的构造

先看看 BoxApp 的构造函数:

BoxApp::BoxApp(HINSTANCE hInstance)
    : D3DApp(hInstance), mBoxVB(0), mBoxIB(0), mFX(0), mTech(0),
      mfxWorldViewProj(0), mInputLayout(0), mTheta(1.5f * MathHelper::Pi),
      mPhi(0.25f * MathHelper::Pi), mRadius(5.0f) {
  mMainWndCaption = L"Box Demo";

  mLastMousePos.x = 0;
  mLastMousePos.y = 0;

  XMMATRIX I = XMMatrixIdentity();
  // 创建单位世界矩阵,说明要直接在世界空间中定义物体
  XMStoreFloat4x4(&mWorld, I);
  XMStoreFloat4x4(&mView, I);
  XMStoreFloat4x4(&mProj, I);
}

首先在 3D 场景中有几个坐标相关的概念需要先理解清楚:

  • 局部坐标系(Local Space,局部空间)
  • 全局场景坐标系(World Space,世界空间)
  • 观察空间坐标系(View Space,观察空间)
世界空间和局部空间
图一:世界空间和局部空间
观察空间
图二:观察空间

局部坐标系是指每个模型都有各自的坐标系。为了方便计算,通常局部坐标系的坐标轴方向与模型的方向对齐(如上图一每个模型的坐标系)。当模型在各自的局部坐标系中制作好了之后,就可以通过一些组合变换把模型变换到世界空间,这个变换称为世界变换(World Transform)。

特别的,如果想要直接在世界空间中定义模型,可以把这个世界变换矩阵设置成单位世界矩阵Identity World Matrix),因为任何矩阵跟单位矩阵相乘的结果还是矩阵本身。这个程序的立方体就是直接在世界空间中定义的,其世界变换矩阵对应的是上述代码的 mWorld

将模型放到世界空间后,我们需要放置一台虚拟摄像机(如上图二),这样,我们才能去定义我们看到的场景范围。DirectX 是以相机位置为原点,以 zz正方向为观察方向,以 yy 轴正方向为向上方向建立观察空间坐标系的。为了后续阶段的方便处理,我们需要根据相机在世界空间中的位置和朝向进行从世界空间到观察空间的变换,该变换称为观察变换View Transform)。变换矩阵对应上述代码的 mView。此时它被初始化为单位矩阵,后面会进行更新的。

mProj 是投影变换矩阵,具体在后面说。此时它也被初始化为单位矩阵,后面会进行更新。

程序初始化

bool BoxApp::Init() {
  if (!D3DApp::Init())
    return false;

  BuildGeometryBuffers();
  BuildFX();
  BuildVertexLayout();

  return true;
}

BoxApp::Init() 除了对其父类进行必要的初始化之外,还做了 3 件事情:

  1. 创建立方体的顶点缓冲区索引缓冲区
  2. 创建 Effect
  3. 创建顶点输入布局

顶点缓冲区和索引缓冲区

先看 BuildGeometryBuffers()

void BoxApp::BuildGeometryBuffers() {
  // Create vertex buffer
  // 定义立方体八个顶点位置及颜色
  Vertex vertices[] = {
      {XMFLOAT3(-1.0f, -1.0f, -1.0f), (const float *)&Colors::White},
      {XMFLOAT3(-1.0f, +1.0f, -1.0f), (const float *)&Colors::Black},
      {XMFLOAT3(+1.0f, +1.0f, -1.0f), (const float *)&Colors::Red},
      {XMFLOAT3(+1.0f, -1.0f, -1.0f), (const float *)&Colors::Green},
      {XMFLOAT3(-1.0f, -1.0f, +1.0f), (const float *)&Colors::Blue},
      {XMFLOAT3(-1.0f, +1.0f, +1.0f), (const float *)&Colors::Yellow},
      {XMFLOAT3(+1.0f, +1.0f, +1.0f), (const float *)&Colors::Cyan},
      {XMFLOAT3(+1.0f, -1.0f, +1.0f), (const float *)&Colors::Magenta}};

  D3D11_BUFFER_DESC vbd;
  // 创建资源后,资源中的内容不会改变
  vbd.Usage = D3D11_USAGE_IMMUTABLE;
  // 顶点缓冲区大小(字节)
  vbd.ByteWidth = sizeof(Vertex) * 8;
  // 缓冲区类型
  vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
  // 指定 CPU 对资源的访问权限。0 表示 CPU 无需读写缓冲
  vbd.CPUAccessFlags = 0;
  vbd.MiscFlags = 0;
  // 用于结构化缓冲,其他缓冲为 0
  vbd.StructureByteStride = 0;
  D3D11_SUBRESOURCE_DATA vinitData;
  // 指向顶点数据
  vinitData.pSysMem = vertices;
  HR(md3dDevice->CreateBuffer(&vbd, &vinitData, &mBoxVB));

  // Create the index buffer
  // 指定组成立方体的每个三角形的索引
  // clang-format off
  UINT indices[] = {
      // front face
      0, 1, 2,
      0, 2, 3,

      // back face
      4, 6, 5,
      4, 7, 6,

      // left face
      4, 5, 1,
      4, 1, 0,

      // right face
      3, 2, 6,
      3, 6, 7,

      // top face
      1, 5, 6,
      1, 6, 2,

      // bottom face
      4, 0, 3,
      4, 3, 7
  };
  // clang-format on

  D3D11_BUFFER_DESC ibd;
  ibd.Usage = D3D11_USAGE_IMMUTABLE;
  ibd.ByteWidth = sizeof(UINT) * 36;
  ibd.BindFlags = D3D11_BIND_INDEX_BUFFER;
  ibd.CPUAccessFlags = 0;
  ibd.MiscFlags = 0;
  ibd.StructureByteStride = 0;
  D3D11_SUBRESOURCE_DATA iinitData;
  iinitData.pSysMem = indices;
  HR(md3dDevice->CreateBuffer(&ibd, &iinitData, &mBoxIB));
}

我们需要通过调用 md3dDevice->CreateBuffer() 创建顶点缓冲区。而顶点的数据类型是可以自定义的,比如该程序的顶点就被定义成如下结构:

struct Vertex {
  XMFLOAT3 Pos;
  XMFLOAT4 Color;
};

可以看到立方体的顶点被定义成三维的位置和四维(RGBA)的颜色

立方体的每个面都是正方形,而每个正方形都可以由两个三角形组成。因此一共需要 12 个三角形。接着我们需要根据顶点位置去描述每个三角形。如果我们直接用顶点的结构去描述三角形,根据前面顶点的定义数组,所有三角形将会定义成:

Vertex triangleList[] = {
    // front face
    vertices[0], vertices[1], vertices[2],
    vertices[0], vertices[2], vertices[3],

    // back face
    vertices[4], vertices[6], vertices[5],
    vertices[4], vertices[7], vertices[6],

    // left face
    vertices[4], vertices[5], vertices[1],
    vertices[4], vertices[1], vertices[0],

    // right face
    vertices[3], vertices[2], vertices[6],
    vertices[3], vertices[6], vertices[7],

    // top face
    vertices[1], vertices[5], vertices[6],
    vertices[1], vertices[6], vertices[2],

    // bottom face
    vertices[4], vertices[0], vertices[3],
    vertices[4], vertices[3], vertices[7]
};

我们可以看到,里面有很多被复制的顶点,当模型足够复杂的时候,这种顶点复制的数量会非常大。尤其当顶点被定义成比较大的结构的时候,这种复制会造成内存需求的增加以及 GPU 的负担加重。因此我们通过顶点索引去改进这个问题,即顶点索引的数组只是存储构成三角形的每个顶点的索引值,如 BuildGeometryBuffers() 里面的 UINT indices[]。由于索引只是一个整型,这种对整型的复制是可以接受的。而索引缓冲区同样是通过调用 md3dDevice->CreateBuffer() 来创建。

到目前为止,我们已经完成的操作是:在世界空间中定义了一个 [1,1]3[-1, 1]^3 的立方体模型。

手绘立方体
图三:手绘立方体

创建 Effect

创建完顶点缓冲区和索引缓冲区后,下一步是创建 Effect。

Effect 框架的作用是管理着色器程序和渲染状态。比如可以使用不同的 Effect 去绘制山、水、云、雾等,每个 Effect 至少要包含以下内容:

  • 一个顶点着色器
  • 一个像素着色器
  • 渲染状态(该示例没有使用到,暂时不展开说)

Effect 是一组工具代码,它的内容被保存在 .fx 后缀的文件里面。不过使用 Effect 框架编写着色器代码已经被官方宣布为过时了。在 DirectX 11 版本中,微软将原来的 Effect 框架从 DirectX SDK 中移除,并不再维护和推荐使用,因为虽然这种方式简单易用,但也存在一些问题,如可扩展性差、兼容性低、性能不够优秀等。微软更推荐的,是直接使用 HLSL 语言(High-level shader language)编写着色器代码(事实上,Effect 文件使用的也是 HLSL 语法)。使用纯 HLSL 代码,可以更加灵活地组织着色器逻辑,支持更多的特性和效果,同时还能提高渲染效率和兼容性。不过这个示例既然用到了 Effect,那就扩展说下,后面有时间了再尝试移除 Effect 文件。本程序的 Effect 文件(color.fx)如下:

// 定义常量缓冲
cbuffer cbPerObject { float4x4 gWorldViewProj; };

struct VertexIn {
  float3 PosL : POSITION;
  float4 Color : COLOR;
};

struct VertexOut {
  float4 PosH : SV_POSITION;
  float4 Color : COLOR;
};

// 顶点着色器
VertexOut VS(VertexIn vin) {
  VertexOut vout;

  // Transform to homogeneous clip space.
  vout.PosH = mul(float4(vin.PosL, 1.0f), gWorldViewProj);

  // Just pass vertex color into the pixel shader.
  vout.Color = vin.Color;

  return vout;
}

// 像素着色器
float4 PS(VertexOut pin) : SV_Target { return pin.Color; }

technique11 ColorTech {
  // 定义几何体渲染方式
  pass P0 {
    SetVertexShader(CompileShader(vs_5_0, VS()));
    SetGeometryShader(NULL);
    SetPixelShader(CompileShader(ps_5_0, PS()));
  }
}

gWorldViewProj 是一个常量缓冲,它是由世界矩阵、观察矩阵和投影矩阵组合起来的矩阵(不过此时它还没有值,需要后续从 C++ 里面取该常量赋值),通过它可以把顶点从局部空间变换到齐次裁剪空间

VS() 是顶点着色器。 VertexIn 对应 C++ 中定义的顶点结构体 Vertex 。而输出参数 VertexOut 的语义是将顶点着色器的输出映射到下一阶段(可能是几何着色器或像素着色器)的输入。 SV_POSITION 是 Direct3D 中一种系统值语义(system-value semantic),用于表示顶点着色器输出的顶点位置。在Direct3D中,顶点着色器通常会输出一些与当前处理的图形相关的数据,如顶点位置、法向量、纹理坐标等信息,这些数据都需要使用语义来标识其含义。

通常我们很难直接从代码上看出向量的坐标系是哪种坐标系,因此,经常使用下面的后缀去区分:

  • L(局部空间)
  • W(世界空间)
  • V(观察空间)
  • H(齐次裁剪空间)

这里 VS() 函数的关键代码就是把模型的顶点从局部空间做 WVP变换(对应应该是 Games-101-View transformations 里面的 MVP 变换):

// Transform to homogeneous clip space.
vout.PosH = mul(float4(vin.PosL, 1.0f), gWorldViewProj);

float4(vin.PosL, 1.0f) 是个四维变量,可以展开成 float4(iPosL.x, iPosL.y, iPosL.z, 1.0f) 。因为它表示的是一个点而不是向量,所以最后一个分量是 1 而不是 0。关于该分量的更多信息可参考 Games-101-2D transformations 平移相关的模块。而 mul 则表示两个矩阵相乘。

再往下的 PS() 是像素着色器。由顶点着色器(或几何着色器,如果有的话)输出的顶点属性都已经经过了插值处理,这些插值数据将会作为像素着色器的输入数据。像素着色器要做的工作是为每一个像素片段(pixel fragment)计算出一个颜色值。

像素和像素片段是两个相关但不完全相同的概念。

像素(Pixel)是计算机图形学中最基本的单位之一,指屏幕上显示出来的一个点,代表了屏幕上的一个光栅化单元。在渲染过程中,使用像素来> 描述图像的颜色、位置和大小等属性,是生成图像的最小单元。

而像素片段(Pixel Fragment)则是 OpenGL 或 DirectX 等图形 API 中的一个概念,指的是渲染管线中由像素着色器处理后得到的最终的像素数据。在渲染过程中,例如绘制一个三角形时,首先需要将三角形分割成多个像素片段,然后通过像素着色器为每个像素片段赋予颜色值和深度值等属性,从而生成最终的图像。

因此,像素和像素片段的区别在于它们所处的阶段不同。像素是显示器上的光栅化单元,而像素片段则是在渲染管线中生成的临时数据,用于最终的图像渲染。

再往下就是 technique,每个 Effect 文件至少要包含一个 technique,而每个 technique 又至少要包含一个 pass。从代码里面可以看到,一个 pass 由一个顶点着色器、一个几何着色器(可选)、一个像素着色器(可选,比如在只绘制深度缓存的时候可以不加像素着色器)以及一些渲染状态组成。

Effect 文件的代码是 HLSL 语法,要在 C++ 里使用它里面定义的东西,需要对它进行编译,而 D3DX 提供了接口去做这个事情。不过因为 D3DX 库已经把 Effect 框架移除了,所以要使用 Effect 相关的接口的话,需要自己去编译 Effect 模块的工程(DirectX SDK\Samples\C++\Effects11)。从 Effect 工程生成对应的 .lib 后,连同接口文件 d3dx11Effect.h 一起引入我们项目的工程就可以使用了:

void BoxApp::BuildFX() {
  DWORD shaderFlags = 0;
#if defined(DEBUG) || defined(_DEBUG)
  shaderFlags |= D3D10_SHADER_DEBUG;             // 用于调试
  shaderFlags |= D3D10_SHADER_SKIP_OPTIMIZATION; // 告诉编译器不做优化
#endif

  ID3D10Blob *compiledShader = 0;
  ID3D10Blob *compilationMsgs = 0;

  // 编译着色器
  HRESULT hr =
      D3DX11CompileFromFile(L"FX/color.fx", 0, 0, 0, "fx_5_0", shaderFlags, 0,
                            0, &compiledShader, &compilationMsgs, 0);

  // compilationMsgs can store errors or warnings.
  if (compilationMsgs != 0) {
    MessageBoxA(0, (char *)compilationMsgs->GetBufferPointer(), 0, 0);
    ReleaseCOM(compilationMsgs);
  }

  // Even if there are no compilationMsgs, check to make sure there were no
  // other errors.
  if (FAILED(hr)) {
    DXTrace(__FILE__, (DWORD)__LINE__, hr, L"D3DX11CompileFromFile", true);
  }

  // 创建 Effect
  HR(D3DX11CreateEffectFromMemory(compiledShader->GetBufferPointer(),
                                  compiledShader->GetBufferSize(), 0,
                                  md3dDevice, &mFX));

  // Done with compiled shader.
  ReleaseCOM(compiledShader);

  mTech = mFX->GetTechniqueByName("ColorTech");
  // 取出 MVP 变换矩阵用于后续更新
  mfxWorldViewProj = mFX->GetVariableByName("gWorldViewProj")->AsMatrix();
}

上述方法属于运行时编译 Effect,它会带来一些不便:如果 Effect 文件有错,要在程序运行时才能发现错误。而 DirectX SDK 里面有个 fxc 工具可以离线编译 Effect。另外我们在 VC++ 项目里面也能通过修改配置来调用 fxc 在程序生成过程中编译 Effect:

  1. 确保路径 DirectX SDK\Utilities\bin\x86 (fxc 程序的目录)位于你的项目的 VC++ 目录的“可执行文件目录(Executable Directories)”之下。
  2. 在项目中添加 Effect 文件。
  3. 在解决方案资源管理器中右击每个 Effect 文件选择属性,添加自定义生成工具:
    • 调试模式:fxc /Fc /Od /Zi /T fx_5_0 /Fo " % (RelativeDir)% (Filename).fxo " " % (FuIIPath) "
    • 发布模式:fxc /T fx_5_0 /Fo " o/o (RelativeDir)% (Filename).fxo” " % (FuIIPath) "

这样,在生成项目时就会为每个 Effect 文件生成对应的编译版本,并保存为 .fxo 后缀的文件。可以通过以下的方式去加载使用它:

std::ifstream fin("fx/color.fxo",std::ios::binary);

fin.seekg(0, std::ios_base::end);
int size = (int)fin.tellg();
fin.seekg(0, std::ios_base::beg);
std::vector<char> compiledShader(size);

fin.read(&compiledShader[0],size);
fin.close();

HR(D3DX11CreateEffectFromMemory(&compiledShader[0],size,
    0, md3dDevice, &mFX));

创建顶点输入布局

由于顶点的结构是我们自定义的,所以我们需要有一种方式去告知 Direct3D 该怎样使用顶点结构的每一个分量。这些分量的描述信息可以通过输入布局的形式提供给 Direct3D。

void BoxApp::BuildVertexLayout() {
  // Create the vertex input layout.
  D3D11_INPUT_ELEMENT_DESC vertexDesc[] = {
      {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
       D3D11_INPUT_PER_VERTEX_DATA, 0},
      {"COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12,
       D3D11_INPUT_PER_VERTEX_DATA, 0}};

  // Create the input layout
  D3DX11_PASS_DESC passDesc;
  mTech->GetPassByIndex(0)->GetDesc(&passDesc);
  // passDesc.pIAInputSignature: 顶点着色器签名
  // 创建顶点输入布局时传入对应的顶点着色器签名,函数内部会验证输入布局和顶点输入签名是否匹配
  // mInputLayout 对象可以在参数完全相同的其他着色器中使用
  HR(md3dDevice->CreateInputLayout(vertexDesc, 2, passDesc.pIAInputSignature,
                                   passDesc.IAInputSignatureSize,
                                   &mInputLayout));
}
* 未经同意不得转载。