环境:VS2017 语言:C++
总起:
本次的博文主要参考红龙书(《Introduction to 3D Game Programming with Directx 11》)和X_Jun96大佬将DirectX SDK移植到最新的Windows SDK的博文:https://blog.csdn.net/x_jun96/article/details/80293670。
之前的Windows编程系列其实就是DirectX(以下简称Dx)编程,当时简单研究后就没有下文了,3D游戏编程大师技巧这本书也是一直忘记拿出来看,最近趁着工作稍微清闲点,打算研究一下底层的东西。
首先说一下红龙书,他的第四章节相当于《Windows游戏编程大师技巧》的所有内容还要多(除了没有实现音乐),所以刚入门的同学还是推荐《Windows游戏编程大师技巧》或跟其内容相似的最新DirectX编程,毕竟《Windows游戏编程大师技巧》实在是太老了,使用的还是Dx7。
接着说一下红龙书上的示例,虽说是Dx11,但他当时使用的是独立的DirectX SDK库,就是一个叫做June 2010的SDK库,但后来微软将Dx库合并到了Windows库中了,所以现在编写Dx程序,打开VS2017,安装Windows环境,就能直接编写Dx程序。
然后说一下为什么选择Dx11。现在已经有Dx12了,但Dx12不支持现在占有率还非常高的Win7系统,而现在公司内部基本用的都是Win7,所以考虑到兼容性和受众率还是选择了Dx11,当然学完之后想学Dx12甚至是OpenGL会很轻松,毕竟大部分思路是共同的(今天要说的这套框架,其实跟Dx7的也没啥太大区别,多了些东西而已)。
接下来进入正题,先附上工程链接:https://github.com/anguangzhihen/Dx11。
1.程序最好运行在Win32上(后来我这边的项目都在x64上跑了,没有说明的话x64都是可以运行的);
2.如果Common下的脚本没有找到,请到工程/属性中添加包含目录;
3.所有的练习都在其中,全局搜索“练习”关键字就能找到,想要运行打开注释即可。
龙书示例:https://github.com/jjuiddong/Introduction-to-3D-Game-Programming-With-DirectX11。
Window SDK示例:https://github.com/walbourn/directx-sdk-samples。
我这边会按照流程梳理一遍整体的思路,然后重点说一下关键的代码。
框架的流程和思路:
D3DApp是游戏循环的框架类,InitDirect3DApp作为它的实现对象完成了将整个屏幕显示成蓝色的任务。
让我先来看看InitDirect3DApp的代码:
// 最简单的Dx框架类实现
class InitDirect3DApp : public D3DApp
{
public:
InitDirect3DApp(HINSTANCE hInstance);
~InitDirect3DApp();
bool Init();
void OnResize();
void UpdateScene(float dt);
void DrawScene();
};
// 程序入口
int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
_In_opt_ HINSTANCE hPrevInstance,
_In_ LPWSTR lpCmdLine,
_In_ int nCmdShow)
{
// 开启实时内存检测
#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();
}
InitDirect3DApp::InitDirect3DApp(HINSTANCE hInstance) : D3DApp(hInstance)
{
}
InitDirect3DApp::~InitDirect3DApp()
{
}
// 初始化
bool InitDirect3DApp::Init()
{
if (!D3DApp::Init())
return false;
return true;
}
// 在玩家改变屏幕分辨率时调用
void InitDirect3DApp::OnResize()
{
D3DApp::OnResize();
}
// 每帧都会调用一次,用于更新当前的逻辑,dt为上一帧到当前帧的时间,类似于Unity中的Update
void InitDirect3DApp::UpdateScene(float dt)
{
}
// 渲染当前场景,类似Unity生命周期中的Scene rendering阶段(OnWillRenderObject、OnRenderImage等方法)
void InitDirect3DApp::DrawScene()
{
assert(md3dImmediateContext);
assert(mSwapChain);
// 蓝色,Colors类好像被移除了
static float blue[4] = { 0.0f, 0.0f, 1.0f, 1.0f };
// 清空当前的渲染对象,并所有像素点默认填上蓝色
md3dImmediateContext->ClearRenderTargetView(mRenderTargetView, reinterpret_cast<const float*>(&blue));
// 清除深度和模版buffer,深度默认为最远1(范围0到1),深度和模版是以像素点为最小单位的,所以如果一个800*600的画面的话,深度和模版值也会有800*600个
md3dImmediateContext->ClearDepthStencilView(mDepthStencilView, D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);
// 切换显示和后备缓冲
mSwapChain->Present(0, 0);
}
wWinMain是整个游戏的入口,创建了InitDirect3App对象后,调用Init初始化,然后调用Run进入游戏循环。
其中Init调用了父类的Init方法:
bool D3DApp::Init()
{
if (!InitMainWindow())
return false;
if (!InitDirect3D())
return false;
return true;
}
初始化了一个窗口后,初始化了Direct对象。
Run方法也是父类的:
int D3DApp::Run()
{
MSG msg = { 0 };
mTimer.Reset(); // 时间类重置
// 进入主循环
while (msg.message != WM_QUIT)
{
// 如果收到窗口消息则处理
if (PeekMessage(&msg, 0, 0, 0, PM_REMOVE))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
else
{
// 否则运行游戏逻辑
mTimer.Tick();
if (!mAppPaused)
{
CalculateFrameStats(); // 更新当前的帧率状态
UpdateScene(mTimer.DeltaTime()); // 更新场景内容逻辑
DrawScene(); // 更新场景的渲染
}
else
{
// 暂停
Sleep(100);
}
}
}
// 游戏结束
return (int)msg.wParam;
}
这个循环是不是似曾相识?进入一个while循环,收到消息就处理,不然就处理游戏内逻辑。
整理下来就是这么简单:初始化窗口和DirectX,接着进入游戏循环,处理由子类实现的UpdateScene和DrawScene。
DirectX的初始化:
窗口初始化和时间类没什么好说的,重点说一下Dx的初始化,这个操作涵盖了两个方法:InitDirect3D和OnResize。其中OnResize在窗口发生变化时也会调用。
让我们一步步来看。
♦创建设备和设备环境
UINT createDeviceFlags = 0;
// DEBUG模式
#if defined(DEBUG) || defined(_DEBUG)
createDeviceFlags |= D3D11_CREATE_DEVICE_DEBUG;
#endif
// 创建设备和设备环境
D3D_FEATURE_LEVEL featureLevel;
HRESULT hr = D3D11CreateDevice(
0, // 使用默认显卡
md3dDriverType, // 一般使用D3D_DRIVER_TYPE_HARDWARE作为参数,不然买来的显卡只能吃灰了
0, // 不使用软件驱动
createDeviceFlags, // Debug下,我们要求Dx输出一些log信息
0, 0, // 默认等级的特性,默认就是11
D3D11_SDK_VERSION, // 我们使用Dx11的SDK
&md3dDevice,
&featureLevel,
&md3dImmediateContext);
重点说一下第四个Flags参数,我们这边在Debug模式下填入了D3D11_CREATE_DEVICE_DEBUG,但是在Win10下调试可能会产生错误,这时就需要打开Win10的可选功能——Graphics Tools。
Flags还可以添加一个D3D11_CREATE_DEVICE_SINGLETHREADED,表明Dx不会被多线程调用,比如在该模式下ID3D11Device::CreateDeferredContext的调用会出错。
♦创建缓冲切换链
// 检查设备是否支持多重采样
HR(md3dDevice->CheckMultisampleQualityLevels(DXGI_FORMAT_R8G8B8A8_UNORM, 4, &m4xMsaaQuality));
assert(m4xMsaaQuality > 0);
// 缓冲切换链需要完成DXGI_SWAP_CHAIN_DESC结构
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; // 使用最经典的32色格式,大部分情况下都使用该格式,可能有些情况下会舍弃Alpha通道
sd.BufferDesc.ScanlineOrdering = DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED; // 显示扫描线模式,似乎是指定图片显示排列是从前往后还是从后往前,这边是未指定
sd.BufferDesc.Scaling = DXGI_MODE_SCALING_UNSPECIFIED; // 缩放模式
// 使用多重采样MSAA
if (mEnable4xMsaa)
{
sd.SampleDesc.Count = 4;
sd.SampleDesc.Quality = m4xMsaaQuality - 1;
}
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; // 如果指定为DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH,在切换为全屏时,会选择最适配的桌面,否则在哪个桌面切换到哪个
// 为了精确获取到SwapChain,必须使用当前设备类查询相关的Factory
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);
多重采样MSAA是在一些硬朗的连接处进行一定的模糊处理,使过渡更为平滑,本次的程序关闭了该技术的使用。
创建缓冲时,使用到了DXGI(DirectX Graphics Infrastructure),该API与Dx是分离的,主要用于处理切换链、枚举硬件设备、切换全屏窗口模式,因为是独立的,所以不用Dx使用其他图形API也可调用其方法。
♦创建显示缓存
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);
GetBuffer的第一个参数指定了当前获取后备缓冲的index,因为只有一个所以只能获取index为0的buffer。
CreateRenderTargetView的第二个参数可以指定当前的数据类型,但我们传入的backBuffer自带了类型数据(即一级的mipmap),所以可以为空。
♦创建深度/模板图
// 定义深度/模板图,深度图用于确定遮挡关系,模板图一般用于模板测试表明像素点是否能显示
D3D11_TEXTURE2D_DESC depthStencilDesc;
depthStencilDesc.Width = mClientWidth;
depthStencilDesc.Height = mClientHeight;
depthStencilDesc.MipLevels = 1; // 固定不生成mipmap
depthStencilDesc.ArraySize = 1; // 固定只生成一张图
depthStencilDesc.Format = DXGI_FORMAT_D24_UNORM_S8_UINT; // 深度24位,模板8位
// 重置多重采样
if (mEnable4xMsaa)
{
depthStencilDesc.SampleDesc.Count = 4;
depthStencilDesc.SampleDesc.Quality = m4xMsaaQuality - 1;
}
else
{
depthStencilDesc.SampleDesc.Count = 1;
depthStencilDesc.SampleDesc.Quality = 0;
}
depthStencilDesc.Usage = D3D11_USAGE_DEFAULT; // 指定深度/模板图只能由GPU读取写入
depthStencilDesc.BindFlags = D3D11_BIND_DEPTH_STENCIL; // 指定是深度/模板图
depthStencilDesc.CPUAccessFlags = 0; // 不给予CPU访问权限
depthStencilDesc.MiscFlags = 0;
// 创建深度/模板图
HR(md3dDevice->CreateTexture2D(&depthStencilDesc, 0, &mDepthStencilBuffer));
HR(md3dDevice->CreateDepthStencilView(mDepthStencilBuffer, 0, &mDepthStencilView));
// 将渲染对象和深度/模板图放到渲染流水线中
md3dImmediateContext->OMSetRenderTargets(1, &mRenderTargetView, mDepthStencilView);
D3D11_TEXTURE2D_DESC.Usage可指定的参数:
1.D3D11_USAGE_DEFAULT,GPU可读可写,CPU不可读不可写
2.D3D11_USAGE_IMMUTABLE,GPU只读,除了创建时CPU和GPU都不可写;
3.D3D11_USAGE_DYNAMIC,CPU可写;
4.D3D11_USAGE_STAGING,CPU可读。
后两种情况都会在CPU内存和GPU内存间进行图片传输,所以应该避免。
D3D11_TEXTURE2D_DESC.BindFlags可指定的参数:
1.D3D11_BIND_DEPTH_STENCIL,深度模板图;
2.D3D11_BIND_RENDER_TARGET,渲染管线中的渲染对象;
3.D3D11_BIND_SHADER_RESOURCE,渲染Shader中的源文件。
D3D11_TEXTURE2D_DESC.CPUAccessFlags可指定的参数:
1.空(0),CPU没有访问权限;
2.D3D11_CPU_ACCESS_WRITE,Usage为CPU可写时指定;
3.D3D11_CPU_ACCESS_READ,Usage为CPU可读时指定。
♦设置视窗
// 设置视窗
mScreenViewport.TopLeftX = 0; // 视窗距窗口的X距离
mScreenViewport.TopLeftY = 0; // 视窗距窗口的Y距离
mScreenViewport.Width = static_cast<float>(mClientWidth); // 宽
mScreenViewport.Height = static_cast<float>(mClientHeight); // 高
mScreenViewport.MinDepth = 0.0f; // 最小深度
mScreenViewport.MaxDepth = 1.0f; // 最大深度
md3dImmediateContext->RSSetViewports(1, &mScreenViewport); // 重置视窗
视窗的设置一般按以上参数就可以了,除非两种情况:1.双人同屏,需要两个视窗;2.想要使用Windows的组件(按钮之类),给其留出一些空余空间。第二种情况已经非常少见了,一般是电子游戏早期可能这么做多一些。
总结:
窗口初始化、消息处理和时间类本文中没有提到,有需要的可以看一下工程。
我这边说一下我研究Dx的流程:
1.将书的一个章节通读一遍;
2.根据龙书的官方示例和Window SDK的官方示例,尽量把程序复写出来;
3.参照X_Jun96大佬的文章把一些过时的方法替换掉;
4.整理整个章节,提取重点部分,写成博文。