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

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

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

PixPro360 4Kを使ったリアルタイムなパノラマビューワーをUnityで作ってみる

リコーのTHETAのようなパノラマ撮影用カメラ、今やいろんな会社が割とリーズナブルな価格で出してて本当にいい時代になりましたよね。 ちょっと前までは数百万するようなカメラを買わないといけなかったのに。

今回はそんなパノラマカメラを使ってリアルタイムなパノラマビューワを作っていこうと思います。 THETA Sでも可能なのですが、これを使います。

f:id:kaninabe:20160424162423j:plain

まあどうせTHETA Sは使ってる人がいっぱいいて今さらですし。

PIXPRO360 4KはTHETAと違って全周は撮れませんが、 動画の解像度はPIXPROの方が高い(THETA Sが全周分で1920x1080に対しPIXPRO360 4Kは半球分で2880x2880)。 取り付け方法、取り付け場所によってはマウントする側などが死角になってしまうケースもあるので、 それならば一方向を高い解像度で撮った方がいい、という考え方もありますよね。

接続方法について

ではさっそくやっていきましょう。 PIXPRO360 4Kでリアルタイムに動画をPCへ入力するには、
1. USBでWebカムとして認識する(UVC)
2. HDMIで接続しキャプチャデバイス経由で入力する
この2つの方法があります。これはTHETA Sと一緒ですね。
ただTHETA SではUSBだと解像度が1280x720に制限されてしまっていましたが、 PIXPROの場合はUSBの場合でもHDMIと同じ2880x2880まで出力可能です。 fpsは遅い(5fps)ですが。

UVCの場合、UnityではWebCamTextureを使うことで簡単に入力映像をテクスチャ化できます。
HDMIを使った方がfpsが高くなるのですが、別途キャプチャデバイスが必要になってしまうし、 Unityに取り込む際に有料のアセットを使う必要が出てきます。
詳しくは解説されている方がいますのでご参照を。
izm-11.hatenablog.com

という訳でとりあえず簡単にできるUSB接続で行きたいと思います。

まず予めPIXPROのUSB接続モードをストレージモードでなくWebカムモードにしておきます。 (撮影待機状態で下ボタン(設定ボタン)2回押してシャッターボタン、もう2回下ボタン押してシャッターボタン、 これでUSB接続モードの選択になるので上下で選んでシャッターボタンで決定)

f:id:kaninabe:20160424170659j:plain:h300

こうなってればOK。これで電源入れてUSBで接続すればWebカムとして認識されます。

f:id:kaninabe:20160424165939j:plain:w300
ちなみにUSB接続すると三脚用ネジ穴が使えないので↓こういう別売りケースとマウントが必要になります。この辺の仕様はいけてないですねー。

Unityでテクスチャに映像を表示

UnityでUVCのWebカム映像をテクスチャに使うにはWebCamTextureを使えばできます。
こんな風に。

using UnityEngine;
using System.Linq;

[RequireComponent(typeof(Renderer))]
public class WebcamTexPlayer : MonoBehaviour {

void Start () {
        //接続されているデバイスの中にPIXPROがあるか探す
        var devices = WebCamTexture.devices.Select(d => d.name);
        var cdevice = devices.FirstOrDefault(devname => devname.Contains("PIXPRO"));

        if(cdevice != null)
        {
            //PIXPROを解像度2880x2880、fpsは5として接続。
            var wcamtex = new WebCamTexture(cdevice, 2880, 2880, 5);

            //現在のオブジェクトのテクスチャに置き換える。
            var mat = GetComponent<Renderer>().sharedMaterial;
            if (mat != null)
                mat.mainTexture = wcamtex;

            //再生開始
            wcamtex.Play();
        }
    }
}

こんな感じのスクリプトを映像を貼り付けたいオブジェクトに付けておけばOKです。 ちなみにPIXPROの解像度はだいたい以下のようになっています。
GLOBAL(魚眼)モード
* 2880 x 2880 (5fps)
* 2048 x 2048 (5fps)
* 1440 x 1440 (15fps)
FRONTモード
* 3840 x 2160 (5fps)
* 1920 x 1080 (15fps)
* 1280 x 720 (30fps)
* 640 x 360 (30fps)

この辺りの違いはこちらのブログで解説されています。
yaaam.blog.jp

FRONTモードというのは前方撮影用のモードなので、今回は魚眼モードの解像度を設定します。 WebCamTextureの引数に解像度を設定しないと3840x2160で読み込まれてしまうので注意。

(注 Macだと1280x720でしか読み込めなかったので今回はWindowsでやっています。)

とりあえずこんな感じでテクスチャに出せました。 少し端が切れてしまいますが、まあ気にせず行きましょう。

f:id:kaninabe:20160424114648p:plain

魚眼映像を球面にマッピング

内側を向いた球面を作りそこにテクスチャを貼り付けます。 Unityデフォルトの球は外向きでメッシュも粗いので別に用意する必要があります。 また通常の球モデルのUVは正距円筒図法いわゆる横長パノラマのテクスチャ向けになっているので、 これを魚眼向けに変更してやる必要があります。 今回は、この魚眼画像に対応するUVへの変換はシェーダで行うことにします。

まず内向きの球メッシュを作成したのでとりあえずここに置いておきます(Unity専用)。 このメッシュに正距円筒な横長パノラマ画像を貼って使うこともできます。

PIXPROの魚眼画像に対応するにはシェーダでUV座標を正距円筒から魚眼投影に変換することで可能となります。 元の正距円筒図法のUV座標は

u = その点の経度 / 360
v = 南極点からの緯度差 / 180

となっています。

一方で魚眼テクスチャの投影方法は以下のようになります。

f:id:kaninabe:20160424002038p:plain

簡単のため、テクスチャの中心を(0, 0)、左下を(-1, -1)、右上が(1, 1)となるような座標系で考え、 赤道がちょうど端に接するものと考えると

r = 北極点からの緯度差 / 90
p = 経度

となります。ただPIXPROの場合90度よりもう少し広い角度まで収まっているので、 仮にこの角度をkとすると、

r = 北極点からの緯度差 / k

となります。 従ってこの座標系上での投影位置UV'は

UV' = (cos(p), sin(p)) * r

となり、これを左下が(0, 0)、右上が(1. 1)となる通常のテクスチャ座標系に変換すれば完成です。 フラグメントシェーダに以下のような感じで書けば正しく描画されるはずです。

fixed4 frag (v2f i) : SV_Target
{
    float p = i.uv[0] * 2.0 * UNITY_PI;
    float r = (1.0 - i.uv[1]) * 180.0 / k;
    float2 uvdash = float2(cos(p), sin(p)) * r;

    float2 new_uv = (uvdash + float2(1.0, 1.0)) * 0.5;

    return tex2D(_MainTex, new_uv);
}

ではこのシェーダを球のマテリアルに設定し、 先ほどのWebCamTexture再生スクリプトも球オブジェクトにアタッチして実行してみましょう。


Realtime PIXPRO360 4K's panorama viewing on Unity

できた。ちょっと不気味な感じの映像になっていますがご容赦を。
2880x2880だとfpsは少し遅いですが。2048x2048にすると画質と速度のバランスがよいようにも感じました。

まあこのような感じでPIXPRO360 4Kも簡単にUnityで使えますので、 THETA Sを導入するか迷ったらこちらも候補に入れてみるのもいいんじゃないでしょうか。。 値段が高いとか三脚用のネジ穴が困った場所にあるとか欠点はあるものの使い勝手は悪くないと思います。

今回作ったコード類はunitypackageにしてこちらに置いておきます。

Oculus SDK 1.3.2リリース Unity 5.4betaにも対応

Oculus SDK (とOVRPlugin, Utilities)のバージョン1.3.2が出てますねぇ。
それほど大きな変更は無さそうですが、OVRPluginの対象Unityバージョンが広がってます。1.3.0のUnity対応は公式には5.3.4p1のみ!となっていましたが、1.3.2は5.3.3p3以降と5.4.0ベータ版でb11以降のバージョンにも対応ということです。

導入方法は1.3.0の時とほぼ同じですね。 vr-cto.hateblo.jp
5.4ベータの場合はOVRPluginをコピーするフォルダ名がほんのちょびっと変わっているのでそこだけ注意です。

f:id:kaninabe:20160422121801p:plain
5.4ベータではコピーするフォルダはWindowsの場合は C:\Program Files\Unity\Editor\Data\VR\Unity (C:\Program Files\Unityにインストールした場合)、
Macの場合は/Application/Unity/Unity.app/Contents/VR/Unityですね。
ダウンロードしたOVRPluginの方も5.3用と5.4用が別々の用意されてます。

OVRPluginは将来的にUnityに同梱されるまでの一時的対応だと思いますが、ちょっと面倒ですね。5.4正式版では対応するかな??

Oculus SDKが新しくなったのでUnityでのGear VRアプリ作成の流れを再確認してみる

春ですね。そして今年はVR元年だそうな。
Oculus Rift製品版やHTC Viveの出荷が始まってVR業界は活気づいてますね。
うちの会社にはまだ届いてませんが…。
まあうちはGear VRメインだからいいんですけどね(負け惜しみ)。

ところで製品版Rift発売に伴ってSDKのバージョンが新しくなったようなので、 その変更点も踏まえつつUnityを使ったGear VRアプリ作成の流れを再確認していきたいと思います。

注) とりあえずAndroid SDKのインストールと設定は終わっているものとします。

1. 必要なものをダウンロード

以下必要なもの(2016/4/17現在)。リンク先のページからダウンロードしておきます。

注) Oculus utilitiesなどのバージョンは常に変わりうるので環境に応じて新しいのを入れましょう。 最新バージョンはOculus Developer CenterのDownloadsページでチェック。

UnityにOculus UtilitiesをプロジェクトにインポートすることでRiftとGear VR用アプリの開発が可能となります。
今まではUtilitiesのインポートだけでよかったのですが、 バージョン1.3.0ではUnityインストール後に別途OVRPluginを入れる必要があることに注意(Unity 5.3.4p5以降は不要になりました)。

2. Unityのインストール

Unityはバージョンにpのついているパッチリリースバージョンをインストールします。Unityの正面のページからダウンロードするのではなく、必ずパッチリリースのページから選んでダウンロードしましょう。 執筆時点で最新の5.3.4p3をとりあえず入れました。

f:id:kaninabe:20160417194055p:plain
インストールはインストーラの指示のままでだいたいOK。Component SelectionでAndroid Build Supportにチェック入れるのを忘れずに。

3. OVRPluginのインストール (Unity 5.3.4p4以前のバージョン)

追記: Unity5.3.4p5以降ではOVRPluginの機能はUnity本体に実装されたのでこの過程は不要です。

Unityのインストールが終わったら次にOVRPluginを入れます。 f:id:kaninabe:20160417210023p:plain
Unityをインストールしたフォルダ内にoculusというフォルダがあります。デフォルトの場合
WindowsならC:¥Program Files¥Unity¥Editor¥Data¥VR¥oculus
Macなら/Applications/Unity/Unity.app/Contents/VR/oculus
この下にあるフォルダを全て削除します。
MacのFinderではUnity.appのアイコンを右クリック(ctrl + クリック)->「パッケージの内容を表示」でその下のフォルダにアクセスできます。
そして同じ場所に、ダウンロードしたファイル中のoculus.zipの中身を全てコピーします。
これでOVRPluginのインストールはOKです。

4. ランタイムのインストール

Gear VRの開発には必要ないのですが、ランタイム無しだとMacのUnity Editorで再生したときにエラーメッセージの嵐になって面倒なので、とりあえず入れておきます。Windowsではなぜか?無くても大丈夫でした。

5. プロジェクト作成、Oculus Utilitiesをインポート

f:id:kaninabe:20160417210046p:plain:w500
Unityを立ち上げて適当なシーンを作成しましょう。Cubeとかを周囲に配置したり。

f:id:kaninabe:20160417205944p:plain
Oculus Utilitiesをインポートします。ダウンロードしたzipファイルを解凍するとOculusUtilities.unitypackageというパッケージファイルが入っています。Unityでプロジェクトを開いた状態でダブルクリックしてインポートします。
[Assets]->[Import Package]->[Custom Package]でパッケージファイルを選択するのでもOK。

f:id:kaninabe:20160417205917p:plain
インポート後、Assets/OVR/Prefabs/OVRCameraRig.prefabをHierarchyに配置して元のMainCameraと置き換えましょう。

この状態で再生してみて問題ないか確認します。
ちなみにOVRPluginを入れていないと再生ボタン押した瞬間にフリーズしたりクラッシュしたりします。

6. osigファイルの配置

アプリ書き出しの前に、アプリをインストールするGalaxy実機に紐付けられたosigファイルという署名ファイルが必要です。 osigジェネレータのページでosigファイルを作成・ダウンロードします(OculusのDeveloperアカウントが必要)。

f:id:kaninabe:20160417205936p:plain
実機を開発用マシンにつないだ時に、adb devicesコマンドで確認できるデバイスIDを入力します。

f:id:kaninabe:20160417205956p:plain
ダウンロードしたファイルはプロジェクトのAssets/Plugins/Android/assets/以下に置きます。 複数台のGalaxyにインストールしたい場合も、このフォルダ内にosigファイルを並べていけばOKです。

7. アプリのビルド・インストール

f:id:kaninabe:20160417205909p:plain
Unityの[File] -> [Build Settings]でBuild Settingsダイアログを開きます。 Platformは当然Android。また、Player SettingsのOther SettingsでVirtual Reality Supportedにチェックを付けるのと、Bundle Identifierを適当に設定しておくことを忘れずに。

実機を接続した状態でBuild And Runボタンを押せばapkの書き出しとインストールを同時にしてくれます。 Buildボタンでapkの書き出しだけやっておいて、別途インストールしてもよいです。

8. 実機で確認

f:id:kaninabe:20160417214646p:plain
インストールしたアプリのアイコンをタップするとGear VRに接続してくれ、と言われるので接続します。 (この時、接続しても反応してくれなかったり、うっかり画面にタッチしてキャンセルされてしまうことがありますが、落ち着いてGalaxyを取り外してアプリを立ち上げなおしましょう。)

f:id:kaninabe:20160417205927p:plain
Unityで作ったシーンが見えているはずです!