最爱午后红茶

绘制立方体

日期图标
2023-06-13

要把模型绘制在屏幕上,就要把模型上的每个点从局部坐标转换成屏幕像素坐标,这一般需要依次经过以下变换:

  1. 模型变换Model Transform
    使用模型变换矩阵将物体从本地坐标系(Local Space)转换到模型空间(即世界空间)。
  2. 视图变换View Transform
    使用视图变换矩阵将物体从模型空间转换到相机空间(View Space)。视图变换矩阵包括了相机的位置、朝向和上方向等信息。
  3. 投影变换Projection Transform
    使用投影变换矩阵将物体从相机空间转换到裁剪空间(Clip Space)。在该空间中,所有的坐标都是齐次坐标,并且坐标范围被规定为 [-1, 1]。
  4. 透视除法Perspective Division
    对裁剪空间中的坐标进行透视除法操作,得到规范化设备坐标(Normalized Device Coordinates,NDC)。透视除法的作用是将物体和相机距离较远的部分进行压缩,从而使得 3D 场景在屏幕上呈现出更加真实的效果。
  5. 视口变换Viewport Transform
    使用视口变换矩阵将规范化设备坐标转换为屏幕像素坐标。视口变换矩阵包括了视口的位置、大小和方向等信息。

基本上每一个变换在 Games-101 中都有详细说明,需理解清楚再往下看。

创建透视投影矩阵

首先是通过 XMMatrixPerspectiveFovLH() 接口创建透视投影矩阵:

void BoxApp::OnResize() {
  D3DApp::OnResize();

  // 创建透视投影矩阵
  // The window resized, so update the aspect ratio and recompute the projection
  // matrix.
  // 垂直视域角设为45°,近平面 z 设为1,远平面 z
  // 设为1000(这些长度是在观察空间中的距离)
  XMMATRIX P = XMMatrixPerspectiveFovLH(0.25f * MathHelper::Pi, AspectRatio(),
                                        1.0f, 1000.0f);
  XMStoreFloat4x4(&mProj, P);
}

调用 XMMatrixPerspectiveFovLH(),可以根据垂直视域角、宽高比(在这里是窗口客户区大小的宽高比)、近平面和远平面的距离创建出对应的透视投影矩阵

从上述代码可以看到,mProj 在每一次 OnResize() 被触发时都会更新。

创建视图变换矩阵

视图(或观察)变换矩阵在 UpdateScene() 中更新,由前面的知识我们知道 UpdateScene() 函数每一帧都会调用。

void BoxApp::UpdateScene(float dt) {
  // Convert Spherical to Cartesian coordinates.
  float x = mRadius * sinf(mPhi) * cosf(mTheta);
  float z = mRadius * sinf(mPhi) * sinf(mTheta);
  float y = mRadius * cosf(mPhi);

  // Build the view matrix.
  //
  // 摄像机相对于世界空间的位置
  XMVECTOR pos = XMVectorSet(x, y, z, 1.0f);
  // 相机看向世界空间原点
  XMVECTOR target = XMVectorZero();
  // 世界坐标系向上的方向
  XMVECTOR up = XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f);

  // 创建左手坐标系下的视图矩阵
  XMMATRIX V = XMMatrixLookAtLH(pos, target, up);
  XMStoreFloat4x4(&mView, V);
}

XNA 库的 XMMatrixLookAtLH() 接口可以用摄像机相对于世界空间的位置、相机的目标点、世界坐标系向上的方向向量作为输入参数创建出对应的视图矩阵。简单说明一下提供这 3 个参数就能确定(摄像机)观察坐标系的原因:

QQ 为摄像机的位置,TT 为摄像机瞄准的目标点,jj 为描述世界空间“向上”方向的单位向量。摄像机的观察方向为:w=TQTQw = \frac{T-Q}{\lVert{T-Q}\rVert}

确定(摄像机)观察坐标系
图一:确定(摄像机)观察坐标系

w\overrightarrow{w} 对应摄像机坐标系的 zz 轴,那指向 w\overrightarrow{w} “右边”的单位向量为:

  • u=j×wj×w\overrightarrow{u} = \frac{\overrightarrow{j} \times \overrightarrow{w}}{\lVert{\overrightarrow{j} \times \overrightarrow{w}}\rVert}

u\overrightarrow{u} 对应摄像机坐标系的 xx 轴,最后,摄像机坐标系 yy 轴的向量为:

  • v=w×u\overrightarrow{v} = \overrightarrow{w} \times \overrightarrow{u}

因此,提供摄像机相对于世界空间的位置、相机的目标点、世界坐标系向上的方向向量,我们就能确定摄像机坐标系,进而能够通过该坐标系创建视图(或观察)矩阵。而 XMMatrixLookAtLH() 帮我们完成了所有工作。

相机的位置

接着我们重点分析相机的位置是怎样计算的。从上面的代码中我们可以知道相机的位置被表示成:

// Convert Spherical to Cartesian coordinates.
float x = mRadius * sinf(mPhi) * cosf(mTheta);
float z = mRadius * sinf(mPhi) * sinf(mTheta);
float y = mRadius * cosf(mPhi);

而从 BoxApp 的构造中我们知道:

mRadius = 5.0f;
mPhi = 0.25 * MathHelper::Pi;
mTheta = 1.5f * MathHelper::Pi;

从名字上可以直观看出来,mRadius 表示半径,另外两个变量是用弧度制来表示角的大小(因为这里三角函数的参数需要使用弧度制)。稍微温习下有关圆的弧度和角度的知识:

  • 当一个圆的角所对应的弧长等于其半径的时候,该角度的大小为 1 弧度。

而圆的周长公式为:L=2πrL = 2 \pi r。由此我们知道角度跟弧度的关系为:360°=2π360° = 2 \pi。也就是:

  • 1 度 = π180\frac{\pi}{180} 弧度
  • 1 弧度 = 180π\frac{180}{\pi}

所以我们可以知道:

  • mPhi = 0.25π0.25 \pi 弧度 = 0.25π×180π=450.25 \pi \times \frac{180}{\pi} = 45 度
  • mTheta = 1.5π1.5 \pi 弧度 = 1.5π×180π=2701.5 \pi \times \frac{180}{\pi} = 270 度

接着我们需要继续补充关于球坐标系的知识(直接分享一个不错的讲解):简单介绍球坐标系

了解完球坐标系的知识后,我们知道 mRadius 代表径向距离、mPhi 表示极角、mTheta 表示方位角(注意这里 yy 轴和 zz 轴跟上面链接里面的讲解是相反的)。

再看看相机位置的计算,我们可以知道相机的位置实际上是一个半径为 5 的球面上的一个点:

手绘相机位置
图二:手绘相机位置

相机在上图 VV 点的位置,看向原点。在该位置上相机看到的图像跟我们程序启动后的图像是一致的(留意顶点的颜色):

程序刚启动看到的画面
图三:程序刚启动看到的画面

现在,我们要理解按住鼠标移动的操作也就不难了,先在指定窗口捕获鼠标按下和松开的事件:

void BoxApp::OnMouseDown(WPARAM btnState, int x, int y) {
  mLastMousePos.x = x;
  mLastMousePos.y = y;

  // 将鼠标捕获至 mhMainWnd 窗口,并将所有鼠标输入消息发送给该窗口。
  SetCapture(mhMainWnd);
}

void BoxApp::OnMouseUp(WPARAM btnState, int x, int y) {
  // 释放鼠标捕获状态
  ReleaseCapture();
}

然后处理鼠标移动的事件:

void BoxApp::OnMouseMove(WPARAM btnState, int x, int y) {
  if ((btnState & MK_LBUTTON) != 0) {
    // 按住鼠标左键移动鼠标
    // Make each pixel correspond to a quarter of a degree.
    float dx =
        XMConvertToRadians(0.25f * static_cast<float>(x - mLastMousePos.x));
    float dy =
        XMConvertToRadians(0.25f * static_cast<float>(y - mLastMousePos.y));

    // Update angles based on input to orbit camera around box.
    mTheta += dx;
    mPhi += dy;

    // Restrict the angle mPhi.
    mPhi = MathHelper::Clamp(mPhi, 0.1f, MathHelper::Pi - 0.1f);
  } else if ((btnState & MK_RBUTTON) != 0) {
    // 按住鼠标右键键移动鼠标
    // Make each pixel correspond to 0.005 unit in the scene.
    float dx = 0.005f * static_cast<float>(x - mLastMousePos.x);
    float dy = 0.005f * static_cast<float>(y - mLastMousePos.y);

    // Update the camera radius based on input.
    mRadius += dx - dy;

    // Restrict the radius.
    mRadius = MathHelper::Clamp(mRadius, 3.0f, 15.0f);
  }

  mLastMousePos.x = x;
  mLastMousePos.y = y;
}

从上面的代码我们可以知道:

  • 按住鼠标左键移动鼠标时,实际是按照 xxyy 移动的距离来更新极角(mPhi)和方位角(mTheta)。也就是说,尽管我们在这个操作下会看到立方体被旋转,实际上我们只是在球面上移动了相机的位置(相机一直是看向立方体中心:世界坐标系原点)。
  • 按住鼠标右键移动鼠标时,实际是按照 xxyy 移动的距离来更新径向距离(mRadius)。虽然视觉上看起来是立方体被放大缩小,但实际上我们只是改变了相机跟立方体的距离。

绘制彩色立方体

那么,这个彩色的立方体是怎样被绘制出来的呢?我们很自然地来到 DrawScene() 函数,该函数跟 UpdateScene() 一样,也是每帧都调用,而且是在 UpdateScene() 之后调用。

void BoxApp::DrawScene() {
  md3dImmediateContext->ClearRenderTargetView(
      mRenderTargetView,
      reinterpret_cast<const float *>(&Colors::LightSteelBlue));
  md3dImmediateContext->ClearDepthStencilView(
      mDepthStencilView, D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);

  // 把输入布局绑定到设备上
  md3dImmediateContext->IASetInputLayout(mInputLayout);
  // 使用三角形列表的形式描述顶点
  md3dImmediateContext->IASetPrimitiveTopology(
      D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);

  UINT stride = sizeof(Vertex);
  UINT offset = 0;
  // 把顶点缓冲区绑定到设备的输入槽
  md3dImmediateContext->IASetVertexBuffers(0, 1, &mBoxVB, &stride, &offset);
  // 把索引缓冲区绑定到输入装配阶段
  // 参数 2 的格式必须与 D3D11_BUFFER_DESC::ByteWidth 的字节长度一致
  md3dImmediateContext->IASetIndexBuffer(mBoxIB, DXGI_FORMAT_R32_UINT, 0);

  // Set constants
  XMMATRIX world = XMLoadFloat4x4(&mWorld);
  XMMATRIX view = XMLoadFloat4x4(&mView);
  XMMATRIX proj = XMLoadFloat4x4(&mProj);
  XMMATRIX worldViewProj = world * view * proj;

  mfxWorldViewProj->SetMatrix(reinterpret_cast<float *>(&worldViewProj));

  D3DX11_TECHNIQUE_DESC techDesc;
  mTech->GetDesc(&techDesc);
  for (UINT p = 0; p < techDesc.Passes; ++p) {
    // 将 Effect 相关的修改更新到 GPU 内存,并启用在 pass 中指定的各种渲染状态
    mTech->GetPassByIndex(p)->Apply(0, md3dImmediateContext);

    // 绘制索引缓冲区中的图元
    // 36 indices for the box.
    md3dImmediateContext->DrawIndexed(36, 0, 0);
  }

  //将渲染结果呈现到屏幕上,并交换前后缓冲区
  HR(mSwapChain->Present(0, 0));
}

在绘制之前,我们首先要绑定输入布局、顶点缓冲区和索引缓冲区。这里需要注意的一点是 IASetPrimitiveTopology(),因为顶点缓冲区里面只是一系列的点,并没有明确说每几个顶点要解析成三角形还是四边形还是线段。该接口用以告诉 DirectX 按照何种方式解析顶点缓冲区里面的顶点:

void ID3D11Device::IASetPrimitiveTopology(
    D3D11_PRIMITIVE_TOPOLOGY Topology);

typedef enum D3D11_PRIMITIVE_TOPOLOGY
{
    D3D11_PRIMITIVE_TOPOLOGY_UNDEFINED = 0,
    D3D11_PRIMITIVE_TOPOLOGY_POINTLIST = 1, // 点列表
    D3D11_PRIMITIVE_TOPOLOGY_LINELIST = 2, // 线列表
    D3D11_PRIMITIVE_TOPOLOGY_LINESTRIP = 3, // 线带
    D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST = 4, // 三角形列表
    D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP = 5, // 三角形带
    D3D11_PRIMITIVE_TOPOLOGY_LINELIST_ADJ = 10,
    D3D11_PRIMITIVE_TOPOLOGY_LINESTRIP_ADJ = 11,
    D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST_ADJ = 12,
    D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP_ADJ= 13,
    D3D11_PRIMITIVE_TOPOLOGY_1_CONTROL_POINT_PATCHLIST = 33,
    D3D11_PRIMITIVE_TOPOLOGY_2_CONTROL_POINT_PATCHLIST = 34,
    //...
    D3D11_PRIMITIVE_TOPOLOGY_32_CONTROL_POINT_PATCHLIST = 64,
} D3D11_PRIMITIVE_TOPOLOGY;

具体就不展开说了,从前面代码我们看到,本程序使用的是三角形列表

再往下我们看到 mfxWorldViewProj(也就是 color.fx 里面的 gWorldViewProj) 被更新为 MVP(或者说是 WVP)变换矩阵。因为顶点着色器不能修改常量缓冲中的数据,所以通过这种方式在 C++ 中进行修改。

// Set constants
XMMATRIX world = XMLoadFloat4x4(&mWorld);
XMMATRIX view = XMLoadFloat4x4(&mView);
XMMATRIX proj = XMLoadFloat4x4(&mProj);
XMMATRIX worldViewProj = world * view * proj;

mfxWorldViewProj->SetMatrix(reinterpret_cast<float *>(&worldViewProj));

最后,我们遍历 technique 里面的 pass,使用 pass 来进行绘制立方体。

我们只是定义了每个顶点的颜色,最终呈现的彩色立方体的效果是因为硬件自动帮我们完成了三角形内部的颜色插值。而颜色插值方法可以参考 Games-101-Barycentric Coordinates

* 未经同意不得转载。