LoginSignup
14
12

Vulkan から WPF に直接イメージをコピーする

Last updated at Posted at 2023-09-06

動機

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内のすべての定義を生成してくれます。

NativeMethods.json
dxgi.*
d3d9.*

DirectXの初期化とレンダーターゲットの作成

D3DImageのサーフェースを用意するため、Direct3D9の初期化を行います。

DirectXの初期化
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側で利用するデバイスと、こちらのデバイスを一致させる必要があるのですが、今回は簡略化のためにサボりました。

DirectXデバイスの初期化
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); }
}
XAML
<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 を呼び出して得られるサーフェースのハンドル を指定する必要があります。
当初、ここを勘違いして直接共有ハンドルを指定してしまい、長いこと躓きました。

WPF へのイメージの共有
// 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での描画は諦めました。

参考

おまけ

これはフルレイトレでレンダリングできたミクさんです。
image.png

14
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
12