yotiky Tech Blog

とあるエンジニアの備忘録

3ds Maxで地図をトレースしてUnityにオブジェクトを取り込む方法

もくじ

はじめに

対象読者は、3ds Max を触り始めたばかりの人です。(実際、触り始めて2、3日目くらいの記事)

最後まで読むと、下絵とした地図画像をトレースして、Unityに取り込むためのFBXの書き出しと、トレースした結果を画像として保存できます。

エディタで操作する箇所は赤枠で囲んだあたりになります。 f:id:yotiky:20181211163031p:plain

また、単位設定でディスプレイとシステムをcmに設定してます。

手順

下絵の設定

  1. 作成 > ジオメトリ > プリミティブ > 平面 でPlaneオブジェクトを作成
    f:id:yotiky:20181211163244p:plain

    1. 修正 > パラメータ で幅と長さを画像に合わせる(例:800 x 600)
    2. 移動でZを-0.01に

    f:id:yotiky:20181211163455p:plainf:id:yotiky:20181211163457p:plain

  2. レンダリング > マテリアルエディタ > コンパクトマテリアルエディタ を開く(もしくは[M])
    ※モードで、コンパクトとスレートを切り替えられる
    f:id:yotiky:20181211163500p:plain

    1. 自己照明を100に
    2. 拡散反射光の「なし」から、ビットマップを選んでトレースする画像を選択
    3. 終結果を表示 をクリック
    4. シェーディングマテリアルをビューポートに表示 をクリック
    5. Planeオブジェクトを選んで、マテリアルを選択へ割り当て をクリック

    f:id:yotiky:20181211163502p:plain:w300f:id:yotiky:20181211163506p:plain

  3. Planeオブジェクト選んで、右クリック > オブジェクトプロパティ f:id:yotiky:20181211163509p:plain

    1. インタラクティブ
      1. フリーズをチェック
    2. 表示プロパティ
      1. フリーズをグレーで表示をアンチェック
    3. レンダリング制御
      1. 表示を0.5
      2. レンダリング可能をアンチェック

    f:id:yotiky:20181211163512p:plain:w300

  4. Planeオブジェクトを選んで、回転でZを180に

    1. ViewPortは後ろから見た絵にすると楽(Unityのデフォルトの向きに合わせる)

    f:id:yotiky:20181211163516p:plain:w500

トレース

  1. 地面用にPlaneオブジェクトをもう一個追加して、サイズと位置(Zだけ0)を下絵のPlaneオブジェクトに重ねる
    f:id:yotiky:20181211163520p:plain:w500

  2. 作成 > シェイプ > スプライン > ライン で、スプラインを使ってトレース開始

    1. あとで調整するので大体の位置に角を置いて輪っかを作り、必要な分だけ書いてく(各スプラインは必ず閉じる)
      f:id:yotiky:20181211163524p:plainf:id:yotiky:20181211163528p:plain
  3. 各Lineオブジェクトで、頂点を選択して、座標を調整していく

    1. 地面用Planeオブジェクトと重ならないように、移動でZを0.1に
      f:id:yotiky:20181211163532p:plainf:id:yotiky:20181211163535p:plainf:id:yotiky:20181211163538p:plain
  4. 各Lineオブジェクトで、ポリゴンを選択(面としてレンダリングされる)
    f:id:yotiky:20181211163541p:plainf:id:yotiky:20181211163544p:plain:w350

  5. レンダリング > マテリアルエディタ >スレートマテリアルエディタ (もしくは[M])でマテリアルを設定
    f:id:yotiky:20181211163547p:plain

    1. フィジカルマテリアル をダブルクリック
    2. Viewで作成したマテリアルのタイトル部分をダブルクリック
    3. 基本パラメータ > ベースカラーと反射 でカラーを指定
    4. マテリアルをLineにドラッグ・アンド・ドロップ

    f:id:yotiky:20181211163551p:plain:w500f:id:yotiky:20181211163555p:plain:w500f:id:yotiky:20181211163600p:plain:w350

  6. 階層 > 基点 > 基点にのみ影響 で基点のXを90に(UnityのYupに合わせる) f:id:yotiky:20181211163605p:plain:w500

エクスポート

  1. Unityに取り込みたいオブジェクトを選択し、ファイル > 書き出し > 選択を書き出し をクリック
    f:id:yotiky:20181211163651p:plainf:id:yotiky:20181211163653p:plain:w350

  2. アニメーション、カメラ、ライトをアンチェックして書き出す f:id:yotiky:20181211163656p:plain:w500

    1. Warning出るけど無視
      f:id:yotiky:20181211163659p:plain:w500
  3. 出力したFBXをUnityにドラッグ・アンド・ドロップで完了 f:id:yotiky:20181211163705p:plainf:id:yotiky:20181211163708p:plain

トレース結果を画像として保存

  1. 作成 > カメラ > フリー でカメラ追加
    f:id:yotiky:20181211163610p:plain

    1. ストックレンズは200mm
    2. 回転でZを180に
      f:id:yotiky:20181211163613p:plain:w300f:id:yotiky:20181211163620p:plain
  2. レンダリング > レンダリング設定 で出力サイズを元画像に合わせる(例:800 x 600)
    f:id:yotiky:20181211163622p:plain:w230f:id:yotiky:20181211163628p:plain:w230

  3. ビューポートをカメラ[C]にしてセーフフレーム[Shift+F]を表示 f:id:yotiky:20181211163630p:plain:w500

  4. カメラの位置を(X, Y) = (0, 0)にして、黄色い枠にPlaneの縁が合うようにZを遠ざけて調整

    1. 必ずカメラのビューポートを確認しながら
  5. カメラのビューポートをアクティブにして、レンダリング > レンダリング からイメージを保存で画像書き出し
    f:id:yotiky:20181211163633p:plainf:id:yotiky:20181211163641p:plain:w500

地面用Planeオブジェクトを抜いて画像で出力し、下絵の画像に重ねた結果。 f:id:yotiky:20181211163446p:plain:w600

エントリーポイントを探してUnityの森を彷徨う

Unity 基礎シリーズ 目次

Unityの開発を始めてまず探したくなるのがエントリーポイント、アプリケーションが始まる場所である。
サンプルアプリ程度の短いコード量ならサクッと処理を追えるだろうと思うわけだ。だがしかし、これが一向に見つからない。
あるはずのものがなくてちゃぶ台をひっくり返す。冷静さを取り戻し再度Unityと向き合うも何度探してもわからない。
お前は一体どこから走り始めたんだ、、、やがてUnityをそっと閉じる。

目次

エントリーポイントが見つからない理由

Unityはゲームループで動くエンジンである。
エンジンとMonoBehaviourを継承したスクリプトプラグインアーキテクチャのような仕組みになっており、規定の名前のイベント関数を実装することで各々のタイミングでエンジンがキックしてくれる。 f:id:yotiky:20181129031839p:plainf:id:yotiky:20181129031841p:plain

開発者が書くスクリプトは、基本的にはこのMonoBehaviourを継承したクラスになり先の仕組みで動く。イベント関数で一番早いのはAwakeだが、実行タイミングはシーンがロードされ、各GameObject(Component)のインスタンスが生成し終わった後くらいに走り出すと言ったところ。アプリケーションのエントリーポイント、ゲームループが始まるよりも前の処理はエンジン側が隠し持ち、開発者が触れる表層ではゲームループ以降となる。

f:id:yotiky:20181129031935p:plain

では、「どの順でAwakeが呼ばれ、どのGameObject(Component)が最初になるのか」だが、UnityではComponentの実行順は保証されていない。恐らくInstanceID順で実行はされるのだが、このIDは保存されておらず編集やUnityの再起動などで簡単に変わってしまう。(詳しくは「複数のComponentのイベント関数の実行順」を参照)

このような構成のため、Unityを触り始めた人がエントリーポイントを探しても簡単には見つからない、理解できないということになる。

エントリーポイントを作る

最近書くアプリケーションでは、エントリーポイントを意図的に作るようにしている。『MVP4U』1記事でEngineの初期化(こちらはアプリ側のEngine)で軽く触れたが、単一のエントリーポイントを定義し、そこを起点にScript側から波及的に初期化を呼び出す設計だ。 f:id:yotiky:20181129032034p:plain

ここではScenePresenterとしているが、これがエントリーポイントとなる。
ScenePresenter以外の全Presenter(以降Presenter)は、ScenePresenter(以降EntryPoint)に登録されている。
PresenterにはInitializeメソッドが定義されておりここに初期化処理を集約する。AwakeやStartといったイベント関数は持たない。
View側も同様にInitializeを用意し、Presenterの初期化の中でViewのそれを呼び出す。Viewの先にあるComponentについてもInitializeに初期化を定義し、PresenterのInitializeを起点として波及的に呼び出されるようになっている。

    // EntryPoint.cs
    public class EntryPoint : MonoBehaviour
    {
        public PresenterBase[] presenters;

        void Start()
        {
            foreach (var p in presenters)
            {
                p.InitializeOnStart();
            }
        }
    }
    // SamplePresenter.cs
    public class SamplePresenter : MonoBehaviour
    {
        private SampleView view;

        public void InitializeOnStart()
        {
            view = GetComponent<FloorView>();
            view.Initialize();
            // その他必要な初期化処理
        }
    }
    // SampleView.cs
    public class SampleView : MonoBehaviour
    {
        public HogeComponent hoge;

        public void Initialize()
        {
            hoge.Initialize();
        }

イベント関数以外のメソッドの実行に関しては「Scriptのイベント関数の実行順と実行可否」の最後に書いたが、GameObject/Componentの有効/無効に関係なく、Awakeが呼ばれて無くてもメソッドの呼び出しは可能である。これは初期化を行うためにGameObjectをアクティブにしたり、編集中にHierarchy上での状態を気にする必要がないことも意味する。
さらにEntryPointはStart(もしくはAwake)となるため、この時点でGameObject/Componentのインスタンス生成はすべて完了しており、Initializeの波及が問題なく実行される。

注意点としては、Initializeで実行順序を担保するので、AwakeやStartなどと初期化を混同しないことだろうか。意図的に分ける場合は除くが。

余談だがこの実装の場合、UniRxのメソッドチェーンは多くのケースでInitializeの中で宣言的に定義することができる。


  1. Unity向けにMVPを解釈したアーキテクチャ(MVP for Unity)

MonoBehaviourのコンストラクタ/デストラクタ

Unity 基礎シリーズ 目次

目次

検証環境

Unity:2017.4.5f1

MonoBehaviourのコンストラクタ/デストラク

MonoBehaviourを継承したクラスではコンストラクタを書いて初期化をせず、イベント関数のAwakeやStartの中で初期化する。Unityの鉄則の一つ。
理由は、クラスがインスタンス化されるタイミングが分からない上に、実行時以外にもインスタンス化と破棄が繰り返し行われているのと、もうひとつはUnityEngine.Objectのコンストラクタは別スレッドで呼ばれるとのこと。1 実際にMonoBehaviourのコンストラクタ/デストラクタの処理が走るタイミングは次のようになった。GameObject3-1はNon-activeにしてある。末尾はインスタンス判別用の識別子。

f:id:yotiky:20181122094239p:plain

  1. Editor上でPlay

    1. GameObject1.Destructor: 0e031e
    2. GameObject2.Destructor: 9ac3b8
    3. GameObject2-1.Destructor: 8551b3
    4. GameObject3.Destructor: dc8392
    5. GameObject3-1.Destructor: 808b20
    6. GameObject1-1.Destructor: 8f06af

    7. GameObject1-1.Constructor : 9884aa

    8. GameObject2.Constructor : 9fb890
    9. GameObject2-1.Constructor : f9fe82
    10. GameObject1.Constructor : 55321f
    11. GameObject3-1.Constructor : 28671f
    12. GameObject3.Constructor : 1fe677

    13. GameObject3-1.Constructor : 8841c6 (GameObject non-active)

    14. GameObject1-1.Constructor : 216283
    15. GameObject2.Constructor : fac40a
    16. GameObject2-1.Constructor : bcab4a
    17. GameObject3.Constructor : 3f2e53
    18. GameObject1.Constructor : c7c2ac

    19. GameObject1-1.Awake : 216283

    20. GameObject2.Awake : fac40a
    21. GameObject2-1.Awake : bcab4a
    22. GameObject3.Awake : 3f2e53
    23. GameObject1.Awake : c7c2ac

    24. GameObject2-1.Destructor: f9fe82

    25. GameObject2.Destructor: 9fb890
    26. GameObject1.Destructor: 55321f
    27. GameObject3-1.Destructor: 28671f
    28. GameObject1-1.Destructor: 9884aa
    29. GameObject3.Destructor: 1fe677
  2. Editor上でStop

    1. GameObject3-1.Constructor : 2d3d8b
    2. GameObject1-1.Constructor : c72899
    3. GameObject2.Constructor : 28c597
    4. GameObject2-1.Constructor : 7e0981
    5. GameObject3.Constructor : 3f5c8f
    6. GameObject1.Constructor : a22ef8

    7. GameObject3-1.Destructor: 8841c6

    8. GameObject2-1.Destructor: bcab4a
    9. GameObject2.Destructor: fac40a
    10. GameObject1-1.Destructor: 216283
    11. GameObject1.Destructor: c7c2ac
    12. GameObject3.Destructor: 3f2e53

Editor上でPlayするとまずインスタンスの破棄が行われる。その後2度インスタンスが生成されひとつはすぐに破棄される。また、Stopした際にはPlay中のインスタンスは破棄され新たなインスタンスが生成される。 Play/Stopの有無にかかわらず、Editor上での編集中やComponentをアタッチした際にも裏で生成と破棄が繰り返し行われている。最初に破棄されたインスタンスや最後に生成されたインスタンスは編集用のインスタンスであろう。

まとめ

MonoBehaviourを継承したクラスではコンストラクタで初期化は行わない鉄則は守る。
GameObjectはActiveかどうかにかかわらずまずインスタンスが生成され、イベント関数同様にGameObject全体のインスタンスを生成した後、Awakeの処理に移る。

複数のComponentのイベント関数の実行順

Unity 基礎シリーズ 目次

目次

検証環境

Unity:2017.4.5f1

Componentが複数の場合の実行順

基本的にHierarchyやInspectorの配置順に関係なく、Componentの実行順は保証されない。

1つのGameObjectに複数のComponent

Editor上でComponentをAddした順番を記憶してて、最後に追加したComponentから逆順に実行されているように見える。上から順にComponentを貼っつけていった場合、綺麗に降順になる。

f:id:yotiky:20181120132245p:plain

  1. Component3.Awake
  2. Component2.Awake
  3. Component1.Awake
  4. Component3.Start
  5. Component2.Start
  6. Component1.Start
  7. Component3.Update
  8. Component2.Update
  9. Component1.Update

次に下のように、Component3、Component2、Component1の順に貼っ付けると結果はこうなる。並び順でもなく追加した順なのがわかる。

f:id:yotiky:20181121011040p:plain

  1. Component1.Awake
  2. Component2.Awake
  3. Component3.Awake
  4. Component1.Start
  5. Component2.Start
  6. Component3.Start
  7. Component1.Update
  8. Component2.Update
  9. Component3.Update

だがしかしUnityを再起動したりSceneをUnload/Loadしようものなら、手中に収めていた実行順がたちまちランダムになる。1つのGameObjectについている複数のComponentはおそらく下から順に呼ばれるようになる。この辺の挙動を紐解く。

Componentは何順に実行されるのか

InspectorをDebug表示にするとInstanceIDが表示されるのだが、Componentの呼び出し順はこのIDの昇順になってそうだ。問題なのはこのIDはシリアライズされないので、Unityの再起動やSceneの読み込みなどのタイミングで一定のロジックを通って採番されなおすことになる。

こちらは上から順にComponentを貼っつけた状態。新たにくっつけたComponentは、マイナス方向にある程度幅を取った数字が割り振られていく。IDの昇順なので、このまま実行すればComponent1、Component2、Component3の順に呼び出される。

f:id:yotiky:20181122005354p:plain

  1. Component1.Awake
  2. Component2.Awake
  3. Component3.Awake

続いて、1度SceneをUnloadしてLoadし直す。するとプラス方向に下から上へIDが採番され直しているのがわかる。仮にランダムな順番にComponentをくっつけた場合でも下から順に採番され直す。この例だと実行結果は先程と変わらない。

f:id:yotiky:20181122005416p:plain

  1. Component1.Awake
  2. Component2.Awake
  3. Component3.Awake

1つのGameObject内であれば採番され直したタイミングで下から順に呼び出されるようになる。たぶん。
これが編集中は新たに貼っつけたComponentとIDの順番があべこべ状態になって実行順がランダムになると錯乱する原因かも。

ツリー状のGameObjectの場合の実行順

以前の記事では他への参照のありなしや、他のGameObjectを使ってみたりして順番に変化が起きるか検証したりもしたが影響がないので分けずに書く。

こちらも上から順にComponentを貼っつけていった場合、そのまま実行すれば綺麗に降順になる。

f:id:yotiky:20181120132228p:plain

  1. GameObject3-1-1.Awake
  2. GameObject3-1.Awake
  3. GameObject3.Awake
  4. GameObject2-1-1.Awake
  5. GameObject2-1.Awake
  6. GameObject2.Awake
  7. GameObject1-1-1.Awake
  8. GameObject1-1.Awake
  9. GameObject1.Awake

次に孫、子、親の順で下からつけた場合の結果。親、子、孫の順に呼び出される。

  1. GameObject1.Awake
  2. GameObject2.Awake
  3. GameObject3.Awake
  4. GameObject1-1.Awake
  5. GameObject2-1.Awake
  6. GameObject3-1.Awake
  7. GameObject1-1-1.Awake
  8. GameObject2-1-1.Awake
  9. GameObject3-1-1.Awake

そしてUnityを再起動したりSceneをUnload/Loadしようものなら、手中に収めていた実行順がたちまちランダムになる。 今度は先程のように予測可能な状態ではない。一度ランダムになると変更を一切加えなければUnityを再起動してもシャッフルされないので、何かしらの採番ルールはありそうではあるが、凡人には到底予測不可能な値で採番される。

こちらはComponentを追加した直後のID。実行すれば降順で呼び出される。

name InstanceID
GameObject1 -26518
GameObject1-1 -26790
GameObject1-1-1 -26980
GameObject2 -27150
GameObject2-1 -27386
GameObject2-1-1 -27552
GameObject3 -27746
GameObject3-1 -27980
GameObject3-1-1 -28174

続いて、1度SceneをUnloadしてLoadし直す。実行結果はランダムと言っても過言ではない。

name InstanceID
GameObject1 13096
GameObject1-1 13160
GameObject1-1-1 13274
GameObject2 13222
GameObject2-1 13308
GameObject2-1-1 13124
GameObject3 13292
GameObject3-1 13216
GameObject3-1-1 13200
  1. GameObject1.Awake
  2. GameObject2-1-1.Awake
  3. GameObject1-1.Awake
  4. GameObject3-1-1.Awake
  5. GameObject3-1.Awake
  6. GameObject2.Awake
  7. GameObject1-1-1.Awake
  8. GameObject3.Awake
  9. GameObject2-1.Awake

まとめ

InstanceIDはGameObjectの一意な識別子として扱われることがあるIDだが、例に上げたように実行時に変更されることはないが、SceneのLoadやUnityの再起動で変わる可能性がある。また、GameObjectとして書かれてることが多いが、GetInstanceIDメソッドは実際はComponentクラスが持っており、GameObjectに対して呼び出した場合はTransformのIDが返される。
Componentの実行順は予測できないので保証されない(常に変わる可能性がある)として理解しておく必要がある。 それでも制御したい場合は、「Script Execution Order」というものもある。1

参考

Scriptのイベント関数の実行順と実行可否

Unity 基礎シリーズ 目次

編集履歴

目次

検証環境

Unity:2017.4.5f1

主な関数の順番

  1. Awake : one time
  2. OnEnable : each time object is enabled
  3. Start : one time
  4. Update : iteration
  5. OnDisable : each time object is disabled
  6. OnDestroy : one time

GameObjectとComponentがactive/non-activeの時に呼ばれる関数

Awake/OnDestroyは、GameObjectに紐づき、他はComponentに紐づくっぽい。
AwakeはGameObjectが有効になった時に最初に1度だけ呼ばれる。Componentに紐づくその他の関数は、GameObjectの初期化であるAwakeをトリガーに呼ばれるようになる。
activeかどうかは自身のGameObject(activeSelf)に加え、親の影響(activeInHierarchy )も受ける。

    bool isActiveFirst;
    void Awake()
    {
        Debug.Log("Awake");
    }
    void OnEnable()
    {
        isActiveFirst = true;
        Debug.Log("OnEnable");
    }
    void Start()
    {
        Debug.Log("Start");
    }
    void Update()
    {
        if (isActiveFirst)
        {
            isActiveFirst = false;
            Debug.Log("Update");
        }
    }
    void OnDisable()
    {
        Debug.Log("OnDisable");
    }
    void OnDestroy()
    {
        Debug.Log("OnDestroy");
    }

Initial state

  • GameObject : non-active / Component : non-active
    • not called
  • GameObject : non-active / Component : active
    • not called
  • GameObject : active / Component : non-active
    1. Awake
  • GameObject : active / Component : active
    1. Awake
    2. OnEnable
    3. Start
    4. Update

Activate

  1. GameObject : non-active / Component : non-active
    • not called
  2. GameObject : active / Component : non-active
    1. Awake
  3. GameObject : active / Component : active
    1. OnEnable
    2. Start
    3. Update

Disable/Enable

  1. GameObject : active / Component : active
    1. Awake
    2. OnEnable
    3. Start
    4. Update
  2. GameObject : active / Component : non-active | GameObject : non-active / Component : active
    1. OnDisable
  3. GameObject : active / Component : active
    1. OnEnable
    2. Update

Destroy

  1. GameObject : active / Component : active
    1. Awake
    2. OnEnable
    3. Start
    4. Update
  2. GameObject : destroy
    1. OnDisable
    2. OnDestroy

StartのCoroutine(コルーチン)を使った場合に呼ばれる順番

Coroutineの再開はUpdateが呼ばれた後に実行される。

    IEnumerator Start()
    {
        Debug.Log("Start1");
        yield return null;
        Debug.Log("Start2");
    }

    int counter = 0;
    void Update()
    {
        if (counter < 3)
        {
            counter++;
            Debug.Log("Update" + counter);
        }
    }
  1. Start1
  2. Update1
  3. Update2
  4. Start2
  5. Update3

StartのCoroutine(コルーチン)完了後にUpdateを開始する

CoroutineはGameObjectが有効な時に実行され、Coroutineが開始された後GameObjectが無効になるとCoroutineも無効になる。CoroutineはUpdateと異なり、1度開始されるとComponentの有効/無効とは関係なく動作する。

    IEnumerator Start()
    {
        var i = 1;
        Debug.Log("Start" + i);
        enabled = false;
        foreach (var x in Enumerable.Range(0, 3))
        {
            i++;
            Debug.Log("Start" + i);
            yield return null;
        }

        enabled = true;
        i++;
        Debug.Log("Start" + i);
    }

    int counter = 0;
    void Update()
    {
        if (counter < 3)
        {
            counter++;
            Debug.Log("Update" + counter);
        }
    }
  1. Start1
  2. Start2
  3. Start3
  4. Start4
  5. Start5
  6. Update1
  7. Update2
  8. Update3

イベント関数以外の関数

独自に実装したメソッドの呼び出しは、GameObject/Component の有効/無効とは関係なく実行できる。これはAwakeが呼ばれていないGameObjectやComponentでもメソッドの呼び出しは可能であることも意味する。

f:id:yotiky:20181120132303p:plain

    public Script4Sub script;
    bool done;
    void Update()
    {
        if (!done)
        {
            done = true;
            Debug.Log("Update");
            script.WriteLog();

            Debug.Log("- GameObject : non-active / Component : active");
            script.gameObject.SetActive(false);
            script.WriteLog();

            Debug.Log("- GameObject : non-active / Component : non-active");
            script.enabled = false;
            script.WriteLog();
        }
    }
    public void WriteLog()
    {
        Debug.Log("GameObject is active ? :" + gameObject.activeSelf);
        Debug.Log("Component is active ? :" + enabled);
    }
  • GameObject : active / Component : active
    • GameObject is active ? : True
    • Component is active ? : True
  • GameObject : non-active / Component : active
    • GameObject is active ? : False
    • Component is active ? : True
  • GameObject : non-active / Component : non-active
    • GameObject is active ? : False
    • Component is active ? : False

参考

Unityと協調するためのアーキテクチャ『MVP4U』

これまでいくつかの記事でアーキテクチャデザインパターンを見てきた。Unity独自の事情なども徐々に咀嚼し、ある程度動きそうなアーキテクチャを開発チームにアウトプットできたので、ここらで共有しておこうと思う。まだ適用し始めた段階なので、今後いくつかのプロジェクトで試してみて内容を精査していければと考えている。

編集履歴

  • 2018/11/28 サンプルプロジェクトを掲載

目次

序章

UWP / Web / UnityのMVx

MVxはView を分離するためのアーキテクチャデザインパターン。いくつかのプラットフォームにおいてどういう構成となるか比較してみる。

f:id:yotiky:20181116001505p:plain

それぞれのViewを見ていくと、

UWP(WPF)は、XAMLというマークアップ言語の部分をViewとして捉えがちだが、Viewの裏に持つFrameworkとMVVM Frameworkが強力な土台となってなりたっている。ただ、実装者から見ると、XAMLとそれをカスタマイズする機能を実装すれば良い設計となっているので、主にXAML≒Viewとして扱われる事が多い。

Web(JavaScript)は、大きくフロントエンドとバックエンド(サーバーサイド)でMVxの構成を取る。その上でさらにフロントエンド内でもMVxを取る二重構成。
フロントエンドに注目した場合、HTML+CSSが極めてピュアなView(フロントヤード)、JavaScriptによる機能実装部分がView以外(バックヤード)ということができる。

Unityは、コンポーネント指向、つまり機能を部品として実装し、組み合わせることで全体を構成する設計思想を取っている。コンポーネント指向に対し、"Viewをどう捉えるか"が難しいという問題を持っている。

UnityのMVPを考える

UnityはUWP(WPF)とWeb(JavaScript)の中間のような構成になっている。
UWP(WPF)ほど実装者に露出する部分が高度に設計された作りになっていないし、そうするための強力なFrameworkも整っていない。また、Webのフロントヤードほど「ピュアなViewとそれ以外」のような設計がされているわけでもない。このため、「Unityのコンポーネント指向に歩み寄りつつ、如何にViewとして実装者が扱いやすい形を定義するか」が肝となる。

MVP4U (MVP for Unity)

f:id:yotiky:20181116002113p:plain

Unityでは、Rxを組み合わせてMVPを構築することが流行っており、PresenterはViewとModelを参照し、逆方向には戻り値やRxで依存関係を一方向に担保する。
MVP4Uでは、Viewの部分に「Componentを調和・調整する役目」として"FrontOrchestrator"を定義する。詳細は次の通り。

f:id:yotiky:20181116002130p:plain

Hierarchyでは、最上位にあたるRootと一階層目にParent、その配下にそれぞれ3DモデルなどのGameObjectが配置される。
RootはSceneに唯一のScenePresenterを持ち、ParentではViewとPresenterをそれぞれ1つ持つ。ScenePresenterには下位のPresenterをすべて登録し、エントリーポイントとしてPresenterの初期化とその順序を制御する役割を持つ。

MVP4UにおけるViewの目的は、「Componentを束ね、Presenterが必要とする情報、操作を公開する」こと。紐づくParent配下で、Script側から操作する必要があるComponentを参照する。自身に機能として処理は持たない。これを許容すると、Viewは使い勝手が良い立ち位置なので処理が集約してしまい、コードが肥大化することが予想される。機能は各Componentに持たせることで適切な責務を分担する。

FrontOrchestratorは、自身のComponentで処理が完結しないComponent同士を連携させたい時に調整する役割を持つ。
MVPを導入し始めた頃に、「Viewとして公開し、PresenterでView同士を紐づける」ことをやりがちだがこれが該当する。PresenterはModelとの橋渡しなので、View同士のコネクションのために無理にPresenterを経由する必要はないので、ここでその辺を吸収するわけだ。

XR

UWP/Web/Unity(XR)のView

f:id:yotiky:20181116002155p:plain

UnityのとくにXRアプリにおいては、MVx パターンが適用しにくい。なぜか。
他のアプリ(UWP/Web/uGUIなど)では"画面"という単位が存在し、画面遷移することでアプリのシナリオが進行する。この"画面"を単位としてViewを定義することで、ViewとViewの境目が明確になる。一方、XRには"空間"しか存在しない。1つの空間を区切る単位が存在しないため、無理に区切ろうとすると結局1つの空間が1つのViewになってしまう。

シナリオの進行

UnityはGame Loopでプログラムが駆動している。1 ループの一巡で、必要な処理が開始して終わるまでがFrameと言う単位になり、ループを繰り返してタイムラインを形成する。
f:id:yotiky:20181116002222p:plain
f:id:yotiky:20181116002226p:plain

アプリを作成するには、Game Loopとは別にアプリのタイムライン、シナリオを組み立ててそれを進行させる必要がある。また画面があるアプリでは、"画面のフレーム(枠)"に対し「状態を入れ替える」ことで遷移するイメージができるが、画面のないXRでは、空間に存在する物の「状態を変化させる」イメージが近いのかも知れない。

XRでの適用例

構成

MVP4UをXRの事情に合わせて適用する例を紹介する。

f:id:yotiky:20181116002321p:plain

シナリオの進行を管理する部分を、アプリのエンジンとして通常のMVP構成とは別けて考える。
シナリオの章となるChapter2を遷移する単位とし、ScenarioはChapterのつながりが書かれた台本となる。シナリオの進行役にはDirectorを配置する。ディクレターに渡す台本を替えると異なるストーリーが展開される、みたいな作り。状態をチャプターを跨いで共有したい場合は、シナリオの文脈ということで、SenarioContextに記録して共有する。
Hierarchyでは、RootにあたるSpaceと、ParentにあたるChapterを持つことになる。HierarchyのChapterはScenarioのChapterとほとんどの場合は1:1の関係である。

フロントヤードでは、Unityのお作法に近いかたちで実装していけば良い。
ComponentやFrontOrchestratorは、Startで初期化したり、gameobjectを直接いじったり、Observableが必要なら使えば良い。FrontOrchestratorがプレーンなクラスで実装できるならその方が良いが、必要があればMonoBehaviourを使っても良いだろう。フロントヤードで初期化の順序を制御したくなったらViewで纏めて調整することもできる。

Chapterの定義

HierarchyのChapterの配下には、各チャプターで登場する3Dモデルが定義される。
空間において同じ登場人物(3Dモデル)は唯一であり、「状態を入れ替える」のではなく「状態を変化させる」と考えると自然にも思える。また、 GameObjectを何かで区切ってそれを跨ぐことは、Unityに対して不自然となるため調和が乱れる。(コストがかかる)Chapterは、"同じ登場人物(3Dモデル)が被らないことを前提に区切れる単位"が最適解となりそう。

f:id:yotiky:20181116002436p:plain

エンジンとして定義したディレクターが、シナリオのチャプターを切り替えることで、アプリのストーリーが展開していく。

f:id:yotiky:20181116002453p:plain

Engineの初期化とScenarioの進行

このあたりはサンプルで実装した詳細なので参考程度に。
エンジン部分の初期化はエントリーポイントであるScenePresenterが全体初期化時に一緒に担当する。

f:id:yotiky:20181116002515p:plain

Scenarioの進行では、各Presenterと対になるUseCasesがScenarioDirectorに遷移を依頼。
ScenarioDirectorは、遷移実行前後のフックポイントを持っているので、それをUseCases / Presenterへと伝播させ、それぞれ必要な遷移前処理 / 遷移後処理を実行できるようになっている。

f:id:yotiky:20181116002529p:plain

遷移へのアプローチ

画面遷移の手段として、①1つのSceneで処理する方法、②Sceneを切り替える方法、③対象をPrefab化しInstantiateして入れ替える方法、を耳にする。
①は、これまでに説明してきたものとなる。②は、ロード時間やデータの共有の手間をペイできるほどのストーリーがまだ出てきていないので保留しているが、HierarchyのRoot直下に一層追加されて、バックヤードも対になる層を持たせてその層で新たなContextが共有されるようなイメージではある。③は、ステージを完全に分断(=3Dモデルが被らない)できるストーリーであれば、バックヤードはそのまま応用が効きそうだし、有効そうである。だが、こちらもまだ使うほどの場面には出くわしていない。

サンプルプロジェクト

構成

最後に参考までにサンプルで実装したアプリの構成を載せておく。
レイヤーの定義を決めかねているので、MVP+αな感じでパッケージだけ分けてある。UI以外は一旦Modelとしてまとめちゃってる状態。
ここではDIもテストも考慮していないので、疎結合のためのInterfaceは切っておらず、規約としてしか使っていない。(IViewくらい)
Model内にModelがあるが、サンプルではドメイン相当となる処理がほとんどないので使っていない。UIには、Sound系やAnimation系なんてのも出てくるのかなとぼんやり。

f:id:yotiky:20181116002544p:plain

github.com


  1. イベント関数の実行順 - Unity マニュアル

  2. サンプルではenumとして各章を定義した