VR道を往く!

VRとかUnityとかそういう話中心に。

スマホ・タブレットでの実方位に合わせたカメラ操作の実装

f:id:kaninabe:20160501232620p:plain

iPhoneAndroidで端末を向けた方向に合わせてカメラを動かすインタフェースをUnityで実装する方法をまとめてみます。ARアプリだと実際の方角に合わせてコンテンツを表示させたいケースも多いので、ジャイロだけでなくコンパスを使って適切にカメラ方向を合わせることを目指します。
まあ割と常識レベルの話かもしれませんが、いざ実装しようとして「あれ?どうするんだっけ?」となったり思いがけない罠に嵌ったりすることがあったので、備忘録がてら。

ジャイロのみによる実装

ではまずジャイロだけで見回し操作をしてみましょう。 Input.gyro.attitudeで得られる値を少し加工してカメラのtransform.rotationに入れてやればよいです。Input.gyro.enabledをtrueにするのも忘れずに。 以下のようなスクリプトをカメラに付けることで実現できます。

using UnityEngine;

public class RotateWithGyro : MonoBehaviour {

    void Start () 
    {
        Input.gyro.enabled = true;
    }
    
    void Update () 
    {
        Quaternion gattitude = Input.gyro.attitude;
        gattitude.x *= -1;
        gattitude.y *= -1;
        transform.localRotation = 
            Quaternion.Euler(90, 0, 0) * gattitude;
    }
}

これはとても簡単ですね。
この辺りの話は以下の記事で考察されており参考にさせてもらいました。ちょっと式は違いますが。

qiita.com

ジャイロだけでも方角が合う?

では次にこれを実際の方角に合わせていきたいのですが、 実は上のジャイロだけを使う方法でも何故か結構方角が合うのです…。

f:id:kaninabe:20160502010103p:plain
若干の誤差はあるもののほぼ同じ方向になる。

経験的にiOSではジャイロだけだと実方位からズレやすいですが、 Androidだと一致する場合が多いように思います。 Input.gyro.attitudeの算出にコンパスの結果も使っているかも? まあ機種ごとの違いもありそうなので絶対とは言えませんが...
ただAndroidはコンパスが安定するまで時間がかかる時も多い気がするので、 この性質があてにできるなら、あえてジャイロだけで実装するのもありかと個人的には思います。

magneticHeading? trueHeading?

実際の方位を知るにはInput.compass.magneticHeadingもしくはInput.compass.trueHeadingで向いている方向の北方向からの角度が分かります。

f:id:kaninabe:20160502000711p:plain

まずこの2つの値の違いですが、
* magneticHeadingは磁力センサーが指し示す北(磁北)からの角度
* trueHeadingは地球の北極点の方向(真北: しんほく)からの角度
という違いです。磁北は実は真北から少しズレており、地図上での正しい方角を得るにはtrueHeadingを使う必要があります。しかしtrueHeadingを求めるには現在地が分かっている必要があるので、UnityではLocationServiceを立ち上げる必要があるのです。
LocationServiceの開始方法は以下を参照。 ちなみにLocationServiceが動いていなくてもtrueHeadingにはそれっぽい値が入ってはいるのですが 正しい値かどうかは保証できません。

docs.unity3d.com

LocationService、つまりGPSを使うとなるとまた一つ使用するセンサーが増えてしまうので、あえてmagneticHeadingで済ます、という考えもあります。 まあコンパス自体の誤差もあるのでそこまでtrueHeadingにこだわる必要もないんじゃ?ということですね。

ただ今回ですが、これらは使いません…。 なぜかというと端末を下の写真のように見上げたりした時に検出される方角が逆になってしまう時があるのです。 今回のような操作方法には相性が良くないなと。

f:id:kaninabe:20160502002939p:plain
こうした時に方角が反転してしまう。

よって今回はこれらのheadingではなく、Input.compass.rawVectorを使います。

Input.compass.rawVectorを使って方位に合わせた回転

rawVectorはコンパスから得られる生のデータで、 ざっくり言うと端末から見た磁北の方向へのベクトルを表しています。 これとジャイロから得られる重力方向のベクトル(gyro.gravity)を使うことで、 実際の方角に基づいた端末の姿勢を求めることができます。

compass.rawVectorを使った方法は以下のページでも紹介されています。

unity-michi.com

基本的にはジャイロで姿勢は取ってくるのですが、 rawVectorを使って求めた方角込みの姿勢に緩やかに補正するような処理をしてくれるコードです。 こちらのページのコードでほぼ問題なく動くのですが、 いくつか嵌ったところもあるので補足していきたいと思います。

+z方向を北にしたい
上記リンクのコードだとUnity内世界の+z方向が南になってしまうので、 せっかくならば+z方向を北にしたいと思います。 そのために適当なタイミングで180度回転させます。

rawVectorの挙動がiOSAndroidで少し違うっぽい
iOSの場合は端末の画面が回転したらrawVectorの軸もそれに合わせて回転させてくれるようですが、 Androidの場合には回転させてくれないので自分で変換する必要があるようです。

rawVectorにはエラー処理が必須!
rawVectorは「raw」というだけあって生のデータなので、 ごくたまにすごいエラー値を出してくる恐れがあります。 端末の起動直後や長時間のスリープから復帰した後などにエラー値を吐くことがあります。

もしエラー処理しないで計算してしまうと、 カメラのrotationがNaNになってしまって何も映らなくなったりします。 こんな風に。

f:id:kaninabe:20160502004000p:plain:w600

という訳でもしエラーであった場合にそのデータは捨てる、 というコードを入れておきます。

以上を考慮して最終的なコードはこのようになりました。 これをカメラにアタッチしましょう。

using UnityEngine;

public class RotateWithCompass : MonoBehaviour {
    double lastCompassUpdateTime = 0;
    Quaternion correction = Quaternion.identity;
    Quaternion targetCorrection = Quaternion.identity;

    // Androidの場合はScreen.orientationに応じてrawVectorの軸を変換
    static Vector3 compassRawVector
    {
        get
        {
            Vector3 ret = Input.compass.rawVector;
            
            if(Application.platform == RuntimePlatform.Android)
            {
                switch(Screen.orientation)
                {
                    case ScreenOrientation.LandscapeLeft:
                        ret = new Vector3(-ret.y, ret.x, ret.z);
                        break;
                    case ScreenOrientation.LandscapeRight:
                        ret = new Vector3(ret.y, -ret.x, ret.z);
                        break;
                    case ScreenOrientation.PortraitUpsideDown:
                        ret = new Vector3(-ret.x, -ret.y, ret.z);
                        break;
                }
            }
            
            return ret;
        }
    }
    
    // Quaternionの各要素がNaNもしくはInfinityかどうかチェック
    static bool isNaN(Quaternion q)
    {
        bool ret = 
            float.IsNaN(q.x) || float.IsNaN(q.y) || 
            float.IsNaN(q.z) || float.IsNaN(q.w) || 
            float.IsInfinity(q.x) || float.IsInfinity(q.y) || 
            float.IsInfinity(q.z) || float.IsInfinity(q.w);
        
        return ret;
    }
    
    static Quaternion changeAxis(Quaternion q)
    {
        return new Quaternion(-q.x, -q.y, q.z, q.w);
    }

    void Start () 
    {
        Input.gyro.enabled = true;
        Input.compass.enabled = true;
    }
    
    void Update () 
    {
            Quaternion gorientation = changeAxis(Input.gyro.attitude);

            if (Input.compass.timestamp > lastCompassUpdateTime)
            {
                lastCompassUpdateTime = Input.compass.timestamp;

                Vector3 gravity = Input.gyro.gravity.normalized;
                Vector3 rawvector = compassRawVector;
                Vector3 flatnorth = rawvector - 
                    Vector3.Dot(gravity, rawvector) * gravity;
 
                Quaternion corientation = changeAxis(
                    Quaternion.Inverse(
                        Quaternion.LookRotation(flatnorth, -gravity)));
 
                // +zを北にするためQuaternion.Euler(0,0,180)を入れる。
                Quaternion tcorrection = corientation * 
                    Quaternion.Inverse(gorientation) * 
                    Quaternion.Euler(0, 0, 180);

                // 計算結果が異常値になったらエラー
                // そうでない場合のみtargetCorrectionを更新する。
                if(!isNaN(tcorrection))
                    targetCorrection = tcorrection;
            }
 
            if (Quaternion.Angle(correction, targetCorrection) < 45)
            {
                correction = Quaternion.Slerp(
                    correction, targetCorrection, 0.02f);
            }
            else 
                correction = targetCorrection;

            transform.localRotation = correction * gorientation;        
    }
}

さいごに: コンパスはデリケート

さて以上のコードを使えば正しい方角でのカメラ見回し操作ができるようになると思います。 (とは言っても手元のGalaxy S6, Xperia Z3tablet, iPad miniでしか動作確認してないので、 うまく動かない機種もあるかもしれません、その時はご容赦を。) ただしあくまでもセンサが正しく動いていればの話です。 コンパスは特にAndroidでは立ち上げ直後など安定しない場合がある点に留意しましょう。 いつまでたってもコンパスが正しく動かない場合は以下の方法を試してみると直る場合が多いです。

support.google.com

また、コンパスなので当然磁気の影響を強く受けます。 マグネットが入っているタイプのスマホタブレットケースを付けていたりすると 正しい方向が出ない可能性があるので注意しましょう。

最近の端末ではセンサの性能や精度も上がってきていますが、 機種や環境によってはそれでも安定しない場合もあるかもしれません。 そんな時は精度よりも安定性を重視し、コンパスをあきらめてジャイロのみで動かすことも選択肢にいれたほうが良いかもしれません。