
1. 项目概述为什么选择DXGI来“录屏”最近在做一个需要实时捕获桌面画面的项目从截图到录屏市面上工具很多但要么功能臃肿要么性能达不到要求要么就是无法满足二次开发的定制化需求。折腾了一圈最终还是决定自己动手用C从底层实现一个高性能的屏幕录制工具。在技术选型上DirectX Graphics Infrastructure也就是DXGI成为了我的核心方案。这玩意儿不是凭空冒出来的它其实是微软DirectX家族中负责管理图形适配器、显示输出和桌面复制的一个底层接口层。简单来说它提供了一套标准化的方法让应用程序能够直接和显卡、显示器“对话”高效地获取桌面图像数据。选择DXGI而不是传统的GDIGraphics Device Interface或者一些第三方库核心原因就两个字效率。GDI是Windows的老牌图形接口稳定但慢尤其是在高分辨率、高刷新率屏幕普及的今天用GDI抓取桌面CPU占用率会飙升画面延迟也大做实时流媒体或游戏录制基本不可行。DXGI则不同它绕过了繁琐的软件渲染路径通过显卡的硬件加速能力直接访问帧缓冲区Frame Buffer实现了近乎零开销的桌面图像捕获。这对于需要低延迟、高帧率录制的场景比如游戏直播、远程桌面、性能监控、自动化测试等是至关重要的。这个工具的目标很明确开发一个轻量级、高性能、可编程的C屏幕录制库。它不追求成为一个功能齐全的OBS Studio而是专注于提供稳定、高效的原始帧数据捕获能力把获取到的图像数据通常是BGRA格式的纹理通过回调函数交给上层应用去处理——你可以编码成视频文件推流到服务器或者进行实时的图像分析。接下来我会把整个开发过程中的核心思路、关键代码、踩过的坑以及性能调优的经验毫无保留地拆解一遍。2. 核心思路与架构设计2.1 技术栈选型与权衡决定用DXGI之后首先要明确具体用它的哪一部分功能。DXGI的桌面复制APIDesktop Duplication API是专门为此场景设计的它首次出现在Windows 8/Server 2012上并在后续版本中不断增强。这套API允许应用程序直接访问操作系统桌面管理器DWM合成后的最终输出图像也就是你实际在屏幕上看到的内容。这意味着它能正确捕获包括硬件加速内容如游戏、视频在内的所有窗口不会因为Aero效果或全屏应用而失效。整个技术栈的核心是Direct3D 11作为与GPU通信的基础。我们需要创建一个D3D11设备Device和设备上下文Device Context用于管理纹理和资源。DXGI Desktop Duplication API核心中的核心通过IDXGIOutputDuplication接口来执行捕获操作。Windows API用于辅助功能如枚举显示器、处理窗口消息、管理线程等。为什么不选更新的Direct3D 12虽然D3D12能提供更底层的控制和潜在的性能优势但其复杂度也呈指数级增长。对于桌面复制这个相对固定的任务D3D11的API更加成熟、稳定文档和社区资源也更丰富开发效率高得多。我们的目标是快速构建一个稳定可用的工具而不是挑战图形API的极限。架构上我设计了一个简单的生产者-消费者模型生产者线程负责循环调用DXGI桌面复制API捕获最新的桌面帧。这个线程需要高优先级并尽可能减少阻塞以保证帧率的稳定。消费者逻辑在捕获到一帧数据后立即通过回调函数或接口将数据抛给应用层。消费者可能是另一个线程负责视频编码也可能是主线程进行实时预览。生产者和消费者之间通过帧缓冲区或直接传递纹理指针来通信避免内存拷贝带来的性能损耗。2.2 关键对象与数据流解析理解DXGI桌面复制的工作流程需要先搞清楚几个关键对象IDXGIFactory1工厂对象用于枚举图形适配器显卡和输出显示器。IDXGIAdapter1代表一个物理或虚拟的图形适配器。IDXGIOutput1代表一个显示输出即一台显示器。我们需要从它获取IDXGIOutputDuplication接口。IDXGIOutputDuplication核心的桌面复制接口。通过它的AcquireNextFrame方法获取一帧桌面图像。ID3D11Texture2DD3D11的2D纹理对象桌面图像数据就存储在这里。从AcquireNextFrame获取到的就是一个纹理的指针。一次完整的捕获数据流大致如下初始化创建D3D11设备 - 获取DXGI适配器 - 枚举DXGI输出 - 为选定的输出创建IDXGIOutputDuplication对象。捕获循环 a. 调用AcquireNextFrame。这个函数会等待直到桌面有新的更新或超时然后返回一个代表新帧的纹理指针以及该帧的元数据如脏矩形、移动矩形信息。 b. 从返回的纹理中将图像数据“映射”Map到CPU可访问的系统内存中。这一步是关键因为GPU纹理默认在显存中CPU不能直接读。 c. 将映射出来的内存数据一个字节数组复制或传递给我们的消费者。 d. 调用ReleaseFrame告知DXGI这一帧我们已经处理完了可以释放相关资源。清理退出时按顺序释放所有COM接口对象。这里有一个非常重要的细节AcquireNextFrame是阻塞式的。它会等待直到有新的帧可用或者超过指定的超时时间。这意味着我们的捕获线程大部分时间在休眠不会无谓地消耗CPU。这是DXGI高性能的秘诀之一——按需取帧而非轮询。3. 核心实现与代码拆解3.1 环境准备与初始化首先我们需要配置开发环境。使用Visual Studio创建一个C控制台或动态链接库项目。在项目属性中确保链接了必要的库d3d11.lib,dxgi.lib,user32.lib。因为涉及COM组件代码中需要包含相应的头文件windows.h,d3d11.h,dxgi1_2.hDesktop Duplication API需要DXGI 1.2或更高版本。初始化的第一步是创建Direct3D 11设备。这里有个小技巧为了兼容性我们通常创建支持D3D_DRIVER_TYPE_HARDWARE硬件驱动的设备但如果某些系统比如没有独立显卡的虚拟机不支持可以回退到D3D_DRIVER_TYPE_WARP软件渲染模式。HRESULT hr S_OK; ID3D11Device* d3dDevice nullptr; ID3D11DeviceContext* d3dContext nullptr; D3D_FEATURE_LEVEL featureLevel; // 尝试创建硬件设备 hr D3D11CreateDevice( nullptr, // 默认适配器 D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, // 无特殊标志 nullptr, // 默认特性等级 0, D3D11_SDK_VERSION, d3dDevice, featureLevel, d3dContext ); if (FAILED(hr)) { // 硬件模式失败尝试WARP软件渲染器兼容性更好 hr D3D11CreateDevice( nullptr, D3D_DRIVER_TYPE_WARP, nullptr, 0, nullptr, 0, D3D11_SDK_VERSION, d3dDevice, featureLevel, d3dContext ); } if (FAILED(hr)) { // 初始化失败处理错误 return false; }设备创建成功后我们需要通过它来获取DXGI接口进而枚举显示器。这里涉及到COM的QueryInterface调用。IDXGIDevice* dxgiDevice nullptr; hr d3dDevice-QueryInterface(__uuidof(IDXGIDevice), (void**)dxgiDevice); if (SUCCEEDED(hr)) { IDXGIAdapter* dxgiAdapter nullptr; hr dxgiDevice-GetAdapter(dxgiAdapter); dxgiDevice-Release(); // 记得释放临时对象 if (SUCCEEDED(hr)) { IDXGIFactory1* dxgiFactory nullptr; hr dxgiAdapter-GetParent(__uuidof(IDXGIFactory1), (void**)dxgiFactory); dxgiAdapter-Release(); if (SUCCEEDED(hr)) { // 现在可以使用dxgiFactory来枚举输出了 // ... 枚举代码见下文 dxgiFactory-Release(); } } }3.2 枚举显示器与创建复制接口一台电脑可能连接了多个显示器我们的工具需要能选择捕获哪一个。通过IDXGIFactory1::EnumAdapters和IDXGIAdapter::EnumOutputs可以遍历所有显示输出。// 假设我们选择捕获第一个显示器索引0 int outputIndex 0; IDXGIOutput* dxgiOutput nullptr; hr dxgiAdapter-EnumOutputs(outputIndex, dxgiOutput); if (FAILED(hr) || dxgiOutput nullptr) { // 指定的显示器不存在 return false; } // 我们需要IDXGIOutput1接口来创建复制对象 IDXGIOutput1* dxgiOutput1 nullptr; hr dxgiOutput-QueryInterface(__uuidof(IDXGIOutput1), (void**)dxgiOutput1); dxgiOutput-Release(); // 释放基础输出接口 if (SUCCEEDED(hr)) { IDXGIOutputDuplication* dxgiOutputDuplication nullptr; // 关键调用创建桌面复制接口 hr dxgiOutput1-DuplicateOutput(d3dDevice, dxgiOutputDuplication); dxgiOutput1-Release(); if (SUCCEEDED(hr)) { // 成功dxgiOutputDuplication 就是我们核心的捕获对象 // 需要保存起来用于后续的捕获循环 } else if (hr E_ACCESSDENIED) { // 这是一个非常常见的错误意味着当前会话没有桌面复制权限。 // 通常发生在远程桌面RDP会话、某些安全软件拦截或者程序没有以足够权限运行的情况下。 // 需要给用户明确的错误提示。 } }注意DuplicateOutput调用返回E_ACCESSDENIED是最常见的“坑”之一。在Windows上桌面复制被视为一个敏感操作。确保你的应用程序在具有桌面访问权限的会话中运行通常是本地控制台会话并且如果是在服务或后台程序中调用需要特殊的权限配置。3.3 捕获循环与帧数据处理拥有了IDXGIOutputDuplication对象后就可以进入核心的捕获循环了。这个循环通常运行在一个独立的线程中。// 假设我们已经有了dxgiOutputDuplication, d3dDevice, d3dContext DXGI_OUTDUPL_FRAME_INFO frameInfo; IDXGIResource* dxgiResource nullptr; // 设置一个合理的超时时间例如33ms对应约30FPS const int timeoutMs 33; while (!stopCaptureThread) { // 1. 获取下一帧 HRESULT hr dxgiOutputDuplication-AcquireNextFrame(timeoutMs, frameInfo, dxgiResource); if (hr DXGI_ERROR_WAIT_TIMEOUT) { // 超时说明在timeoutMs内没有新帧继续循环等待 continue; } else if (FAILED(hr)) { // 其他错误如DXGI_ERROR_ACCESS_LOST显示器分辨率改变、休眠等 // 需要重建整个复制接口 HandleCaptureError(hr); break; } // 2. 获取纹理对象 ID3D11Texture2D* acquiredTexture nullptr; hr dxgiResource-QueryInterface(__uuidof(ID3D11Texture2D), (void**)acquiredTexture); dxgiResource-Release(); // 及时释放资源接口 if (FAILED(hr)) { dxgiOutputDuplication-ReleaseFrame(); continue; } // 3. 检查帧是否有实际内容更新 // frameInfo.LastPresentTime 和 frameInfo.AccumulatedFrames 可以帮助判断 if (frameInfo.LastPresentTime.QuadPart 0 frameInfo.AccumulatedFrames 0) { // 这可能是一帧没有实际图像更新的元数据帧可以跳过处理 acquiredTexture-Release(); dxgiOutputDuplication-ReleaseFrame(); continue; } // 4. 处理纹理数据例如复制到CPU内存 ProcessCapturedTexture(acquiredTexture, frameInfo); // 5. 释放资源 acquiredTexture-Release(); dxgiOutputDuplication-ReleaseFrame(); // **必须调用**否则后续AcquireNextFrame会失败 }ProcessCapturedTexture函数是业务逻辑的入口。这里我们需要将GPU纹理数据读到CPU内存。一个高效的做法是创建一个与捕获纹理格式兼容的“暂存纹理”Staging Texture然后使用ID3D11DeviceContext::CopyResource将数据从GPU拷贝到这块CPU可访问的内存中最后再“映射”它。void ProcessCapturedTexture(ID3D11Texture2D* acquiredTexture, const DXGI_OUTDUPL_FRAME_INFO frameInfo) { // 获取捕获纹理的描述 D3D11_TEXTURE2D_DESC acquiredDesc; acquiredTexture-GetDesc(acquiredDesc); // 检查并创建匹配的暂存纹理只在第一次或纹理尺寸改变时创建 static ID3D11Texture2D* stagingTexture nullptr; static UINT lastWidth 0, lastHeight 0; if (!stagingTexture || lastWidth ! acquiredDesc.Width || lastHeight ! acquiredDesc.Height) { if (stagingTexture) stagingTexture-Release(); lastWidth acquiredDesc.Width; lastHeight acquiredDesc.Height; D3D11_TEXTURE2D_DESC stagingDesc {}; stagingDesc.Width acquiredDesc.Width; stagingDesc.Height acquiredDesc.Height; stagingDesc.MipLevels 1; stagingDesc.ArraySize 1; stagingDesc.Format acquiredDesc.Format; // 通常是DXGI_FORMAT_B8G8R8A8_UNORM stagingDesc.SampleDesc.Count 1; stagingDesc.Usage D3D11_USAGE_STAGING; // 关键CPU可读 stagingDesc.CPUAccessFlags D3D11_CPU_ACCESS_READ; stagingDesc.MiscFlags 0; d3dDevice-CreateTexture2D(stagingDesc, nullptr, stagingTexture); } // 将GPU纹理拷贝到CPU可读的暂存纹理 d3dContext-CopyResource(stagingTexture, acquiredTexture); // 映射暂存纹理获取CPU指针 D3D11_MAPPED_SUBRESOURCE mappedResource; HRESULT hr d3dContext-Map(stagingTexture, 0, D3D11_MAP_READ, 0, mappedResource); if (SUCCEEDED(hr)) { // mappedResource.pData 就是指向图像数据的指针 // mappedResource.RowPitch 是每一行数据的字节数可能包含填充字节 unsigned char* imageData static_castunsigned char*(mappedResource.pData); int width acquiredDesc.Width; int height acquiredDesc.Height; size_t rowPitch mappedResource.RowPitch; // 注意可能不等于 width * 4 // **重要**这里的数据布局是BGRA且RowPitch可能包含填充。 // 如果你需要连续的RGB数据必须进行逐行拷贝跳过填充部分。 // 例如将数据拷贝到自定义缓冲区 // for (int y 0; y height; y) { // memcpy(destBuffer y * width * 4, imageData y * rowPitch, width * 4); // } // 调用用户回调传递图像数据、宽高、步长等信息 if (userFrameCallback) { userFrameCallback(imageData, width, height, rowPitch, PixelFormat::BGRA); } d3dContext-Unmap(stagingTexture, 0); } }实操心得RowPitch行间距是新手最容易忽略的坑。由于内存对齐的要求纹理每一行数据在内存中的长度RowPitch可能大于宽度 * 每像素字节数。如果你直接把pData当成一个连续的width*height*4的数组来处理图像会错乱。必须使用RowPitch来计算行偏移量。4. 性能优化与高级特性4.1 脏矩形与移动矩形智能更新DXGI桌面复制API一个强大的特性是它能提供帧的元数据主要是脏矩形Dirty Rects和移动矩形Move Rects。frameInfo结构体中包含了这两个信息。脏矩形一个矩形数组表示自上一帧以来屏幕上哪些区域的内容发生了改变。移动矩形一个矩形数组表示屏幕上某些矩形区域的内容被移动到了新位置比如拖动窗口。它包含源矩形和目标矩形。利用这些信息我们可以实现“增量更新”而不是每一帧都处理整个屏幕。这对于网络流传输如远程桌面或视频编码某些编码器支持区域更新是巨大的性能优化。例如如果只有屏幕的一小部分在变化如鼠标移动、输入光标闪烁我们只需要编码和发送这一小块区域的数据带宽和CPU占用会大幅降低。处理逻辑如下if (frameInfo.TotalMetadataBufferSize 0) { // 1. 获取脏矩形 UINT dirtyRectsCount 0; RECT* dirtyRects nullptr; if (frameInfo.TotalMetadataBufferSize sizeof(RECT)) { // 需要预先分配足够大的缓冲区 hr dxgiOutputDuplication-GetFrameDirtyRects(frameInfo.TotalMetadataBufferSize, dirtyRectsBuffer, dirtyRectsCount); if (SUCCEEDED(hr)) { dirtyRects (RECT*)dirtyRectsBuffer; // 处理脏矩形... } } // 2. 获取移动矩形 UINT moveRectsCount 0; DXGI_OUTDUPL_MOVE_RECT* moveRects nullptr; // ... 类似地调用GetFrameMoveRects }在实际项目中我通常会维护一个“累积脏区域”的机制。将连续多帧的脏矩形合并成一个更大的区域避免因频繁更新微小区域而产生过多开销。同时对于移动矩形需要先处理移动操作将源区域数据拷贝到目标区域再处理脏矩形更新顺序不能错。4.2 多显示器捕获与帧率控制捕获多个显示器有两种策略独立捕获为每个显示器创建独立的IDXGIOutputDuplication对象和捕获线程。这种方式灵活可以为每个显示器设置不同的帧率或处理逻辑但线程管理和资源占用较多。虚拟显示器捕获如果系统设置了多显示器扩展模式可以创建一个覆盖所有显示器的虚拟大桌面来捕获。但这种方式获取的是拼接后的单一纹理需要自己根据显示器布局进行分割。帧率控制主要依赖于AcquireNextFrame的超时参数。如果你设置timeoutMs为100那么理论上最大帧率就是10 FPS1000ms / 100ms。但这不是精确的帧率控制因为AcquireNextFrame只在有桌面更新时才返回。如果桌面静止你可能很长时间都拿不到新帧。对于需要固定帧率输出的场景如录制视频你需要一个独立的计时器。当捕获线程拿到新帧时更新一个共享的纹理资源而编码线程则按照固定时间间隔如33ms一帧去读取这个最新的纹理如果纹理没有更新就重复编码上一帧或编码一个表示“无变化”的帧。4.3 内存与资源管理陷阱COM对象管理是C/DX开发的老大难问题。必须牢记引用计数规则确保每个AddRef都有对应的Release。我的经验是对于函数内创建的临时COM对象如通过QueryInterface获得在函数退出前释放。对于作为类成员的长生命周期对象如d3dDevice,dxgiOutputDuplication在类的析构函数中集中释放。使用ComPtrWindows Runtime C Template Library中的智能指针可以极大减轻管理负担让代码更安全。例如Microsoft::WRL::ComPtrID3D11Texture2D texture;另一个内存陷阱是纹理资源泄露。AcquireNextFrame和ReleaseFrame必须成对调用。如果AcquireNextFrame成功但后续因为错误跳过了ReleaseFrame这个复制接口很快就会进入错误状态DXGI_ERROR_ACCESS_LOST必须重建。因此错误处理路径上一定要确保资源被正确释放。5. 常见问题排查与实战调试5.1 错误代码与应对策略开发过程中你会频繁遇到各种HRESULT错误。以下是一些最常见的错误码 (HRESULT)含义可能原因与解决方案E_ACCESSDENIED访问被拒绝。1. 程序在非控制台会话中运行如服务、某些远程会话。2. 安全软件杀毒、屏幕保护阻止。解决确保程序在交互式桌面会话中运行。对于服务需启用“允许服务与桌面交互”并处理会话隔离问题。DXGI_ERROR_ACCESS_LOST对输出复制接口的访问已丢失。1. 显示模式改变分辨率、旋转、多显示器配置变更。2. 全屏应用切换如游戏AltTab。3. 睡眠/唤醒。解决这是预期内的错误。捕获循环中检测到此错误后应释放所有相关资源然后重新执行初始化流程创建设备、枚举输出、复制接口。DXGI_ERROR_WAIT_TIMEOUT等待新帧超时。AcquireNextFrame在指定时间内未检测到桌面更新。解决这不是错误是正常情况。循环应继续调用AcquireNextFrame。可以借此机会让出CPU时间片Sleep(1)。DXGI_ERROR_INVALID_CALL无效的API调用。通常是因为调用顺序错误比如未ReleaseFrame就再次调用AcquireNextFrame或对已释放的接口进行操作。解决仔细检查代码逻辑确保API调用符合规范。E_INVALIDARG参数错误。传递给API的参数无效比如创建纹理时格式不对。解决检查函数调用时的参数值。5.2 图像异常问题诊断即使代码能跑起来拿到的图像也可能有问题。这里有个排查清单花屏/错位首要怀疑对象RowPitch处理错误。确保在拷贝或处理数据时使用正确的行间距而不是简单地width * 4。检查纹理格式确认acquiredDesc.Format。桌面复制通常是DXGI_FORMAT_B8G8R8A8_UNORMBGRA。如果你的处理代码假设是RGBA颜色通道就会错乱。检查数据对齐某些图像处理库要求数据按特定字节对齐如4字节、16字节。如果pData指针不满足对齐要求可能会导致处理异常。黑屏/无图像检查frameInfo的LastPresentTime和AccumulatedFrames。如果都是0可能捕获到的是一帧没有图像数据的元数据包例如只有光标形状更新。这种情况应该跳过纹理处理。检查程序是否运行在正确的桌面会话。可以尝试输出当前进程的会话ID (WTSGetActiveConsoleSessionId) 并与控制台会话ID对比。以管理员身份运行程序试试虽然不总是必须但有时能解决权限问题。性能低下使用性能分析工具如Visual Studio Profiler查看热点。瓶颈通常出现在内存拷贝memcpy和纹理映射Map/Unmap上。优化建议避免在捕获循环内进行昂贵的操作如文件写入、复杂的图像处理。尽快将数据传递出去。考虑使用双缓冲或环形缓冲区让捕获线程和编码/处理线程解耦。如果不需要Alpha通道可以尝试在拷贝后将其填充为固定值0xFF或者使用更紧凑的像素格式如NV12进行转换但这会增加CPU开销需要权衡。5.3 调试技巧与工具启用DXGI调试层在创建D3D11设备时将D3D11_CREATE_DEVICE_FLAG设置为包含D3D11_CREATE_DEVICE_DEBUG。这会在输出窗口打印详细的DXGI/D3D11错误和警告信息对于定位资源泄露和API误用极其有用。使用spy或Process Explorer查看你的进程是否在正确的桌面WinSta0\Default上运行。手动检查HRESULT不要忽略任何API调用的返回值。使用SUCCEEDED()或FAILED()宏判断并对所有失败情况进行日志记录或处理。简化测试先做一个最简单的程序只捕获一帧将纹理保存为位图文件.bmp。验证从捕获到保存的整个链路是否正确再逐步增加循环、多线程等复杂逻辑。开发这个工具的过程是一个不断与Windows图形子系统底层细节打交道的过程。从最初的权限困惑到RowPitch导致的图像错乱再到处理显示器热插拔带来的ACCESS_LOST每一个坑都加深了对DXGI和桌面图形管线的理解。最终当你看到一个高效、稳定的帧数据流从你的代码中涌出时那种成就感是使用现成库无法比拟的。这套核心逻辑不仅可以用于录屏稍加改造就能成为游戏数据提取、自动化UI测试、甚至自定义桌面混合现实应用的坚实基础。