凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity でネイティブプラグインの実行時更新を可能にする PatchLibrary を使ってみた

はじめに

主に Windows での開発の話になります。

Unityネイティブプラグインの開発していると、一番困るのが DLL の更新です。Unity では DLL を読み込むとエディタを再起動するまで解放してくれないため、エディタ実行中に DLL を置き換えることが出来ません。そのため、ネイティブ側のコードを書き換えた場合は、一度エディタを立ち下げ、ビルドし、再度エディタを立ち上げ更新の確認をする、という非常に面倒な開発サイクルになります。

そこで本エントリでは、この問題を解決し、その上、Visual Studio でのエディットコンティニュのようなホットリロード(実行時の動的なコードの置き換え)も可能にした id:i-saint さんが作成された PatchLibrary についての紹介を行いたいと思います。

環境

テスト

ダウンロードと実行

GitHub からダウンロードします。

Test フォルダ以下にサンプルが入っているので見てみます。まず、TestUnityProject に含まれる TestUnityPlugin.unity シーンを開いて実行します。すると、TestUnityPlugin コンポーネントの中で、DLL 側で定義されている int TestFunction() が呼び出され、その戻り値が 0.5 秒おきにコンソールへ出力されます(デフォルトでは 0 が出力され続けます)。

次に、TestUnityPluginTestUnityPlugin.sln を開きます。TestFunction() が書かれているので、この戻り値を 100 など適当な値に書き換えてビルドします。すると Unity 側のコンソールで 0 だった出力が 100 に切り替わっているのが見て取れると思います。

動画

自分のプロジェクトに組み込む

テストプロジェクトを見てみる

どのように PatchLibrary が使われているのかプロジェクトを見てみると、Build Events(ビルド イベント)の Post-Build Event(ビルド後)で以下のように PatchLibraryProxy64.exe が呼び出されています。

f:id:hecomi:20170128140436p:plain

xcopy /Yq $(TargetPath) $(SolutionDir)..\TestUnityProject\Assets\Plugins\x86_64\
$(SolutionDir)..\..\bin\PatchLibraryProxy64.exe /target:Unity.exe /patch:$(TargetPath)

通常のネイティブプラグイン開発プロジェクトでは、xcopy コマンドをするだけです。エディタの未実行時であれば問題ないのですが、1 度でも DLL を利用するコードを実行すると、DLL のコピーが共有違反で出来ず、エラーとなってしまいます。

そこで次に PatchLibraryProxy を実行しています。内部では、/target オプションで指定したプロセス(ここでは Unity.exe)が立ち上がっている時に、DLL インジェクション(インジェクションする DLL は PatchLibrary64.dll)を行って Unity.exe との間で通信を行えるようにし、/patch オプションで指定した DLL の中身を置き換える処理を色々と行っているようです(参考 12)。

組み込む

オンライン上のリポジトリで管理する際に同梱しなくても済むよう、私は bin ディレクトリ下にある EXE と DLL を適当な場所に移し、そこへ環境変数の PATH を登録することで、どこからでも PatchLibraryProxy を呼び出せるようにしています。そして、PatchLibrary を所有していない別の開発者の環境でもエラーが起こさないに Post-Build Event を以下のように書き換えました。

xcopy /Yq $(TargetPath) $(SolutionDir)..\PATH_TO_YOUR_UNITY_PROJECT\Assets\Plugins\x86_64\
where /q PatchLibraryProxy64
if not ERRORLEVEL 1 (PatchLibraryProxy64 /target:Unity.exe /patch:$(TargetPath))

これで色々なネイティブプラグインプロジェクトの開発が便利になると思います。

注意点

DLL 内で状態を持つような場合は、自分でその状態の復元をする必要があるようです。

例えば、Unity 公式の FrameCapture では、以下のように IUnityInterfaces のポインタを引き継ぐ処理をしています。

BOOL WINAPI DllMain(HINSTANCE module_handle, DWORD reason_for_call, LPVOID reserved)
{
    if (reason_for_call == DLL_PROCESS_ATTACH)
    {
        ...
        // DLL のエントリポイントは ::LoadLibrary() で呼ばれるため、DLL 更新時にここを通る
        fcGfxForceInitialize();
    }
    ...
}
static IUnityInterfaces* g_unity_interface;

fcCLinkage fcExport IUnityInterfaces* fcGetUnityInterface()
{
    return g_unity_interface;
}

typedef IUnityInterfaces* (*fcGetUnityInterfaceT)();

void fcGfxForceInitialize()
{
    // PatchLibrary で突っ込まれたモジュールは UnityPluginLoad() が呼ばれないので、
    // 先にロードされているモジュールからインターフェースをもらって同等の処理を行う。
    HMODULE m = ::GetModuleHandleA("FrameCapturer.dll");
    if (m) {
        auto proc = (fcGetUnityInterfaceT)::GetProcAddress(m, "fcGetUnityInterface");
        if (proc) {
            auto *iface = proc();
            if (iface) {
                UnityPluginLoad(iface);
            }
        }
    }
}

この他にも状態を持つマネージャの様なオブジェクトは、何かしらの機構を用意してコンテキストを引き継ぐか、前のものを終了処理して新しい方を開始処理するようなコードが必要になります。

おまけ:手動でロード・アンロードする方法

コードが煩雑になりますが、DllImport を使用せずに手動で kernel32.dll の機能である LoadLibrary()FreeLibrary() をする、という方法もあります。

関数の呼び出しは、GetProcAddress() で取ってきた関数ポインタを GetDelegateForFunctionPointer() でデリゲートへと変換し、それを DynamicInvoke() することで実現しています。ただ記事中の方法のままではエディタ上でしか使えないので、ちょっと使い方を考える必要はあります。

おわりに

Unity 標準でこういった動的更新の仕組みが入ると嬉しいですね(せめて解放処理だけでも手動でできるようになると嬉しい)。