云游戏技术之高速截屏和GPU硬编码 (3) 桌面复制接口 (Desktop Duplication API)

184 阅读6分钟

在上一章 应用程序主控 (DemoApplication) 中,我们认识了整个项目的“导演”——DemoApplication 类。

现在,是时候认识DDAImpl 类。

DDAImpl 是什么?

DDAImpl 是我们流水线的第一站:捕获。它的唯一任务,就是从你的电脑屏幕上抓取一帧画面。

想象一下,你想用相机拍一张照片。最简单的方法是拿起手机,对着屏幕拍一张。但这样做的效率低,而且画质会受影响。有没有一种方法能直接从屏幕的“数据源”里把图像“复制”出来呢?

Windows 系统提供了一种名为 桌面复制 API (Desktop Duplication API, DDA) 的高级技术,它正是为此而生。这种技术允许程序直接从显卡的内存中复制屏幕图像,速度极快,效率极高,而且不会有任何画质损失。

DDAImpl 类就是对这个复杂 Windows API 的一个简化封装。它将所有底层的、繁琐的设置和调用都隐藏了起来,只向我们暴露了几个简单易用的接口。

真实世界类比nvEncDXGI 组件作用
高速摄像机DDAImpl精确地对准屏幕,不断地“拍照”,将每一张照片(屏幕帧)实时传送出去。

DDAImpl 的工作成果,是一张存储在显存中的原始图像,我们称之为“图形纹理 (Texture)”。这张纹理随后会被传递给流水线的下一个环节。

如何使用 DDAImpl

在我们的项目中,DemoApplication(导演)是唯一需要直接和 DDAImpl(摄影师)打交道的人。它的使用流程非常简单,分为两步:

1. 初始化:

在录制开始前,“导演”需要先确保“摄影师”已经准备就绪。这通过调用 DDAImplInit() 方法来完成。

// 在 DemoApplication::Init() 内部
// ...
// 创建 DDAImpl 对象
pDDAWrapper = new DDAImpl(pD3DDev, pCtx); 
// 初始化 DDAImpl,让它准备好捕获屏幕
hr = pDDAWrapper->Init(); 
// ...

这段代码告诉 DDAImpl:“嘿,准备开工了!找到主显示器,设置好所有需要的东西。” 如果 Init() 成功返回,就意味着我们的高速摄像机已经架设完毕,对准了屏幕,随时可以开始拍摄。

2. 捕获帧:

当一切准备就绪,在主录制循环中,“导演”会在每一轮都向“摄影师”发出指令:“拍一张!” 这个指令就是调用 GetCapturedFrame() 方法。

// 在 DemoApplication::Capture() 内部

// 从 DDAImpl 获取一帧画面,并将其存入 pTex2D
// wait 参数告诉它最多等待 500 毫秒来获取新画面
HRESULT hr = pDDAWrapper->GetCapturedFrame(&pTex2D, 500); 

这个函数是 DDAImpl 的核心。它会尝试从屏幕上获取一个新的画面。

  • 输入参数: wait 值(单位是毫秒)告诉它,如果没有新画面,应该等待多久。如果在这段时间内屏幕内容有更新,它就会立刻返回。
  • 输出: 如果成功捕获到画面,它会通过第一个参数返回一个 ID3D11Texture2D 对象。你可以把它想象成一张存储在显存里的“数字底片”,包含了刚刚捕获到的屏幕图像(通常是 RGBA 格式)。

就是这么简单!Init() 一次,然后在循环里不断调用 GetCapturedFrame()DDAImpl 就能源源不断地为我们的流水线提供最新鲜的屏幕画面。

深入内部:DDAImpl 是如何工作的?

现在,让我们揭开这位“摄影师”的神秘面纱,看看它是如何与 Windows 系统底层交互来完成屏幕捕获的。

初始化 (Init) 的幕后故事

当你调用 Init() 时,DDAImpl 就像一个侦探,需要按图索骥,找到它需要的目标——主显示器的输出信号。

sequenceDiagram
    participant App as DemoApplication
    participant DDA as DDAImpl
    participant DXGI as Windows DXGI 系统
    participant GPU as 显卡驱动

    App->>DDA: Init()
    DDA->>DXGI: "你好,我想和你谈谈显卡的事"
    DXGI-->>DDA: "好的,这是显卡适配器(Adapter)对象"
    DDA->>DXGI: "请问这块显卡连接的第一个显示器(Output 0)是哪个?"
    DXGI-->>DDA: "就是这个,给你显示器对象"
    DDA->>DXGI: "太好了!请为这个显示器创建一个'复制会话'(DuplicateOutput)"
    DXGI->>GPU: "准备一下,开始复制这个显示器的画面"
    GPU-->>DXGI: "复制会话已建立"
    DXGI-->>DDA: "给你'复制会话'的钥匙(pDup 对象),以后用它来拿画面"
    DDA-->>App: 初始化成功!

这个过程在代码中(位于 DDAImpl.cppInit 函数)体现为一系列的查询和调用:

  1. 找到显卡 (Adapter) 我们从已有的 D3D11 设备出发,层层向上追溯,直到找到代表物理显卡的 IDXGIAdapter 对象。

    // 文件: DDAImpl.cpp (Init)
    
    // 从 D3D11 设备获取 DXGI 设备接口
    hr = pD3DDev->QueryInterface(__uuidof(IDXGIDevice2), (void**)&pDevice);
    // ...
    // 从 DXGI 设备获取它的“父亲”——显卡适配器
    hr = pDevice->GetParent(__uuidof(IDXGIAdapter), (void**)&pAdapter);
    // ...
    
  2. 找到主显示器 (Output) 一块显卡可能连接了多个显示器。我们通过 EnumOutputs(0, ...) 来获取索引为 0 的显示器,它通常是主显示器。

    // 文件: DDAImpl.cpp (Init)
    
    // 枚举并获取索引为 0 的显示器
    hr = pAdapter->EnumOutputs(0, &pOutput);
    // ...
    
  3. 创建复制会话 (Duplication) 这是最关键的一步。我们请求显示器对象为我们创建一个“复制品”。

    // 文件: DDAImpl.cpp (Init)
    
    // 将 pOutput 转换为支持复制功能的 IDXGIOutput1 接口
    hr = pOutput->QueryInterface(__uuidof(IDXGIOutput1), (void**)&pOut1);
    // ...
    // 请求创建输出复制接口,结果保存在 pDup 成员变量中
    hr = pOut1->DuplicateOutput(pDevice, &pDup);
    

    执行完这句代码后,pDup(类型为 IDXGIOutputDuplication*)就成了我们与桌面复制功能交互的唯一句柄。

获取帧 (GetCapturedFrame) 的秘密

Init() 完成后,我们就可以通过 pDup 这个“钥匙”来不断地获取新画面了。GetCapturedFrame() 的工作流程如下:

  1. 释放上一帧资源 (如果有的话):每次获取新帧之前,必须先告诉系统:“上一帧我已经用完了,你可以回收了。”

    // 文件: DDAImpl.cpp (GetCapturedFrame)
    
    // 如果 pResource 不为空,说明还持有着上一帧的资源
    if (pResource)
    {
        pDup->ReleaseFrame(); // 告诉 DDA 可以释放了
        pResource->Release(); // 释放我们自己的引用
        pResource = nullptr;
    }
    
  2. 请求下一帧: 调用 AcquireNextFrame 来获取最新的屏幕画面。这个调用会“阻塞”程序,直到有新画面出现或者等待超时。

    // 文件: DDAImpl.cpp (GetCapturedFrame)
    
    DXGI_OUTDUPL_FRAME_INFO frameInfo; // 用来存储帧信息
    
    // 等待最多 wait 毫秒,获取下一帧
    hr = pDup->AcquireNextFrame(wait, &frameInfo, &pResource);
    if (FAILED(hr))
    {
        // 如果是超时,这是正常情况,直接返回错误码让上层处理
        if (hr == DXGI_ERROR_WAIT_TIMEOUT) { /* ... */ }
        return hr;
    }
    

    如果成功,pResource 会指向一个通用的 IDXGIResource 对象,它代表了新的屏幕帧。frameInfo 则包含了这帧的详细信息,比如时间戳。

  3. 转换为纹理: IDXGIResource 是一个比较通用的类型,我们需要把它转换成 D3D11 能直接使用的 ID3D11Texture2D 格式。

    // 文件: DDAImpl.cpp (GetCapturedFrame)
    
    // 将通用的资源对象“查询”并转换为我们需要的 2D 纹理对象
    hr = pResource->QueryInterface(__uuidof(ID3D11Texture2D), (void**)ppTex2D);
    return hr;
    

    完成这一步后,ppTex2D 指向的指针就包含了这张宝贵的屏幕截图,可以交给流水线的下一个环节了。

总结

在本章中,我们深入了解了流水线的起点——DDAImpl

  • 我们知道了 DDAImpl 是对 Windows 桌面复制 API 的一个封装,是实现高性能屏幕捕获的关键。
  • 它的角色是流水线中的“高速摄像机”,负责抓取原始的屏幕图像。
  • 我们学习了它的核心用法:通过 Init() 进行初始化,然后循环调用 GetCapturedFrame() 来获取一帧帧的画面(ID3D11Texture2D 格式)。
  • 我们还探究了其内部原理,了解了它如何通过 DXGI 找到主显示器并创建复制会话,以及如何获取和释放每一帧。