はじめに

MagicLeapで周囲店舗の場所を確認できるアプリを作っていきます。

開発環境

Unity 2019.3.15f1
Lumin SDK 0.24.1
Magic Leap Unity Package 0.24.1

開発環境構築

【MagicLeap】開発環境構築
こちらの記事を参考に環境構築をします。

もしビルド時に、

Assets\Plugins\Lumin\manifest.xml does not exist

のようなエラーが出る場合は、Assets\MagicLeap\Examples\Plugins\Lumin\manifest.xmlAssets\Plugins\Luminに移動してみてください。

また、UnityTemplate-0.24.0HelloCubeシーンを複製して使う場合は、Main CameraClipping PlanesFar10に設定されているので1000に変更します。
これで1000m以内の店舗が描画されるようになります。

位置情報クラスを定義

位置情報を高精度で扱うためにdouble型のパラメータを2つ(緯度と経度)持ったデータクラスを用意します。

/// <summary>
/// 位置情報クラス
/// </summary>
public class LocationData
{
    /// <summary>
    /// 緯度
    /// </summary>
    public double Latitude => _latitude;

    /// <summary>
    /// 経度
    /// </summary>
    public double Longitude => _longitude;

    /// <summary>
    /// 緯度
    /// </summary>
    private double _latitude;

    /// <summary>
    /// 経度
    /// </summary>
    private double _longitude;

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="latitude">緯度</param>
    /// <param name="longitude">経度</param>
    public LocationData(double latitude, double longitude)
    {
        _latitude = latitude;
        _longitude = longitude;
    }
}

現在地の緯度経度を取得

MagicLeapは現状、端末の位置情報を GPSで取得することはできません。ですが、Wi-Fiアクセスポイントから取得することは可能です。

位置情報を取得するAPIは、

  • CoarseLocation(小数第2位まで)
  • FineLocation(小数第5位まで)

の2種類が用意されています。
今回は高精度の位置情報を扱いたいので、FineLocationを選択します。

/// <summary>
/// MagicLeap用位置情報クラス
/// </summary>
public class MagicLeapLocationModule
{
    /// <summary>
    /// 位置情報APIが開始されているか
    /// </summary>
    public static bool IsStartedLocation => MLLocation.IsStarted;
    
    /// <summary>
    /// 初期化処理
    /// </summary>
    public static void Init()
    {
        MLPrivileges.Start();
        MLPrivileges.RequestPrivilegeAsync(MLPrivileges.Id.FineLocation, OnRequestPrivilegeCallback);
    if(!reslut.IsOk) throw new Exception("位置情報の権限の要求に失敗しました。");
    }

    /// <summary>
    /// 現在地を取得する
    /// </summary>
    /// <returns>位置情報</returns>
    public static LocationData GetCurrentLocation()
    {
        MLLocation.Location data = new MLLocation.Location();
        MLResult result = MLLocation.GetLastFineLocation(out data);
        if(!result.IsOk) throw new Exception("現在位置の取得に失敗しました。");
        return new LocationData(data.Latitude, data.Longitude);
    }

    /// <summary>
    /// 権限要求のコールバックを受け取る
    /// </summary>
    /// <param name="result">要求結果</param>
    /// <param name="id">権限ID</param>
    private static void OnRequestPrivilegeCallback(MLResult result, MLPrivileges.Id id)
    {
        if(id == MLPrivileges.Id.FineLocation)
    {
        if(result.Result == MLResult.Code.PrivilegeGranted)
        {
            MLPrivileges.Stop();
            MLLocation.Start();
        }
    }
    }
}

その他の必要な情報を取得

店舗の情報

現在地周囲の店舗情報(緯度経度や名称)が必要です。
コードの記載は省略しますが、今回のサンプルでは、Yahoo!ローカルサーチAPIを使用してそれらを取得しています。

デバイスの角方位

AR空間の向きと現実の向きを同期するためにデバイスの角方位(真北から時計回りに何度回転しているか)を取得する必要があります。

デバイスに搭載された電子コンパスから取得したいところですが、2020年6月現在、MagicLeapはその利用が解放されていないので、今回は起動時に真北を手動で指定する方法を取ることにします。

下のコードは、MagicLeap装着者がスマホのコンパスアプリなどで真北を向くように立ち位置を調整したら、コントローラのトリガーを押下、するとメインカメラのY軸の回転角度を取得するようにしています。

この回転角度が、AR空間での角方位になります。

/// <summary>
/// ワールドの回転のキャリブレーションクラス
/// </summary>
public class WorldRotationCalibrator : MonoBehaviour
{
    /// <summary>
    /// ワールドの回転のズレ
    /// </summary>
    public float WorldRotationOffset => _worldRotationOffset;

    /// <summary>
    /// キャリブレーション済みか
    /// </summary>
    public bool IsCaribrated => _isCaribrated;

    /// <summary>
    /// コントローラー
    /// </summary>
    private MLInput.Controller _controller;

    /// <summary>
    /// ワールドの回転のズレ
    /// </summary>
    private float _worldRotationOffset = 0;

    /// <summary>
    /// キャリブレーション済みか
    /// </summary>
    private bool _isCaribrated = false;

    /// <summary>
    /// Start
    /// </summary>
    private void Start()
    {
        MLInput.Start();
        _controller = MLInput.GetController(MLInput.Hand.Left);
    }

    /// <summary>
    /// OnDestroy
    /// </summary>
    private void OnDestroy()
    {
        MLInput.Stop();
        _controller = null;
    }

    /// <summary>
    /// Update
    /// </summary>
    private void Update()
    {
        if(_isCaribrated) return;
        
        if (_controller.TriggerValue > 0.8f)
        {
            calibrate();
        }
    }

    /// <summary>
    /// キャリブレーション実行
    /// </summary>
    private void calibrate()
    {
        var camera = Camera.main.transform;
        _worldRotationOffset = camera.eulerAngles.y;
        _isCaribrated = true;
    }
}

緯度経度をワールド座標に変換

ここまでに取得したデータを使って、店舗のワールド座標を求めます。

以下、緯度経度をワールド座標に変換するコードです。

/// <summary>
/// 位置計算クラス
/// </summary>
public static class LocationCalculateModule
{
    /// <summary>
    /// 地球の半径(m)
    /// </summary>
    private const int EARTH_RADIUS = 637100;

    /// <summary>
    /// 平角(180°)
    /// </summary>
    private const int STRAIGHT_ANGLE = 180;

    /// <summary>
    /// 店舗のワールド座標を取得する
    /// </summary>
    /// <param name="currentLocation">現在地情報</param>
    /// <param name="destinationLocation">店舗情報</param>
    /// <param name="orientation">端末の角方位</param>
    /// <param name="player">プレイヤーのTransform</param>
    /// <returns>ワールド座標</returns>
    public static Vector3 GetWorldPosition(LocationData currentLocation, LocationData destinationLocation,
        float orientation, Transform player)
    {
        var distance = GetDistance(currentLocation, destinationLocation);
        var direction = GetDirection(currentLocation, destinationLocation, orientation, player);
        // 相対座標を求める
        var posX = distance * Math.Sin(direction * Math.PI / STRAIGHT_ANGLE);
        var posZ = distance * Math.Cos(direction * Math.PI / STRAIGHT_ANGLE);
        
        // ユーザーのワールド座標に店舗の相対座標を加算して、店舗のワールド座標を求める
        var pos = new Vector3((float) posX, 0, (float) posZ) + player.position;
        return pos;
    }

    /// <summary>
    /// 現在地から店舗までの距離(m)を取得する
    /// </summary>
    /// <param name="currentLocation">現在地情報</param>
    /// <param name="destinationLocation">店舗情報</param>
    /// <returns>距離(m)</returns>
    public static double GetDistance(LocationData currentLocation, LocationData destinationLocation)
    {
        var dlat1 = currentLocation.Latitude * Math.PI / STRAIGHT_ANGLE;
        var dlng1 = currentLocation.Longitude * Math.PI / STRAIGHT_ANGLE;
        var dlat2 = destinationLocation.Latitude * Math.PI / STRAIGHT_ANGLE;
        var dlng2 = destinationLocation.Longitude * Math.PI / STRAIGHT_ANGLE;
        var d1 = Math.Sin(dlat1) * Math.Sin(dlat2);
        var d2 = Math.Cos(dlat1) * Math.Cos(dlat2) * Math.Cos(dlng2 - dlng1);
        var distance = EARTH_RADIUS * Math.Acos(d1 + d2);
        return distance;
    }

    /// <summary>
    /// Z軸と現在地-店舗ベクトルのなす角を取得する(X-Z平面)
    /// </summary>
    /// <param name="currentLocation">現在地情報</param>
    /// <param name="destinationLocation">店舗情報</param>
    /// <param name="orientation">端末の角方位(真北から)</param>
    /// <returns>Z軸と現在地-店舗ベクトルのなす角</returns>
    public static double GetDirection(LocationData currentLocation, LocationData destinationLocation,
        float orientation)
    {
        var fromLat = currentLocation.Latitude * Math.PI / STRAIGHT_ANGLE;
        var fromLng = currentLocation.Longitude * Math.PI / STRAIGHT_ANGLE;
        var toLat = destinationLocation.Latitude * Math.PI / STRAIGHT_ANGLE;
        var toLng = destinationLocation.Longitude * Math.PI / STRAIGHT_ANGLE;
        var deltaX = toLng - fromLng;
        var y = Math.Sin(deltaX);
        var x = Math.Cos(fromLat) * Math.Tan(toLat) - Math.Sin(fromLat) * Math.Cos(deltaX);
        // 現在地-店舗ベクトルと真北ベクトルのなす角
        var fromNorthToDestinationDir = Math.Atan2(y, x) * STRAIGHT_ANGLE / Math.PI;
        var dir = fromNorthToDestinationDir + orientation;
return dir;
    }
}

現在地と店舗の緯度経度の差分から現在地から店舗までのベクトル真北ベクトルのなす角を求めます。

var fromLat = currentLocation.Latitude * Math.PI / STRAIGHT_ANGLE;
        var fromLng = currentLocation.Longitude * Math.PI / STRAIGHT_ANGLE;
        var toLat = destinationLocation.Latitude * Math.PI / STRAIGHT_ANGLE;
        var toLng = destinationLocation.Longitude * Math.PI / STRAIGHT_ANGLE;
        var deltaX = toLng - fromLng;
        var y = Math.Sin(deltaX);
        var x = Math.Cos(fromLat) * Math.Tan(toLat) - Math.Sin(fromLat) * Math.Cos(deltaX);
        // 現在地-店舗ベクトルと真北ベクトルのなす角
        var fromNorthToDestinationDir = Math.Atan2(y, x) * STRAIGHT_ANGLE / Math.PI;

この値に、キャリブレーションで取得したAR空間のY軸の回転量を加算して、現在地から店舗までのベクトルZ軸のなす角を求めます。

var dir = fromNorthToDestinationDir + orientation;
return dir;

距離と向きから現在地から店舗までの相対座標を求めます。

// 相対座標を求める
        var posX = distance * Math.Sin(direction * Math.PI / STRAIGHT_ANGLE);
        var posZ = distance * Math.Cos(direction * Math.PI / STRAIGHT_ANGLE);

相対座標に現在地のワールド座標を加算することで、店舗のワールド座標を求めます。

// ユーザーのワールド座標に店舗の相対座標を加算して、店舗のワールド座標を求める
        var pos = new Vector3((float) posX, 0, (float) posZ) + player.position;
        return pos;

全体を制御するクラスを作成

ここまで作ってきたモジュールを動かすためのクラスを作成します。

/// <summary>
/// マジックリープの位置情報制御クラス
/// </summary>
public class MLLocationController : MonoBehaviour
{
    [SerializeField] private LocationDataScriptableObject _currentLocationData;
    
    /// <summary>
    /// ワールドの回転のキャリブレーション用
    /// </summary>
    [SerializeField] private WorldRotationCalibrator _calibrator;

    /// <summary>
    /// Start
    /// </summary>
    private async void Start()
    {
        // キャリブレーション完了待ち
        await UniTask.WaitUntil(() => _calibrator.IsCaribrated);
        // 位置情報を取得する権限の要求とAPIの開始
        MagicLeapLocationModule.Init();
        await UniTask.WaitUntil(() => MagicLeapLocationModule.IsStartedLocation);
        
        // 現在地の緯度経度を取得する
        LocationData currentLocation = MagicLeapLocationModule.GetCurrentLocation();
        //currentLocation = _currentLocationData.LocationData;
        
        // 周囲の店舗の情報を取得する
        var localSearchModule = new YolpLocalSearchModule();
        var localRandMark = await localSearchModule.GetLocalLandMark(currentLocation, 1);
        // カメラを取得する
        var camera = Camera.main.transform;
        // マーカーを作成する
        var markerCreateModule = new MarkerCreateModule();
        foreach (var feature in localRandMark.Feature)
        {
            var pos = LocationCalculateModule.GetWorldPosition(currentLocation, feature.GetLocationData(),
                _calibrator.WorldRotationOffset, camera);
            markerCreateModule.CreateMarker(feature.GetName(), pos);
        }
    }
}

動かしてみる

実際に動かしてみた動画です。
店舗の座標にピンを刺して、上に店名を表示しています。

おわりに

次の2つが今後の課題になりそうです。

  • デバイスの角方位を自動で取得できない。
  • 物体の奥行き(オクルージョン)が再現できないので、ピンのような仮想物体を配置しても遠近感がわかりづらい。

自前でなんとかするにはどちらも難易度が高そうです。
SDKのバージョンアップで今後対応することを期待したいですね!