LoginSignup
5
8

More than 3 years have passed since last update.

Windows 10のウィンドウキャプチャAPIをC#のデスクトップアプリから呼び出す

Last updated at Posted at 2021-03-13

はじめに

Windows 10のウィンドウキャプチャAPI (Windows.Graphics.Capture) を使うと、指定したウィンドウのキャプチャイメージを取得することができます。
画面の取り込み - UWP applications | Microsoft Docs

最小化されていない限り、他のウィンドウが上から重なっていたりしても問題なし。こんな感じで。
image.png

しかしC++からは使えるっぽいけど、C#の方が書きやすいんだよな。
UWPアプリだとセットアップが面倒なので、デスクトップアプリから使いたい。無理なのかな?
いえいえ、大丈夫。このサンプル、実はC#のデスクトップアプリだよ!(マジで?)

正確に言えば、WinRTを呼び出すためのラッパーライブラリをC++で書き、C#側から呼び出しています。
概要は以前の記事をどうぞ。
[C#] C++/WinRTのブリッジを作ってC#から呼び出す方法 - Qiita

これを応用して、Windows 10のウィンドウキャプチャAPIをラッパーライブラリ化してみましょう。

検証環境

  • Windows 10 Home (20H2)
  • Visual Studio 2019 Community

Visual Studio Installerからコンポーネントを追加インストールします。

  • ワークロード: 以下の3つにチェック
    • .NETデスクトップ開発
    • C++によるデスクトップ開発
    • ユニバーサルWindowsプラットフォーム開発
  • 個別のコンポーネント
    • Windows 10 SDK (10.0.18362.0以降を1つ以上)
    • (他にもあったかどうか忘れました…)

さらに、C++/WinRT開発のためのVSIXをインストールしておきます。
C++/WinRT - Visual Studio Marketplace

GitHubにコードを置いています

以下の内容を実行したソースコード一式をGitHubに置いておきます。手っ取り早く動作確認したい方はそちらをどうぞ。
build1024/GraphicsCaptureCSharp: Sample of C# project using features of Windows.Graphics.Capture through a C++/WinRT bridge library

ラッパーライブラリ (C++/WinRT) の開発

プロジェクトの作成

まずは、Windows Runtime Component (C++/WinRT) プロジェクトを以下の設定で作ります。

  • プロジェクト名は GraphicsCaptureBridge
  • ソリューション: 新しいソリューションを作成する
  • ソリューション名 GraphicsCaptureCSharp
  • Windowsのターゲットバージョンは1903以降で。

こうなっています。
image.png

ラッパークラスの名前、Class ではあんまりなので、Capture に変えておきます。
image.png
Capture.idl, Capture.cpp, Capture.h の中で、 Class と書いてあるところを全部 Capture に変えます。ClassTCaptureT に変えます。
そのままプロジェクトをビルドすると Capture.g.cpp が見つからない、といったエラーが出ますが、一度Visual Studioを終了し、再度このソリューションを開いて、プロジェクトをリビルドすると成功します。

さらに、プロジェクトのプロパティ「構成プロパティ」→「C/C++」→「プリプロセッサ」と進み、「プリプロセッサの定義」に以下の2つを追加します(順番も大事です)。これを書かないと後々ハマることになります。DebugとReleaseの両方に追加するのをお忘れなく。

%(PreprocessorDefinitions)
WINAPI_FAMILY=WINAPI_FAMILY_DESKTOP_APP

image.png

参照の追加

必要なパッケージを追加します。
プロジェクト名を右クリック→「NuGet パッケージの管理」から以下の2つを追加します。

  • directxtk_desktop_2017 (v2021.1.10.1)
  • wtl (v10.0.10320)

実装

ここからの実装は↓の記事をかなり参考にしています。
Windows 10のウィンドウキャプチャAPI - Qiita

pch.h

↓を参考に、実装に必要なヘッダファイルを書いていきます。
examples/stdafx.h at master · opysky/examples · GitHub

pch.h
#pragma once

#define NOMINMAX

#include <ppltasks.h>

#include <atlbase.h>
#include <atlapp.h>
extern CAppModule _Module;
#include <atlwin.h>
#include <atldlgs.h>
#include <atlcrack.h>
#include <atlmisc.h>

#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.System.h>
#include <winrt/Windows.Graphics.Capture.h>
#include <winrt/Windows.Graphics.DirectX.h>
#include <winrt/Windows.Graphics.DirectX.Direct3d11.h>

#include <shcore.h>
#include <dwmapi.h>
#include <DispatcherQueue.h>
#include <d3d11_4.h>
#include <dxgi1_6.h>
#include <d2d1_3.h>
#include <d2d1_3helper.h>
#include <windows.ui.composition.interop.h>
#include <windows.graphics.directx.direct3d11.interop.h>
#include <Windows.Graphics.Capture.Interop.h>

#include <SpriteBatch.h>

Direct3DHelper.h

新しくヘッダファイルを追加します。中身は↓のコードをそのまま使います。
examples/Direct3DHelper.h at master · opysky/examples · GitHub

Capture.h

ベースは↓です。
examples/CaptureView.h at master · opysky/examples · GitHub

Capture.h
#pragma once

#include "Capture.g.h"

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

namespace winrt::GraphicsCaptureBridge::implementation
{
    struct Capture : CaptureT<Capture>
    {
        Capture(int64_t ownerHwnd);
        IAsyncOperation<bool> StartCaptureForPickedWindowAsync();
        void StopCapture();
        void Resize();
        bool IsCapturing() { return _framePool != nullptr; }
    private:
        HRESULT CreateDevice();
        void StartCapture(winrt::Windows::Graphics::Capture::GraphicsCaptureItem const& item);
        void OnFrameArrived(
            Direct3D11CaptureFramePool const& sender,
            winrt::Windows::Foundation::IInspectable const& args);

        HWND _ownerHwnd;

        winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice _device{ nullptr };
        winrt::com_ptr<ID3D11Device> _d3dDevice;
        winrt::com_ptr<IDXGISwapChain1> _dxgiSwapChain;
        winrt::com_ptr<ID3D11RenderTargetView> _chainedBufferRTV;

        std::unique_ptr<::DirectX::SpriteBatch> _spriteBatch;

        winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool _framePool{ nullptr };
        winrt::Windows::Graphics::Capture::GraphicsCaptureItem _captureItem{ nullptr };
        winrt::Windows::Graphics::Capture::GraphicsCaptureSession _captureSession{ nullptr };
        winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool::FrameArrived_revoker _frameArrived;
    };
}

namespace winrt::GraphicsCaptureBridge::factory_implementation
{
    struct Capture : CaptureT<Capture, implementation::Capture>
    {
    };
}

元記事では CWindowImpl を継承してウィンドウのサブクラスとして実装し、そのウィンドウにキャプチャされた画像が描画されるようになっています。しかしこれではC#から使いにくいので、代わりにキャプチャ出力先のウィンドウハンドルをコンストラクタで受け取るようにします。

キャプチャ対象のウィンドウ指定について、ピッカーを使用する点は同じですが、GraphicsCapturePickerGraphicsCaptureItem オブジェクトをC#側に見せる必要はないので、「ピッカーを表示し、選択されたらそのウィンドウに対してキャプチャを開始する」というメソッドとして公開します。それが以下の部分です。

IAsyncOperation<bool> StartCaptureForPickedWindowAsync();

IAsyncOperation 型のオブジェクトは、C#側では .AsTask() メソッドで Task オブジェクトに変換することにより await で待機できるようになります。

WindowsRuntimeSystemExtensions.AsTask Method (System) | Microsoft Docs

あとは、ウィンドウサイズの変更時に拡大率を再計算できるように Resize() メソッドを追加しました(これは今回の本筋からは若干外れます)。

Capture.cpp

クラスの実装です。ベースは↓です。
examples/CaptureView.cpp at master · opysky/examples · GitHub

Capture.cpp
#include "pch.h"
#include "Capture.h"
#if __has_include("Capture.g.cpp")
#include "Capture.g.cpp"
#endif

#include "Direct3DHelper.h"
using namespace ::DirectX;

// Ref: https://github.com/opysky/examples/tree/master/winrt/GraphicsCapture/CapturePreview
namespace {
    // 今回はキャプチャ対象をPickerで指定するようにする
    auto ShowWindowPickerAsync(HWND ownerHwnd)
    {
        auto picker = GraphicsCapturePicker();
        picker.as<IInitializeWithWindow>()->Initialize(ownerHwnd);
        return picker.PickSingleItemAsync();
    }

    auto FitInBox(Size const& source, Size const& destination)
    {
        // アスペクト比を保持したままボックスに収まる矩形を計算
        Rect box;

        box.Width = destination.Width;
        box.Height = destination.Height;
        float aspect = source.Width / source.Height;
        if (box.Width >= box.Height * aspect) {
            box.Width = box.Height * aspect;
        }
        aspect = source.Height / source.Width;
        if (box.Height >= box.Width * aspect) {
            box.Height = box.Width * aspect;
        }
        box.X = (destination.Width - box.Width) * 0.5f;
        box.Y = (destination.Height - box.Height) * 0.5f;

        return CRect(
            static_cast<int>(box.X),
            static_cast<int>(box.Y),
            static_cast<int>(box.X + box.Width),
            static_cast<int>(box.Y + box.Height));
    }
}

namespace winrt::GraphicsCaptureBridge::implementation
{
    Capture::Capture(int64_t ownerHwnd) {
        _ownerHwnd = (HWND)ownerHwnd;
        this->CreateDevice();
    }

    HRESULT Capture::CreateDevice()
    {
        WINRT_VERIFY(IsWindow(_ownerHwnd));

        _device = CreateDirect3DDevice();
        _d3dDevice = GetDXGIInterfaceFromObject<ID3D11Device>(_device);

        auto dxgiDevice = _d3dDevice.as<IDXGIDevice2>();
        com_ptr<IDXGIAdapter> dxgiAdapter;
        check_hresult(dxgiDevice->GetParent(guid_of<IDXGIAdapter>(), dxgiAdapter.put_void()));
        com_ptr<IDXGIFactory2> dxgiFactory;
        check_hresult(dxgiAdapter->GetParent(guid_of<IDXGIFactory2>(), dxgiFactory.put_void()));

        CRect clientRect;
        GetWindowRect(_ownerHwnd, clientRect);

        DXGI_SWAP_CHAIN_DESC1 scd = {};
        scd.Width = clientRect.Width();
        scd.Height = clientRect.Height();
        scd.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
        scd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
        scd.BufferCount = 2;
        scd.SampleDesc.Count = 1;
        scd.SwapEffect = DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL;
        scd.AlphaMode = DXGI_ALPHA_MODE_IGNORE;

        check_hresult(dxgiFactory->CreateSwapChainForHwnd(
            _d3dDevice.get(),
            _ownerHwnd, // *this,
            &scd,
            nullptr,
            nullptr,
            _dxgiSwapChain.put()));

        com_ptr<ID3D11Texture2D> chainedBuffer;
        check_hresult(_dxgiSwapChain->GetBuffer(0, guid_of<ID3D11Texture2D>(), chainedBuffer.put_void()));
        check_hresult(_d3dDevice->CreateRenderTargetView(chainedBuffer.get(), nullptr, _chainedBufferRTV.put()));

        com_ptr<ID3D11DeviceContext> context;
        _d3dDevice->GetImmediateContext(context.put());
        _spriteBatch = std::make_unique<SpriteBatch>(context.get());

        return S_OK;
    }

    void Capture::StartCapture(winrt::Windows::Graphics::Capture::GraphicsCaptureItem const& item)
    {
        // Direct3D11CaptureFramePool は使いまわせても良さそうな気がするけど
        // GraphicsCaptureSession と一緒に破棄しないとダメっぽ
        StopCapture();

        _captureItem = item;

        _framePool = Direct3D11CaptureFramePool::Create(
            _device,
            DirectXPixelFormat::B8G8R8A8UIntNormalized,
            2,
            _captureItem.Size());
        _frameArrived = _framePool.FrameArrived(auto_revoke, { this, &Capture::OnFrameArrived });

        _captureSession = _framePool.CreateCaptureSession(item);
        _captureSession.StartCapture();
    }

    IAsyncOperation<bool> Capture::StartCaptureForPickedWindowAsync()
    {
        auto op = ShowWindowPickerAsync(_ownerHwnd);
        GraphicsCaptureItem item { co_await op };
        bool successful = (item != nullptr);
        if (successful) {
            StartCapture(item);
        }
        co_return successful;
    }

    void Capture::StopCapture()
    {
        if (IsCapturing()) {
            _frameArrived.revoke();

            _captureSession = nullptr;

            // 何故か Direct3DCaptureFramePool は Close を手動で呼び出さないと
            // 参照カウンタが残ってるというレポートが吐かれる?
            _framePool.Close();
            _framePool = nullptr;

            _captureItem = nullptr;
        }
    }

    void Capture::Resize()
    {
        if (_dxgiSwapChain == nullptr) {
            return;
        }

        CRect clientRect;
        GetWindowRect(_ownerHwnd, clientRect);

        if (!IsIconic(_ownerHwnd) && clientRect.Width() > 0 && clientRect.Height() > 0) {
            _chainedBufferRTV = nullptr;
            _dxgiSwapChain->ResizeBuffers(2, clientRect.Width(), clientRect.Height(), DXGI_FORMAT_B8G8R8A8_UNORM, 0);

            com_ptr<ID3D11Texture2D> chainedBuffer;
            check_hresult(_dxgiSwapChain->GetBuffer(0, guid_of<ID3D11Texture2D>(), chainedBuffer.put_void()));
            check_hresult(_d3dDevice->CreateRenderTargetView(chainedBuffer.get(), nullptr, _chainedBufferRTV.put()));

            InvalidateRect(_ownerHwnd, nullptr, true);
        }
    }

    void Capture::OnFrameArrived(
        Direct3D11CaptureFramePool const& sender,
        winrt::Windows::Foundation::IInspectable const& args){
        auto frame = sender.TryGetNextFrame();
        auto frameSurface = GetDXGIInterfaceFromObject<ID3D11Texture2D>(frame.Surface());

        // なんでか知らんが CaptureItem::Size と CaptureFrame::ContentSize が異なる場合が多い
        // レンダリングは ContentSize を基準にするべき
        auto contentSize = frame.ContentSize();

        com_ptr<ID3D11ShaderResourceView> frameSurfaceSRV;
        check_hresult(_d3dDevice->CreateShaderResourceView(frameSurface.get(), nullptr, frameSurfaceSRV.put()));

        com_ptr<ID3D11DeviceContext> context;
        _d3dDevice->GetImmediateContext(context.put());

        ID3D11RenderTargetView* pRTVs[1];
        pRTVs[0] = _chainedBufferRTV.get();
        context->OMSetRenderTargets(1, pRTVs, nullptr);

        D3D11_VIEWPORT vp = { 0 };
        DXGI_SWAP_CHAIN_DESC1 scd;
        _dxgiSwapChain->GetDesc1(&scd);
        vp.Width = static_cast<float>(scd.Width);
        vp.Height = static_cast<float>(scd.Height);
        context->RSSetViewports(1, &vp);

        auto clearColor = D2D1::ColorF(D2D1::ColorF::CornflowerBlue);
        context->ClearRenderTargetView(_chainedBufferRTV.get(), &clearColor.r);

        _spriteBatch->Begin();

        CRect sourceRect, destinationRect;

        sourceRect.left = 0;
        sourceRect.top = 0;
        sourceRect.right = contentSize.Width;
        sourceRect.bottom = contentSize.Height;

        destinationRect = FitInBox(
            { static_cast<float>(contentSize.Width), static_cast<float>(contentSize.Height) },
            { static_cast<float>(scd.Width), static_cast<float>(scd.Height) });

        _spriteBatch->Draw(frameSurfaceSRV.get(), destinationRect, &sourceRect);

        _spriteBatch->End();

        DXGI_PRESENT_PARAMETERS pp = { 0 };
        _dxgiSwapChain->Present1(1, 0, &pp);

        // Surface のサイズはおそらく FramePool 作成時のバッファサイズ。
        // CaptureItem のサイズはウィンドウのサイズに追従するので
        // 差異があるなら FramePool を再作成する
        auto surfaceDesc = frame.Surface().Description();
        auto itemSize = _captureItem.Size();
        if (itemSize.Width != surfaceDesc.Width || itemSize.Height != surfaceDesc.Height) {
            // GraphicsCaptureItem::Closed は参照しているウィンドウが破棄されたら
            // 発行されるイベントかと思ったけどそういうわけでも無さそう…
            // Size が 0 なら破棄されたと見做すしかないかな?
            SizeInt32 size;
            size.Width = std::max(itemSize.Width, 1);
            size.Height = std::max(itemSize.Height, 1);
            _framePool.Recreate(_device, DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, size);
        }
    }
}

Capture::Capture(int64_t ownerHwnd) はキャプチャ内容の描画先ウィンドウを引数に取りますが、後述のidlファイルとの関係で int64_t にしてあります(内部では HWND などにキャストして用います)。

先ほどの StartCaptureForPickedWindowAsync について、co_return で値を返すように書くことで、戻り値は IAsyncOperation<T> 型になります。このオブジェクトは前述のように、C#側で Task を介して await することができます。以下に例が載っています。
C++/WinRT を使用した同時実行操作と非同期操作 - UWP applications | Microsoft Docs

Capture.idl

C#側から見えるインタフェースになります。もともと入っている内容を消して、以下のように書きます。

Capture.idl
namespace GraphicsCaptureBridge
{
    [default_interface]
    runtimeclass Capture 
    {
        Capture(Int64 ownerHwnd);
        Windows.Foundation.IAsyncOperation<Boolean> StartCaptureForPickedWindowAsync();
        void StopCapture();
        void Resize();
        Boolean IsCapturing();
    }
}

int64_t 型は Int64 になり、bool 型は Boolean になります。

ここまで来たら、プロジェクトをビルドしておきましょう。ビルドが通ればC++側はクリアです。

メインプログラム (C#)

プロジェクトの作成

今あるソリューション GraphicsCaptureCSharp に、新しいプロジェクトを追加します。「Windows フォーム アプリケーション (.NET Framework)」プロジェクトを作ります。「WPF アプリ」でもよいのですが、ウィンドウハンドルを得るのがちょっと面倒なので。

  • プロジェクト名は CapturePreviewCSharp
  • フレームワークは .NET Framework 4.7.2 で。

このプロジェクト名を右クリック→「スタートアップ プロジェクトに設定」をしておきます。

プラットフォーム設定

まず、ソリューションの構成マネージャから、C#側プロジェクトのプラットフォームを設定します。Any CPU のままだと動きません。32ビット向けの設定はこんな感じで追加します(x64も同様)。
image.png
image.png

参照の追加

プロジェクトの「参照」を右クリック→「参照の追加」で「参照マネージャ」を開き、画面左側「プロジェクト」を選び、ブリッジライブラリである GraphicsCaptureBridge にチェックを入れます。
また、画面下側の「参照」ボタンから、以下の3ファイルを順に追加します。

  • C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.18362.0\Facade\windows.winmd
  • C:\Program Files (x86)\Windows Kits\10\References\10.0.18362.0\Windows.Foundation.UniversalApiContract\8.0.0.0\Windows.Foundation.UniversalApiContract.winmd
  • C:\Program Files (x86)\Windows Kits\10\References\10.0.18362.0\Windows.Foundation.FoundationContract\3.0.0.0\Windows.Foundation.FoundationContract.winmd

NuGetパッケージの追加

次に、プロジェクト名を右クリック→「NuGet パッケージの管理」から以下のパッケージを追加します。
パッケージを追加する時に「Packages.config」か「PackageReference」を選ぶ画面が出たら、「Packages.config」にしておきます。

  • Microsoft.VCRTForwarders.140 (v1.0.7)
  • System.Runtime.WindowsRuntime (v4.7.0)
  • System.Runtime.WindowsRuntime.UI.Xaml (v4.7.0)

UIの作成

Form1ButtonPictureBox を配置します。この PictureBox にキャプチャした画像を描画します。
image.png

PitureBoxAnchor プロパティに RightBottom を追加して、ウィンドウサイズ変更時に自動的にリサイズさせるようにするとよいです。

イベントの実装

Form1 の中身は以下のようになります。コードをコピペする場合は、先にデザイナでイベントハンドラを追加してから行ってください。
コメントにも書いていますが、GraphicsCaptureBridge.Capture(pictureBox1.Handle.ToInt64()); はウィンドウハンドルを使うため Load イベントに書いています。コンストラクタに書くと動きません。

Form1.cs(一部)
    public partial class Form1 : Form
    {
        private GraphicsCaptureBridge.Capture capture = null; // この行を追加

        public Form1()
        {
            InitializeComponent();
        }

        private async void button1_Click(object sender, EventArgs e)
        {
            await capture.StartCaptureForPickedWindowAsync().AsTask();
        }

        private void Form1_Load(object sender, EventArgs e)
        {
            // コンストラクタの段階ではウィンドウハンドルが生成されていないのでダメ
            capture = new GraphicsCaptureBridge.Capture(pictureBox1.Handle.ToInt64());
        }

        private void Form1_FormClosing(object sender, FormClosingEventArgs e)
        {
            capture?.StopCapture();
        }

        private void pictureBox1_Resize(object sender, EventArgs e)
        {
            capture?.Resize();
        }
    }

app.manifest

過去の記事でも書いたのですが、クラスの実装がどのDLLに入っているかを、C#側で明示的に指定しなければなりません。
[C#] C++/WinRTのブリッジを作ってC#から呼び出す方法 - Qiita

プロジェクト名を右クリック→「追加」から、「アプリケーション マニフェスト ファイル (Windows のみ)」を追加します。
image.png

追加した app.manifest の最後の方に以下の記述を追加します。

app.manifest
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
  <assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
  (略)
  <file name="GraphicsCaptureBridge.dll">
    <activatableClass
      name="GraphicsCaptureBridge.Capture"
      threadingModel="both"
      xmlns="urn:schemas-microsoft-com:winrt.v1" />
  </file>
</assembly>

いざ実行

C#プロジェクトをF5キーで実行してみましょう。動きましたか?

トラブルシューティング

[C#] C++/WinRTのブリッジを作ってC#から呼び出す方法 - Qiita もあわせてご覧ください。

エラー E0266「"std::IUnknown" があいまいです」

C#側 プロジェクトに追加すべき参照のどれかが足りないみたいです。IntelliSenseのエラーは C++側 のエラーとして出るので分かりにくい。

エラー CS0012「型 'IAsyncOperation<>' は、参照されていないアセンブリに定義されています。(以下略)」

C#側プロジェクトで、winmdファイルの参照が足りないみたいです。

ウィンドウのキャプチャが取れないことがある

最小化されているウィンドウのキャプチャは取ることができません(他ウィンドウの後ろに隠れているだけなら問題なく取れます)。これはAPIの仕様なので、C++で開発している場合も同じ制約を受けます。

5
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
8