ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++ DirectX 11 实现吃豆人:从渲染管线到游戏逻辑

C++ DirectX 11 实现吃豆人:从渲染管线到游戏逻辑 简介这是一份基于 C 与 DirectX 11 实现的吃豆人游戏完整工程面向 DirectX 初学者和游戏编程爱好者也可作为图形学课程设计或毕业设计的参考实现。项目参照 1980 年原始版本制作支持方向键移动、能量豆吃鬼反击、普通与狩猎阶段切换等玩法四只鬼魂各具独立 AI生动复现早期街机的追逐与逃跑逻辑操控手感在致敬经典的同时略快于原作。压缩包共 61 个文件、约 6MB以 18 个头文件、15 个 C 源码和 8 个 HLSL 着色器为主体另有贴图素材、演示动图以及 Visual Studio 2017 解决方案目录划分清晰编译所需的 DirectXTK 依赖约定通过 NuGet 安装并在说明中标注。已有 109 人学习下载。工程将二维地图拆分为分离立方体并通过合并相邻连接面减少三角形数量、规避 z-fighting 问题对理解网格生成、渲染批次优化以及 DirectX 管线均有实际参考价值也便于二次修改与学习。1. 为什么这个 C DirectX 11 吃豆人值得你打开看看工作这么多年见过不少学员拿 贪吃蛇 当 D3D 入门作品交差但真正按DirectX 11那一套渲染管线从零磨出来的反而是眼前这个吃豆人更像个正经的图形学项目。它没有花哨的 PBR、没有骨骼动画却刚好覆盖了你要踩的坑——窗口创建、设备与交换链初始化、顶点缓冲、着色器编译、Sprite 渲染、键盘状态轮询以及最折磨人的帧同步。如果你正处在「C 语法都认识但不知道一个能运行的 Windows 游戏工程该长什么样」的阶段这个包就是给你准备的。它适合两类人一是想用 C 巩固图形学基础、D3D11 又不至于像 D3D12 那样劝退的初学者二是手里已有 Win32 或 GDI 小游戏经验、想看看「正式一点的渲染器」怎么组织代码的转岗者。整个方案的做法是用 ImGui 做调试面板、用 DX11 的 Immediate Context 绘制 2D 纹理再手动实现吃豆人的碰撞和简单 AI。接下来我从编译到运行把每一步拆给你看。2. DirectX 11 选型与工程骨架为什么这套组合最适合复现老游戏2.1 C 配 DirectX 11 的理由能在 Windows 上稳定跑起来的经典组合选 C 做这个项目不是为了情怀而是因为 DirectX 11 的 API 本身就是 COM 接口C 能直接用不像 C# 还要包一层 SharpDX虽然现在也维护得一般。常见的替代方案是 SFML 或 SDL它们封装好了窗口和纹理但你就看不到交换链、渲染目标视图这些东西了用 D3D11 是能看到「图形 API 真正在干什么」的下限。另一个很现实的原因是资料多老外写的 DX11 教程一抓一大把踩坑时搜索命中率高。这套组合最贴近吃豆人这种 2D 游戏的实际需求不用顶点法线、不用模版缓冲但你需要深度测试来保证地图块的绘制顺序需要用D3D11_BLEND_ALPHA让小球和幽灵的透明纹理正确叠加还要处理多重采样抗锯齿。如果你用 GDI 做刷新率一高就闪用 D3D11 做性能富余到能跑几百帧这时候反而要加IDXGISwapChain::Present(1, 0)限制垂直同步。2.2 拿到工程之后先对照这几样解决方案结构与应用类入口打开压缩包后第一件事不是急着编译而是确认这套工程的组织方式。常见做法是一个解决方案里放着Pacman.sln下面有项目文件、d3d11依赖和content目录。核心入口通常是一个继承自DX11*基类或直接持有HWND的Game类主要职责是初始化窗口、创建设备与交换链、加载资源、运行消息循环。我建议你按这个顺序去读代码先找CreateDeviceAndSwapchain相关函数确认交换链是双缓冲还是三缓冲再看GameLoop或Tick里的逻辑更新与绘制分离是否清晰然后对照地图数据确认墙体、豆子、能量豆的图块在二维数组里是怎么编号的。我在工程里用了一个std::wstring保存资产路径主要目的是处理中英文路径混排这个细节后面还会提到。一个要注意的习惯是不要把所有代码塞进wWinMain里不然以后想改成状态机主菜单/游戏中/暂停时会非常痛苦。吃豆人的逻辑天然适合拆成Game规则、Map碰撞、Actor实体三个类。2.3 用 Visual Studio 编译工程环境配置与生成项打开解决方案后如果直接 F5 报一堆头文件找不到八成是 SDK 目录没配对。先确认系统装了 Windows 10/11 SDK再检查项目属性的「VC 目录」——包含目录要有$(IncludePath)库目录要有$(LibraryPath)。Debug 和 Release 的字符集统一设成「使用 Unicode 字符集」避免 TCHAR 转 WCHAR 时出乱码。还要把_CRT_SECURE_NO_WARNINGS加进预处理器因为fopen这类函数在 VS 里默认会报警告虽然能忽略但输出窗口一红就有新手以为编译失败了。如果附加依赖项里没有d3d11.lib和dxgi.lib需要在「链接器 → 输入」里手动加。Release 配置下记得关掉「启用增量链接」否则改一次资源重编译时会偶尔出现链接器死锁这个坑不常见但很消耗耐心。2.4 DirectX 11 渲染流程的关键代码块下面是创建 D3D11 设备与交换链的最小实现组合了我们在 2.1 节和 2.2 节考虑的因素。这是初始化环节通常放在Game::Init里代码基于CreateDXGIFactory枚举适配器这是避免拿到集成显卡和独立显卡错配的常见做法。// d3d11_device.cpp — 初始化 D3D11 设备、上下文与交换链 ComPtrID3D11Device device; ComPtrID3D11DeviceContext context; ComPtrIDXGISwapChain swapChain; DXGI_SWAP_CHAIN_DESC scDesc {}; scDesc.BufferCount 2; // 双缓冲减少画面撕裂 scDesc.BufferDesc.Width 960; scDesc.BufferDesc.Height 720; scDesc.BufferDesc.Format DXGI_FORMAT_B8G8R8A8_UNORM; // 兼容性最好取代 R8G8B8A8 scDesc.BufferUsage DXGI_USAGE_RENDER_TARGET_OUTPUT; scDesc.OutputWindow hwnd; scDesc.SampleDesc.Count 4; // 4x MSAA可调谨慎配合透明混合 scDesc.SampleDesc.Quality static_castUINT(D3D11_STANDARD_MULTISAMPLE_PATTERN); scDesc.Windowed TRUE; scDesc.SwapEffect DXGI_SWAP_EFFECT_DISCARD; scDesc.Flags DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH; D3D_FEATURE_LEVEL levels[] { D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0 }; HRESULT hr D3D11CreateDeviceAndSwapChain( nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, D3D11_CREATE_DEVICE_DEBUG, // Debug 模式带校验层开发期强烈建议开启 levels, ARRAYSIZE(levels), D3D11_SDK_VERSION, scDesc, swapChain, device, nullptr, context); if (FAILED(hr)) { /* 检查 hr 是不是 DXGI_ERROR_UNSUPPORTED若是则把 SampleDesc.Count 改回 1 */ }这里的逻辑顺序是先描述交换链scDesc再创建设备与交换链。参数上BufferCount2是为垂直同步服务的Format用B8G8R8A8是因为桌面合成器对它的处理最成熟SampleDesc.Count4开了抗锯齿会给边缘更平滑但如果你后面用点对点采样放大像素风反而建议关掉设成 1。Debug 标志平时开着可以看输出窗口的调用栈警告但发布时记得删掉不然性能会明显下降。设备创建完毕后还要CreateRenderTargetView并设置视口这是下一小节绘制回调的前置条件。调试的时候验证层会在 Output 窗口逐条报错格式是D3D11 ERROR遇到不认识的可以去查微软文档线上搜 DX11 error codes 对应关系也可以。3. 把吃豆人拆成类与机制地图、精灵、移动与碰撞判定3.1 地图数据怎么表达二维数组与图块索引吃豆人地图本质上是瓷砖数组我用std::vectorint加一行行字符串常量来初始化。字符#表示墙.表示豆子O表示能量豆-表示通道。这样做的好处是关卡策划可以直接改文本地图而不动代码。// map_data.cpp — 用文本地图描述关卡示例与完整版地图边界一致 std::vectorstd::string rawMap { ############################, #............##............#, #.####.#####.##.#####.####.#, #O####.#####.##.#####.####O#, #.####.#####.##.#####.####.#, #..........................#, #.####.##.########.##.####.#, #.####.##.##OO##.##.####.#, // 注意这行是两条豆路中间包能量豆 #......##....##....##......#, ############################ }; // 转换时注意把 char 解析成 enum TileType0空地 1墙 2豆子 3能量豆解析时要把二维索引换算成顶点坐标x col * TILE_WIDTHy row * TILE_HEIGHT。这里踩过一次坑row对应的是屏幕 Y但 D3D 的投影矩阵不同会导致地图上下颠倒我加了个flipY参数统一处理。碰撞判定要用「玩家中心点所在的瓷砖索引」来判断而不是用顶点的坐标否则在拐弯处会因为坐标没完全对齐而卡住。3.2 吃豆人移动四方向输入与平滑位移移动逻辑的核心是「先拐弯、再走路」。玩家按方向键后如果目标格子不是墙就更新方向。速度按帧乘deltaTime避免高帧率下速度不一致。// movement.cpp — PacmanActor::Move 片段基于 deltaTime 的匀速率移动 void PacmanActor::Move(float dt) { // 计算目标格子若下一格可走则换成新方向 int curTileX (int)(pos.x halfSize) / TILE_SIZE; int curTileY (int)(pos.y halfSize) / TILE_SIZE; int nextTileX curTileX dirX; int nextTileY curTileY dirY; if (!map-IsWall(nextTileX, nextTileY)) { dir bufferedDir; // 预输入方向玩家手感会好很多 } // 位移给个小量偏移防止负数坐标 pos.x dirX * speed * dt; pos.y dirY * speed * dt; }这个实现用了「缓存输入方向」的技巧当玩家在十字路口前0.2秒预按方向键系统会保存方向并在可行时立即转向这比“到格子中心才能转向”的方案手感好一个等级。参数上halfSize TILE_SIZE * 0.3f做碰撞宽容度因为吃豆人的嘴巴在动画过程中是张开的如果用满格判定会在转角处误判为穿墙这个值调太大又会抄近道我一般保持在0.25~0.35之间。3.3 豆子的碰撞与计分为什么对 AABB 而不是逐像素吃豆人吃豆的判断我用的不是逐像素融合而是通过简化后的 AABB 矩形中心距离来检测。因为豆子图块之间是有间隙的如果用严格的矩形相交在动画过程的张合嘴状态下会漏判玩家会感觉豆子“不吸”或延迟判定。// collision.cpp — 豆子吸收判定 bool PacmanActor::TryEatDot(Dot dot, int score) { float centerDist Length(pos - dot.pos); float radiusSum PacRadius DotRadius - 2.0f; // 减 2 像素是故意做的吸豆补偿 if (centerDist radiusSum !dot.eaten) { dot.eaten true; score dot.isPowerUp ? 50 : 10; return true; } return false; }这个「吸豆补偿」是典型的经验参数如果公差太小贴边快速移动时会漏掉一整行豆子如果太大虽然看着吸附效果强但碰到能量豆时判定会显得不真实。比较讲究的做法是把检测频率与移动速度挂钩当速度大于某个阈值时采用扫掠线段碰撞检测或者把pos偏移量拆成两帧检测防止在极高帧率下穿透墙体。3.4 幽灵 AI从红蓝随机到简单寻路至少要能追、能逃吃豆人里的幽灵 AI 如果完全随机玩家很快会觉得无趣如果全都直线追尾又会因为速度一致而在岔路反复横跳。我建议开局时每个幽灵用不同策略组合Blinky 直线追踪玩家所在行/列的走廊Pinky 预测玩家移动方向提前两步截击Clyde 随机巡逻积攒“恐惧值”。这样生成的追逐行为复杂度足够又不需要写复杂的 A* 路径搜索。为了控制难度我加了「吓跑模式」标志位。吃能量豆后 5 秒内幽灵下降速度 20%且寻路的目标变成吃豆人对角——这样形成一种“同时被追但并不会被秒杀”的平衡。4. 让吃豆人真正动起来的渲染管线着色器、纹理混合与帧循环4.1 顶点缓冲与图元绘制的正确姿势Sprite 如何出现在屏幕上吃豆人的角色渲染不只是把图像贴到屏幕矩形上而是在 D3D11 里先用顶点缓冲描述四边形再用着色器把四边形和纹理绑定最终绘制。我的做法是维护一个SpriteBatch类把所有角色纹理按通道打包到张纹理图集里避免频繁切换PSSetShaderResources——切换是 D3D11 性能黑洞本项目规模下虽影响不大但这是 DX11 中好的实践。// spritebatch.cpp — 把吃豆人单帧图集绘制到屏幕的简化顶点装配 struct Vertex { float x, y, z; float u, v; float r, g, b, a; }; // 每个精灵 6 个顶点两个三角形形成矩形 // 顶点缓冲每帧动态重建因为精灵世界坐标与相机滚动都在变着色器方面顶点着色器做 world-view-projection 变换像素着色器采样纹理并乘以顶点色实现淡入淡出。吃豆人嘴巴张开闭合的动画实际上是纹理坐标的偏移而非采用骨骼动画。我不建议在吃豆人这种规模的项目里引入骨骼动画系统维护成本会大于收益。4.2 透明混合与能量豆的闪动效果把混色模式搞清楚吃豆人游戏里有许多“闪动”效果——能量豆高亮、幽灵半透明、吃豆人被反色闪白都用到了混合状态ID3D11BlendState。DirectX 11 默认是不做混合的你需要手动创建透明混合状态并在绘制 UI 前设置好。// blendstate.cpp — 标准Alpha混合状态用于能量豆与幽灵半透明效果 D3D11_BLEND_DESC bd {}; bd.RenderTarget[0].BlendEnable TRUE; bd.RenderTarget[0].SrcBlend D3D11_BLEND_SRC_ALPHA; bd.RenderTarget[0].DestBlend D3D11_BLEND_INV_SRC_ALPHA; bd.RenderTarget[0].BlendOp D3D11_BLEND_OP_ADD; bd.RenderTarget[0].SrcBlendAlpha D3D11_BLEND_ONE; bd.RenderTarget[0].DestBlendAlpha D3D11_BLEND_ZERO; device-CreateBlendState(bd, blendState);这里的关键语义是SrcBlendAlpha使用D3D11_BLEND_ONE而不是D3D11_BLEND_SRC_ALPHA——因为通常不希望在透明度通道上二次叠加。如果初学者直接抄 DX9 时代的代码很容易在画叠加层时出现黑边就是这个参数设成了SRC_ALPHA。闪动效果的实现方式是在 CPU 端每帧更新时间t并传入像素着色器的常量缓冲区然后对能量豆的 alpha 做sin(t*频率)调制。这类恒定变化用 CB 传参比在 CPU 端反复创建混合状态要高效得多。4.3 游戏循环与消息泵为什么要PeekMessage而不是GetMessage标准 Win32 游戏循环通常用PeekMessage并配合WM_QUIT退出而不是直接用阻塞式的GetMessage因为你要在“没有窗口消息”时继续渲染和更新逻辑。如果使用GetMessage窗口空闲时画面会冻结。下面给出最小可运行的循环骨架这是从Run()里抽出来的关键部分。// gameloop.cpp — 帧循环主结构 void Game::Run() { MSG msg {}; LARGE_INTEGER frequency, last, now; QueryPerformanceFrequency(frequency); QueryPerformanceCounter(last); while (msg.message ! WM_QUIT) { if (PeekMessage(msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(msg); DispatchMessage(msg); } else { QueryPerformanceCounter(now); float dt (float)(now.QuadPart - last.QuadPart) / frequency.QuadPart; dt min(dt, 0.05f); // 防止最小化后回放时把deltaTime拉到巨大 last now; Update(dt); // 玩家输入、AI、碰撞、得分 Render(); // 清屏、绘制全场景、交换链Present } } }参数核心是dt的截断值。如果不限幅窗口被拖动或最小化 2 秒后恢复那一帧的dt≈2s吃豆人会瞬移穿过一排墙。我把上限设为0.05f上一帧卡顿 20 帧以内不受影响若超过则加速追赶若干毫秒。另一个注意点是QueryPerformanceCounter在多核 CPU 上有时会跨核偏移一般进程默认亲和性不会变动若遇到异常尽量锁定单核处理计时。4.4 vsync、Present(1,0) 与帧率控制的取舍贪吃鬼类项目最容易出现的现象是画面横向撕裂。 D3D11 的Present第一个参数传1表示开启垂直同步一般显示器刷新率是 60Hz这样逻辑就稳定在 60 帧上下。显示器的刷新率是 144Hz 的机器上代码应改为按dt驱动逻辑而不是按 60 帧假设。如果你在意最高帧率比如测试时想看出渲染耗时可以保持Present(0,0)同时用定时器做逻辑限速。常见做法是维护accumulator每帧累计dt达到1.0 / 60才跑一步Update渲染保持尽量多帧。注意如果同时开启垂直同步和 CPU 限速会叠加出 30 帧的奇怪顿挫。5. 避坑与常见问题DirectX 11 开发中那些容易让人止步的翻车点5.1 现象运行时崩溃并提示 D3D11 ERROR: ID3D11DeviceContext::DrawIndexed, State is invalid这是最常见的问题。原因是顶点缓冲与输入布局不匹配——例如你在顶点结构里加了u,v纹理坐标却没有在CreateInputLayout的数组里对应添加D3D11_APPEND_ALIGNED_ELEMENT。我用D3D11_APPEND_ALIGNED_ELEMENT时如果顺序颠倒报错信息可能不会直接指出来但DrawIndexed一定会炸。解决方法打印输入布局描述逐个核对语义字符串是否为POSITION、TEXCOORD、COLOR且格式为DXGI_FORMAT_R32G32B32_FLOAT或R32G32_FLOAT。5.2 现象窗口客户区一片黑色 / 背景色持续闪烁黑色通常是渲染目标视图没有清屏或清屏颜色是 (0,0,0,0) 但在Present之前没有设置渲染目标。如果是闪烁可能是交换链缓冲计数不对时Present(1, 0)和清屏交替时产生前后台不同步。打开D3D11_CREATE_DEVICE_DEBUG后只看到清屏但没有任何绘制日志就需要检查顶点着色器是否绑定了VS常量缓冲。5.3 现象纹理黑边或透明边缘有“灰圈”在画吃豆人和幽灵的圆弧边缘时明明带了 Alpha 通道边缘还是出现一圈灰色光晕。这几乎可以断定是纹理格式问题——你可能把带有灰度预乘的 PNG 直接当成不预乘的直通式 Alpha 处理了。解决方法是统一约定资源管线在加载贴图时把像素点做去缝展开处理即在纹理四周填充透明像素或者确保美术输出的素材是「直通 Alpha」而非「预乘 Alpha」。5.4 现象Debug 下能跑Release 下炸或反之只在 Release 炸这种不稳定十有八九是未初始化变量。Debug 会把栈内存清零Release 则保留脏数据别人项目里常见的pos变量声明后未赋初值正是这类问题。解决办法是把所有成员变量放到构造函数初始化列表中显式赋初值关闭优化/Od后逐段排除。5.5 现象FPS 正常但移动时“一顿一顿”如果 FPS 稳定在 60输入响应也很快但精灵移动时还是像在 15 帧那样一顿一顿且主要发生在斜向移动时。这大概率是渲染逻辑用了固定step累积而移动代码只按dt更新导致两套时钟打架渲染定时器本位推进而精灵位置只按步进帧跳跃。统一方案是把所有模拟逻辑都放在Update(dt)里执行并把渲染定时器与交换链的Present参数绑定为同一个时钟源。6. 进阶验证与方法把吃豆人跑顺之后的三个优化向当你把吃豆人跑通、能吃豆、能被幽灵追之后接下来值得做的不是加特效而是做三件事。第一把里头的std::endl、OutputDebugString全替换成轻量级日志库因为高频轮询和日志写盘会在某帧突然卡一下这是很多人没意识到的“隐形打断”。第二在渲染循环里加入 GPU 时间戳查询——用ID3D11Query的D3D11_QUERY_EVENT测量实际绘制耗时分辨当前的瓶颈是Present等待还是 CPU 端的碰撞计算这个落在工程里就是每帧统计一个draw_ms并在 ImGui 面板显示。第三把地图从写死的vectorstring改成CSV文件加载同时加上纹理图集坐标定义表为以后扩展关卡铺路。我个人的习惯是每次写完一版游戏循环都强制自己运行一遍 Release 配置再运行一遍带调试层的 Debug 配置最后检查Output窗口里是否有D3D11 WARNING。把警告清零再提交这个习惯避免了很多后期难以排查的问题。比如像D3D11_WARNING提示的“资源在仍然绑定时被释放”这种隐蔽资源泄漏靠眼睛盯代码是看不出来的。另外如果你集成 ImGui 的话别忘了把它的渲染器和 D3D11 的渲染状态做隔离我可以这么说ImGui 的顶点缓冲和你的精灵渲染共用一个ID3D11DeviceContext任何混用混合状态的时机没对齐画面就会诡异地少一排豆子。建议你把帧率上限、音量、背景音乐开关做成配置项放进一个ini文件再用GetPrivateProfileString读取这样以后送测给别人时对方不用重新编译就能调参。这些细节并不会让你的吃豆人变成 3A但它会把一个“能编译”的 demo 推进成“值得维护”的项目。希望这些步骤和踩过的坑能帮到你也让这套 C 与 DirectX 11 的骨架在之后的项目中继续为你工作。本文还有配套的精品资源点击获取
返回列表