DirectXとMediaFoundation仮想カメラとの効率の良い連携

この記事は[グラフィックス全般 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/graphics) 7日目の記事です。

N.Mです。今年もネタがあったので、アドベントカレンダーに参加させていただくことにしました。以前にMediaFoundationで仮想カメラを実装する記事を書きましたが、今回はその動作の改善について触れていきます。


# はじめに -そもそも仮想カメラとは-

仮想カメラは、PC上の映像をWebカメラなどと同じようにカメラの映像として映すためのソフトウェアカメラです。仮想カメラを利用することで別アプリケーションで作った映像をDiscordやTeamsのカメラ映像として映すことができます。

2024年12月現在、Windowsでは以下のフレームワークを利用することで仮想カメラを開発することができます。
- **MediaFoundation** (新しいフレームワーク。仮想カメラの機能はWindows11 (Windows Build 22000以降) のみサポート)
- **DirectShow** (古いフレームワーク。古いWindowsでも仮想カメラの機能を使用できる。)

例えば、配信・録画ソフトウェアであるOBS StudioではDirectShowの仮想カメラが入っております。[^1] また、Windows11のスマートフォン連携で、スマートフォンのカメラをWebカメラとして使う機能では、MediaFoundationの仮想カメラが使われているようです。[^2]

[^1]: OBS Studioがあるため、ただWebカメラとしてPCの映像を映したいということであれば、OBS Studioを使えばいいという話になります...  

[^2]: MediaFoundationの仮想カメラはカメラ名の後に「(Windows 仮想カメラ)」と表示されます。スマートフォンのカメラにもこの表示があったので、MediaFoundationの仮想カメラであると推測しています。

::: note warn
今回の記事では、MediaFoundationのカメラを対象としていますが、仮想カメラの実装方法については触れません。

Windowsのアプリケーションウィンドウをキャプチャし、自作仮想カメラに映す方法については、自分の過去の記事をご覧ください。（ウィンドウのキャプチャにはWinRTのWindowsGraphicsCaptureAPIを使用しています。）

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

- **DirectShow**

    * [DirectShowで仮想カメラを自作しよう](https://qiita.com/HexagramNM/items/2311f025f3af758c83a0)

- **ウィンドウをキャプチャし、仮想カメラに映像を渡す方法**

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

    * [DirectXでテクスチャを別プロセスに共有する](https://qiita.com/HexagramNM/items/5fa74f59f0b7bb4b80e9)

前提として、WindowsGraphicsCapture APIでウィンドウをキャプチャして、DirectXのテクスチャに書き込み、そのテクスチャを仮想カメラ上で表示する試みをしています。

:::


# 今回紹介する改善

[以前の記事の注釈](https://qiita.com/HexagramNM/items/52f5442f26dce89d3502#fn-9)で書いた以下のことを改善します。

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


## 以前の実装の問題

以前は参考にしているsmourier氏の[VCamSample](https://github.com/smourier/VCamSample)で実装されている描画の流れをほぼそのまま採用していました。そのため、キャプチャしたウィンドウ画面のテクスチャを一度ビットマップとして取り出し、そのビットマップをDirect2Dで再度テクスチャに書き込んでいました。Direct2Dでテクスチャを書き込む際に、ウィンドウのサイズに合わせて変形しています。[^3] 

結果として、図のようにCPUとGPUとのデータのやりとりが1往復分余計に発生していました。

[^3]: ウィンドウのサイズ変更の度にテクスチャを作り直す必要がないように、ウィンドウ画面のテクスチャはあらかじめ大きいサイズで作っております。テクスチャをそのまま描画すると余白がかなりある状態で表示されてしまいます。また、ウィンドウ画面は左上に寄った状態になります。仮想カメラの映像めいっぱいに中央にウィンドウを表示するには、ウィンドウのサイズに合わせて、テクスチャを拡大、移動する必要があります。

![oldVcamFlow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/2adf0103-95fa-5640-7a61-4a98480cabe2.png)


## 改善手法

今回、GPU上でキャプチャ結果のテクスチャを変形して描画し、その描画先を別のテクスチャに指定する **オフスクリーンレンダリング** [^4] の手法をとります。もともと使用されていたDirect2Dの機能ではできなさそうであったので、Direct3Dの機能を使って別テクスチャに描画します。[^5] 処理の流れとしては以下の図のようになり、CPUとGPUとのデータのやりとりを削減できます。

[^4]: 基本的にテクスチャなどは表示画面であるスクリーン上に描画しますが、今回のように表示されないメモリ上に描画することをオフスクリーンレンダリングといいます。この方法ではレンダリング結果を別テクスチャとしてポリゴンに貼り付けることができ、応用するとラスタライズ（ポリゴンでの表示）でも鏡面反射のような表現ができるようです。（参考: [OpenGL GLUT cube mapping](https://www.youtube.com/watch?v=PAqBxjb1IBw)）

[^5]: DirectShowの仮想カメラでも、ウィンドウサイズに合わせたテクスチャ変形をCPU上で処理していました。オフスクリーンレンダリングを使用するとテクスチャ変形をGPU上で処理できるので、DirectShowの仮想カメラでも同じような方法で処理効率を改善できそうです。

![newVcamFlow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/4479f7bc-118b-4c13-6ca3-8af27e929dff.png)


# サンプルコード

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

ビルドして実行すると、以下のように、ウィンドウが立ち上がり、仮想カメラを経由してWindows標準のカメラアプリなどに映し出すことができます。 [^6]

[^6]: 映像は過去の記事の使いまわしです。

![NM_MFVCamSample_Result.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/86ed35f9-e5f7-49aa-45f6-b26dde5374f2.gif)

以前の記事から、今回の改善のために主に以下のコードを変更・追加しました。

- [VCamSampleSource/FrameGenerator.h](https://github.com/HexagramNM/NM_MFVcamSample/blob/main/VCamSampleSource/FrameGenerator.h)

- [VCamSampleSource/FrameGenerator.cpp](https://github.com/HexagramNM/NM_MFVcamSample/blob/main/VCamSampleSource/FrameGenerator.cpp)

- [VCamSampleSource/SpriteShader.hlsl](https://github.com/HexagramNM/NM_MFVcamSample/blob/main/VCamSampleSource/SpriteShader.hlsl)

- （[VCamSampleSource/MediaStream.cpp](https://github.com/HexagramNM/NM_MFVcamSample/blob/main/VCamSampleSource/MediaStream.cpp) ...`FrameGenerator`側で`SetD3DManager`が不要になったので、その部分を消しています。） 


## 動作環境

以下の自分のマシンの環境で動作を確認していますが、太字の要件を満たしていれば動作すると思います。

- OS: **Windows11**

- CPU: 13th Gen Intel(R) Core(TM) i7-13700H 2.40 GHz

- RAM: 32.0GB

- GPU: NVIDIA GeForce RTX 4060 Laptop GPU (**DirectX11**使用)


# 実装詳細

## 1. DirectXのデバイス作成

前回から共有テクスチャを受け取るためにDirectXのデバイスを作成していますが、さらに2つの処理を行う必要があります。

1. デバイスをデバイスマネージャ`IMFDXGIDeviceManager`に紐づける

2. デバイスに対して、`ID3D11Multithread`の`SetMultithreadProtected`メソッドを呼び出し、`true`に設定する。

1について、<ins>このデバイスマネージャは1つのDirectXのデバイスを複数スレッドで使用できるようにするために必要です。</ins>後続の`MediaFoundationTransform`で映像を1ピクセル当たりのビット数が少ないフォーマットに変換していますが、この処理が別スレッドでDirectXの処理を行っているために必要なようです。

デバイスマネージャは`MFCreateDXGIDeviceManager`関数で作成でき、その際に`resetToken`という`UINT`のデータを取得できます。DirectXのデバイスとこの`resetToken`を引数に渡した上で、デバイスマネージャのメソッド`ResetDevice`を呼び出すことで、デバイスマネージャとデバイスを紐づけることができます。

[IMFDXGIDeviceManager interface](https://learn.microsoft.com/en-us/windows/win32/api/mfobjects/nn-mfobjects-imfdxgidevicemanager)

2については、<ins>この設定をしないとDirectX側で「複数スレッドから同時に同一のデバイスを操作した」という例外が発生し、動作が停止してしまいます。</ins>[このstackoverflowの質問](https://stackoverflow.com/questions/78865984/should-i-use-semaphore-or-id3d10multithread-to-solve-the-corrution-of-the-m)を参考に、`ID3D11Multithread`を用いて設定しました。

ここまでの処理は、以下のコードで行っております。

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

// 略

HRESULT FrameGenerator::SetupD3D11Device() {
    // デバイスマネージャの作成（1の処理）
	UINT resetToken;
	RETURN_IF_FAILED(MFCreateDXGIDeviceManager(&resetToken, _dxgiManager.put()));

	UINT createDeviceFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;
	createDeviceFlags |= D3D11_CREATE_DEVICE_VIDEO_SUPPORT;
#ifdef _DEBUG
	createDeviceFlags |= D3D11_CREATE_DEVICE_DEBUG;
#endif

	wil::com_ptr_nothrow<ID3D11Device> device;
	D3D_FEATURE_LEVEL d3dFeatures[7] = {
		D3D_FEATURE_LEVEL_11_1
	};

    // DirectXのデバイス作成
	RETURN_IF_FAILED(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE,
		nullptr, createDeviceFlags, d3dFeatures, 1, D3D11_SDK_VERSION,
		device.put(), nullptr, _dxDeviceContext.put()));

    // ID3D11Multithreadで設定（2の処理）
	wil::com_ptr_nothrow<ID3D11Multithread> dxMultiThread;
	_dxDeviceContext->QueryInterface(IID_PPV_ARGS(dxMultiThread.put()));
	dxMultiThread->SetMultithreadProtected(true);

    // DirectXのデバイスをデバイスマネージャに紐づけ（1の処理）
	_dxgiManager->ResetDevice(device.get(), resetToken);

    // 共有テクスチャを受け取るためにID3D11Device1に変換
	device->QueryInterface(IID_PPV_ARGS(_dxDevice.put()));

	return S_OK;
}

// 略

```


## 2. オフスクリーンレンダリングの準備

オフスクリーンレンダリングをする際、画面いっぱいに四角形ポリゴンを1枚作り、そこに元のテクスチャを変形して描画します。この描画先を別のテクスチャにすることで、変形後の画像をテクスチャとして取得できます。

オフスクリーンレンダリングをする上で必要な以下のものをこのコードで準備しています。[^7]

[^7]: 本来はさらにサンプラーという、テクスチャから色を取得したり、範囲外の場合にどう処理するかを決めたりするものが必要ですが、設定しなくてもデフォルトのもので描画はできるようです。（DirectX側で警告は出てしまいますが）

- **変形後の画像を書き込むテクスチャ**

- **レンダーターゲットビュー**

    * 描画結果をどこに記録するかを設定する。今回はテクスチャに描画させる。

    * DirectXのデバイスコンテキストに設定することで、描画先の設定が反映される。

- **シェーダリソースビュー**

    * 描画で使用するテクスチャを登録する。

- **ビューポート**

    * DirectXの世界での座標をスクリーン上の座標に変換する。
	
	* 結果として描画する座標の範囲（≒描画先の画像サイズ）を指定できる。

- **頂点シェーダ、ピクセルシェーダ**

    * DirectXでテクスチャを貼り付けたポリゴンを描画するためのコード

- **入力レイアウト**

    * ポリゴンの頂点を格納する頂点バッファと頂点シェーダとを紐づける。

- **頂点バッファ**

	* 書き込むポリゴンの頂点情報を含む、GPU上のデータ

上記の関係を図に表すと以下のようになります。[^8] DirectXのデバイスから作成するデバイスコンテキストで描画の命令を出すと、図の青矢印の流れで描画の処理がGPU上で行われます。

[^8]: 自分のイメージで描いているので、少し厳密性は欠けるかもしれません

![DirectXFlow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/c2c5092d-ae51-3389-2f05-bd89e84b12b2.png)

このオフラインレンダリングの準備をするコードは以下のコードになります。以下の記事を参考に書いています。

[ゼロから始めるDirectX11ゲームプログラミング入門](https://gamesgard.com/category/programming/gameprogramming/directx11/)

[MinimalOffscreenD3D](https://github.com/carasuca/MinimalOffscreenD3D/tree/master)

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

// 略

// _renderTextureへのオフスクリーンレンダリングの準備
HRESULT FrameGenerator::SetupOffscreenRendering() {

    // 変形後の画像を描画するテクスチャの設定
	DXGI_FORMAT dxgiFormat = DXGI_FORMAT_B8G8R8A8_UNORM;
	CD3D11_TEXTURE2D_DESC desc;
	desc.Width = _width;
	desc.Height = _height;
	desc.Format = dxgiFormat;
	desc.ArraySize = 1;
	desc.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET;
	desc.MipLevels = 1;
	desc.SampleDesc.Count = 1;
	desc.SampleDesc.Quality = 0;
	desc.CPUAccessFlags = 0;
	desc.MiscFlags = 0;
	desc.Usage = D3D11_USAGE_DEFAULT;
	
    // テクスチャの作成
	RETURN_IF_FAILED(_dxDevice->CreateTexture2D(&desc, nullptr, _renderTexture.put()));

    // レンダーターゲットビューを作成し、描画先をテクスチャに指定
	CD3D11_RENDER_TARGET_VIEW_DESC renderTargetViewDesc(D3D11_RTV_DIMENSION_TEXTURE2D, dxgiFormat);
	RETURN_IF_FAILED(_dxDevice->CreateRenderTargetView(_renderTexture.get(),
		&renderTargetViewDesc, _renderTargetView.put()));

	// デバイスコンテキストにレンダーターゲットビューを設定
	_dxDeviceContext->OMSetRenderTargets(1, _renderTargetView.addressof(), nullptr);

    // シェーダリソースビューを作成し、
    // 描画で使用するキャプチャしたウィンドウのテクスチャを登録
	CD3D11_SHADER_RESOURCE_VIEW_DESC shaderResourceViewDesc(D3D11_SRV_DIMENSION_TEXTURE2D, dxgiFormat);
	RETURN_IF_FAILED(_dxDevice->CreateShaderResourceView(_sharedCaptureWindowTexture.get(),
		&shaderResourceViewDesc, _shaderResourceView.put()));

	// ※シェーダリソースビューのみ、この時点ではデバイスコンテキストには設定せず、
	// 毎フレームの描画タイミングでデバイスコンテキストに設定する。
	// （そうしないと更新されるテクスチャが反映されないのか、真っ黒な表示になってしまった。）

    // ビューポートの設定
	D3D11_VIEWPORT vp = { 0.0f, 0.0f, (float)_width, (float)_height, 0.0f, 1.0f };
	_dxDeviceContext->RSSetViewports(1, &vp);

    // 頂点シェーダとピクセルシェーダのコンパイル
    // "VS", "PS"はSpriteShader.hlslにある関数名。この指定でシェーダでどこの処理から開始するかを指定している。
	size_t hlslSize = std::strlen(hlslCode);
	wil::com_ptr_t<ID3DBlob> compiledVS;
	RETURN_IF_FAILED(D3DCompile(hlslCode, hlslSize, nullptr, nullptr, nullptr,
		"VS", "vs_5_0", 0, 0, compiledVS.put(), nullptr));

	wil::com_ptr_t<ID3DBlob> compiledPS;
	RETURN_IF_FAILED(D3DCompile(hlslCode, hlslSize, nullptr, nullptr, nullptr,
		"PS", "ps_5_0", 0, 0, compiledPS.put(), nullptr));

    // 実際に使用するためのシェーダオブジェクトを作成し、デバイスコンテキストに設定
	RETURN_IF_FAILED(_dxDevice->CreateVertexShader(compiledVS->GetBufferPointer(),
		compiledVS->GetBufferSize(), nullptr, _spriteVS.put()));
	_dxDeviceContext->VSSetShader(_spriteVS.get(), 0, 0);

	RETURN_IF_FAILED(_dxDevice->CreatePixelShader(compiledPS->GetBufferPointer(),
		compiledPS->GetBufferSize(), nullptr, _spritePS.put()));
	_dxDeviceContext->PSSetShader(_spritePS.get(), 0, 0);

    // 入力レイアウトを作成し、デバイスコンテキストに設定
    // "POSITION"や"TEXUV"はSpriteShader.hlslのVS関数がとる、引数のラベル
    // TEXUVのところで指定している12は、POSITIONが3つのfloat（4バイト）であり、12バイト分のオフセットが必要であることに由来
	D3D11_INPUT_ELEMENT_DESC layout[2] = {
		{"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0},
		{"TEXUV", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0}
	};

    // 今回、各頂点に対して位置座標とテクスチャ座標の2要素があるので、一部の引数で2を設定
	RETURN_IF_FAILED(_dxDevice->CreateInputLayout(layout, 2, compiledVS->GetBufferPointer(),
		compiledVS->GetBufferSize(), _spriteInputLayout.put()));
	_dxDeviceContext->IASetInputLayout(_spriteInputLayout.get());

    // 頂点バッファを作成
	_vbDesc.BindFlags = D3D11_BIND_VERTEX_BUFFER;
	_vbDesc.ByteWidth = sizeof(VertexType) * 4;
	_vbDesc.MiscFlags = 0;
	_vbDesc.StructureByteStride = 0;
	_vbDesc.Usage = D3D11_USAGE_DEFAULT;
	_vbDesc.CPUAccessFlags = 0;

	D3D11_SUBRESOURCE_DATA initData = {
		_polygonVertex, sizeof(_polygonVertex), 0
	};

	_dxDevice->CreateBuffer(&_vbDesc, &initData, &_vertexBuffer);

	// 頂点バッファをデバイスコンテキストに設定し、その頂点でどのように三角形ポリゴンを作るかを指定
	UINT stride = sizeof(VertexType);
	UINT offset = 0;
	_dxDeviceContext->IASetVertexBuffers(0, 1, _vertexBuffer.addressof(), &stride, &offset);
	_dxDeviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);

	return S_OK;
}

// 略

```

DirectXで描画する際は頂点シェーダやピクセルシェーダが必要になります。<ins>MediaFoundationの仮想カメラはセッション0上で動作し、権限が弱いです。</ins>このため、hlslファイルを作っても、読むことのできない可能性が高いです。そのため、以下を参考にプリプロセッサでシェーダコードをC++コード内に埋め込んでいます。

[C++ソース内にシェーダソースを文字列として埋め込む](https://tech.hikware.com/article/20171025b.html)

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

// 略

#define HLSL_EXTERNAL_INCLUDE(...) #__VA_ARGS__

// シェーダコードの埋め込み
// SpritShader.hlsl内でシェーダコードをHLSL_EXTERNAL_INCLUDEマクロで囲む。
const char* hlslCode =
#include "SpriteShader.hlsl"
;

// 略

```

また、このシェーダはビルド時にはコンパイルしないように設定する必要があります。VisualStudioでこのhlslコードのプロパティを開くと、「項目の種類」の設定が「HLSLコンパイラ」となっているので、これを「ビルドに含めない」に変更します。

SpriteShader.hlsl自体はテクスチャを貼った1枚ポリゴンを映すだけの、シンプルなシェーダコードです。以下の記事にあったものを、少し修正して使用している感じです。

[ゼロから始めるDirectX11ゲームプログラミング入門 #6「画像を面に貼ろう！ - テクスチャマッピング その２- 」](https://gamesgard.com/directx11_lesson06/)

## 3. キャプチャするウィンドウのサイズに合わせたオフスクリーンレンダリング

以下のコードでは、まず毎フレームウィンドウのサイズに合わせてポリゴンの頂点位置やテクスチャ座標を計算し、頂点バッファの中身を更新しています。その後、デバイスコンテキストにシェーダリソースビューを設定し、描画命令を出しています。

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

// 略

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

	// 画面を黒で塗りつぶし
	float color[4] = { 0.0f, 0.0f, 0.0f, 1.0f };
	_dxDeviceContext->ClearRenderTargetView(_renderTargetView.get(), color);

	float xPosRate = 1.0f;
	float yPosRate = 1.0f;
	float rectRate = static_cast<float>(_width) / static_cast<float>(_height);
	float floatWindowWidth = static_cast<float>(_captureWindowWidth);
	float floatWindowHeight = static_cast<float>(_captureWindowHeight);

	// サンプルコードにある動画のように、ウィンドウが変形しても画面いっぱいに見切れることなく
	// ウィンドウが描画されるようにポリゴン頂点の座標を計算
	if (floatWindowWidth > floatWindowHeight * rectRate) {
		yPosRate = floatWindowHeight * rectRate / floatWindowWidth;
	}
	else {
		xPosRate = floatWindowWidth / (floatWindowHeight * rectRate);
	}

	// テクスチャの余白を見せないように、必要なテクスチャ座標を計算
	float widthTextureRate = static_cast<float>(_captureWindowWidth) 
		/ static_cast<float>(_captureTextureWidth);
	float heightTextureRate = static_cast<float>(_captureWindowHeight) 
		/ static_cast<float>(_captureTextureHeight);
	
	// 頂点バッファに格納する頂点データ（位置座標とテクスチャ座標のセット）を更新
	_polygonVertex[0] = { {-xPosRate, yPosRate, 0}, {0, 0} };
	_polygonVertex[1] = { {xPosRate, yPosRate, 0}, {widthTextureRate, 0} };
	_polygonVertex[2] = { {-xPosRate, -yPosRate, 0}, {0, heightTextureRate} };
	_polygonVertex[3] = { {xPosRate, -yPosRate, 0}, {widthTextureRate, heightTextureRate} };
	_dxDeviceContext->UpdateSubresource(_vertexBuffer.get(), 0, nullptr, _polygonVertex, 0, 0);

	// キャプチャウィンドウのテクスチャは他プロセスにもアクセスされるので、排他処理を行う
	wil::com_ptr_nothrow<IDXGIKeyedMutex> mutex;
	_sharedCaptureWindowTexture->QueryInterface(IID_PPV_ARGS(mutex.put()));
	mutex->AcquireSync(MUTEX_KEY, INFINITE);

	// キャプチャウィンドウのテクスチャを更新するために、
	// 毎フレーム、デバイスコンテキストにシェーダリソースビューを設定
	_dxDeviceContext->PSSetShaderResources(0, 1, _shaderResourceView.addressof());

	// デバイスコンテキストでGPUに描画命令を出す
	_dxDeviceContext->Draw(4, 0);
	_dxDeviceContext->Flush();
	mutex->ReleaseSync(MUTEX_KEY);
}

// 略

```


## 4. 変形後のテクスチャをサンプルとして仮想カメラに渡す

MediaFoundationの仮想カメラで映像を映すには、映す画像のピクセルデータが入った`IMFMediaBuffer`を`IMFSample`に追加し、`outSample`に渡す必要があります。<ins>MediaFoundationではDirectXのテクスチャから`IMFMediaBuffer`を作る`MFCreateDXGISurfaceBuffer`という関数があります。</ins>これを使い、できたものを`AddBuffer`メソッドでサンプルに追加します。

また、よりビット数の少ないフォーマットに変換するために、一度サンプルを`MFTransform`である`_converter`で処理し、変換後のサンプルを`outSample`に渡すこともしています。[^9]

[^9]: このあたりのコードは元のsmourier氏のコードをそのまま採用しています。

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

// 略

HRESULT FrameGenerator::Generate(IMFSample* sample, REFGUID format, IMFSample** outSample)
{
	// 略

	DrawSharedCaptureWindow(); // オフスクリーンレンダリングの処理

    // ここまでで _renderTexture に変形後のテクスチャができています。
    // これ以降は元のソースコードにもあった処理です。
	wil::com_ptr_nothrow<IMFMediaBuffer> mediaBuffer;

	// サンプル内のバッファの削除（リセット）
	RETURN_IF_FAILED(sample->RemoveAllBuffers());

	// _renderTextureからIMFMediaBufferを作成し、サンプルにこのバッファを追加
	RETURN_IF_FAILED(MFCreateDXGISurfaceBuffer(__uuidof(ID3D11Texture2D), _renderTexture.get(), 0, 0, &mediaBuffer));
	RETURN_IF_FAILED(sample->AddBuffer(mediaBuffer.get()));

	if (format == MFVideoFormat_NV12)
	{
        // フォーマットがNV12の場合は、_converter (IMFTransform... MediaFoundationTransform) を用いて、
        // GPU上でBGRAからフォーマットの変換
		assert(_converter);
		RETURN_IF_FAILED(_converter->ProcessInput(0, sample, 0));

		MFT_OUTPUT_DATA_BUFFER buffer = {};
		DWORD status = 0;
		RETURN_IF_FAILED(_converter->ProcessOutput(0, 1, &buffer, &status));
		*outSample = buffer.pSample;
	}
	else
	{
		sample->AddRef();
		*outSample = sample;
	}

	return S_OK;
}

// 略

```


# 処理時間計測

Releaseモードでビルドし、[動作環境](#動作環境)に記載した自分のマシンで、映像の1フレームを生成するのにかかる時間を計測してみました。仮想カメラに映す映像の画素数は1920px×1080pxで計測しました。

リポジトリには入れていませんが、`VCamSampleSource/MediaStream.cpp`のコードを以下のように修正して、マイクロ秒単位の時間を計測できるようにしています。以下を参考に、Visual Studioでプロセスにアタッチした際、計測結果が出力ウィンドウに表示されるようにしました。

[Visual Studioの出力ウィンドウにデバッグ文字列を出力する(ATLTRACE, AtlTrace, _RPTn)](https://donadona.hatenablog.jp/entry/2016/02/07/171459)

```C++:VCamSampleSource/MediaStream.cpp
// 略

#include <chrono>

// 略

STDMETHODIMP MediaStream::RequestSample(IUnknown* pToken)
{
    // 略

	// 計測開始地点
    wchar_t str[256];
    auto start = std::chrono::high_resolution_clock::now();

    // ここがフレーム生成処理本体
    wil::com_ptr_nothrow<IMFSample> outSample;
	RETURN_IF_FAILED(_generator.Generate(sample.get(), _format, &outSample));

    // 計測終了と結果の出力
    auto end = std::chrono::high_resolution_clock::now();
    swprintf_s(str, L"Generate time: %lld micro sec\n",
	std::chrono::duration_cast<std::chrono::microseconds>(end - start).count());
    OutputDebugString(str);

    // 略

	return S_OK;
}

// 略

```

改善前と改善後それぞれについて、仮想カメラの映像をDiscordで映した場合、Windows標準のカメラアプリ（標準カメラ）に映した場合の2パターン計測しました。結果としては以下のようになりました。[^10]

|  | 改善前<br>Discord | 改善前<br>標準カメラ | 改善後<br>Discord | 改善後<br>標準カメラ |
|:---:| ---:| ---:| ---:| ---:|
| 1フレームあたりの平均処理時間<br>（マイクロ秒） | 10288 | 6812 | **966** | **1085** |
| 分散<br> (マイクロ秒^2) | $4.849 × 10^6$ | $3.937 × 10^6$ | $4.751 × 10^5$ | $8.789 × 10^5$ |
| 計測フレーム数 | 1690 | 1723 | 1815 | 1773 |

[^10]: 改善前の場合、DiscordかWindows標準のカメラアプリかでも処理時間が異なるのは、`FrameGenerator`の`SetD3DManager`が呼ばれたかどうかで処理が分岐されていたからです。Discordの時はこの`SetD3DManager`が呼ばれず、RGBAのフォーマットをよりピクセル当たりのビット数の少ないフォーマットに変換する処理がCPUで行われます。一方、Windows標準のカメラアプリでは`SetD3DManager`が呼ばれ、このフォーマット変換がMediaFoundationの機能でGPU上で行われます。GPUで並列処理が行われるため、Windows標準のカメラアプリの場合はDiscordの場合よりも処理時間が短くなります。

CPUとGPUとのデータのやりとりがなくなった分、改善後では改善前と比較し、大幅に処理時間が短縮されました。


# まとめ

今回のオフスクリーンレンダリングで、以前の記事投稿時点で自分がモヤモヤしていた少し非効率な処理を改善することができました。オフスクリーンレンダリングはいろいろ応用が利く手法かと思います。

今年、自分はMediaFoundationの仮想カメラを知りました。そして、今年はMediaFoundation仮想カメラを自作したり、得られた知見を発信したりした年となりました。[^11] 皆さんの開発の参考になれば幸いです。

[^11]: ちなみに自作のMediaFoundation仮想カメラでごくまれにクラッシュするバグを見つけ、1か月前くらいにようやく原因や修正方法を見つけました。元にしているsmourier氏のコードの時点で存在していた問題だったので、issueを投げて報告し、現在はsmourier氏側のコードも修正していただいております。