凹みTips

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

現実の手を直接 VR 内に持ち込めるようになった Leap Motion Core Asset v2.3.0 を詳しく調べてみた

はじめに

VR 向け Leap Motion アセットに再び神アップデートがきました。

いくつかアップデートがある中で目玉は「Image Hand」という機能で、従来は 3D のモデルを認識した手の形状に合わせて動かしていたのに対し、カメラで取得した実際の手の領域を直接描画するモードが追加されました。従来同様当たり判定も効く上にオクルージョンVR 内の 3D オブジェクトの後ろに回りこむような表現)も再現されています。

本エントリでは、Image Hand に焦点を当てながら、前回(VR の世界に手を持ち込める Leap Motion VR の仕組みを調べてみた - 凹みTips)との差分などについて解説したいと思います。

環境

現在、OVRSDK 0.6.0beta へ対応中とのことです(動くかどうかは試してないです)。

デモ

公式の 500 Blocks デモを動かしてみました。

デモで遊ぶ

公式のデモがアップデートされているのでダウンロードすれば直接遊ぶことが出来ます。各環境をアップデートしておきましょう。

Unity で触ってみる

以下のリンクから Unity Core Asset v2.3.0 がダウンロードできます。

LeapMotionCoreAssets_2_3_0.unitypackage をインポートすると以下の様な階層にサンプルシーンが含まれています。

f:id:hecomi:20150524155149p:plain

実行すると実際に遊ぶことが出来ます。

アップデート内容

詳細は後述しますが、アップデート内容についてざっと見てみます。

Enhanced passthrough experience

  • Unity でのパススルー画の表示を 2 msec 削減
    • Update() タイミングから OnPreRender() タイミングにしたこと?
  • パススルー画表示アセット利用時の Oculus Rift の IPD 補正の自動化
    • Leap Motion のカメラと Oculus の Configuration Utility で設定した IPD との補正を自動で行う

Image Hands

  • 前述のようにリアルの手を持ち込んでインタラクション出来るようになった

Other updates and bug fixes

  • 新しいパススルー画表示の仕組み
    • ↑の内容と併せて後述します
  • その他バグフィックスもろもろ

これらについてより詳しく見ていきます。

Image Hand の表示の仕組み

はじめに一番面白い「Image Hand はどうやって動いているか」について見ていきます。

オクルージョン表現

まずオクルージョンの表現についてです。はじめに勘の鋭い人はこれ一枚で仕組みが分かるかもしれません。

f:id:hecomi:20150525224808p:plain

どうでしょう?何か 3D モデルの腕のところにカメラで撮った指が映っているのが見て取れると思います。実は背景と腕にスクリーンスペースで Leap Motion のカメラ画を適用しています(モデルの UV 情報に関係なくスクリーン座標でテクスチャを貼り付けている)。これによってカメラから見ると手のモデルは完全に透けて見えるような形になります。しかしこの手の 3D モデルだけ他のオブジェクトとの前後関係を考慮するようにレンダリングすることで、他のオブジェクトの奥にある場合は他のオブジェクトを、前にある場合にはこのスクリーンスペースのテクスチャを貼り付けた手を表示することができ、オクルージョンが再現できるわけです。

この手のモデルは実際の手よりも少し厚めになっています。なので Leap Motion の認識がちょっと遅れて指がついてきたりずれていたりしても、大きな破綻なくそのままの手を透かして見ることが出来ます(後述しますが条件によってははみ出して見える時もあります)。

f:id:hecomi:20150526001005p:plain

手の縁が光る表現

ではこの手の縁が良い感じに光る表現はどうやっているのでしょうか。光っている画像と光っていない画像を並べてみます。

f:id:hecomi:20150526003204p:plain f:id:hecomi:20150526003215p:plain

...そうです、お察しの通りいい感じの灰色をいい感じの青色にスレッショルドをパラメタ調整して変えているだけです。なので赤外線をよく反射する物体が後ろにあり、それがスレッショルドとかぶる輝度をしていると、そこも青く光ってしまうわけですね。

基本はこれだけなのですが、もう一つ、インタラクションした物体との境界でグローする表現が加えられています。

f:id:hecomi:20150526010614p:plain

これはカメラの深度テクスチャを利用して表現しています。詳しくはコードを見てみましょう。

シェーダのコードで見てみる

簡易化したコードを簡単な説明付きで以下に示します。

Shader "LeapMotion/Passthrough/ImageHandHighlight" {
    ...
    CGINCLUDE

    #define USE_DEPTH_TEXTURE

    frag_in vert(appdata v) {
        frag_in o;
        o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
        float3 norm = mul ((float3x3)UNITY_MATRIX_IT_MV, v.normal);
        o.vertex.xy += TransformViewToProjection(norm.xy) * _Extrude;
        o.screenPos = ComputeScreenPos(o.vertex);
        #ifdef USE_DEPTH_TEXTURE
        o.projPos = o.screenPos;
        // COMPUTE_EYEDEPTH は頂点の視点空間デプスを計算して出力する
        COMPUTE_EYEDEPTH(o.projPos.z);
        #endif
        return o;
    }

    float4 trackingGlow(float4 screenPos) {
        // Leap で取得した画の輝度情報を取得
        float4 leapRawColor = LeapRawColorBrightness(screenPos);
        // スレッショルドでクリッピング
        clip(leapRawColor.a - _MinThreshold);
        // リニアにする
        float3 leapLinearColor = pow(leapRawColor.rgb, _LeapGammaCorrectionExponent);
        // いい感じのグレー部分(= 手の領域)だけ抽出
        float brightness = smoothstep(_MinThreshold, _MaxThreshold, leapRawColor.a) * _Fade;
        // いい感じのグレー部分(= 手の縁)だけ抽出
        float glow = smoothstep(_GlowThreshold, _MinThreshold, leapRawColor.a) * brightness;
        // 縁を指定した色で光らせる
        float4 linearColor = pow(_Color, _ColorSpaceGamma) * glow * _GlowPower;
        // 混ぜる
        return float4(leapLinearColor + linearColor, brightness);
    }

    #ifdef USE_DEPTH_TEXTURE
    float4 intersectionGlow(float4 handGlow, float4 projPos) {
        // カメラ深度テクスチャから該当ピクセルの z 座標を取得
        float sceneZ = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(projPos)));
        // COMPUTE_EYEDEPTH で取得した頂点の視点空間での z 座標を取得
        float partZ = projPos.z;
        // 差を良い感じにスレッショルドを設けてスムージング
        // _Intersection ~ 0 付近の細かい段差部分が光る
        float diff = smoothstep(_Intersection, 0, sceneZ - partZ);
        // リニアにすると同時に輝度の強さをパラメタ調整
        float4 linearColor = pow(_Color, _ColorSpaceGamma) * _IntersectionEffectBrightness;
        return float4(lerp(handGlow.rgb, linearColor.rgb, diff), handGlow.a * (1 - diff));
    }
    #endif

    // グロー部分を描く
    float4 frag(frag_in i) : COLOR {
        // 縁を光らせる
        float4 handGlow = trackingGlow(i.screenPos);
        #ifdef USE_DEPTH_TEXTURE
        // 3D オブジェクトとの境界を光らせる
        handGlow = intersectionGlow(handGlow, i.projPos);
        #endif
        return float4(handGlow.rgb, _Fade * handGlow.a);
    }

    // Leap Motion の画でクリッピングをする
    float4 alphaFrag(frag_in i) : COLOR {
        // 後で ColorMask 0 で切り抜く
        float4 leapRawColor = LeapRawColorBrightness(i.screenPos);
        clip(leapRawColor.a - _MinThreshold);
        return float4(0,0,0,0);
    }

    ENDCG

    SubShader {
        Tags {"Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="Transparent"}
        Blend SrcAlpha OneMinusSrcAlpha

        // 1-pass 目はクリッピングを行う
        Pass {
            ZWrite On
            ColorMask 0

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment alphaFrag
            ENDCG
        }

        // 2-pass 目は手の画像や青く光るグロー部分を描く
        Pass{
            ZWrite Off

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            ENDCG
        }
    } 

    Fallback "Unlit/Texture"
}

うーん、とてもおもしろいですね。

追記(2015/05/27)

すみません、早とちりしていましたので上記説明を修正しました。具体的には、1-pass 目に通常の画を描いて 2-pass 目でグローを描いていると思っていたのですが、1-pass 目はクリッピングを行い、2-pass 目にグローを含む手全体の画像を描画していました。

パススルー画の高速化

概要

次にパススルー画の高速化について見てみます。基本的な戦略としては、以前は MonoBehaviour.Update() のタイミングで画を取得していたのに対し、今回から SyncModeLOW_LATENCY の場合は、描画する直前のタイミングで呼ばれる MonoBehaviour.OnPreRender() のタイミングで画を取ってくるようになった点だと考えています。LeapOVRCameraRigLeftEyeAnchorRightEyeAnchor にアタッチされた LeapImageRetriever.cs 内で次のようなコードが記載されています。

public enum SYNC_MODE {
    SYNC_WITH_HANDS,
    LOW_LATENCY
}
public SYNC_MODE syncMode = SYNC_MODE.LOW_LATENCY;

void Update() {
    ...
    if (syncMode == SYNC_MODE.SYNC_WITH_HANDS) {
        _imageList = frame.Images;
    }
}

void OnPreRender() {
    if (syncMode == SYNC_MODE.LOW_LATENCY) {
        _imageList = _controller.Images;
    }
    ...
}

詳細

これに伴い、若干描画方法が複雑になっています。

まず、カメラ画を表示するプレーンは CenterEyeAnchorQuad が担当します。

f:id:hecomi:20150526020712p:plain

ここには LeapImageBasedMaterial.cs というスクリプトがアタッチされており、どちらのカメラの画像を利用するかとシェーダのパラメタの設定、そして Leap Motion から画を取ってくる LeapImageRetriever.cs へ登録を行っています(static 経由)。

public class LeapImageBasedMaterial : MonoBehaviour 
{
    public enum ImageMode {
        STEREO,
        LEFT_ONLY,
        RIGHT_ONLY
    }

    public ImageMode imageMode = ImageMode.STEREO;

    void Awake() 
    {
        ...
    }

    void OnEnable() 
    {
        LeapImageRetriever.registerImageBasedMaterial(this);
        ... (シェーダの設定)
    }

    void OnDisable() 
    {
        LeapImageRetriever.unregisterImageBasedMaterial(this);
    }
}

LeapImageRetriever.cs は左右のカメラに取り付けられていて、この1枚の Quad を共有し、それぞれの OnPreRender() タイミングでテクスチャを書き換えレンダリングを行います。

public class LeapImageRetriever : MonoBehaviour 
{
    public enum EYE {
        LEFT = 0,
        RIGHT = 1
    }
    public EYE eye = (EYE)(-1);
    private static List<LeapImageBasedMaterial> _registeredImageBasedMaterials = new List<LeapImageBasedMaterial>();

    public static void registerImageBasedMaterial(LeapImageBasedMaterial imageBasedMaterial) 
    {
        _registeredImageBasedMaterials.Add(imageBasedMaterial);
        ...
    }

    public static void unregisterImageBasedMaterial(LeapImageBasedMaterial imageBasedMaterial) 
    {
        _registeredImageBasedMaterials.Remove(imageBasedMaterial);
    }

    void OnPreRender() 
    {
        if (syncMode == SYNC_MODE.LOW_LATENCY) {
            _imageList = _controller.Images;
        }

        // それぞれのカメラに応じたテクスチャを設定
        Image referenceImage = _imageList[(int)eye];
        ...
        loadMainTexture(referenceImage);
        ...
        foreach (LeapImageBasedMaterial material in _registeredImageBasedMaterials) {
            if (material.imageMode == LeapImageBasedMaterial.ImageMode.STEREO ||
               (material.imageMode == LeapImageBasedMaterial.ImageMode.LEFT_ONLY  && eye == EYE.LEFT) ||
               (material.imageMode == LeapImageBasedMaterial.ImageMode.RIGHT_ONLY && eye == EYE.RIGHT)) {
                updateImageBasedMaterial(material, ref referenceImage);
            }
        }
    }
    
    private void loadMainTexture(Image sourceImage) 
    {
        Marshal.Copy(sourceImage.DataPointer(), _mainTextureData, 0, _mainTextureData.Length);
        _mainTexture.LoadRawTextureData(_mainTextureData);
        _mainTexture.Apply();
    }
    
    private void updateImageBasedMaterial(LeapImageBasedMaterial imageBasedMaterial, ref Image image) 
    {
        imageBasedMaterial.GetComponent<Renderer>().material.SetTexture("_LeapTexture", _mainTexture);
        ...
    }
}

テクスチャのコピーも for 文を回していたのに対し LoadRawTextureData() を利用するようになってますね。ぱっと見コードは読みづらかったですがやってることはとても単純なので、仕組みがわかればコードは追いやすいと思います。

IPD 補正の自動化

IPD 補正に関しては、CenterEyeAnchor にアタッチされた LeapCameraAlignment.cs が担当しています。

f:id:hecomi:20150526022712p:plain

簡略化したコードを以下に示します。

public class LeapCameraAlignment : MonoBehaviour 
{
    void LateUpdate() 
    {
        LeapDeviceInfo device = handController.GetDeviceInfo();

        // 毎フレーム OVRSDK でセットされる Oculus Configuration Utility
        // で設定した IPD の値が Unity の世界でのカメラ間距離に設定される
        // (Execution Order に注意する必要がある)
        var oculusIPD = rightEye.position - leftEye.position;

        // Leap Motion のカメラ間距離と Oculus Rift の IPD の差の半分
        //   device.baseline: 2つのカメラ間距離
        //   tween: Quick Switch Demo の用に動的に Leap の世界と切り替わる時用
        Vector3 addIPD = 0.5f * oculusIPD.normalized * (device.baseline - oculusIPD.magnitude) * tween;

        // Leap Motion の撮像素子までの z 方向オフセット
        Vector3 toDevice = centerEye.forward * device.focalPlaneOffset * tween;

        // カメラ間距離を Leap に併せてかつ z 方向オフセットを足す
        leftEye.position = leftEye.position - addIPD + toDevice;
        rightEye.position = rightEye.position + addIPD + toDevice;
        centerEye.position = 0.5f * (leftEye.position + rightEye.position);
    }
}

自動で Leap の世界の IPD になってます。Leap のカメラ間距離はちょっと狭い気がするので、次期バージョンではもう少し広がるんじゃないかな、と思ってますがどうなのでしょう。

おわりに

また一段階 Leap Motion が進化した気がします。スクリプト / シェーダの随所に RGB 画へのアクセスについてのコードが仕込まれているので、おそらく公式では次期バージョンである Dragonfly の開発が既に始まっているのではないかと想像できます。今後の展開がとても楽しみですね。