Mirrativ tech blog

株式会社ミラティブの開発者(サーバサイドエンジニア,iOSエンジニア,Androidエンジニア,機械学習エンジニア,インフラエンジニア, etc...)によるブログです

【Unity】Mirrativのアバターがなんで動いているのか誰にもわからないので説明する

こんにちは、よこてです。Mirrativ のアバターは Unity で動いているという話をします。Mirrativ は iOS/Android の ライブ配信アプリですが、機能の一つとしてエモモ(アバター)があります。

f:id:n0mimono:20191203204328p:plain

これは Unity で動いているのですが Mirrativ そのものはネイティブのモバイルアプリです。意味がわかりませんね。具体的には

f:id:n0mimono:20191203204412p:plain

オレンジの部分がネイティブで実装されていて、青い部分がUnityで実装されています。わかりにくいですね。要するに 基本的にはネイティブ実装されていて、アバターの部分だけがUnityで実装されています

このように Mirrativ は ネイティブ実装とUnity実装のハイブリッド構成 になっています。これは歴史的経緯があるのですが、Mirrativ はモバイルのライブ配信サービスとして開発され運用されてきました。最初はアバターという機能はなかったため、普通のiOS/Androidのネイティブアプリだったわけです。その後にサービスグロースの過程において Mirrativ にアバターを導入することにしたのですが、 Mirrativ の既存の資産を活かして市場検証を最速で回したいというモチベーションがありました。このような背景があり、 native app + Unity という構成の導入が検討されました。

ネイティブアプリの資産を活かしつつ Unity を利用する方法はいくつか考えられますが、その一つに WebGLを使う方法があります。アバターとしての機能は WebGL という形で出力して、アプリ側からは WebView 経由で表示させるという方法です。Mirrativ のアバター導入検討時の最初期にはこれを実験したのですが、パフォーマンス上の問題があり断念しました。

今 Mirrativ では Unity をフレームワークとして扱い、ネイティブに埋め込むという Embedded Unity を採用しています。Unity 2019.3 では Unity as a library という形でサポートされる予定ですが、実はそれ以前のバージョンでも同じことができるのです。

Embedding Unity

Unity をネイティブアプリに組み込むにあたり考えるべきことは

  1. Unity の view の切り出し
  2. フレームワーク化(iOS なら .framework 、 Android なら .aar として出力)
  3. ネイティブアプリ(Swift/Kotlin)からの利用

まず重要なのは Unity が出力するプロジェクト は single view application である、ということです。Unity は一つの view を生成して全画面表示しているだけで、この view は iOS では単なるUIView、 Android ではSurfaceViewです。つまり Unity の view はネイティブアプリがもつ他の view と全く同じように扱うことができます。

具体的には、 Unity の iOS ビルド時に export される Xcode プロジェクトは

  • main()が Unity のエンジンを起動、またUIApplicationMain()の呼び出し
  • UnityAppControllerUIApplicationDelegateを実装
  • UnityGetGLView()UIViewを取得

となっていて

  • Unity が通常実行するmain()の中身をネイティブ側のコードから実行(かつUIApplicationMain()を呼ばない)
  • UnityAppControllerをネイティブ側のapplicationに追加
  • UnityGetGLView()で得られる view をネイティブ側のもつ view にinsertSubview

とすれば Unity の view をネイティブな iOS アプリ上で動作させることができます。

ちなみに

int main(int argc, char* argv[])
{
    UnityInitStartupTime();
    @autoreleasepool
    {
        UnityInitTrampoline();
        UnityInitRuntime(argc, argv);

        RegisterMonoModules();
        RegisterFeatures();

        std::signal(SIGPIPE, SIG_IGN);

        UIApplicationMain(argc, argv, nil, [NSString stringWithUTF8String: AppControllerClassName]);
    }

    return 0;
}

Unity のmain()はこんな感じで実装されています。

iOS

ビルド

iOS のフレームワーク化はjiulongw/swift-unityの実装がほぼすべてなのですが簡単に説明していきます。

Unity が export する Xcode のプロジェクトはこのような構成になります。この中で特に重要なのは次の3つで

  • Classes
    • Unity のエンジンに相当
    • Classes/UnityAppController.mmUIApplicationDelegate相当
    • Classes/main.mmが entry point 相当
    • これを外部から利用できる形にコードを修正する
  • Libraries
    • 主に .NET の周辺
  • Data
    • アプリに embed されるデータ群

基本的には bridging header を追加してこれらを framework としてビルドするだけです。 .framework が得られるため、他の iOS アプリからフレームワークとして利用することができます。

ネイティブからの利用

ネイティブ側からの利用については、特に重要な部分を抜粋すると

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    @objc var currentUnityController: UnityAppController!

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {        
        unity_init(CommandLine.argc, CommandLine.unsafeArgv)
        
        currentUnityController = UnityAppController()
        currentUnityController.application(application, didFinishLaunchingWithOptions: launchOptions)
        
        currentUnityController.applicationDidBecomeActive(application!)
        currentUnityController.applicationWillResignActive(application!)

        return true
    }

    func applicationWillResignActive(_ application: UIApplication) {
        currentUnityController.applicationWillResignActive(application)
    }
    
    func applicationDidBecomeActive(_ application: UIApplication) {
        currentUnityController.applicationDidBecomeActive(application)
    }
}

unity_init()main()の置き換えになります。unity_init()でエンジンを起動して、UnityAppControllerUIApplicationDelegateとして登録しています。applicationDidBecomeActiveを一度呼び出しているのは、UnityAppControllerapplicationDidBecomeActive時点でグラフィックの初期化(= view の初期化)を行っているためで、明示的にこれを実行しています。

また、view の利用は

class ViewController: UIViewController {
    func showUnitySubView() {
        if let unityView = UnityGetGLView() {
            view?.insertSubview(unityView, at: 0)
            
            unityView.translatesAutoresizingMaskIntoConstraints = false
            let views = ["view": unityView]
            let w = NSLayoutConstraint.constraints(withVisualFormat: "|-0-[view]-0-|", options: [], metrics: nil, views: views)
            let h = NSLayoutConstraint.constraints(withVisualFormat: "V:|-75-[view]-0-|", options: [], metrics: nil, views: views)
            view.addConstraints(w + h)
        }
    }
}

適当なUIViewControllerinsertSubviewすることを考えます。UnityGetGLView()でもらえるUIViewinsertSubviewするだけです。実際にはapplicationDidBecomeActiveから呼び出されるグラフィックの初期化を待った上でshowUnitySubViewを呼び出す必要があります。

メッセージング

ネイティブから Unity に対してはSendMessageを利用できます。

void UnitySendMessage(const char* obj, const char* method, const char* msg);

Unity からネイティブに対しては native plugin を利用できます。文字列を渡すだけなら

#ifndef UnityPlayerToIOS_h
#define UnityPlayerToIOS_h

@protocol UnityCallback <NSObject>
@optional
@required
- (void)receiveMessage: (NSString *)msg;
@end

@interface UnityPlayerToIOS : NSObject
@property (class, nonatomic) id <UnityCallback> receiver;
+ (void)sendMessage: (NSString *)msg;

@property (class, nonatomic) id <UnityCallback> callbackReceiver;
+ (void)sendCallbackMessage: (NSString *)msg;
@end
#endif
#import "UnityPlayerToIOS.h"

static id <UnityCallback> _receiver = nil;

@implementation UnityPlayerToIOS
+ (id <UnityCallback>) receiver {
    return _receiver;
}
+ (void)setReceiver: (id <UnityCallback>)receiver {
    _receiver = receiver;
}
+ (void)sendMessage: (NSString *)msg {
    if (_receiver) {
        [_receiver receiveMessage:msg];
    }
}
@end

extern "C" {
    void sendMessage(const char *msg) {
        [UnityPlayerToIOS sendMessage:[NSString stringWithCString: msg encoding:NSUTF8StringEncoding]];
    }
}

Unity からは文字列を流し込み、ネイティブ側はコールバックを受け取るようにします。

これら2つで双方向にテキストを流せるため、ネイティブからの呼び出しという形で Unity 側のAPIを構築することができます。

Android

ビルド

こちらの記事がほぼすべてになります。Android のライブラリ作成は iOS と比べて簡単で、 AndroidManifest.xml と build.gradle を書き換えるだけでライブラリを作ることができます。

AndroidManifest.xml から applicationタグを消します。

  <!--
  <application android:icon="@mipmap/app_icon" android:label="@string/app_name" android:isGame="true" android:banner="@drawable/app_banner" android:theme="@style/UnityThemeSelector.Translucent">
    ...
  </application>
  -->

build.gradle をlibraryに対応します。

apply plugin: 'com.android.library'

これだけです。あとはメニューからビルドをポチっとするだけで .aar が作成されます。

ネイティブからの利用

Unity のライブラリにはエンジンをラップする形でUnityPlayerという view があり、UnityPlayerActivityがこれをもっています。UnityPlayerを使う最も手っ取り早い方法はUnityPlayerActivityを継承してしまうことで

class UnityActivity : UnityPlayerActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_unity)

        frame.addView(mUnityPlayer)
    }
}

これだけで Unity を埋め込んだ形で使うことができます。ただし、 iOS では view を生成する部分のコードが直接出力されるため view の不透明/半透明を切り替えるのはそれほど難しくありませんが、Android で同じことをやろうとすると多少の工夫が必要になります。UnityPlayerの実体はSurfaceViewですがこれに直接触る手段がないため

        var view = mUnityPlayer.view
        var field = view.javaClass.getDeclaredField("s")
        field.isAccessible = true
        var sf = field.get(view) as SurfaceView
        sf.setZOrderOnTop(true)
        sf.holder.setFormat(PixelFormat.TRANSPARENT)

このようにリフレクションでSurfaceViewを引っ張ってくる必要があります(フィールド名はUnityのバージョンによって変わります)。

メッセージング

iOS と同様にネイティブから Unity に渡すときはSendMessage

UnityPlayer.UnitySendMessage(gameObject, method, msg)

Unity からネイティブにわたすときはコールバックを使います。

public class UnityPlayerToAndroid {
    public interface ICallback {
        public void receiveMessage(String message);
    }

    public static ICallback receiver = null;

    public void sendMessage(String message) {
        if (receiver != null) {
            receiver.receiveMessage(message);
        }
    }

    public static ICallback callbackReceiver = null;

    public void sendCallbackMessage(String message) {
        if (callbackReceiver != null) {
            callbackReceiver.receiveMessage(message);
        }
    }
}

おわりに

だいたいこんな感じで iOS/Android のネイティブアプリに Unity を組み込むことができます。

現実の運用ではCDが必要になりますが、 Cloud Buildのような便利ツールはないので自前で作ります。 正直、ビルドする方法探すよりこっちのほうが骨が折れます。 またフレームワーク単体だとUnityエディタ上で動作確認が一切行えないため、開発用のシーンを別途作ってあるのですが、フレームワーク自体に操作するためのUIがないためこちらもそれなりに作り込む必要があります。

Unity をネイティブアプリのフレームワークとして扱うことが有用なケースとしては、アプリのコアな機能がネイティブ側にある場合で 3D を扱う必要がある場合には検討しても良いのではないでしょうか。 3D である程度リッチな機能を高速につくろうとすると Unity が圧倒的に優秀な一方、 Unity はあくまでゲームエンジンなのでネイティブアプリのような構成でつくるのは必ずしも得意というわけではないからです。 Mirrativ はこれに相当します。ただし、開発・運用は複雑になり細かいハマりポイントも多くあるためあまり安易に導入するのは現状としてはオススメできません。

今後の展望としては 2019.3 でサポートされる形への乗り換えを考えています。単純に Unity のバージョンを最新に保ちたいというのが理由ですが、現状 Unity のバージョンを更新するたびに 微妙な不具合が毎回出る (普通にビルドした場合は起きない)というのが観測されていてつらいなーと思っています。そういうわけで、公式がサポートしてくれるのは 仕事がなくなって嬉しい 心強いなーと思っています。

We are hiring!

ミラティブでは Unity を hack しながらエモモを一緒に作ってくれるエンジニアを募集中です! 体験入社という制度もあるのでお気軽にどうぞ!

www.mirrativ.co.jp

www.wantedly.com