N.Mです。
以前、DirectShowで仮想カメラを作成し、Qiitaに記事を投稿しました。
(DirectShowについては、この以前の記事をご覧ください。)
その時はアプリケーションで表示する映像をZoomなどのカメラに表示するために、GDI関数であるBitBltでピクセル情報を取得しました。
今回は古いGDI関数ではなく、WinRTに含まれるWindows.Graphics.Captureという比較的新しいAPIを利用して、アプリケーションの映像をキャプチャし、DirectShowの仮想カメラフィルタに送ってみました。
BitBltでの問題
ChromeやDiscordなどのアプリケーションでは、ハードウェアアクセラレーションというレンダリングを高速化するための機能があります。これを使用してウィンドウを表示している場合は、BitBltでピクセル情報を取得することができません。
ハードウェアアクセラレーションをONにするとGPU上で描画の処理を行い、その結果をウィンドウに貼り付けますが、BitBltだとGPU上で処理された映像を取得することができず、貼り付ける前の真っ黒な映像が取得されます。
WinRTのWindows.Graphics.Captureでは、GPU上で描画処理をした映像でもDirectXのテクスチャという形で取得することができ、以下のようにハードウェアアクセラレーションをONにしたChromeの映像も仮想カメラに映すことができます。
サンプルコード
Githubリポジトリにあげているので、参考にしてください。
コンパイル時の注意点
今回、DirectShowというかなり古いAPIとWinRTという新しめのAPIを組み合わせようとしているため、コンパイルの段階で注意すべき点が3つあります。
-
新しめのWindowsSDK (10.0.19041.0)を使用する必要があります。Visual Studio 2017を使用している場合は、ここからインストールする必要があります。
-
言語標準をC++17に従う必要があります。Visual Studioでコンパイルする場合は、プロジェクトのプロパティで
C/C++ → 言語 → C++言語標準
をISO C++17標準
に設定する必要があります。(WinRTではco_awaitなど新しいC++の標準に則ったプログラミングもされているため。) -
ヘッダファイルでWinRTに関係するヘッダファイルをインクルードする前に
#undef DisplayType
を記述する必要があります。(DirectShowで定義されているDisplayType
というマクロがWinRTのヘッダファイルで競合しているため。)
WinRTからDirectShowに映像を渡すまでの流れ
WinRTからDirectShowに映像を渡すまでは、以下のような流れになります。
-
Direct3DDeviceとCPUで読み出し可能なバッファテクスチャの設定
-
キャプチャする対象となるウィンドウ情報をもつ
GraphicsCaptureItem
の取得 -
1のDirect3DDeviceと2の
GraphicsCaptureItem
から、Direct3D11CaptureFramePool
を作成 -
3で作成した
Direct3D11CaptureFramePool
のイベントFrameArrived
で、キャプチャした画像を1で設定したCPUで読み出し可能なバッファテクスチャにコピー -
CPUで読み出し可能なバッファテクスチャからピクセル情報を取得
-
DirectShowの
FillBuffer
関数内で、引数であるpSample
に5で取得したピクセル情報を渡す。
この後、1つ1つ詳細を解説していきます。
Step1. Direct3DDeviceとCPUで読み出し可能なバッファテクスチャの設定
以下のcreateDirect3DDevice
をソースフィルタのピンクラスであるCSourceStream
のコンストラクタで呼び出して、Direct3DDeviceの設定をします。設定したDirect3DDeviceを利用して、CPUで読み出し可能なテクスチャの作成も行います。
CPUで読み出し可能なテクスチャはウィンドウサイズの変更に対応できるように、大きめに作成しておきます。
//コンストラクタあたりでこの関数を呼び出します。
void NMVCamPin::createDirect3DDevice() {
UINT createDeviceFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CPU_ACCESS_READ;
#ifdef _DEBUG
createDeviceFlags |= D3D11_CREATE_DEVICE_DEBUG;
#endif
//m_dxDevice...IDirect3DDevice型
//今回取得する目的のオブジェクト。m_dxDeviceはStep3, 4で使用します。
if (m_dxDevice != nullptr) {
m_dxDevice.Close();
}
com_ptr<ID3D11Device> d3dDevice = nullptr;
check_hresult(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE,
nullptr, createDeviceFlags, nullptr, 0, D3D11_SDK_VERSION,
d3dDevice.put(), nullptr, nullptr));
com_ptr<IDXGIDevice> dxgiDevice = d3dDevice.as<IDXGIDevice>();
com_ptr<::IInspectable> device = nullptr;
check_hresult(::CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.get(), device.put()));
m_dxDevice = device.as<winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice>();
//m_deviceCtx...com_ptr<ID3D11DeviceContext>型
//m_deviceCtxはStep4, 5で使用する。
d3dDevice->GetImmediateContext(m_deviceCtx.put());
//CPUから読みだすためのバッファテクスチャ
//m_bufferTextureDesc...D3D11_TEXTURE2D_DESC型
//リサイズのために大きめのサイズでテクスチャを作っておきます。
//(サンプルでは仮想カメラが1280px×720pxに対し、バッファテクスチャは3840px×2160pxで設定しております。)
m_bufferTextureDesc.Width = MAX_CAP_WIDTH;
m_bufferTextureDesc.Height = MAX_CAP_HEIGHT;
m_bufferTextureDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
m_bufferTextureDesc.ArraySize = 1;
m_bufferTextureDesc.BindFlags = 0;
m_bufferTextureDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
m_bufferTextureDesc.MipLevels = 1;
m_bufferTextureDesc.MiscFlags = 0;
m_bufferTextureDesc.SampleDesc.Count = 1;
m_bufferTextureDesc.SampleDesc.Quality = 0;
m_bufferTextureDesc.Usage = D3D11_USAGE_STAGING;
//m_bufferTexture...ID3D11Texture2D型
d3dDevice->CreateTexture2D(&m_bufferTextureDesc, 0, &m_bufferTexture);
}
Step2. GraphicsCaptureItem
の取得
キャプチャ対象となるウィンドウの情報をもつGraphicsCaptureItemを取得する必要がありますが、WinRTに含まれるGraphicsCapturePickerを使用する方法と、ウィンドウハンドル(HWND)を用いて作成する方法の2通りあります。
前者は長くなるのでTipsに後述します。前者の方法だとユーザがどのウィンドウをキャプチャするか指定することができるため、汎用性は高いですが、場合によってピッカーUIが開かないなどの問題があります。(サンプルコードの方では前者のGraphicsCapturePickerを使用する方法でやっております。)
後者については以下の記事が参考になります。特定のウィンドウをキャプチャする場合は後者のほうが手っ取り早いです。
ほぼ後者の記事の引用になってしまいますが、特定ウィンドウ名のGraphicsCaptureItemを取得するコードです。
(サンプルでは前者を採用しているため、このコードはリポジトリには含まれておりません。)
struct WindowCell {
HWND window;
WCHAR name[256];
};
BOOL CALLBACK EnumWndProc(HWND hWnd, LPARAM lParam) {
//1つのウィンドウについて、指定した名前のウィンドウか確認し、そうならば、lParam(WindowCellのポインタを渡す)に
//そのウィンドウハンドルを入れて返すコールバック関数。
WCHAR buff[256] = L"";
SendMessageTimeoutW(hWnd, WM_GETTEXT, sizeof(buff), (LPARAM)buff, SMTO_BLOCK, 100, NULL);
if (wcscmp(buff, ((WindowCell *)lParam)->name) == 0) {
((WindowCell *)lParam)->window = hWnd;
return FALSE;
}
return TRUE;
}
GraphicsCaptureItem CreateCaptureItemForWindow(HWND hwnd)
{
namespace abi = ABI::Windows::Graphics::Capture;
WindowCell wc;
wc.window = NULL;
//キャプチャ対象とするウィンドウ名をwc.nameにコピー
wcscpy(wc.name, "TARGET_WINDOW_NAME");
//デスクトップ上にあるウィンドウを走査し、特定ウィンドウ名のウィンドウハンドルを取得します。
EnumWindows(EnumWndProc, (LPARAM)&wc);
//以降のコードは参考記事からほぼ引用
auto factory = get_activation_factory<GraphicsCaptureItem>();
auto interop = factory.as<::IGraphicsCaptureItemInterop>();
GraphicsCaptureItem item{ nullptr };
if (wc.window != NULL) {
check_hresult(interop->CreateForWindow(wc.window, guid_of<abi::IGraphicsCaptureItem>(),
reinterpret_cast<void**>(put_abi(item))));
}
return item;
}
Step3. Direct3D11CaptureFramePool
の作成
Direct3DDeviceとGraphicsCaptureItemを取得することができれば、以下のようなコードでキャプチャを開始することができます。
この時にGraphicsCaptureSession.IsCursorCaptureEnabled(bool)
を使用して、マウスカーソルもキャプチャするかの設定も可能です。
//m_graphicsCaptureItem...GraphicsCaptureItem型
//ここに取得したGraphicsCaptureItemを入れます。
m_graphicsCaptureItem = targetCaptureItem;
//m_capWinSize...SizeInt32型
//現在のウィンドウのサイズを格納し、ウィンドウのサイズが変更されたかを検出するためのデータ
m_capWinSize = m_graphicsCaptureItem.Size();
//m_capWinSizeInTexture...D3D11_BOX型
//こちらも現在のウィンドウサイズを格納しますが、Step4のテクスチャのコピーの際に使用します。
m_capWinSizeInTexture.right = m_capWinSize.Width;
m_capWinSizeInTexture.bottom = m_capWinSize.Height;
//m_framePool...Direct3D11CaptureFramePool型
m_framePool = Direct3D11CaptureFramePool::CreateFreeThreaded(m_dxDevice, DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, m_capWinSize);
//m_frameArrived...event_revoker<IDirect3D11CaptureFramePool>型
//1フレームキャプチャできた時に呼ばれるイベントFrameArrivedを設定します。詳細はStep4
m_frameArrived = m_framePool.FrameArrived(auto_revoke, { this, &NMVCamPin::onFrameArrived });
//m_captureSession...GraphicsCaptureSession型
//このオブジェクトを介してキャプチャを開始できます。
m_captureSession = m_framePool.CreateCaptureSession(m_graphicsCaptureItem);
//IsCursorCaptureEnabledでマウスカーソルもキャプチャするか指定できます。
//trueでカーソルも入り、falseでカーソルは消えます。
m_captureSession.IsCursorCaptureEnabled(false);
m_captureSession.StartCapture();
Step4. Direct3D11CaptureFramePool.FrameArrived
でDirect3D11CaptureFrame
の取得
Step3でm_frameArrived
にイベントを設定していましたが、そのイベント時に呼び出す処理が以下の通りです。
onFrameArrived
の引数であるsender
のTryGetNextFrame
でキャプチャしたフレームを取得できます。これをID3D11Texture2D
のテクスチャの形に変換し、CPUで読み出し可能なバッファテクスチャにコピーします。
ID3D11DeviceContext::CopySubresourceRegion
(詳細)
でCPUで読み出し可能なテクスチャの一部にコピーするようにしております。ここを参考サイトのようにID3D11DeviceContext::CopyResource
を使用してコピーしようとすると、コピー元とコピー先でサイズが一致する必要があります。結果としてウィンドウのリサイズに対応しようとするといちいちCPUで読み出し可能なテクスチャをそのウィンドウサイズに合わせて作り直す必要が発生し、処理が重くなってしまいます。
void NMVCamPin::onFrameArrived(Direct3D11CaptureFramePool const &sender,
winrt::Windows::Foundation::IInspectable const &args)
{
auto frame = sender.TryGetNextFrame();
SizeInt32 itemSize = frame.ContentSize();
if (itemSize.Width <= 0) {
itemSize.Width = 1;
}
if (itemSize.Height <= 0) {
itemSize.Height = 1;
}
if (itemSize.Width != m_capWinSize.Width || itemSize.Height != m_capWinSize.Height) {
//ウィンドウサイズが変更された場合の処理。
//Direct3D11CaptureFramePoolのRecreateメソッドでキャプチャ中も簡単にサイズ変更ができます。
m_capWinSize.Width = itemSize.Width;
m_capWinSize.Height = itemSize.Height;
m_capWinSizeInTexture.right = m_capWinSize.Width;
m_capWinSizeInTexture.bottom = m_capWinSize.Height;
m_framePool.Recreate(m_dxDevice, DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, m_capWinSize);
}
//frameからID3D11Texture2Dに変換します。
com_ptr<ID3D11Texture2D> texture2D = getDXGIInterfaceFromObject<::ID3D11Texture2D>(frame.Surface());
//CPU読み込み可能なバッファテクスチャにGPU上でデータコピー
m_deviceCtx->CopySubresourceRegion(m_bufferTexture, 0, 0, 0, 0,
texture2D.get(), 0, &m_capWinSizeInTexture);
//Step5の処理
convertFrameToBits();
}
Step5. CPUで読み出し可能なバッファテクスチャからピクセル情報の取得
CPUで読み出し可能なテクスチャにコピーできれば、D3D11_MAPPED_SUBRESOURCE
にマップすることで、キャプチャされた画像の画素にアクセスできます。この画素情報をunsigned char
型の配列に格納していきます。
void NMVCamPin::convertFrameToBits() {
//m_frameBitsはunsigned charのポインタで、コンストラクタで(仮想カメラの横ピクセル数×仮想カメラの縦ピクセル数×3)の要素数の配列として
//動的生成しております。1画素につきRGBの計24ビット=3バイトであるため×3となっております。
D3D11_MAPPED_SUBRESOURCE mapd;
HRESULT hr;
hr = m_deviceCtx->Map(m_bufferTexture, 0, D3D11_MAP_READ, 0, &mapd);
const unsigned char *source = static_cast<const unsigned char *>(mapd.pData);
int texBitPos = 0;
//取得したピクセル情報からビットマップを作る処理
//説明を簡単にするためのコードであるため、以下のコードだとキャプチャされた画像がそのままのサイズで上下反転した状態で出ます。
//仮想カメラ内に収める場合については、サンプルリポジトリのNMVCamPin::changePixelPos()やNMVCamPin::convertFrameToBits()をご覧ください。
int pixelPosition = 0;
int bitPosition = 0;
for (int y = 0; y < WINDOW_HEIGHT; y++) {
for (int x = 0; x < WINDOW_WIDTH; x++) {
texBitPos = mapd.RowPitch * y + 4 * x;
for (int cIdx = 0; cIdx < 3; cIdx++) {
m_frameBits[bitPosition + cIdx] = source[texBitPos + cIdx];
}
pixelPosition++;
bitPosition += 3;
}
}
m_deviceCtx->Unmap(m_bufferTexture, 0);
}
Step6. DirectShowのFillBuffer
関数での、ピクセル情報の送信
あとは継承しているソースフィルタのピンクラスCSourceStream
のFillBuffer
メソッドで、以下のようにStep5で取得した画素情報を送ることで、仮想カメラにキャプチャした映像が表示されます。
FillBuffer
メソッドの最後にsleep_for
メソッドを入れ、適度に他のスレッドにCPU時間を渡すと、仮想カメラ起動中もZoomの応答が良くなりました。(なかった場合は、Zoomのボタンがなかなか反応しませんでした。)
HRESULT NMVCamPin::FillBuffer(IMediaSample *pSample) {
HRESULT hr=E_FAIL;
CheckPointer(pSample,E_POINTER);
// ダウンストリームフィルタが
// フォーマットを動的に変えていないかチェック
ASSERT(m_mt.formattype == FORMAT_VideoInfo);
ASSERT(m_mt.cbFormat >= sizeof(VIDEOINFOHEADER));
LPBYTE pSampleData=NULL;
const long size=pSample->GetSize();
//ここで画素情報を送るべきメモリへのポインタpSampleDataを取得しています。
pSample->GetPointer(&pSampleData);
//中略
//pSampleDataへ画素情報をコピー
//WINDOW_WIDTH...仮想カメラの横の画素数
//WINDOW_HEIGHT...仮想カメラの縦の画素数
memcpy(pSampleData, m_frameBits, WINDOW_WIDTH * WINDOW_HEIGHT * 3);
//中略
//CPU使用率を抑えて、ZoomなどのUIの反応をしやすくするために適度にSleepします。
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
Tips
終了時やウィンドウ切替え時の後始末
ウィンドウを切り替える時やキャプチャを終了するときは、一度以下のようなコードで後始末をする必要があります。
void NMVCamPin::stopCapture() {
if (m_framePool != nullptr) {
//FrameArrivedイベントの取り消し
m_frameArrived.revoke();
//GraphicsCaptureSessionの後始末
m_captureSession = nullptr;
//Direct3D11CaptureFramePoolの後始末
m_framePool.Close();
m_framePool = nullptr;
//前に使用していたGraphicsCaptureItemの後始末
m_graphicsCaptureItem = nullptr;
}
}
ただ、この後始末はDirect3D11CaptureFramePool
やGraphicsCaptureSession
を生成したスレッドと同じスレッドで行う必要があります。別のスレッドで後始末をしてしまうとwrong_thread
という例外が発生し、Visual Studioでは「別スレッド用にマーシャリングされたインタフェースを呼び出しました」というメッセージが表示され、止まってしまいます。
キャプチャするウィンドウをいつでも切り替えられるように、Step3をCSourceStream
のFillBuffer
メソッドで行っていた場合は、後始末をCSourceStream
クラスのデストラクタで呼ぶと上記のwrong_thread
の例外が発生します。CSourceStream
は別スレッドでFillBuffer
を実行しているようで、デストラクタが実行されるスレッドとは異なるようです。
CSourceStream
クラスには、カメラ切替えなどで終了し、FillBuffer
を実行していたスレッドが破棄される際にOnThreadDestroy
という仮想メソッドが呼ばれるため、このメソッドをオーバーライドし、ここで後始末をすれば、例外は発生しません。
HRESULT NMVCamPin::OnThreadDestroy() {
stopCapture();
return NOERROR;
}
GraphicsCapturePickerの利用(要調査)
GraphicsCapturePickerを起動する際に、別スレッドで起動をするようにしました。
(サンプルコードでは、FillBuffer
メソッド内でDirectInputによるキー入力を受け付けることで、起動しております。)
//m_isSelectingWindow...bool型
//ピッカーでウィンドウを選択中かどうかを管理します。
m_isSelectingWindow = true;
m_capturePickerThread = new std::thread([](NMVCamPin *inst) {inst->openCaptureWindowPicker(); }, this);
生成した別スレッドでは、以下のような処理をしております。(参考)
UWPアプリではない場合は、ピッカーをウィンドウと関連付ける必要があるため、ウィンドウを生成し、ピッカーの選択肢から生成ウィンドウを外すためにSetWindowDisplayAffinity
で設定をしています。
また、ピッカーを開く前に、init_apartment();
をする必要がありますが、CSourceStream
のメソッドでやると、仮想カメラを開くアプリケーションによっては例外が発生してしまいます。
(init_apartment();
はWinRTのCOMの初期化をしているようです。仮想カメラを開く頃にはアプリケーション側で初期化が済んでおり、この時の初期化の設定(STAかMTA)がアプリケーションで行う初期化での設定とずれていると例外が発生します。)
そのため、別スレッドでピッカーの起動処理を行っているわけです。
void NMVCamPin::openCaptureWindowPicker() {
init_apartment();
//ピッカーに関連付けるウィンドウの生成します。
m_attatchedWindow = CreateWindowW(L"STATIC", L"NMUniversalVCam", SS_WHITERECT,
0, 0, 300, 1, NULL, NULL, GetModuleHandleW(NULL), NULL);
ShowWindow(m_attatchedWindow, SW_SHOW);
UpdateWindow(m_attatchedWindow);
//ピッカーを開くために作ったウィンドウをピッカーの選択肢から除外します。
SetWindowDisplayAffinity(m_attatchedWindow, WDA_EXCLUDEFROMCAPTURE);
updateAttatchedWindow();
//ウィンドウとピッカーとの関連づけます。
GraphicsCapturePicker picker;
auto interop = picker.as<::IInitializeWithWindow>();
m_pickerResult = interop->Initialize(m_attatchedWindow);
//ピッカーからの結果を受け取るためのオブジェクトをm_graphicsCaptureAsyncResultに格納します。
m_graphicsCaptureAsyncResult = picker.PickSingleItemAsync();
while (m_isSelectingWindow) {
//結果を受け取るまで生成したウィンドウへのメッセージを処理します。
//FillBufferのスレッドで結果を受け取ると、m_isSelectingWindowがfalseになります。
updateAttatchedWindow();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
updateAttatchedWindow();
}
void NMVCamPin::updateAttatchedWindow() {
MSG msg;
//GetMessageだとメッセージを受け取るまでここでブロックしてしまうので、
//PeekMessageを使用します。
if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE) > 0) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
仮想カメラピンクラスのFillBuffer
メソッド側では、以下のようにしてピッカーからの結果を受け取ります。
GraphicsCapturePicker
に関する参考記事等ではm_graphicsCaptureAsyncResult
に対しco_await
を使用することで、待機しているようですが、co_awaitを使用すると、使用した関数(今回だとFillBuffer
メソッド)の返り値もIAsyncAction
にする必要があります。
FillBuffer
メソッドはオーバーライドしたメソッドで、返り値を変更できないため、手動で待機処理を再現しております。
if (m_isSelectingWindow) {
if (m_graphicsCaptureAsyncResult) {
//ピッカーから結果が来ると、statusがCompletedになります。
//FillBufferで毎回このstatusを確認することで、ピッカーから結果が返ってきたかどうかを確認します。
winrt::Windows::Foundation::AsyncStatus status = m_graphicsCaptureAsyncResult.Status();
if (status == winrt::Windows::Foundation::AsyncStatus::Completed) {
GraphicsCaptureItem tmpTarget = m_graphicsCaptureAsyncResult.GetResults();
//changeWindowで、生成ウィンドウの破棄とStep3のDirect3D11CaptureFramePool生成処理を行っております。
changeWindow(tmpTarget);
m_isSelectingWindow = false;
//ピッカー生成のために起動した別スレッドの終了
m_capturePickerThread->join();
delete m_capturePickerThread;
m_capturePickerThread = nullptr;
}
}
}
ただ、ここまでの方法だと一部のアプリケーションではうまくピッカーが開きませんでした。別スレッドで生成するウィンドウは開きますが、GraphicsCapturePicker
のピッカーが立ち上がらず、すぐに生成したウィンドウが閉じてしまいます。
ZoomやFireFox, OpenCVでカメラ管理をする自作アプリではピッカーが開きましたが、DiscordやChrome, Slackでは開きませんでした。この部分は時間があるときに調査しようと思います。(ご存知の方がいらっしゃれば、コメント等で教えていただけると幸いです。)
まとめ
前回の仮想カメラの記事から発展して、新しめであるWinRTのAPIを古いDirectShowでも利用できるという話でした。これを利用すれば、Zoomなどにある元々の画面共有に加えて自分のカメラも画面共有として利用することもできるでしょう。