
简介这份资源面向使用 Visual C 进行游戏开发的程序员针对 DirectX 硬件加速环境下常规截屏键失效的问题提供一套可编译运行的截屏实现方案。当游戏通过 Direct3D 渲染并借助硬件覆盖层输出画面时系统级截屏工具往往只能抓到黑屏或空白资源从渲染管线入手演示如何访问交换链与帧缓冲区、复制纹理数据并转换为位图保存帮助开发者理解底层图形截屏的关键环节。压缩包共 12 个文件约 22KB包含 4 个 h 头文件与 2 个 cpp 源文件构成核心代码另有 ico 图标、rc 资源脚本、vcproj 工程文件与 sln 解决方案以及一份说明硬件覆盖层导致抓屏失败的 txt 文档工程结构完整可直接用 Visual C 打开编译。目前已有 559 人学习下载适合具备一定 C 与 Direct3D 基础、希望为游戏添加截屏功能或排查抓屏异常的开发者参考借鉴。1. 游戏截屏翻车现场为什么 PrintScreen 在 DirectX 游戏里抓出一片黑用 Visual C 给 DirectX 游戏做截屏很多人第一次都会翻车按下 PrintScreen 或调 GDI 的BitBlt截出来要么纯黑要么只有窗口边框游戏画面整个消失。原因不玄学——DirectX 走的是硬件加速渲染画面直接由 GPU 写进显存里的后台缓冲再翻转呈现到前台根本不经过 GDI 那套设备上下文。你抓的是窗口 DC可窗口 DC 里压根没有游戏像素。要拿到真实画面必须用 DirectX 自己的接口去访问交换链的后台缓冲把显存里的数据回读到系统内存再编码成图片。这篇笔记就围绕 Visual C 和 DirectX 截屏这条线把原理、最小可跑代码、参数设置和踩坑记录一次讲透适合正在做游戏辅助工具、自动化测试、录屏采集的 C 开发者。2. DirectX 截屏的底层逻辑与方案选型2.1 前台缓冲、后台缓冲与交换链到底谁在存画面DirectX 应用初始化时会创建交换链Swap Chain它至少管理两个缓冲后台缓冲Back Buffer负责接收 GPU 的绘制指令前台缓冲Front Buffer负责显示到屏幕。每帧结束调用Present两者交换。关键点在于后台缓冲是一块可以直接访问的 Direct3D 表面或纹理而前台缓冲的内容在多数驱动实现里并不保证可读。所以截屏的正确目标是后台缓冲而不是屏幕 DC。在 D3D9 时代后台缓冲可以通过GetBackBuffer拿到IDirect3DSurface9再用D3DXSaveSurfaceToFile直接存成图片代码量极小。到了 D3D11/D3D12后台缓冲是ID3D11Texture2D必须先用CopyResource拷到一块带D3D11_USAGE_STAGING标志的临时纹理再Map回读到 CPU 内存。这一步是硬性要求因为默认的后台缓冲 usage 是D3D11_USAGE_DEFAULTCPU 无法直接映射。理解这个差异后面选方案就不会走弯路。提示D3D9 的D3DXSaveSurfaceToFile依赖已废弃的 D3DX 库新项目建议直接用 D3D11 的 staging 纹理方案兼容性和可控性都更好。2.2 注入、Hook 还是外部抓帧三条路怎么选给别人的游戏截屏绕不开“怎么进到游戏进程里”这个问题。常见做法有三条第一条是进程内注入把写好的 DLL 注入目标游戏在 DLL 里拿到IDirect3DDevice9或IDXGISwapChain指针直接在Present调用前后抓后台缓冲。优点是能拿到最原始的画面不受窗口遮挡影响缺点是需要处理注入权限、位数匹配32 位游戏必须注入 32 位 DLL而且部分带反作弊的游戏会拦截。第二条是 API Hook通过 HookPresent或EndScene函数在游戏每帧呈现时插入自己的截屏逻辑。这条和注入经常配合使用注入负责把 Hook 代码送进去Hook 负责在正确时机触发。工具链上常用 Detours 或 MinHook。第三条是外部抓帧不注入进程而是用 Desktop Duplication APIDXGI抓整个桌面或指定窗口。它不需要进游戏进程兼容性好但抓到的是合成后的桌面画面窗口被遮挡或最小化时拿不到内容且对全屏独占模式支持有限。我一般会这样选做通用工具、目标游戏没有反作弊优先注入加 Hook画面最干净做录屏或对稳定性要求高、不想碰注入用 Desktop Duplication只针对 D3D9 老游戏进程内直接GetBackBuffer最省事。2.3 用 D3D11 staging 纹理抓一帧的最小可跑代码下面这段代码假设你已经通过注入或 Hook 拿到了IDXGISwapChain指针目标是抓取当前后台缓冲并存成 PNG。核心思路是取后台纹理 → 建 staging 纹理 → 拷贝 → Map 读数据 → 用 WIC 编码。// 假设 pSwapChain 是已获取的 IDXGISwapChain* // 抓取一帧后台缓冲并保存为 PNG bool CaptureBackBuffer(IDXGISwapChain* pSwapChain, ID3D11Device* pDevice, ID3D11DeviceContext* pContext, const wchar_t* outPath) { // 1. 拿到后台缓冲纹理 ID3D11Texture2D* pBackBuffer nullptr; HRESULT hr pSwapChain-GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)pBackBuffer); if (FAILED(hr) || !pBackBuffer) return false; // 2. 读取纹理描述据此创建 staging 纹理 D3D11_TEXTURE2D_DESC desc; pBackBuffer-GetDesc(desc); desc.Usage D3D11_USAGE_STAGING; // CPU 可读 desc.BindFlags 0; // staging 不能绑定 desc.CPUAccessFlags D3D11_CPU_ACCESS_READ; desc.MiscFlags 0; ID3D11Texture2D* pStaging nullptr; hr pDevice-CreateTexture2D(desc, nullptr, pStaging); if (FAILED(hr)) { pBackBuffer-Release(); return false; } // 3. 拷贝后台缓冲到 staging pContext-CopyResource(pStaging, pBackBuffer); // 4. Map 回读像素 D3D11_MAPPED_SUBRESOURCE mapped; hr pContext-Map(pStaging, 0, D3D11_MAP_READ, 0, mapped); if (FAILED(hr)) { pStaging-Release(); pBackBuffer-Release(); return false; } // 5. 用 WIC 编码成 PNG此处省略 WIC 初始化细节 bool ok EncodeToPNG(mapped.pData, mapped.RowPitch, desc.Width, desc.Height, outPath); pContext-Unmap(pStaging, 0); pStaging-Release(); pBackBuffer-Release(); return ok; }逻辑说明GetBuffer(0)取的是交换链第 0 号后台缓冲多缓冲交换链里这个索引通常就是当前渲染目标。CopyResource是 GPU 内部拷贝速度快但要求源和目标纹理格式、尺寸完全一致所以 staging 纹理直接复用后台缓冲的描述最稳妥。Map之后mapped.pData指向系统内存里的像素首地址mapped.RowPitch是每行字节数注意它不一定等于宽度 × 每像素字节数编码时必须按 RowPitch 逐行拷贝否则图像会错位。参数说明D3D11_USAGE_STAGING是必须的只有它允许 CPU 访问CPUAccessFlags设D3D11_CPU_ACCESS_READ表示只读回读BindFlags必须清零staging 纹理不能作为渲染目标或着色器资源。如果游戏用的是 D3D9把GetBuffer换成GetBackBufferCopyResource换成D3DXSaveSurfaceToFile即可思路一致。3. 从注入到出图Visual C 工程落地全流程3.1 判断目标游戏用的是 D3D9 还是 D3D11动手前先确认目标游戏的图形 API选错接口白忙一场。最直接的办法是用工具看进程加载了哪些 DLL如果加载了d3d9.dll基本是 D3D9加载dxgi.dll和d3d11.dll是 D3D11d3d12.dll则是 D3D12。也可以用 Process Explorer 查看模块列表或者自己写个小工具枚举进程模块。另一个办法是看游戏目录下的配置文件或启动参数很多游戏支持-dx9、-dx11切换。确认 API 之后Hook 的目标函数也不同D3D9 通常 HookIDirect3DDevice9::EndScene或PresentD3D11 通常 HookIDXGISwapChain::Present。Hook 时机选在Present之前此时后台缓冲内容完整还没被交换走。3.2 用 MinHook 挂住 Present 的完整步骤MinHook 是轻量级的 x86/x64 Hook 库适合做这件事。完整流程分四步第一步在 DLL 的DllMain里等游戏初始化完 D3D 之后再安装 Hook。太早安装时交换链还没创建拿不到指针。常见做法是 HookPresent本身第一次被调用时说明交换链已就绪在回调里保存pSwapChain指针。第二步定义原始函数指针和 Hook 函数// 原始 Present 函数指针 typedef HRESULT(WINAPI* Present_t)(IDXGISwapChain*, UINT, UINT); Present_t g_originalPresent nullptr; IDXGISwapChain* g_pSwapChain nullptr; // Hook 后的 Present HRESULT WINAPI HookedPresent(IDXGISwapChain* pSwapChain, UINT SyncInterval, UINT Flags) { if (!g_pSwapChain) g_pSwapChain pSwapChain; // 首次调用保存指针 // 在这里触发截屏比如检测到热键 if (g_bCaptureRequested) { CaptureBackBuffer(pSwapChain, g_pDevice, g_pContext, Lshot.png); g_bCaptureRequested false; } return g_originalPresent(pSwapChain, SyncInterval, Flags); }第三步用 MinHook 创建 Hook// 获取 Present 函数地址并安装 Hook // vtable 第 8 个槽位是 PresentD3D11 交换链 void* pPresent (*reinterpret_castvoid***(g_pSwapChain))[8]; MH_CreateHook(pPresent, HookedPresent, reinterpret_castvoid**(g_originalPresent)); MH_EnableHook(pPresent);第四步在 DLL 卸载时MH_DisableHook并MH_Uninitialize避免游戏退出时崩溃。逻辑说明交换链的 vtable 布局里Present固定在第 8 个槽位索引 8这是 D3D11 的约定D3D9 的EndScene在设备 vtable 的索引 42。直接按 vtable 索引取地址比用GetProcAddress更可靠因为游戏可能静态链接了 D3D 运行时。参数上MH_CreateHook的第二个参数是替换函数第三个参数用来接收原始函数地址必须保存好否则游戏正常渲染会断掉。3.3 回读像素后怎么编码成 PNG 不丢色拿到mapped.pData只是原始 BGRA 像素要存成 PNG 还得编码。Windows 平台推荐用 WICWindows Imaging Component它是系统自带不引入第三方依赖。关键点有三个第一后台缓冲的像素格式通常是DXGI_FORMAT_B8G8R8A8_UNORM也就是 BGRA 排列而 WIC 的GUID_WICPixelFormat32bppBGRA正好对应直接喂进去即可不用手动换通道。第二前面强调过RowPitch可能大于实际行宽WIC 写入时需要按行拷贝。如果直接把pData当成连续缓冲区传给 WIC遇到 RowPitch 有 padding 的情况图像会斜切。稳妥做法是逐行memcpy到一个紧凑缓冲区或者用 WIC 的WritePixels指定行数分多次写。第三编码前记得设置像素格式和尺寸IWICBitmapFrameEncode::SetSize和SetPixelFormat都要调顺序不能反否则编码器会用默认格式导致颜色异常。注意如果游戏开了 HDR后台缓冲格式可能是DXGI_FORMAT_R10G10B10A2_UNORM这时不能直接按 32bppBGRA 编码需要先做色调映射或格式转换否则存出来的图颜色发灰发暗。4. 截屏黑屏、花屏、崩溃的排查清单4.1 截出来全黑九成是抓错了缓冲现象代码跑通没报错存出来的 PNG 全黑或只有 UI 层。原因抓的是前台缓冲或窗口 DC而不是后台缓冲。或者 Hook 时机在Present之后此时后台缓冲已经交换内容被清空。解决确认 Hook 的是Present且在调用原始函数之前抓帧确认GetBuffer(0)拿到的是后台缓冲。如果游戏用了多重采样MSAA后台缓冲不能直接CopyResource到 staging需要先ResolveSubresource解析到单采样纹理再拷贝。4.2 图像斜切或颜色错位RowPitch 没对齐现象存出来的图整体倾斜或者红蓝通道互换。原因mapped.RowPitch不等于宽度 × 4直接按紧凑缓冲区处理导致每行错位或者像素格式是 RGBA 而按 BGRA 编码。解决编码时严格按 RowPitch 逐行处理用desc.Format判断实际格式BGRA 用GUID_WICPixelFormat32bppBGRARGBA 用GUID_WICPixelFormat32bppRGBA不要想当然。4.3 游戏崩溃或卡死Hook 没还原或线程冲突现象截屏瞬间游戏闪退或者截完游戏帧率骤降。原因DLL 卸载时没禁用 Hook游戏调用Present跳到已卸载的代码段或者在渲染线程之外的线程调用MapD3D11 的 immediate context 不是线程安全的。解决DllMain的DLL_PROCESS_DETACH里务必MH_DisableHook和MH_Uninitialize截屏逻辑放在Present回调的同一线程执行不要另起线程操作设备上下文。如果必须异步用ID3D11DeviceContext的 deferred context 或加锁串行化。4.4 注入失败位数不匹配或权限不足现象DLL 注入后游戏没反应或者注入工具报错。原因32 位游戏注入了 64 位 DLL或反之目标进程权限高于注入进程比如游戏以管理员运行而注入器没有。解决用IsWow64Process判断目标进程位数编译对应位数的 DLL注入器以管理员权限运行。部分游戏有反注入保护这种情况外部抓帧Desktop Duplication是更稳的退路。4.5 全屏独占模式下抓不到画面现象窗口模式正常切到全屏独占就黑屏。原因全屏独占模式下交换链的呈现路径不同部分驱动不允许回读后台缓冲或者 Desktop Duplication 在独占全屏下拿不到内容。解决优先用进程内 Hook 方案它不受独占模式影响如果只能用外部抓帧建议让游戏跑无边框窗口模式。另外可以尝试在Present参数里关注DXGI_PRESENT_TEST标志避免干扰正常呈现。5. 让截屏更稳的几个进阶技巧5.1 用事件驱动代替轮询避免拖慢帧率早期我图省事在Present回调里每帧都判断热键状态结果游戏帧率掉了好几帧。后来改成事件驱动注册全局热键RegisterHotKey热键触发时只置一个std::atomicbool标志Present回调里读这个标志决定是否抓帧。这样每帧只多一次原子读开销可以忽略。标志用std::atomic而不是普通 bool是因为热键消息在 UI 线程Present在渲染线程跨线程读写必须保证可见性。5.2 连续抓帧做录屏时的缓冲复用如果需求不只是单张截图而是连续抓帧做录屏每帧都CreateTexture2D创建 staging 纹理会非常慢。正确做法是复用第一次抓帧时按后台缓冲描述创建一块 staging 纹理并缓存后续每帧只做CopyResource和Map/Unmap。注意分辨率变化时要重建监听IDXGISwapChain::ResizeBuffers或每帧比对描述。下面是一个复用结构的示意// 缓存的 staging 纹理避免每帧重建 struct CaptureCache { ID3D11Texture2D* staging nullptr; UINT width 0, height 0; DXGI_FORMAT format DXGI_FORMAT_UNKNOWN; bool Ensure(ID3D11Device* dev, const D3D11_TEXTURE2D_DESC src) { if (staging width src.Width height src.Height format src.Format) return true; // 描述没变直接复用 if (staging) { staging-Release(); staging nullptr; } D3D11_TEXTURE2D_DESC d src; d.Usage D3D11_USAGE_STAGING; d.BindFlags 0; d.CPUAccessFlags D3D11_CPU_ACCESS_READ; d.MiscFlags 0; if (FAILED(dev-CreateTexture2D(d, nullptr, staging))) return false; width src.Width; height src.Height; format src.Format; return true; } };逻辑说明Ensure每次比对源纹理的宽高和格式一致就复用不一致才重建。这样稳态下每帧只有一次 GPU 拷贝和一次 Map开销可控。参数上D3D11_USAGE_STAGING和CPUAccessFlags的约束和单帧方案一样不能省。5.3 验证截屏正确性的三个土办法写完代码怎么确认抓对了我一般用三个办法交叉验证。第一抓一张纯色背景的游戏画面用取色工具看像素值是否和游戏内一致能快速发现通道错位。第二抓一张带文字的界面放大看文字边缘是否清晰如果模糊说明抓的是缩放后的前台缓冲。第三连续抓十帧比对文件大小和哈希如果完全一样说明可能抓到了静止的缓存帧需要检查 Hook 时机。5.4 分辨率缩放不为 1 时的坐标换算现在很多笔记本默认 125% 或 150% 缩放这会影响窗口坐标和截图区域。如果截屏逻辑里涉及鼠标位置或窗口区域换算必须用GetDpiForWindow拿到实际 DPI把逻辑坐标乘以dpi/96得到物理像素。DirectX 后台缓冲的尺寸是物理像素而GetCursorPos返回的是逻辑坐标两者不换算就会截偏。这个坑我在做区域截图功能时踩过排查了半天才发现是缩放比例在作怪。最后说个习惯每次改完 Hook 代码我都会先在记事本这类简单 D3D 程序上验证再拿去测目标游戏。游戏环境变量太多直接上容易把问题搅在一起。这套 Visual C 加 DirectX 截屏的路子核心就是把后台缓冲回读这一件事做对剩下的都是工程细节。希望帮到你。本文还有配套的精品资源点击获取