この記事はグラフィックス全般 Advent Calendar 2024 21日目の記事です。
N.Mです。最近DirectXで今まで作ったものを最適化するのに味を占めています。
以前の記事で書いたオフスクリーンレンダリングを使用した最適化を、DirectShowの仮想カメラに対して進めていました。その最中に、さらに別のところでもDirectXの機能で最適化できるところを見つけたので今回記事にしました。コンピュートシェーダもなんだかんだ初めて使用してみたので、使い方をわかりやすく紹介できればと思います。
この記事は以前投稿したWindowsGraphicsCapture APIと仮想カメラの記事の実装を最適化するという内容になります。WindowsGraphicsCapture APIやDirectShowの仮想カメラの詳細はこの記事では割愛しますので、気になる方は以下の記事をご覧ください。
今回最適化する箇所
以前の記事のStep5の処理を最適化していきます。この処理では、以下のようにCPUで読み出し可能なバッファテクスチャからピクセル情報を取得し、映像サンプルとして渡せる状態にしていました。
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);
}
仮想カメラ内に収めるようキャプチャされた画像を変形させる処理は、以前紹介したオフスクリーンレンダリングで効率よく処理できそうです。しかし、映像のピクセルデータを3重ループで処理している処理が見るからに重そうです。1 また、テクスチャが1ピクセルにつきBGRA (Blue, Green, Red, Alpha) の32bit使用しているのに対し、映像サンプルとして渡すピクセルデータはBGRの24bitであり、フォーマットが違います。このため、以下の図のようなフォーマット変換を行う必要もあります。
いろいろ調べつつ、ChatGPTでテクスチャのフォーマットや、効率のよいコピーの仕方を聞いていたところ、
GPUで32ビットデータから24ビットを取り出す場合、コンピュートシェーダーを使用して、テクスチャデータを適切に処理するのが一般的です。
という回答が降ってきました。2 この回答をきっかけにコンピュートシェーダを使用して最適化する方向で実装を始めました。
コンピュートシェーダとは
本来GPUは、以前の記事 で説明したようにポリゴンを描画する役割をもちます。この描画の特性上、GPUは単純な処理を大量に並列処理することが得意です。
この特性を汎用的な計算処理に使えるようにした仕組みがコンピュートシェーダです。テクスチャやGPU上のバッファのデータを入力に、コンピュートシェーダ上で処理を行い、別のバッファに処理結果を書き込むという処理の流れを並列に行えます。DirectXを含む様々なグラフィックスAPIでサポートされています。
目標
キャプチャされた画像の入ったテクスチャ(1ピクセルにつきBRGAの計32bit)をコンピュートシェーダで1ピクセルにつきBGRの計24bitのフォーマットに変換し、GPU上のバッファに書き込むことを目指します。最終的にはGPU上のバッファの内容をCPU上のメモリに一括コピーし、仮想カメラに映像サンプルとして渡します。
サンプルコード
このリポジトリにコードを置いています。
仮想カメラを起動した状態で左SHIFTとスペースを同時に押すと、図の右側にあるウィンドウ選択のピッカーが開きます。選択されたウィンドウは、図の左側のように仮想カメラに映し出されます。
今回の記事に関わるソースコードは以下のファイルになります。
動作環境
以下の自分のマシンの環境で動作を確認していますが、太字の要件を満たしていれば動作すると思います。
-
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
に指定する必要があります。しかし、この状態だとパイプライン(シェーダの処理)の入力、出力に設定できなくなります。このため、シェーダの出力を格納するGPU上のバッファとCPUからアクセス可能なバッファを分ける必要があります。
-
Unordered Access View
GPU上のバッファはコンピュートシェーダで処理する際に、GPUの複数スレッドから読み書きが行われることになります。その際に競合なくアクセスできるように、このUnordered Access Viewを経由する必要があります。GPU上のバッファはこのビューに紐づける必要があります。ここでいうUnordered Accessというのは、複数スレッドから順序を問わずに読み書きのアクセスをされることをいうようです。
上記で用意しているものは、今回の場合、以下の図のようにつながっています。 3
これらを準備するコードは以下のようになります。コンピュートシェーダのコードは前回の記事と同様にinclude文を利用してhlslFormatterCode
の変数に埋め込んでいます。
// 略
#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_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ピクセル × 3バイト = 12バイト分を1つの処理で書き込んでいます。書き込みにはoutputBuffer.Store3
を使っています。
こうすることで、4バイト単位でのアクセスに対応しています。また、映像の解像度は基本的に4の倍数なので余りを考える必要もありません。
注意点2: コンピュートシェーダのスレッド
他のシェーダと違い、コンピュートシェーダでは以下の部分のように処理に使用するスレッド数を指定します。(CS_THREADS_NUM_IN_CS
はマクロで20を指定しています。)
[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までです。
では、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
です。
void formatterMain(uint3 dispatchThreadId: SV_DispatchThreadID)
これがスレッドのIDを示すもので、x, y, zに対応した3つの整数値からなります。ドキュメントにはいろいろ書いてありますが、要は各成分が以下の整数の範囲を取ります。そして取りうる全ての組み合わせに対し1回ずつ処理が走ります。
[numthreads(20, 20, 1)] Dispatch(12, 36, 1) の場合 |
[numthreads(tx, ty, tz)] 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. コンピュートシェーダの呼び出し
毎フレームの映像サンプルを作成する処理では、以下のような流れでシェーダの実行とサンプルへのデータコピーを行います。
- 準備したシェーダリソースビューをコンピュートシェーダに設定する。(
CSSetShaderResources
) -
Dispatch
でコンピュートシェーダを実行。実行後に1のシェーダリソースビューの設定を解除する。 -
CopyResource
でGPU上のバッファから、CPUからアクセス可能なバッファにデータをコピーする。 - CPUからアクセス可能なバッファを
IDXGISurface
でCPU上にマッピングし、映像サンプルのメモリにデータをコピーする。
今回、シェーダリソースビューに紐づけているテクスチャは、オフスクリーンレンダリングのレンダーターゲットでもあります。 同一のテクスチャが複数のシェーダリソースビューやレンダーターゲットに紐づいている場合、それらのうち1つしか設定することができません。 同時に複数設定されると、実行のタイミングでDirextXからエラーメッセージが表示されます。5 このため2のシェーダリソースビュー設定の解除は必要です。同時にオフスクリーンレンダリングのレンダーターゲットも毎フレーム設定と設定解除の両方を行う必要があります。
このシェーダ実行やデータコピーの流れをコードに書くと以下のようになります。
// 略
// コンピュートシェーダでサンプルのフォーマットにあったバッファを作成
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 計測方法は以前の記事と同じようなコードを使用して、マイクロ秒単位で計測しております。
今回は仮想カメラをZoomの設定画面で映した場合7に、改善前、オフスクリーンレンダリングのみ適用した状態、今回のコンピュートシェーダを適用した状態の3パターンを計測してみました。結果は以下の通りとなりました。
改善前 | オフスクリーンレンダリングのみ | 今回の改善後(スレッド数: 20×20) | |
---|---|---|---|
1フレームあたりの平均処理時間 (マイクロ秒) |
5158 | 3781 | 3168 |
分散 (マイクロ秒^2) |
$3.045 × 10^6$ | $1.090 × 10^6$ | $8.027 × 10^5$ |
計測フレーム数 | 1326 | 1808 | 1515 |
結果として、オフスクリーンレンダリングによる改善と今回のコンピュートシェーダによる改善、それぞれ最適化の効果がみられる結果となりました。8 9 また、両方の最適化を行った場合が最も処理時間のブレが小さく、安定しているようでした。
スレッド数での処理時間の違い
またスレッド数を変えた場合の処理時間も計測してみました。
スレッド数: 10×10 | スレッド数: 16×16 | スレッド数: 20×20 | スレッド数: 30×30 | |
---|---|---|---|---|
1フレームあたりの平均処理時間 (マイクロ秒) |
3170 | 3517 | 3168 | 3454 |
分散 (マイクロ秒^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 あまりスレッド数は気にしなくて良いのかなぁと思っています。
まとめ
今回、DirectShow仮想カメラの一部の処理をコンピュートシェーダで最適化してみました。ところどころ通常の頂点シェーダやピクセルシェーダと異なる点はありますが、おおまかな流れは他のシェーダと似ているかなと思います。
コンピュートシェーダを初めて使ってみましたが、確かにこの仕組みであれば、汎用的に並列処理を行うことができそうだなと思いました。今まで頂点シェーダやピクセルシェーダのイメージしかなかったので、これでどうやって機械学習などの処理をGPU上で行っているんだろうと少し疑問に思っていましたが、その疑問も解決しました。
-
そもそも、MediaFoundationの仮想カメラでやっていたように、GPU上のデータを直接映像サンプルとして渡す機能がDirectShowにもあれば良かったのですが... 自分が調べた限りだと標準の方法では無さそうでした。 ↩
-
今回の記事で紹介している実装はかなりChatGPTに助けてもらいながら、コーディングしていました。最近の生成AIって日本語もかなり自然で、的確な答えが返ってくることも多いのですごいですね。 ↩
-
今回も自分のイメージをもとに描いているので、少し厳密性に欠けるところはあるかもしれません。また、コンピュートシェーダは入出力周りの融通が利くので、この図はあくまで一例となります。 ↩
-
メモリに効率よくアクセスできるよう、4バイトごとにアラインメントされている影響です。 ↩
-
複数設定できるようにしてしまうと、リソースを管理しているメモリのアクセスで競合が発生してしまうので、それが起こらないように同時複数設定を許可しないようになっているのでしょう。 ↩
-
以前のバージョンのコードと簡単に比較できるように、解像度を合わせるためです。 ↩
-
DirectShowの仮想カメラの場合、Zoomの設定画面だと仮想カメラを使用しているプロセスを特定するのが簡単で、Visual Studioでプロセスのアタッチがしやすいです。 ↩
-
以前の記事のMediaFoundationの仮想カメラと違い、GPUのバッファからCPUにコピーする処理があるので、その分処理に時間はかかってしまいますね。 ↩
-
今回の計測では改善前後の結果が小さいと感じられる方もいらっしゃるかなと思います。しかし、コンピュートシェーダで処理する場合の利点として、映像サンプルの解像度が大きくなった場合も、並列処理により処理時間が長くなりにくく、スケーラブルである点が挙げられます。 ↩
-
おそらくスレッドのグループもそれぞれ並列に実行されているので、スレッド数で処理時間が大きくは変わらないということなのでしょう。Compute Shaderの次世代最適化ソリューションの記事だと32や64の整数倍のスレッド数が最適とはありましたが、今回のケースだとそうでもなさそうという感じです。映像の解像度を大きくした場合はまた結果が変わってくる可能性はありますね。 ↩