Edited at

(もうちょっと) 楽に Unity ネイティブプラグイン を実装したい


はじめに

この記事は Unityゆるふわサマーアドベントカレンダー2019 の 28 日目の記事です。

昨年末のアドカレでネイティブプラグインの実装記事を書きました。

この記事では Unity Editor 上でネイティブプラグインを動的にロード/アンロードできるようにすることことで、いちいち Unity Editor を再起動することなくネイティブプラグインのリビルド~入れ替えをできるようになったわけですが、ぶっちゃけそれでもまだ面倒くさいわけです。

結局のところ Unity で動作確認しているから面倒くさくなるので Unity に持ってくる前に手軽に動作確認を・・・もっといえば単体アプリのように IDE 上で即 "デバッグ実行~停止" を高速に行えればよいわけです。

C++ で単体テストアプリを作成 ( Unity HDR Display Output plugin ではそうしてます) すればよいのですが、 C++ だと C# とのつなぎ込み部分が確認できず、 C# 部分は Unity にもっていってからという事になります。できればそこもテストアプリで確認したい。

ということで C# (.NET Framework) でテストアプリを作ってテストできるようにしたいと思います。対象は Windows です。

実例プロジェクトは後日上げます (すいません) 。


環境


  • Windows 10

  • Visual Studio 2019 (16.2.1)

  • Unity 2019.1.11f1


プロジェクトを作る

Unity プロジェクトとテストアプリとはテスト対象の C# コードを共有するようにしておきたいので、配置には配慮したいところです。ソースコードのコピーはしたくないので考えられるやり方としては


  1. シンボリックリンクをはる

  2. テストアプリプロジェクトのフォルダー内に Unity プロジェクトを配置する

今回は 2. でやっています。逆にする (Unity プロジェクト内にテストアプリプロジェクト) と Assets の下にテストアプリの .csproj などが配置されてしまうのであまりよろしくないのではないかと思います。

.csproj の直接編集をしているので注意してください。


  1. Visual Studio で Windows Forms プロジェクトを作成する

  2. Unity プロジェクトを 1. で作成した .csproj の位置を保存先にして作成する

  3. Assets の下にネイティブプラグイン用の C# コード (テストアプリと Unity プロジェクトで共有するもの) を配置するフォルダーを作成する

  4. テストアプリのプロジェクトを VS 上で "プロジェクトのアンロード" を行う



  5. "編集" を選択して .csproj を開く



  6. <ItemGroup> タグに 3. で作成したフォルダーを <Folder> タグで追加する。(下記は例)


    <Folder Include="UnityNativePlugin\Assets\NativePluginTest\Scripts\" />



  7. "プロジェクトの再読み込み" をする

以上でテストアプリと Unity プロジェクトで必要な C# コードの共有化準備ができました。 "Assets" までは Unity 側でフォルダーを作成してしまうので、 .csproj 直接編集しないとうまくいかないと思います。これ以後はテストアプリの .csproj で 6. のフォルダーにコードを追加していけば Unity 側からも参照できるようになります。


ネイティブプラグインとつなぎ込みコードの実装

ネイティブプラグイン自体は通常の手順で実装します。

つなぎ込みに関しては DllImport などの DLL 読み込み定義と C# で使いやすくするためのラッパー実装など Unity に非依存なコードまでを実装します。

この辺りに関しては私の過去記事もご一読ください。


テストアプリを作成する

テストアプリのプロジェクトにテストコードを実装していきます。

Unity で扱うのであれば GPU を利用したプラグインである事が多いはずです (もしそうでなければ本記事はここまで) 。よってテストアプリでもグラフィックス API を扱っていきます。

.NET で DirectX を扱う場合は SharpDX というライブラリを利用します。

SharpDX は機能別に細かくパッケージに分かれています。テストアプリの作成には上記くらいのパッケージは必要になります (SharpDX.Desktop は必須ではないですが使った方が楽) 。

SharpDX はプロジェクトが終了していますが、十分安定していると思うので Direct3D 11 レベルまでだったら SharpDX でよいと思います。 Direct3D 12 の最新を使用するのであれば別のライブラリを検討してください。


UnityPluginLoad / UnityPluginUnload に対応する

Unity のネイティブプラグインはロード時に UnityPluginLoad 、アンロード時に UnityPluginUnload が呼ばれます。UnityPluginLoad で渡される IUnittyInterfaces を使う (GPU の Device を必要とする) ネイティブプラグインをテストアプリで確認するには UnityPluginLoad に対応しなくてはならないわけです。

そこで問題になってくるのが "ネイティブプラグインから呼ばれる IUnityInterfaces をどうやって実装するか" です。 ネイティブから呼ばれるので C++ で書けば簡単確実ですが管理するものが増えてしまって最終的には面倒になるので C# で実装してみました。

using System;

using System.Runtime.InteropServices;
using D3D11 = SharpDX.Direct3D11;

namespace NativePluginTest
{
class UnityInterface : IDisposable
{
private delegate IntPtr FnGetInterface(Guid guid);
private delegate IntPtr FnGetDevice();

private FnGetInterface _fnGetInterface;
private FnGetDevice _fnGetDevice;

private GCHandle _handleUnityInterfaces;
private GCHandle _handleUnityGraphicsD3D11;

private IntPtr[] _unityInterfaces;
private IntPtr[] _unityGraphicsD3D11;

private D3D11.Device _device;

public UnityInterface(D3D11.Device device)
{
_device = device.QueryInterface<D3D11.Device>();

_fnGetInterface = UnityInterfaceGetInteface;
_fnGetDevice = UnityGraphicsD3D11GetDevice;

_unityInterfaces = new IntPtr[] { Marshal.GetFunctionPointerForDelegate(_fnGetInterface) };
_unityGraphicsD3D11 = new IntPtr[] { Marshal.GetFunctionPointerForDelegate(_fnGetDevice) };

_handleUnityInterfaces = GCHandle.Alloc(_unityInterfaces, GCHandleType.Pinned);
_handleUnityGraphicsD3D11 = GCHandle.Alloc(_unityGraphicsD3D11, GCHandleType.Pinned);
}

public void Dispose()
{
_device?.Dispose();
_device = null;

_handleUnityInterfaces.Free();
_handleUnityGraphicsD3D11.Free();
}

public IntPtr GetUnityInterfaces()
{
return _handleUnityInterfaces.AddrOfPinnedObject();
}

private IntPtr UnityInterfaceGetInteface(Guid guid)
{
return _handleUnityGraphicsD3D11.AddrOfPinnedObject();
}

private IntPtr UnityGraphicsD3D11GetDevice()
{
return (_device?.NativePointer).GetValueOrDefault();
}
}
}

要点としては


  • IUnityInterfaces の実態は構造体定義の関数ポインターテーブルなので IntPtr 配列に Marshal.GetFunctionPointerForDelegate で取得したポインターを設定する

  • IntPtr 配列は GCHandle.Alloc でピン止めしてポインターを動かないようにしておく

とりあえず Direct3D Device を渡せればよいのでその関連のメソッドだけ実装しました。

本来、 IUntyInterfaces::GetInterface に対応する実装では渡される UnityInterfaceGUID に応じて適切なインターフェースポインターを返さなくてはいけないですが、ここも決め打ち実装にしています。

ピン止めですが、 .NET の Object は参照が有効でも実体の場所は保証されていないので、ピン止めすることでその Object の実体位置が固定化されます (そもそもピン止めしないとポインターは取得できないですが) 。ピン止めはその仕組み上、長時間ピン止めするのは望ましくないですが、今回の場合はアプリ終了まで維持する必要があります。

Marshal.GetFunctionPointerForDelegate で取得したポインターは個人的には結構謎なのですが、参照元のデリゲート自身を GCHandle.Alloc することができないようなので別の仕組みでポインターの有効性を保証しているのでしょう (多分) 。

使う時は次のようにします (抜粋) 。

using System;

using DXGI = SharpDX.DXGI;
using D3D11 = SharpDX.Direct3D11;

[DllImport("NativePlugin")]
static extern void UnityPluginLoad(IntPtr unityInterfaces);

[DllImport("NativePlugin")]
static extern void UnityPluginUnload();

static void Main(string[] args)
{
DXGI.SwapChain swapchain = null;
D3D11.Device device = null;
UnityInterface unityInterface = null;
try
{
var desc = new DXGI.SwapChainDescription() { /* 略 */ };
D3D11.Device.CreateWithSwapChain(D3D.DriverType.Hardware, D3D11.DeviceCreationFlags.None, desc, out device, out swapchain);

unityInterface = new UnityInterface(device);
UnityPluginLoad(unityInterface.GetUnityInterfaces());

// テストコードをここに書く

UnityPluginUnload();
}
finally
{
device?.Dispose();
swapchain?.Dispose();
factory?.Dispose();
unityInterface?.Dispose();
}
}

SharpDX で Direct3D Device を生成し、それを元に UnityInterface クラスのインスタンスを生成します。 UnityInterface からは IUnityInterfaces の実装コードのポインターが取得できるのでこれを引数にネイティブプラグインの UnityPluginLoad を実行します。

これにより初期化からテストアプリで動作確認ができるようになり、 Unity に持っていく前に行えるテスト範囲が大分広くなりました。


おわりに

ネイティブプラグインの実装は必要でないケースの方が多いとは思いますが、いざやろうとするとどうしても面倒と感じてしまう場合が多いように思います。

前回記事でも書きましたが事前準備が多いのでそこがすでに面倒くさい感じもするかもですが、一度準備すれば後は早いと思いますのである程度以上の規模になるなら見合った手間になるのではないかと思います。テストアプリ部分はテンプレート化しておきたいですね。