Unity
Unity #3Day 11

Unity でネイティブプラグインを動的にロード/アンロードする (とはまりポイントを回避する)

はじめに

この記事は Unity #3 Advent Calendar 2018 の 11 日目の記事です。

Unity でネイティブプラグインを使用する場合、 .NET のお約束に従って DllImportAttribute でロードするのが基本ですが、 DllImport でロードするとその Process が終了するまでアンロードされないという問題にぶつかります。ビルドしたアプリケーションだったら問題はないですが、 Unity Editor 上でロード、実行をする場合いちいち Editor を終了させないとアンロードできないわけです。これはネイティブプラグインのコードを変更 → Unity Editor 上でテスト、という作業の効率が著しく低下するので、これをなんとかしたいと考えるわけです。

この問題の回避はいくつか方法があります。

こちらの記事はとても参考になります。ありがとうございます。

私的に一番素直 (と思う) な "LoadLibrary / FreeLibrary を使う" という方法をベースに、この方法での問題とその回避方法を解説していきます。

本記事の対象は Windows です。 Mac は持ってないので・・・

サンプルプロジェクトはこちら。

環境は Unity 2018.2.17f1 + Visual Studio 2017 (15.9.3) ですが、 Unity は 5.3 くらいでも通用する話のはずです (コード自体は .NET 4 の API を使ってしまっているのでその辺りはうまく合わせてください) 。

ネイティブプラグインを動的にロードする

元々 Win32 では LoadLibrary, FreeLibrary という DLL を動的にロード/アンロードする API があるので、これを直接利用して自前でロード/アンロードすれば Unity Editor を終了しなくても DLL の入れ替えが可能になります。

とりあえず便利クラスを作ります。

using System;
using System.Runtime.InteropServices;

public class DllManager : IDisposable
{
    private IntPtr _dll = IntPtr.Zero;

    public DllManager(string dllpath)
    {
        _dll = LoadLibrary(dllpath);
    }

    public void Dispose()
    {
        if (_dll != IntPtr.Zero)
        {
            FreeLibrary(_dll);
            _dll = IntPtr.Zero;
        }
    }

    public TDelegate GetDelegate<TDelegate>(string procname)
    {
        if (_dll != IntPtr.Zero)
        {
            return Marshal.GetDelegateForFunctionPointer<TDelegate>(GetProcAddress(_dll, procname));
        }
        return default(TDelegate);
    }

    [DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode)]
    private static extern IntPtr LoadLibrary(string lpFileName);

    [DllImport("kernel32", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool FreeLibrary(IntPtr hModule);

    [DllImport("kernel32", CharSet = CharSet.Ansi, SetLastError = true)]
    private static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
}

例えばこういったネイティブの関数があったとして

int32_t UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API Sum(int32_t a, int32_t b)
{
    return a + b;
} 

DllImport ではこう

[DllImport("SumLib")]
public static extern int Sum(int a, int b);

というところをこうします。例なのでメソッド内で完結させてますが、実際は DllManager のインスタンスとデリゲートは不要になるまでは保持すべきです。

delegate int FnSum(int a, int b);

public static int Sum(int a, int b)
{
    using (var dll = DllManager("Assets/Plugins/x86_64/SumLib.dll"))
    {
        var funcSum = dll.GetDelegate<FnSum>("Sum");
        return funcSum(a, b);
    }
    throw new Exception();
}

DLL のロードパスについてですが、 DllImport は Unity 側の設定によって読み込む具体的なパスが決定されますが、自前で LoadLibrary をする場合はそのようなフォローがはないので注意が必要です。

LoadLibrary でロードする場合の問題点

一言で言ってしまうと "UnityPluginLoad, UnityPluginUnload" が呼ばれません。

Unity のネイティブプラグインはロード時に UnityPluginLoad 、アンロード時に UnityPluginUnload が export 関数として存在していた場合にそれらが呼び出される仕様になっていますが、これは DllImport でロードした時のみ有効で、 LoadLibrary / FreeLibrary を使用した場合は呼び出されません。 Unity としては DllImport でロードするのが正規のルートで LoadLibrary は想定していないということなのでしょう。多分。

UnityPluginLoad が呼ばれないというのは大きな問題でして、この引数で渡される IUnityInterfaces というインターフェースポインタを取得できるタイミングがここしかありません。 IUnityInterfaces から実際のデバイスを取得したりするため、例えば Direct3D を直接叩いてレンダリングをしたい、といった時に何もできなくなる可能性が出てきます。

解決方法

そもそも IUnityInterfaces が必要ない

先にさも問題がありそうな事を書きましたが、実際のところ IUnityInterfaces がいらない可能性は大いにあり得ます。 IUnityInterfaces が必要になる場合の多くは Unity ではできない GPU を使った機能を Unity アプリで利用したい、といった事が考えられますが、そうでないなら UnityPluginLoad 自体を気にする必要はないでしょう。

IUnityInterfaces がなくても必要なものが取得できる場合がある

Direct3D 11 や Direct3D 12 だったら Device 自体はテクスチャ等から取得できてしまいます。テクスチャなど多くのリソースは ID3D11DeviceChild (12 だったら ID3D12DeviceChild) を継承しているため、その GetDevice メソッドから取得できます。

Unity からネイティブ側にテクスチャを渡す場合、 Texture.GetNativeTexturePtr メソッドを使用しますが、ここで渡されるポインターは PlayerSettings の使用 Graphics API の種類によりますが、実際のそのプラットフォームのリソースそのもので、 Direct3D11 では ID3D11Texture2D だったりしますのでそこからデバイスの取得ができます。

void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API FillTexture(IUnknown *unityTexture, float x, float y, float z, float w)
{
    ComPtr<ID3D11Texture2D> texture;
    ComPtr<ID3D11Device> device;
    ComPtr<ID3D11DeviceContext> dc;

    if (unityTexture == nullptr || FAILED(unityTexture->QueryInterface(&texture)))
    {
        return;
    }

    texture->GetDevice(&device);
    device->GetImmediateContext(&dc);
    //  (以下略)
}

Direct3D 11 の場合、 Device と ImmediateDeviceContext が取得できたら後はなんでもできるので、 IUnityInterfaces がなくてもなんとかなるようになる可能性が高くなります (12 は厳しいかも) 。

IUnityInterfaces を保持するだけのプラグインを用意する

どうしても IUnityInterfaces が必要なケースもあります。

例えばさまざまな Graphics API に対応したプラグインを開発する場合、 Unity がどの API で動作をしているかを判断するためには IUnityInterfaces を用いなければ判断できません。 Texture.GetNativeTexturePtr はどんなポインター値なのか全く推測できない (先の例では Unity 側の設定がわかっている決めつけ状態でした) ので、正しく実装するなら IUnityInterfaces は必要でしょう。

Unity から IUnityInterfaces を取得するには DllImport でプラグインをロードするようにしなくてはならない。・・・ということはそれだけのプラグインを用意すればいいんじゃない?という発想になるわけです。

#ifdef __cplusplus
extern "C" {
#endif

IUnityInterfaces *g_unityInterfaces = nullptr;

IUnityInterfaces UNITY_INTERFACE_EXPORT *UNITY_INTERFACE_API GetUnityInterface()
{
    return g_unityInterfaces;
}

void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginLoad(IUnityInterfaces* unityInterfaces)
{
    g_unityInterfaces = unityInterfaces;
}

void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginUnload()
{
    g_unityInterfaces = nullptr;
}

#ifdef __cplusplus
}
#endif

これだけです。このプラグインを "UnityInterfaceManager.dll" という名前でビルドして Unity プロジェクト下に配置し、先ほどの DllManager に一部手を加えます。

delegate void FnUnityPluginLoad(IntPtr unityInterfaces);
delegate void FnUnityPluginUnload();

public DllManager(string dllpath)
{
    _dll = LoadLibrary(dllpath);

    if (_dll != IntPtr.Zero)
    {
        GetDelegate<FnUnityPluginLoad>("UnityPluginLoad")?.Invoke(GetUnityInterface());
    }
}

public void Dispose()
{
    if (_dll != IntPtr.Zero)
    {
        GetDelegate<FnUnityPluginUnload>("UnityPluginUnload")?.Invoke();
        FreeLibrary(_dll);
        _dll = IntPtr.Zero;
    }
}

[DllImport("UnityInterfaceManager")]
private static extern IntPtr GetUnityInterface();

ポイントは

  • コンストラクタ、 Dispose でそれぞれ UnityPluginLoad, UnityPluginUnload を呼ぶ。
  • UnityPluginLoad で渡す IUnityInterfaces は DllImport でロードした UnityInterfaceManager.dll から取得する。

となっていることです。

さらにこの動的にロード/アンロードするプラグインは MonoBehavior 実装クラス内で記述し GameObject に登録することで "実行開始時にロード ~ 終了時にアンロード" と大体普通に想像できる挙動にすることが可能になります。

public class NativePluginImporter : MonoBehaviour
{
    public static void FillTexture(System.IntPtr unityTexture, float x, float y, float z, float w)
    {
        _funcFillTexture?.Invoke(unityTexture, x, y, z, w);
    }


    private DllManager _dll = null;

    private delegate void FnFillTexture(System.IntPtr unityTexture, float x, float y, float z, float w);
    private static FnFillTexture _funcFillTexture = null;

    private void Awake()
    {
        _dll = new DllManager(@"Assets/Plugins/x86_64/MainPlugin.dll");
        _funcFillTexture = _dll.GetDelegate<FnFillTexture>("FillTexture");

        DontDestroyOnLoad(this);
    }

    private void OnDestroy()
    {
        _dll?.Dispose();
        _dll = null;
        _funcFillTexture = null;
    }
}

どうにもならない IUnityInterfaces の取得だけを DllImport でのロードとし、それ以外を LoadLibrary とすることで DllImport のアンロード不可縛りをほぼ解消させているのがポイントとなります。

おわりに

ネイティブプラグインの開発において動的ロード/アンロードができることは作業効率に大きな影響があると思います。 LoadLibrary を使うのは DllImport と比べれば確かに手数は増えますが、一度実装すればよいので長い目で見れば動的ロードの仕組みを組み込む方がよいのではないかと思います。プリプロセッサを使用して Unity Editor では動的ロード/アンロード、ビルドした場合は DllImport と使い分けるとよいと思います。

また、昨年のアドカレで書いた記事と組み合わせると、ほとんどの機能は C++ のクラスメソッド経由となって DllImport 対象となる直接 export している関数はほとんどなくなって実質的な手数にあまり差がなくなるという事もあります。

よろしければこの記事も合わせてお読みください。

参考: サンプルプロジェクトの試し方

NativePluginProject.sln のビルド

  • Unity Editor 下にある "Editor\Data\PluginAPI" をプロジェクト下にフォルダー名 "PluginAPI" でそのままコピーしてください。
  • .sln を開き、 x64 構成でビルドしてください。出力先は Assets/Plugins/x86_64 下に配置するようにしています。

Unity Project

  • NativePluginImporter.cs の先頭に定義している USE_DYNAMICLOADER プリプロセッサを無効化すると DllImport でプラグインをロードします。
  • DllManager.cs の先頭でコメントアウトしている DO_NOT_CALL_UNITYPLUGINLOAD プリプロセッサを有効化すると動的ロード時に UnityPluginLoad を呼び出しません。 (ネイティブ側は呼んでも呼ばなくても問題ないようにしています)