最爱午后红茶

剖析演示程序框架

日期图标
2023-05-15

本书后续章节所有的演示程序都基于这个演示程序框架去扩展的,因此有必要去剖析该演示程序框架。

整体的类图如下:

演示程序框架类图
图一:演示程序框架类图

程序初始化

框架程序主函数:

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE prevInstance, PSTR cmdLine,
                   int showCmd) {
  // Enable run-time memory check for debug builds.
#if defined(DEBUG) | defined(_DEBUG)
  _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
#endif

  InitDirect3DApp theApp(hInstance);

  if (!theApp.Init()) {
    return 0;
  }

  return theApp.Run();
}

theApp.Init() 里面创建了程序主窗口和对 Direct3D 进行初始化:

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

  if (!InitDirect3D()) {
    return false;
  }

  return true;
}

InitMainWindow() 的实现如下:

bool D3DApp::InitMainWindow() {
  WNDCLASS wc;
  wc.style = CS_HREDRAW | CS_VREDRAW;
  wc.lpfnWndProc = MainWndProc;
  wc.cbClsExtra = 0;
  wc.cbWndExtra = 0;
  wc.hInstance = mhAppInst;
  wc.hIcon = LoadIcon(0, IDI_APPLICATION);
  wc.hCursor = LoadCursor(0, IDC_ARROW);
  wc.hbrBackground = (HBRUSH)GetStockObject(NULL_BRUSH);
  wc.lpszMenuName = 0;
  wc.lpszClassName = L"D3DWndClassName";

  if (!RegisterClass(&wc)) {
    MessageBox(0, L"RegisterClass Failed.", 0, 0);
    return false;
  }

  // Compute window rectangle dimensions based on requested client area
  // dimensions.
  RECT R = {0, 0, mClientWidth, mClientHeight};
  AdjustWindowRect(&R, WS_OVERLAPPEDWINDOW, false);
  int width = R.right - R.left;
  int height = R.bottom - R.top;

  mhMainWnd = CreateWindow(L"D3DWndClassName", mMainWndCaption.c_str(),
                           WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT,
                           width, height, 0, 0, mhAppInst, 0);
  if (!mhMainWnd) {
    MessageBox(0, L"CreateWindow Failed.", 0, 0);
    return false;
  }

  ShowWindow(mhMainWnd, SW_SHOW);
  UpdateWindow(mhMainWnd);

  return true;
}

其中 MainWndProc() 是一个全局的窗口过程函数,用于接收各种窗口消息,而它里面的实现是把窗口消息透传给 D3DApp 去处理:

namespace {
// This is just used to forward Windows messages from a global window
// procedure to our member function window procedure because we cannot
// assign a member function to WNDCLASS::lpfnWndProc.
D3DApp *gd3dApp = 0; // gd3dApp 在 D3DApp 的构造函数中被赋值为 this,即 gd3dApp 指向 D3DApp 自身。
} // namespace

LRESULT CALLBACK MainWndProc(HWND hwnd, UINT msg, WPARAM wParam,
                             LPARAM lParam) {
  // Forward hwnd on because we can get messages (e.g., WM_CREATE)
  // before CreateWindow returns, and thus before mhMainWnd is valid.
  return gd3dApp->MsgProc(hwnd, msg, wParam, lParam);
}

InitDirect3D() 则是对 Direct3D 进行了必要的初始化:

bool D3DApp::InitDirect3D() {
  // Create the device and device context.

  UINT createDeviceFlags = 0;
#if defined(DEBUG) || defined(_DEBUG)
  createDeviceFlags |= D3D11_CREATE_DEVICE_DEBUG;
#endif

  D3D_FEATURE_LEVEL featureLevel;
  HRESULT hr = D3D11CreateDevice(
      0, // default adapter
      md3dDriverType,
      0,                       // no software device
      createDeviceFlags, 0, 0, // default feature level array
      D3D11_SDK_VERSION, &md3dDevice, &featureLevel, &md3dImmediateContext);

  if (FAILED(hr)) {
    MessageBox(0, L"D3D11CreateDevice Failed.", 0, 0);
    return false;
  }

  if (featureLevel != D3D_FEATURE_LEVEL_11_0) {
    MessageBox(0, L"Direct3D Feature Level 11 unsupported.", 0, 0);
    return false;
  }

  // Check 4X MSAA quality support for our back buffer format.
  // All Direct3D 11 capable devices support 4X MSAA for all render
  // target formats, so we only need to check quality support.

  HR(md3dDevice->CheckMultisampleQualityLevels(DXGI_FORMAT_R8G8B8A8_UNORM, 4,
                                               &m4xMsaaQuality));
  assert(m4xMsaaQuality > 0);

  // Fill out a DXGI_SWAP_CHAIN_DESC to describe our swap chain.

  DXGI_SWAP_CHAIN_DESC sd;
  sd.BufferDesc.Width = mClientWidth;
  sd.BufferDesc.Height = mClientHeight;
  sd.BufferDesc.RefreshRate.Numerator = 60;
  sd.BufferDesc.RefreshRate.Denominator = 1;
  sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
  sd.BufferDesc.ScanlineOrdering = DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED;
  sd.BufferDesc.Scaling = DXGI_MODE_SCALING_UNSPECIFIED;

  // Use 4X MSAA?
  if (mEnable4xMsaa) {
    sd.SampleDesc.Count = 4;
    sd.SampleDesc.Quality = m4xMsaaQuality - 1;
  }
  // No MSAA
  else {
    sd.SampleDesc.Count = 1;
    sd.SampleDesc.Quality = 0;
  }

  sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
  sd.BufferCount = 1;
  sd.OutputWindow = mhMainWnd;
  sd.Windowed = true;
  sd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
  sd.Flags = 0;

  // To correctly create the swap chain, we must use the IDXGIFactory that was
  // used to create the device.  If we tried to use a different IDXGIFactory
  // instance (by calling CreateDXGIFactory), we get an error:
  // "IDXGIFactory::CreateSwapChain: This function is being called with a device
  // from a different IDXGIFactory."

  IDXGIDevice *dxgiDevice = 0;
  HR(md3dDevice->QueryInterface(__uuidof(IDXGIDevice), (void **)&dxgiDevice));

  IDXGIAdapter *dxgiAdapter = 0;
  HR(dxgiDevice->GetParent(__uuidof(IDXGIAdapter), (void **)&dxgiAdapter));

  IDXGIFactory *dxgiFactory = 0;
  HR(dxgiAdapter->GetParent(__uuidof(IDXGIFactory), (void **)&dxgiFactory));

  HR(dxgiFactory->CreateSwapChain(md3dDevice, &sd, &mSwapChain));

  ReleaseCOM(dxgiDevice);
  ReleaseCOM(dxgiAdapter);
  ReleaseCOM(dxgiFactory);

  // The remaining steps that need to be carried out for d3d creation
  // also need to be executed every time the window is resized.  So
  // just call the OnResize method here to avoid code duplication.

  OnResize();

  return true;
}

从上面代码可以看到我们先调用 D3D11CreateDevice() 创建 Direct3D 11 设备(ID3D11Device: md3dDevice)和上下文(ID3D11DeviceContext: md3dImmediateContext)。

  • ID3D11Device:用于检测显示适配器功能和分配资源
  • ID3D11DeviceContext:用于设置管线状态、将资源绑定到图形管线和生成渲染命令

接着调用 CheckMultisampleQualityLevels() 检查 4X 多重采样质量等级,而 MSAA 指的是 Multiple Sample Antialiasing,是抗锯齿的一种实现方案。具体见 Games-101 Antialiasing

然后通过 CreateSwapChain() 创建交换链。创建交换链我们使用了 DXGI 接口,DXGIDirectX Graphics Infrastructure)是一套比 Direct3D 更底层的接口,用于处理与图形关联的东西,例如交换链等。而 Direct3D 则是建立于 DXGI 之上。其他的一些图形接口比如 Direct2D 在交换链、图形硬件枚举、在窗口和全屏模式之间切换时,也可以使用 DXGI 的接口。

补充:你也可以使用 D3D11CreateDeviceAndSwapChain() 方法同时创建设备、设备上下文和交换链。

接着我们在 OnResize() 中完成了一系列的视图和缓冲区的创建(以及重建):

void D3DApp::OnResize() {
  assert(md3dImmediateContext);
  assert(md3dDevice);
  assert(mSwapChain);

  // Release the old views, as they hold references to the buffers we
  // will be destroying.  Also release the old depth/stencil buffer.

  ReleaseCOM(mRenderTargetView);
  ReleaseCOM(mDepthStencilView);
  ReleaseCOM(mDepthStencilBuffer);

  // Resize the swap chain and recreate the render target view.

  HR(mSwapChain->ResizeBuffers(1, mClientWidth, mClientHeight,
                               DXGI_FORMAT_R8G8B8A8_UNORM, 0));
  ID3D11Texture2D *backBuffer;
  HR(mSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D),
                           reinterpret_cast<void **>(&backBuffer)));
  HR(md3dDevice->CreateRenderTargetView(backBuffer, 0, &mRenderTargetView));
  ReleaseCOM(backBuffer);

  // Create the depth/stencil buffer and view.

  D3D11_TEXTURE2D_DESC depthStencilDesc;

  depthStencilDesc.Width = mClientWidth;
  depthStencilDesc.Height = mClientHeight;
  depthStencilDesc.MipLevels = 1;
  depthStencilDesc.ArraySize = 1;
  depthStencilDesc.Format = DXGI_FORMAT_D24_UNORM_S8_UINT;

  // Use 4X MSAA? --must match swap chain MSAA values.
  if (mEnable4xMsaa) {
    depthStencilDesc.SampleDesc.Count = 4;
    depthStencilDesc.SampleDesc.Quality = m4xMsaaQuality - 1;
  }
  // No MSAA
  else {
    depthStencilDesc.SampleDesc.Count = 1;
    depthStencilDesc.SampleDesc.Quality = 0;
  }

  depthStencilDesc.Usage = D3D11_USAGE_DEFAULT;
  depthStencilDesc.BindFlags = D3D11_BIND_DEPTH_STENCIL;
  depthStencilDesc.CPUAccessFlags = 0;
  depthStencilDesc.MiscFlags = 0;

  HR(md3dDevice->CreateTexture2D(&depthStencilDesc, 0, &mDepthStencilBuffer));
  HR(md3dDevice->CreateDepthStencilView(mDepthStencilBuffer, 0,
                                        &mDepthStencilView));

  // Bind the render target view and depth/stencil view to the pipeline.

  md3dImmediateContext->OMSetRenderTargets(1, &mRenderTargetView,
                                           mDepthStencilView);

  // Set the viewport transform.

  mScreenViewport.TopLeftX = 0;
  mScreenViewport.TopLeftY = 0;
  mScreenViewport.Width = static_cast<float>(mClientWidth);
  mScreenViewport.Height = static_cast<float>(mClientHeight);
  mScreenViewport.MinDepth = 0.0f;
  mScreenViewport.MaxDepth = 1.0f;

  md3dImmediateContext->RSSetViewports(1, &mScreenViewport);
}

从上面的代码可以看到,我们通过 CreateRenderTargetView() 创建了资源视图,用于后续将其绑定到指定的管线阶段。而它需要用到的后台缓冲区则是通过调用 GetBuffer() 获得。然后我们调用 CreateDepthStencilView() 创建深度/模板视图(跟资源视图类似,需要绑定到管线上),同时通过 CreateTexture2D() 创建它需要用到的深度/模板缓冲区

此时我们离渲染出图已经很接近了,下一步要做的事情就是将视图绑定到输出合并器阶段。我们通过调用 OMSetRenderTargets() 完成。

最后,我们调用 RSSetViewports() 创建并设置视口

更新场景

演示程序的场景更新并不复杂,基本就两点:

  • 当前循环有窗口消息,就去处理它。
  • 当前循环没有窗口消息,如果游戏进行中就去更新游戏场景;如果游戏暂停,就 Sleep(100)
int D3DApp::Run() {
  MSG msg = {0};

  mTimer.Reset();

  while (msg.message != WM_QUIT) {
    // If there are Window messages then process them.
    if (PeekMessage(&msg, 0, 0, 0, PM_REMOVE)) {
      TranslateMessage(&msg);
      DispatchMessage(&msg);
    }
    // Otherwise, do animation/game stuff.
    else {
      mTimer.Tick();

      if (!mAppPaused) {
        CalculateFrameStats();
        UpdateScene(mTimer.DeltaTime());
        DrawScene();
      } else {
        Sleep(100);
      }
    }
  }

  return (int)msg.wParam;
}
  • UpdateScene() 每帧都会调用,需要根据实际情况填写逻辑,目前里面是空的。
  • DrawScene() 每帧都会调用,用于将当前帧绘制在后台缓存区。其中实现里面的 IDXGISwapChain::Present() 方法用于将后台缓冲区的内容呈现在屏幕上。
void InitDirect3DApp::DrawScene() {
  assert(md3dImmediateContext);
  assert(mSwapChain);

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

  HR(mSwapChain->Present(0, 0));
}

消息处理

我们需要从分发给窗口过程(MainWndProc())的消息里面挑选出我们需要的消息进行处理。

WM_ACTIVATE

当窗口被激活或非激活时会发送 WM_ACTIVATE 消息。当非激活时我们会暂停游戏,从前面的 Run() 我们知道,暂停游戏时没有再更新场景,而是将空闲 CPU 周期返回给操作系统,目的在于使得程序处在非活动状态时独占 CPU 周期。而当激活时则重新开启游戏。

// WM_ACTIVATE is sent when the window is activated or deactivated.
// We pause the game when the window is deactivated and unpause it
// when it becomes active.
case WM_ACTIVATE:
  if (LOWORD(wParam) == WA_INACTIVE) {
    mAppPaused = true;
    mTimer.Stop();
  } else {
    mAppPaused = false;
    mTimer.Start();
  }
  return 0;

WM_SIZE

该消息在窗口大小发生改变时会触发,在这个消息发生时,我们会根据实际情况调用 OnResize()。我们使用该消息的重要原因是:

  • 为了防止图像拉伸,我们需要保证后台缓冲区和深度/模板缓冲区的大小跟窗口客户区的大小相同。而要改变缓冲区的大小,必须把缓冲区销毁重建,对应的视图也需要销毁重建。
  • 通过拖动窗口边界改变窗口大小时会大量触发 WM_SIZE,需要对该类消息进行针对性处理。
  • 用作交换链的 IDXGISwapChain 接口可以自动捕获 Alt + Enter 组合键消息使应用程序切换到全屏模式(full-screen mode),再次按下 Alt + Enter 组合键可以返回窗口模式。这两个模式切换会导致窗口大小变化。
// WM_SIZE is sent when the user resizes the window.
case WM_SIZE:
  // Save the new client area dimensions.
  mClientWidth = LOWORD(lParam);
  mClientHeight = HIWORD(lParam);
  if (md3dDevice) {
    if (wParam == SIZE_MINIMIZED) {
      mAppPaused = true;
      mMinimized = true;
      mMaximized = false;
    } else if (wParam == SIZE_MAXIMIZED) {
      mAppPaused = false;
      mMinimized = false;
      mMaximized = true;
      OnResize();
    } else if (wParam == SIZE_RESTORED) {

      // Restoring from minimized state?
      if (mMinimized) {
        mAppPaused = false;
        mMinimized = false;
        OnResize();
      }

      // Restoring from maximized state?
      else if (mMaximized) {
        mAppPaused = false;
        mMaximized = false;
        OnResize();
      } else if (mResizing) {
        // If user is dragging the resize bars, we do not resize
        // the buffers here because as the user continuously
        // drags the resize bars, a stream of WM_SIZE messages are
        // sent to the window, and it would be pointless (and slow)
        // to resize for each WM_SIZE message received from dragging
        // the resize bars.  So instead, we reset after the user is
        // done resizing the window and releases the resize bars, which
        // sends a WM_EXITSIZEMOVE message.
      } else // API call such as SetWindowPos or
              // mSwapChain->SetFullscreenState.
      {
        OnResize();
      }
    }
  }
  return 0;

WM_ENTERSIZEMOVE & WM_EXITSIZEMOVE

当用户拖动窗口或者通过拖动窗口边界改变窗口大小时:

  • 按下鼠标时,会触发一次 WM_ENTERSIZEMOVE
  • 松开鼠标时,会触发一次 WM_EXITSIZEMOVE

因此我们可以通过在按下或松开鼠标时设置标记 mResizing,在接收到 WM_SIZE 消息时判断该标记来避免大量销毁重建缓冲区和视图的操作。

// WM_EXITSIZEMOVE is sent when the user grabs the resize bars.
case WM_ENTERSIZEMOVE:
  mAppPaused = true;
  mResizing = true;
  mTimer.Stop();
  return 0;

// WM_EXITSIZEMOVE is sent when the user releases the resize bars.
// Here we reset everything based on the new window dimensions.
case WM_EXITSIZEMOVE:
  mAppPaused = false;
  mResizing = false;
  mTimer.Start();
  OnResize();
  return 0;

WM_DESTROY

处理窗口关闭消息。

// WM_DESTROY is sent when the window is being destroyed.
case WM_DESTROY:
  PostQuitMessage(0);
  return 0;

WM_MENUCHAR

WM_MENUCHAR 是一个菜单消息,当用户在菜单处键入一个字符时就会触发该消息。 Alt + Enter 组合键在全屏与窗口模式中切换时会发出“哔”的声音,可以通过以下代码消除声音:

// The WM_MENUCHAR message is sent when a menu is active and the user
// presses a key that does not correspond to any mnemonic or accelerator
// key.
case WM_MENUCHAR:
  // Don't beep when we alt-enter.
  return MAKELRESULT(0, MNC_CLOSE);

WM_GETMINMAXINFO

防止窗口变得过小:

// Catch this message so to prevent the window from becoming too small.
case WM_GETMINMAXINFO:
  ((MINMAXINFO *)lParam)->ptMinTrackSize.x = 200;
  ((MINMAXINFO *)lParam)->ptMinTrackSize.y = 200;
  return 0;

帧的统计数值

我们通过每帧调用 CalculateFrameStats() 来统计跟帧相关的数据:

void D3DApp::CalculateFrameStats() {
  // Code computes the average frames per second, and also the
  // average time it takes to render one frame.  These stats
  // are appended to the window caption bar.

  static int frameCnt = 0;
  static float timeElapsed = 0.0f;

  frameCnt++;

  // Compute averages over one second period.
  if ((mTimer.TotalTime() - timeElapsed) >= 1.0f) {
    float fps = (float)frameCnt; // fps = frameCnt / 1
    float mspf = 1000.0f / fps;

    std::wostringstream outs;
    outs.precision(6);
    outs << mMainWndCaption << L"    " << L"FPS: " << fps << L"    "
         << L"Frame Time: " << mspf << L" (ms)";
    SetWindowText(mhMainWnd, outs.str().c_str());

    // Reset for next average.
    frameCnt = 0;
    timeElapsed += 1.0f;
  }
}

fps 变量表示帧率(计算某一特定时间段 tt 中处理的总帧数 nn,然后可以计算出 fpsavg=n/tfps_{avg} = n/t,如果 t=1t = 1,那么 fpsavg=n/1=nfps_{avg} = n/1 = n)。

注意:帧时间与 FPS 是倒数关系,通过乘以 1000ms/ 1s 可以将秒转换为毫秒(1 秒等于 1000 毫秒)。

因此,我们可以计算每帧的耗时:

float mspf = 1000.0f / fps;
* 未经同意不得转载。