N.Mです。いつの間にかMediaFoundationにも仮想カメラの機能が追加されていました。
はじめに
まず、仮想カメラというのは、PC上の映像をUSBカメラなどと同じようにカメラの映像としてDiscordやZoom, Teamsなどに映すためのソフトウェアカメラのことです。OBS Studioの画面をDiscordに映したりする際に使われているものです。1
以前、かなり古いAPIであるDirectShowで仮想カメラを作り、Qiitaの記事を書きました。こちら多くの方にご覧いただけたようです。ご覧いただきありがとうございました。
DirectShowで仮想カメラを自作しよう
WinRTのWindowsGraphicsCaptureAPIでキャプチャしたウィンドウをDirectShowで自作した仮想カメラに映そう
これを書いたのが2021年の頃ですが、この時点では比較的新しいAPIであるMediaFoundationには仮想カメラの機能はなく、仮想カメラを実装するならDirectShowで書く必要がある状況でした。
ですが、その約半年後...
Virtual Camera API in Windows 11 (Build 22000)
Windows11向けに、とうとうMediaFoundationにも仮想カメラの機能が追加されました。以降、MediaFoundationの仮想カメラのことを「MF仮想カメラ」と記載します。
IMFVirtualCamera インターフェイス (mfvirtualcamera.h)
2021年当時はまだ自分のPCはWindows11でなく、この情報も知りませんでした。去年Windows11のPCに乗り換えて、数か月前にこのMF仮想カメラのことを知ったので着手しました。
今回の記事は以下の2点にフォーカスしていきます。
- MediaFoundationの仮想カメラがどのような感じか
- 以前のように他のウィンドウの映像を映すにはどうすればよいか
この記事はなんとかMediaFoundationの仮想カメラを使える形までにしたという話で、これがベストプラクティスというわけではありません。
また、N.MはMediaFoundation初心者で、よくわかっていない箇所がかなりあるので、有識者の方々はコメントなどでいろいろ教えてください。
キャプチャに使用するWinRTのWindowsGraphicsCaptureAPIの説明は今回割愛します。
気になる方は以前のこの記事をご覧ください。
MF仮想カメラの使用感について
メリット
DirectShowよりも新しいAPI
Microsoftもドキュメントで新しいAPIであるMediaFoundationに書き換えることを提案していますね。
またMF仮想カメラはすでにSimon Mourier氏がシンプルなVCamSampleを公開しており、仮想カメラをDiscordなどに表示するところまではこのサンプルで比較的簡単にできました。2後述する成果物では、MF仮想カメラの部分はこのサンプルコードをほとんどそのまま使用させていただいております。
柔軟に仮想カメラを起動、終了できる
MFCreateVirtualCamera 関数 (mfvirtualcamera.h)
アプリケーション側でMFCreateVirtualCamera
を呼び出すことでMF仮想カメラが作られ、作られたMF仮想カメラのShutdown
メソッドを呼ぶことでMF仮想カメラがカメラ一覧から除外されます。このようにアプリケーションが動いているときだけ使える仮想カメラを作成できます。3
アプリケーション停止後もMF仮想カメラを使用できるようにするかや、MF仮想カメラの表示名といった制御もMFCreateVirtualCamera
の引数でできます。4
MediaFoundationはもともとC++のライブラリですが、Simon Mourier氏がDirectNというC#ラッパーライブラリをnugetで公開しております。WPFなどC#で書かれたアプリケーションでもMFCreateVirtualCamera
を呼び出すことができます。
Windows標準のカメラアプリにも対応
DirectShowの仮想カメラはWindows標準のカメラアプリで認識されませんでしたが、MF仮想カメラは認識されるようです。
MF仮想カメラのdllを32bit版、64bit版の両方でビルドする必要がない
MF仮想カメラは64bitでビルドすれば、様々なアプリケーションでMF仮想カメラが認識されました。5
デメリット
Windows11以降でないと使用できない
新しめの機能であるので仕方ありませんが、Windows10やより古いWindowsでは使用できません。
Local Serviceが動かすFrameServer上で動作している
(IMFVirtualCamera インターフェース (mfvirtualcamera.h) から)
これが一番大きな障害になります。
MF仮想カメラが映像を表示する処理はFrameServerというサービスで動作します。
その際のアカウントは権限の弱いLocalServiceで動いており、つまりセッション0で動いております。詳細は割愛しますが、要は普段PCを使用する我々ユーザから分離されたところで動いております。
結果として以下のような問題が出てきます。6
-
セッション0からはユーザのデスクトップ情報を取得できず、ウィンドウハンドルを取得できない。つまりウィンドウの指定ができず、WindowsGraphicsCaptureAPIによるキャプチャがMF仮想カメラ上ではできない。
-
LocalServiceの権限が弱いため、(試していないが)ファイルにアクセスするのも厳しそう
-
LocalServiceの権限が弱いため、あらかじめMF仮想カメラのdllファイルの権限も設定する必要がある。 ユーザのドキュメントフォルダ内にdllを作っている場合、Usersに「読み取りと実行」の権限を追加する必要があった。
-
MF仮想カメラには
IMFAttributes
という一見仮想カメラのパラメタを制御できそうなインタフェースをもつが、アプリケーションとは別のサービスで動いているためかアプリケーションから操作してもMF仮想カメラには反映されなかった。
デバッグも手間がかかる
FrameServerで動いているために、MF仮想カメラの処理をVisualStudioでデバッグする際、以下のようひと手間かけないとブレークポイントで止まりません。7
-
VisualStudioを管理者権限で起動する
-
アプリケーションから仮想カメラを起動し、Zoomなどでその仮想カメラを表示する
-
タスクマネージャでFrameServerのプロセスIDを調べる
-
VisualStudioで3のIDのプロセスにアタッチする8
デメリットを乗り越えてなんとかできたもの
DirectShowの仮想カメラでは映せなかったWindows標準のカメラアプリに映し出すことができました。キャプチャウィンドウの動的なサイズ変更にも対応しております。
GitHubのリポジトリ:NM_MFVCamSample
動作環境
- 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
- ウェブカメラテスト
どうやればキャプチャしたウィンドウを映せるか
手段
MF仮想カメラ上でウィンドウキャプチャが使用できない以上、アプリケーション側でウィンドウキャプチャを行い、キャプチャした画像をMF仮想カメラに送るということになります。LocalServiceの権限も弱く、別セッションの別プロセスに送るので、送る手段も限られます。
今回はそこで、以前のようにWindowsGraphicsCaptureAPIでキャプチャしたウィンドウ画像をテクスチャに描き、そのテクスチャを共有リソースとしてFrameServer上でも使用できるようにハンドルを作成しました。 このハンドルをテクスチャ共有ハンドルとここでは呼びます。
ちなみに、ウィンドウのサイズといったパラメタも、テクスチャを共有する形で伝達しております。
WindowsGraphicsCaptureAPIでも利用するDirectXにはCreateSharedHandleがあり、GPU上のテクスチャならFrameServerに転送できるのではないかという発想にいたりました。
テクスチャをFrameServer上のMF仮想カメラに送る際には、権限設定などの注意点がありますが、こちらは別の記事で紹介し、この記事ではおおまかな流れを紹介します。
アプリケーション側
アプリケーションではキャプチャしたウィンドウの画像を描くテクスチャを作成し、テクスチャ共有ハンドルをCreateSharedHandle
で作成しておきます。作成時に名前Global\NM_Capture_Window
を設定することで、その名前からFrameServer上でテクスチャを使用できるようにします。
#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仮想カメラ側
以下で紹介している方法でも映像を表示することはできますが、いったんCPU上に画像をコピーし、またGPUに戻すような処理をしており、余計な処理負荷がかかってしまいます。
本記事投稿後、CPUにコピーせず、直接GPU内から映像を送る方法が分かりました。その方法を以下の記事で紹介しております。そちらのほうが処理負荷も小さいのでおすすめです。
MF仮想カメラではOpenSharedResourceByName
でアプリケーション側のテクスチャ共有ハンドルからキャプチャしたウィンドウ画像の入ったテクスチャ(キャプチャテクスチャ)を取得します。加えてCPUからアクセス可能なテクスチャを作成し、キャプチャテクスチャの中身をこのテクスチャにコピーします。
こうすることで、CPUからアクセス可能なテクスチャを経由してキャプチャしたウィンドウ画像を取り出し、仮想カメラの映像として描画できるようになります。テクスチャからビットマップ画像を得る処理はここを参考に実装しました。 9
// 略
// 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月現在)
-
OBSで映せるので、仮想カメラ周りを自分で実装しようとする人、ほとんどいなさそう... Qiitaにもこの記事含めて2024年6月現在12記事しかありません... ↩
-
DirectShowは仮想カメラが認識されるようになるまで苦戦した記憶があります... ↩
-
DirectShowのときは、一度仮想カメラをインストールするとアンインストールするまでカメラの一覧に入ってました。 ↩
-
DirectShowでは表示名を仮想カメラのコードにハードコーディングする必要がありました。 ↩
-
DirectShowでは32bit版、64bit版のdllを用意しないと、一部のアプリケーションは仮想カメラを認識しません。 ↩
-
FrameServerという1つのサービスでMF仮想カメラを動かすようにすることで、64bit版のdllのビルドのみで済むようになっているのかと思います。 ↩
-
DirectShowのときはZoomに仮想カメラを表示させ、VisualStudioをそのZoomとアタッチさせればデバッグできました。 ↩
-
VisualStudio上では、FrameServerが
svchost.exe
で表示されており、さらに他のサービスもsvchost.exe
で表示されています。 ↩ -
今回は一旦ビットマップとして画像を取り出していますが、この部分をテクスチャから直接仮想カメラの映像を描画するようにできれば、GPUからCPUへの転送が不要となり、より効率的にできそうです。 ↩