WPF の Window 上に Direct3D12 でレンダリングする
はじめに
UI は WPF だけどメインのレンダリングに Direct3D を使いたい、というケースはあると思うのですが、 WPF は (Unity などと同じように) ウィンドウ全面をレンダリングするので何も考えずにやろうとするとお互い衝突してうまくいきません。共存させる手順をまとめてみました。せっかくなので (?) Direct3D12 を使ってみました。
サンプルはこちらです。
.NET 5 (TargetFramework = net5.0-windows) で実装しています。
Direct3D12 部分は Microsoft の "D3D12HelloTriangle" を SharpDX を用いて C# に移植 + 若干改造して外部からパラメータ調整ができるようにしたものを使っています (それを WPF のスライダーで操作するようしています) が、この部分は C++ でも書けます (C++ と C# 間の受け渡しについては言及しません) 。
WPF 側は異なる 2 種類の方法で実装しています。どちらも長所短所があり、それぞれ個別に解説していきます。ソリューション内に二つの exe プロジェクトがあるので実行ターゲットを切り替えてお試しください。
- WindowsFormsHost 版 (WPF から独立)
- D3DImage 版 (WPF と合成)
記事引用中のコードは要点だけを抽出して実際には不完全なものです。実動コードは GitHub のものを参照してください。
このサンプルは本来であれば必要な手続きをかなり端折っています (ウィンドウリサイズ、移動、正しいデバイスの選択、エラー時の対処など) のでご注意ください。
以前に WPF での GPU ネタ記事を書いていますのでよろしければそちらも読んでみてください。
WindowsFormsHost
WindowsFormsHost は WPF の領域の中に治外法権的に Windows Forms のコンポーネントを配置するためのクラスです。技術的には指定の領域に Win32 の Child Window を生成し、その部分に Windows Forms のコンポーネントを配置できるようにしています。 WindowsFormsHost 自体は WPF のコンポーネントですが、その中に配置される Windows Forms コンポーネント (および Win32 ネイティブコントロール) は WPF 側からは制御できない領域 (右図の赤枠内) になります。
Windows Forms は Win32 のラッパーであり、各コンポーネントには全て Window Handle が割り当てられています。この Window Handle をソースに Direct3D (DXGI) を初期化する事で WPF の Window 内部に Direct3D でレンダリングした映像を表示します。
もちろん WPF の Window も Windows GUI の Window なので Window Handle は持っていますが、この WPF Window の Window Handle に対してネイティブレンダリングをしようとすると WPF 側のレンダリングと干渉してしまいおかしなことになってしまいます。
特徴
完全なネイティブレンダリングである
専用の Window Handle を渡し、その Window Handle に対して Swapchain が直接表示するため WPF から独立したネイティブレンダリングになります。そのため
- パフォーマンス的には有利なやり方になります
- Direct3D だけではなく OpenGL, Vulkan なども可能
- Swapchain のフォーマットに制約も受けないので HDR ディスプレイ出力が可能
但し、 WPF の UI スレッド上でレンダリングを行うと WPF 側の負荷に巻き込まれますので注意が必要です。
WPF の要素と重ね合わせができない
WPF から独立した領域であるため WPF とのオーバーレイ表示ができません。 Direct3D でレンダリングしたものの上に WPF UI を重ね合わせるといった事はできません。
WindowsFormsHost クラス自体は WPF のコンポーネントなので重ね合わせをするようなレイアウトも記述可能ですが、実際には WindowsFormsHost の部分が最前面にきてしまいます。
実装方法
WindowsFormsHost の配置
まず任意の位置に WindowsFormsHost と Direct3D のレンダリング先となる Child Window を持たせるために Windows Forms の Panel を配置します。 Windows Forms のクラスを XAML 内で扱うために xmlns 宣言が必要になります。
<Window
xmlns:wf="clr-namespace:System.Windows.Forms;assembly=System.Windows.Forms">
<WindowsFormsHost Grid.Row="0" x:Name="WinFormsHost">
<wf:Panel x:Name="WinFormsPanel" />
</WindowsFormsHost>
あとはこの Panel の Handle プロパティより Window Handle を取得し、それを用いて Direct3D / DXGI の初期化をすれば OK です。
ただコンストラクタのタイミングでは Window は存在しない = ハンドルが存在しないので、 Loaded イベントで行うのがよいでしょう。
public MainWindow()
{
InitializeComponent();
WinFormsHost.Loaded += (_, _) =>
{
_renderer = new D3D12HelloTriangleWindow(WinFormsPanel.Handle, WinFormsPanel.Width,
WinFormsPanel.Height);
};
}
D3D12HelloTriangleWindow クラスの詳細は割愛しますが一般的な Swapchain と Direct3D12 の初期化手続きです。
レンダリング
あまり WPF の事は気にせず、 Direct3D12 のネイティブレンダリング処理を実装、動作させれば OK です。但し、 WPF の UI 要素は UI スレッド (メインスレッド) 上以外からではアクセスできませんので、 UI で指定した値をレンダリングに反映させたい場合は注意が必要です。
UI スレッド上でゲームループのようなものをまわす場合、 WPF の場合は CompositionTarget.Rendering イベント を使うのが一般的ですが、 WPF の各種処理に巻き込まれるので、繰り返しになりますがパフォーマンス面で注意が必要です。
D3D12HelloTriangleWpfWinFormInterop では雑な実装ですが、 Direct3D12 のレンダリング、 Swapchain の操作を別スレッドで行うようにしています。 Swapchain の Present も含めての別スレッドであるためレンダリングに関しては WPF 側から完全に独立した状態になっています。
D3DImage
キューブ自体は WPF でのレンダリングで、 そのキューブに貼ってある三角形画像のテクスチャが Direct3D12 でレンダリングしているもの (WindowsFormsHost 版とレンダリング処理自体は共通) です。
D3DImage は WPF 内の ImageSource (Bitmap ソースを表すクラス) の一種で外部の Direct3D のネイティブテクスチャをそのまま WPF 内のソースとして扱えるようにするためのクラスです。 Unity で例えると Texture2D の CreateExternalTexture に近いものです。 WPF にはメモリ経由で Bitmap を生成、更新する仕組みがいくつか存在しますがその中で最も高速なものです (次点は InteropBitmap クラス ) 。
D3DImage は "Direct3D を経由して映像ソースを更新する" というものなので最終的な映像出力は WPF の仕組みに準じます。
特徴
レンダリングされた映像が WPF に完全に統合される
WindowsFormsHost では独立空間であったため Direct3D のレンダリングは WPF と分離していましたが、 D3DImage は WPF の映像ソースそのものなので WPF で合成、加工、変形が可能です。そのため WPF アプリ的には WPF の恩恵を全て受けられる事が大きなメリットとなります。
それを明確に示すため、このサンプルでは上図の通り Direct3D12 でレンダリングした D3DImage を WPF でレンダリングしているキューブ (あまり知られていない気もしますが WPF はある程度 3D 表現が可能です) の Material とし、さらに Slider をその上にオーバーレイ表示させています。
つまり普通に 2D の Image として D3DImage を表示すれば WindowsFormsHost では不可能だった "Direct3D のレンダリング結果に WPF UI をオーバーレイする" も実現可能なわけです。
WPF の制約を受ける
WPF と完全統合されるため、そのメリットと同時に WPF の制約も受けます。
最終的な出力は WPF のレンダリングパイプラインの中で行われるため WPF 自体のパフォーマンスの影響を直接受けます。 D3DImage は GPU 側で全て完結するため WPF の中では確かに最速の方法ですが、 WPF 自体がそれなりに重いので Direct3D レンダリング以外の部分で重くなることがあります。
また、 WPF は 8bit カラーであるため HDR ディスプレイ出力を行うことはできません。
必ず Direct3D9Ex の API を通す必要がある
これも WPF の制約の一部ですが、 WPF はバックエンドが Direct3D9 ベースなので Direct3D9 の Surface にしないと WPF 側から扱うことができません。これだと "Direct3D12 はダメじゃん" という事になるのですが、 9Ex 以降には "共有リソース" という仕組みがあってうまく解決できるようになっています。 OpenGL/Vulkan は同様の仕組みがあればいけますが、ないと難しいですね。
この記事では触れませんが、 Direct3D11 でも同じような手続きで組み込みが可能です。 Direct3D11 に関しては D3DImage をラップして Direct3D11 API の範囲だけで使えるライブラリが存在するのですが、メンテナンスされていないようです (私は使ったことありません) 。
実装方法
前述しましたが、 D3DImage を使うためには Direct3D12 側からのレンダリングターゲットとして共有リソースを作成する必要があります。これは一方で共有リソースを生成し、生成時に取得された "共有ハンドル" を用いてもう一方側で開くと成立します。
作成自体はどちらでも構わないのですが、今回は Direct3D9 で作成した RenderTarget を Direct3D12 側で開くようにします。
D3DImage の配置
D3DImage を WPF の画面内に配置します。今回は XAML に記述していますが後付けで C# コードから設定しても大丈夫です。
3D 表示の兼ね合いで ModelVisual3D クラスを使用していますが、 2D 表示の場合は一般的に Image クラス を用います。
<Window
xmlns:i="clr-namespace:System.Windows.Interop;assembly=PresentationCore">
<DiffuseMaterial>
<DiffuseMaterial.Brush>
<ImageBrush>
<ImageBrush.ImageSource>
<i:D3DImage x:Name="D3DImage" />
</ImageBrush.ImageSource>
</ImageBrush>
</DiffuseMaterial.Brush>
</DiffuseMaterial>
Direct3D9Ex の準備
using D3D9 = SharpDX.Direct3D9;
public GraphicsDevice9(int width, int height)
{
D3DEx = new D3D9.Direct3DEx();
Device = new D3D9.DeviceEx(D3DEx, 0, D3D9.DeviceType.Hardware, IntPtr.Zero,
D3D9.CreateFlags.Multithreaded | D3D9.CreateFlags.HardwareVertexProcessing,
new D3D9.PresentParameters
{
// 略
});
IntPtr sharedHandle = IntPtr.Zero;
RenderTarget = D3D9.Surface.CreateRenderTarget(Device, width, height, D3D9.Format.A8R8G8B8,
D3D9.MultisampleType.None, 0, false, ref sharedHandle);
RenderTargetSharedHanlde = sharedHandle;
}
GraphicsDevice9 クラスは Direct3D9 関係をまとめたクラスです。
まず IDirect3D9Ex から IDirect3DDevice9Ex のインスタンスを生成しますが、このデバイスが直接画面に出力するわけではないので D3DPRESENT_PARAMETERS (ここでは PresnetParameters) には適当な設定をします。 Window Handle も NULL (IntPtr.Zero) で OK です。
次に CreateRenderTarget で Direct3D9 の RenderTarget を生成しますが、最終パラメータに共有ハンドルを格納する変数へのポインタ (参照) を指定することで共有リソースが生成されます。
Direct3D12 の準備
次に Direct3D12 側です。 ID3D12Device の生成自体は WindowsFormsHost の時と同じですが、 Swapchain を生成しません 。画面出力は WPF が行うため Swapchain を必要としてないためです。
また、 Device 生成の対象となる GPU は WPF/Direct3D9Ex/Direct3D12 全てで揃える必要があります。 そろえないとエラーが発生したりエラーが発生しなくても実行時にコピーが発生してパフォーマンスが低下したりします。今回のサンプルでは
- Direct3D のデバイスはプライマリで生成する
- WPF 側の配慮はしない
としているのでプライマリディスプレイ (または同じ GPU の別ディスプレイ) で表示するようにしてください。これを厳密に対処するのはそこそこ面倒です。
public D3D12HelloTriangleRenderTarget(int width, int height)
{
_device9 = new GraphicsDevice9(width, height);
_device = new GraphicsDevice(false);
// 略
IntPtr intf;
_device.Device.OpenSharedHandle(_device9.RenderTargetSharedHanlde,
Utilities.GetGuidFromType(typeof(D3D12.Resource)), out intf);
_renderTarget = new D3D12.Resource(intf);
_device.Device は ID3D12Device です。
OpenSharedHandle メソッドで指定の共有ハンドルに対応したリソースを取得します。ここでは ID3D12Resource を取得します (上記は SharpDX を用いた場合の書き方です) 。
あとは通常の Direct3D12 と同じで、 Descriptor Heap を生成し、取得した共有リソースの ID3D12Resource の RenderTarget を生成します。
リソースのピクセルフォーマット
Direct3D は 9 までは BGRA 並びが標準でしたが 10 以降では RGBA 並びが標準になりました。よって 9 とリソース共有する場合は BGRA 並びにします。
9 側で作成する場合は通常通り D3DFMT_A8R8G8B8 もしくは D3DFMT_X8R8G8B8 を選択しますが、 10 以降側で作成する場合は DXGI_FORMAT_B8G8R8A8_UNORM もしくは DXGI_FORMAT_B8G8R8X8_UNORM にする必要があるので注意してください。
ちなみに WPF と無関係な場合など、 10 以降のみで 9 がからまない場合は DXGI_FORMAT_R8G8B8A8_UNORM で共有して問題ありません。
レンダリング
private D3D12HelloTriangleRenderTarget _renderer;
private void Render(float ratio)
{
var d3dimage = D3DImage;
d3dimage.Lock();
try
{
d3dimage.SetBackBuffer(D3DResourceType.IDirect3DSurface9, _renderer.RenderTargetSurface.NativePointer);
_renderer.Render(ratio);
d3dimage.AddDirtyRect(new Int32Rect(0, 0, RenderTargetWidth, RenderTargetHeight));
}
finally
{
d3dimage.Unlock();
}
}
D3DImage に対する更新処理は D3DImage.Lock ~ D3DImage.Unlock の中で行います。
D3DImage.SetBackBuffer で D3DImage で表示する IDirect3D9Surface を指定します。 SetBackBuffer は描画の度に行う必要はなく、変更がないなら一回だけでも OK です。
Direct3D12 側で描画 (ここでは _renderer.Render) を行います。
最後に D3DImage.AddDirtyRect で更新領域を指定し、 Unlock で解除すると Direct3D のレンダリング結果が WPF 側に反映されます。
ちなみに D3DImage.SetBackBuffer で指定する IDirect3D9Surface は WPF が内部で使用している Device とは無関係にアプリ側が独自に取得した Device 下の Surface を指定します (WPF 内の Device は隠蔽化されていて触ることはできません) 。この事より実際は D3DImage で指定した Surface と WPF 内部の間でさらに リソース共有、もしくは転送が行われている はずです。
また、 D3DImage を扱う際、厳密には D3DImage.IsFrontBufferAvailable プロパティ の監視、対応が必要なのですが今回は割愛しています。
おわりに
今回解説した方法は長所短所がそれぞれ正反対みたいなところがあるのでケースバイケースで選ぶ事になるかなと思います。おそらくは D3DImage の方が WPF 的に使い勝手はよいと思いますが、パフォーマンス維持が難しいとかネイティブレンダリングをしたい、といった場合は WindowsFormsHost を使うといった感じでしょうか。
WPF は根本的なところでバックエンドが Direct3D9 というのが現在では致命的で正直避けたいのですが、良い移行先がなかなかないのがつらいところなのですよね。 WinUI (UWP) が早くデスクトップアプリ用として不自由なく使えるようになって欲しいところです。
Discussion