拓冰建站拓冰建站
首页 / 资讯中心 / 正文

C++与DirectX 11实战:从零构建高效吃豆人游戏

简介使用C与DirectX 11开发的经典吃豆人游戏复刻工程面向具备一定图形学基础的学习者适合想了解DirectX 11管线、2D游戏逻辑与经典AI设计的开发者。项目基于1980年原版玩法支持方向键移动包含被幽灵追击、食用能量剂后反制幽灵等机制每个幽灵都实现了源自原版的不同AI行为并设有追击与逃跑阶段。工程依赖DirectXTK简化DirectX使用解决方案面向Visual Studio 2017世界由分离立方体构成的2D地图生成还针对相邻立方体合并做了优化以减少三角形数量、避免z-fighting。资源共61个文件含18个头文件、15个C源文件、8个HLSL着色器、7张PNG图片及工程配置等压缩包仅6MB结构完整便于对照学习。已有109位学习者下载适合用来分析DirectX 11游戏框架、幽灵AI与地图渲染优化技巧。1. 为什么这个 C 和 DirectX 11 吃豆人项目值得跑一遍把“使用 C 和 DirectX 11 开发的吃豆人游戏.zip”解压开的人多半以为里面只是一个课程作业级别的游戏 Demo。但实际跑通之后你会发现这个压缩包把 C 游戏开发中最容易绕晕的三条线直接给拧在了一起Win32 窗口与消息循环、DirectX 11 渲染管线的初始化和每帧绘制、以及吃豆人这类玩家物所必需的碰撞检测与状态管理。适合两类人一类是刚学完 C 语法、想结束黑框程序跳进图形世界的初级开发者另一类是手里已经攒了好几个小游戏源码、但还没认真啃过 DirectX 11 的朋友。别把这个 zip 当成普通压缩包解压完就双击 EXE 收工字符集、调试图层、高 DPI 缩放和缺少 Visual C Redistributable 都是隐藏关卡。这篇文章从“怎么建项目”一路写到“怎么调成 60 FPS”帮你把这个吃豆人项目拆开、跑顺、再改成自己的版本。2. 拆解项目架构游戏循环、渲染与状态管理2.1 为什么吃豆人这种 2D 游戏也值得选 DirectX 11很多人第一反应是吃豆人不过是背景网格加几个圆形精灵用 GDI 或 GDI 画位图就够了何必搬出 DirectX 11。实际动手后你会发现GDI 在窗口拉伸、高分屏缩放和大量矩形绘制时性能衰减非常快并且它只在 CPU 上软绘制几十个豆子还能撑住一旦加上玩家移动时的背景刷新红屏或者拖影就会出现。DirectX 11 的精灵绘制虽然也要走顶点缓冲和着色器但每帧的渲染提交可以由 GPU 并行处理纹理采样、混合、放大缩小都是硬件加速。对于吃豆人这种画面简单但要求帧率高、响应快的游戏DX11 反而是性价比最高的上手方案不涉及复杂的 PBR 光照也用不到几何着色器只要把交换链、渲染管线和一条精灵绘制路径搭好就行。我一般会把这类项目的代码结构分成三块。第一块是 Win32 入口负责创建窗口、处理消息循环、接收键盘和关闭事件第二块是 DirectX 11 资源管理器负责 ID3D11Device、ID3D11DeviceContext、IDXGISwapChain、渲染目标视图和深度模板视图的创建与释放第三块是游戏逻辑层维护地图数组、吃豆人坐标、豆子列表、分数和关卡状态。别把三块全揉进一个 main 函数里否则后期加一个“吃豆人被抓住重新开始”的功能都会让函数爆炸。常见做法是定义 Game 类内部持有 D3D 资源指针和玩家对象对外只暴露 Init、Update、Render、Shutdown 四个方法。2.2 固定时间步长与消息循环把逻辑帧和渲染帧分开直接照搬 Win32 默认的消息循环会导致一个经典问题窗口拖动或最小化时PeekMessage 返回节奏变化吃豆人移动速度忽快忽慢。解决方法是使用固定时间步长。逻辑上让游戏世界每 16.666 毫秒更新一次60 FPS 对应渲染循环则尽量按照系统实际频率走中间用累积器补偿。常见的简化版本是LARGE_INTEGER freq, prev, curr; QueryPerformanceFrequency(freq); QueryPerformanceCounter(prev); float fixedStep 1.0f / 60.0f; float accumulator 0.0f; while (running) { // 消息循环 MSG msg; while (PeekMessage(msg, NULL, 0, 0, PM_REMOVE)) { if (msg.message WM_QUIT) running false; TranslateMessage(msg); DispatchMessage(msg); } QueryPerformanceCounter(curr); float frameTime (float)(curr.QuadPart - prev.QuadPart) / (float)freq.QuadPart; prev curr; if (frameTime 0.25f) frameTime 0.25f; // 防止调试挂起后大步跨越 accumulator frameTime; while (accumulator fixedStep) { game-Update(fixedStep); // 固定步长更新逻辑 accumulator - fixedStep; } game-Render(); // 渲染可以不同步但通常也按当前状态绘制 }这段代码的逻辑在于渲染循环尽量快但 Update 永远按固定步长推进。好处是碰撞检测和动画不会出现“同一帧里移动了 3 像素下一帧移动了 10 像素”的随机性。参数说明里最常调的是 fixedStep想改成 30 FPS 就换成 1.0f / 30.0f但如果你的显示器刷新率是 144Hz仍然建议逻辑更新在 60Hz避免物理效果和碰撞精度被高刷新率干扰。2.3 精灵绘制纹理矩形的四个顶点与三角形列表DirectX 11 没有内置“贴图”接口游戏里看到的所有吃豆人精灵本质上都是一个由 4 个顶点构成的四边形再在像素着色器里采样纹理。顶点必须包含位置坐标和纹理坐标位置决定屏幕上的矩形区域纹理坐标决定采样贴图的哪一块。渲染时使用三角形列表图元拓扑四个顶点生成两个三角形。对这种简单游戏尽量把所有精灵纹理合在一张图集里可以减少状态切换和内存占用比如一张 256x256 的 png 里放上吃豆人的 4 个方向帧、小豆子和背景网格绘制时通过不同纹理坐标区域裁剪出对应贴图。在资源管理上我见过不少翻车案例把纹理写成 BMP 再让 D3D 一行一行拷贝效率低还容易踩对齐坑。常见做法是使用 DirectXTK 或 DDSTextureLoader 加载 DDS 或 png。如果项目里没有现成工具直接用 STBI 这种单头文件图像库把图像解码成 32 位 BGRA 纹素数组再创建 ID3D11Texture2D。这里关键是设置 Format 为 DXGI_FORMAT_R8G8B8A8_UNORMSRV 创建时不要随意开 mipmap2D 游戏一般只用 LevelCount 1。3. 从零搭一个可复现的 DirectX 11 吃豆人初始化、精灵与键盘控制3.1 交换链、渲染目标视图和视口最小可跑通的启动代码拿到 zip 后如果你不想直接用别人的工程可以先自己创建一个空项目把核心初始化代码敲进去。我建议先从设备创建开始这也是最容易被环境坑到的地方。下面这段代码是去掉错误处理后的最小结构但关键注释不能省// D3D11 初始化设备、上下文、交换链 UINT createDeviceFlags 0; #ifdef _DEBUG createDeviceFlags | D3D11_CREATE_DEVICE_DEBUG; // 调试图层能提示资源泄漏和错误 ID3D11Debug #endif D3D_FEATURE_LEVEL featureLevels[] { D3D_FEATURE_LEVEL_11_0 }; D3D_FEATURE_LEVEL outFeatureLevel; DXGI_SWAP_CHAIN_DESC swapChainDesc {}; swapChainDesc.BufferCount 2; // 双缓冲减少画面撕裂 swapChainDesc.BufferDesc.Width width; swapChainDesc.BufferDesc.Height height; swapChainDesc.BufferDesc.Format DXGI_FORMAT_R8G8B8A8_UNORM; swapChainDesc.BufferDesc.RefreshRate.Numerator 60; // 不一定强制生效由 Present 决定 swapChainDesc.BufferDesc.RefreshRate.Denominator 1; swapChainDesc.BufferUsage DXGI_USAGE_RENDER_TARGET_OUTPUT; swapChainDesc.OutputWindow hWnd; swapChainDesc.SampleDesc.Count 1; // 吃豆人不需要 MSAA保持 1 swapChainDesc.Windowed TRUE; swapChainDesc.SwapEffect DXGI_SWAP_EFFECT_DISCARD; HRESULT hr D3D11CreateDeviceAndSwapChain( nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, createDeviceFlags, featureLevels, 1, D3D11_SDK_VERSION, swapChainDesc, swapChain, device, outFeatureLevel, context); ID3D11Texture2D* backBuffer nullptr; swapChain-GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)backBuffer); device-CreateRenderTargetView(backBuffer, nullptr, rtv); backBuffer-Release(); D3D11_VIEWPORT viewport { 0.0f, 0.0f, (float)width, (float)height, 0.0f, 1.0f }; context-RSSetViewports(1, viewport);这段代码里最容易出问题的是D3D11_CREATE_DEVICE_DEBUG。如果你的机器没有启用 Windows 软件开发工具包中的 DirectX Debug Layer设备创建会直接返回E_INVALIDARG或者运行期抛出异常。解决办法不是删掉调试标志而是先安装“图形工具”可选功能或在代码里降级重试一次。另外BufferCount 2是常见双缓冲设置改成 3 也没问题但要确保 Present 参数里同步标志配合否则撕裂观察不出来性能反而下降。3.2 绘制一个带纹理的吃豆人顶点缓冲与着色器的最小搭配有了设备和上下文下一步是让一个四边形显示出来。吃豆人不是单像素游戏模型复杂度不高但顶点缓冲的概念是通用的。我常用的结构体如struct SpriteVertex { XMFLOAT3 pos; XMFLOAT2 uv; };然后建立动态顶点缓冲允许每帧修改坐标来移动吃豆人。这里给出顶点着色器与像素着色器的 HLSL 最小实现// SpriteVS.hlsl cbuffer WorldConstants : register(b0) { float2 ScreenSize; // 用矩形设置比例 float2 Offset; // 精灵位移 }; struct VS_INPUT { float3 pos : POSITION; float2 uv : TEXCOORD0; }; struct VS_OUTPUT { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; VS_OUTPUT main(VS_INPUT input) { VS_OUTPUT output; float2 clipPos (input.pos.xy Offset) / ScreenSize * 2.0f - 1.0f; // 把 0..width, 0..height 转成 -1..1 的裁剪空间 output.pos float4(clipPos.x, -clipPos.y, 0.0f, 1.0f); // Y 轴翻转 output.uv input.uv; return output; }// SpritePS.hlsl Texture2D spriteTexture : register(t0); SamplerState linearSampler : register(s0); float4 main(VS_OUTPUT input) : SV_TARGET { float4 color spriteTexture.Sample(linearSampler, input.uv); // 如果不想让透明区域显示为黑块使用 alpha 测试或者混合 clip(color.a - 0.1f); return color; }代码逻辑不复杂核心是把像素坐标通过除法映射到裁剪空间并记住 DirectX 的纹理坐标系原点在左上角而裁剪空间上方是正 Y。为了让背景透明像素着色器里用clip丢弃 alpha 低于 0.1 的像素这种方式写起来简单但边缘会显得硬如果想做柔和边缘应该改用混合状态D3D11_BLEND_SRC_ALPHA和D3D11_BLEND_INV_SRC_ALPHA配合 alpha 值。项目里如果发现吃豆人边缘有白边多半就是没有设置混合状态或者纹理加载时把透明通道替换成了白色。3.3 键盘控制与碰撞检测让吃豆人在网格上动起来吃豆人移动规则和普通横版游戏不同它只能在上下左右四个方向走而且通常要“对齐到网格”。很多二次开发的人把输入直接映射成连续位移导致吃豆人斜着卡在墙里。常见做法是维护一个二维地图数组1 表示墙壁0 表示豆子通道玩家每次输入只是设定朝向实际移动时只在当前朝向没有墙壁的前提下更新坐标。碰撞检测不必用像素级检测直接用玩家中心点和豆子中心的欧几里得距离小于阈值即可。下面是一个精简的更新逻辑void Game::UpdatePlayer(float dt) { float speed 120.0f; // 像素/秒 float newX player.x dirX * speed * dt; float newY player.y dirY * speed * dt; // 网格对齐在转向之前先计算当前格子坐标 int curCol (int)(player.x / tileWidth); int curRow (int)(player.y / tileHeight); // 如果新坐标仍处于同一格内直接移动 // 否则检查目标格子是否为墙壁 int nextCol (int)(newX / tileWidth); int nextRow (int)(newY / tileHeight); if (map[nextRow * mapWidth nextCol] 1) { // 撞墙不更新坐标 return; } player.x newX; player.y newY; // 收集豆子遍历豆子列表时用索引删除避免迭代器失效 for (size_t i 0; i pellets.size(); ) { float dx pellets[i].x - player.x; float dy pellets[i].y - player.y; if (sqrt(dx * dx dy * dy) 16.0f) { pellets.erase(pellets.begin() i); // 简化示例正式代码建议优先删尾再交换 score 10; } else { i; } } }代码里的一个细节是遍历豆子时从尾部删除不要在循环里直接用erase后继续i否则会跳过相邻豆子。真实项目中我一般会用一个isAlive标记数组避免频繁 realloc吃豆人这类豆子数量少用std::vector直接删除也能接受但要小心性能退化。键盘输入可以用GetAsyncKeyState它在消息循环外也能读取但注意系统级按键扫描会有延迟更正式的做法是处理WM_KEYDOWN并记录状态。在这个项目里我更倾向于在Update开头调用GetAsyncKeyState(VK_LEFT)判断方向因为响应速度直观代码也简洁。4. 编译和运行中的常见问题从红屏到 60 FPS 的避坑清单4.1 D3D11CreateDevice 返回 E_INVALIDARG 或初始化失败现象代码明明是从网上抄的标准初始化但D3D11CreateDeviceAndSwapChain返回E_INVALIDARG换一台机器又能跑。原因通常是启用了D3D11_CREATE_DEVICE_DEBUG但系统没有 DirectX 调试图层或者特征级别数组里写的不是D3D_FEATURE_LEVEL_11_0。解决办法是分两步先检测是否处于调试模式如果失败就去掉该标志再调用一次同时把特征级别数组改为从 11_0 到 9_3 的降级列表这样在老显卡或虚拟机里也能创建旧版本设备。记住交换链描述结构体必须用{}清零否则残留未初始化字段导致莫名其妙的失败。4.2 窗口显示出来全是黑色但游戏能听到声音或 CPU 占用很高现象日志没有错误窗口也能移动但画面上什么都看不到。原因大概率是每帧没有ClearRenderTargetView或者清屏之后没有把渲染目标重新绑定到输出合并阶段。调试时候你会发现 context 里的OMSetRenderTargets在初始化时绑定过一次但你在程序里重新创建了渲染目标视图之后忘了重新绑定。解决办法是每帧渲染前固定执行“设置视口 - 绑定 RTV - 清除背景色 - 绘制”把这三步写在 Render 函数开头而不是只在初始化时做一次。另外检查 Resize 事件窗口大小变化时如果没有重新创建交换链也会导致黑屏。4.3 吃豆人图像有黑底或者背景方块盖住了豆子现象贴图绘制出来是一块黑色矩形背景网格完全被遮住看不出透明效果。原因就是没有设置混合状态。DirectX 11 默认混合模式是不透明的alpha 通道不会参与输出。解决办法是在初始化时创建ID3D11BlendState设置SrcBlend D3D11_BLEND_SRC_ALPHADestBlend D3D11_BLEND_INV_SRC_ALPHA再调用context-OMSetBlendState绑定。如果用了上一章像素着色器里的clip方法则不需要混合状态但颜色边缘会有锯齿这两种方案选一种即可不要同时用否则会让 alpha 变淡两次。4.4 帧率波动大吃豆人行走一卡一卡现象任务管理器显示 GPU 没跑满但游戏帧率在 60 和 30 之间跳。原因几乎都是把固定步长和刷新率同步混在一起了。最典型的错误是每次都调用swapChain-Present(1, 0)然后还用Sleep(16)去限制帧率显示器刷新率其实是 144Hz于是逻辑和垂直同步打架。正确思路是固定步长只负责游戏逻辑渲染循环让Present的SyncInterval保持 1或者用Present(0, 0)配合手动QueryPerformanceCounter限制 FPS。如果你不想纠结最简单做法是Present(1, 0)然后把固定步长也设成 16.666ms保证逻辑帧和渲染帧严格对齐如果鼠标点击或键盘操作有滞后再考虑开启DXGI_SWAP_EFFECT_FLIP_DISCARD这需要 Win10 及以上系统。4.5 换一台电脑运行报错 0xc000007b或者提示找不到 d3d11.dll现象zip 解压后在开发机跑得好好的拿到另一台机器双击 EXE 直接弹错误或者系统提示缺少 d3d11.dll。d3d11.dll 是系统组件正常 Win7 以上都自带真正缺的是 Visual C Redistributable。很多 DirectX 项目用 Visual Studio 编译时没有设置/MT静态运行时导致目标机器必须安装对应版本的 VC 运行库。最简单的解决方式是编译时在项目属性里把“运行库”改成“多线程 (/MT)”但要求项目本身没有依赖 DLL 版运行时的第三方库。如果项目用了 DirectXTK建议还是继续用/MD然后在发布包里带上对应版本的vc_redist.x64.exe安装程序。另一个跑不了的原因是项目本身用了 Debug 配置调试版本依赖d3d11d.dll普通机器没有所以发布一定要切 Release x64。5. 还没完给吃豆人加动画、查性能瓶颈和验证帧耗时5.1 用纹理坐标切换做帧动画吃豆人的嘴巴不是静态的它沿行进方向一张一合。最简单不引入骨骼动画的做法是把多帧嘴巴状态横向排在一张纹理图集里顶点着色器中通过位移改变 UV 偏移。比如uv.x加一个frameIndex乘以 0.25这样不用切换纹理只需每 0.15 秒更新frameIndex。在Update中累积animTimer超过阈值就切换帧并归零。你会发现同一个精灵矩形只改 UV 就能做到动画效果这就是精灵图集的价值。5.2 用 PIX 和调试图层验证绘制调用想要知道是不是某个绘制状态频繁切换拖慢了性能可以用 Visual Studio 自带的图形调试器也可以打开 DirectX 调试图层后看输出窗口的警告。掌握三个看项目是否正常的标准DrawIndexed调用次数是否和精灵数量一致每帧是否真的只使用了一种着色器如果有多个像素着色器频繁切换从架构上看就该做批处理合成用ID3D11Debug的ReportLiveDeviceObjects在退出时判断有无资源泄漏。吃豆人这层游戏如果出现掉帧肯定不是像素填充量的问题而是你一边加载纹理一边每帧创建对象。5.3 用 QueryPerformanceCounter 验证真实耗时很多项目写着 60 FPS其实渲染时间只有 2ms瓶颈在逻辑更新或窗口消息阻塞。我习惯在每帧渲染后记录一个frameCount每 100 帧打印一次平均帧耗时。代码很短但能一眼看出问题如果在固定步长循环里做碰撞检测时遍历整个地图每帧多跑 1000 个循环吃豆人这种简单场景并不明显加到 5000 个格子后就会看出帧时间异常。用下面这种计时方式可以在不接入 profiler 的情况下快速验证static int frames 0; static double elapsed 0.0; static LARGE_INTEGER last; QueryPerformanceCounter(last); // ... 渲染后 LARGE_INTEGER now; QueryPerformanceCounter(now); double ms (double)(now.QuadPart - last.QuadPart) / (double)freq.QuadPart * 1000.0; elapsed ms; frames; if (frames 100) { char buf[128]; sprintf_s(buf, Avg %.3f ms (%.1f FPS), elapsed / frames, 1000.0 / (elapsed / frames)); SetWindowTextA(hWnd, buf); frames 0; elapsed 0.0; }这是我每次复现别人 DX11 小项目时都会加的一段代码它能覆盖掉“我觉得很快”的错觉。之前朋友跑同一个吃豆人说卡成幻灯片结果发现是他用 Debug 模式跑然后窗口里还有大量 GDI 控件叠加刷新。换成 Release 并清除无谓的窗口重绘后帧耗时直接从 34ms 掉到 3ms。做这份笔记的过程里我也重踩了调试图层不能用的坑花了一个晚上定位到是忘了装“图形工具”可选组件。吃豆人这种项目虽然小但把它跑顺、再亲手加一个动画帧你对 DirectX 11 从“看得懂”到“能掌控”的转变就是这么发生的。希望帮到你。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门