ComputeShaderでの最適化例: DirectShowの仮想カメラ

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

N.Mです。最近DirectXで今まで作ったものを最適化するのに味を占めています。

[以前の記事](https://qiita.com/HexagramNM/items/66895f843d12e4ebd203)で書いたオフスクリーンレンダリングを使用した最適化を、DirectShowの仮想カメラに対して進めていました。その最中に、さらに別のところでもDirectXの機能で最適化できるところを見つけたので今回記事にしました。コンピュートシェーダもなんだかんだ初めて使用してみたので、使い方をわかりやすく紹介できればと思います。

::: note warn

この記事は以前投稿した[WindowsGraphicsCapture APIと仮想カメラの記事](https://qiita.com/HexagramNM/items/8493350d40608433421c)の実装を最適化するという内容になります。WindowsGraphicsCapture APIやDirectShowの仮想カメラの詳細はこの記事では割愛しますので、気になる方は以下の記事をご覧ください。

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

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

:::

# 今回最適化する箇所
[以前の記事のStep5](https://qiita.com/HexagramNM/items/8493350d40608433421c#step5-cpu%E3%81%A7%E8%AA%AD%E3%81%BF%E5%87%BA%E3%81%97%E5%8F%AF%E8%83%BD%E3%81%AA%E3%83%90%E3%83%83%E3%83%95%E3%82%A1%E3%83%86%E3%82%AF%E3%82%B9%E3%83%81%E3%83%A3%E3%81%8B%E3%82%89%E3%83%94%E3%82%AF%E3%82%BB%E3%83%AB%E6%83%85%E5%A0%B1%E3%81%AE%E5%8F%96%E5%BE%97)の処理を最適化していきます。この処理では、以下のようにCPUで読み出し可能なバッファテクスチャからピクセル情報を取得し、映像サンプルとして渡せる状態にしていました。

```C++
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);
}
```

仮想カメラ内に収めるようキャプチャされた画像を変形させる処理は、[以前紹介したオフスクリーンレンダリング](https://qiita.com/HexagramNM/items/66895f843d12e4ebd203#%E6%94%B9%E5%96%84%E6%89%8B%E6%B3%95)で効率よく処理できそうです。しかし、映像のピクセルデータを3重ループで処理している処理が見るからに重そうです。[^1] また、テクスチャが1ピクセルにつきBGRA (Blue, Green, Red, Alpha) の32bit使用しているのに対し、映像サンプルとして渡すピクセルデータはBGRの24bitであり、フォーマットが違います。このため、以下の図のようなフォーマット変換を行う必要もあります。

![VideoFormat.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/dbac9f22-3070-c216-05c2-06853a274e6f.png)

[^1]: そもそも、MediaFoundationの仮想カメラでやっていたように、GPU上のデータを直接映像サンプルとして渡す機能がDirectShowにもあれば良かったのですが... 自分が調べた限りだと標準の方法では無さそうでした。

いろいろ調べつつ、ChatGPTでテクスチャのフォーマットや、効率のよいコピーの仕方を聞いていたところ、

```
GPUで32ビットデータから24ビットを取り出す場合、コンピュートシェーダーを使用して、テクスチャデータを適切に処理するのが一般的です。
```

という回答が降ってきました。[^2] この回答をきっかけにコンピュートシェーダを使用して最適化する方向で実装を始めました。

[^2]: 今回の記事で紹介している実装はかなりChatGPTに助けてもらいながら、コーディングしていました。最近の生成AIって日本語もかなり自然で、的確な答えが返ってくることも多いのですごいですね。

## コンピュートシェーダとは

本来GPUは、[以前の記事](https://qiita.com/HexagramNM/items/66895f843d12e4ebd203#2-%E3%82%AA%E3%83%95%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%83%AC%E3%83%B3%E3%83%80%E3%83%AA%E3%83%B3%E3%82%B0%E3%81%AE%E6%BA%96%E5%82%99) で説明したようにポリゴンを描画する役割をもちます。この描画の特性上、GPUは単純な処理を大量に並列処理することが得意です。

この特性を汎用的な計算処理に使えるようにした仕組みがコンピュートシェーダです。テクスチャやGPU上のバッファのデータを入力に、コンピュートシェーダ上で処理を行い、別のバッファに処理結果を書き込むという処理の流れを並列に行えます。DirectXを含む様々なグラフィックスAPIでサポートされています。

## 目標

キャプチャされた画像の入ったテクスチャ（1ピクセルにつきBRGAの計32bit）をコンピュートシェーダで1ピクセルにつきBGRの計24bitのフォーマットに変換し、GPU上のバッファに書き込むことを目指します。最終的にはGPU上のバッファの内容をCPU上のメモリに一括コピーし、仮想カメラに映像サンプルとして渡します。


# サンプルコード

[このリポジトリ](https://github.com/HexagramNM/NMUniversalVCamFilter)にコードを置いています。

![NM_UniversalVirtualCamera.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/0ffdbdca-df40-988d-64f7-d72a3115e1bc.png)

仮想カメラを起動した状態で左SHIFTとスペースを同時に押すと、図の右側にあるウィンドウ選択のピッカーが開きます。選択されたウィンドウは、図の左側のように仮想カメラに映し出されます。

今回の記事に関わるソースコードは以下のファイルになります。

- [NMVCamPinImpl.cpp](https://github.com/HexagramNM/NMUniversalVCamFilter/blob/main/NMVCamPin.cpp)

- [NMVCamFilter.h](https://github.com/HexagramNM/NMUniversalVCamFilter/blob/main/NMVCamFilter.h)

- [SampleFormatter.hlsl](https://github.com/HexagramNM/NMUniversalVCamFilter/blob/main/SampleFormatter.hlsl)


## 動作環境

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

- OS: Windows11（**Windows10**以降なら動作すると思います。）

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

- RAM: 32.0GB

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


# 実装詳細

## 1. コンピュートシェーダのための準備

今回のコンピュートシェーダでは以下のものが必要なので準備します。

- **入力テクスチャ**

    前回のオフスクリーンレンダリングで描画した結果のテクスチャです。

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

    入力テクスチャをコンピュートシェーダに紐づけるビューです。シェーダ上でテクスチャを扱うために必要です。

- **コンピュートシェーダ**

    コンパイルは通常の頂点シェーダやピクセルシェーダと同じ要領です。`CreateComputeShader`や`CSSetShader`は他のシェーダと異なりますが、他のCreate〇〇Shaderや〇〇SetShaderと同じ使い方です。

- **出力先のGPU上のバッファ**

    頂点バッファと似たような流れで設定していきますが、以下のフラグ設定に注意が必要です。

    * `BindFlags`... `D3D11_BIND_UNORDERED_ACCESS`に指定します。

        後述するUnordered Access Viewに紐づけて、シェーダ上で扱うために必要な設定です。

    * `MiscFlags`... `D3D11_RESOURCE_MISC_BUFFER_ALLOW_RAW_VIEWS`に指定します。

        コンピュートシェーダ上でバッファを`RWByteAddressBuffer`でアクセスできるようにし、バイト単位でデータを書き込めるようにするための設定です。

- **CPUからアクセス可能なバッファ**

    上記のGPU上のバッファはCPUからアクセスできません。そのため、CPUからアクセスできるバッファを用意する必要があり、そこに結果をコピーします。CPUからアクセス可能なバッファは`CPUAccessFlags`を`D3D11_CPU_ACCESS_READ`に指定する必要があります。しかし、この状態だとパイプライン（シェーダの処理）の入力、出力に設定できなくなります。

    [参考](https://learn.microsoft.com/ja-jp/windows/win32/api/d3d11/ne-d3d11-d3d11_cpu_access_flag)

    このため、シェーダの出力を格納するGPU上のバッファとCPUからアクセス可能なバッファを分ける必要があります。

- **Unordered Access View**

    GPU上のバッファはコンピュートシェーダで処理する際に、GPUの複数スレッドから読み書きが行われることになります。その際に競合なくアクセスできるように、このUnordered Access Viewを経由する必要があります。GPU上のバッファはこのビューに紐づける必要があります。ここでいうUnordered Accessというのは、複数スレッドから順序を問わずに読み書きのアクセスをされることをいうようです。

    [参考](https://learn.microsoft.com/ja-jp/windows/win32/direct3d11/typed-unordered-access-view-loads)

上記で用意しているものは、今回の場合、以下の図のようにつながっています。 [^3]

[^3]: 今回も自分のイメージをもとに描いているので、少し厳密性に欠けるところはあるかもしれません。また、コンピュートシェーダは入出力周りの融通が利くので、この図はあくまで一例となります。

![ComputeShaderFlow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/f93104c1-b1a1-702d-ba20-7179e5c3c4b0.png)

これらを準備するコードは以下のようになります。コンピュートシェーダのコードは[前回の記事](https://qiita.com/HexagramNM/items/66895f843d12e4ebd203#2-%E3%82%AA%E3%83%95%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%83%AC%E3%83%B3%E3%83%80%E3%83%AA%E3%83%B3%E3%82%B0%E3%81%AE%E6%BA%96%E5%82%99)と同様にinclude文を利用して`hlslFormatterCode`の変数に埋め込んでいます。

```C++:NMVCamPin.cpp
// 略

#define HLSL_EXTERNAL_INCLUDE(...) #__VA_ARGS__

const char* hlslFormatterCode =
#include "SampleFormatter.hlsl"
;

// 略

// SampleFormatter実行の準備
// getやputを呼び出している部分はスマートポインタで、winrt::com_ptrを使用しております。
void NMVCamPin::setupSampleFormatter()
{
    // 入力するテクスチャ（オフスクリーンレンダリングで描画した結果のテクスチャ）を紐づけるシェーダリソースビュー
	CD3D11_SHADER_RESOURCE_VIEW_DESC shaderResourceViewDesc(D3D11_SRV_DIMENSION_TEXTURE2D, DXGI_FORMAT_B8G8R8A8_UNORM);
	_dxDevice->CreateShaderResourceView(_offscreenRenderingTexture.get(),
		&shaderResourceViewDesc, _formatterSRV.put());

    // テクスチャのピクセル数 × 3 Byte
	UINT bufferByteSize = WINDOW_WIDTH * WINDOW_HEIGHT * PIXEL_BYTE;

    // コンピュートシェーダのコンパイルと作成。
    size_t hlslSize = std::strlen(hlslFormatterCode);

    // コンパイル時にマクロを指定し、C++で設定したマクロと同期させる
	std::string csThreadsStr = std::to_string(CS_THREADS_NUM);
	std::string windowWidthStr = std::to_string(WINDOW_WIDTH);
	com_ptr<ID3DBlob> compiledCS;
	D3D_SHADER_MACRO csMacro[] = {
		"CS_THREADS_NUM_IN_CS", csThreadsStr.c_str(),
		"WINDOW_WIDTH_IN_CS", windowWidthStr.c_str(),
		NULL, NULL
	};
	D3DCompile(hlslFormatterCode, hlslSize, nullptr, csMacro, nullptr,
		"formatterMain", "cs_5_0", 0, 0, compiledCS.put(), nullptr);

	_dxDevice->CreateComputeShader(compiledCS->GetBufferPointer(),
		compiledCS->GetBufferSize(), nullptr, _formatterCS.put());
	_deviceCtx->CSSetShader(_formatterCS.get(), 0, 0);

    // 出力先のGPU上のバッファ
    // BindFlagsとMiscFlagsのフラグ設定に注意
	D3D11_BUFFER_DESC bufferDesc;
	bufferDesc.ByteWidth = bufferByteSize;
	bufferDesc.Usage = D3D11_USAGE_DEFAULT;
	bufferDesc.BindFlags = D3D11_BIND_UNORDERED_ACCESS;
	bufferDesc.CPUAccessFlags = 0;
	bufferDesc.MiscFlags = D3D11_RESOURCE_MISC_BUFFER_ALLOW_RAW_VIEWS;
	_dxDevice->CreateBuffer(&bufferDesc, nullptr, _gpuFormatterBuffer.put());

    // GPU上のバッファはそのままではCPU上で扱えないので、CPUからアクセスできるバッファも作成。
    // コンピュートシェーダの処理後にこのバッファに結果をコピーする
	bufferDesc.Usage = D3D11_USAGE_STAGING;
	bufferDesc.BindFlags = 0;
	bufferDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
	bufferDesc.MiscFlags = 0;
	_dxDevice->CreateBuffer(&bufferDesc, nullptr, _cpuSampleBuffer.put());

    // Unordered Access Viewの設定
    // コンピュートシェーダにGPU上のバッファを紐づける
	D3D11_UNORDERED_ACCESS_VIEW_DESC uavDesc;
	uavDesc.ViewDimension = D3D11_UAV_DIMENSION_BUFFER;
	uavDesc.Format = DXGI_FORMAT_R32_TYPELESS;
	uavDesc.Buffer.FirstElement = 0;
    // コンピュートシェーダ上ではメモリアラインメントの関係で4Byte単位で扱うことになる
    // そのためバイト数を4で割っている
	uavDesc.Buffer.NumElements = bufferByteSize / 4;
	uavDesc.Buffer.Flags = D3D11_BUFFER_UAV_FLAG_RAW;
	_dxDevice->CreateUnorderedAccessView(_gpuFormatterBuffer.get(), &uavDesc, _formatterUAV.put());

	ID3D11UnorderedAccessView* uavs[] = { _formatterUAV.get() };
	UINT initialCounts[] = { 0 };
	_deviceCtx->CSSetUnorderedAccessViews(0, 1, uavs, initialCounts);
}

// 略

```


## 2. コンピュートシェーダ本体

実際のコンピュートシェーダは以下のように書いています。先ほど準備したテクスチャは`offscreenTexture`として、GPU上のバッファは`outputBuffer`として参照されます。

```HLSL:SampleFormatter.hlsl

HLSL_EXTERNAL_INCLUDE(

Texture2D<float4> offscreenTexture : register(t0);
RWByteAddressBuffer outputBuffer: register(u0);

[numthreads(CS_THREADS_NUM_IN_CS, CS_THREADS_NUM_IN_CS, 1)]
void formatterMain(uint3 dispatchThreadId: SV_DispatchThreadID)
{
    // 横4ピクセル分の色情報を取得
    float4 pixel0 = offscreenTexture.Load(int3(4 * dispatchThreadId.x, dispatchThreadId.y, 0));
    float4 pixel1 = offscreenTexture.Load(int3(4 * dispatchThreadId.x + 1, dispatchThreadId.y, 0));
    float4 pixel2 = offscreenTexture.Load(int3(4 * dispatchThreadId.x + 2, dispatchThreadId.y, 0));
    float4 pixel3 = offscreenTexture.Load(int3(4 * dispatchThreadId.x + 3, dispatchThreadId.y, 0));
    
    // BGRBGR...の順で12バイト分のデータを作成
    uint3 bgr24_3;
    bgr24_3.x = (uint(pixel0.b * 255.0) & 0xFF) | ((uint(pixel0.g * 255.0) & 0xFF) << 8)
        | ((uint(pixel0.r * 255.0) & 0xFF) << 16) | ((uint(pixel1.b * 255.0) & 0xFF) << 24);
    bgr24_3.y = (uint(pixel1.g * 255.0) & 0xFF) | ((uint(pixel1.r * 255.0) & 0xFF) << 8)
        | ((uint(pixel2.b * 255.0) & 0xFF) << 16) | ((uint(pixel2.g * 255.0) & 0xFF) << 24);
    bgr24_3.z = (uint(pixel2.r * 255.0) & 0xFF) | ((uint(pixel3.b * 255.0) & 0xFF) << 8)
        | ((uint(pixel3.g * 255.0) & 0xFF) << 16) | ((uint(pixel3.r * 255.0) & 0xFF) << 24);
    
    // 12バイト書き込み
    uint index = (dispatchThreadId.y * WINDOW_WIDTH_IN_CS + 4 * dispatchThreadId.x) * 3;
    outputBuffer.Store3(index, bgr24_3);
}

)

```

注意点としては以下の2つになります。


### 注意点1: 1つの処理で対象とするピクセル数

最初はピクセルシェーダのように、1つの処理で1ピクセル分処理することを考えるかと思います。しかし、`RWByteAddressBuffer`は4バイト単位でのアクセスしかできず、1バイトだけ書き込むということはできません。[^4]

[^4]: メモリに効率よくアクセスできるよう、4バイトごとにアラインメントされている影響です。

そこで今回のシェーダでは、以下の図のように横4ピクセル分取得し、4ピクセル × 3バイト = 12バイト分を1つの処理で書き込んでいます。書き込みには`outputBuffer.Store3`を使っています。

![ComputeShaderPixelUnit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/ec6824d3-eb89-a1b4-2e96-78d20e9993a0.png)

こうすることで、4バイト単位でのアクセスに対応しています。また、映像の解像度は基本的に4の倍数なので余りを考える必要もありません。


### 注意点2: コンピュートシェーダのスレッド

他のシェーダと違い、コンピュートシェーダでは以下の部分のように処理に使用するスレッド数を指定します。（`CS_THREADS_NUM_IN_CS`はマクロで20を指定しています。）

```HLSL
[numthreads(CS_THREADS_NUM_IN_CS, CS_THREADS_NUM_IN_CS, 1)]
```

`[numthreads(tx, ty, tz)]`のように指定することで、$(t_x × t_y × t_z)$個のスレッドを並列させて処理することになります。しかし、以下の公式のドキュメントにあるように、このスレッド数は`cs_5_0`のバージョンの場合、最大1024までです。

[numthreads](https://learn.microsoft.com/ja-jp/windows/win32/direct3dhlsl/sm5-attributes-numthreads)

では、1024個分の処理しかできないのかというとそんなことはありません。これらのスレッドのまとまり（グループ）を何個実行させるかを指定することができます。シェーダを実行する際、DirectXのデバイスコンテキストの`Dispatch`関数を呼び出します。この関数は、グループの数を指定するために、3つの引数を取ります。シェーダで`[numthreads(tx, ty, tz)]`と指定し、実行時に`Dispatch(gx, gy, gz)`と指定することで、$(t_x × g_x × t_y × g_y × t_z × g_z)$個分の処理を並列に行うことができます。つまり、`numthreads`と`Dispatch`で指定した計6つの引数の掛け算分、並列処理が走ります。

例えば、シェーダで`[numthreads(20, 20, 1)]`と指定し、実行時に`Dispatch(12, 36, 1)`と指定した場合は、$(20×12×20×36×1×1)=(20×12)×(20×36)=(240×720)$個分の処理を行うことができます。注意点1で述べたように、今回1つの処理で横4ピクセル分を処理するので、この指定で960×720の画像に含まれる全ピクセルを処理できます。

これで大量の処理を並列して行えそうですが、このままだとシェーダ内でどこの部分を処理しているかがわかりません。そこで登場するのが以下の部分にある`SV_DispatchThreadID`です。

```HLSL
void formatterMain(uint3 dispatchThreadId: SV_DispatchThreadID)
```

これがスレッドのIDを示すもので、x, y, zに対応した3つの整数値からなります。ドキュメントにはいろいろ書いてありますが、要は各成分が以下の整数の範囲を取ります。そして取りうる全ての組み合わせに対し1回ずつ処理が走ります。

|  | `[numthreads(20, 20, 1)]`<br>`Dispatch(12, 36, 1)`の場合 | `[numthreads(tx, ty, tz)]`<br>`Dispatch(gx, gy, gz)`の場合 |
|:---:|:---:|:---:|
| x成分 | 0 ~ 239 | 0 ~ (tx × gx - 1) |
| y成分 | 0 ~ 719 | 0 ~ (ty × gy - 1) |
| z成分 | 0 ~ 0 | 0 ~ (tz × gz - 1) |

あとは、このIDの各成分を多重ループで回した際のインデックスに見立てて、シェーダの処理を書く形となります。


## 3. コンピュートシェーダの呼び出し

毎フレームの映像サンプルを作成する処理では、以下のような流れでシェーダの実行とサンプルへのデータコピーを行います。

1. 準備したシェーダリソースビューをコンピュートシェーダに設定する。（`CSSetShaderResources`）
2. `Dispatch`でコンピュートシェーダを実行。実行後に1のシェーダリソースビューの設定を解除する。
3. `CopyResource`でGPU上のバッファから、CPUからアクセス可能なバッファにデータをコピーする。
4. CPUからアクセス可能なバッファを`IDXGISurface`でCPU上にマッピングし、映像サンプルのメモリにデータをコピーする。

今回、シェーダリソースビューに紐づけているテクスチャは、オフスクリーンレンダリングのレンダーターゲットでもあります。 **同一のテクスチャが複数のシェーダリソースビューやレンダーターゲットに紐づいている場合、それらのうち1つしか設定することができません。** 同時に複数設定されると、実行のタイミングでDirextXからエラーメッセージが表示されます。[^5] このため2のシェーダリソースビュー設定の解除は必要です。同時にオフスクリーンレンダリングのレンダーターゲットも毎フレーム設定と設定解除の両方を行う必要があります。

[^5]: 複数設定できるようにしてしまうと、リソースを管理しているメモリのアクセスで競合が発生してしまうので、それが起こらないように同時複数設定を許可しないようになっているのでしょう。

このシェーダ実行やデータコピーの流れをコードに書くと以下のようになります。

```C++:NMVCamPin.cpp
// 略

// コンピュートシェーダでサンプルのフォーマットにあったバッファを作成
void NMVCamPin::getSampleOnCaptureWindow(LPBYTE sampleData)
{
	ID3D11ShaderResourceView* tempShaderResourceViewPtr[] = { _formatterSRV.get() };
	_deviceCtx->CSSetShaderResources(0, 1, tempShaderResourceViewPtr);
	_deviceCtx->Dispatch(WINDOW_WIDTH / (CS_THREADS_NUM * 4), WINDOW_HEIGHT / CS_THREADS_NUM, 1);

	ID3D11ShaderResourceView* tempShaderResourceViewNullPtr[] = { nullptr };
	_deviceCtx->CSSetShaderResources(0, 1, tempShaderResourceViewNullPtr);

	_deviceCtx->CopyResource(_cpuSampleBuffer.get(), _gpuFormatterBuffer.get());

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

	DXGI_MAPPED_RECT mapFromCpuSampleBuffer;
	dxgiSurface->Map(&mapFromCpuSampleBuffer, DXGI_MAP_READ);

	CopyMemory((PVOID)sampleData, (PVOID)mapFromCpuSampleBuffer.pBits, WINDOW_WIDTH * WINDOW_HEIGHT * PIXEL_BYTE);

	dxgiSurface->Unmap();
}

// 略
```


# 処理時間計測

今回も自分のマシンで、Releaseモードでビルドした上で、映像の1フレームを生成するのにかかる時間を計測してみました。仮想カメラに映す映像の画素数は960px×720pxで計測しました。[^6] 計測方法は[以前の記事](https://qiita.com/HexagramNM/items/66895f843d12e4ebd203#%E5%87%A6%E7%90%86%E6%99%82%E9%96%93%E8%A8%88%E6%B8%AC)と同じようなコードを使用して、マイクロ秒単位で計測しております。

[^6]: 以前のバージョンのコードと簡単に比較できるように、解像度を合わせるためです。

今回は仮想カメラをZoomの設定画面で映した場合[^7]に、改善前、オフスクリーンレンダリングのみ適用した状態、今回のコンピュートシェーダを適用した状態の3パターンを計測してみました。結果は以下の通りとなりました。

[^7]: DirectShowの仮想カメラの場合、Zoomの設定画面だと仮想カメラを使用しているプロセスを特定するのが簡単で、Visual Studioでプロセスのアタッチがしやすいです。

|  | 改善前 | オフスクリーンレンダリングのみ | 今回の改善後（スレッド数: 20×20） |
|:---:| ---:| ---:| ---:|
| 1フレームあたりの平均処理時間<br>（マイクロ秒） | 5158 | 3781 | **3168** |
|分散<br> (マイクロ秒^2)| $3.045 × 10^6$ | $1.090 × 10^6$ | $8.027 × 10^5$ |
| 計測フレーム数 | 1326 | 1808 | 1515 |

結果として、オフスクリーンレンダリングによる改善と今回のコンピュートシェーダによる改善、それぞれ最適化の効果がみられる結果となりました。[^8] [^9] また、両方の最適化を行った場合が最も処理時間のブレが小さく、安定しているようでした。

[^8]: 以前の記事のMediaFoundationの仮想カメラと違い、GPUのバッファからCPUにコピーする処理があるので、その分処理に時間はかかってしまいますね。

[^9]: 今回の計測では改善前後の結果が小さいと感じられる方もいらっしゃるかなと思います。しかし、コンピュートシェーダで処理する場合の利点として、映像サンプルの解像度が大きくなった場合も、並列処理により処理時間が長くなりにくく、スケーラブルである点が挙げられます。


## スレッド数での処理時間の違い

またスレッド数を変えた場合の処理時間も計測してみました。

|  | スレッド数: 10×10 | スレッド数: 16×16 | スレッド数: 20×20 | スレッド数: 30×30 |
|:---:| ---:| ---:| ---:| ---:|
| 1フレームあたりの平均処理時間<br>（マイクロ秒） | 3170 | 3517 | 3168 | 3454 |
|分散<br> (マイクロ秒^2)| $6.314 × 10^5$ | $5.199 × 10^5$ | $8.027 × 10^5$ | $5.529 × 10^5$ |
| 計測フレーム数 | 2075 | 1979 | 1515 | 1963 |

意外にもスレッド数が16×16や30×30の場合に処理時間がよりかかるという結果になりました。[^10] あまりスレッド数は気にしなくて良いのかなぁと思っています。

[^10]: おそらくスレッドのグループもそれぞれ並列に実行されているので、スレッド数で処理時間が大きくは変わらないということなのでしょう。[Compute Shaderの次世代最適化ソリューション](https://qiita.com/UWATechnology/items/626aaa2a0e6ee5afd01a)の記事だと32や64の整数倍のスレッド数が最適とはありましたが、今回のケースだとそうでもなさそうという感じです。映像の解像度を大きくした場合はまた結果が変わってくる可能性はありますね。


# まとめ

今回、DirectShow仮想カメラの一部の処理をコンピュートシェーダで最適化してみました。ところどころ通常の頂点シェーダやピクセルシェーダと異なる点はありますが、おおまかな流れは他のシェーダと似ているかなと思います。

コンピュートシェーダを初めて使ってみましたが、確かにこの仕組みであれば、汎用的に並列処理を行うことができそうだなと思いました。今まで頂点シェーダやピクセルシェーダのイメージしかなかったので、これでどうやって機械学習などの処理をGPU上で行っているんだろうと少し疑問に思っていましたが、その疑問も解決しました。