MediaFoundationの仮想カメラで、キャプチャしたウィンドウを映してみた


N.Mです。いつの間にかMediaFoundationにも仮想カメラの機能が追加されていました。

# はじめに
まず、仮想カメラというのは、PC上の映像をUSBカメラなどと同じようにカメラの映像としてDiscordやZoom, Teamsなどに映すためのソフトウェアカメラのことです。OBS Studioの画面をDiscordに映したりする際に使われているものです。[^1]

[^1]: OBSで映せるので、仮想カメラ周りを自分で実装しようとする人、ほとんどいなさそう... Qiitaにもこの記事含めて2024年6月現在12記事しかありません...

以前、かなり古いAPIであるDirectShowで仮想カメラを作り、Qiitaの記事を書きました。こちら多くの方にご覧いただけたようです。ご覧いただきありがとうございました。

[DirectShowで仮想カメラを自作しよう](https://qiita.com/HexagramNM/items/2311f025f3af758c83a0)
[WinRTのWindowsGraphicsCaptureAPIでキャプチャしたウィンドウをDirectShowで自作した仮想カメラに映そう](https://qiita.com/HexagramNM/items/8493350d40608433421c)

これを書いたのが2021年の頃ですが、この時点では比較的新しいAPIであるMediaFoundationには仮想カメラの機能はなく、仮想カメラを実装するならDirectShowで書く必要がある状況でした。

ですが、その約半年後...

[Virtual Camera API in Windows 11 (Build 22000)](https://alax.info/blog/2206)

Windows11向けに、とうとうMediaFoundationにも仮想カメラの機能が追加されました。以降、MediaFoundationの仮想カメラのことを「MF仮想カメラ」と記載します。
[IMFVirtualCamera インターフェイス (mfvirtualcamera.h)](https://learn.microsoft.com/ja-jp/windows/win32/api/mfvirtualcamera/nn-mfvirtualcamera-imfvirtualcamera)

2021年当時はまだ自分のPCはWindows11でなく、この情報も知りませんでした。去年Windows11のPCに乗り換えて、数か月前にこのMF仮想カメラのことを知ったので着手しました。

今回の記事は以下の2点にフォーカスしていきます。
- **MediaFoundationの仮想カメラがどのような感じか**
- **以前のように他のウィンドウの映像を映すにはどうすればよいか**

:::note warn
この記事はなんとかMediaFoundationの仮想カメラを使える形までにしたという話で、これがベストプラクティスではありません。

記事投稿後にいろいろ試した感じだと、以下のように処理を行うのが都合良さそうです。（**こうすることで共有テクスチャのみをやり取りすればよくなります。**）
- **ウィンドウをキャプチャするアプリケーション**
    - 以下の記事で紹介しているオフスクリーンレンダリングを行い、カメラに映すための共有テクスチャを準備する
    [DirectXとMediaFoundation仮想カメラとの効率の良い連携](https://qiita.com/HexagramNM/items/66895f843d12e4ebd203)
- **MediaFoundationの仮想カメラ**
    - 送られてきた共有テクスチャを映像サンプルとして送る（必要に応じて映像形式の変換）

また、N.MはMediaFoundation初心者で、よくわかっていない箇所がかなりあるので、有識者の方々はコメントなどでいろいろ教えてください。
:::

:::note warn
キャプチャに使用するWinRTのWindowsGraphicsCaptureAPIの説明は今回割愛します。

気になる方は[以前のこの記事](https://qiita.com/HexagramNM/items/8493350d40608433421c)をご覧ください。
:::

:::note
MediaFoundationの仮想カメラで行われている処理をまとめたページを作りました。必要に応じてこちらもご覧ください。

[MediaFoundationの仮想カメラ](https://github.com/HexagramNM/NM_WindowCaptureVirtualCamera/wiki/MediaFoundationVirtualCamera)
:::


# MF仮想カメラの使用感について

## メリット

### DirectShowよりも新しいAPI
![DirectShow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/d810d155-0664-1ca0-5734-bd69fa595480.png)
（[DirectShow リファレンス](https://learn.microsoft.com/ja-jp/windows/win32/directshow/directshow-reference)から）

Microsoftもドキュメントで新しいAPIであるMediaFoundationに書き換えることを提案していますね。

またMF仮想カメラはすでにSimon Mourier氏がシンプルな[VCamSample](https://github.com/smourier/VCamSample)を公開しており、仮想カメラをDiscordなどに表示するところまではこのサンプルで比較的簡単にできました。[^2]後述する成果物では、MF仮想カメラの部分はこのサンプルコードをほとんどそのまま使用させていただいております。

[^2]: DirectShowは仮想カメラが認識されるようになるまで苦戦した記憶があります...

### 柔軟に仮想カメラを起動、終了できる
[MFCreateVirtualCamera 関数 (mfvirtualcamera.h)](https://learn.microsoft.com/ja-jp/windows/win32/api/mfvirtualcamera/nf-mfvirtualcamera-mfcreatevirtualcamera)

アプリケーション側で`MFCreateVirtualCamera`を呼び出すことでMF仮想カメラが作られ、作られたMF仮想カメラの`Shutdown`メソッドを呼ぶことでMF仮想カメラがカメラ一覧から除外されます。このようにアプリケーションが動いているときだけ使える仮想カメラを作成できます。[^3]

[^3]: DirectShowのときは、一度仮想カメラをインストールするとアンインストールするまでカメラの一覧に入ってました。

アプリケーション停止後もMF仮想カメラを使用できるようにするかや、MF仮想カメラの表示名といった制御も`MFCreateVirtualCamera`の引数でできます。[^4]

[^4]: DirectShowでは表示名を仮想カメラのコードにハードコーディングする必要がありました。

MediaFoundationはもともとC++のライブラリですが、Simon Mourier氏が[DirectN](https://www.nuget.org/packages/DirectN/)というC#ラッパーライブラリをnugetで公開しております。WPFなどC#で書かれたアプリケーションでも`MFCreateVirtualCamera`を呼び出すことができます。

### Windows標準のカメラアプリにも対応
DirectShowの仮想カメラはWindows標準のカメラアプリで認識されませんでしたが、MF仮想カメラは認識されるようです。

### MF仮想カメラのdllを32bit版、64bit版の両方でビルドする必要がない
MF仮想カメラは64bitでビルドすれば、様々なアプリケーションでMF仮想カメラが認識されました。[^5]

[^5]: DirectShowでは32bit版、64bit版のdllを用意しないと、一部のアプリケーションは仮想カメラを認識しません。


## デメリット

### Windows11以降でないと使用できない
新しめの機能であるので仕方ありませんが、Windows10やより古いWindowsでは使用できません。

### Local Serviceが動かすFrameServer上で動作している
![IMFVirtualCameraInterface.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/a01da33b-a4e3-7a29-fc01-4de5a58f5b06.png)
（[IMFVirtualCamera インターフェース (mfvirtualcamera.h)](https://learn.microsoft.com/ja-jp/windows/win32/api/mfvirtualcamera/nn-mfvirtualcamera-imfvirtualcamera) から）

**これが一番大きな障害になります。**
MF仮想カメラが映像を表示する処理はFrameServerというサービスで動作します。
![FrameServer.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/c736cfd1-350a-092c-9fa8-a0fbe29382c7.png)

その際のアカウントは権限の弱いLocalServiceで動いており、つまりセッション0で動いております。詳細は割愛しますが、要は普段PCを使用する我々ユーザから分離されたところで動いております。

結果として以下のような問題が出てきます。[^6]
- セッション0からはユーザのデスクトップ情報を取得できず、ウィンドウハンドルを取得できない。つまり**ウィンドウの指定ができず、WindowsGraphicsCaptureAPIによるキャプチャがMF仮想カメラ上ではできない。**

- LocalServiceの権限が弱いため、（試していないが）**ファイルにアクセスするのも厳しそう**

- LocalServiceの権限が弱いため、**あらかじめMF仮想カメラのdllファイルの権限も設定する必要がある。** ユーザのドキュメントフォルダ内にdllを作っている場合、Usersに「読み取りと実行」の権限を追加する必要があった。

- MF仮想カメラには`IMFAttributes`という一見仮想カメラのパラメタを制御できそうなインタフェースをもつが、アプリケーションとは別のサービスで動いているためか**アプリケーションから操作してもMF仮想カメラには反映されなかった。**

[^6]: FrameServerという1つのサービスでMF仮想カメラを動かすようにすることで、64bit版のdllのビルドのみで済むようになっているのかと思います。

### デバッグも手間がかかる
FrameServerで動いているために、MF仮想カメラの処理をVisualStudioでデバッグする際、以下のようひと手間かけないとブレークポイントで止まりません。[^7]

1. VisualStudioを管理者権限で起動する

2. アプリケーションから仮想カメラを起動し、Zoomなどでその仮想カメラを表示する

3. タスクマネージャでFrameServerのプロセスIDを調べる

4. VisualStudioで3のIDのプロセスにアタッチする[^8]

[^7]: DirectShowのときはZoomに仮想カメラを表示させ、VisualStudioをそのZoomとアタッチさせればデバッグできました。

[^8]: VisualStudio上では、FrameServerが`svchost.exe`で表示されており、さらに他のサービスも`svchost.exe`で表示されています。


# デメリットを乗り越えてなんとかできたもの
![NM_MFVCamSample_Result.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/86ed35f9-e5f7-49aa-45f6-b26dde5374f2.gif)

DirectShowの仮想カメラでは映せなかったWindows標準のカメラアプリに映し出すことができました。キャプチャウィンドウの動的なサイズ変更にも対応しております。

GitHubのリポジトリ：[NM_MFVCamSample](https://github.com/HexagramNM/NM_MFVcamSample/tree/Old_copyTextureToBitmap)

## 動作環境
- OS: **Windows11**（MediaFoundationの仮想カメラの機能はWindows11 (Windows Build 22000以降)から追加されたものであるため、Windows10以前だと動きません。）
- CPU: 13th Gen Intel(R) Core(TM) i7-13700H 2.40 GHz
- RAM: 32.0GB
- GPU: NVIDIA GeForce RTX 4060 Laptop GPU (**DirectX11使用**)

太字の要件を満たしているマシンであれば、動作しそうです。

## 仮想カメラを使用できたソフトウェア
- Windows標準のカメラアプリ
- Discord
- Zoom
- Teams
- [ウェブカメラテスト](https://ja.webcamtests.com/)


# どうやればキャプチャしたウィンドウを映せるか

## 手段
MF仮想カメラ上でウィンドウキャプチャが使用できない以上、アプリケーション側でウィンドウキャプチャを行い、キャプチャした画像をMF仮想カメラに送るということになります。LocalServiceの権限も弱く、別セッションの別プロセスに送るので、送る手段も限られます。

今回はそこで、[以前](https://qiita.com/HexagramNM/items/8493350d40608433421c)のようにWindowsGraphicsCaptureAPIでキャプチャしたウィンドウ画像をテクスチャに描き、**そのテクスチャを共有リソースとしてFrameServer上でも使用できるようにハンドルを作成しました。** このハンドルを**テクスチャ共有ハンドル**とここでは呼びます。

ちなみに、ウィンドウのサイズといったパラメタも、テクスチャを共有する形で伝達しております。

WindowsGraphicsCaptureAPIでも利用するDirectXには[CreateSharedHandle](https://learn.microsoft.com/ja-jp/windows/win32/api/dxgi1_2/nf-dxgi1_2-idxgiresource1-createsharedhandle)があり、GPU上のテクスチャならFrameServerに転送できるのではないかという発想にいたりました。

テクスチャをFrameServer上のMF仮想カメラに送る際には、権限設定などの注意点がありますが、こちらは[別の記事](https://qiita.com/HexagramNM/items/5fa74f59f0b7bb4b80e9)で紹介し、この記事ではおおまかな流れを紹介します。

## アプリケーション側
アプリケーションではキャプチャしたウィンドウの画像を描くテクスチャを作成し、テクスチャ共有ハンドルを`CreateSharedHandle`で作成しておきます。作成時に名前`Global\NM_Capture_Window`を設定することで、その名前からFrameServer上でテクスチャを使用できるようにします。

```C++: NM_CaptureWindow/NM_CaptureWindow.cpp

#include <windows.h>
#include <sddl.h>
#include "NM_CaptureWindow.h"

// 略

void NM_CaptureWindow::CreateSharedCaptureWindowTexture() {
	if (_d3dDevice == nullptr) {
		return;
	}

	//他プロセスと共有するためのテクスチャ
	D3D11_TEXTURE2D_DESC bufferTextureDesc;

	bufferTextureDesc.Width = MAX_SOURCE_WIDTH;
	bufferTextureDesc.Height = MAX_SOURCE_HEIGHT;
	bufferTextureDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
	bufferTextureDesc.ArraySize = 1;
	bufferTextureDesc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
	bufferTextureDesc.CPUAccessFlags = 0;
	bufferTextureDesc.MipLevels = 1;
	bufferTextureDesc.MiscFlags = D3D11_RESOURCE_MISC_SHARED_NTHANDLE | D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX;
	bufferTextureDesc.SampleDesc.Count = 1;
	bufferTextureDesc.SampleDesc.Quality = 0;
	bufferTextureDesc.Usage = D3D11_USAGE_DEFAULT;

	check_hresult(_d3dDevice->CreateTexture2D(&bufferTextureDesc, 0, _bufferTextureForCapture.put()));

	com_ptr<IDXGIResource1> sharedCaptureWindowResource;
	_bufferTextureForCapture->QueryInterface(IID_PPV_ARGS(sharedCaptureWindowResource.put()));

	// Session0であるFrameServerで共有テクスチャにアクセスできるようにするため、
	// SECURITY_ATTRIBUTESを設定する必要がある。
	SECURITY_ATTRIBUTES secAttr;
	secAttr.nLength = sizeof(SECURITY_ATTRIBUTES);
	secAttr.bInheritHandle = FALSE;
	secAttr.lpSecurityDescriptor = NULL;

	// Local Serviceにのみ読み取りのアクセス権（今回のFrameServerのパイプラインならこれで十分）
	// Interactive User（現在のユーザ）に全アクセス権を付与するなら(A;;GA;;;IU)を追加する。
	if (ConvertStringSecurityDescriptorToSecurityDescriptor(
		TEXT("D:(A;;GR;;;LS)"),
		SDDL_REVISION_1, &(secAttr.lpSecurityDescriptor), NULL)) {

		HRESULT hr = sharedCaptureWindowResource->CreateSharedHandle(&secAttr,
			DXGI_SHARED_RESOURCE_READ,
			TEXT("Global\\NM_Capture_Window"),
			&_sharedCaptureWindowHandle);
		com_ptr<IDXGIKeyedMutex> mutex;
		_bufferTextureForCapture.as(mutex);
		mutex->AcquireSync(0, INFINITE);
		mutex->ReleaseSync(MUTEX_KEY);
	}
}

// 略

```

## MF仮想カメラ側
:::note warn
以下で紹介している方法でも映像を表示することはできますが、いったんCPU上に画像をコピーし、またGPUに戻すような処理をしており、余計な処理負荷がかかってしまいます。

本記事投稿後、CPUにコピーせず、直接GPU内から映像を送る方法が分かりました。その方法を以下の記事で紹介しております。そちらのほうが処理負荷も小さいのでおすすめです。

[DirectXとMediaFoundation仮想カメラとの効率の良い連携](https://qiita.com/HexagramNM/items/66895f843d12e4ebd203)
:::

MF仮想カメラでは`OpenSharedResourceByName`でアプリケーション側のテクスチャ共有ハンドルからキャプチャしたウィンドウ画像の入ったテクスチャ（キャプチャテクスチャ）を取得します。加えてCPUからアクセス可能なテクスチャを作成し、キャプチャテクスチャの中身をこのテクスチャにコピーします。

こうすることで、CPUからアクセス可能なテクスチャを経由してキャプチャしたウィンドウ画像を取り出し、仮想カメラの映像として描画できるようになります。テクスチャからビットマップ画像を得る処理は[ここ](https://stackoverflow.com/questions/22383209/id3d11texture2d-to-id2d1bitmap-is-it-possible)を参考に実装しました。 [^9] 

[^9]: 今回は一旦ビットマップとして画像を取り出していますが、この部分をテクスチャから直接仮想カメラの映像を描画するようにできれば、GPUからCPUへの転送が不要となり、より効率的にできそうです。

```C++: VCamSampleSource/FrameGenerator.cpp

// 略

// CPUからアクセス可能なテクスチャを作成し、
// ハンドルから共有されたテクスチャを取得。
void FrameGenerator::CreateSharedCaptureWindowTexture()
{
	if (_sharedCaptureWindowTexture != nullptr)
	{
		return;
	}

	D3D11_TEXTURE2D_DESC bufferTextureDesc;

	bufferTextureDesc.Width = MAX_SOURCE_WIDTH;
	bufferTextureDesc.Height = MAX_SOURCE_HEIGHT;
	bufferTextureDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
	bufferTextureDesc.ArraySize = 1;
	bufferTextureDesc.BindFlags = 0;
	bufferTextureDesc.MipLevels = 1;
	bufferTextureDesc.SampleDesc.Count = 1;
	bufferTextureDesc.SampleDesc.Quality = 0;
	bufferTextureDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
	bufferTextureDesc.MiscFlags = 0;
	bufferTextureDesc.Usage = D3D11_USAGE_STAGING;
	check_hresult(_textureDevice->CreateTexture2D(&bufferTextureDesc, 0, _cpuCaptureWindowTexture.put()));

	_textureDevice->OpenSharedResourceByName(TEXT("Global\\NM_Capture_Window"),
		DXGI_SHARED_RESOURCE_READ, IID_PPV_ARGS(_sharedCaptureWindowTexture.put()));
}

void FrameGenerator::DrawSharedCaptureWindow()
{
	if (_sharedCaptureWindowTexture == nullptr || _cpuCaptureWindowTexture == nullptr)
	{
		return;
	}

  // 共有されたテクスチャの内容をCPUからアクセス可能なテクスチャにコピー
	com_ptr<IDXGIKeyedMutex> mutex;
	_sharedCaptureWindowTexture.as(mutex);
	mutex->AcquireSync(MUTEX_KEY, INFINITE);
	_textureDeviceContext->CopyResource(_cpuCaptureWindowTexture.get(), _sharedCaptureWindowTexture.get());
	mutex->ReleaseSync(MUTEX_KEY);

	com_ptr<IDXGISurface> dxgiSurface;
	_cpuCaptureWindowTexture->QueryInterface(IID_PPV_ARGS(dxgiSurface.put()));

	DXGI_MAPPED_RECT mapFromTexture;
	dxgiSurface->Map(&mapFromTexture, DXGI_MAP_READ);
	D2D1_BITMAP_PROPERTIES bitmapFormat;
	bitmapFormat.pixelFormat.format = DXGI_FORMAT_B8G8R8A8_UNORM;
	bitmapFormat.pixelFormat.alphaMode = D2D1_ALPHA_MODE_IGNORE;
	bitmapFormat.dpiX = 0.0f;
	bitmapFormat.dpiY = 0.0f;

  // CPUからアクセス可能なテクスチャをもとにビットマップを作成
	D2D1_SIZE_U size = D2D1::SizeU(MAX_SOURCE_WIDTH, MAX_SOURCE_HEIGHT);
	_renderTarget->CreateBitmap(size, (void*)mapFromTexture.pBits,
		mapFromTexture.Pitch, bitmapFormat, _captureWindowBitmap.put());
	dxgiSurface->Unmap();

	D2D1_RECT_F sourceRect, destRect;
	if (_captureWindowWidth > 0 && _captureWindowHeight > 0) {
		// 略

    // ビットマップを仮想カメラの映像に描画
		_renderTarget->DrawBitmap(_captureWindowBitmap.get(), destRect, 1.0f, D2D1_BITMAP_INTERPOLATION_MODE_LINEAR, sourceRect);
	}
}

```


# まとめ
ようやくMediaFoundationに仮想カメラの機能が追加され、DirectShowがいずれ非推奨になる可能性があることを考えると乗り換えたいところではありますね。

しかし、「LocalServiceの動かすFrameServer上に仮想カメラがある」という制約が結構厳しいと思いました。まだMF仮想カメラについての知見もネットに多くはなさそうなので、DirectShowから置き換わるのにはまだまだ時間がかかりそうかなと思っています。（2024年6月現在）