Mixed Reality Toolkit - Unity で始めるHoloLensとImmersiveHMD連携 ～ 両デバイスを1つのアプリで作る基本

# HoloLensとImmersiveHMDを1つアプリケーションで提供する
Mixed Reality 250はHoloLensとImmersive HMDを1つのアプリケーションで利用しています。例えば、Immersive HMDで起動した場合は表示されるオブジェクトが大きくなっています。HoloLensでは俯瞰視点のため縮尺が変わるようになっています。また、アプリケーションの操作方法も異なります。
一方で、Immersiveではモーションコントローラでの制御、HoloLensではジェスチャ制御という部分はMixed Reality Toolkit - Unityを利用することで開発者はあまり気にすることなく扱うことができます。
今回はMRTKにあるサンプル「SharingWithUnetExample」を少し変更しImmersive HMDとHoloLensで表現が変わるサンプルを作成したのでご紹介します。
以前、[紹介したHoloLensとImmersiveHMDのどちらで動いてるか判断できる方法](https://qiita.com/miyaura/items/fb2219f3344409023001)を活用して処理分岐するのですが、少しでもすっきりした構造にするための実装方法の1例です。

## 開発環境

環境については以下の通りです。

* Windows 10 Fall CreatorsUpdate(Immersive HMD用)
* Unity 2017.2.1p2
* Mixed Reality Toolkit - Unity 2017.2.1.1
* Visual Studio 2017 Community Edition(15.6)

HoloLensは公式サイトではUniry 2017.1系となっていますが、Immersive側に合わせる形で環境を構築しました。今回のサンプルでは問題なく動作してますが、実際の開発では事前検証をお勧めします。

## サンプルコード

サンプルコードは以下の場所にGithubで公開しています。
* [MRSharingWithUnetSamples](https://github.com/TakahiroMiyaura/MRSharingWithUnetSamples.git)

今回このアプリケーションを作成するにあたって行った実装を紹介します。基本的にはすでにあるサンプルコードなので今回の変更部分を中心に説明します。

## 実装方法

次に今回作ったサンプルアプリです。
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">今更SharingWithUNET。Mixed Reality 250は古いMRTK+αと特殊なので新しいMRTK-Unityでサンプル作る。ImmersiveとHoloLensで違う制御もできた。 <a href="https://twitter.com/hashtag/HoloLensJP?src=hash&amp;ref_src=twsrc%5Etfw">#HoloLensJP</a> <a href="https://twitter.com/hashtag/WinMR?src=hash&amp;ref_src=twsrc%5Etfw">#WinMR</a> <a href="https://twitter.com/hashtag/MixedRealityToolkit?src=hash&amp;ref_src=twsrc%5Etfw">#MixedRealityToolkit</a> <a href="https://t.co/hYpv9nKgpX">pic.twitter.com/hYpv9nKgpX</a></p>&mdash; takabrz (@takabrz1) <a href="https://twitter.com/takabrz1/status/976950081042567169?ref_src=twsrc%5Etfw">2018年3月22日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

サンプルの動作は「SharingWithUnetExample」と同じです。AirTapまたはモーションコントローラのSelectを押すとカメラ正面方向に弾が発射されます。左上がHoloLens側、右下がImmersive HMD側になります。動画はそれぞれ対面に一縷すようにカメラ位置を調整して撮っています。HoloLensがCube、Immersive HMDがSphereになっています。それぞれ別々のオブジェクトで表現しているのですが、この部分が今回制御を加えたものになります。

### SharingWithUnetExampleの変更

HoloLensとImmersive HMDで動作を変えるためにはNetworkManagerに設定するPlayerの修正を行います。MRTKでもサンプルにはPlayerControllerがいるのですが、この部品を作り変えます。以降の手順はMTRKがインポート済みの状態からのスタートです。

#### Sceneのコピー

ベースはすでにある「SharingWithUnetExample」を使います。まずは、シーンを別名で保存し、内容を変更します。

1. [File]-[Open Scene]を選択し、Assets\HoloToolkit\SharingWithUNET\Scenes\SharingWithUnetExample.unityを開きます。
2. [File]-[Save Scene as ...]を選択し任意の場所に別名で保存します。
3. [Hierarchy]タブ内のHoloLensCameraを削除します。
4. [Project]タブ内からAsset\HoloToolit\Input\prefabs\MixedRealityCameraParent.prefabを選択し[Hierarchy」タブに追加します。

以上で、HoloLensとImmersive HMDの主な制御を切り替えることができます。MixedRealityCameraParentはImmersiveHMDに関するモーションコントローラや移動範囲の境界に関する機能を提供していますが、HoloLensで動作する場合はこれれの機能が無効化されます。なお、Skybox等の背景の設定も自動で切替ります。
このため、実装者は基本機能についてはあまり両者の違いを気にする必要はありません。

### Playerの見直し
PlayerのPrefabを見直します。Player.prefabは1つだけしか使えないため、これをHoloLensの時とImmersive HMDの時で制御を変える仕掛けを加えます。
構造としては新たにSamplePlayerControllerを作成します。この処理の中でHoloLensとImmersiveHMDのどちらで動いているか判断し各デバイス用のコンポーネントHoloLensController、ImmersiveHMDControllerのいずれかを使用します。

1. [Project]タブ内で[Create]-[C# script]を選択し「SamplePlayerController」、「HoloLensController」「ImmersiveHMDController」「DeviceControllerBase」の4つスクリプトを作成します。
2. [SamplePlayerController]を編集し[MonoBehaviour]から[NetworkBehaviour]に変更し継承クラスを変更します。
1. [Project]タブ内からAsset\HoloToolkit\SharingWithUNET\prefabs\Player.prefabを[Hierarchy]内に追加します。
1. [Hierarchy]タブで[Player]を選択し[PlayerのPlayerController]を削除
2. [Hierarchy]タブで[Player]を選択し[SamplePlayerController]、[HoloLensController]、[ImmersiveHMDController]の3を追加
3. Playerの子要素にあるCubeを削除する

#### ロジックの実装

次の作成した４つのコンポーネントの実装を行っていきます。
クラスは以下のような構成です(青矢印：継承、緑点線：使用)。
SamplePlayerControllerが自身を参照しているのはシングルトンパターンの実装があるためです。SamplePlayerControllerではデバイス毎の処理をDeviceContollerBaseを経由して実行します。

![Dependencies Graph.png](https://qiita-image-store.s3.amazonaws.com/0/152548/cc527ca9-63a3-dcf8-6913-bc82a47377e8.png)

##### SamplePlayerController

もともとあったPlayerControllerをもとにデバイス毎の制御だけを外部で行うように変更します。主な変更個所は、以下の2つです。
まずはPlayerControllerクラスの内容をクラス名以外全てSamplePlayerControllerクラスにコピーします。その後以下の内容を修正します。

* 初期化処理（Startメソッド）
* Updateメソッド

###### 初期化処理（Startメソッド）
Playerの初期化処理を修正します。処理のほとんどは変更していません。修正箇所は以下の３つです。

* 現在動作しているデバイスに合わせてDeviceContollerBaseの継承クラスを設定するためのメソッドSetDeviceControllerメソッドの呼び出し
* ローカルプレーヤ（自分自身）の初期化
* リモートプレーヤ（ネットワークに参加しているほかのプレーヤー）の初期化

```csharp:SamplePlayerController.cs
        private void Start()
        {
            //WorldAnchorをセットするためのSharedCollectionが存在しない場合は有効かしない
            if (SharedCollection.Instance == null)
            {
                Debug.LogError("This script required a SharedCollection script attached to a GameObject in the scene");
                Destroy(this);
                return;
            }

            //稼働しているデバイスに応じてHoloLens用、ImmersiveHMD用のコンポーネントをセットする。
            SetDeviceController();

            if (isLocalPlayer)
            {
                //プレーヤー自身の場合はAirTapなどの処理を有効にするためのリスナーを追加
                InputManager.Instance.AddGlobalListener(gameObject);
                //他のプレーヤに対して自身のデバイスが何であるかを送信する。
                CmdSetCurrentDisplayType(MixedRealityCameraManager.Instance.CurrentDisplayType);
                //プレーヤーの初期化を実施
                InitializeLocalPlayer();
            }
            else
            {
                Debug.Log("remote player");
                //リモートプレーヤの初期化を実施
                InitializeRemotePlayer();
            }
            
            //Playerコンポーネントの親をSharedCollectionにすることでWorldAnchorの管理下に置く。
            _sharedWorldAnchorTransform = SharedCollection.Instance.gameObject.transform;
            transform.SetParent(_sharedWorldAnchorTransform);
        }
        
        /// <summary>
        ///     Sets up all of the local player information
        /// </summary>
        private void InitializeLocalPlayer()
        {
            if (isLocalPlayer)
            {
                Debug.Log("Setting instance for local player ");
                _instance = this;
                Debug.LogFormat("Set local player name {0} IP {1}", _networkDiscovery.broadcastData,
                    _networkDiscovery.LocalIp);
                //プレーヤーの情報を他のプレーヤーに送信
                CmdSetPlayerName(_networkDiscovery.broadcastData);
                CmdSetPlayerIp(_networkDiscovery.LocalIp);
                
                //アンカーを共が可能なデバイスかを判断する。
#if UNITY_WSA
#if UNITY_2017_2_OR_NEWER
                CanShareAnchors = !UnityEngine.XR.WSA.HolographicSettings.IsDisplayOpaque;
#else
                CanShareAnchors = !Application.isEditor && UnityEngine.VR.VRDevice.isPresent;
#endif
#endif
                Debug.LogFormat("local player {0} share anchors ", CanShareAnchors ? "does not" : "does");
                //他のプレーヤにアンカー共有が可能か伝える
                CmdSetCanShareAnchors(CanShareAnchors);
             }
        }

        private void InitializeRemotePlayer()
        {
            //アンカーの状態に応じて同期化を実施
            AnchorEstablishedChanged(_anchorEstablished);
            SharesAnchorsChanged(SharesSpatialAnchors);
        }
```

SetDeviceControllerメソッドは次の通りです。同じGameObjectに追加したDeviceControllerBaseを継承したコンポーネント（ImmersiveHMDControllerとHoloLensController）を全て取得し、現在稼働しているデバイスにあうControllerを有効化し、それ以外のControllerは無効にします。DeviceControllerBaseにはあらかじめ「どのデバイスで有効化するか」を返すSupportDisplayTypeを定義しているので判別ができるようになっています。

```csharp:SamplePlayerController.cs
        private void SetDeviceController()
        {

            //DeviceControllerBaseを継承しコンポーネント
            var deviceControllerBases = GetComponents<DeviceControllerBase>();

            //コンポーネントがなければPlayerを廃棄する
            if (deviceControllerBases.Length == 0)
            {
                Debug.LogError(
                    "This script required one or more DeviceController(inheritate DeviceControllerBase) script attached to this GameObject.");
                Destroy(this);
                return;
            }

            //稼働デバイスの判定
            //ローカルプレーヤー（自分自身）であれば、MixedRealityCameraManagerから稼働デバイスの情報で判断します。
            //リモートプレーヤーの場合であれば、SamplePlayerControllerの設定値（同期されたデータ）の情報で判断します。
            foreach (var controller in deviceControllerBases)
                if (isLocalPlayer && MixedRealityCameraManager.Instance.CurrentDisplayType ==
                    controller.SupportDisplayType
                    || !isLocalPlayer && _currentDisplayType == controller.SupportDisplayType)
                {
                    _deviceController = controller;
                    controller.enabled = true;
                }
                else
                {
                    controller.enabled = false;
                    controller.RemovePlayerObject();
                }

            _deviceController.SetNetworkBehaviour(this);
            _deviceController.PlayerInstance = gameObject;

            //初期処理の呼び出し
            if (isLocalPlayer)
            {
                _deviceController.InitializeLocalPlayer();
            }
            else
            {
                _deviceController.InitializeRemotePlayer();
            }
        }
```

###### Updateメソッド

Updateメソッドではカメラ位置に表示するオブジェクト（SamplePlayerControllerの子として定義）の位置同期を行います。

```csharp:SamplePlayerController.cs

        [SyncVar(hook = "CurrentDisplayTypeChanged")]
        private MixedRealityCameraManager.DisplayType _currentDisplayType;

        private void CurrentDisplayTypeChanged(MixedRealityCameraManager.DisplayType update)
        {
            Debug.LogFormat("CurrentDisplayType changing from {0} to {1}", _currentDisplayType, update);
            _currentDisplayType = update;
            _refreshDeviceController = true;
        }
        
        private void Update()
        {
            //対象デバイスが変更されている場合は使用するDeviceContollerの再設定を行う。
            if (_refreshDeviceController)
            {
                SetDeviceController();
                _refreshDeviceController = false;
            }

            // SamplePlayerControllerが他のプレーヤで同期されている場合は座標を調整する。
            if (!isLocalPlayer && string.IsNullOrEmpty(_playerName) == false)
            {
                transform.localPosition = Vector3.Lerp(transform.localPosition, _localPosition, 0.3f);
                transform.localRotation = _localRotation;
                return;
            }

            if (!isLocalPlayer) return;

            // もしアンカーの状態が変更されていればすべてに通知する。
            if (_anchorEstablished != _anchorManager.AnchorEstablished)
                CmdSendAnchorEstablished(_anchorManager.AnchorEstablished);

            // アンカーの設置が未完了の場合座標の更新は行わない。
            if (_anchorEstablished == false)
            {
                return;
            }

            //DeviceContollerを経由してローカルプレーヤの更新を行います。
            _deviceController.LocalPlayerUpdate();

            // 変更した座標情報を他のプレーヤーに通知します。
            CmdTransform(transform.localPosition, transform.localRotation);
        }

```



##### DeviceControllerBase

デバイス毎の処理を記述するための抽象クラスです。SamplePlayerControllerはこの抽象クラス使って書く処理を行います。今回は各デバイスで共通で行うPlayerのオブジェクトを生成についてはこのクラスで実装します。それ以外の機能については抽象メソッドで定義し各デバイスのControllerクラス内で実装を行います。

まず、プレーヤーのキャラクタに相当するオブジェクトを作するための実装です。キャラクタのPrefabはPlayerObjectプロパティに[Inspector]タブから設定します。キャラクタの生成としてCreatePlayerObjectメソッドとRemovePlayerObjectメソッドを追加します。今回はPrimitiveなオブジェクトが入る想定で色を付けられるようにしています。この部分は実際に使うキャラクタによって見直してください。生成したオブジェクトについては後で廃棄するパターンがあるので、削除用のメソッドも追加します。

```csharp:DeviceControllerBase.cs
        public GameObject PlayerObject;
       
        private GameObject _playerObject;

        protected void CreatePlayerObject(Color playerColor)
        {
            _playerObject = Instantiate(PlayerObject);
            _playerObject.transform.position = Vector3.zero;
            _playerObject.transform.SetParent(transform);
            _playerObject.transform.localPosition = Vector3.zero;
            var material = new Material(Shader.Find("Diffuse"));
            material.color = playerColor;
            var componentInChildren = _playerObject.GetComponent<MeshRenderer>();
            componentInChildren.material = material;
        }

        public void RemovePlayerObject()
        {
            if (_playerObject != null) DestroyImmediate(_playerObject);
        }
```

次にSamplePlayerControllerを取得できるメソッドを追加します。
この実装は各デバイスでSamplePlayerControllerを使って他のプレーヤーに情報を通知する処理(Cmd～で始まるメソッド)を呼ぶのに使います。

```csharp:DeviceControllerBase.cs
        private NetworkBehaviour _playerNetworkBehaviour;

        public void SetNetworkBehaviour(NetworkBehaviour behaviour)
        {
            _playerNetworkBehaviour = behaviour;
        }
        
        protected NetworkBehaviour GetPlayerNetworkBehaviour()
        {
            return _playerNetworkBehaviour;
        }
```

最後に各デバイスで実装する機能を抽象メソッドで実装します。

```csharp:DeviceControllerBase.cs

        //Updateメソッド内で呼び出される自分自身のプレーヤーの制御に使用
        public abstract void LocalPlayerUpdate();
        
        //自分自身のプレーヤーの初期化処理で使用
        public abstract void InitializeLocalPlayer();
        
        //他のプレーヤーの初期化処理で使用
        public abstract void InitializeRemotePlayer();

        //子クラスがどのデバイスの時の処理をサポートするかを取得するプロパティ
        public abstract MixedRealityCameraManager.DisplayType SupportDisplayType { get; }
```

この抽象クラスを使った各デバイスの処理を記述します。

##### HoloLensController

HoloLens用の処理の実装を行います。今回はAirTapで弾を打ち出すことと、自分自身は青色のオブジェクトでカメラ位置に表示するという物です。
注意点としてはSupportDisplayTypeの戻り値をTransparentにします。MRTKではTransparentがHoloLensを表しています。

```csharp:HoloLensController.cs
[RequireComponent(typeof(SamplePlayerController))]
public class HoloLensController : DeviceControllerBase, IInputClickHandler
{
    private MixedRealityCameraManager.DisplayType _supportDisplayType;


    private SamplePlayerController PlayerNetworkBehaviour
    {
        get { return (SamplePlayerController) GetPlayerNetworkBehaviour(); }
    }


    public override void LocalPlayerUpdate()
    {
        transform.position = CameraCache.Main.transform.position;
        transform.rotation = CameraCache.Main.transform.rotation;
    }
        
    public override void InitializeLocalPlayer()
    {
        CreatePlayerObject(Color.blue);
    }

    public override void InitializeRemotePlayer()
    {
        CreatePlayerObject(Color.red);
    }

    public override MixedRealityCameraManager.DisplayType SupportDisplayType
    {
        //HoloLensをサポートしている列挙値(MixedRealityCameraManager.DisplayType.Transparent)を返す
        get { return MixedRealityCameraManager.DisplayType.Transparent; }
    }

    public void OnInputClicked(InputClickedEventData eventData)
    {
        if (PlayerNetworkBehaviour.isLocalPlayer) PlayerNetworkBehaviour.CmdFire();
    }
}
```

##### ImmersiveHMDController

ImmersiveHMD用の処理の実装を行います。こちらはSupportDisplayTypeの戻り値以外はHoloLens用の実装と同じです。弾の発射はモーションコントローラのSelectで行います。SupportDisplayTypeの戻り値をOpaqueにします。

```csharp:ImmersiveHMDController
[RequireComponent(typeof(SamplePlayerController))]
public class ImmersiveHMDController : DeviceControllerBase, IInputClickHandler
{
    private MixedRealityCameraManager.DisplayType _supportDisplayType;

    private SamplePlayerController PlayerNetworkBehaviour
    {
        get { return (SamplePlayerController) GetPlayerNetworkBehaviour(); }
    }

    public override void LocalPlayerUpdate()
    {
        transform.position = CameraCache.Main.transform.position;
        transform.rotation = CameraCache.Main.transform.rotation;
    }
        
    public override void InitializeLocalPlayer()
    {
        CreatePlayerObject(Color.blue);
    }

    public override void InitializeRemotePlayer()
    {
        CreatePlayerObject(Color.red);
    }

    public override MixedRealityCameraManager.DisplayType SupportDisplayType
    {
        get { return MixedRealityCameraManager.DisplayType.Opaque; }
    }

    public void OnInputClicked(InputClickedEventData eventData)
    {
        if (PlayerNetworkBehaviour.isLocalPlayer) PlayerNetworkBehaviour.CmdFire();
    }
}
```

### Playerの完成と最後の仕上げ
最後にHoloLensController,ImmersiveHMDControllerそれぞれに表示するキャラクタのPrefabを設定します。
![image](https://qiita-image-store.s3.amazonaws.com/0/152548/eb1729d6-c5ed-8abc-352a-bc7c09bc7c17.png)
最後に[Hierarchy]タブ上で編集していたPlayerを別フォルダもしくは別名にしてPrefab化します。
そして[Hierarchy]タブ内のManagers\UNETSharingStageを選択し[Inspector]タブの[Spawn Info]-[Player Prefab]に先ほど作ったPrefabをセットすれば完成です！
![image](https://qiita-image-store.s3.amazonaws.com/0/152548/0c1949e5-b844-c614-b03e-78efc3d66321.png)

後はHoloLensで起動→Hostで開始、ImmersiveHMDで起動→HoloLensのHost名を選択してJoinするとそれぞれ別のキャラクターでネットワーク通信が可能になります。

## 最後に

実は大したことはしていません。見てわかる通りクラスで条件分岐させているだけです。ただ、もし各デバイス用でネットワーク通信型のアプリケーションを共通化する場合は、処理の中でif文で分けるのではなく、クラス構造で分けて扱った方が後々の対応も楽になると思います。