ARTICLE DETAIL

资讯详情

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

Windows DWM与DXGI桌面复制API实现精准屏幕截图

Windows DWM与DXGI桌面复制API实现精准屏幕截图 1. 项目缘起为什么不用BitBlt在Windows平台上做屏幕截图很多人的第一反应是使用经典的GDI函数BitBlt。这确实是一个简单直接的方法从桌面设备上下文DC中把像素数据“位块传输”到内存位图中。我最初的项目也是这么开始的代码简洁几行就能跑起来。但很快我就遇到了一个几乎所有桌面应用开发者都会踩的坑当目标窗口被其他窗口比如一个置顶的视频播放器、一个半透明的聊天窗口部分遮挡或者窗口本身使用了DirectX/Direct2D等硬件加速渲染时BitBlt捕获到的画面是“错误”的。这个“错误”不是说程序崩溃而是它捕获到的是Windows在合成桌面之前各个窗口自己提交的、未经最终混合的画面。对于被遮挡的区域BitBlt抓到的是窗口原本的内容而不是你最终在屏幕上看到的、被其他窗口盖住后的样子。更麻烦的是对于那些使用WPF、UWP、甚至一些现代Qt应用开启原生窗口模糊等效果BitBlt抓到的可能是一片黑或者扭曲的图像。这时就需要请出Windows Vista之后引入的桌面窗口管理器——DWMDesktop Window Manager。DWM的核心工作就是负责所有窗口的合成它掌握着最终呈现在屏幕上的每一帧完美画面。我们的目标就是从DWM这里拿到经过它合成后的、最“真实”的桌面图像。这就是本次项目的核心绕过传统的GDI路径直连DWM实现一个健壮、准确的屏幕截图工具。2. DWM截图的核心原理与API选择要理解如何从DWM获取截图首先得明白DWM的工作流程。在DWM启用的情况下现代Windows默认开启应用程序不再直接向屏幕缓冲区绘制而是先绘制到一个离屏的“表面”Surface通常是Direct3D的纹理。DWM则作为合成器收集所有窗口的表面按照Z序、透明度、特效等进行混合最终输出到显示设备。因此从DWM获取截图本质上是请求DWM提供它当前用于合成输出的那个后备缓冲区Back Buffer的一份拷贝。微软为此提供了专门的APIDwmGetDxSharedSurface和更现代的DwmGetGraphicsStreamClient等。但对于我们C开发者来说最实用、文档最全、兼容性也足够好的是DwmRegisterThumbnail和DwmUpdateThumbnailProperties这一对组合配合DXGIDirectX Graphics Infrastructure的桌面复制API。为什么不直接用DwmGetDxSharedSurface这个API更底层理论上更直接但它对调用线程的COM初始化状态、DXGI设备创建有更严格的要求且在不同Windows版本如Win7, Win8, Win10上行为可能有细微差异文档示例也较少。对于截图这个单一任务用桌面复制API是更“标准”和“安全”的路径。我们的技术路线图如下初始化COM库因为我们要使用DXGI它构建在COM之上。创建DXGI设备与输出连接到默认的图形适配器和显示器输出。创建桌面复制接口IDXGIOutputDuplication这是关键对象它代表了我们对桌面输出图像的“复制”权限。获取桌面帧从复制接口中获取最新的桌面图像数据IDXGIResource。转换与保存将获取到的DXGI表面通常是ID3D11Texture2D中的数据拷贝到CPU可访问的内存中并转换为常见的位图格式如BMP、PNG保存。这条路线的优势在于它直接对接图形驱动获取的是DWM合成后的最终帧完美解决了窗口遮挡和硬件加速渲染的问题并且性能极高适合需要高帧率截图的场景如录屏。3. 环境准备与项目配置在开始敲代码之前正确的开发环境配置是成功的一半。这里我以Visual Studio 2022为例但核心思想适用于任何现代C开发环境。3.1 创建项目与设置属性首先创建一个新的“控制台应用”或“桌面应用”项目。接下来是关键的属性页配置配置类型确保是应用程序(.exe)。C语言标准建议设置为C17或更高以获得更好的标准库支持。字符集使用Unicode字符集。这是现代Windows编程的标配。3.2 链接库与头文件DWM和DXGI相关的函数和接口定义在特定的SDK头文件和库中。我们需要在项目属性中正确设置。附加包含目录通常VS会自动配置Windows SDK的路径。如果没有确保$(WindowsSDK_IncludePath)在包含目录中。附加库目录同样确保$(WindowsSDK_LibraryPath_x64)或$(WindowsSDK_LibraryPath_x86)根据你的目标平台在库目录中。附加依赖项链接器输入这是核心。我们需要链接以下库dwmapi.lib提供DwmRegisterThumbnail等DWM函数。dxgi.lib提供DXGI基础接口如IDXGIFactory,IDXGIOutput。d3d11.lib我们将使用Direct3D 11设备来操作纹理。这是与DXGI桌面复制API协作最常用的版本。在你的stdafx.h或主源文件头部需要包含这些头文件#include windows.h #include dwmapi.h #include dxgi1_2.h // 注意是1_2版本它包含了IDXGIOutputDuplication #include d3d11.h #include wincodec.h // 用于使用Windows Imaging Component (WIC)保存图片 #include vector #include string #include iostream #pragma comment(lib, dwmapi.lib) #pragma comment(lib, dxgi.lib) #pragma comment(lib, d3d11.lib) #pragma comment(lib, Windowscodecs.lib) // WIC库注意dxgi1_2.h是关键。早期的dxgi.h不包含IDXGIOutputDuplication接口。确保你的Windows SDK版本足够新例如随VS2022安装的SDK。3.3 处理COM智能指针我们将大量使用COM接口指针如IDXGIOutputDuplication*、ID3D11Device*等。手动管理它们的AddRef和Release极易出错。强烈建议使用Microsoft::WRL::ComPtr需要包含#include wrl/client.h。它能像std::unique_ptr一样自动管理COM对象生命周期让代码安全又简洁。4. 分步实现从初始化到保存图片现在让我们进入核心的代码实现环节。我将把整个过程分解为几个清晰的函数。4.1 初始化COM与创建D3D设备一切始于COM和Direct3D设备。bool InitializeDirectX(ID3D11Device** ppDevice, ID3D11DeviceContext** ppContext) { HRESULT hr S_OK; // 1. 创建D3D11设备 D3D_FEATURE_LEVEL featureLevels[] { D3D_FEATURE_LEVEL_11_0 }; UINT createDeviceFlags 0; #ifdef _DEBUG createDeviceFlags | D3D11_CREATE_DEVICE_DEBUG; // 调试时启用调试层 #endif hr D3D11CreateDevice( nullptr, // 默认适配器 D3D_DRIVER_TYPE_HARDWARE, // 使用硬件驱动 nullptr, // 没有软件驱动 createDeviceFlags, // 标志位 featureLevels, // 特性等级 1, // 特性等级数量 D3D11_SDK_VERSION, // SDK版本 ppDevice, // 返回的设备指针 nullptr, // 不关心特性等级 ppContext // 返回的设备上下文指针 ); if (FAILED(hr)) { std::cerr Failed to create D3D11 device. HRESULT: std::hex hr std::endl; return false; } return true; }这个函数创建了Direct3D 11设备和上下文。设备上下文是执行渲染命令的“命令列表”在拷贝纹理数据时会用到。4.2 获取DXGI输出与创建桌面复制器有了D3D设备我们就可以获取代表显示器的DXGI输出并创建关键的桌面复制接口。bool CreateDesktopDuplication(ID3D11Device* pDevice, IDXGIOutputDuplication** ppDuplication) { HRESULT hr S_OK; Microsoft::WRL::ComPtrIDXGIFactory1 dxgiFactory; Microsoft::WRL::ComPtrIDXGIAdapter adapter; Microsoft::WRL::ComPtrIDXGIOutput dxgiOutput; Microsoft::WRL::ComPtrIDXGIOutput1 dxgiOutput1; // 需要QueryInterface得到 // 1. 获取DXGI工厂 hr CreateDXGIFactory1(__uuidof(IDXGIFactory1), dxgiFactory); if (FAILED(hr)) return false; // 2. 获取默认适配器显卡 hr dxgiFactory-EnumAdapters(0, adapter); if (FAILED(hr)) return false; // 3. 获取第一个输出主显示器 hr adapter-EnumOutputs(0, dxgiOutput); if (FAILED(hr)) return false; // 4. 查询IDXGIOutput1接口它包含DuplicateOutput方法 hr dxgiOutput.As(dxgiOutput1); if (FAILED(hr)) return false; // 5. 创建桌面复制接口 hr dxgiOutput1-DuplicateOutput(pDevice, ppDuplication); if (hr DXGI_ERROR_NOT_CURRENTLY_AVAILABLE) { std::cerr DuplicateOutput failed: DXGI_ERROR_NOT_CURRENTLY_AVAILABLE. This usually means the desktop is locked or another application is already duplicating. std::endl; return false; } else if (FAILED(hr)) { std::cerr DuplicateOutput failed. HRESULT: std::hex hr std::endl; return false; } return true; }DuplicateOutput是核心调用。它告诉系统“我要复制这个显示输出的内容”。如果失败并返回DXGI_ERROR_NOT_CURRENTLY_AVAILABLE通常是因为桌面被锁定了或者已经有另一个进程比如某些录屏软件在复制了。一个健壮的程序应该处理这种情况例如等待或提示用户。4.3 捕获一帧桌面图像复制器创建好后我们就可以尝试从它那里“拉取”一帧图像。bool AcquireDesktopFrame(IDXGIOutputDuplication* pDuplication, ID3D11Device* pDevice, ID3D11Texture2D** ppAcquiredTexture, DXGI_OUTDUPL_FRAME_INFO* pFrameInfo) { HRESULT hr S_OK; Microsoft::WRL::ComPtrIDXGIResource dxgiResource; // 1. 获取一帧 hr pDuplication-AcquireNextFrame(500, pFrameInfo, dxgiResource); // 超时500毫秒 if (hr DXGI_ERROR_WAIT_TIMEOUT) { // 在超时时间内没有新的帧 return false; } else if (FAILED(hr)) { // 其他错误比如复制器被释放了 std::cerr AcquireNextFrame failed. HRESULT: std::hex hr std::endl; return false; } // 2. 从IDXGIResource查询到ID3D11Texture2D hr dxgiResource.As(ppAcquiredTexture); if (FAILED(hr)) { pDuplication-ReleaseFrame(); // 记得释放帧 return false; } return true; }AcquireNextFrame会等待直到有新的桌面帧可用或者超时。成功获取后我们得到一个IDXGIResource需要将其转换为ID3D11Texture2D以便后续操作。非常重要的一点如果AcquireNextFrame成功你必须最终调用pDuplication-ReleaseFrame()来释放这一帧否则后续的AcquireNextFrame会失败。这个释放操作通常放在处理完纹理数据之后。4.4 将GPU纹理拷贝到CPU可读的内存我们获取到的ID3D11Texture2D通常位于GPU显存中CPU无法直接访问。为了保存为图片文件需要创建一个位于系统内存CPU可访问的“暂存纹理”并将GPU纹理的内容拷贝过去。bool CopyTextureToSystemMemory(ID3D11Device* pDevice, ID3D11DeviceContext* pContext, ID3D11Texture2D* pSourceTexture, ID3D11Texture2D** ppStagingTexture, D3D11_TEXTURE2D_DESC* pTexDesc) { HRESULT hr S_OK; // 1. 获取源纹理的描述信息宽度、高度、格式等 pSourceTexture-GetDesc(pTexDesc); // 2. 修改描述创建CPU可读的暂存纹理 D3D11_TEXTURE2D_DESC stagingDesc *pTexDesc; stagingDesc.BindFlags 0; // 不能绑定为渲染目标或着色器资源 stagingDesc.MiscFlags 0; stagingDesc.CPUAccessFlags D3D11_CPU_ACCESS_READ; // 关键CPU可读 stagingDesc.Usage D3D11_USAGE_STAGING; // 关键用途是暂存 // 3. 创建暂存纹理 hr pDevice-CreateTexture2D(stagingDesc, nullptr, ppStagingTexture); if (FAILED(hr)) return false; // 4. 将GPU纹理数据拷贝到暂存纹理 pContext-CopyResource(*ppStagingTexture, pSourceTexture); return true; }创建暂存纹理时D3D11_USAGE_STAGING和D3D11_CPU_ACCESS_READ这两个标志是必须的。拷贝操作CopyResource是异步的但在这个简单场景下我们假设它立即完成。对于更复杂的应用可能需要刷新命令队列或使用ID3D11DeviceContext::Map的同步机制。4.5 映射内存并保存为图片文件现在暂存纹理在系统内存中我们可以“映射”它获取一个指向像素数据的指针然后保存为文件。这里使用Windows Imaging Component (WIC)来保存为PNG格式它比手动写BMP文件头更方便也支持压缩。bool SaveTextureToPNG(ID3D11DeviceContext* pContext, ID3D11Texture2D* pStagingTexture, const D3D11_TEXTURE2D_DESC texDesc, const std::wstring filename) { HRESULT hr S_OK; D3D11_MAPPED_SUBRESOURCE mapped {0}; // 1. 映射暂存纹理获取CPU可读的指针 hr pContext-Map(pStagingTexture, 0, D3D11_MAP_READ, 0, mapped); if (FAILED(hr)) { std::cerr Failed to map staging texture. std::endl; return false; } // 2. 使用WIC保存图片 Microsoft::WRL::ComPtrIWICImagingFactory wicFactory; Microsoft::WRL::ComPtrIWICBitmapEncoder encoder; Microsoft::WRL::ComPtrIWICBitmapFrameEncode frame; Microsoft::WRL::ComPtrIWICStream stream; // 创建WIC工厂 hr CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(wicFactory)); if (FAILED(hr)) goto Cleanup; // 创建文件流 hr wicFactory-CreateStream(stream); if (FAILED(hr)) goto Cleanup; hr stream-InitializeFromFilename(filename.c_str(), GENERIC_WRITE); if (FAILED(hr)) goto Cleanup; // 创建PNG编码器 hr wicFactory-CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder); if (FAILED(hr)) goto Cleanup; hr encoder-Initialize(stream, WICBitmapEncoderNoCache); if (FAILED(hr)) goto Cleanup; // 创建新帧并设置参数 hr encoder-CreateNewFrame(frame, nullptr); if (FAILED(hr)) goto Cleanup; hr frame-Initialize(nullptr); if (FAILED(hr)) goto Cleanup; // 设置帧的宽度、高度、像素格式。DXGI_FORMAT_B8G8R8A8_UNORM 对应 GUID_WICPixelFormat32bppBGRA hr frame-SetSize(texDesc.Width, texDesc.Height); if (FAILED(hr)) goto Cleanup; GUID pixelFormat GUID_WICPixelFormat32bppBGRA; // 注意格式对应关系 hr frame-SetPixelFormat(pixelFormat); if (FAILED(hr)) goto Cleanup; // 3. 写入像素数据 // 计算步幅每行字节数。注意mapped.RowPitch是纹理的行间距可能大于宽度*4由于内存对齐 UINT stride texDesc.Width * 4; // 32bpp 4 bytes per pixel // 如果纹理的行间距与计算的一致可以直接写入 if (mapped.RowPitch stride) { hr frame-WritePixels(texDesc.Height, mapped.RowPitch, texDesc.Height * stride, static_castBYTE*(mapped.pData)); } else { // 行间距不一致需要逐行拷贝对齐 std::vectorBYTE rowData(stride); BYTE* pSrc static_castBYTE*(mapped.pData); for (UINT y 0; y texDesc.Height; y) { memcpy(rowData.data(), pSrc, stride); hr frame-WritePixels(1, stride, stride, rowData.data()); if (FAILED(hr)) break; pSrc mapped.RowPitch; // 移动到下一行按纹理行间距 } } if (FAILED(hr)) goto Cleanup; hr frame-Commit(); if (FAILED(hr)) goto Cleanup; hr encoder-Commit(); if (FAILED(hr)) goto Cleanup; Cleanup: // 4. 无论如何都要取消映射 pContext-Unmap(pStagingTexture, 0); if (FAILED(hr)) { std::cerr Failed to save PNG. HRESULT: std::hex hr std::endl; DeleteFile(filename.c_str()); // 尝试删除可能不完整的文件 return false; } std::wcout LScreenshot saved to: filename std::endl; return SUCCEEDED(hr); }这段代码有几个关键点Map和Unmap必须成对调用Unmap必须在释放任何资源前调用。像素格式桌面复制通常返回DXGI_FORMAT_B8G8R8A8_UNORM格式BGRA顺序每个通道8位。WIC对应的格式是GUID_WICPixelFormat32bppBGRA。行间距RowPitch纹理内存是对齐的mapped.RowPitch可能大于宽度 * 4。直接写入整个缓冲区会导致WIC读取到多余的填充数据图片会错位。因此需要判断并可能进行逐行拷贝。这是新手最容易忽略的坑会导致保存的图片出现斜向扭曲。4.6 主函数流程串联最后我们将上述所有步骤串联在main函数中。int main() { // 初始化COM单线程公寓模型STA HRESULT hr CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); if (FAILED(hr)) { std::cerr Failed to initialize COM. std::endl; return -1; } ID3D11Device* pDevice nullptr; ID3D11DeviceContext* pContext nullptr; IDXGIOutputDuplication* pDeskDupl nullptr; ID3D11Texture2D* pAcquiredTexture nullptr; ID3D11Texture2D* pStagingTexture nullptr; do { // 1. 初始化D3D if (!InitializeDirectX(pDevice, pContext)) break; // 2. 创建桌面复制器 if (!CreateDesktopDuplication(pDevice, pDeskDupl)) break; // 3. 捕获一帧 DXGI_OUTDUPL_FRAME_INFO frameInfo {0}; if (!AcquireDesktopFrame(pDeskDupl, pDevice, pAcquiredTexture, frameInfo)) { std::cout No new frame available or timeout. std::endl; break; } // 4. 拷贝到系统内存 D3D11_TEXTURE2D_DESC texDesc {0}; if (!CopyTextureToSystemMemory(pDevice, pContext, pAcquiredTexture, pStagingTexture, texDesc)) break; // 5. 保存为PNG std::wstring filePath Lscreenshot_dwm.png; if (!SaveTextureToPNG(pContext, pStagingTexture, texDesc, filePath)) break; std::cout Screenshot captured successfully via DWM/DXGI. std::endl; } while (false); // 只执行一次的“循环”方便用break统一清理 // 6. 至关重要的清理工作 if (pDeskDupl pAcquiredTexture) { // 必须在释放复制器接口前释放已获取的帧 pDeskDupl-ReleaseFrame(); } if (pStagingTexture) pStagingTexture-Release(); if (pAcquiredTexture) pAcquiredTexture-Release(); if (pDeskDupl) pDeskDupl-Release(); if (pContext) pContext-Release(); if (pDevice) pDevice-Release(); CoUninitialize(); return 0; }这个main函数清晰地展示了整个工作流。注意do...while(false)的用法它允许我们在任何一步失败时用break跳到统一的资源清理代码块避免重复的清理逻辑。5. 实战中的坑与进阶优化把代码跑通只是第一步。在实际项目中应用你会遇到更多问题。下面是我踩过的一些坑和对应的解决方案。5.1 多显示器与指定屏幕截图上面的例子只捕获了第一个显示器EnumOutputs(0)。现代工作站多显示器很常见。如何截图所有显示器或指定某个显示器解决方案遍历适配器的所有输出。std::vectorIDXGIOutput* GetAllOutputs(IDXGIAdapter* pAdapter) { std::vectorIDXGIOutput* outputs; IDXGIOutput* pOutput nullptr; for (int i 0; ; i) { HRESULT hr pAdapter-EnumOutputs(i, pOutput); if (hr DXGI_ERROR_NOT_FOUND) { // 没有更多输出了 break; } else if (SUCCEEDED(hr)) { outputs.push_back(pOutput); } } return outputs; }你可以让用户选择显示器或者为每个输出创建一个IDXGIOutputDuplication分别截图然后拼接或单独保存。5.2 性能考量与帧率控制AcquireNextFrame会等待新帧。如果你在一个循环中不断调用它来录屏它会尽可能快地获取帧这可能占用大量CPU/GPU。对于非实时性要求高的截图工具你可能需要主动控制频率。解决方案在循环中增加Sleep或使用高精度计时器。#include chrono #include thread //... auto frameInterval std::chrono::milliseconds(33); // 约30FPS auto nextFrameTime std::chrono::steady_clock::now(); while (!quit) { // ... 获取并处理帧 ... std::this_thread::sleep_until(nextFrameTime); nextFrameTime frameInterval; }更高级的做法是使用AcquireNextFrame的超时参数并将其作为主要的等待机制。5.3 错误处理与资源管理我们的示例代码错误处理比较基础。在生产环境中你需要更健壮的处理设备丢失DXGI_ERROR_DEVICE_REMOVED或DXGI_ERROR_DEVICE_RESET当显卡被拔掉、驱动更新或发生严重图形错误时会发生。处理方式是释放所有D3D和DXGI资源然后从头重新初始化。访问被拒绝如果桌面安全策略禁止如远程桌面连接时DuplicateOutput可能失败。需要有友好的用户提示。智能指针的全面使用示例中为了清晰展示了裸指针但强烈建议全部改用Microsoft::WRL::ComPtr这样即使发生异常资源也能自动释放避免泄漏。5.4 从全屏独占应用如游戏截图当某个应用如游戏以全屏独占模式运行时DWM桌面合成会被禁用。此时IDXGIOutputDuplication可能无法工作或抓不到正确内容。对于这种特殊情况需要另辟蹊径使用游戏提供的API如Steam Overlay、NVIDIA ShadowPlay/Ansel、AMD ReLive等。注入DLL与Hook高级技术通过注入到目标进程Hook其Present调用直接从其交换链获取图像。这涉及复杂的逆向工程和稳定性问题一般用于专业工具。回退到BitBlt对于全屏独占的非DXGI应用有时BitBlt反而能工作但这不可靠。一个折中的方案是先尝试DWM/DXGI路径如果失败例如获取到的纹理尺寸为0或全黑再优雅地回退到BitBlt或给出明确错误。6. 封装与实战一个简单的命令行截图工具将上面的代码模块化封装成一个类比如叫DwmScreenCapturer会大大提高复用性。这个类可以负责生命周期管理RAII提供Initialize(),CaptureFrame(),SaveToFile()等方法。这里分享一个我常用的技巧如何生成不重复的文件名。一个简单的命令行工具每次运行都覆盖screenshot.png并不友好。std::wstring GenerateTimestampFilename(const std::wstring prefix, const std::wstring extension) { auto now std::chrono::system_clock::now(); auto in_time_t std::chrono::system_clock::to_time_t(now); std::tm tm_buf; localtime_s(tm_buf, in_time_t); // 使用安全的localtime_s std::wstringstream wss; wss prefix std::put_time(tm_buf, L%Y%m%d_%H%M%S) extension; return wss.str(); } // 使用 std::wstring fileName GenerateTimestampFilename(Lscreenshot_, L.png);这样会生成像screenshot_20231027_143022.png这样的文件名。最终你可以编译这个程序得到一个简单的命令行工具。运行它就能在当前目录下生成一张精准的、反映当前桌面真实状态的PNG截图。相比于网上很多基于GDI的截图代码这个方案在面对复杂桌面环境时显得无比可靠。整个实现过程涉及了Windows图形系统的核心组件从应用层对DWM的认知到通过DXGI与图形驱动交互再到使用Direct3D操作纹理最后利用WIC编码成文件。它不仅仅是一个截图功能更是一个理解现代Windows桌面图形栈的绝佳实践。
返回列表