剖析演示程序框架
本书后续章节所有的演示程序都基于这个演示程序框架去扩展的,因此有必要去剖析该演示程序框架。
整体的类图如下:
程序初始化
框架程序主函数:
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 接口,DXGI(DirectX 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 变量表示帧率(计算某一特定时间段 中处理的总帧数 ,然后可以计算出 ,如果 ,那么 )。
注意:帧时间与 FPS 是倒数关系,通过乘以 1000ms/ 1s 可以将秒转换为毫秒(1 秒等于 1000 毫秒)。
因此,我们可以计算每帧的耗时:
float mspf = 1000.0f / fps;