はじめに
VR 向け Leap Motion アセットに再び神アップデートがきました。
- Unity Core Assets 2.3.0 + ImageHands - Development - Leap Motion (Ultraleap) Community Forums
- https://developer.leapmotion.com/gallery/category/image-hand
いくつかアップデートがある中で目玉は「Image Hand」という機能で、従来は 3D のモデルを認識した手の形状に合わせて動かしていたのに対し、カメラで取得した実際の手の領域を直接描画するモードが追加されました。従来同様当たり判定も効く上にオクルージョン(VR 内の 3D オブジェクトの後ろに回りこむような表現)も再現されています。
本エントリでは、Image Hand に焦点を当てながら、前回(VR の世界に手を持ち込める Leap Motion VR の仕組みを調べてみた - 凹みTips)との差分などについて解説したいと思います。
環境
- Windows 8.1
- OVRSDK 0.5.0.1
- Leap Motion 2.2.5+26752
- Unity 5.1.0f1
現在、OVRSDK 0.6.0beta へ対応中とのことです(動くかどうかは試してないです)。
デモ
公式の 500 Blocks デモを動かしてみました。
デモで遊ぶ
公式のデモがアップデートされているのでダウンロードすれば直接遊ぶことが出来ます。各環境をアップデートしておきましょう。
- VR – Ultraleap Gallery
- Widgets | Leap Motion Developers
- https://developer.leapmotion.com/gallery/image-hand-moving-demo
- https://developer.leapmotion.com/gallery/500-blocks
Unity で触ってみる
以下のリンクから Unity Core Asset v2.3.0 がダウンロードできます。
LeapMotionCoreAssets_2_3_0.unitypackage
をインポートすると以下の様な階層にサンプルシーンが含まれています。
実行すると実際に遊ぶことが出来ます。
アップデート内容
詳細は後述しますが、アップデート内容についてざっと見てみます。
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 はどうやって動いているか」について見ていきます。
オクルージョン表現
まずオクルージョンの表現についてです。はじめに勘の鋭い人はこれ一枚で仕組みが分かるかもしれません。
どうでしょう?何か 3D モデルの腕のところにカメラで撮った指が映っているのが見て取れると思います。実は背景と腕にスクリーンスペースで Leap Motion のカメラ画を適用しています(モデルの UV 情報に関係なくスクリーン座標でテクスチャを貼り付けている)。これによってカメラから見ると手のモデルは完全に透けて見えるような形になります。しかしこの手の 3D モデルだけ他のオブジェクトとの前後関係を考慮するようにレンダリングすることで、他のオブジェクトの奥にある場合は他のオブジェクトを、前にある場合にはこのスクリーンスペースのテクスチャを貼り付けた手を表示することができ、オクルージョンが再現できるわけです。
この手のモデルは実際の手よりも少し厚めになっています。なので Leap Motion の認識がちょっと遅れて指がついてきたりずれていたりしても、大きな破綻なくそのままの手を透かして見ることが出来ます(後述しますが条件によってははみ出して見える時もあります)。
手の縁が光る表現
ではこの手の縁が良い感じに光る表現はどうやっているのでしょうか。光っている画像と光っていない画像を並べてみます。
...そうです、お察しの通りいい感じの灰色をいい感じの青色にスレッショルドをパラメタ調整して変えているだけです。なので赤外線をよく反射する物体が後ろにあり、それがスレッショルドとかぶる輝度をしていると、そこも青く光ってしまうわけですね。
基本はこれだけなのですが、もう一つ、インタラクションした物体との境界でグローする表現が加えられています。
これはカメラの深度テクスチャを利用して表現しています。詳しくはコードを見てみましょう。
シェーダのコードで見てみる
簡易化したコードを簡単な説明付きで以下に示します。
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()
のタイミングで画を取得していたのに対し、今回から SyncMode
が LOW_LATENCY
の場合は、描画する直前のタイミングで呼ばれる MonoBehaviour.OnPreRender()
のタイミングで画を取ってくるようになった点だと考えています。LeapOVRCameraRig
の LeftEyeAnchor
、RightEyeAnchor
にアタッチされた 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; } ... }
詳細
これに伴い、若干描画方法が複雑になっています。
まず、カメラ画を表示するプレーンは CenterEyeAnchor
の Quad
が担当します。
ここには 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
が担当しています。
簡略化したコードを以下に示します。
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 の開発が既に始まっているのではないかと想像できます。今後の展開がとても楽しみですね。