動機
VulkanでレイトレーシングしてWPFでいじれるツールを作っていたときのこと...
レンダリング結果をWPF側のウィンドウに表示する実装を
Vulkanでテクスチャにレンダリング → GPUからCPUメモリにコピー → WriteableImage に書き込み
とやっていました。
確かに動きます。
動きはしますが、コピーにかかる負荷でCPUがすぐにアチアチになってしまいます。
これは実装を替えたほうが良さそうです。
どうにかしてCPUを介さずにVulkan・WPF間で直接イメージをやり取りできないだろうか…
D3DImage
と、調べていたところ、GPUの共有メモリを直接バックバッファに使えるD3DImageなるクラスがあることがわかりました。
これならVulkanから直接GPUコピーでレンダリング結果を表示できそうです。
実装
- Vulkan・WPF間のイメージ用GPUメモリをどこで確保するか、についてはいくつか方法があるようですが、今回はDirectXで確保してVulkan側に共有することにしました。
- Vulkan側では描画用とD3DImage共有用の2個のイメージを作成します。
- Vulkan側のデバイスの初期化等はあまりに長すぎるので省略します。
パッケージの準備
D3DImageを利用するためには、DirectXで管理されたサーフェースが必要なので、C#からDirectXの関数群を使えるようにP/Invoke定義します。
ただ、 自前でP/Invoke定義するのは大変なので自動生成することにします。
NuGetで次のパッケージをインストールします。
- Microsoft.Windows.SDK.Win32Metadata
- Microsoft.Windows.CsWin32
プロジェクト内に NativeMethods.json
を作成し、そのファイルに生成したい関数名を記述します。
dll名.*
で記述すると、そのDLL内のすべての定義を生成してくれます。
dxgi.*
d3d9.*
DirectXの初期化とレンダーターゲットの作成
D3DImageのサーフェースを用意するため、Direct3D9の初期化を行います。
const uint D3D_SDK_VERSION = 32;
Windows.Win32.Graphics.Direct3D9.IDirect3D9Ex d3d;
HRESULT result = Windows.Win32.PInvoke.Direct3DCreate9Ex(D3D_SDK_VERSION, out d3d);
if (result.Failed)
{
throw new InvalidOperationException($"failed. hr={result.Value}");
}
続けてデバイスの初期化を行います。
本来はVulkan側で利用するデバイスと、こちらのデバイスを一致させる必要があるのですが、今回は簡略化のためにサボりました。
Windows.Win32.Graphics.Direct3D9.IDirect3DDevice9Ex device;
D3DPRESENT_PARAMETERS presentParameters = new()
{
BackBufferWidth = 1,
BackBufferHeight = 1,
SwapEffect = D3DSWAPEFFECT.D3DSWAPEFFECT_DISCARD,
Windowed = true,
};
d3d.CreateDeviceEx(
0,
D3DDEVTYPE.D3DDEVTYPE_HAL,
HWND.Null,
D3DCREATE_MULTITHREADED | D3DCREATE_HARDWARE_VERTEXPROCESSING,
&presentParameters,
null,
out device
);
DirectX側のレンダーターゲットを作成します。
Vulkan側にデバイスメモリを共有するために、 pSharedHandle
パラメーターにハンドルのポインタを指定して共有ハンドルを手に入れておきます。
// Vulkan 側にメモリを共有するための共有ハンドル
HANDLE dxSharedHandle;
// D3DImage のバックバッファに指定するサーフェース
IDirect3DSurface9 dxSurface;
_device.CreateRenderTarget(
Width: 1280,
Height: 720,
Format: D3DFORMAT.D3DFMT_A8R8G8B8,
MultiSample: D3DMULTISAMPLE_TYPE.D3DMULTISAMPLE_NONE,
MultisampleQuality: 0,
Lockable: true,
ppSurface: out var dxSurface,
// 共有ハンドルを取得する
pSharedHandle: &dxSharedHandle
);
Vulkan側のイメージの準備
Vulkan側ではイメージを2個用意します。
1個はVulkan側のレンダリング用のイメージで、もう1個はD3DImageとの共有用です。
多分1個でも問題ないとは思いますが、そうなるとVulkan側の描画をUIスレッドを内で行う必要があり、UI側のパフォーマンスが悪くなるため今回は描画用と共有用を分けました。
描画用のイメージは通常のVulkanのイメージの初期化と変わらないので省略します。
共有用のイメージの初期化には通常の VkImageCreateInfo
に加え、 VkExternalMemoryImageCreateInfo
を チェーンに追加して外部メモリを使用することを宣言します。
handleTypes
には VkExternalMemoryHandleTypeFlagBits.D3D11_TEXTURE_KMT_BIT_KHR
を指定します。( 似た値に D3D11_TEXTURE_BIT_KHR
がありますがこちらではありません)
var externalMemoryImageCreateInfo = new VkExternalMemoryImageCreateInfo()
{
sType = VkStructureType.EXTERNAL_MEMORY_IMAGE_CREATE_INFO,
handleTypes = VkExternalMemoryHandleTypeFlagBits.D3D11_TEXTURE_KMT_BIT_KHR,
};
var createInfo = new VkImageCreateInfo
{
imageType = VkImageType._2D,
format = Format,
extent = new VkExtent3D(1280, 720, 1),
mipLevels = 1,
arrayLayers = 1,
samples = VkSampleCountFlagBits.BIT_1,
tiling = VkImageTiling.Optimal,
usage = VkImageUsageFlagBits.TransferDst,
sharingMode = VkSharingMode.Exclusive,
initialLayout = VkImageLayout.Preinitialized,
pNext = &externalMemoryImageCreateInfo,
};
var result = NativeMethods.vkCreateImage(
vkDevice.Handle,
createInfo,
IntPtr.Zero,
out VkImage hImage);
イメージが作成できたので今度はイメージ用のメモリの確保を行います。
といっても実際にはVulkan側でメモリが確保されるわけではなくすでに確保済みのDirectX側のメモリをVulkan側にインポートするだけです。
VkMemoryAllocateInfo
に加え、VkImportMemoryWin32HandleInfoKHR
をチェーンに追加して外部のメモリをインポートすることを宣言します。
handle
にはDirectX側で作成したレンダーターゲットの共有ハンドルを渡します。
var memoryAllocInfo = new VkMemoryAllocateInfo()
{
sType = VkStructureType.MEMORY_ALLOCATE_INFO,
allocationSize = Size,
memoryTypeIndex = memIndex.Value,
};
var win32HandleInfo = new VkImportMemoryWin32HandleInfoKHR()
{
sType = VkStructureType.IMPORT_MEMORY_WIN32_HANDLE_INFO_KHR,
// DirectX 側で作成したレンダーターゲットの共有ハンドルを指定する
handle = dxSharedHandle.Value,
handleType = VkExternalMemoryHandleTypeFlagBitsKHR.D3D11_TEXTURE_KMT_BIT_KHR,
};
memoryAllocInfo.win32HandleInfo.pNext = &win32HandleInfo;
var result = NativeMethods.vkAllocateMemory(
vkDevice.Handle,
memoryAllocInfo,
IntPtr.Zero,
out var hMemory);
イメージの作成ができたらイメージへ確保したメモリのバインドを行いますが、これは通常のバインドの手順と変わらないため省略します。
WPF の準備
WPFで画面に画像を表示するためのイメージソースをD3DImageで作成します。
XAMLへのImageSourceのバインドはPrismを用いています。
class Screen : BindableBase
{
private D3DImage _image = new D3DImage(dpiX: 96, dpiY: 96);
public D3DImage Image { get => _image; set => SetProperty(ref _image, value); }
}
<Image
Source="{Binding Image}"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch">
</Image>
WPF へのイメージの共有
準備が整ったので実際のコピー処理を実装します。
Vulkan側のスレッドでイメージへの描画が完了しタイミングで、Dispatcher.InvokeAsync
を経由してUIスレッド側からバックバッファへの共有ハンドルの指定と、 D3DImageのバックバッファの更新の通知を行います。
バックバッファの設定は SetBackBuffer
を呼び出します。
backBufferType
にはバックバッファの種別を D3DResourceType
列挙型で指定しますが、 値が IDirect3DSurface9
しかないのでこれを指定します。
backBuffer
にはバックバッファとして割り当てる Direct3D サーフェイスのハンドルを指定します。
backBuffer
に指定するハンドルはVulkanと共有している共有ハンドルではなく ID3DSurface9
Comオブジェクトに対して Marshal.GetIUnknownForObject
を呼び出して得られるサーフェースのハンドル を指定する必要があります。
当初、ここを勘違いして直接共有ハンドルを指定してしまい、長いこと躓きました。
// Vulkan側で描画完了後
screen.Image.Dispatcher.InvokeAsync(() =>
{
System.Windows.Int32Rect rect = new(0, 0, 1280, 720);
screen.Image.Lock();
// ここで vkCmdCopyImage などで描画用イメージから転送用イメージにコピーする
// (省略)
var surfaceHandle = System.Runtime.InteropServices.Marshal.GetIUnknownForObject(dxSurface);
// SetBackBuffer はダブルバッファリング等をしないのであれば初回だけでいい
screen.Image.SetBackBuffer(D3DResourceType.IDirect3DSurface9, _surfaceHandle);
// バックバッファが更新されたことを通知する
screen.Image.AddDirtyRect(rect);
screen.Image.Unlock();
});
終わりに
参考資料がほとんどなく手探りで実装することになりました…
特にVulkanのメモリの共有周りは実装コードらしきものすら見つけることができず、公式のリファレンスを翻訳してコードをいじってエラー、を繰り返す日々でした。
D3DImageについても勘違いをしている部分に気づくまで長いことかかりました。
記事内にはないですが、Lockメソッドの用法を間違えたことによるチラツキの発生を解消するまでに、これまた長い時間を要しました。
ただ、苦労の甲斐あってかCPUコピーをしていたコードと比べ、CPUの負荷が見違えるほど改善されたのでこれは良かったと思いました。
(CPUプロファイルにて全体の60%を超えていたイメージのコピー処理部分がほぼ0になりました。)
さー パフォーマンスチューニングするぞー
HwndHostがあるではないか
そもそもこんなまどろっこしいことをしなくても、Vulkan側のスワップチェインで直接WPFのウィンドウに描画するという方法があります。
D3DImageなんかにコピーしなくて良くなるのでおそらく最速ですが、Vulkanの描画スレッドとUIスレッドが別にしてしまった都合上、ウィンドウサイズの変更が描画中に起きるとVulkan側がクラッシュする問題が解決できず断念しました。
(もしかしたらうまいことできる方法があるのかもしれないです)
また、ドッキングウィンドウを使うためにAvalonDockを使っているため、ドッキング可能ウィンドウ上でVulkanで正しく描画できるのか?という問題(未調査)があり、やはりHwndHostでの描画は諦めました。
参考