WPFのコントロールにDirectX12で描画する


モチベーション

モーショングラフィックスエンジン的なものを作りたい。
グラフィックスの描画はDXRを見越してDirectX12が使いたい。
でも、周りのUIはグラフィックスとは分離して、MVC的なフレームワークで作りたい。

Unityでいいじゃんって話なのですが…

やりたいこと

WPFで色々なパラメータやリソースを表示編集できるWindowの中に、DirextXが描画するコントロールを置きたい。

ポリ一枚表示しただけですし、スライダーで動くわけでもなければリサイズにも対応していませんが、
一応上記画像のようにできました。

D3DImage/D3D11Image(今回は不採用)

WPFにはD3DImage/D3D11Imageというクラスが用意されており、それぞれDirextX9/11向けのRenderTargetを提供しています。
WPFDXInterop

DirectX12向けのクラスは提供されていませんが、DirectX11-12間でテクスチャリソースの共有が可能です。
DirectX11のWPFDXInteropでは、WPFControlからIDXGIResourceが手に入るので、それからID3D11Texture2Dを取得し、RenderTargetViewを作成し描画します。
DirectX12の場合も同様にIDXGIResourceからID3D12Resource1を取得してRenderTargetViewを作成して描画すればいいはずです。
(DirectX12ではTextureやConstantBufferなどのインターフェイスとしての垣根がなくなり、ID3D12Resource1に統一されました。)

しかし、いざ実装してみようとすると、SwapChain使わない場合のやり方などがよくわからなかったので、確実なHwndHostを使ってみました。
ID3D12CommandQueue::ExecuteCommandListsの後、IDXGISwapChain::Presentを呼ばずに、描画完了も待たずにリターンすればいいのでしょうか?WPFコントロールから渡されるIDXGIResourceはバックバッファなんですかね?とすると、描き切れなかったら半端なRTが表示されちゃう???

HwndHost(WPF Control)とは

DirectX12のサンプルなどでは生Win32でGUIを生成して、ルートのWindowに対して描画をしています。
Win32のGUIというのは、以下のようなもので前半部がWindowの初期化、後半部がいわゆるUpdateやイベント(メッセージ)処理です。
HWNDという変数が出てきますが、これはFormやWPFでいうWindow/FormやControlに相当するもので文字通りのウインドウからボタンまでWindowとして扱い、HWNDで管理します。
DirectXにHWNDを指定して描画させる場合、このWindowの矩形領域内に描画することになります。

main.cpp
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PSTR lpCmdLine, int nCmdShow)
{
    WNDCLASS winc; // WNDCLASSの設定
    HWND hwnd = CreateWindow(
        TEXT("MYCLASSNAME"), TEXT("Title"),
        WS_OVERLAPPEDWINDOW | WS_VISIBLE,
        CW_USEDEFAULT, CW_USEDEFAULT,
        CW_USEDEFAULT, CW_USEDEFAULT,
        NULL, NULL, hInstance, NULL
    );

    MSG msg;
    while (GetMessage(&msg, NULL, 0, 0))
    {
        DispatchMessage(&msg);
        theApp.Render();
    }
    return msg.wParam;
}

それで、HwndHostはこのWINAPIの枠を提供してくれます。
実質的にはWindowの矩形領域を提供してくれる&HWNDを保持管理してくれます。

class MyControl : HwndHost
{
    protected virtual HandleRef BuildWindowCore(HandleRef hwndParent)
    {
        // 初期化処理(ただし、この時点ではまだ矩形情報が取れない!)
        // CreateWindowとかして、帰ってきたHWNDを返す
    }
    protected virtual IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
    {
        // Win32の後半のメッセージループの部分
        // DirectXのUpdate(Render)処理はここで
    }
    protected virtual void DestroyWindowCore(HandleRef hwnd)
    {
        // 終了処理
    }
}

ここにうまいことDirectX12の初期化処理とUpdate(Render)処理を挟みます。
この方法の最大の利点は、Win32で作る場合とほとんど変わらない…というかWin32との互換性維持のための機能という点です。
DirextXはそもそもゲームを作るためのAPIなので外部GUIは不要でWindowいっぱいに描けばいいので、手に入るサンプルやノウハウも大抵の場合は生Win32のルートWindowに描いています。それらを参考にできます。

ということで、このHwndHostとWin32とDirectX12合体させてみたのがこちらになります。
DirectX12の処理は「DirectX12 Programming Vol.1」の付録のサンプルコードを流用させていただきました。

DX.cs
class DX : HwndHost
{
    [Flags] enum WindowStyle : int { /* 省略 */ }

    IntPtr app = IntPtr.Zero;

    protected override HandleRef BuildWindowCore(HandleRef hwndParent)
    {
        // Win32のWindowの初期化
        IntPtr hwnd = CreateWindowEx(
            0, "STATIC", "",
            WindowStyle.WS_CHILD | WindowStyle.WS_VISIBLE,
            0, 0,
            (int)ActualWidth, (int)ActualHeight,
            hwndParent.Handle,
            (IntPtr)WindowStyle.HOST_ID,
            IntPtr.Zero, 0);
        return new HandleRef(this, hwnd);
    }

    protected override IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
    {
        if (app == IntPtr.Zero)
        {
            // DirectX12の初期化
            // BuildWindowCoreでInitしたかったが、は矩形が0のままなのでDepthBufferが作れない。
            // 本当はリサイズも考慮してデバイスのInitとRenderTarger/DepthBufferの生成を分けるべき。
            app = Init(hwnd, (int)ActualWidth, (int)ActualHeight);
        }

        // DirectX12の描画(のリクエスト)処理
        Render(app);

        handled = false;
        return IntPtr.Zero;
    }

    protected override void DestroyWindowCore(HandleRef hwnd)
    {
        // Win32のWindowとDirectX12の終了処理
        DestroyWindow(hwnd.Handle);
        Dispose(app);
    }

    [DllImport("user32.dll")]
    static extern IntPtr CreateWindowEx( /* 省略 */ );
    [DllImport("user32.dll")]
    static extern bool DestroyWindow(IntPtr hwnd);

    [DllImport("02_SimpleTriangle.dll")]
    static extern IntPtr Init(IntPtr hwnd, int width, int height);
    [DllImport("02_SimpleTriangle.dll")]
    static extern void Render(IntPtr app);
    [DllImport("02_SimpleTriangle.dll")]
    static extern IntPtr Dispose(IntPtr app);
}
Export.cpp
TriangleApp* Init(HWND hwnd, int width, int height)
{
    auto app = new TriangleApp(); // TriangleApp.h
    app->Initialize(hwnd, width, height);
    return app;
}

void Render(TriangleApp* app)
{
    app->Render();
}

void Dispose(TriangleApp* app)
{
    app->Cleanup();
    delete app;
}
MainWindow.xaml
<Window> <!-- 属性は省略 -->
    <Grid>
        <local:DX Margin="18,43,342,122"></local:DX>
        <WrapPanel Margin="527,101,53,281">
            <TextBlock Text="Hogehoge"></TextBlock>
        </WrapPanel>
        <Slider Margin="517,149,39,231"></Slider>
    </Grid>
</Window>

DLLImportするときはImportするDLLが参照しているDLLもexeのディレクトリに並べないとDllNotFoundExceptionが出ます。
この例では、02_SimpleTriangle.dlldxcompiler.dllなどを参照していてハマりました。

全文は こちら(Github)

技術領域問題

WPFのコントロールは一枚のDirectX9で描かれています。そこに無理やりWin32のウインドウを乗っけてそこにDirextX12で描画している訳です。
なのでWPFコントロールでサンドイッチすることができません。

詳しくは 技術領域の概要(MSDN)

余談

DirectX12の情報…少なすぎ!!
最近のMSの動向としてはDirectXを触るのは本当に限られた場合だけで、基本的にはUnityとか使ってね!君たちはDirectXなんて知らなくていいよ!ってスタンスですし、いまだにDirectX9が現役(?)ですからね。ゲームの専門学校などでもいまだにDirectX9と聞きます。確かにシンプルで初学向けなのかもしれませんが。
MSDNは情報「量」だけは結構ありますし、「DirectX12 Programming Vol.1」のおかげで基本的な使い方は理解できましたが、少し外れたことを、応用したいと思うと似たような例が見つかりませんね。

参考