DirectShowで仮想カメラを自作しよう

::: note warn
この仮想カメラ実装で使用しているDirectShowはかなり古いAPIです。Windows11からMediaFoundationという比較的新しいAPIでも仮想カメラが実装できるようになりました。このMediaFoundationによる仮想カメラについても以下の記事を書いているので、ぜひご覧ください。

[MediaFoundationの仮想カメラで、キャプチャしたウィンドウを映してみた](https://qiita.com/HexagramNM/items/52f5442f26dce89d3502)

Windows10やより古いWindows向けに仮想カメラを実装する場合は、ここで紹介しているDirectShowを使用した実装が必要です。
:::

N.Mです。

リモート会議や配信などが増えてきた昨今、Webカメラの代わりとなる**仮想カメラ**を耳にすることが多くなってきました。

今回、Windows環境で自作する機会があり、そこで滅茶苦茶つまづいたので、まとめました。

今回作ったコードはGitHubでも公開しました。わからない箇所は以下のリポジトリもぜひご覧ください。

[NMVCamFilter](https://github.com/HexagramNM/NMVCamFilter)

::: note
DirectShowの仮想カメラ実装に必要な要素をまとめたページを作りました。必要に応じてこちらもご覧ください。

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

# そもそも仮想カメラとは

アプリケーションからの映像を、Webカメラからの映像と同じような形で流すモジュールのことです。導入されると、そのモジュールが他のWebカメラなどと同じように、一覧に表示されます。

<img width="226" alt="vcInDiscord.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/f1035c1d-451c-c4b4-c160-c592aca5f1e0.png">

最近だと、OBSの映像を仮想カメラのプラグインを介してZoom等に流すなんていう話も聞いたりします。
[OBSをバーチャルカメラとして出力してZoomやTeamsで映像ソースとして認識させる方法](https://level69.net/archives/26918)


# 余談1：今回やりたかったこと

Zoomなどで音声のみの状態にしたいときに、マイクからの音声を波形映像として出力するアプリケーションを作って、公開できればと思いました。ただ、OBSなどほかのアプリケーション頼りになってしまうと、そのアプリケーションを入れてもらわないといけないなど手間がかかってしまうので、作れるなら仮想カメラの部分も自作できればと思いました。

（オレンジの自作仮想カメラが今回の対象）
<img width="575" alt="MyApplication.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/ef97dc5b-17a3-e9c9-44bb-4dd3321c69b1.png">



# Windows環境で仮想カメラを作るには

DirectShowというフレームワークを利用すると良いようです。~~後継にMediaFoundationがありますが、これにはアプリケーションの映像をWebカメラと同じように流すといった機能がないようです。~~ Windows11でMediaFoundationでも仮想カメラが作れるようになりました！（[MediaFoundationの仮想カメラで、キャプチャしたウィンドウを映してみた](https://qiita.com/HexagramNM/items/52f5442f26dce89d3502)）

DirectShowにはフィルタとピンという概念があります。それぞれ、このようなものです。
- **フィルタ**...映像を流す機能（ソースフィルタ）や映像を画面に移す機能（レンダリングフィルタ）など、1つの機能
- **ピン**...フィルタ同士をつなぎ、映像信号などをやり取りする端子。

仮想カメラの場合、他のWebカメラと同じように認識されるソースフィルタを作成することになります。

DirectShowがどういううものかを軽くつかむために、通常のWindowsであればすでに入っている*GraphEditor*というものを使ってみましょう。これは`C:\Program Files (x86)\Windows Kits\10\bin\10.0.17134.0\x86\graphedt.exe` (10.0.17134.0の部分は環境によって異なるかもしれません)にあり、フィルタがピンでつながっている様子を見ることができます。フィルタはGraph→Insert Filterから追加することができます。

<img width="331" alt="DirectShow.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/939f6e62-0c43-8c94-20ab-a8435b4e224d.png">

この図ではWebカメラ（ソースフィルタ扱い）からの映像がVideo Rendererというフィルタにピンを介して送られています。真ん中の再生ボタンを押して実行すると、Webカメラの映像が映し出されます。

DirectShowやGraphEditorについては[ここ](https://qiita.com/tomoki0sanaki/items/fec5cb057b9872c0664c)でもう少し詳しく説明されているので、気になる方はご覧ください。


# ソースフィルタの例

[ここ](https://blog.firefly-vj.net/2007/10/08/push-model-source-filter.html)で紹介されている例が、経過時間を表示するという単純なものでわかりやすかったです。GitHubにも[リポジトリ](https://github.com/syu5-gh/mysourcefilter)があり、MITライセンスだったので、使わせていただきました。

32bit版（Win32）と64bit版（x64）の両方でビルドできるようにしておきましょう（理由は後述）。ビルドが完了すると、32bit版ならDebugかReleaseフォルダに、64bit版ならx64フォルダ内のDebugかReleaseフォルダにdllファイルが出力されます。

GraphEditorで確認するためには`regsvr32.exe`で生成されたdllファイルを登録する必要があります。管理者権限でコマンドプロンプトを起動し、以下のコマンドを入力すれば登録できます。

`regsvr32.exe "dllファイルのパス"`

※64bit版を登録しようとして失敗する場合は、一度`cd ../SysWOW64`で64bit用のフォルダに移動してから、上記のコマンドを入力してください。（自分の環境ではSystem32のregsvr32.exeで問題なく登録はできました。）

登録が完了すると、GraphEditorでDirectShow Filtersとして認識されていることが確認できます。


# DirectShowで仮想カメラ実装時に出てきた問題

DirectShowを用いて仮想カメラを実装していくわけですが、以下のような多くの問題が発生しました。

- コンパイルに必要なインクルードファイルが見つからない
- サンプルも全然見つからない。
- ソースフィルタがWebカメラと同じカテゴリに配置されない。
- Webカメラと同じカテゴリに配置されても、Webカメラの一覧に出てこない。
- Discordだと映像が出力されない。
- コンパイル後のdllファイルへの書き込みができない


## コンパイルに必要なインクルードファイルが見つからない

DirectShowは結構古いフレームワークです。GraphEditorのコピーライトの年号が2002になっていることからも察することができます。そのため、Windows10にはDirectShowのincludeファイルやライブラリは含まれておらず、上記のソースフィルタの例もコンパイルすることができません。Windows7あたりまではあったようですが...

[sdk71examples](https://github.com/ganboing/sdk71examples)

そのため、ここからダウンロードし、`sdk71examples/multimedia/directshow/baseclasses/`にある、ソリューションファイルをビルドして、libファイルを出力しましょう。（ソリューションファイルでのincludeフォルダやライブラリフォルダのパス設定はダウンロードしてきた場所に応じて、修正する必要はあります。）

libファイルが無事出力されたら、ソースフィルタのサンプルについて、includeフォルダやライブラリフォルダのパスをsdk71examplesのものに変更することでビルドできるようになるはずです。


## サンプルも全然見つからない

DirectShowのAPIなどは[Microsoft](https://docs.microsoft.com/en-us/windows/win32/directshow/directshow)から調べることもできますが、基本英語なのと、そもそもパラメタの設定などが独特ということもあり、サンプルが欲しくなりました。ですが、ネットで探しても、DirectShowのサンプル、特に仮想カメラのサンプルが出てこないんですよね...

なので、見つかった数少ないサンプルを紹介するという目的もあり、今回の記事を書きました。後でも触れますが、今回ここのサンプルが大変参考になりました。

ソースフィルタのサンプル：[mysourcefilter](https://github.com/syu5-gh/mysourcefilter)

仮想カメラ用フィルタで使用するインタフェースのサンプル：[svcam](https://github.com/aricatamoy/svcam)


## ソースフィルタがWebカメラと同じカテゴリに配置されない

ソースフィルタのサンプルでは、GraphEditor上でもDirectShow Filtersというデフォルトのカテゴリに配置されてしまい、Webカメラなどが入っている`Video Capture Sources`のカテゴリに配置されません。カテゴリの配置はサンプルのDllSetup.cppで、登録（`DllRegisterServer`）のタイミングで行われております。しかし、サンプルの`return AMovieDllRegisterServer2(TRUE);`だと、目的のカテゴリに配置することができないので、以下のように変更する必要があります。（[参考](https://docs.microsoft.com/en-us/windows/win32/directshow/implementing-dllregisterserver)）

```C++
STDAPI DllRegisterServer() {

	HRESULT hr;
	IFilterMapper2 *pFM2 = NULL;
	hr = AMovieDllRegisterServer2(TRUE);
	if (FAILED(hr)) {
		return hr;
	}

	hr = CoCreateInstance(CLSID_FilterMapper2, NULL, CLSCTX_INPROC_SERVER,
		IID_IFilterMapper2, (void **)&pFM2);
	if (FAILED(hr)) {
		return hr;
	}

	hr = pFM2->RegisterFilter(
		CLSID_MySource,
		FILTER_NAME,
		NULL,
		&CLSID_VideoInputDeviceCategory,
		FILTER_NAME,
		&rf2FilterReg
	);
	if (pFM2) {
		pFM2->Release();
	}
	return hr;
}
```

この変更に合わせて、`DllUnregisterServer`（登録解除）のほうも修正する必要があります。

```C++
STDAPI DllUnregisterServer() {
	HRESULT hr;
	IFilterMapper2 *pFM2 = NULL;
	hr = AMovieDllRegisterServer2(FALSE);
	if (FAILED(hr)) {
		return hr;
	}

	hr = CoCreateInstance(CLSID_FilterMapper2, NULL, CLSCTX_INPROC_SERVER,
		IID_IFilterMapper2, (void **)&pFM2);
	if (FAILED(hr)) {
		return hr;
	}

	hr = pFM2->UnregisterFilter(
		&CLSID_VideoInputDeviceCategory,
		FILTER_NAME,
		CLSID_MySource
	);
	if (pFM2) {
		pFM2->Release();
	}
	return hr;
}
```

これで、ソースフィルタがGraphEditor上で`Video Capture Sources`カテゴリに配置されるようになりました。


## Webカメラと同じカテゴリに配置されても、Webカメラの一覧に出てこない

`Video Capture Sources`には配置されましたが、これだけではまだDiscordやZoomのWebカメラリストに自作フィルタが出てきませんでした。原因としては2つありました。

- 32bit版、64bit版を用意していなかった。
- 仮想カメラとして認識されるために必要なインタフェースを継承していなかった。


### 32bit版と64bit版

[ここ](https://stackoverflow.com/questions/39683384/directshow-virtual-camera-does-not-appear-in-the-list-on-some-configurations)にもありますが、アプリのbitとdll側のbitが一致していないと、まず認識されないようです。DiscordやZoomは32bit（x86）アプリケーションで、Chromeは64bit（x64）アプリケーションであるため、すべてに対応させようとすると、前述のとおり32bit版、64bit版の両方でビルドし、それぞれのdllファイルを`regsvr32.exe`で登録する必要があります。

（自分はまさかDiscordとかZoomとかが32bitアプリケーションだとは思わず、64bit版のdllファイルのみを生成していたので、ここで1回詰まりました...）


### 仮想カメラとして認識されるために必要なインタフェース

ソースフィルタが仮想カメラとして認識されるためには、`IAMStreamConfig`, `IKsPropertySet`, `IAMFilterMiscFlags`の3つのインタフェースをピンクラスに追加しておく必要があります。（[参考](https://community.osr.com/discussion/245023/virtual-camera-source-filter-directshow)）

これらを追加する際には、仮想関数となっている以下のメソッドを実装する必要があります。（`IUnknown`は`IAMStreamConfig`や`IKsPropertySet`に継承されているようで、`IUnknown`にあるメソッドも実装する必要があります。）

- `IUnknown`: QueryInterface, AddRef, Release
- `IKsPropertySet`: Get, Set, QuerySupported
- `IAMStreamConfig`: GetFormat, GetNumberOfCapabilities, GetStreamCaps, SetFormat
- `IAMFilterMiscFlags`: GetMiscFlags

これらのメソッドではポインタを引数として渡すものも多く、このメソッドでメモリを確保するのか、それともメモリが確保されたポインタが渡されるのかすら分かりません。というか、そもそもこれらのメソッドで具体的に何をすべきかもよく分かりません。そのため、素直にサンプルのコードに従いましょう。（実はこの実装のサンプルがなかなか見つからず、一番厄介なポイントでした。）

[svcam](https://github.com/aricatamoy/svcam)
[NMVCamFilter](https://github.com/HexagramNM/NMVCamFilter)（自分のコードですが、上のsvcamを参考にしております。）

`IAmStreamConfig`の`GetStreamCaps`はビデオの形式の設定を伝える関数で、どうパラメタが対応しているのかわかりづらいのと、人によって設定を変えたりすると思うので、以下にパラメタを軽く説明しておきます。ここでパラメタを指定する構造体の詳細を詳しく知りたい方は

- [BITMAPINFOHEADER](https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapinfoheader),
- [AM_MEDIA_TYPE](https://docs.microsoft.com/en-us/windows/win32/api/strmif/ns-strmif-am_media_type),
- [VIDEO_STREAM_CONFIG_CAPS](https://docs.microsoft.com/en-us/windows/win32/api/strmif/ns-strmif-video_stream_config_caps)

をご覧ください。


```C++
HRESULT NMVCamPin::GetStreamCaps(
	int           iIndex,
	AM_MEDIA_TYPE **ppmt,
	BYTE          *pSCC
)
{
	*ppmt = CreateMediaType(&m_mt);
	VIDEOINFOHEADER *pvi = (VIDEOINFOHEADER *)(*ppmt)->pbFormat;

	pvi->bmiHeader.biCompression = BI_RGB;  //圧縮形式。ここでは非圧縮のRGBで指定。
	pvi->bmiHeader.biBitCount = PIXEL_BIT;  //1ピクセル当たりのbit数
	pvi->bmiHeader.biSize = sizeof(BITMAPINFOHEADER);  //BITMAPINFOHEADER構造体のサイズ（バイト単位）
	pvi->bmiHeader.biWidth = WINDOW_WIDTH;  //画面の幅（ピクセル単位）
	pvi->bmiHeader.biHeight = WINDOW_HEIGHT;  //画面の高さ（ピクセル単位）
	pvi->bmiHeader.biPlanes = 1; //ここは必ず1。
	pvi->bmiHeader.biSizeImage = GetBitmapSize(&pvi->bmiHeader);  //1フレームあたりの画像のサイズ（バイト単位）
	pvi->bmiHeader.biClrImportant = 0;  //重要な色の数。0にするとすべての色が重要という扱いになる。とりあえず0でよい。

	SetRectEmpty(&(pvi->rcSource));
	SetRectEmpty(&(pvi->rcTarget));

	(*ppmt)->majortype = (const GUID)(*sudPinTypes.clsMajorType);  //ピンのメジャータイプ（仮想カメラの場合は MEDIATYPE_Video）
	(*ppmt)->subtype = (const GUID)(*sudPinTypes.clsMinorType);  //ピンのサブタイプ（仮想カメラの場合は MEDIASUBTYPE_RGB24）
	(*ppmt)->formattype = FORMAT_VideoInfo;  //ppmtの設定で使用するフォーマット形式。仮想カメラの場合はFORMAT_VideoInfo
	(*ppmt)->bTemporalCompression = FALSE;  //フレームの時間的な圧縮があるか（中間フレームを生成するか）
	(*ppmt)->bFixedSizeSamples = TRUE;  //1フレームあたりのデータサイズは固定か（非圧縮ならTRUE）
	(*ppmt)->lSampleSize = pvi->bmiHeader.biSizeImage;  //1フレームあたりの画像のサイズ（バイト単位）
	(*ppmt)->cbFormat = sizeof(VIDEOINFOHEADER);  //ppmtの指定で使用したフォーマットのサイズ (バイト単位)

	VIDEO_STREAM_CONFIG_CAPS *pvscc = (VIDEO_STREAM_CONFIG_CAPS *)pSCC;
	pvscc->guid = FORMAT_VideoInfo;  //pvsccの設定で使用するフォーマット形式。仮想カメラの場合はFORMAT_VideoInfo
	pvscc->VideoStandard = AnalogVideo_None;  //アナログ形式のビデオをサポートするか。通常はAnalogVideo_None

	//ここからの指定はMicrosoftのドキュメント上だとDeprecatedとなっているので、指定しなくても良さそう。
	//以下は入力画像について
	pvscc->InputSize.cx = WINDOW_WIDTH;  //画像の最大サイズの幅（ピクセル単位）
	pvscc->InputSize.cy = WINDOW_HEIGHT;  //画像の最大サイズの高さ（ピクセル単位）
	pvscc->MinCroppingSize.cx = WINDOW_WIDTH;  //拡大縮小した際の画像の最小の幅（ピクセル単位）
	pvscc->MinCroppingSize.cy = WINDOW_HEIGHT;  //拡大縮小した際の画像の最小の高さ（ピクセル単位）
	pvscc->MaxCroppingSize.cx = WINDOW_WIDTH;  //拡大縮小した際の画像の最大の幅（ピクセル単位）
	pvscc->MaxCroppingSize.cy = WINDOW_HEIGHT;  //拡大縮小した際の画像の最大の高さ（ピクセル単位）
	pvscc->CropGranularityX = 80;  //拡大縮小する際の幅の変化量（ピクセル単位）
	pvscc->CropGranularityY = 60;  //拡大縮小する際の高さの変化量（ピクセル単位）
	pvscc->CropAlignX = 0;  //拡大縮小する際の幅の変化量（ピクセル単位）
	pvscc->CropAlignY = 0;  //拡大縮小する際の高さの変化量（ピクセル単位）

	//以下は出力画像について
	pvscc->MinOutputSize.cx = WINDOW_WIDTH;  //拡大縮小した際の画像の最小の幅（ピクセル単位）
	pvscc->MinOutputSize.cy = WINDOW_HEIGHT;   //拡大縮小した際の画像の最小の高さ（ピクセル単位）
	pvscc->MaxOutputSize.cx = WINDOW_WIDTH;  //拡大縮小した際の画像の最大の幅（ピクセル単位）
	pvscc->MaxOutputSize.cy = WINDOW_HEIGHT;  //拡大縮小した際の画像の最大の高さ（ピクセル単位）
	pvscc->OutputGranularityX = 0;  //拡大縮小する際の幅の変化量（ピクセル単位）
	pvscc->OutputGranularityY = 0;  //拡大縮小する際の高さの変化量（ピクセル単位）
	pvscc->StretchTapsX = 0;  //横方向に拡大する際に、どうピクセルを処理するか
	pvscc->StretchTapsY = 0;  //縦方向に拡大する際に、どうピクセルを処理するか
	pvscc->ShrinkTapsX = 0;  //横方向に縮小する際に、どうピクセルを処理するか
	pvscc->ShrinkTapsY = 0;  //縦方向に縮小する際に、どうピクセルを処理するか
	//Deprecatedとなっているのはここまで

	pvscc->MinFrameInterval = 200000; //最小のフレーム間の時間（100nsec単位）。この指定だと50fps
	pvscc->MaxFrameInterval = 50000000; //最大のフレーム間の時間（100nsec単位）。この指定だと0.2fps
	pvscc->MinBitsPerSecond = (WINDOW_WIDTH * WINDOW_HEIGHT * PIXEL_BIT) / 5;  //1秒あたりの最小ビットレート
	pvscc->MaxBitsPerSecond = (WINDOW_WIDTH * WINDOW_HEIGHT * PIXEL_BIT) * 50;  //1秒あたりの最大ビットレート

	return S_OK;
}
```


## Discordだと映像が出力されない

```C++
//NMVCamFilter.h
const AMOVIESETUP_MEDIATYPE sudPinTypes =
{
	&MEDIATYPE_Video,
	&MEDIASUBTYPE_RGB24
};
```

ここで、ピンのタイプを定義する際に、`MEDIASUBTYPE_RGB32`の1ピクセル32bitにしようとしたところ、形式が合わないのか、カメラのリストに仮想カメラが出現しても、映像が表示されませんでした。`MEDIASUBTYPE_RGB24`の場合は、Discord, Zoom, Google Chromeすべてで映像もしっかり映りました。

## コンパイル後のdllファイルへの書き込みができない

これはDirectShowに限った話ではありませんが、dllファイルが使用されている間はコンパイルしても書き込みができないため、エラーが発生します。この場合は、一度DiscordやZoomなどWebカメラを使いそうなアプリをすべて終了してからコンパイルしましょう。（×で閉じるだけではダメで、タスクトレイで該当アプリを終了する必要があります。）`regsvr32.exe`では引数に`/u`を与えると登録解除できますが、しなくても問題なくdllファイルの書き込みができ、更新が反映されます。


# 余談2：フィルタ内にOpenGLなどの処理をまとめて入れるのはやめよう。

ソースフィルタの例を見るとわかるように、デバイスコンテキストを利用しているので、OpenGLでこのデバイスコンテキストに書き込めば、仮想カメラと映像出力を1つにまとめられるのではないかと思い、試してみました。OpenGLの映像を映すこと自体はできるのですが、いろいろ不都合がありました。

- 滅茶苦茶遅い。通常なら余裕で60fpsくらい出るものも、10fps以下になってしまう。

- フィルタ内の実装だとVertex Buffer Objectはおろか、Vertex Array Objectも使えず、ろくな高速化ができない（OpenGLのバージョンも1.1扱いになってしまい、glewのほとんどの拡張が使えない）。

- GraphEditorではOpenGLの映像が映っても、Zoomでは映らなかったりした。

- フィルタからマイクのバッファを直接触ろうとすると、セグフォで落ちる。おそらく、DirectShowのフィルタでやるならちゃんと音声用のピンを作らないといけないのだろうが、DiscordやZoomなどで使えるようにする以上、入力、出力両方のピンを作る必要がある。サンプルもあまり出てこないDirectShowでこれ以上はあまり実装したくない。

よって、OpenGLで映像を作る部分は、別にアプリケーションをつくって、以下のような流れにしたほうが楽だと思います。（`windows.h`をインクルードし、WindowsのAPIを使えるようにしています。）

1. 別アプリケーションでOpenGLを使って映像を作る。
2. 仮想カメラ側で、そのアプリケーションのデバイスコンテキストを取得してくる。
3. 2で取得したデバイスコンテキストに映っているものを`BitBlt`で仮想カメラ側のデバイスコンテキストに転送し、この転送されたものを仮想カメラの映像として出力する。

2のところで、目的のウィンドウを取得するために、`EnumWindows`を使って各ウィンドウの名前を調べたりするかと思いますが、`GetWindowText`だと特にカメラ切替で仮想カメラを終了する際にデッドロック状態になり、固まってしまいます。そのため、`SendMessageTimeoutW`を使用して、タイムアウトできる状態でウィンドウの名前を取得しましょう。（[参考](http://yu1rows.blogspot.com/2014/05/getwindowtext.html)）


# まとめ

今回の目的以外でも、DirectShowで仮想カメラフィルタを作成すれば、自作アプリの映像をWebカメラ経由で配信できるようになるので、ぜひ上記のことに気を付けて、試してみてください！