凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

C++ から Windows Graphics Capture API を利用する方法について調べてみた

はじめに

i_saint さんの記事で Windows 10 April 2018 Update (1803) より Windows Graphics Capture という API が追加されたのを最近知りました。

qiita.com

docs.microsoft.com

ざっくり言えば HWND や HMONITOR から直接 ID3D11Texture2D を引っ張ってこれるものになっています。

私は Unity 向けに uWindowCapture という各ウィンドウを管理・キャプチャして Unity 上で簡単に使えるようにするアセットを作成しているのですが、ここでは BitBlt や PrintWindow といった Win32 API を使っており、一度キャプチャした結果を CPU 側で扱って GPU 側へアップロードする関係上、パフォーマンスがなかなかに厳しいものになっています(デスクトップサイズだと 30 fps 程度かつシングルスレッドでしかキャプチャできず大量のウィンドウを表示したときのパフォーマンスが悪い)。しかしながら、ID3D11Texture2D を直接扱えれば GPU 上でコピーが完結するのでかなり高速化できるものと見込まれます。

そこでまずはこの Windows Graphics Capture について調べ、色々わかってきたら uWindowCapture へと取り込む流れで開発していこうと思います。本記事では調査のメモを残します。

デモ

遅延もほとんど無く可変サイズのウィンドウをキャプチャできています。

C++ / WinRT

Windows Graphics Capture API は C++/WinRT を利用しています。

www.infoq.com

WinRT は Windows Runtime の略称で(Windows RT ではないです)、2012 年の Windows 8(Modern UI(旧 Metro UI))以降の OS で実装されたアーキテクチャです。Win32 API は C 言語によるインターフェースでしたが、こちらは C++ でオブジェクト指向をもとに実装されています。COM をベースとした API であるため、複数の言語からも利用することができるようです。定義は WinMD(Windows メタデータ)ファイルに格納されていて、P/Invoke よりも高速でシンプルな呼び出しが可能なようです。

docs.microsoft.com

C++/WinRT はこの WinRT プラットフォームのための C++17 を使ったヘッダーオンリーなライブラリです。C++/CX や WRL から取って代わったもので、Windows SDK をインストールすれば使えるようになります。また公式のVisual Studio 用プラグインも配布されています。

github.com

Windows Graphics Capture

Windows Graphics Capture は Windows 10 version 1803 から追加された API です。

docs.microsoft.com

C++ からは C++/WinRT 経由で利用することができます。また、Windows 10 May 2019 Update から Win32 API との連携が追加され、ウィンドウやモニタのハンドル(HWND や HMONITOR)で指定した対象をキャプチャできるようになりました。

blogs.windows.com

公式によるコードのサンプルは以下で公開されています。

github.com

本リポジトリには .NET 向け含めいくつかサンプルが入っているのですが、C++ 向けには cpp/ScreenCaptureforHWND がとても参考になります。

コード

利用するための小さいサンプルを見ていきましょう。Win32 API との併用を考え、WinRT 関連は一部のコードに閉じるように設計し、適当なウィンドウをキャプチャする簡単なクラスを作成してみます。まずは次のようなヘッダを用意します。

ヘッダ

#pragma once

#include <functional>
#include <dxgi.h>
#include <d3d11.h>
#include <winrt/Windows.Graphics.DirectX.Direct3D11.h>
#include <winrt/Windows.Graphics.Capture.h>

class GraphicsCapture final
{
public:
    using FrameArrivedCallback = std::function<void(ID3D11Texture2D*, int, int)>;

    explicit GraphicsCapture(ID3D11Device* pDevice);
    ~GraphicsCapture();
    int GetHeight() const { return size_.Height; }
    int GetWidth() const { return size_.Width; }
    void SetCallback(const FrameArrivedCallback& callback) { callback_ = callback; }
    void Start();

private:
    void OnFrameArrived(
        const winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool& sender,
        const winrt::Windows::Foundation::IInspectable& args);

    winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice deviceWinRt_ = nullptr;
    winrt::Windows::Graphics::Capture::GraphicsCaptureItem graphicsCaptureItem_ = nullptr;
    winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool captureFramePool_ = nullptr;
    winrt::Windows::Graphics::Capture::GraphicsCaptureSession graphicsCaptureSession_ = nullptr;
    winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool::FrameArrived_revoker frameArrivedRevoker_;
    winrt::Windows::Graphics::SizeInt32 size_ = { 0, 0 };
    FrameArrivedCallback callback_;
};

色々なメンバがありますが...、それぞれの意味はソース側で見ていきましょう。

ソース

コード中にコメントを挿入します。

#include "GraphicsCapture.h"
#include <inspectable.h>
#include <winrt/base.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.System.h>
#include <winrt/Windows.Graphics.h>
#include <winrt/Windows.Graphics.DirectX.h>
#include <winrt/Windows.Graphics.DirectX.Direct3D11.h>
#include <windows.graphics.directx.direct3d11.interop.h>
#include <Windows.Graphics.Capture.Interop.h>
#include <windows.foundation.h>
#include <Windows.h>

using namespace winrt;
using namespace winrt::Windows;
using namespace winrt::Windows::Graphics::Capture;
using namespace winrt::Windows::Graphics::DirectX;
using namespace winrt::Windows::Graphics::DirectX::Direct3D11;
using namespace ::Windows::Graphics::DirectX::Direct3D11;

#pragma comment(lib, "windowsapp")

GraphicsCapture::GraphicsCapture(ID3D11Device* pDevice)
{
    // キャプチャには WinRT の世界でラップされたデバイスが必要なので、
    // ID3D11Device => IDXGIDevice => IDirect3DDevice へと変換
    com_ptr<IDXGIDevice> dxgiDevice;
    const auto hr = device->QueryInterface<IDXGIDevice>(dxgiDevice.put());
    if (FAILED(hr)) return;

    com_ptr<::IInspectable> deviceWinRt;
    ::CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.get(), deviceWinRt.put());
    deviceWinRt_ = deviceWinRt.as<IDirect3DDevice>();

    // GraphicsCaptureItem を作成する。これは、キャプチャ対象を保持するもので、
    // HWND を指定する CreateForWindow() と HMONITOR を指定する CreateForMonitor() がある
    const auto factory = get_activation_factory<GraphicsCaptureItem>();
    const auto interop = factory.as<IGraphicsCaptureItemInterop>();
    const auto hWnd = ::WindowFromPoint(POINT { 100, 100 });
    interop->CreateForWindow(
        hWnd,
        guid_of<ABI::Windows::Graphics::Capture::IGraphicsCaptureItem>(), 
        reinterpret_cast<void**>(put_abi(graphicsCaptureItem_)));

    // 対象のウィンドウのキャプチャに必要なサイズ
    size_ = graphicsCaptureItem_.Size();

    // キャプチャした結果を保持する Direct3D11CaptureFramePool を作成
    // デバイス、フォーマット、バッファの数、キャプチャの大きさを指定
    // CreateFreeThreaded() という内部のスレッドから呼ぶ出す方式もある(後述)
    captureFramePool_ = Direct3D11CaptureFramePool::Create(
        deviceWinRt_, 
        DirectXPixelFormat::B8G8R8A8UIntNormalized,
        2,
        size_);

    // FrameArrived を登録すると、更新があったときに指定したコールバックが呼ばれる
    // auto_revoke を指定するとデストラクタで revoke() が自動的に呼ばれる
    frameArrivedRevoker_ = captureFramePool_.FrameArrived(
        auto_revoke, 
        { this, &GraphicsCapture::OnFrameArrived });

    // キャプチャしたい対象を指定してキャプチャの開始・停止を行うセッションを作成
    graphicsCaptureSession_ = captureFramePool_.CreateCaptureSession(
        graphicsCaptureItem_);
}


GraphicsCapture::~GraphicsCapture()
{
    // イベントの破棄とセッションの終了
    frameArrivedRevoker_.revoke();

    graphicsCaptureSession_.Close();
    graphicsCaptureSession_ = nullptr;

    captureFramePool_.Close();
    captureFramePool_ = nullptr;

    deviceWinRt_ = nullptr;
    graphicsCaptureItem_ = nullptr;
}


void GraphicsCapture::Start()
{
    // キャプチャの開始
    if (graphicsCaptureSession_)
    {
        graphicsCaptureSession_.StartCapture();
    }
}


void GraphicsCapture::OnFrameArrived(
    const Direct3D11CaptureFramePool &sender,
    const Foundation::IInspectable &args)
{
    // ここは更新があったときに呼ばれる

    if (!callback_) return;

    // フレーム(Direct3D11CaptureFrame)を取得
    // ここからはサーフェスやサイズを取得できる
    const auto frame = sender.TryGetNextFrame();
    if (!frame) return;

    // サーフェス(IDirect3DSurface)を取得
    // IDXGISurface の WinRT ラッパー
    const auto surface = frame.Surface();
    if (!surface) return;

    // サーフェスから ID3D11Texture2D を取り出す
    auto access = surface.as<IDirect3DDxgiInterfaceAccess>();
    com_ptr<ID3D11Texture2D> texture;
    const auto hr = access->GetInterface(guid_of<ID3D11Texture2D>(), texture.put_void());
    if (FAILED(hr)) return;

    // フレームからサイズを取得
    const auto size = frame.ContentSize();

    // 登録されたコールバックを呼び出す
    callback_({ texture.get(), size.Width, size.Height });

    // ウィンドウのサイズが変更されたときはプールを作成し直す
    // セッションを一旦停止しなくてもこのように更新が可能
    if ((size_.Width != size.Width) || (size_.Height != size.Height))
    {
        size_ = size;
        captureFramePool_.Recreate(
            deviceWinRt_, 
            DirectXPixelFormat::B8G8R8A8UIntNormalized,
            2,
            size_);
    }
}

キャプチャ開始の流れとしては、

  1. WinRT のデバイスのラッパーである IDirect3DDevice を作成
  2. 適当な HWND を指定してキャプチャ対象を管理する IGraphicsCaptureItem を作成
  3. キャプチャした結果を保持するプール Direct3D11CaptureFramePool を作成
  4. プールが更新されたときに呼び出されるコールバックを登録
  5. プールに 2 で作成したキャプチャ対象を指定しセッション GraphicsCaptureSession を作成
  6. StartCapture() を呼び出しキャプチャを開始

となります。ちょっとやることが多いですが、流れは理解できるしシンプルです。

フレームの情報の保存

ドキュメントを読むと、TryGetNextFrame() でプールからフレームを取得すると、Direct3D11CaptureFrame が返されますが、このタイミングでチェックアウトした状態になります。そしてスコープを抜けてこのオブジェクトが破棄されるタイミングで、自動的にチェックインされるようです。また、次のようにも書いてあります。

Applications should not save references to Direct3D11CaptureFrame objects, nor should they save references to the underlying Direct3D surface after the frame has been checked back in.

解釈が正しいかはわからないですが、こうした挙動からやってきたテクスチャのポインタをそのまま保持しておくのは副作用があるかもしれないので、コールバック内ではこれをコピーするのが安全そうです(大丈夫な気もするので要調査)。とはいえ、やってくるテクスチャは D3D11_BIND_RENDER_TARGET と D3D11_BIND_SHADER_RESOURCE のフラグが立っていて直接シェーダに指定できたりします。この辺りの記述との兼ね合いをどう解釈すればいいのかは悩ましいですね。。

利用側

ではこのクラスの利用側について見ていきましょう。

ComPtr<ID3D11Device> device_;
ComPtr<ID3DTexture2D> texture_;

bool App::StartGraphicsCapture()
{
    graphicsCapture_ = std::make_unique<GraphicsCapture>(device_.Get());

    // コピー用のテクスチャを作成
    D3D11_TEXTURE2D_DESC desc;
    desc.Width = graphicsCapture_->GetWidth();
    desc.Height = graphicsCapture_->GetHeight();
    desc.MipLevels = 1;
    desc.ArraySize = 1;
    desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
    desc.SampleDesc.Count = 1;
    desc.SampleDesc.Quality = 0;
    desc.Usage = D3D11_USAGE_DEFAULT;
    desc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE;
    desc.CPUAccessFlags = 0;
    desc.MiscFlags = 0;
    const auto hr = device_->CreateTexture2D(&desc, nullptr, &texture_);
    if (FAILED(hr)) return false;

    // コールバックの登録
    graphicsCapture_->SetCallback([&](const GraphicsCapture::Result &result)
    {
        const int w = result.width;
        const int h = result.height;
        if (w == 0 || h == 0) return;

        D3D11_TEXTURE2D_DESC desc;
        texture_->GetDesc(&desc);

        if (desc.Width != w || desc.Height != h)
        {
            desc.Width = w;
            desc.Height = h;
            const auto hr = device_->CreateTexture2D(&desc, nullptr, &texture_);
            if (FAILED(hr)) return;
        }

        ComPtr<ID3D11DeviceContext> context;
        device_->GetImmediateContext(&context);
        context->CopyResource(texture_.Get(), result.pTexture);
    });

    // キャプチャ開始リクエスト
    graphicsCapture_->Start();

    return true;
}

スレッドアパートメントの指定

Windows Graphics Capture の利用は上で見てきたとおりですが、C++/WinRT を利用する際はまず以下の API を最初に呼び出す必要があります。

winrt::init_apartment();

これによって、Windows ランタイムのスレッドおよび COM の初期化が行われます。引数なしのデフォルトの呼び出しではマルチスレッド用に初期化されます。自分の環境では呼ばなくても動きはしましたが、終了時に以下のように Revoker の呼び出し時に Revoker の持つ弱参照へのアクセスエラーで落ちました。

f:id:hecomi:20210318232741p:plain

スレッドアパートメントモデルについてはあまり理解できていないです...

docs.microsoft.com

フレームプールのスレッドについて

フレームプールの作成方法としては、Direct3D11CaptureFramePool::Create() と Direct3D11CaptureFramePool::CreateFreeThreaded() の 2 種類があります。それぞれ FrameArrived を呼び出すスレッドが異なります。

Create

Create() で作成したフレームプールはメインスレッドから呼び出されます。具体的にはメッセージループの DispatchMessage() の中で呼び出されます。

f:id:hecomi:20210322004018p:plain

CreateFreeThreaded

一方 CreateFreeThreaded() で作成した場合は、GraphicsCapture.dll の専用の内部のスレッドから呼び出されます。

f:id:hecomi:20210322004318p:plain

スレッドが分かれるのでコピーする操作を行うときは更に排他制御などする必要がありそうです。ドキュメントではプールに紐付けられたデバイスから ID3D11Multithread を取得することが推奨されています。

手動で取得

FrameArrived を利用しなくても任意のタイミングで自分で TryGetNextFrame() を呼び出して新しいフレームが無いか調べる方法でもテクスチャを取ってくることができます。

GraphicsCapture::Result GraphicsCapture::TryGetNextResult() const
{
    const auto frame = captureFramePool_.TryGetNextFrame();
    if (!frame) return {};

    const auto surface = frame.Surface();
    if (!surface) return {};

    auto access = surface.as<IDirect3DDxgiInterfaceAccess>();
    com_ptr<ID3D11Texture2D> texture;
    const auto hr = access->GetInterface(guid_of<ID3D11Texture2D>(), texture.put_void());
    if (FAILED(hr)) return {};

    const auto size = frame.ContentSize();
    return { texture.get(), size.Width, size.Height };
}

// 利用側
const auto result = graphicsCapture_->TryGetNextResult();
UpdateTexture(result.pTexture, result.width, result.height);
DispatcherQueue

公式のサンプルを見ると、DispatcherQueue というものを利用 & 推奨しています。

docs.microsoft.com

// Direct3D11CaptureFramePool requires a DispatcherQueue
auto CreateDispatcherQueueController()
{
    namespace abi = ABI::Windows::System;

    DispatcherQueueOptions options
    {
        sizeof(DispatcherQueueOptions),
        DQTYPE_THREAD_CURRENT,
        DQTAT_COM_STA
    };

    Windows::System::DispatcherQueueController controller { nullptr };
    check_hresult(CreateDispatcherQueueController(
        options, 
        reinterpret_cast<abi::IDispatcherQueueController**>(put_abi(controller))));
    return controller;
}

int CALLBACK WinMain(... /* 略 */)
{
    ...

    // Create a DispatcherQueue for our thread
    auto controller = CreateDispatcherQueueController();

    ...

    // Enqueue our capture work on the dispatcher
    auto queue = controller.DispatcherQueue();
    auto success = queue.TryEnqueue([=]() -> void
    {
        g_app->Initialize(root);
    });
    WINRT_VERIFY(success);

    // Message pump
    MSG msg;
    while (GetMessage(&msg, NULL, 0, 0))
    {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }

    ...
}

全然説明がないので追ってみた感じですが、DispacherQueue 経由で初期化を行うと、どうやら DispatchMessage() の中で順に実行されるようです。これにより、フレームプールを Create() で作成したときは同じスレッドで初期化されていることが保証できる感じでしょうか?サンプルではどれもすべてメインスレッドで実行されることから、DispatcherQueue を使わなくても問題なく動きました。

カーソルの消去

Windows 10 version 2004 からはカーソルの ON/OFF のトグルが出来るようになりました。

docs.microsoft.com

ただ、最初私の環境で試したときは以下のようにエラーで落ちました。

f:id:hecomi:20210322234944p:plain

これは私の手元の Windows のバージョンが古かったからです...。

f:id:hecomi:20210322234155p:plain

とまぁそういう人もいるため、この API を叩く前に考慮しなければなりません。Windows のバージョンによってこの API が利用可能かについての判別方法は以下の公式の issue に書いてありました:

github.com

対象の機能にプロパティが存在しているかを調べることで判別可能なようです。

#include <winrt/Windows.Foundation.Metadata.h>

bool IsCursorCaptureEnabledApiAvailable()
{
    using ApiInfo = winrt::Windows::Foundation::Metadata::ApiInformation;
    return ApiInfo::IsPropertyPresent(
        L"Windows.Graphics.Capture.GraphicsCaptureSession",
        L"IsCursorCaptureEnabled");
}

...
if (IsCursorCaptureEnabledApiAvailable())
{
    graphicsCaptureSession_.IsCursorCaptureEnabled(false);
}

1909 の時点でこれを実行すると if ブロックの中に入らなくなり落ちなくなりました。Windows Update で最新の 20H2 に更新したところ無事、カーソルが消えるようになりました。

f:id:hecomi:20210323214619p:plain

キャプチャされないようにする方法

逆に本 API で画面をキャプチャしたくないアプリを作成したいときは、SetWindowDisplayAffinity() API を使うことで実現できるようです。

docs.microsoft.com

WDA_MONITOR を指定するとモニタ上では表示される一方、キャプチャ時に真っ黒になるようです。また、WDA_EXCLUDEFROMCAPTURE を指定するとこちらは完全にキャプチャからは除外され何も映らない状態になるようです。この様子をデモ用途でキャプチャしようとしてキャプチャ出来ないことに気づき仕方なくスマホで撮影してみました:

例外処理(2021/05/02 追記)

Windows Graphics Capture が使えないウィンドウハンドル(ポップアップなど)を渡したときや、プール作成時にサイズ 0 を渡したときなど、幾つかの Windows Graphics Capture 関連の API を呼び出し時に例外が発生することがあります。この例外は winrt::hresult_error 型で補足することが出来ます。

try
{
    ... (不正な Windows Graphics Capture API 呼び出し)
}
catch (const winrt::hresult_error& e)
{
    const int code = e.code(); // winrt::hresult
    const auto message = e.message(); // winrt::hstring
}

何もしないとアプリケーションが落ちるので適切にハンドリングするようにしましょう。ScreenCaptureforHWND サンプルだとコンボボックスに Windows Graphics Capture が使えないアイテムも入ってしまっている関係でこのように winrt::hresult_invalid_argument で落ちることがあります。

f:id:hecomi:20210502170901p:plain

エラーハンドリングの詳細に関しては公式ドキュメントをご参照ください。

docs.microsoft.com

Unity プラグインとしての設計方針検討

Unity 側のパフォーマンスには極力影響がないようにしたいので、uWindowCapture は別デバイスを作成し共有テクスチャにキャプチャ結果を保存、Unity のレンダリングスレッドでそれをコピーする、という手法をとっています。なので、渡すデバイスもこちらも分けたいです。しかしながら Windows Graphics Capture から渡ってくるテクスチャは共有テクスチャのフラグが立っていないので、Unity 側のデバイスへコピーするには一度更に別の共有テクスチャにコピーするというオーバーヘッドが発生してしまいます...。

割り切って Unity と同じデバイスでセットアップし、やってきたテクスチャを直接 CreateExternalTexture() / UpdateExternalTexture() で扱う辺りが良い落とし所かもしれません。。

tips.hecomi.com

uWindowCapture では他のキャプチャ手法との兼ね合いから共有テクスチャ経由方式にしようかと思います。

追記:2021/05/02

共有テクスチャ方式で設計して完成しました。

tips.hecomi.com

参考 / 関連

qiita.com

@eguo さんの記事では Windows Graphics Capture API が詳細に解説されています。

ruccho.hateblo.jp

@ruccho さんは Unity 向けにすでに Windows Graphics Capture を利用したプラグインを提供されています。これを利用した リモートで自作ゲームを展示できる Web サービスも現在開発されているようです。

おわりに

uWindowCapture 作成時にこういう API がないかな~、と調べていて出来ていなかったものがようやく用意され、しかもとても簡単に使えるようになって嬉しいです。次は uWindowCapture のキャプチャ手段の一つとして統合するところをやります。