はじめに
主に Windows での開発の話になります。
Unity でネイティブプラグインの開発していると、一番困るのが DLL の更新です。Unity では DLL を読み込むとエディタを再起動するまで解放してくれないため、エディタ実行中に DLL を置き換えることが出来ません。そのため、ネイティブ側のコードを書き換えた場合は、一度エディタを立ち下げ、ビルドし、再度エディタを立ち上げ更新の確認をする、という非常に面倒な開発サイクルになります。
そこで本エントリでは、この問題を解決し、その上、Visual Studio でのエディットコンティニュのようなホットリロード(実行時の動的なコードの置き換え)も可能にした id:i-saint さんが作成された PatchLibrary についての紹介を行いたいと思います。
環境
- Windows 10
- Unity 5.5.0f3
テスト
ダウンロードと実行
GitHub からダウンロードします。
Test
フォルダ以下にサンプルが入っているので見てみます。まず、TestUnityProject
に含まれる TestUnityPlugin.unity
シーンを開いて実行します。すると、TestUnityPlugin
コンポーネントの中で、DLL 側で定義されている int TestFunction()
が呼び出され、その戻り値が 0.5 秒おきにコンソールへ出力されます(デフォルトでは 0 が出力され続けます)。
次に、TestUnityPlugin
の TestUnityPlugin.sln
を開きます。TestFunction()
が書かれているので、この戻り値を 100 など適当な値に書き換えてビルドします。すると Unity 側のコンソールで 0 だった出力が 100 に切り替わっているのが見て取れると思います。
動画
自分のプロジェクトに組み込む
テストプロジェクトを見てみる
どのように PatchLibrary が使われているのかプロジェクトを見てみると、Build Events(ビルド イベント)の Post-Build Event(ビルド後)で以下のように PatchLibraryProxy64.exe
が呼び出されています。
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 の中身を置き換える処理を色々と行っているようです(参考 1、2)。
組み込む
オンライン上のリポジトリで管理する際に同梱しなくても済むよう、私は 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 内で状態を持つような場合は、自分でその状態の復元をする必要があるようです。
@hecomi 更新DLLはUnityPluginLoad()が呼ばれないので、場合によっては自力初期化が必要です(例: https://t.co/BGDXCFDVYj ) あとはコンテキストを持ち越したい場合、自力でシリアライズ&デシリアライズする必要があります。
— i-saint (@i_saint) 2017年1月9日
例えば、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()
をする、という方法もあります。
- Unloading Native Plugins in the Unity Editor » Running Dimensions
- Dynamically calling an unmanaged dll from .NET (C#) – Jonathan Swift's Blog
関数の呼び出しは、GetProcAddress()
で取ってきた関数ポインタを GetDelegateForFunctionPointer()
でデリゲートへと変換し、それを DynamicInvoke()
することで実現しています。ただ記事中の方法のままではエディタ上でしか使えないので、ちょっと使い方を考える必要はあります。
おわりに
Unity 標準でこういった動的更新の仕組みが入ると嬉しいですね(せめて解放処理だけでも手動でできるようになると嬉しい)。