VR の世界に手を持ち込める Leap Motion VR の仕組みを調べてみた - 凹みTips

凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

VR の世界に手を持ち込める Leap Motion VR の仕組みを調べてみた

はじめに

8月末に Leap Motion が公式でステレオの赤外画の取得の API の公開と、VR 用のソフトウェア群、および Oculus Rift DK2 マウンタを公開しました。

サンプルも幾つか用意されていて試すことが出来ます。マウンタは買わずとも両面テープやマジックテープでくっつければ問題なく動作しますし、3D プリンタをお持ちであればデータも公開されているので、自分で用意することも可能です。

ステレオの赤外カメラ画を両目に展開して、AR 的にその絵にぴったり合う形で手の 3D モデルが重畳されます。これは自分の手が VR 内のオブジェクトに簡単に干渉できることを意味し、とても衝撃的な体験でした。今回のアップデートによりトップダウンラッキングHMD ポジションからの手形状のトラッキング)が大幅に改良され、以前 Leap Motion マウンタを使ったそれとは全く異なる体験となっています。

今回はこの詳細な仕組みについて調べてみましたのでご紹介します。

Unity サンプル

以下に Leap Motion VR 用の Unity サンプルが上がっています。

LeapOculusPassthrough.unitypackage をダウンロードして Assets 下にある LeapOculusPassthrough シーンを開くと次のような画面が表示されます。

f:id:hecomi:20140922210736p:plain

Editor 拡張で Hand ControllerGizmoLeap Motion アイコンが表示しているのがわかりやすくて良いですね。実行すると以下のように歪み補正をしたカメラ画に、認識した手にぴったり合う形で手オブジェクトを重畳した、冒頭のサンプルのような画面が動作します。

f:id:hecomi:20140922215114p:plain

カメラ、カメラ画を重畳したプレーン、手オブジェクト、その他のオブジェクトの位置関係はこの順で次のようになっています。

f:id:hecomi:20140922215247p:plain

カメラ画を表示するプレーンや Leap Motion の手の位置の原点となる HandControllerOVRCameraController の子となっているので、Oculus Rift のヘッドトラッキングに追従してその位置も変わるため、顔の動きとそこから見える手の位置は常に一致してくれます。

それでは、まず画をどうやって取得しているのか見てみましょう。

Leap Motion からの画の取得

画の取得については以下のドキュメントが参考になります。

が、ドキュメントベースだと飽きるので実際に動いているスクリプトで見て行きましょう。Scenes ディレクトリを見てみると、以前(Leap Motion の Oculus Rift 用マウンタを使って VR の世界で積み木ゲームしてみた - 凹みTips)のものと比べてサンプルシーンが増えています。その中のひとつである RealtimeCameraViewer シーンを見てみます。

生画(1 押下時)

f:id:hecomi:20140922220527p:plain

歪み補正(2 押下時)

f:id:hecomi:20140922220817p:plain

背景抜き(3 押下時)

f:id:hecomi:20140922220836p:plain

こんな感じに表示されます。LeapOculusPassThrough シーンよりもシンプルなのでこのシーンで仕組みを見て行きましょう。画が適用されている Quad を見てみます。

f:id:hecomi:20140922222632p:plain

LeapImageRetriever.csImageRetrieverTypes.cs がアタッチされています。LeapImageRetriever.csLeapCSharp.NET3.5.dll 内内で定義されている Leap.Image を通じて Leap Motion から画を取ってくるスクリプトです。ImageRetrieverTypes.cs はこの LeapImageRetriever.cs のフラグをキー操作に応じてちょこっとだけ書き換える小さなスクリプトです。

LeapImageRetriever.cs 自体も 200 行程度の短いスクリプトなのでざっくり処理を見てみます。

LeapImageRetriever.cs(一部改変)

void Update()
{
    // フラグに応じ生画 / 歪み補正用にシェーダを切り替え
    if (undistortImage) {
        renderer.material = new Material(Shader.Find(UNDISTORT_SHADER));
    } else {
        renderer.material = new Material(Shader.Find(NORMAL_SHADER));
    }

    // Leap Motion からの情報が色々つまった Frame を取得
    Frame frame = leap_controller_.Frame();

    // ...(略)

    // 画および関連した情報が詰まった Image を取得
    Image image = frame.Images[imageIndex];
    int image_width = image.Width;
    int image_height = image.Height;

    // ...(略)

    // 生画を取得
    image_data_ = image.Data;
    distortion_data_ = image.Distortion;

    // Leap Motion から取得した値をアルファ値としてセット
    // (画は使わずにアルファのみを利用している)
    int num_pixels = main_texture_.width * main_texture_.height;
    for (int i = 0; i < num_pixels; ++i) {
        image_pixels_[i].a = image_data_[i];
    }

    // ...(略)

    // テクスチャに image_pixels_ をセット
    ApplyDataToTextures();

    // ...(略)
}

Leap.Controller.Frame を通じて Image(画のデータや情報が入ったクラス)を取得しています。取得した画は単色グレースケールで 8 bit の明度が入っているのみです。上記コードではこれをアルファとしてテクスチャにセットし、フラグメントシェーダ内で以下のように利用しています。

LeapDistorted.shader(一部改変)

float4 frag(fragment_input input) : COLOR
{
    // ...(略)

    // 先ほどアルファをセットしたテクスチャ
    float4 textureColor = tex2D(_MainTex, position);

    // そのアルファを取り出す
    float a = textureColor.a;

    // GUI から設定した明るい場所の色
    float4 color = _Color;

    // 透明モード(3 キー押下時)だったらアルファをそのまま透明度として反映
    if (_BlackIsTransparent == 1) {
        color.a *= a;
    // そうでない時は背景は黒にしてアルファを明るさとして使用し RGB 値の方を暗くする
    } else {
        color = a * color;
        color.a = 1.0;
    }
}

とてもシンプルです。

歪み補正について

Leap Motion の生画は画角 150°の広角レンズを通じて得られる画であり、レンズの特性により樽型の歪みが生じます。Leap Motion ではこの補正をシェーダ内で面白いやり方で補正しています。具体的には、予め Image.Distortion を利用してキャリブレーションマップを画像として用意しておき、これを利用して歪み前から歪み後の座標へと変換しています。

LeapImageRetriever.cs

void Update()
{
    // ...(略)

    // Leap Motion SDK からキャリブレーションマップを貰う
    distortion_data_ = image.Distortion;

    // ...(略)

    // キャリブレーションマップをシェーダ内で扱える形に変換
    if (undistortImage) {
        EncodeDistortion();
    }

    // ...(略)
    if (undistortImage) {
        // そのキャリブレーションマップをシェーダにデータをセット
        renderer.material.SetTexture("_DistortX", distortionX_);
        renderer.material.SetTexture("_DistortY", distortionY_);
        renderer.material.SetFloat("_RayOffsetX", image.RayOffsetX);
        renderer.material.SetFloat("_RayOffsetY", image.RayOffsetY);
        renderer.material.SetFloat("_RayScaleX", image.RayScaleX);
        renderer.material.SetFloat("_RayScaleY", image.RayScaleY);
    }
}

Image.Distortion は 64x64x2(2 は x と y 両方詰まっているので) の float のデータなのですが、これはある位置 {(x, y)}{(x', y')} に変換するマップになっています。つまり、{x' = f(x, y)}{y' = g(x, y)} となる関数を作ってるわけです。より具体的にはこの float を RGBA にエンコードし、シェーダ内でこの RGBA を float にデコードしています。補間は内部的にテクスチャで Bilinear で行われていると思います。ちなみにエンコードされたテクスチャ(distortionX_distortionY_)を見てみるとこんな感じです(絵柄そのものに意味はありません、モアレになっているのは x, y, z, w で各オーダーを扱っているからだと思います)。

f:id:hecomi:20140923175004p:plain

CPU パワーを借りるのであれば Image.Warp()Image.Rectify() を使えば良いようですが、処理が遅くなると思うので GPU パワーを借りるためにこの方式を採用しているものと思われます。法線マップも (x, y, z) を (r, g, b) なテクスチャに置き換えて処理していますが、こういった形で予め用意したテクスチャを元に画像処理を行うのは色々出来そうで面白いです。

手のトラッキングと重畳

次に手のトラッキングを見るには PassthroughWithTracking シーンがシンプルで分かりやすいです。トラッキングには以前と同じく HandController.cs が担当していますので詳しくは以前の説明に譲ります。

差分としてはツール(鉛筆などのような物体)が使えるようになっていることで、これは TooDarkToSee シーンなどを参考にすると良いと思います。カメラ画とオブジェクトの重畳に関しては、そもそもカメラ画をベースに手の位置を推定しているため、カメラ画 - 手オブジェクト - カメラが適切な距離で並びさえすれば Unity のカメラの画角によらず一致するようになります。

f:id:hecomi:20140923184237p:plain

インタラクションの作成

@koukiwf さんが先駆けてやられています。

この表現を LeapOculusPassthrough をベースに作成してみます。

手の大きさを変えるには色々な場所を修正する必要があるので、今回は割愛して、周りのオブジェクトを触りやすい大きさにします。以下はプレーン3枚のコライダの Is Trigger をチェックして簡単なスクリプトをくっつけたサンプルになります。

using UnityEngine;
using System.Collections;

public class LeapTouchablePanel : MonoBehaviour 
{
    public Color normalColor  = new Color(0, 0, 255);
    public Color touchedColor = new Color(0, 255, 0);
    private int touchedColliderNum_ = 0;

    void Start()
    {
        renderer.material.color = normalColor;
    }

    void OnTriggerEnter(Collider other)
    {
        if (touchedColliderNum_ == 0) {
            renderer.material.color = touchedColor;
        }
        ++touchedColliderNum_;
    }

    void OnTriggerExit(Collider other)
    {
        --touchedColliderNum_;
        if (touchedColliderNum_ == 0) {
            renderer.material.color = normalColor;
        }
    }
}

そのままでは距離感が取りづらいので、例えば影を落としてあげるといった前後関係の提示の工夫は必要だと思います。

Unity 4.6 UI との組み合わせ

公式のフォーラムに実現された方の動画が上がっています。

前回の記事(Oculus Rift で頭の動き + タップで簡単に Unity 4.6 UI を選択できるやつを作ってみた - 凹みTips)を元に同じように作成してみようと思います(別記事で公開予定)。

Leap Motion VR の Tips

以下のエントリに 12 の Q&A が公開されています。重要なポイントだけピックアップしてみます。

HMD モード

ヘッドマウントディスプレイに最適化したモード(上下反対にして配置した時の手の検出を改良したモード)があるのですが、これは個々のアプリケーションからポリシーフラグに 'POLICY_OPTIMIZE_HMD' をセットすることで有効かどうかを切り替えます。サンプル中では LeapImageRetriever.csStart() 中で以下のようにポリシーフラグをセットしています。

leap_controller_.SetPolicyFlags(leap_controller_.PolicyFlags | Controller.PolicyFlag.POLICY_IMAGES);

Oculus Rift DK2 のポジショントラッキングの妨げにならないか?

Leap Motion を前面に配置することから、DK2 本体に配置されているポジショントラッキング用の赤外線 LED を覆ってしまうことで精度が落ちてしまう懸念がありますが、DK2 にはロバスト性確保のために複数の赤外線 LED が配置されていることから問題とならないようです。

DK2 の USB ポートを利用できないか?

DK2 には本体に拡張用の USB ポートがついているのですが、残念ながら現在のところ転送速度に問題があり認識のフレームレートが落ちてしまうことから、PC 本体の USB ポートを使うことが推奨されています。

認識範囲について

Leap Motion の FOV は 150° x 120° もあり、Oculus Rift DK2 の対角 100° を十分にカバーする範囲を扱うことが出来ます。つまり見える範囲で手が届く範囲のオブジェクトは操作できるということになります。

https://blog.leapmotion.com/wp-content/uploads/2014/08/mount-fov.png (http://blog.leapmotion.com/12-faqs-vr-developer-mount/ より)

今後の展開

Dragonfly

公式のエントリによると、Dragonfly というコードネームで、HD よりも高解像度な画を扱い、近赤外画と共にカラー画も取得でき、かつ FOV も大きな次世代バージョンを開発しているとのことです。とても楽しみですね。

おわりに

他にもカメラ画を加工してあげたりして面白い表現を作成したりできそうなので色々と試していきたいです。