Windows で Unity as a Library

はじめに

Windows でも Unity as a Library ができる、と Twitter で見かけたので試してみました。

unity.com

Windows では Unity 2019.3 以降での対応のようです。この記事では Unity 2019.4.1f1 で行っています。

ホスト側は .NET Core 3.1 + Windows Forms で作りました。

f:id:tan-y:20200625073117p:plain
動作例 / © UTJ/UCL

github.com

手順

Windows でやる場合、方法が 3 通りあります。

  • a) Windows Standalone / Unity コードを外部プロセスとして起動する
  • b) Windows Standalone / Unity コードを DLL として自身のプロセスにロードする
  • c) UWP

ここでは a) と b) について取り上げます。

まず適当に Unity のプロジェクトを作成し、 Windows Standalone でビルドしておきます。

Unity コードを外部プロセスとして起動する

Unity のアプリを起動する際のコマンドラインオプションに "-ParentHWND" が追加されています。このオプションで親ウィンドウとなるウィンドウのハンドルを指定します。ウィンドウの親子関係は異なるプロセス間でも成立できます (これができないとウェブブラウザのプロセス分離ができないことになります) 。

原理的にはこれだけなのですが、実際には親ウィンドウの状態変更に応じた処理 (リサイズなど) が必要になります。 Unity によるサンプル を参考に汎用的な対応コードを実装しました。

  • UnityLibrary.cs
    • Unity のウィンドウハンドル取得
    • Unity ウィンドウのリサイズ
    • Unity ウィンドウの有効化 / 無効化
    • Unity ウィンドウのクローズ

Unity コードを DLL として自身のプロセスにロードする

Unity の実行ファイル本体は UnityPlayer.dll になります。ビルド時に生成されている .exe ではなく、自前で UnityPlayer.dll をロードし、エントリーポイントを呼び出す事でも Unity アプリを起動することができます。これ自体は以前から可能のようですが、ここで指定する起動パラメーターに先ほどの -ParentHWND を指定することで親ウィンドウをした上で起動することができるようになっています。

こちらの手法ではインプロセス実行になるため理屈的には Unity 側とホスト側でメモリ (ポインタ) を共有できることになります (実際にはどうやってポインタ値の受け渡しをするのか検討の必要があります) 。

UnityPlayer.dll をロードする場合、いくつか事前準備が必要になります。

必要なファイルのコピー

まず、ビルドした Unity アプリケーションをホスト側の実行ファイルと同じ位置にコピーする必要があります。厳密には Unity アプリの実行時は カレントディレクトリが Unity アプリと同じになっていなくてはならない という制約に準拠するためです。外部プロセス実行の場合はどこに置いてもそのプロセス下での制約になるのでホスト側は影響を受けません。

また、コピーの際は "???_Data" というフォルダはコピー後に ??? の部分をホスト側の実行ファイル名と同じものにリネームする必要があります。例えば

  • Unity ビルドの実行ファイル名: UnityApp.exe
  • ホストの実行ファイル名: Win32.exe

だった場合 "UnityApp_Data" というフォルダをコピー先で "Win32_Data" にリネームする必要があります。

UnityPlayer.dll のロード ~ 実行

delegate int FnUnityMain(IntPtr hInstance, IntPtr hPrevInstance, [MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine, int nShowCmd);

DLL をロードして "UnityMain" のエントリーポイントを取得、実行をします。

var p = GetProcAddress(_dll, "UnityMain");
var fnUnityMain = Marshal.GetDelegateForFunctionPointer<FnUnityMain>(p);
fnUnityMain(Process.GetCurrentProcess().Handle, IntPtr.Zero, arg, 1);

arg には "-ParentHWND" で親ウィンドウを指定するオプションを設定します。

Unity - ホスト間で通信をする

通常の Win32 ウィンドウの中に Unity アプリを表示することができるようになりました。しかし、これだけだと単に表示がされるだけで統合がされているような感じはしません。組み込むからにはホスト側から Unity の操作をしたり、 Unity の情報をホスト側に伝えたくなるわけです。

iOS ではそういうルートも用意されているっぽい ですが Windows では特にないので、別の方法を考えます。

ということで今回は MagicOnion を使ってみました。 MagicOnion は gRPC を通信基盤としているもののインターフェース定義を全て C# で行えるので使い勝手がとてもよいです。

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

qiita.com

今回は Unity がライブラリ、つまり WinForms → Unity という呼び出しが主になりますが、 Unity は MagicOnion のサーバーになれないので WinForms 側をサーバーに仕立てます。この構成だと "サーバー → クライアント" という呼び出しが主になってしまいます。 MagicOnion は "サーバー → クライアント" 通信では登録済クライアントに対するブロードキャストのみですが、今回は 1 対 1 通信と決まっているのでブロードキャストでも結果ほぼ変わらないのでまあいいかなと。

public interface IUnityChanControllerReceiver
{
    void SetAnimation(AnimeType animeType);
    void SetMessageText(string msg);
}

public interface IUnityChanController : IStreamingHub<IUnityChanController, IUnityChanControllerReceiver>
{
    Task RegisterAsync();
    Task UnregisterAsync();

    Task SetAnimationAsync(AnimeType animeType);
    Task SetMessageTextAsync(string msg);
}

これに対する IUnityChanController (WinForms 側) の実装は次のようにしました。

private IGroup _group;

async Task IUnityChanController.RegisterAsync()
{
    _group = await Group.AddAsync("UnityLibrary");
}

Task IUnityChanController.UnregisterAsync()
{
    return _group?.RemoveAsync(Context).AsTask();
}

Task IUnityChanController.SetAnimationAsync(AnimeType animeType)
{
    Broadcast(_group).OnSetAnimation(animeType);
    return Task.CompletedTask;
}

Task IUnityChanController.SetMessageTextAsync(string msg)
{
    Broadcast(_group).OnSetMessageText(msg);
    return Task.CompletedTask;
}

SetAnimationAsync, SetMessageTextAsync は対応する Receiver に Broadcast でそのままたらしい回しをします。

WinForms 側の Receiver は何もしない空実装であるのに対し、 Unity 側はアニメーション制御だったり UI の更新をしたりしています。 使う時は Unity / WinForms 双方から StreamingHubClient.Connect で接続し、 RegisterAsync で Group の登録をして Broadcast の送受信をできるようにしておきます。実際に Broadcast を受けて何かするのは Unity 側のみ、ということになります。

WinForms の UI から SetAnimationAsync, SetMessageTextAsync を呼び出せば結果的に Unity 側の処理が呼び出されるという構造になりました。

おわりに

Windows での Unity as a Library は実際提供されている機能的には極々限られていて、特に標準で通信手段が用意されていないのはなかなかつらいものがあります。 Unity との通信には MagicOnion を使ってみましたが、 Broadcast を使うしかないですし実際はあんまりお勧めできないかと思います。他の方法といってもパイプとか泥臭い方法になりそうですが・・・

実際のアプリ開発でこの機能を利用しようとした場合、 Unity 側とホスト側で完全に分離してしまうので作業がなかなか大変なことになってしまうとは思います。そこまでしてネイティブ UI を使いたい場合は一考の余地ありといったところでしょうか。