ARTICLE DETAIL

资讯详情

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

DirectX游戏截屏实战:用VC++ Hook Present获取后台缓冲

DirectX游戏截屏实战:用VC++ Hook Present获取后台缓冲 简介这份资源面向使用 Visual C 进行游戏开发的程序员尤其是遇到 DirectX 硬件加速游戏无法用常规截屏键抓屏这一问题的开发者。它通过一个可编译运行的示例工程演示如何在 Direct3D 渲染管线中获取帧缓冲区数据并保存为图片从而绕过硬件覆盖层导致的截屏失效。压缩包共 12 个文件约 22KB包含 4 个 h 头文件、2 个 cpp 源文件、2 个 ico 图标、1 个 rc 资源脚本、1 个 vcproj 工程文件、1 个 sln 解决方案以及 1 个 txt 说明文档结构完整可直接用 Visual Studio 打开学习。其中源码部分展示了初始化 D3D 设备与交换链、复制帧缓冲、转换位图格式并写盘的核心流程txt 文档则解释了 hardware overlay 硬件加速为何让普通抓屏工具失效。目前已有 559 人学习适合想掌握底层图形截屏技术、为游戏添加截图功能或调试画面分享的开发者参考。1. 游戏截屏为什么在 DirectX 面前集体失灵用 PrintScreen 截游戏十次有八次得到一张纯黑图或者干脆截到桌面壁纸——这个现象几乎每个做游戏工具的人都撞过。原因不玄学DirectX 游戏走的是硬件加速渲染路径最终画面直接由 GPU 写进显存里的后台缓冲Back Buffer和交换链Swap Chain再经 DWM 合成上屏。GDI 那套 BitBlt 抓的是窗口 DC 的可见区域而这块区域对全屏独占或 Flip 模式呈现的 D3D 表面根本拿不到像素抓到的自然是一片黑。标题里说的「必须用 DX 写个代码在游戏中截屏」本质就是绕开 GDI直接向 D3D 设备要那一帧的后台缓冲数据。这篇面向的是用 Visual C 做游戏辅助、录屏工具、自动化测试的从业者也适合被「截屏黑屏」卡住、想搞懂底层为什么的开发者。下面从原理到可编译代码把这条路走通。2. 先搞清 D3D 截屏的取帧路径与版本选型2.1 后台缓冲、前置缓冲与交换链的关系要截到正确的画面得先知道像素在哪。D3D9 时代设备通过IDirect3DDevice9管理一个或多个后台缓冲Present调用把后台缓冲翻转到前台显示。截屏的黄金时机是「这一帧已经渲染完、但还没被下一次 Clear 覆盖」的瞬间此时后台缓冲里就是完整画面。D3D11/12 换成了 DXGI 交换链IDXGISwapChain后台缓冲是一张ID3D11Texture2D通过GetBuffer(0, ...)拿到。关键点在于你不能在任意时刻随便读必须保证读的时候这一帧数据有效。全屏独占模式下后台缓冲的访问还受呈现队列状态影响读早了是上一帧读晚了可能已被复用。D3D9 用GetBackBufferGetRenderTargetData把显存里的表面拷到系统内存表面D3DPOOL_SYSTEMMEM再LockRect读像素。D3D11 则用CopyResource把后台缓冲纹理拷到一张D3D11_USAGE_STAGING的纹理再Map读。两条路径思路一致显存 → 暂存 → 内存 → 编码。选 D3D9 还是 D3D11取决于目标游戏用哪个 API不是越新越好。2.2 为什么优先用 D3D9 的 GetRenderTargetData 而不是直接 Lock很多人第一反应是直接LockRect后台缓冲结果返回D3DERR_INVALIDCALL。原因是后台缓冲默认在D3DPOOL_DEFAULT池这个池的表面不可锁定只能被 GPU 读写。正确做法是先建一张D3DPOOL_SYSTEMMEM的离屏表面用GetRenderTargetData把默认池表面拷过来再锁系统内存表面。这一步是 D3D9 截屏的核心也是新手最容易翻车的地方。// D3D9 截取后台缓冲到系统内存表面 IDirect3DSurface9* pBackBuffer nullptr; IDirect3DSurface9* pSysSurface nullptr; // 1. 拿到后台缓冲表面 HRESULT hr pDevice-GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, pBackBuffer); if (FAILED(hr)) return false; // 2. 查询后台缓冲的尺寸和格式 D3DSURFACE_DESC desc; pBackBuffer-GetDesc(desc); // 3. 创建同格式的系统内存表面关键D3DPOOL_SYSTEMMEM 才可 Lock hr pDevice-CreateOffscreenPlainSurface( desc.Width, desc.Height, desc.Format, D3DPOOL_SYSTEMMEM, pSysSurface, nullptr); if (FAILED(hr)) { pBackBuffer-Release(); return false; } // 4. 显存 - 系统内存 拷贝 hr pDevice-GetRenderTargetData(pBackBuffer, pSysSurface); if (FAILED(hr)) { /* 释放并返回 */ } // 5. 锁定并读取像素 D3DLOCKED_RECT lockedRect; hr pSysSurface-LockRect(lockedRect, nullptr, D3DLOCK_READONLY); if (SUCCEEDED(hr)) { // lockedRect.pBits 指向像素首地址lockedRect.Pitch 是行字节跨度 // 注意 Pitch 可能大于 Width*4逐行拷贝时要用 Pitch 而不是 Width*4 pSysSurface-UnlockRect(); }逻辑说明GetBackBuffer拿到的表面在默认池不能直接锁CreateOffscreenPlainSurface建一张系统内存表面作为中转GetRenderTargetData完成显存到内存的搬运LockRect后pBits就是可读的像素数据。参数上desc.Format常见是D3DFMT_X8R8G8B8或D3DFMT_A8R8G8B8都是 32 位每像素 4 字节。行跨度Pitch往往大于Width * 4GPU 有对齐要求逐行拷贝时必须按Pitch偏移否则图像会斜切。这是血泪经验第一次写基本都会踩。2.3 D3D11 的 CopyResource Map 路径如果目标游戏是 D3D11思路换成 DXGI。先GetBuffer(0, __uuidof(ID3D11Texture2D), ...)拿后台缓冲纹理再建一张D3D11_USAGE_STAGING、CPU_ACCESS_READ的纹理CopyResource过去最后Map读。// D3D11 截取交换链后台缓冲 ID3D11Texture2D* pBackBuffer nullptr; HRESULT hr pSwapChain-GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)pBackBuffer); if (FAILED(hr)) return false; D3D11_TEXTURE2D_DESC desc; pBackBuffer-GetDesc(desc); // 暂存纹理必须 STAGING CPU_ACCESS_READ D3D11_TEXTURE2D_DESC stagingDesc desc; stagingDesc.Usage D3D11_USAGE_STAGING; stagingDesc.BindFlags 0; stagingDesc.CPUAccessFlags D3D11_CPU_ACCESS_READ; stagingDesc.MiscFlags 0; ID3D11Texture2D* pStaging nullptr; hr pDevice-CreateTexture2D(stagingDesc, nullptr, pStaging); if (FAILED(hr)) { pBackBuffer-Release(); return false; } // 显存 - 暂存 pContext-CopyResource(pStaging, pBackBuffer); // 映射读取 D3D11_MAPPED_SUBRESOURCE mapped; hr pContext-Map(pStaging, 0, D3D11_MAP_READ, 0, mapped); if (SUCCEEDED(hr)) { // mapped.pData 像素首地址mapped.RowPitch 行跨度 pContext-Unmap(pStaging, 0); }逻辑说明GetBuffer(0)拿的是交换链第 0 号后台缓冲CopyResource要求源和目标同尺寸同格式Map时RowPitch同样可能大于Width * 4。参数上D3D11_MAP_READ表示只读映射映射期间 GPU 不能写这张纹理所以别在渲染线程高频调用否则会拖慢帧率。常见做法是截屏请求触发时做一次而不是每帧都做。3. 用 Visual C 把截屏代码接进游戏进程3.1 注入与 Hook Present 的时机选择截屏代码要跑在游戏进程里才能拿到那个进程的 D3D 设备。常见做法是 DLL 注入后 HookIDXGISwapChain::PresentD3D11或IDirect3DDevice9::PresentD3D9。为什么 Hook Present 而不是别的函数因为 Present 是每帧渲染完成的标志点此时后台缓冲内容完整且稳定是取帧的最佳时机。Hook 方式有几种改虚函数表VTable Hook、用 Detours 库、或 MinHook。VTable Hook 最轻量不需要额外依赖。// 以 D3D9 Present 的 VTable Hook 为例简化骨架 typedef HRESULT(WINAPI* Present_t)(IDirect3DDevice9*, const RECT*, const RECT*, HWND, const RGNDATA*); Present_t g_origPresent nullptr; HRESULT WINAPI HookedPresent(IDirect3DDevice9* pDevice, const RECT* pSrc, const RECT* pDst, HWND hWnd, const RGNDATA* pDirty) { // 在这里做截屏此时后台缓冲是完整的一帧 if (g_bCaptureRequested) { CaptureBackBuffer(pDevice); // 内部走 GetRenderTargetData 流程 g_bCaptureRequested false; } return g_origPresent(pDevice, pSrc, pDst, hWnd, pDirty); } // 安装 Hook从设备对象取 VTable替换 Present 槽位 void InstallHook(IDirect3DDevice9* pDevice) { void** vtable *(void***)pDevice; // Present 在 IDirect3DDevice9 的 VTable 中索引为 17 DWORD oldProtect; VirtualProtect(vtable[17], sizeof(void*), PAGE_EXECUTE_READWRITE, oldProtect); g_origPresent (Present_t)vtable[17]; vtable[17] (void*)HookedPresent; VirtualProtect(vtable[17], sizeof(void*), oldProtect, oldProtect); }逻辑说明*(void***)pDevice取出对象的虚函数表指针vtable[17]是Present的槽位D3D9 的接口方法顺序固定。替换前用VirtualProtect把该内存页改成可写改完再改回。参数上g_bCaptureRequested是个标志位由外部触发比如热键避免每帧都截。注意不同 D3D 版本的 Present 索引不同D3D11 的IDXGISwapChain::Present索引是 8写错会直接崩。3.2 从像素缓冲到 BMP/PNG 的落盘拿到pBits和Pitch后要编码成文件。最省事的是写 BMP因为格式简单、无需第三方库。BMP 文件头 14 字节 信息头 40 字节后面跟像素数据。注意 BMP 是自底向上存储的而 D3D 表面是自顶向下写的时候要倒序。// 把锁定的像素写成 32 位 BMP bool SaveBMP(const char* path, BYTE* pBits, int width, int height, int pitch) { BITMAPFILEHEADER fileHeader {}; BITMAPINFOHEADER infoHeader {}; int rowSize width * 4; // 32 位每像素 int imageSize rowSize * height; fileHeader.bfType 0x4D42; // BM fileHeader.bfOffBits sizeof(BITMAPFILEHEADER) sizeof(BITMAPINFOHEADER); fileHeader.bfSize fileHeader.bfOffBits imageSize; infoHeader.biSize sizeof(BITMAPINFOHEADER); infoHeader.biWidth width; infoHeader.biHeight height; // 正数表示自底向上 infoHeader.biPlanes 1; infoHeader.biBitCount 32; infoHeader.biCompression BI_RGB; infoHeader.biSizeImage imageSize; FILE* fp fopen(path, wb); if (!fp) return false; fwrite(fileHeader, sizeof(fileHeader), 1, fp); fwrite(infoHeader, sizeof(infoHeader), 1, fp); // D3D 表面自顶向下BMP 自底向上逐行倒着写 for (int y height - 1; y 0; --y) { fwrite(pBits y * pitch, rowSize, 1, fp); } fclose(fp); return true; }逻辑说明bfType固定0x4D42biHeight为正表示像素自底向上所以写的时候从最后一行开始。参数上pitch是 D3D 表面的行跨度rowSize是 BMP 期望的紧凑行宽两者不一定相等所以按pitch定位行首、按rowSize写。如果游戏画面有 alpha 通道且你不需要可以在写之前把每像素第 4 字节置 0xFF否则某些看图软件会显示成全透明。3.3 编译环境与运行库依赖用 Visual C 编译这个 DLL建议 VS2019 或 VS2022平台选 x86 还是 x64 必须和目标游戏一致——32 位游戏注入 64 位 DLL 会直接失败。链接时需要d3d9.lib或d3d11.lib、dxgi.lib。运行库方面目标机器上要有对应的 Microsoft Visual C Redistributable否则 DLL 加载会报缺库。常见做法是静态链接运行库/MT省去装 redistributable 的麻烦代价是 DLL 体积大一点。DirectX 运行库本身在 Win10/Win11 是系统自带的一般不用额外装但如果目标机器报d3dx9_43.dll缺失那是 DirectX End-User Runtime 没装全装一下 DirectX 修复工具或官方 runtime 即可。4. 截屏黑屏、花屏、崩溃的排查清单4.1 截出来全黑现象、原因、解决现象文件生成了打开一片纯黑。原因通常是取帧时机不对或者读的是已经被 Clear 的后台缓冲。解决确认 Hook 点在 Present 调用之前且截屏逻辑在g_origPresent之前执行如果游戏用了多后台缓冲D3DPRESENT_PARAMETERS.BackBufferCount 1要确认GetBackBuffer的索引和当前呈现的那张一致一般取索引 0。另一个原因是全屏独占模式下 DWM 不参与合成某些保护机制会阻止读取改成无边框窗口模式往往能解决。4.2 图像斜切或错位Pitch 没对齐现象截出来的图整体斜着或者右边多一条花边。原因就是逐行拷贝时用了width * 4而不是Pitch。解决所有按行访问像素的地方行首地址一律用pBits y * pitch行内有效数据长度才是width * 4。这个坑在 D3D9 和 D3D11 里都存在D3D11 的RowPitch同样可能大于width * 4。4.3 注入后游戏闪退VTable 索引或线程问题现象DLL 注入瞬间游戏崩溃。原因可能是 Present 的 VTable 索引写错或者 Hook 安装在了错误的线程上。解决D3D9 的 Present 索引是 17D3D11 的IDXGISwapChain::Present是 8务必核对Hook 安装要在游戏主线程或渲染线程完成别在 DllMain 里直接做重活DllMain 里只做最小初始化实际 Hook 放到一个独立线程里延迟执行。4.4 截屏后帧率骤降Map 阻塞了 GPU现象每次截屏游戏卡一下。原因是Map或LockRect会强制 GPU 同步等待当前帧渲染完成。解决降低截屏频率别每帧都截D3D11 下可以用异步方式先把CopyResource提交隔一两帧再Map给 GPU 留出时间。另外暂存纹理用完及时Release别每次截屏都新建复用一张即可。4.5 高 DPI 缩放下截图尺寸不对现象在缩放比例不为 100% 的显示器上截图尺寸和预期不符。原因是进程的 DPI 感知设置影响了窗口和缓冲尺寸。解决在 DLL 里调用SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)让进程按物理像素处理这样后台缓冲尺寸就是真实的渲染分辨率不会被打折。5. 让截屏更稳的几个进阶技巧第一个技巧是复用暂存资源。上面代码每次截屏都CreateOffscreenPlainSurface或CreateTexture2D高频调用下开销不小。正确做法是在第一次截屏时创建好暂存表面/纹理缓存起来后续只做GetRenderTargetData或CopyResource尺寸变了再重建。我一般会用一个静态指针加尺寸标记来管理省掉反复分配释放。第二个技巧是用事件驱动代替轮询。别在主循环里if (g_bCaptureRequested)死等而是用SetEvent唤醒一个专门的截屏线程或者直接在 Hook 里判断标志位。这样对游戏帧率的影响最小。第三个技巧是验证截屏是否真的抓到了游戏画面。一个简单办法是截完后算一下像素的平均亮度或颜色直方图如果全黑或全同色说明抓错了可以自动重试或切换取帧策略。下面这段做个快速校验// 快速判断截图是否有效统计非黑像素比例 bool IsFrameValid(BYTE* pBits, int width, int height, int pitch) { long nonBlack 0; long total (long)width * height; for (int y 0; y height; y 4) { // 抽样跳行加速 BYTE* row pBits y * pitch; for (int x 0; x width; x 4) { BYTE b row[x * 4], g row[x * 4 1], r row[x * 4 2]; if (b 8 || g 8 || r 8) nonBlack; } } // 抽样总数是 total/16非黑占比超过 5% 认为有效 return (nonBlack * 16) (total / 20); }逻辑说明按 4 像素步长抽样统计 RGB 任一通道大于 8 的像素数占比超过 5% 就认为画面有效。参数上阈值 8 是为了滤掉压缩噪声5% 是经验值纯黑场景比如游戏加载黑屏可能误判可以结合场景再调。这个校验在自动化截屏工具里很实用能自动跳过无效帧。最后一个习惯永远在目标游戏上先手动验证取帧路径再写自动化。我吃过亏代码在测试 demo 上跑得好好的换到一个用 D3D11 且开了 Flip 模式的游戏上就全黑折腾半天才发现是交换链的SwapEffect不同导致后台缓冲访问方式有差异。所以每换一个游戏先用最小代码确认能拿到非黑帧再往上堆功能。希望帮到你。本文还有配套的精品资源点击获取
返回列表