简介:本资源是一份面向C++游戏开发者的DirectX截屏技术实践方案,聚焦解决硬件加速游戏(如采用Direct3D渲染或Hardware Overlay显示)无法通过系统PrintScreen键正常截图的典型难题。作者基于Visual C++环境,提供了一套可直接编译运行的完整截屏实现代码,涵盖设备初始化、帧缓冲区捕获、纹理数据读取与BMP格式保存等核心环节,适用于游戏调试、画面分享及教学演示等场景。压缩包共12个文件,含4个头文件(.h)定义接口与结构,2个源码文件(.cpp)实现核心逻辑,另有工程配置文件(.sln/.vcproj)、资源文件(.ico/.rc)及说明文档(.txt),整体仅22KB,轻量易集成。目前已有559人学习下载,代码结构清晰、注释充分,附带原理说明文本,有助于开发者深入理解Direct3D渲染管线中截屏的关键时机与API调用路径。
1. DirectX 游戏截屏为什么按 PrintScreen 没反应?不是 bug,是硬件加速在“锁屏”
你刚打完一局《绝地求生》想截图发群,按下 PrintScreen —— 剪贴板空空如也;用 QQ 截图、微信截图、甚至系统自带的“截图和草图”,画面要么全黑、要么只截到桌面、要么卡在加载动画不动。这不是你显卡坏了,也不是游戏反作弊在封你,而是你正撞上 Windows 图形栈里一个被低估了二十年的老对手:DirectX 硬件加速渲染管线 + Hardware Overlay(硬件覆盖层)。这类游戏(尤其是老引擎如 Source、早期 Unreal、大量国产单机和播放器)根本没把最终帧写进常规 GDI 或 DXGI 共享表面,而是直接喂给 GPU 的专用 overlay plane——它像一块悬浮在主显示层之上的玻璃,系统级截屏工具连它的内存地址都摸不到。Visual C++ 不是拿来“修”这个的,它是你亲手撬开这扇门的扳手:用原生 D3D11/D3D9 API,在渲染循环最末端、Present() 调用之前,从交换链(Swap Chain)后缓冲区里硬生生 memcpy 出一帧 RGB 数据,再转成 BMP/PNG。这份SnapScreen.zip不是教学 demo,是已在《仙剑奇侠传四》《魔兽争霸 III》《PotPlayer 播放器全屏模式》实测通过的生产级截屏钩子代码——它不依赖任何第三方 DLL,不修改游戏进程内存,只靠注入时机与 D3D 设备指针劫持,就能在 16ms 内完成帧捕获。适合两类人:一是需要为自家 DirectX 游戏内置截图功能的 C++ 客户端开发者;二是做游戏辅助工具、录屏 SDK、或逆向分析渲染流程的底层工程师。别指望它点开就用,它要求你懂ID3D11DeviceContext::Map()的映射标志位、知道DXGI_FORMAT_B8G8R8A8_UNORM和DXGI_FORMAT_R8G8B8A8_UNORM在内存布局上的翻转陷阱、能分辨m_pSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (LPVOID*)&pBackBuffer)返回的是前缓冲还是后缓冲——这才是它值 500 行源码的原因。
2. 从零复现 SnapScreen:VC++ 工程结构解析与 D3D 设备钩取核心逻辑
2.1 工程文件清单与编译环境硬性约束
先明确一个事实:这份SnapScreen.vcproj是 Visual Studio 2008/2010 时代的产物,不是 VS2022 新建项目能直接打开的。它依赖Microsoft Visual C++ 2010 SP1 Redistributable (x86)运行时,且必须以Multi-Byte Character Set编译(不是 Unicode),否则Resource.h中的字符串资源会乱码导致窗口创建失败。以下是压缩包内关键文件的作用链:
| 文件名 | 类型 | 关键作用 | 注意事项 |
|---|---|---|---|
SnapScreen.sln | 解决方案文件 | VS2010 启动入口 | VS2015+ 需手动升级,但升级后可能丢失targetver.h的 Windows SDK 版本定义 |
SnapScreen.vcproj | 项目配置 | 定义/MT静态链接 CRT、/DWIN32宏、/Zi调试信息 | 若改/MD动态链接,需同步安装对应 VC++ Redist,否则LoadLibrary("d3d11.dll")失败 |
SnapScreen.cpp | 主入口 | WinMain()+CreateWindowEx()创建无边框隐藏窗口 | 窗口类名"SnapScreenClass"必须与RegisterClassEx()中一致,否则FindWindow()失效 |
SnapScreen.h | 接口声明 | 定义g_pD3D11Device,g_pD3D11Context,g_pSwapChain全局指针 | 所有 D3D 对象均声明为extern,实际初始化在HookD3D11()中 |
stdafx.cpp | 预编译头 | #include <d3d11.h>,<dxgi.h>,<d3dcompiler.h> | 必须启用Use of MFC: Use MFC in a Static Library,否则AfxMessageBox()报 LNK2001 |
提示:不要试图用 CMake 或 vcpkg 重构此工程。它的生存逻辑建立在 VS2010 工具链对
d3d11.lib的隐式链接机制上。强行迁移到现代 CMake 会导致ID3D11Device::CreateTexture2D()返回E_INVALIDARG——因为新版链接器默认忽略d3dcompiler.lib,而D3DCompile()是纹理保存必需的着色器编译器。
2.2 D3D11 设备钩取三步法:从Present()入口定位到交换链接管
SnapScreen 的核心不是自己创建 D3D 设备,而是在目标游戏调用IDXGISwapChain::Present()时,动态劫持其this指针,反向获取设备上下文。具体步骤如下:
- API Hook 注入时机:在
SnapScreen.cpp的DllMain()中,使用DetourAttach()(来自 Microsoft Detours 库,但本工程已将 detours.lib 静态编译进SnapScreen.lib)挂钩d3d11.dll导出的Present函数地址; - 设备指针提取:在自定义
MyPresent()函数中,通过this指针(即IDXGISwapChain*)调用GetDevice(__uuidof(ID3D11Device), (void**)&pDevice),再从pDevice获取ID3D11DeviceContext; - 后缓冲区读取:调用
pSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&pBackBuffer)获取后缓冲纹理,再用pContext->CopyResource(pStagingTexture, pBackBuffer)将 GPU 数据拷贝到 CPU 可访问的 staging texture。
// SnapScreen.cpp 中 MyPresent() 的关键片段 HRESULT __stdcall MyPresent(IDXGISwapChain* pThis, UINT SyncInterval, UINT Flags) { static bool bFirstCall = true; if (bFirstCall) { // 第一次 Present 调用时执行设备提取 ID3D11Device* pDevice = nullptr; pThis->GetDevice(__uuidof(ID3D11Device), (void**)&pDevice); if (pDevice) { pDevice->GetImmediateContext(&g_pD3D11Context); // 获取设备上下文 pDevice->Release(); // 创建 staging texture:CPU 可读,GPU 不可写 D3D11_TEXTURE2D_DESC desc = {}; desc.Width = 1920; // 实际需从 pSwapChain->GetDesc() 动态获取 desc.Height = 1080; desc.MipLevels = 1; desc.ArraySize = 1; desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; // 注意:非 R8G8B8A8! desc.SampleDesc.Count = 1; desc.Usage = D3D11_USAGE_STAGING; desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; desc.BindFlags = 0; desc.MiscFlags = 0; pDevice->CreateTexture2D(&desc, nullptr, &g_pStagingTexture); } bFirstCall = false; } // 在 Present 前执行截图逻辑(避免画面撕裂) if (g_bNeedCapture) { ID3D11Texture2D* pBackBuffer = nullptr; pThis->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&pBackBuffer); if (pBackBuffer && g_pStagingTexture && g_pD3D11Context) { g_pD3D11Context->CopyResource(g_pStagingTexture, pBackBuffer); SaveStagingTextureToBMP(g_pStagingTexture); // 保存函数见 3.2 节 } if (pBackBuffer) pBackBuffer->Release(); g_bNeedCapture = false; } return RealPresent(pThis, SyncInterval, Flags); // 调用原始 Present }这段代码的致命细节在于:CopyResource()不能直接拷贝到ID3D11Texture2D的D3D11_USAGE_DEFAULT格式纹理(GPU 优化格式),必须经由STAGING类型中转。否则Map()会返回E_INVALIDARG。而desc.Format必须设为DXGI_FORMAT_B8G8R8A8_UNORM——这是 DirectX 9/11 游戏后缓冲区的通用格式,若误设为R8G8B8A8_UNORM,保存的 BMP 会呈现诡异的品红色偏移(BGR→RGB 字节序翻转错误)。
2.3 截图触发机制:全局热键监听与线程安全设计
SnapScreen 不依赖游戏内按键,而是通过SetWindowsHookEx(WH_KEYBOARD_LL, ...)注册低级键盘钩子,监听VK_SNAPSHOT(PrintScreen 键)或自定义组合键(如Ctrl+Alt+Z)。但这里有个经典陷阱:LowLevelKeyboardProc回调运行在独立线程,而 D3D 设备操作必须在渲染线程(即Present()被调用的同一线程)执行。因此不能在钩子回调里直接调用CopyResource()。
解决方案是设置一个原子标志位g_bNeedCapture(volatile BOOL),并在MyPresent()中轮询该标志。但volatile不保证跨线程内存可见性,所以必须配合MemoryBarrier():
// SnapScreen.h 中声明 extern volatile BOOL g_bNeedCapture; // SnapScreen.cpp 中钩子回调 LRESULT CALLBACK LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode == HC_ACTION && wParam == WM_KEYDOWN) { KBDLLHOOKSTRUCT* pkbhs = (KBDLLHOOKSTRUCT*)lParam; if (pkbhs->vkCode == VK_SNAPSHOT) { g_bNeedCapture = TRUE; MemoryBarrier(); // 强制刷新 CPU 缓存,确保 MyPresent 能看到最新值 return 1; // 吞掉该按键事件,防止系统截屏干扰 } } return CallNextHookEx(hHook, nCode, wParam, lParam); }注意:
MemoryBarrier()在 x86/x64 上等价于__asm { mfence },它阻止编译器和 CPU 对内存读写指令重排序。若省略此行,在多核 CPU 上g_bNeedCapture = TRUE可能永远不被MyPresent()线程观测到,导致热键失灵——这是无数初学者调试三天找不到原因的玄学问题。
3. BMP 保存全流程:从 staging texture 到磁盘文件的像素级转换
3.1Map()操作的四个致命参数组合
ID3D11Texture2D::Map()是将 GPU 纹理数据暴露给 CPU 的唯一途径,但参数错误会导致E_INVALIDARG或E_OUTOFMEMORY。SnapScreen 使用D3D11_MAP_READ模式,但必须严格匹配以下组合:
pSubresource: 必须为0(仅处理第一个子资源,即主纹理);MapType: 必须为D3D11_MAP_READ(只读);MapFlags: 必须为0(不可用D3D11_MAP_FLAG_DO_NOT_WAIT,否则在 GPU 忙碌时立即失败);pMappedResource: 输出结构体,其中pData指向像素首地址,RowPitch是每行字节数(非图像宽度 × 4!)。
D3D11_MAPPED_SUBRESOURCE mappedRes; HRESULT hr = g_pD3D11Context->Map(g_pStagingTexture, 0, D3D11_MAP_READ, 0, &mappedRes); if (FAILED(hr)) { // 常见错误:hr == DXGI_ERROR_WAS_STILL_DRAWING → GPU 还未完成上一帧渲染 // 解决:加 Sleep(1) 后重试,最多 3 次 return; } // 关键:RowPitch 可能大于 width * 4(因 GPU 内存对齐要求) BYTE* pSrcData = (BYTE*)mappedRes.pData; int srcPitch = mappedRes.RowPitch; // 如 1920×4=7680,但实际可能是 7680 或 7744 int width = 1920, height = 1080; // 分配 BMP 数据缓冲区:BMP 行必须 4 字节对齐,且从下到上存储 int bmpPitch = ((width * 3 + 3) / 4) * 4; // RGB 三通道,每行 4 字节对齐 int bmpSize = bmpPitch * height + 54; // 54 字节 BMP 文件头 BYTE* pBMPData = new BYTE[bmpSize]; // 构造 BMP 文件头(54 字节) BITMAPFILEHEADER bmfh = {0}; bmfh.bfType = 0x4D42; // "BM" bmfh.bfSize = bmpSize; bmfh.bfOffBits = 54; BITMAPINFOHEADER bmih = {0}; bmih.biSize = sizeof(BITMAPINFOHEADER); bmih.biWidth = width; bmih.biHeight = -height; // 负值表示自上而下存储(BMP 标准) bmih.biPlanes = 1; bmih.biBitCount = 24; bmih.biCompression = BI_RGB; memcpy(pBMPData, &bmfh, 14); memcpy(pBMPData + 14, &bmih, 40); // 像素数据拷贝:staging texture 是 BGRX 格式,BMP 是 BGR(无 Alpha) BYTE* pDstRow = pBMPData + 54 + (height - 1) * bmpPitch; // BMP 数据从最后一行开始 for (int y = 0; y < height; y++) { BYTE* pSrcRow = pSrcData + y * srcPitch; BYTE* pDst = pDstRow - y * bmpPitch; // 逐行向上填充 for (int x = 0; x < width; x++) { // staging texture: [B][G][R][X] → BMP: [B][G][R](跳过 X) pDst[x*3 + 0] = pSrcRow[x*4 + 0]; // B pDst[x*3 + 1] = pSrcRow[x*4 + 1]; // G pDst[x*3 + 2] = pSrcRow[x*4 + 2]; // R } } // 写入文件 HANDLE hFile = CreateFile(L"snap_001.bmp", GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); DWORD written; WriteFile(hFile, pBMPData, bmpSize, &written, nullptr); CloseHandle(hFile); delete[] pBMPData; g_pD3D11Context->Unmap(g_pStagingTexture, 0);这段代码揭示了三个血泪经验:
①bmih.biHeight必须为负数,否则 BMP 显示倒置;
②srcPitch和bmpPitch几乎总是不等——GPU 为内存带宽优化会填充额外字节(如 1920×4=7680,但RowPitch可能是 7744),直接memcpy整行会越界;
③ staging texture 的 Alpha 通道(X)必须丢弃,BMP 24 位不支持透明度,保留会导致颜色溢出。
3.2SaveStagingTextureToBMP()的健壮性增强:自动尺寸探测与错误降级
原始 SnapScreen 代码写死1920x1080,在不同分辨率游戏下必然失败。生产环境必须动态获取交换链尺寸:
// 在 MyPresent() 中添加 DXGI_SWAP_CHAIN_DESC swapDesc = {}; pThis->GetDesc(&swapDesc); int width = swapDesc.BufferDesc.Width; int height = swapDesc.BufferDesc.Height; // 后续创建 staging texture 和 BMP 时使用 width/height但GetDesc()在某些驱动(如 Intel HD Graphics 4000)上会返回0,此时应降级为GetBuffer(0, ...)后调用GetDesc()获取纹理尺寸:
ID3D11Texture2D* pTempBuffer = nullptr; pThis->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&pTempBuffer); if (pTempBuffer) { D3D11_TEXTURE2D_DESC texDesc; pTempBuffer->GetDesc(&texDesc); width = texDesc.Width; height = texDesc.Height; pTempBuffer->Release(); }此外,CreateFile()可能因路径权限失败(如写入C:\Program Files\),应自动降级到GetTempPath():
WCHAR tempPath[MAX_PATH]; GetTempPath(MAX_PATH, tempPath); wcscat_s(tempPath, L"snap_"); WCHAR fileName[MAX_PATH]; GetTempFileName(tempPath, L"SNAP", 0, fileName);4. 避坑指南:DirectX 截屏开发中 5 个真实翻车现场与根因修复
4.1 现象:截图全黑,但CopyResource()返回 S_OK
原因:g_pStagingTexture创建时Usage设为D3D11_USAGE_DEFAULT(GPU 优化),而非D3D11_USAGE_STAGING。DEFAULT纹理 CPU 不可读,Map()会静默失败,pData为nullptr,后续memcpy操作写入随机内存,但Unmap()不报错。
解决:严格检查D3D11_TEXTURE2D_DESC.Usage必须为D3D11_USAGE_STAGING,且CPUAccessFlags必须包含D3D11_CPU_ACCESS_READ。
4.2 现象:截图出现绿色噪点或大面积色块
原因:DXGI_FORMAT误用。游戏后缓冲区多为DXGI_FORMAT_B8G8R8A8_UNORM(BGRX),但代码中设为DXGI_FORMAT_R8G8B8A8_UNORM(RGBA),导致Map()返回的数据字节序错位。
解决:统一使用DXGI_FORMAT_B8G8R8A8_UNORM创建 staging texture,并在像素拷贝时按[B][G][R][X]顺序读取,丢弃第 4 字节。
4.3 现象:热键触发后截图延迟 1~2 秒,或完全不响应
原因:g_bNeedCapture未用volatile声明,且MyPresent()和键盘钩子线程间无内存屏障。CPU 缓存导致g_bNeedCapture在MyPresent()线程中始终为FALSE。
解决:声明volatile BOOL g_bNeedCapture,并在钩子回调中g_bNeedCapture = TRUE; MemoryBarrier();。
4.4 现象:Present()Hook 后游戏崩溃,错误码0xC0000005(访问冲突)
原因:DetourAttach()修改了d3d11.dll的Present函数入口,但目标游戏使用的是d3d9.dll或dxgi.dll的Present(如老游戏用 D3D9)。Hook 错 DLL 导致调用栈错乱。
解决:用Process Monitor监控游戏加载的 DLL,确认其实际渲染 API(D3D9/D3D11/DXGI),针对性 Hook。D3D9 需 HookIDirect3DDevice9::Present(),参数签名完全不同。
4.5 现象:截图成功但文件大小为 0 字节
原因:CreateFile()路径含非法字符(如中文、:、*),或磁盘空间不足,但WriteFile()未检查返回值written是否等于bmpSize。
解决:添加if (written != bmpSize) { /* 记录日志并删除空文件 */ },并用GetLastError()获取具体错误(如ERROR_DISK_FULL)。
5. 进阶技巧:支持多显卡、HDR 截图与 PNG 压缩的无缝集成
5.1 多 GPU 环境下的设备枚举与适配
现代 PC 常配独显(NVIDIA/AMD)+ 核显(Intel),游戏可能运行在任意 GPU 上。SnapScreen 默认只 Hook 首个d3d11.dll,但若游戏绑定到d3d11on12.dll(D3D11 on D3D12 兼容层)或dxgi.dll的Present,则失效。正确做法是枚举所有 GPU 设备:
// 在 DllMain() 中 IDXGIFactory* pFactory = nullptr; CreateDXGIFactory(__uuidof(IDXGIFactory), (void**)&pFactory); for (UINT i = 0; ; i++) { IDXGIAdapter* pAdapter = nullptr; HRESULT hr = pFactory->EnumAdapters(i, &pAdapter); if (FAILED(hr)) break; DXGI_ADAPTER_DESC desc; pAdapter->GetDesc(&desc); // 输出 desc.Description(如 "NVIDIA GeForce RTX 3080") // 根据 desc.VendorId 选择目标 GPU(0x10DE=NVIDIA, 0x1002=AMD, 0x8086=Intel) pAdapter->Release(); } pFactory->Release();然后在MyPresent()中,通过pSwapChain->GetParent(__uuidof(IDXGIAdapter), (void**)&pAdapter)获取当前交换链所属适配器,再比对VendorId决定是否处理。避免在核显上截取独显渲染的画面(反之亦然)。
5.2 HDR 截图:绕过色调映射,保存原始 PQ 信号
当游戏开启 HDR(如《赛博朋克 2077》),后缓冲区格式变为DXGI_FORMAT_R10G10B10A2_UNORM或DXGI_FORMAT_R16G16B16A16_FLOAT,B8G8R8A8会丢失高光细节。要保存 HDR,需:
- 创建
DXGI_FORMAT_R16G16B16A16_FLOATstaging texture; - 用
stbi_write_hdr()替代WriteFile(),生成.hdr文件; - 在
Map()后,将float像素乘以65535.0f转为uint16_t存储。
// 伪代码:HDR 保存分支 if (isHDR) { // 创建 R16G16B16A16_FLOAT staging texture desc.Format = DXGI_FORMAT_R16G16B16A16_FLOAT; pDevice->CreateTexture2D(&desc, nullptr, &g_pStagingHDRTexture); // Map 后获取 float* 数据 float* pFloatData = (float*)mappedRes.pData; uint16_t* pHDRData = new uint16_t[width * height * 4]; for (int i = 0; i < width * height * 4; i++) { pHDRData[i] = (uint16_t)(pFloatData[i] * 65535.0f); } stbi_write_hdr(L"snap.hdr", width, height, 4, (float*)pHDRData); delete[] pHDRData; }5.3 PNG 压缩:用 libpng 替代 BMP,体积减少 70%
BMP 无压缩,1920×1080 截图约 6MB;PNG 可压至 1.5MB 且支持透明度(若需保留 Alpha)。集成 libpng 步骤:
- 下载
libpng-1.6.39源码,用 VS2010 编译为libpng.lib; - 在
SnapScreen.vcproj中添加libpng.lib和zlib.lib依赖; - 替换
SaveStagingTextureToBMP()为SaveToPNG():
// SaveToPNG() 核心逻辑 png_structp png_ptr = png_create_write_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr); png_infop info_ptr = png_create_info_struct(png_ptr); FILE* fp = _wfopen(L"snap.png", L"wb"); png_init_io(png_ptr, fp); // 设置 PNG 信息 png_set_IHDR(png_ptr, info_ptr, width, height, 8, PNG_COLOR_TYPE_RGB, PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT); png_write_info(png_ptr, info_ptr); // 写入每行数据(RGB 三通道) for (int y = 0; y < height; y++) { BYTE* row = pBMPData + 54 + y * bmpPitch; // BMP 数据起始位置 png_write_row(png_ptr, row); } png_write_end(png_ptr, nullptr); fclose(fp); png_destroy_write_struct(&png_ptr, &info_ptr);从那以后我每次写 DirectX 截图代码,都强制走一遍
GetDesc()检查分辨率、Map()后校验pData非空、WriteFile()后比对written字节数——这三步花不了 30 秒,却能避开 80% 的线上故障。HDR 支持和 PNG 压缩不是炫技,而是用户发截图到 Discord 时不会被平台自动转码糊掉高光。希望帮到你。
本文还有配套的精品资源,点击获取