
简介一份基于DirectX 11的3D推箱子益智游戏源码包“心理盒子”面向游戏开发学习者和图形编程爱好者可用作DX11渲染管线与基础游戏逻辑的参考。资源共293个文件涵盖h/hpp头文件、cpp源文件、hlsl着色器、dll库以及glb模型、mp3音频和ttf字体等整体大小仅11.4MB目录按模块划分便于检索。已有81人浏览学习适合个人项目或课程设计。源码覆盖关卡解析、动画控制、相机与碰撞检测等模块并附带Premake构建脚本可一键生成Visual Studio或Ninja工程降低环境配置成本是理解DirectX 11渲染流程的紧凑示例。1. DirectX 11 渲染 3D 推箱子一个轻量级图形的完整闭环先给一个反直觉的结论推箱子这种规则简单、逻辑量少的游戏玩法代码往往不到两百行真正决定它能否稳定运行的反而是 DirectX 11 渲染器最前面那几行初始化代码。很多人拿到一个 .zip 发布包解压运行后直接闪退第一反应是找关卡逻辑的毛病实际上大部分场景是 D3D11 设备创建失败、交换链参数不兼容或者着色器编译没有做降级处理。这篇内容围绕一个使用 DirectX 11 渲染的 3D 推箱子项目展开场景由方格与立方体组成摄像机在棋盘周围旋转玩家可以用方向键移动角色并把箱子推到目标格。全文会按一条可复现路径推进从交换链初始化、顶点缓冲、光照模型到游戏状态更新最后落到 .zip 发布与常见显卡兼容问题的排查。这里不会引入完整的游戏引擎而是用 D3D11 的底层接口把每个环节串一遍适合已经能写 C 工具、想通过一个完整图形程序把渲染管线理清楚的开发者。推箱子这类体素风格场景有一个好处它恰好能把 DirectX 11 里的设备、交换链、顶点布局、常量缓冲、深度模板视图、像素着色器这些概念各自用一遍。比画三角形多出足够的工程细节又比物理沙盒类项目简单得多便于专注在渲染本身。2. 初始化 DirectX 11交换链、设备与渲染视图在往场景里放任何方块之前先得把 D3D11 的设备、交换链、渲染目标视图和深度模板视图建好。这些对象的创建顺序会直接影响后续每一帧的工作方式也决定这个 .zip 发布包到了别人机器上能不能跑起来。2.1 设备和交换链创建的参数选择D3D11 的程序通常用D3D11CreateDeviceAndSwapChain一次性创建设备、上下文和交换链。这样能保证三者的 Feature Level 和格式一致避免后续反复调试驱动层报错。DXGI_SWAP_CHAIN_DESC sd {}; sd.BufferCount 2; sd.BufferDesc.Format DXGI_FORMAT_R8G8B8A8_UNORM; sd.BufferDesc.Width width; sd.BufferDesc.Height height; sd.BufferDesc.RefreshRate.Numerator 0; sd.BufferDesc.RefreshRate.Denominator 1; sd.BufferUsage DXGI_USAGE_RENDER_TARGET_OUTPUT; sd.OutputWindow hwnd; sd.SampleDesc.Count 1; sd.SampleDesc.Quality 0; sd.Windowed TRUE; sd.SwapEffect DXGI_SWAP_EFFECT_DISCARD; UINT createDeviceFlags 0; #ifdef _DEBUG createDeviceFlags | D3D11_CREATE_DEVICE_DEBUG; #endif D3D_FEATURE_LEVEL levels[] { D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0 }; ID3D11Device* device nullptr; ID3D11DeviceContext* context nullptr; IDXGISwapChain* swapChain nullptr; D3D_FEATURE_LEVEL featureLevel D3D_FEATURE_LEVEL_11_0; HRESULT hr D3D11CreateDeviceAndSwapChain( nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, createDeviceFlags, levels, 3, D3D11_SDK_VERSION, sd, swapChain, device, featureLevel, context);这段代码里有三个参数最容易改错。第一是BufferCount推箱子这类轻量场景用 2 就足够双缓冲能避免撕裂也不需要为了省几十毫秒去设成 1。第二是SampleDesc多重采样抗锯齿要在交换链描述里声明但SampleDesc.Count大于 1 时必须先用CheckMultisampleQualityLevels查询直接写Quality 0会导致CreateRenderTargetView返回E_INVALIDARG。第三是 Feature Level 数组的降级顺序从 11_0 一路降到 10_0这样老显卡也能至少把游戏逻辑跑起来代价是效果降级为无光照的平面着色这在推箱子场景里完全可玩。创建完成后交换链只是给了后缓冲还没有把可以写入的颜色目标绑定到管线上。接下来需要拿到渲染目标视图。2.2 渲染目标与深度缓冲的绑定顺序深度缓冲是新人最容易漏掉的一环。推箱子场景里常常出现箱子堆叠和墙壁遮挡不创建深度模板视图的话物体之间的遮挡关系会变得混乱甚至看到远处的墙穿到近处的箱子前面。ID3D11Texture2D* depthTex nullptr; D3D11_TEXTURE2D_DESC dd {}; dd.Width width; dd.Height height; dd.MipLevels 1; dd.ArraySize 1; dd.Format DXGI_FORMAT_D24_UNORM_S8_UINT; dd.SampleDesc.Count 1; dd.Usage D3D11_USAGE_DEFAULT; dd.BindFlags D3D11_BIND_DEPTH_STENCIL; device-CreateTexture2D(dd, nullptr, depthTex); ID3D11DepthStencilView* dsv nullptr; device-CreateDepthStencilView(depthTex, nullptr, dsv); context-OMSetRenderTargets(1, rtv, dsv); D3D11_VIEWPORT vp {0, 0, (float)width, (float)height, 0.0f, 1.0f}; context-RSSetViewports(1, vp);这段代码隐含了一个顺序问题渲染目标先清除再绑定还是先绑定再清除D3D11 的推荐做法是每帧先调用ClearRenderTargetView和ClearDepthStencilView再开始绘制。这两次 Clear 之间的顺序无所谓但一定不能出现在绘制调用之后否则整帧内容会被清空。D24_UNORM_S8_UINT中的模板组件在推箱子项目里也可以用来做目标格高亮。做法是先用模板写入标记目标格下一帧在标记区域混合半透明光斑。不过项目初期不需要这个功能先保留dsv就能避免之后改架构时又要重建交换链。渲染对象创建函数关键参数常见错误逻辑设备D3D11CreateDeviceAndSwapChainFeatureLevel 数组降级不提供降级老机器初始化失败交换链同上BufferCount、SwapEffect在 DISCARD 模式下强行使用多采样渲染目标视图CreateRenderTargetView格式必须与后缓冲一致使用与 BackBuffer 不一致的格式深度模板视图CreateDepthStencilViewFormat 为 D24_UNORM_S8_UINT忘记绑定导致深度测试失效这里的渲染设置render settings对后续功能影响很大。例如 MSAA 对推箱子这种大色块、硬边场景的提升非常明显但代价是显存和填充率。我一般会先用 1x 保证逻辑正确最后再开 4x 对比画面。需要注意交换链和渲染目标视图的多采样级别必须完全一致不一致时创建渲染目标视图同样会报错。3. 3D 推箱子的顶点与网格几何、缓冲与变换设备和交换链就绪后需要往管线上放实际的几何体。推箱子的所有物体本质上都是不同尺寸的方块区别只在长宽高和颜色因此先定义一个可复用的方块几何体工厂比逐个建模更划算。3.1 用顶点布局定义一个可复用方块先定义顶点结构和输入布局。推箱子项目的方块有两种常见需求一种是纯色方块用于角色、箱子和墙壁另一种需要贴纹理比如地板带网格线。两者在顶点结构上差别很大但输入布局可以共用同一个顶点着色器入口。struct VertexPosColor { XMFLOAT3 position; XMFLOAT4 color; }; const D3D11_INPUT_ELEMENT_DESC LayoutPosColor[2] { { POSITION, 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 }, { COLOR, 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 }, };位置用 3 个 float颜色用 4 个 float单顶点 28 字节。这个输入布局必须与顶点着色器的输入签名完全一致否则CreateInputLayout会返回失败。需要给箱子贴图时可以把COLOR字段换成TEXCOORD格式改成DXGI_FORMAT_R32G32_FLOAT顶点结构也随之缩小到 20 字节。两种布局可以同时保留绘制时切换IASetInputLayout即可。要注意D3D11 没有内置的“立方体”图元所有几何体都得自己生成顶点和索引。一个带独立面法线的立方体有 24 个顶点每个面用一组独立的 4 顶点这样才能在像素着色器里对面做正确的光照。3.2 索引缓冲与三角形绕序的取舍立方体使用索引缓冲是更合理的做法。每个面 4 个顶点可以复用两个三角形总共 36 个索引显存占用远小于直接塞 36 个顶点。std::vectoruint16_t indices; int baseIndex 0; for (int face 0; face 6; face) { uint16_t i0 baseIndex 0; uint16_t i1 baseIndex 1; uint16_t i2 baseIndex 2; uint16_t i3 baseIndex 3; indices.push_back(i0); indices.push_back(i1); indices.push_back(i2); indices.push_back(i0); indices.push_back(i2); indices.push_back(i3); baseIndex 4; }这里的前提是顶点缓冲里已经按“每面 4 个顶点”的顺序存储了位置和法线。三角形绕序要注意D3D11 光栅化状态默认把顶点按照顺时针排列的三角形视为正面。上面的索引顺序是按逆时针方向写的所以对应的D3D11_RASTERIZER_DESC里FrontCounterClockwise需要设为TRUE否则从摄像机方向看箱子会看到透明的内侧面。图元类型使用D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST提交。不要为了看线框效果直接改图元类型线框调试应该使用RSSetState里FillMode D3D11_FILL_WIREFRAME的光栅化状态这样只用切换状态对象不用改几何体提交方式。3.3 常量缓冲与世界矩阵变换推箱子的关卡规模一般不超过 20 个箱子每个箱子单独用一个常量缓冲成本不高而且让调试更直观。角色、箱子、地板各自的world矩阵在逻辑移动后更新即可。struct PerObjectBuffer { XMFLOAT4X4 world; XMFLOAT4X4 view; XMFLOAT4X4 proj; XMFLOAT4 color; }; // 每一帧根据方块坐标更新 world XMStoreFloat4x4(data.world, worldMatrix); context-UpdateSubresource(cbPerObject, 0, nullptr, data, 0, 0); context-VSSetConstantBuffers(0, 1, cbPerObject);PerObjectBuffer里四个XMFLOAT4X4正好是 16 字节对齐。使用UpdateSubresource更新常量缓冲时RowPitch和DepthPitch必须传 0否则驱动会报参数错误。这里说的 3D 建模modeling不是美术工作而是把逻辑坐标转换成渲染坐标玩家把箱子从(row, col)推到相邻格子渲染层就把箱子的world矩阵平移到对应世界坐标。把世界、视图、投影拆成三个矩阵放在常量缓冲里比提前算出 MVP 更灵活后面调相机时会方便很多。4. 游戏逻辑与碰撞推箱子玩法怎么和渲染解耦推箱子玩法本身是离散坐标移动而不是连续物理模拟。把逻辑层与渲染层分开既能提高可测试性又能避免键盘输入抖动引入不可复现的 bug。4.1 用方格坐标推演移动而不是用物理引擎每走一步角色只移动一个固定边长。不要用物理引擎去推箱子物理仿真会带来摩擦、弹性、碰撞响应这些不必要的复杂度。用二维网格数组就能精确判定每一步。enum Tile { FLOOR, TARGET, BOX, WALL, GOAL }; struct GameState { int rows, cols; std::vectorTile board; int playerX, playerY; int goalCount; }; bool tryMove(GameState gs, int dx, int dy) { int nx gs.playerX dx; int ny gs.playerY dy; if (nx 0 || nx gs.cols || ny 0 || ny gs.rows) return false; if (isWall(gs, nx, ny)) return false; if (isBox(gs, nx, ny)) { int bx nx dx; int by ny dy; if (bx 0 || bx gs.cols || by 0 || by gs.rows) return false; if (isWall(gs, bx, by) || isBox(gs, bx, by)) return false; moveBox(gs, nx, ny, bx, by); } gs.playerX nx; gs.playerY ny; return true; }判定顺序是先做边界检查再查目标格是否墙然后才检查是否为箱子。这个顺序不能反过来否则在棋盘边缘会出现数组越界。moveBox和isBox可以通过board[idx]的简单二维下标实现核心点在于状态更新全部发生在逻辑层渲染层只需要读取格子状态来设置颜色。另一个关键点是动画。很多 3D 推箱子会给角色和箱子做滑动插值让移动更顺滑。为了不让动画影响判定应该保存两份坐标逻辑格坐标用于tryMove渲染插值坐标用于绘制。逻辑坐标瞬间改变渲染坐标通过lerp或accumulate(dt)逐步逼近最后停留在目标格上。这样即使用户按住方向键狂按逻辑层也只做一次移动动画不会出现回弹或者越过墙的情况。4.2 包围盒碰撞与三角面相交的边界在视觉层面角色和箱子通常都是单位立方体紧贴地面最常见的是轴对齐包围盒 AABB。推箱子里的方块不发生旋转只要对齐到世界坐标轴AABB 就完全够用。bool aabbIntersect(const XMFLOAT3 minA, const XMFLOAT3 maxA, const XMFLOAT3 minB, const XMFLOAT3 maxB) { return (minA.x maxB.x maxA.x minB.x) (minA.y maxB.y maxA.y minB.y) (minA.z maxB.z maxA.z minB.z); }这段碰撞代码使用在视觉层用于检查角色模型与箱子模型是否贴近到需要微调渲染位置的程度。对于单个角色斜向移动想穿过墙角的情况只做一个 AABB 检测是不够的需要拆成两个轴分别检测或者直接把移动拆成 x 方向和 z 方向两个子步骤。不过推箱子玩法的核心碰撞已经由tryMove完成了AABB 只是辅助视觉平滑不建议让逻辑层依赖它。4.3 输入处理与固定步长帧更新在WndProc的WM_KEYDOWN里直接调用tryMove不是好做法。Windows 键盘消息会重复发送按下方向键不松开会连续触发角色会不受控制地连推几步。void handleKeyDown(WPARAM wParam) { switch (wParam) { case VK_UP: pendingDir {0, -1}; break; case VK_DOWN: pendingDir {0, 1}; break; case VK_LEFT: pendingDir {-1, 0}; break; case VK_RIGHT: pendingDir {1, 0}; break; } } void updateGame(float dt) { if (pendingDir.x ! 0 || pendingDir.y ! 0) { tryMove(state, pendingDir.x, pendingDir.y); pendingDir {0, 0}; } }这里只记录最后按下方向在帧更新里调用一次tryMove后立刻清零。这种方式还可以让撤销操作变得简单想实现按Z回到上一步只要把角色和箱子的坐标历史压入栈undo时弹出上一个状态即可不需要改动输入逻辑。推箱子这类游戏符合离散状态机模型逻辑层与渲染层用同一份GameState驱动后续做自动寻路也很方便。5. 光照、纹理与视图渲染让 3D 场景可看的渲染设置几何体已经能画出来了但纯顶点颜色看起来会比较平。推箱子场景用一套逐像素平行光着色器就能获得不错的立体感同时保留手办风格的朴素观感。5.1 一个够用的逐像素光照着色器这里用 Blinn-Phong 的简化版本只保留漫反射和环境光适合大多数低多边形场景。cbuffer SceneConstants : register(b0) { float4x4 world; float4x4 view; float4x4 proj; float4 lightDir; float4 lightColor; float4 ambient; }; struct VSIn { float3 pos : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD; }; struct VSOut { float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD0; float2 uv : TEXCOORD1; }; VSOut VSMain(VSIn vin) { VSOut vout; vout.pos mul(float4(vin.pos, 1.0f), world); vout.pos mul(vout.pos, view); vout.pos mul(vout.pos, proj); vout.worldNormal mul(vin.normal, (float3x3)world); vout.uv vin.uv; return vout; } float4 PSMain(VSOut pin) : SV_TARGET { float3 n normalize(pin.worldNormal); float3 l normalize(-lightDir.xyz); float diff saturate(dot(n, l)); float3 result ambient.rgb lightColor.rgb * diff; return float4(result, 1.0f); }重点看一下矩阵乘法。C 侧用XMMatrixMultiply(world, view)得到组合矩阵对应 HLSL 这边要避免一次性mul(world, mul(view, proj))造成的顺序混淆。更安全的写法是分三步做每一步都遵循行向量左乘的语义。worldNormal这里直接用世界矩阵的 3x3 部分变换前提是世界矩阵没有非等比缩放推箱子场景中的方块都保持均匀尺寸这个简化是成立的。关于光源推箱子关卡范围很小用单一平行光就能照顾全局。不要在场景里加多个点光源多个光源会让色块之间出现复杂过渡反而降低手办感的地图识别度。环境光给一个偏冷的中性色比如(0.15, 0.17, 0.20)能让箱子阴影不至于死黑。光照参数推荐值作用平行光方向normalize(-0.5, -1.0, -0.3)从左上往右下打光突出顶面和侧面区分环境光强度0.15~0.25避免背光面完全黑掉漫反射强度0.8保留足够层次又不让饱和度过高目标格高亮色(1.0, 0.85, 0.3)与箱子红色形成对比5.2 摄像机与视图渲染的调试视角视图渲染view rendering在这个项目里的含义是把逻辑棋盘通过 view/proj 矩阵变成一个可交互观察的 3D 画面。推箱子建议使用轨道摄像机围绕棋盘中心转动。XMFLOAT3 eyePos {...}; XMFLOAT3 lookAt {0.0f, 0.0f, 0.0f}; XMVECTOR eye XMLoadFloat3(eyePos); XMVECTOR at XMLoadFloat3(lookAt); XMMATRIX view XMMatrixLookAtLH(eye, at, XMVectorSet(0.0f, 1.0f, 0.0f));eyePos可以从球坐标转换水平角度用鼠标左右移动控制垂直角度限制在 15 度到 85 度之间。仰角太低箱子相互遮挡严重仰角太高场景退化成一个从上向下的 2D 棋盘这就失去 3D 渲染的意义了。滚轮修改摄像机距离限制在 5 到 25 的范围内。场景本身不移动摄像机固定在棋盘中心附近所以不需要额外的碰撞检测和六自由度移动逻辑。视图渲染里常被忽略的是棋盘格线。给地板画一层细线框可以让玩家快速感知场景深度做法是创建D3D11_FILL_WIREFRAME的光栅化状态并对线框开启深度偏移避免格线直接嵌在地板表面上产生闪烁。5.3 纹理采样与采样器状态给地板和墙壁贴纹理是很容易出问题的环节。用 WIC 加载纹理时如果不手动生成 mipmap远处的地板会出现大量闪烁噪点。D3D11_SAMPLER_DESC sdTex {}; sdTex.Filter D3D11_FILTER_MIN_MAG_MIP_LINEAR; sdTex.AddressU D3D11_TEXTURE_ADDRESS_WRAP; sdTex.AddressV D3D11_TEXTURE_ADDRESS_WRAP; sdTex.MaxLOD D3D11_FLOAT32_MAX; device-CreateSamplerState(sdTex, samplerLinear); context-PSSetSamplers(0, 1, samplerLinear);地板这种大平面UV 通常会超过 1因此AddressU/V要设置成WRAP否则会出现镜像重复的接缝。MaxLOD设成最大浮点值避免某些驱动把采样器默认 LOD 限制在 0导致 mipmap 链不可用时出现错误纹理。加载完纹理后手动生成 mipmap 是一个比较隐蔽的细节CreateWICTextureFromFile不一定自动生成完整 mip chain显式调用GenerateMips会更稳妥。6. 发布为 .zip 与显卡兼容排错项目做完成后给别人的交付物通常是一个 .zip 压缩包。DirectX 11 的安装依赖比普通 Win32 程序多一层这里提供一个排查顺序和兜底方案。发布前建议把依赖整理成清单如果用动态 CRT需要带上或要求安装vcredist_x64.exe着色器编译需要d3dcompiler_47.dllD3D11SDKLayers.dll是 Debug 模式才需要的发布时不要发 Debug 版本。本地 Release 能跑不代表换机器也能跑最保守的方式是用/MT静态链接 C/C 运行库同时把d3dcompiler_47.dll放到 exe 旁边。压缩时用标准 Deflate 算法7-Zip 的 Deflate64 兼容性稍差上次帮别人排查一个打开即报invalid zip archive: could not find EOCD的案例就是传输中 zip 文件被截断重新上传即可。遇到黑屏或闪退优先查这三类错误设备创建返回E_INVALIDARG常见是SampleDesc或 Feature Level 数组问题Present返回DXGI_ERROR_DEVICE_REMOVED说明显卡驱动在运行中重置CreateTexture2D返回E_OUTOFMEMORY多为纹理尺寸过大推箱子场景里一张 1024x1024 的地板贴图完全够用没必要上 4096。双显卡笔记本上默认适配器可能是核显设备创建时可以用IDXGIFactory1::EnumAdapters枚举并寻找支持D3D_FEATURE_LEVEL_11_0的适配器避免把渲染任务硬塞给核显导致过慢。最后留一个低成本兜底技巧在WinMain里增加-safemode命令行参数当硬件设备创建失败或枚举不到可用适配器时自动改用D3D_DRIVER_TYPE_WARP软件渲染重试。WARP 对推箱子这种低负载场景完全能跑而且不依赖具体显卡驱动。这样用户反馈黑屏时可以让对方先用game.exe -safemode启动能进主界面就起码说明逻辑与资源加载没问题剩下的是设备端适配问题。这段重试逻辑写在设备创建函数里只增加十几行代码却能把一半以上的兼容性问题挡在门外。本文还有配套的精品资源点击获取