C# 通过共享内存与 C++ 宿主协同捕获软件窗口

项目地址在:https://github.com/transient-fool/CaptureHost.git

最近在做上位机的时候,碰到客户提出这么一个要求,就是说我们的软件全屏展示之后,还需要一个窗口来展示另外一个软件的界面。因为我们的软件是由很多的窗口出现的,利用的是C# 的winForm里面的去统一管理不同的控件,也就是说所有的控件最终都是嵌入到Panel中,这个小视频窗口也是。询问ai给了集中方式,一开始是采用了简单的win32原生的api:PrintWindow 和 BitBlt。

cs 复制代码
[DllImport("user32.dll")]
private static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, int nFlags);

[DllImport("gdi32.dll")]
private static extern bool BitBlt(IntPtr hdcDest, int xDest, int yDest, int width, int height,IntPtr hdcSrc, int xSrc, int ySrc, int rop);

但是这两者捕获的软件窗口有个很大的问题,就是一些用gpu渲染的窗口没法捕获,比如像kiro这种现代的桌面端,走的是gpu合成路线,这个时候用这两种方式捕获指挥展示黑屏。

可以看到右侧捕获的vs的界面没有问题,但是左侧的Kiro无法捕获,cursor也是同样的道理。可见这种方式有很大的局限性。后来又提供了一种新的方法,就是win10以上电脑版本提供的一种新的api;Windows.Graphics.Capture。可以捕获gpu合成的软件界面。但是由于我的.NET版本太低了,还是4的版本号(谁知道为啥公司用这么Low版本的.NET),与这个新的api很难交互,所以就采取了新的方式,就是用C++调用api去捕获软件窗口并且把帧放到共享内存,然后由C#读取这块共享内存并且展示。

windows电脑中使用句柄来**唯一标识和管理各种系统资源的值。**我们要捕获软件的窗口,就要通过软件的窗口句柄来获取窗口。这里可以用VS自带的窗口工具Spy++来查看窗口句柄。

但是我们要捕获的肯定是软件最外层的窗口,而不是内部的窗口,这里展示的就是内部窗口。我们需要用代码获取到窗口的顶层窗口:

cs 复制代码
static HWND ResolveTopLevelWindow(HWND hwnd)
{
    if (!hwnd)
    {
        return nullptr;
    }

    HWND root = GetAncestor(hwnd, GA_ROOT);
    if (root && IsWindow(root))
    {
        return root;
    }

    return hwnd;
}

这里的Spy++也可以获取到最顶层的窗口:

可以看出父子两个窗口的句柄并不一样,我们的Panel需要展示最顶层的窗口,因此对于传进来的窗口全部用代码处理,以获得最顶层窗口的句柄。欧克啊,现在已经拿到软件窗口的句柄可以进行窗口帧的捕获了,但是这里有个问题,句柄只是帮我们找到个这个窗口,但是windows提供的api是没法直接根据句柄来捕获窗口帧的,所以在这里要做一层桥接,把句柄包装为可以被api识别的对象:

cpp 复制代码
static winrt::Windows::Graphics::Capture::GraphicsCaptureItem
CreateItemForWindow(HWND hwnd)
{
    std::wcout << L"Creating capture item for window. hwnd = " << hwnd << L"\n";
    PrintWindowInfo(hwnd, L"CreateItemForWindow target");

    auto interopFactory =
        winrt::get_activation_factory<
        winrt::Windows::Graphics::Capture::GraphicsCaptureItem,
        IGraphicsCaptureItemInterop>();

    winrt::Windows::Graphics::Capture::GraphicsCaptureItem item{ nullptr };

    HRESULT hr = interopFactory->CreateForWindow(
        hwnd,
        winrt::guid_of<winrt::Windows::Graphics::Capture::GraphicsCaptureItem>(),
        winrt::put_abi(item));

    std::wcout << L"IGraphicsCaptureItemInterop::CreateForWindow hr = "
        << HResultToHex(hr) << L"\n";

    if (FAILED(hr))
    {
        std::wcout << L"CreateForWindow message: " << HResultToMessage(hr) << L"\n";
        check_hresult(hr);
    }

    std::wcout << L"CreateForWindow succeeded.\n";
    return item;
}

重点就两个,一个是获取激活工厂,这个工厂的类型是 IGraphicsCaptureItemInterop。这个接口不是一个标准的 WinRT 类型,而是一个COM 互操作接口 。它提供了一些底层的、非类型安全的 API,用于从传统的 Win32 句柄(如 HWND)创建对应的 WinRT 对象。interopFactory->CreateForWindow(...) : 这才是真正执行创建操作的方法。winrt::guid_of<T>() 是一个 C++/WinRT 提供的模板函数,它会返回类型 T 所对应的 GUID 值。在这里用来告诉这个工厂我需要创建啥对象。winrt::put_abi(obj) 是一个 C++/WinRT 提供的函数,它返回一个指向对象内部 ABI (Application Binary Interface) 指针的指针。ABI 是 C++/WinRT 对象在底层(即原始 COM 层面)的表示方式,它就是一个普通的 IUnknown* 或派生接口指针,这里是修改内部的指针的指针。

下面有个这个要捕捉的窗口对象了,那还得有摄像头取景器啊,这里Windows.Graphics.Capture采用的是WinRT 的 IDirect3DDevice。但是我们只有现成的创建创建原生 D3D11 设备的函数:

cpp 复制代码
   D3D_FEATURE_LEVEL levels[] =
        {
            D3D_FEATURE_LEVEL_11_1,
            D3D_FEATURE_LEVEL_11_0,
            D3D_FEATURE_LEVEL_10_1,
            D3D_FEATURE_LEVEL_10_0
        };

        D3D_FEATURE_LEVEL levelOut{};
        HRESULT hr = D3D11CreateDevice(
            nullptr,
            D3D_DRIVER_TYPE_HARDWARE,
            nullptr,
            flags,
            levels,
            ARRAYSIZE(levels),
            D3D11_SDK_VERSION,
            d3dDevice.put(),
            &levelOut,
            d3dContext.put());

简单介绍一下Direct3D 11 (D3D11): 是 微软Windows 生态系统打造的"官方御用"图形 API,与 Windows 紧密集成,功能强大,简单来说我们可以用这个来操作底层的显卡。我们先是定义了一个特权级,D3D_FEATURE_LEVEL 描述了 D3D11 API 的不同功能级别。级别越高,支持的 GPU 功能就越强大。这里的D3D11创建函数函数又是很多参数,让我们一一解释:

参数名 (Parameter Name) 参数含义 (Meaning)
pAdapter 指定要使用的 GPU。传入 nullptr 表示让系统自动选择默认的图形适配器(通常是性能最强的那个)。
DriverType 指定驱动程序的类型。D3D_DRIVER_TYPE_HARDWARE 表示使用真正的物理显卡来加速。其他选项还有 WARP(一种高质量的软件渲染器)或 REFERENCE(参考光栅化器,仅用于调试)。
Software 如果 DriverType 设置为 D3D_DRIVER_TYPE_SOFTWARE,则在此处指定软件光栅化器的句柄。在这里不使用,所以是 nullptr
Flags 这是一个由程序员预先定义的变量,用于启用额外的设备创建选项。最常见的用途是启用调试层(Debug Layer),在开发时可以输出详细的错误和性能警告信息。发布版程序通常为 0。
pFeatureLevels 传入上面定义的特性等级列表 (levels 数组) 的首地址。
FeatureLevels 传入特性等级列表的元素个数。ARRAYSIZE(levels) 是一个宏,用于计算数组长度。
SDKVersion 传入一个常量 (D3D11_SDK_VERSION),用于验证头文件和 DLL 版本是否兼容。
ppDevice 输出参数。d3dDevice 很可能是一个 winrt::com_ptr<ID3D11Device> 智能指针。.put() 方法会返回一个指针,以便 D3D11CreateDevice 函数可以将新创建的设备对象指针填入其中。这个设备是创建其他 D3D11 资源(如纹理、着色器等)的工厂。
pFeatureLevel 输出参数。将 levelOut 的地址传入,函数会把实际创建成功的特性等级写入这个变量。
ppImmediateContext 输出参数。d3dContext 很可能是一个 winrt::com_ptr<ID3D11DeviceContext> 智能指针。D3D11CreateDevice 会将新创建的立即上下文(Immediate Context)指针填入。这个上下文是你命令 GPU 执行绘图、设置渲染状态等操作的地方,几乎所有的绘制调用都要通过它来完成。

但是winrt并不能直接接收这个IDirect3DDevice,这里又得作一层桥接(所以我觉得win的api的转换麻烦又严谨)。具体代码如下:

cpp 复制代码
 winrtDevice = CreateDirect3DDeviceFromD3D11Device(d3dDevice.get());



static winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice
CreateDirect3DDeviceFromD3D11Device(ID3D11Device* d3dDevice)
{
    std::wcout << L"Creating WinRT Direct3D device from D3D11 device...\n";

    com_ptr<IDXGIDevice> dxgiDevice;
    HRESULT hr = d3dDevice->QueryInterface(__uuidof(IDXGIDevice), dxgiDevice.put_void());
    std::wcout << L"QueryInterface(IDXGIDevice) hr = " << HResultToHex(hr) << L"\n";
    check_hresult(hr);

    com_ptr<::IInspectable> inspectable;
    hr = CreateDirect3D11DeviceFromDXGIDevice(
        dxgiDevice.get(),
        reinterpret_cast<::IInspectable**>(inspectable.put()));
    std::wcout << L"CreateDirect3D11DeviceFromDXGIDevice hr = " << HResultToHex(hr) << L"\n";
    check_hresult(hr);

    return inspectable.as<winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice>();
}

这段代码是一个典型的 WinRT 与 DirectX 之间进行互操作 (Interoperability / Interop) 的例子。它的核心目的就是:将一个底层的、原生的 ID3D11Device 对象,转换/包装成一个高层的、现代化的 WinRT 接口 IDirect3D11Device 对象。

cpp 复制代码
com_ptr<IDXGIDevice> dxgiDevice;
HRESULT hr = d3dDevice->QueryInterface(__uuidof(IDXGIDevice), dxgiDevice.put_void());
  • com_ptr<IDXGIDevice> dxgiDevice;: 创建一个智能指针,用于安全地持有 IDXGIDevice 接口。
  • d3D11Device->QueryInterface(...): 这是 COM (Component Object Model) 对象的基本方法。它询问这个 ID3D11Device 实例:"你是否也实现了 IDXGIDevice 这个接口?"
  • __uuidof(IDXGIDevice): 传入 IDXGIDevice 接口的唯一 ID (GUID)。
  • dxgiDevice.put_void(): put_void() 用于获取 dxgiDevice 内部原始指针的地址(void**),让 QueryInterface 可以将转型后的指针写入其中。
  • 结果 :如果成功,dxgiDevice 智能指针现在就持有了与 d3dDevice 相关联的 IDXGIDevice 接口。
cpp 复制代码
com_ptr<::IInspectable> inspectable;
hr = CreateDirect3D11DeviceFromDXGIDevice(
    dxgiDevice.get(),
    reinterpret_cast<::IInspectable**>(inspectable.put()));
  • com_ptr<::IInspectable> inspectable;: 创建一个指向 IInspectable 接口的智能指针。IInspectable 是所有 WinRT 对象的根接口,类似于 .NET 中的 Object
  • CreateDirect3D11DeviceFromDXGIDevice: 这是一个 Windows Runtime 提供的函数,它接收一个底层的 DXGI 设备 (IDXGIDevice),并创建一个对应的、可以在 WinRT 中使用的 Direct3D 设备。
  • dxgiDevice.get(): 传入上一步获取的 IDXGIDevice 接口指针。
  • reinterpret_cast<::IInspectable**>(inspectable.put()): 这是一个比较复杂的类型转换。inspectable.put() 返回一个指向其内部原始指针 (IInspectable*) 的指针 (IInspectable**)。由于命名空间的原因(:: 表示全局命名空间),这里需要强制转换,确保类型匹配。CreateDirect3D11DeviceFromDXGIDevice 会将新创建的 WinRT 对象指针放入 inspectable 中。
  • 结果inspectable 现在持有一个 WinRT 对象,这个对象内部封装了原始的 ID3D11Device
cpp 复制代码
return inspectable.as<winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3D11Device>();
  • inspectable.as<...>(): 这是 winrt::com_ptr 的一个方法,它执行一个安全的接口转换(类似 QueryInterface)。它告诉系统:"我知道 inspectable 持有的对象实际上实现了 IDirect3D11Device 接口,请给我一个该接口的强类型实例。"
  • 返回值 :函数最终返回一个类型为 winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3D11Device 的对象,这个对象可以在 C++/WinRT 编写的 UWP 应用或其他 WinRT 组件中无缝使用。

ok啊,现在要获取的窗口句柄有了,用来获取的工具有了,下面得讲解内存共享和数据帧格式的内容了。在共享内存中,数据的格式是很重要的,就像计算机网络传输数据帧一样。对于进程来说,如果没有数据格式,那么内存不过是一块连续的数据空间罢了,只有我们先定义了内存格式,进程间的才能读取并且解析有意义的数据。下面给出经典的数据帧的格式要求:

cpp 复制代码
#pragma pack(push, 1)
struct FrameHeader
{
    uint32_t magic;        // 'WGCH'
    uint32_t version;      // 1
    uint32_t width;
    uint32_t height;
    uint32_t stride;
    uint32_t format;       // 1 = BGRA8
    uint64_t frameId;
    uint64_t timestampQpc;
    uint32_t dataSize;
    uint32_t reserved;
};
#pragma pack(pop)

上面这段是我们自定义数据帧的格式,让我们细细讲解这个帧的具体格式。先介绍帧头部的各个字段:

字段名 (Field Name) 类型 (Type) 说明 (Description)
magic uint32_t / char[4] 帧魔数(例如 'WGCH'),用于快速判断这块内存是否是您定义的协议。
version uint32_t 协议版本号,便于未来升级兼容。
width uint32_t 图像宽度(像素)。
height uint32_t 图像高度(像素)。
stride uint32_t 每行有效字节数(当前实现是 width * 4)。
format uint32_t 像素格式标识(当前 1 = BGRA8)。
frameId uint64_t 单调递增帧序号,消费端可据此判重、跳帧检测。
timestampQpc uint64_t QueryPerformanceCounter 时间戳,便于计算延迟/FPS。
dataSize uint32_t 像素数据总字节数(通常 stride * height)。
reserved uint32_t[2] / uint64_t[1] 预留字段,给后续扩展用。

这里的stride很有用,因为采取的像素存取的格式是BGRA8这种数据格式,三个字节表示颜色,一个字节表示alpha(透明度)。所以这里的一个stride实际是width*4,因为假如宽度是933个像素,那么这一行在内存就占933*4个字节,也就是width*4,这是为了让程序能够知道每一行开头的位置在哪。此外就是#pragma pack(push, 1)#pragma pack(pop)。这是为了接收者和发送者的内存对齐方式是一致的,不会解析出错。帧首部后面跟的就是像素值,这两个函数就是用来获取帧首部和后侧像素值的地址的。

cpp 复制代码
    FrameHeader* Header() const
    {
        return reinterpret_cast<FrameHeader*>(base);
    }

    uint8_t* Pixels() const
    {
        return reinterpret_cast<uint8_t*>(base) + sizeof(FrameHeader);
    }

现在帧的格式有了,接下来就是要建立共享内存以存放帧给消费者接收并且解析。这里的代码也是比较复杂:

cpp 复制代码
struct SharedFrameBuffer
{
    HANDLE hMap = nullptr;
    HANDLE hEvent = nullptr;
    void* base = nullptr;
    size_t size = 0;

//......
    bool Create(const std::wstring& mapName, const std::wstring& eventName, size_t mappingSize)
    {
        size = mappingSize;
        std::wcout << L"Creating shared memory with mapName: " << mapName
            << L", eventName: " << eventName
            << L", size=" << mappingSize << L"\n";

        hMap = CreateFileMappingW(
            INVALID_HANDLE_VALUE,
            nullptr,
            PAGE_READWRITE,
            static_cast<DWORD>((mappingSize >> 32) & 0xFFFFFFFF),
            static_cast<DWORD>(mappingSize & 0xFFFFFFFF),
            mapName.c_str());

        if (!hMap)
        {
            DWORD gle = GetLastError();
            std::wcerr << L"CreateFileMappingW failed. GLE=" << gle
                << L", msg=" << GetLastErrorMessage(gle) << L"\n";
            return false;
        }

        std::wcout << L"File mapping created successfully.\n";

        base = MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, mappingSize);
        if (!base)
        {
            DWORD gle = GetLastError();
            std::wcerr << L"MapViewOfFile failed. GLE=" << gle
                << L", msg=" << GetLastErrorMessage(gle) << L"\n";
            return false;
        }

        hEvent = CreateEventW(nullptr, FALSE, FALSE, eventName.c_str());
        if (!hEvent)
        {
            DWORD gle = GetLastError();
            std::wcerr << L"CreateEventW failed. GLE=" << gle
                << L", msg=" << GetLastErrorMessage(gle) << L"\n";
            return false;
        }

        ZeroMemory(base, mappingSize);
        Header()->magic = FRAME_MAGIC;
        Header()->version = FRAME_VERSION;
        Header()->format = FRAME_FORMAT_BGRA8;

        std::wcout << L"Shared memory initialized successfully.\n";
        return true;
    }

    void Close()
    {
        if (base)
        {
            UnmapViewOfFile(base);
            base = nullptr;
            std::wcout << L"Unmapped view of file.\n";
        }

        if (hMap)
        {
            CloseHandle(hMap);
            hMap = nullptr;
            std::wcout << L"Closed file mapping handle.\n";
        }

        if (hEvent)
        {
            CloseHandle(hEvent);
            hEvent = nullptr;
            std::wcout << L"Closed event handle.\n";
        }
    }

    ~SharedFrameBuffer()
    {
        Close();
    }
};

这里最重要的就是Create函数,首先是三个传入参数:

  • mapName: 一个宽字符串,用于给共享内存区域指定一个全局唯一的名称。其他进程可以通过这个名字来打开这个已经存在的共享内存。
  • eventName: 一个宽字符串,用于给同步事件指定一个全局唯一的名称。其他进程也可以通过这个名字来打开这个事件。
  • mappingSize: 一个 size_t 类型的整数,指定要创建的共享内存区域的大小(以字节为单位)。

然后是创建命名共享对象:CreateFileMappingW

  • 目的:创建一个命名的共享内存对象(在 Windows 中称为"文件映射对象")。
  • INVALID_HANDLE_VALUE: 第一个参数表示要在系统页文件(Page File)上创建一块匿名的共享内存。如果传入一个真实的文件句柄,则是在该文件上创建映射。
  • nullptr : 安全属性,传入 nullptr 表示使用默认的安全性。
  • PAGE_READWRITE: 指定内存保护属性,表示创建的内存区域是可读可写的。
  • (mappingSize >> 32) & 0xFFFFFFFF(mappingSize & 0xFFFFFFFF) : CreateFileMappingW 使用两个 32 位的参数来指定总大小(因为单个参数最大只能表示 4GB)。这里将 size_t 类型的 mappingSize 进行位运算拆分,高位部分作为最大文件大小的高位,低位部分作为低位。
  • mapName.c_str() : 传入共享内存对象的名称。如果系统中已经存在一个同名的共享内存,CreateFileMappingW 会返回那个已存在对象的句柄,而不是创建一个新的。

映射到进程虚拟空间:MapViewOfFile

  • 目的:将创建的共享内存对象"映射"到当前进程的虚拟地址空间中,使其可以像普通内存一样被访问。
  • hMap: 传入上一步创建的共享内存对象的句柄。
  • FILE_MAP_ALL_ACCESS: 请求对映射视图的完全访问权限(读、写、执行)。
  • 0, 0: 指定映射的起始偏移量,这里从 0 开始。
  • mappingSize: 指定要映射的区域大小。
  • base : MapViewOfFile 成功后会返回一个指向映射内存起始地址的指针,并将其赋值给成员变量 base。从此,程序就可以通过 base 指针来读写这块共享内存了。

创建同步命名事件对象:CreateEventW

  • 目的:创建一个用于同步的命名事件对象。
  • nullptr: 安全属性,使用默认。
  • FALSE (bManualReset) : FALSE 表示这是一个自动重置事件。当一个等待线程被唤醒后,系统会自动将事件状态重置为无信号。
  • FALSE (bInitialState) : FALSE 表示事件的初始状态为无信号
  • eventName.c_str(): 传入事件对象的名称,用于其他进程打开。
  • hEvent : 如果成功,返回的事件句柄会被保存到成员变量 hEvent 中。

初始化工作:ZeroMemory以及一下。

  • 清零内存ZeroMemory 将整块共享内存(从 base 地址开始,mappingSize 大小)清零,避免残留垃圾数据。
  • 初始化帧头 :接下来三行代码,利用之前定义的 Header() 成员函数(它返回指向内存开头的 FrameHeader 结构体的指针),来设置帧协议的初始信息。设置了魔数、协议版本和像素格式,这样其他进程在读取时就能识别并正确解析数据。

总结一下:这个 Create 函数完成了共享内存 IPC 的准备工作。它创建了一个名为 mapName 的共享内存块和一个名为 eventName 的事件,将内存映射到当前进程的地址空间,初始化了协议头,为后续的生产者-消费者模式(一个进程写入数据,另一个进程读取并等待事件通知)奠定了基础。(有关上述api的参数的详情可看下面的界面,贴个网址,MapViewOfFile function (memoryapi.h) - Win32 apps | Microsoft Learn

下面继续深入,讨论帧池和会话这两个重要的概念。在 Windows.Graphics.Capture 这套模型里,framePoolsession 不是重复概念,而是分工非常清晰的两个层次。session 负责"控制面",也就是把哪个窗口作为捕获目标、何时开始/结束捕获这类会话级动作;framePool 负责"数据面",也就是缓存捕获到的帧并提供取帧机制。换句话说:没有 session,捕获不会开始;没有 framePool,即使开始了也没有地方接收帧。这也是代码里必须同时创建两者的根本原因。对应实现中,先用 CreateFreeThreaded(...) 建帧池,再用 framePool.CreateCaptureSession(item) 建会话,这里的item就是我们之前获取的句柄,最后 session.StartCapture() 启动,形成完整链路:

cpp 复制代码
//  CreateFreeThreaded
framePool = winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool::CreateFreeThreaded(
    winrtDevice,
    winrt::Windows::Graphics::DirectX::DirectXPixelFormat::B8G8R8A8UIntNormalized,
    2,
    lastSize);


frameArrivedToken = framePool.FrameArrived({ this, &CaptureApp::OnFrameArrived });

session = framePool.CreateCaptureSession(item);

session.StartCapture();

为什么先创建帧池再创建会话?因为会话启动后帧会持续产生,如果没有帧池承接,数据流就没有落点。另外这里选择的是 CreateFreeThreaded,它去掉了对 DispatcherQueue 的依赖(这里需要了解windows的线程模型),让 FrameArrived 在内部工作线程触发,这对我们这种独立 Host 进程更合适:主线程不需要 UI 消息循环也能工作。代码的主循环也印证了这一点,它只是监控窗口是否还存在,并不承担帧处理职责:

cpp 复制代码
std::wcout << L"Entering message loop surrogate. Monitoring hwndTop...\n";
while (IsWindow(hwndTop))
{
    Sleep(50);
}

std::wcout << L"Target window is no longer valid. Stopping capture.\n";
app.Stop();

有了帧池和会话之后,下一层就是事件管理。事件管理的目的,是把"帧到来"从轮询模式改成通知模式:不是你不断问"有没有新帧",而是系统在有帧时主动触发回调。这样做有三个直接收益:第一,减少空转轮询;第二,天然贴合异步高频场景;第三,方便把处理逻辑集中在一个入口函数里。代码里用 frameArrivedToken 保存订阅句柄,这样 Stop() 时可以精确解绑,避免释放对象后回调还在打进来:

cpp 复制代码
void Stop()
{
    std::wcout << L"Stopping capture session...\n";

    if (framePool)
    {
        framePool.FrameArrived(frameArrivedToken);
    }

    session = nullptr;
    framePool = nullptr;
    item = nullptr;

    std::wcout << L"Capture session stopped.\n";
}

这段释放顺序其实就是事件管理的一部分:先取消事件,再销毁资源,否则高频回调很容易踩到悬空对象。

OnFrameArrived 是整条链路的核心,它把"显卡中的帧"转成"共享内存可读数据"。这个函数可以按四步理解。第一步,取帧和基本校验:TryGetNextFrame() 可能拿到空帧,ContentSize 可能无效,这两类都直接返回,避免后续无效计算。第二步,处理尺寸变化:如果窗口尺寸改变,就 Recreate(...) 重建帧池,不强行按旧尺寸继续处理。第三步,执行 GPU→CPU 读回:从 frame.Surface()ID3D11Texture2D,创建 D3D11_USAGE_STAGING 纹理,CopyResourceMap 到 CPU 地址。第四步,写入共享内存并发信号:逐行 memcpy(按 mapped.RowPitch 读、按 stride 写),更新 FrameHeader,最后 SetEvent 通知消费者来取新帧。

cpp 复制代码
void OnFrameArrived(
    winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender,
    winrt::Windows::Foundation::IInspectable const&)
{
    // ...日志省略
    auto frame = sender.TryGetNextFrame();
    if (!frame) return;

    auto contentSize = frame.ContentSize();
    if (contentSize.Width <= 0 || contentSize.Height <= 0) return;

    if (contentSize.Width != lastSize.Width || contentSize.Height != lastSize.Height)
    {
        lastSize = contentSize;
        framePool.Recreate(
            winrtDevice,
            winrt::Windows::Graphics::DirectX::DirectXPixelFormat::B8G8R8A8UIntNormalized,
            2,
            contentSize);
        return;
    }

    // ...进入纹理转换与拷贝


auto access = frame.Surface().as<IDirect3DDxgiInterfaceAccess>();

com_ptr<ID3D11Texture2D> srcTexture;
HRESULT hr = access->GetInterface(__uuidof(ID3D11Texture2D), srcTexture.put_void());
check_hresult(hr);

// staging 纹理:让 CPU 可以读取
D3D11_TEXTURE2D_DESC stagingDesc = srcDesc;
stagingDesc.Usage = D3D11_USAGE_STAGING;
stagingDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
// ...

d3dContext->CopyResource(staging.get(), srcTexture.get());

D3D11_MAPPED_SUBRESOURCE mapped{};
hr = d3dContext->Map(staging.get(), 0, D3D11_MAP_READ, 0, &mapped);
check_hresult(hr);

// 逐行拷贝,兼容 RowPitch != width*4
for (uint32_t y = 0; y < height; ++y)
{
    std::memcpy(dst + y * stride, src + y * mapped.RowPitch, stride);
}




auto* hdr = shared.Header();
hdr->magic = FRAME_MAGIC;
hdr->version = FRAME_VERSION;
hdr->width = width;
hdr->height = height;
hdr->stride = stride;
hdr->format = FRAME_FORMAT_BGRA8;
hdr->dataSize = dataSize;
hdr->timestampQpc = static_cast<uint64_t>(qpc.QuadPart);
hdr->frameId = ++frameCounter;

d3dContext->Unmap(staging.get(), 0);

if (shared.hEvent)
{
    SetEvent(shared.hEvent);
}

帧池和会话解决"捕获系统如何建立",事件管理和 OnFrameArrived 解决"每一帧如何被可靠处理并投递给下游"。

最后就是简单的C#启动这个编译好的exe,双方进行通信了,主要是三个层面的对齐:

(1)协议对齐

cs 复制代码
[StructLayout(LayoutKind.Sequential, Pack = 1)]
private struct FrameHeader
{
    public uint magic;        // 'WGCH' = 0x48434757
    public uint version;      // 1
    public uint width;
    public uint height;
    public uint stride;
    public uint format;       // 1 = BGRA8
    public ulong frameId;
    public ulong timestampQpc;
    public uint dataSize;
    public uint reserved;
}

private const uint FRAME_MAGIC = 0x48434757;

(2)数据对齐(像素格式+按行拷贝)

cs 复制代码
const uint32_t width = srcDesc.Width;
const uint32_t height = srcDesc.Height;
const uint32_t stride = width * 4;
const uint32_t dataSize = stride * height;

uint8_t* dst = shared.Pixels();
uint8_t* src = reinterpret_cast<uint8_t*>(mapped.pData);

for (uint32_t y = 0; y < height; ++y)
{
    std::memcpy(dst + y * stride, src + y * mapped.RowPitch, stride);
}

(3)时序对齐(事件 + 帧号)

cs 复制代码
_frameEvent = EventWaitHandle.OpenExisting(_eventName);

// ...
if (hdr.frameId == _lastFrameId) return;
// ...
_lastFrameId = hdr.frameId;

ok,看下最终的效果:

终于是可以捕获cursor,kiro这种走gpu渲染路线的窗口了!但是又出现了新的问题,没法捕获浏览器窗口,C++那边的程序没法获取窗口标题,这个bug改天去修正一下,另外虽然C++那边给的事是事件驱动的代码,但是C#这边采用的是轮询机制,我记得当时是因为事件驱动在时序上有bug所以采用了驱动的方式,后续修改完继续上传github。

相关推荐
j_xxx404_2 小时前
蓝桥杯基础--时间复杂度
数据结构·c++·算法·蓝桥杯·排序算法
章鱼丸-2 小时前
DAY34 GPU 训练与类的 call 方法
开发语言·python
学嵌入式的小杨同学2 小时前
STM32 进阶封神之路(二十五):ESP8266 深度解析 —— 从 WiFi 通信原理到 AT 指令开发(底层逻辑 + 实战基础)
c++·vscode·stm32·单片机·嵌入式硬件·mcu·智能硬件
2501_945423542 小时前
C++跨平台开发实战
开发语言·c++·算法
英俊潇洒美少年2 小时前
函数组件(Hooks)的 **10 大优点**
开发语言·javascript·react.js
Oueii2 小时前
分布式系统监控工具
开发语言·c++·算法
小陈工2 小时前
2026年3月24日技术资讯洞察:边缘AI商业化,Java26正式发布与开源大模型成本革命
java·运维·开发语言·人工智能·python·容器·开源
haibindev2 小时前
把近5万个源文件喂给AI之前,我先做了一件事
java·前端·c++·ai编程·代码审计·架构分析
方安乐3 小时前
Javascript工具库:classnames
开发语言·javascript·ecmascript