はじめに

今回の記事は、PolySpatialでVision Proアプリを作ってみよう(導入編)の続きとなります。
前回の記事を読んでいることを前提で書いていくので、ぜひ導入編からご覧ください。

前回は、Bounded Volume でピンチ入力を検知するサンプルアプリを作成しました。
今回は、Unbounded Volume で両手のジェスチャで操作するコンテンツを作成していこうと思います。

※この記事は2024/3/1時点までの調査結果を元に作成しました。

開発環境

  • Unity 2022.3.18f1
  • PolySpatial 1.0.3
  • XR Hands 1.3.0
  • VisionOS 1.0.3
  • macOS Sonoma 14.2
  • Xcode 15.2

今回の目標

今回は、↓の動画のMeta Quest向けに作ったジオラマアプリをVisionPro向けに移植していこうと思います。

VisionProでできること

まずは、QuestとVisionProでできること/できないことを洗い出していこうと思います。

ジオラマモデルの表示

Quest版のモデルには独自のシェーダーが使われているのですが、このシェーダーがVisionProでは正常に表示できませんでした。
なので、今回はURP/Litで代用します。

操作

Quest版では右のコントローラーで移動、左のコントローラーで回転を行っています。
VisionProはコントローラーがないのでハンドジェスチャで操作を行うようにします。

https://www.techno-edge.net/article/2023/06/10/1418.html
↑こちらのサイトを参考に、Tapで建物などの選択、Pinch and Dragで移動、Zoomで拡大縮小、Rotateで回転を行うようにします。

また、Quest版ではコントローラーのボタンで行っている道案内の表示/非表示は、シーン上にボタンを置くことで対応します。

ホバー演出

ポインタが操作可能なオブジェクトと重なった時のホバー演出は、Quest版ではSEがなるようになっています。
しかし、前回も書いたように、VisionProではピンチ開始時以外では視線を取得することができないので、ホバー時の演出は VisionOSHoverEffect コンポーネントを使います。

道案内のシェーダー

道案内では指定したパスに徐々に線が引かれていくという演出をしています。
このシェーダーがVisionProでは機能しなかったので、今回は赤いSphereを連続で生成して線が引かれているように見せることにします。

シーンの準備

作るものの仕様が決まったので、早速実装に入っていきます。
前回のようなシンプルな Bounded Volume のコンテンツでは、 Volume Camera の設定だけで良かったのですが、今回はもう少し準備が必要になります。

Volume Camera の設定

前回同様に Volume Camera をシーンに配置し、Volume Window Configration を設定します。
ここで、前回は Volume Window ConfigrationModeBounded に設定しましたが、今回は Unbounded に設定します。

AR Kit 用のオブジェクトを配置

今回は両手ジェスチャによる操作をしたいので、AR Kitのハンドトラッキング機能を利用します。

まず、 AR Session を作成します。

次にカメラをARカメラに変更します。
XR Origin (AR) を使うのが簡単なのですが、 XR Interaction Toolkit などが入っていると、余計なものまで同時に作られてしまうので、今回はPolySpatialパッケージのサンプルの MixedReality シーンから持ってきました。
ARカメラを配置したら元からあった Main Camera は削除してください。

ハンドトラッキングを実装

シーンの準備ができたので、ハンドトラッキングを実装していきます。
この部分は、PolySpatialパッケージのサンプルの Mixed Reality の処理を参考にしています。

両手のデータを取得

両手の各種データは XR Hands から取得することができます。
今回は、両手の親指と人差し指のデータを取得してみます。

using UnityEngine;
using UnityEngine.XR.Hands;
using UnityEngine.XR.Management;

namespace Gaprot.Behaviours
{
/// <summary>
/// ジェスチャ管理クラス
/// </summary>
public class GestureManager : MonoBehaviour
{
/// <summary>
/// Hand Subsystem
/// </summary>
private XRHandSubsystem _handSubsystem;

/// <summary>
/// Start
/// </summary>
private void Start()
{
_handSubsystem = getHandSubsystem();
_handSubsystem?.Start();
}

/// <summary>
/// Update
/// </summary>
private void Update()
{
if(_handSubsystem == null) return; // エディタ実行などでHandSubsystemがない時は処理終了

var updateSuccessFlags = _handSubsystem.TryUpdateHands(XRHandSubsystem.UpdateType.Dynamic); // トラッキング情報を更新

if ((updateSuccessFlags & XRHandSubsystem.UpdateSuccessFlags.RightHandRootPose) != 0)
{
var rightThumbTipJoint = _handSubsystem.rightHand.GetJoint(XRHandJointID.ThumbTip); // 右手親指の先端
var rightIndexTipJoint = _handSubsystem.rightHand.GetJoint(XRHandJointID.IndexTip); // 右手人差し指の先端
}

if ((updateSuccessFlags & XRHandSubsystem.UpdateSuccessFlags.LeftHandRootPose) != 0)
{
var leftThumbTipJoint = _handSubsystem.leftHand.GetJoint(XRHandJointID.ThumbTip); // 左手親指の先端
var leftIndexTipJoint = _handSubsystem.leftHand.GetJoint(XRHandJointID.IndexTip); // 左手人差し指の先端
}
}

/// <summary>
/// HandSubsystemを取得
/// </summary>
/// <returns></returns>
private XRHandSubsystem getHandSubsystem()
{
var xrGeneralSettings = XRGeneralSettings.Instance;
if (xrGeneralSettings == null) return null;
var manager = xrGeneralSettings.Manager;
if (manager == null) return null;
var loader = manager.activeLoader;
return loader == null ? null : loader.GetLoadedSubsystem<XRHandSubsystem>();
}
}
}

ピンチ操作を検知

両手の親指、人差し指のデータが取得できたので、このデータを使ってピンチ操作を検知してみます。
前回の記事のように EnhancedTouch.Touch からピンチ操作を検知することもできますが、あちらは1つ目の入力、2つ目の入力というように左右を判別していないので、今回は自前で実装します。

using UnityEngine;
using UnityEngine.XR.Hands;
using UnityEngine.XR.Management;

namespace Gaprot.Behaviours
{
/// <summary>
/// ジェスチャ管理クラス
/// </summary>
public class GestureManager : MonoBehaviour
{
/// <summary>
/// VolumeCameraのTransform
/// </summary>
[SerializeField] private Transform _volumeTransform;

/// <summary>
/// Hand Subsystem
/// </summary>
private XRHandSubsystem _handSubsystem;

/// <summary>
/// ピンチの閾値
/// </summary>
private const float PINCH_THRESHOLD = 0.02f;

//-------------------------------------------------------------------------------
// 中略
//-------------------------------------------------------------------------------

/// <summary>
/// ピンチを検知する
/// </summary>
/// <param name="thumb">親指</param>
/// <param name="index">人差し指</param>
/// <returns>ピンチ入力時は入力位置, 非入力時はnull</returns>
private Vector3? detectPinch(XRHandJoint thumb, XRHandJoint index)
{
if (thumb.trackingState == XRHandJointTrackingState.None ||
index.trackingState == XRHandJointTrackingState.None) return null;

if (!thumb.TryGetPose(out var thumbPose)) return null;
var thumbPos = _volumeTransform.InverseTransformPoint(thumbPose.position); // 親指の位置を取得

if (!index.TryGetPose(out var indexPose)) return null;
var indexPos = _volumeTransform.InverseTransformPoint(indexPose.position); // 人差し指の位置を取得

var offset = thumbPos - indexPos;
var pinchDistanceSqr = Vector3.SqrMagnitude(offset); // 親指と人差し指の間の距離を取得

if (pinchDistanceSqr > PINCH_THRESHOLD * PINCH_THRESHOLD) return null; // 閾値より離れているなら処理終了

var pos = (thumbPos + indexPos) / 2; // 親指と人差し指の中点
return pos;
}
}
}

↓実機だとこんな感じです。
ピンチ入力位置に青と赤のスフィアを表示するようにしました。
手のボーンはPolySpatialのサンプルに入っている Hand Visualizer コンポーネントを使っています。

ハンドジェスチャを実装

両手の情報の取得とピンチ操作の検知ができたので、ハンドジェスチャを実装していきます。
ここはPolySpatial固有の実装というわけではないので、作りたいものに合わせて自由に作っていいと思います。

移動

片手だけピンチ入力中の時のみ移動するようにします。
前フレームとの差分を移動量として返すモジュールを作成しました。

using UnityEngine;

namespace Gaprot.Modules
{
/// <summary>
/// 移動モジュールクラス
/// </summary>
public class MoveModule
{
/// <summary>
/// 前回の入力位置
/// </summary>
private Vector3? _prePos;

/// <summary>
/// 移動量を計算する
/// </summary>
/// <param name="rightPos"></param>
/// <param name="leftPos"></param>
public Vector3 CalcMoveValue(Vector3? rightPos, Vector3? leftPos)
{
if (rightPos.HasValue == leftPos.HasValue)
{
_prePos = null;
return Vector3.zero; // 両手ともピンチor非ピンチなら処理終了
}

var currentPos = rightPos ?? leftPos; // 現在の入力位置を取得

var moveValue = Vector3.zero;
if (_prePos.HasValue)
{
moveValue = currentPos.Value - _prePos.Value;
}

_prePos = currentPos;
return moveValue;
}
}
}

拡大縮小

両手がピンチ入力中に、両手間の距離が広がったら拡大、狭まったら縮小するようにします。
両手間の距離を計算して、前フレームの距離との差分を拡大縮小量として返すモジュールを作成しました。

using UnityEngine;

namespace Gaprot.Modules
{
/// <summary>
/// 拡大縮小モジュールクラス
/// </summary>
public class ZoomModule
{
/// <summary>
/// 前回の距離
/// </summary>
private float? _preDistance;

/// <summary>
/// 拡大縮小の量を計算する
/// </summary>
/// <param name="rightPos"></param>
/// <param name="leftPos"></param>
/// <returns></returns>
public float CalcZoomValue(Vector3? rightPos, Vector3? leftPos)
{
if (!rightPos.HasValue || !leftPos.HasValue)
{
_preDistance = null;
return 0; // 両手ともピンチ入力中でないなら処理終了
}

var currentDistance = Vector3.Distance(rightPos.Value, leftPos.Value);
var zoomValue = 0f;
if (_preDistance.HasValue)
{
zoomValue = currentDistance - _preDistance.Value;
}

_preDistance = currentDistance;
return zoomValue;
}
}
}

回転

両手がピンチ入力中に、両手の位置を回転させたらジオラマも回転するようにします。
右手から左手へのベクトルを計算して、前フレームのベクトルとの差分を回転量として返すモジュールを作成しました。

using UnityEngine;

namespace Gaprot.Modules
{
/// <summary>
/// 回転モジュールクラス
/// </summary>
public class RotateModule
{
/// <summary>
/// 前回のベクトル
/// </summary>
private Vector3? _preVec;

/// <summary>
/// 回転量を計算する
/// </summary>
/// <param name="rightPos"></param>
/// <param name="leftPos"></param>
/// <returns></returns>
public float CalcRotateValue(Vector3? rightPos, Vector3? leftPos)
{
if (!rightPos.HasValue || !leftPos.HasValue)
{
_preVec = null;
return 0; // 両手ともピンチ入力中でないなら処理終了
}

var currentVec = (leftPos.Value - rightPos.Value).normalized; // 右手から左手への単位ベクトル
var rotateValue = 0f;
if (_preVec.HasValue)
{
rotateValue = Vector3.SignedAngle(_preVec.Value, currentVec, Vector3.up); // ジオラマと同じY軸回転で計算
}

_preVec = currentVec;
return rotateValue;
}
}
}

実機で確認

ここまでに実装したものを実機で動かしてみました。

タップ可能オブジェクトを実装

建物や道案内ボタンをタップできるようにします。
基本的には前回の記事のカワセミと同じように設定すればOKです。

https://discussions.unity.com/t/ui-button-click-event-not-working/321829
↑道案内ボタンなどのuGUIは新InputSystemを設定すればいつものように Button コンポーネントで制御できるようです。
まだ調査できていないので、今回はボタンも建物と同じように実装しました。

完成

完成したものがこちら↓です。

おわりに

いかがだったでしょうか?
ロジック部分は今までのデバイスと比べて、そこまで難しくはないかなー、と思います。
逆に、描画部分に関しては結構制約があって、作りづらいかもしれません。

シェーダーグラフなどの描画に関する記事も(書くのは僕ではありませんが)今後上がるかもしれないので、ぜひお楽しみに!!



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる