
简介该资源为一个用 C 与 DirectX 11 实现的经典吃豆人游戏项目灵感来源于 1980 年原始版本适合对游戏开发、图形渲染或复古游戏复刻感兴趣的初学者与进阶开发者。项目依赖 DirectXTK 简化 DirectX 使用工程基于 Visual Studio 2017包含完整可编译源码、着色器、精灵图及资源文件共 61 个文件涵盖 h/cpp 源码、hlsl 着色器、png/gif 图像资源以及 sln/vcxproj 工程配置等压缩包约 6MB。游戏还原了原版操作手感支持方向键控制、幽灵追击、能量豆反制等经典机制四个幽灵均拥有基于 1980 年代版本的独立 AI并提供能量状态切换与阶段变化。地图以分离立方体构建通过相邻面合并优化三角形数量避免 z-fighting 问题体现了实际渲染优化思路。压缩包内附 README 与资源说明便于查阅与二次开发。截至目前已有 109 人学习下载适合作为 DirectX 入门练手或游戏机制复刻的参考项目。1. 用 C 和 DirectX 11 重写吃豆人为什么值得折腾用 C 和 DirectX 11 开发一个吃豆人游戏听起来像 Computer Graphics 课的陈年作业但当你在网上看到那个“吃豆人游戏.zip”并解压后会发现这里藏着的不是几张贴图而是一条完整的 DirectX 11 渲染管线。这套东西能让你明白现代游戏引擎替你藏了多少事。如果你已经会 C 基础想搞清楚 2D 游戏从窗口创建到精灵绘制、再到碰撞判定到底是怎么串起来的这个项目非常合适。本文不点评某个具体压缩包而是讲清这类 C/DirectX 11 吃豆人工程最常见的实现路径、参数和翻车点让新手能照着复现熟手能直接填坑。2. 拿到工程先别急着跑弄清楚 D3D11 项目该长什么样2.1 解压后的常见文件结构一份清单的意义一个用 C 和 DirectX 11 写的吃豆人项目解压后通常不是只有一个 exe。我更关心源码、资源、着色器是怎么组织的因为这三个部分分别代表了渲染逻辑、游戏内容和 GPU 指令。路径/文件作用常见后缀src/C 源码包含 Win32 窗口入口、游戏类、DirectX 11 辅助工具类.cpp / .hassets/地图数据、贴图、字体、音效等运行时资源.txt / .png / .dds / .wavshaders/HLSL 着色器决定顶点怎么变换、像素怎么染色.hlsl / .fxCMakeLists.txt 或 .sln构建方式直接决定环境好不好配.txt / .slnREADME.md项目说明包含运行环境、操作方式、已知 bug.md拿到 zip 先做的事不是找 exe而是看 README 和构建文件。很多这类项目都是课程作业级别默认你用 Visual Studio 且装过 Windows SDK。如果你直接双击编译好的 exe可能会因为 shaders 或 assets 路径不对而黑屏。为何因为代码里常常用相对路径“shaders\sprite.hlsl”而工作目录和 exe 所在目录不一致时文件就找不到了。我一般会在构建阶段自动把 assets 和 shaders 复制到目标目录这个后面讲。还有一点容易忽视资源文件是文本地图还是图片地图。经典吃豆人用字符网格表示墙和豆子比如 # 代表墙. 代表豆子O 代表能量豆。这种格式的好处是任何人都能直接改关卡也方便在控制台输出 DEBUG 状态。DirectX 11 只是渲染最外层游戏逻辑其实不依赖图形 API这也是为什么这种项目特别适合学习解耦。2.2 环境配置Visual Studio 与 Windows SDK 的边界在 Visual Studio 2019 或 2022 中Windows SDK 自带 d3d11.h 和 d3d11.lib不需要像旧时代那样单独装 DirectX SDK。很多新手在配置属性里乱加包含路径结果反而让编译器找到版本不对的 d3d11.h报出一堆不明错误。我的做法很简单安装 VS 时勾选“使用 C 的桌面开发”工作负载它默认会装上 Windows SDK。项目平台建议选 x64不要用 x86。为什么DirectX 11 本身 32/64 都支持但现代系统里 x64 下指针宽度一致调试工具识别更好资源加载也不容易碰到重定向问题。如果你遇到“无法打开 d3d11.h”多半是 Windows SDK 版本没装全回到 VS Installer 里勾选当前 Windows SDK 后修复即可。如果你更习惯 vscode 配置 c/c 环境再配 CMake当然也可以但需要确认编译器能链接到系统库。直接在 CMake 里使用target_link_libraries指向 d3d11、dxgi、d3dcompiler 是跨 VS 与 vscode 的标准做法。另外一个值得注意的点是NOMINMAX。Windows.h 里有min和max宏会和 C 标准库的std::min冲突让代码变得很玄学。CMake 里加target_compile_definitions(PacMan PRIVATE NOMINMAX)能省掉很多烦躁。2.3 用 CMake 组织构建是这类项目最常见的后悔药很多从课程流出的源码是 .sln 工程的VS 打开能直接编但可移植性差。我更愿意用 CMake 重写构建脚本至少以后换机器不用反复配置库路径。下面是一个最精简的 CMakeLists.txt能覆盖大多数 D3D11 2D 游戏项目。cmake_minimum_required(VERSION 3.20) project(PacManD3D11) set(CMAKE_CXX_STANDARD 17) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) add_executable(PacMan src/main.cpp src/PacManGame.cpp src/PacManGame.h src/D3D11Helper.cpp src/D3D11Helper.h shaders/sprite.hlsl ) target_include_directories(PacMan PRIVATE src) target_compile_definitions(PacMan PRIVATE NOMINMAX) # DirectX 11 链接 target_link_libraries(PacMan PRIVATE d3d11 dxgi d3dcompiler) # 运行前自动拷贝 assets 和 shaders 到 exe 所在目录 add_custom_command(TARGET PacMan POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/assets $TARGET_FILE_DIR:PacMan/assets COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/shaders $TARGET_FILE_DIR:PacMan/shaders COMMENT Copying runtime resources )这段脚本的逻辑很简单d3d11提供设备和上下文dxgi负责交换链和显示模式d3dcompiler负责在运行时把 HLSL 编译成字节码。加 DirectXTK 这类第三方库时再补find_package就可以但这里的核心项目用不到。POST_BUILD这两条命令非常重要否则因为你直接运行bin/PacMan.exe时当前目录是 bin代码里std::ifstream(assets/map.txt)会失败。在实际项目里我还会加一个#define用来控制是否启用 D3D11 调试层。平时 Debug 构建开启调试层可以帮你看到很多资源和状态错误Release 构建就关掉避免性能损耗。配置参数就写在 CMake 里比如target_compile_definitions(PacMan PRIVATE $$CONFIG:Debug:D3D11_DEBUG )然后在 C 里读取它决定传给D3D11CreateDevice的D3D11_CREATE_DEVICE_DEBUG标志。这是后面排查纹理格式和渲染状态最有效的第一步别等画面黑屏了再去猜哪里爆了。3. DirectX 11 渲染管线的最小骨架从窗口到第一个精灵3.1 创建设备与交换链每个 D3D11 游戏的“地基”无论吃豆人还是其他 2D 游戏第一步都是在 Win32 窗口上创建ID3D11Device设备和IDXGISwapChain交换链。设备用来创建纹理、缓冲区、着色器交换链则决定你渲染好的画面怎么输出到显示器。窗口初始化不是本文重点但hwnd必须有。#include d3d11.h #include dxgi1_3.h #include wrl/client.h using Microsoft::WRL::ComPtr; ComPtrID3D11Device device; ComPtrID3D11DeviceContext context; ComPtrIDXGISwapChain swapChain; DXGI_SWAP_CHAIN_DESC sd {}; sd.BufferDesc.Width 800; sd.BufferDesc.Height 600; sd.BufferDesc.Format DXGI_FORMAT_B8G8R8A8_UNORM; sd.BufferDesc.RefreshRate.Numerator 60; sd.BufferDesc.RefreshRate.Denominator 1; sd.SampleDesc.Count 1; sd.SampleDesc.Quality 0; sd.BufferCount 2; sd.BufferUsage DXGI_USAGE_RENDER_TARGET_OUTPUT; sd.OutputWindow hwnd; sd.Windowed TRUE; sd.SwapEffect DXGI_SWAP_EFFECT_DISCARD; HRESULT hr D3D11CreateDeviceAndSwapChain( nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT, nullptr, 0, D3D11_SDK_VERSION, sd, swapChain, device, nullptr, // 不关心 feature level context ); if (FAILED(hr)) { // 打印 hr 值大概率是设备初始化失败 }这段代码里的参数直接影响后面能不能画对。BufferCount 2是双缓冲减少画面闪烁Format DXGI_FORMAT_B8G8R8A8_UNORM是交换链常见格式和 GDI 兼容性更好SwapEffect用DISCARD表示每帧丢弃前一个缓冲简单粗暴但也够用。创建完交换链还要拿到它的后台缓冲区绑定为渲染目标否则你画的东西无处可去ComPtrID3D11Texture2D backBuffer; swapChain-GetBuffer(0, IID_PPV_ARGS(backBuffer)); ComPtrID3D11RenderTargetView rtv; device-CreateRenderTargetView(backBuffer.Get(), nullptr, rtv); context-OMSetRenderTargets(1, rtv.GetAddressOf(), nullptr);之后在绘制循环里先清屏再绘制float clearColor[4] { 0.0f, 0.0f, 0.0f, 1.0f }; context-ClearRenderTargetView(rtv.Get(), clearColor);有个常踩的坑是忘记在窗口大小变化时重建缓冲区和 RTV结果拉伸后画面糊掉或直接花屏。吃豆人一般窗口固定可以跳过但如果你加缩放功能必须监听WM_SIZE并调用IDXGISwapChain::ResizeBuffers。3.2 用两个三角形拼出一个 sprite没有 SpriteBatch 的日子DirectX 11 没有内置的SpriteBatch这和 XNA 时代不一样。2D 渲染的本质是画两个三角形组合成一个四边形再贴上纹理。四个顶点组成一个矩形这个矩形就是你要显示的精灵。顶点结构通常这样定义struct SpriteVertex { DirectX::XMFLOAT3 pos; // 位置 DirectX::XMFLOAT2 uv; // 纹理坐标 };随后创建顶点缓冲区把四个点按三角形顺序填进去。这里最关键的参数是纹理坐标的方向。我一般用 (0,0) 表示纹理左上角(1,1) 表示右下角这样和美术同学的习惯一致。代码里把坐标写成SpriteVertex vertices[] { { { -0.5f, -0.5f, 0.f }, { 0.f, 1.f } }, { { 0.5f, -0.5f, 0.f }, { 1.f, 1.f } }, { { 0.5f, 0.5f, 0.f }, { 1.f, 0.f } }, { { -0.5f, 0.5f, 0.f }, { 0.f, 0.f } }, };请注意这里 uv 的 y 值。DirectX 纹理坐标系中v0 对应纹理顶部还是底部取决于顶点着色器的SV_Position语义和你的投影矩阵。若搞反你会发现图案上下颠倒。为了避免这种反直觉我在实际项目里统一以屏幕左上角为原点并在 HLSL 中处理顶点的y方向翻转这放在 3.3 节详细讲。然后是 HLSL 着色器。这是整个 D3D11 黑匣子里最容易出错的部分一旦矩阵顺序写错精灵通常直接消失或变成一条线。cbuffer Transform : register(b0) { matrix mvp; }; Texture2D tex : register(t0); SamplerState samp : register(s0); struct VSInput { float3 pos : POSITION; float2 uv : TEXCOORD0; }; struct PSInput { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; PSInput VSMain(VSInput input) { PSInput output; // 行主序矩阵向量在前 output.pos mul(float4(input.pos, 1.0f), mvp); output.uv input.uv; return output; } float4 PSMain(PSInput input) : SV_TARGET { return tex.Sample(samp, input.uv); }mul(float4(input.pos, 1.0f), mvp)是行主序矩阵乘法DirectXMath 的XMMatrixTranspose和它配套。如果你发现精灵没有出现优先怀疑矩阵是否在 CPU 端被转置错了。顶点缓冲区和输入布局创建完之后绘制调用只有一句context-VSSetShader(vs.Get(), nullptr, 0); context-PSSetShader(ps.Get(), nullptr, 0); context-IASetInputLayout(layout.Get()); context-IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); context-Draw(4, 0);3.3 正交投影让精灵坐标和像素坐标对上吃豆人这类 2D 游戏最省心的做法是用单位化像素坐标比如你想把精灵画到屏幕 (100, 200) 的位置就直接以像素为单位设置顶点位置。要达到这个效果需要为渲染创建一个正交投影矩阵。常见做法是让投影矩阵把像素坐标映射到屏幕中心DirectX::XMMATRIX proj DirectX::XMMatrixOrthographicLH( (float)screenWidth, (float)screenHeight, 0.1f, 1000.0f);但XMMatrixOrthographicLH默认把原点放在屏幕中心x 朝右y 朝上。画面坐标和 Windows 窗口坐标不一致因为窗口坐标 y 轴是朝下的。解决方式有好几种我一般采用“先平移再翻转 y”的方案。定义一个适合吃豆人的坐标系左上角为 (0,0)x 向右y 向下便于直接对应地图行列。这可以通过修改投影矩阵实现DirectX::XMMATRIX view DirectX::XMMatrixIdentity(); DirectX::XMMATRIX proj DirectX::XMMatrixOrthographicLH( (float)screenWidth, (float)screenHeight, 0.1f, 1000.0f); proj DirectX::XMMatrixMultiply( DirectX::XMMatrixTranslation(-(float)screenWidth * 0.5f, (float)screenHeight * 0.5f, 0.0f), proj);这个矩阵会让原本在中心的原点移动到左上角并且 y 向下。实际使用时你只需要给精灵设置世界矩阵// 精灵的屏幕位置spriteX, spriteY, 缩放 scaleX/scaleY DirectX::XMMATRIX world DirectX::XMMatrixTranspose( DirectX::XMMatrixTranslation(spriteX, spriteY, 0.0f) * DirectX::XMMatrixScaling(scaleX, scaleY, 1.0f));这里的核心参数是screenWidth和screenHeight如果和交换链缓冲尺寸不匹配精灵就会显示偏移。我一般会把这段代码封装成SetOrthographicProjection辅助函数并每次窗口WM_SIZE重建。3.4 纹理加载D3DX 已是旧时代WIC 才是现役方案很多旧教程还在用D3DX11CreateShaderResourceViewFromFile但 DirectX SDK 已经废弃这个函数不存在于标准 Windows SDK 中。现役方案是用 WICWindows Imaging Component加载 PNG/JPEG或者直接使用 DirectXTK 的CreateWICTextureFromFile。我们在 CMake 里没有引入 DirectXTK所以这里给出一个基于 WIC 的最小加载流程。#include wincodec.h #include d3d11.h HRESULT LoadTextureFromWIC(ID3D11Device* device, const wchar_t* path, ID3D11ShaderResourceView** srvOut) { if (!device || !path || !srvOut) return E_INVALIDARG; // 初始化 COM因为 WIC 是 COM 组件 CoInitialize(nullptr); IWICImagingFactory* factory nullptr; IWICBitmapDecoder* decoder nullptr; IWICBitmapFrameDecode* frame nullptr; IWICFormatConverter* converter nullptr; HRESULT hr CoCreateInstance( CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(factory)); if (FAILED(hr)) return hr; hr factory-CreateDecoderFromFilename( path, nullptr, GENERIC_READ, WICDecodeMetadataCacheOnLoad, decoder); if (FAILED(hr)) goto cleanup; hr decoder-GetFrame(0, frame); if (FAILED(hr)) goto cleanup; // 统一转为 32bpp BGRA 格式和交换链一致 hr factory-CreateFormatConverter(converter); if (FAILED(hr)) goto cleanup; hr converter-Initialize(frame, GUID_WICPixelFormat32bppBGRA, WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom); if (FAILED(hr)) goto cleanup; hr device-CreateShaderResourceViewFromWIC(converter, srvOut); cleanup: if (converter) converter-Release(); if (frame) frame-Release(); if (decoder) decoder-Release(); if (factory) factory-Release(); CoUninitialize(); return hr; }这段代码每个函数的参数都有讲究GUID_WICPixelFormat32bppBGRA对应交换链的DXGI_FORMAT_B8G8R8A8_UNORMCreateShaderResourceViewFromWIC是 D3D11 提供的便捷方法内部会创建纹理和 SRV。你不需要自己再创建纹理。加载成功后在像素着色器里采样这张贴图。吃豆人的地图可以拆成几个小精灵豆子、墙、鬼魂、角色也可以全部放到一张图集里用不同的 uv 区域绘制。推荐图集法一次纹理加载多次Draw节省 GPU 状态切换。后面避坑章节会提到图集 uv 写错是最容易翻车的地方。4. 吃豆人自己的逻辑层地图、移动、碰撞与简单 AI4.1 用字符数组做地图省心且容易调试吃豆人世界里没有复杂的物理引擎地图本质是网格。最靠谱的数据结构是二维字符数组。# 表示墙. 表示普通豆子O 表示能量豆 表示空地。下面是一段典型地图摘取片段const int mapRows 15; const int mapCols 19; char map[mapRows][mapCols 1] { ###################, #.........#.......#, #o##.###.#.###.##.#, #.................#, #.##.#.#####.#.##.#, #....#...#...#....#, ####.###.#.###.####, ###.#.........#.###, ####.#.#.###.#.####, #........#........#, #.#####.#.#####..#., #o................o#, ###################, };每个字符对应地图上一个格子。格子大小cellSize是精灵移动的标准单位。我习惯用 18 或 20 像素这样角色大小 16 像素时能居中。角色位置和地图坐标的换算float spriteX col * cellSize (cellSize - spriteWidth) / 2.0f; float spriteY row * cellSize (cellSize - spriteHeight) / 2.0f;这样做的最大好处是碰撞检测可以完全不依赖像素只依赖“格子是否可通行”。当角色准备移动到下一格时检查目标单元格的字符是否#即可。如果你让角色在网格间平滑移动就还需要判断“正在穿越的格子”和“目标格子”之间的边界这部分很容易产生穿墙问题放到第五章详细说。吃豆人豆子的管理可以做成一个简单数组或链表。每个豆子保存所在行列、是否已被吃掉。因为地图量小每帧遍历所有豆子做矩形相交测试成本可以忽略。但如果你想更专业一点可以在每格用一个位图标记豆子是否还存在省去遍历。4.2 固定时间步长不要让快机器吃掉更多豆子游戏循环是吃豆人最微妙的部分。如果直接while (running) { Update(); Draw(); }帧率高的机器上豆子会以双倍速度被吃完幽灵快如闪电。现代游戏引擎普遍使用固定时间步长加帧率插值但吃豆人这种逻辑简单的小游戏固定步长就够了。const float fixedStep 1.0f / 60.0f; float accumulator 0.0f; LARGE_INTEGER prev {}, curr {}, freq {}; QueryPerformanceFrequency(freq.past ); // 纠正QueryPerformanceFrequency(freq); QueryPerformanceCounter(last); while (running) { QueryPerformanceCounter(curr); float frameTime static_castfloat(curr.QuadPart - prev.QuadPart) / static_castfloat(freq.QuadPart); frameTime min(frameTime, 0.25f); // 防止长时间卡顿后跳帧 prev curr; accumulator frameTime; while (accumulator fixedStep) { Update(fixedStep); accumulator - fixedStep; } Draw(); }重要参数fixedStep 1/60。如果逻辑里用dt乘以速度那么这个值就是基础时间单位。考虑到 Debug 断点暂停恢复时会累计很多帧必须限制frameTime最大值否则当你从断点恢复时游戏会瞬间“追上”一大段逻辑幽灵瞬移。限制 0.25 秒是一个常见选择能避免这种跳跃感。关于角色速度我喜欢用“每多少秒移动一格”来描述而不是像素每秒。比如吃豆人移动速度是cellSize * 6秒每格其实写成每秒格数比较好speed 6则每帧移动speed * fixedStep格。这样地图调整大小也不会影响手感。4.3 幽灵 AI 与碰撞曼哈顿距离和 AABB 足够用吃豆人的幽灵不需要 A* 寻路因为地图小且多为连通走廊最经典的策略是“追逐”和“逃逸”两态切换。敌人要去的目标就是玩家所在格子然后用曼哈顿距离判断下一步走哪个相邻格子最靠近玩家。struct Vec2i { int row, col; }; Vec2i nextGhostStep(Vec2i ghost, Vec2i player, int map[mapRows][mapCols], Vec2i prevDir) { int dirs[4][2] { {-1,0}, {1,0}, {0,-1}, {0,1} }; // 禁止 180 度掉头否则幽灵会原地抽搐 int dirIndex[4] {0,1,2,3}; Vec2i bestDir prevDir; int minDist INT_MAX; for (int d 0; d 4; d) { if (dirs[d][0] -prevDir.row dirs[d][1] -prevDir.col) continue; int nr ghost.row dirs[d][0]; int nc ghost.col dirs[d][1]; if (map[nr][nc] #) continue; int dist abs(player.row - nr) abs(player.col - nc); if (dist minDist) { minDist dist; bestDir { dirs[d][0], dirs[d][1] }; } } return bestDir; }这段代码里最值得学习的是“禁止掉头”逻辑只排除与当前移动方向完全相反的方向避免鬼魂卡在走廊里左右横跳。INT_MAX初始值保证必然选出一个方向因为地图不会四周全是墙。如果要实现逃跑模式把minDist改为maxDist即可。碰撞检测方面因为所有角色使用像素坐标绘制但逻辑用格子坐标最简单的做法是分别维护两种表示格子坐标用于更新像素坐标用于渲染。在渲染前根据格子坐标计算像素位置再做矩形重叠测试bool overlapRect(float ax, float ay, float aw, float ah, float bx, float by, float bw, float bh) { return ax bx bw bx ax aw ay by bh by ay ah; }吃豆人和豆子的碰撞豆子中心所在格子是否被玩家“吸收”只需要检测玩家矩形中心和豆子中心的距离是否小于某个阈值而不需要严格的矩形相交因为豆子本身就是 8x8 像素。参数阈值我会取cellSize * 0.4f这样在看着没碰到但实际已进入格子时能提前吃掉豆子手感会很顺滑。如果用过大的阈值玩家会还没靠近就吃掉显得作弊。5. DirectX 11 吃豆人最常见的 4 个翻车现场5.1 现象豆子和角色上下颠倒墙壁位置全反很多人第一次用 D3D11 渲染 2D 时屏幕上的贴图是上下颠倒的。原因并不玄学Direct3D 的剪裁空间和纹理采样坐标都没有统一约定“屏幕左上角”。当你把原本用于屏幕左下角为原点的顶点 y 同时用在 HLSL 的SV_Position时v 坐标方向就会有一个隐含的反转。解决确认你的纹理坐标约定。我统一写成左上角为 uv 原点 (0,0)并且正交投影矩阵翻转 y 轴代码见 3.3。如果你看到 y 翻转只需要把proj改成XMMatrixOrthographicLH(width, -height, ...)或者修改世界矩阵在 y 轴乘以 -1。具体改哪个取决于你是要在数学坐标系y 向上里做逻辑还是在窗口坐标系y 向下里做逻辑。吃豆人游戏逻辑建议直接用窗口坐标系整个世界都在屏幕左上角直觉最舒服。5.2 现象纸张纹理 白色豆子半透明边缘发白用 PNG 做豆子贴图时你会发现画面边缘有一圈白边。主因是纹理格式和混合状态不一致。如果你把一张 RGBA PNG 载入DXGI_FORMAT_R8G8B8A8_UNORM通常没问题但如果原图带 alpha而你没有创建合适的混合状态默认的 D3D11 state 是对 alpha 零处理的白边就出来了。解决创建ID3D11BlendStateD3D11_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; bd.RenderTarget[0].BlendOpAlpha D3D11_BLEND_OP_ADD; device-CreateBlendState(bd, blendState); context-OMSetBlendState(blendState.Get(), nullptr, 0xffffffff);参数里最有价值的是SrcBlend SRC_ALPHA和DestBlend INV_SRC_ALPHA这是最标准的 alpha 混合。若你的贴图中不含 alpha 通道只是 BGR那大概率是美术资源导出的问题而不是代码问题。另一个隐藏参数是SampleMask如果不是 0xffffffff 会导致奇怪半透明效果很多老代码会忽略。5.3 现象纹理加载失败但文件路径明明存在CreateDecoderFromFilename返回0x80070003路径不存在但资源文件就在 exe 同级的 assets 目录里。原因极有可能是工作目录不是 exe 所在目录。当你从 Visual Studio 调试启动时默认工作目录是工程目录而不是输出目录。所以相对路径 “assets/map.png” 自然找不到。解决在代码里不做相对路径拼接而是用模块路径。获取 exe 路径再拼资源路径wchar_t buffer[MAX_PATH]; GetModuleFileNameW(nullptr, buffer, MAX_PATH); std::wstring baseDir buffer; baseDir baseDir.substr(0, baseDir.find_last_of(L\\/)); std::wstring texPath baseDir L\\assets\\map.png;这样无论是调试还是双击 exe资源路径都稳定。如果你用 CMake 的POST_BUILD拷贝资源exe 目录和资源目录一致性就更有保障。这是解决所有 DirectX 项目资源加载问题的通用后悔药。5.4 现象碰撞检测时灵时不灵幽灵穿墙频繁吃豆人和墙碰撞靠格子判断理论上不会穿墙但如果你采用了更“精致”的像素移动就可能在每帧Update里一次跨越多个格子。正常情况角色按 fixedStep 移动速度太快、格子太小时就可能跳过墙线。比如在 80 帧的调试窗口下速度cellSize * 8每帧移动约 2.4 像素而墙的碰撞边界只有 1 像素就会漏检。解决为移动做细分。把一步拆成多个小步每一小步执行碰撞检测并停止float stepTotal speed * fixedStep * cellSize; while (stepTotal 0.0f) { float step min(stepTotal, cellSize * 0.3f); float nextX position.x dir.x * step; float nextY position.y dir.y * step; // 检查 nextX/nextY 所在格子是否撞墙 if (canMoveTo(nextX, nextY)) { position.x nextX; position.y nextY; } else { break; } stepTotal - step; }这里的关键参数是cellSize * 0.3f即每步最多移动格子的三分之一保证不会跨格。如果你直接限制最大速度也可以避免它但细分步长更稳定。顺着这条路做下来你还会发现幽灵在病死路口的转向不自然因为 AI 只选择最近曼哈顿距离而非考虑前方路段是否需要回头。这时可以结合“当前格只能前进或转弯”限制让幽灵回到经典吃豆人的行为风格。6. 调试、扩展与让项目真正变成产品的落地技巧当你跑通第一版之后值得花时间做的事情有三件调试、批处理、地图数据化。用 Visual Studio 的“图形诊断”功能可以逐帧捕获画面查看每个 draw call 绑定了哪些资源。这是 DirectX 11 调试最重要的入口。如果你之前没开启调试层在D3D11CreateDeviceAndSwapChain的flags参数里加上D3D11_CREATE_DEVICE_DEBUGIDE 的输出窗口会实时打印错误信息比如“D3D11 ERROR: ID3D11DeviceContext::Draw: Input Assembler 没有绑定顶点缓冲区”。这个信息比黑屏直接得多。批处理是 2D 游戏性能的分水岭。吃豆人地图最多不到 200 个豆子如果每个豆子都单独Draw(4,0)就是 200 次 draw call。D3D11 单帧 draw call 多了会明显吃 CPU。常见做法是把地图中所有豆子收敛到一个顶点缓冲区一次性传入所有四边形的顶点和 uv然后一次绘制。这样豆子的全部变化只是顶点位置不同纹理全部相同。你可以维护一个std::vectorSpriteVertex每次地图更新后UpdateSubresource重建顶点缓冲但吃豆人豆子只在被吃掉时消失所以也可以不重建而把消失豆子的 uv 设置为一个透明区域一次性绘制整张地图。地图数据化是接下来最值得投入的改动。把 4.2 里的char map[15][20]从 C 源码移到assets/map.txt游戏启动时用文本流读取。一个简单的std::ifstream就能完成但要注意换行符\r在 Windows 下会被读进字符串导致地图错位。读每行后手工去掉\r是必须的std::string line; std::getline(ifs, line); if (!line.empty() line.back() \r) line.pop_back();地图文件还能顺便承载关卡编号比如在文件开头写一个数字代表能量豆数量或幽灵速度这套方案是课程项目常见扩展方向。有了外部关卡后续加新地图、调整难度、甚至写地图编辑器都方便。音效方面DirectX 11 不负责音频常见的做法是 XAudio2 或直接调第三方库。对吃豆人来说播放豆子被吃掉和吃到能量豆的两段 wav 足够。它的初始化思路类似 D3D11 设备创建也需要一个设备对象和一个声音源缓冲但不牵涉渲染管线。如果你没有做过 XAudio2可以先不碰图形项目没有音效也能跑通。最后说一个我自己的习惯游戏循环里加一个帧率计数器每秒钟把 FPS 写到窗口标题栏。别看这很简单它能最快暴露“逻辑更新耗时还是渲染耗时”。当我发现 Debug 模式 FPS 只有个位数而 Release 模式很流畅时就知道是断点调试期间构建的 Debug 没有优化而不是代码本身的问题。和 DirectX 11 相处越久越能体会黑屏和闪烁大半是状态设置问题不是代码逻辑问题。当初我因为没有理解垂直同步和Present参数导致一台 144Hz 显示器上豆子消失速度加快最后用IDXGIOutput::GetFrameStatistics才搞清楚。希望这些接地气的填坑记录能帮你少走几步如果哪天你解压了类似的项目先看构建脚本再抓一次帧剩下的多半能在半小时内跑通。希望帮到你。本文还有配套的精品资源点击获取