ForgeVision Engineer Blog

フォージビジョン エンジニア ブログ

Meta Quest Proのスタイラスペン機能をUnityで使う方法

こんにちは!VR事業部の長谷川(id:waffle_maker)です。
今回はMeta Quest Proのスタイラスペン機能をUnityで利用するテクニックについて紹介します。

はじめに

VRとアートの融合分野において、Tilt BrushQuillなどの多数のアプリケーションが公開され、注目を集めています。
また、せきぐちあいみさんのように、VRアーティストとしてその世界で名を馳せる方々も現れています。

しかし、筆圧や反力を考慮すると、従来のVRコントローラは鉛筆やペンタブレットに比べて劣る部分があります。
この問題を解決するためには、『ペンタVR』のように既存のペンタブレットをVR入力デバイスとして用いるか、『Wacom VR Pen』のような専用デバイスを利用する事が考えられます。

そこで、Meta Quest Proのスタイラスペン機能を使用すると、VRコントローラとペンタブレットの長所を組み合わせた操作が可能になります。

バージョン情報など

各種バージョン情報は以下の通りです。

ドキュメントを確認

まずはMeta社の公式ドキュメントを検索してみましょう。

Unityの記述

Meta Quest Touch Pro Controllers | Oculus Developers

スタイラスフォース スタイラスフォース軸。Touch Proにはオプションのスタイラスペン先があり、付属のストラップと交換できます。このペン先は圧力値(0.0~1.0の範囲)を検出し、字を書いたり絵を描いたりする場合に使用できます。この軸をクエリすると、ペン先にかかっている力の圧力レベル示す浮動小数点値(0.0~1.0)が返されます。0.0は圧力がかかっていない状態、1.0は最大の圧力がかかっている状態を表します。

Unreal Engineの記述

Oculus Controller Input Mapping | Oculus Developers

Oculus Touch (L) Stylus Force - 左コントローラーのスタイラスの力のイベント。Touch Proにはオプションのスタイラスペン先があり、付属しているストラップと交換できます。このペン先は圧力値(0.0~1.0の範囲)を検出し、字を書いたり絵を描いたりするのに使用できます。このイベントの[Axis Value (軸値)]は、ペン先にかかっている力のさまざまな圧力レベル示す浮動小数点値(0.0~1.0)を返します。0.0は圧力がかかっていない状態であり、1.0は最大の圧力がかかっている状態を表します。このイベントは、これを含むアクターの入力が有効になっている場合、フレームごとに1回起動します。注: 対応する同名のゲームパッド値のBlueprintもあります。このBlueprintはいつでも明示的に取得できます。


Oculus Touch (R) Stylus Force - 右コントローラーのスタイラスの力のイベント。Touch Proにはオプションのスタイラスペン先があり、付属しているストラップと交換できます。このペン先は圧力値(0.0~1.0の範囲)を検出し、字を書いたり絵を描いたりするのに使用できます。このイベントの[Axis Value (軸値)]は、ペン先にかかっている力のさまざまな圧力レベル示す浮動小数点値(0.0~1.0)を返します。0.0は圧力がかかっていない状態であり、1.0は最大の圧力がかかっている状態を表します。このイベントは、これを含むアクターの入力が有効になっている場合、フレームごとに1回起動します。注: 対応する同名のゲームパッド値のBlueprintもあります。このBlueprintはいつでも明示的に取得できます。

WebXRの記述

Meta Quest Touch Pro Controller Support for Meta Quest Browser | Oculus Developers

ボタン7 - スタイラス圧力 スタイラスチップの圧力を報告します。

0 = 圧力なし 1 = 全圧

OpenXRの記述

Creating Actions, Action Sets, and Suggested Bindings: Native | Oculus Developers

このインタラクションプロフィールは、Meta Quest Touch Proコントローラーでのインプットソースとハプティクスを表します。これは、既存のMeta Quest Touchコントローラープロフィールのスーパーセットです。 (省略) …/input/stylus_fb/force

うーん、見る限りUnityでの具体的な実装方法の記述は無さそうですね。

サンプルシーンの動作を確認

スタイラスペン先の取り付け

下記ページに従って、Touch Proコントローラにスタイラスペン先を取り付けましょう。

スタイラスペン先の取り付け方法について
www.meta.com

スタイラスペン先の追加購入について
www.meta.com

Unityプロジェクトの準備

本題では無いため、大まかに以下の手順を実施しました。

  1. Unity 2022.3.12f1で新規プロジェクトを作成
  2. Project Settingsを開く
  3. XR Plugin Managementを有効化し、必要なパッケージをインポート
  4. Oculusを選択し、必要なパッケージをインポート
  5. Asset Storeから、Oculus Integration 57.0をインポート

TextMesh Proの設定や、Oculus Integrationの設定などの確認を求められることがありますが、本記事に限っては全て肯定的な選択で良いと思います。

エディタ実行

Project Windowで"stylus"を検索したら、サンプルシーンやスクリプトが見つかりました。

Meta Quest ProでQuest Link接続を確立した状態で、StylusTipSampleシーンを開いてエディタ実行してみます。

VR内に、説明パネル、筆圧インジケータ、Touch Proコントローラなどが表示され、スタイラスペン先で現実のテーブルをなぞることで、筆圧に応じた大きさの球が次々に生成されることが確認出来ました。

※本記事では割愛しますが、Androidビルド後も問題無く動作するはずです。

サンプルシーンの内容を確認

スタイラス関連ファイルを確認

各ファイルの役割はざっくり以下の様です。

  • StylusTip.cs
    • ソースコード
  • StylusTipBreadCrumbPf.prefab
    • パンくずのプレハブ
      ※スタイラスペン先に表示する球を『bread crumbs(パンくず)』と呼んでいる様です。
  • StylusTipPf.prefab
    • StylusTipをアタッチしたGameObjectのプレハブ
  • StylusTipSample.unity
    • サンプルシーン
  • UiPanelPf (Controls - StylusTip).prefab
    • 操作説明パネル

サンプルシーンを確認

OVRCameraRigとサンプルシーン用のGameObjectを除くと、StylusTipがアタッチされた以下の2つのGameObjectが重要そうです。

  • StylusTipPf (Left)
  • StylusTipPf (Right)

ソースコード(StylusTip.cs)を確認

ペン先を表示する機能

以下の部分でペン先の位置と向きを取得し、ペン先のメッシュに反映しているようです。

// Update stylus tip position
Pose T_device = new Pose(OVRInput.GetLocalControllerPosition(m_controller),
    OVRInput.GetLocalControllerRotation(m_controller));
Pose T_world_device = T_device.GetTransformedBy(m_trackingSpace);
Pose T_world_stylusTip = GetT_Device_StylusTip(m_controller).GetTransformedBy(T_world_device);
this.transform.SetPositionAndRotation(T_world_stylusTip.position, T_world_stylusTip.rotation);

スタイラスの筆圧を取得する機能

以下の部分で筆圧の値を取得し、またペン先が物に触れているか判定しているようです。

// Get stylus tip data
float stylusTipForce = OVRInput.Get(OVRInput.Axis1D.PrimaryStylusForce, m_controller);
bool isStylusTipTouching = stylusTipForce > 0;

パンくずを生成する機能

以下の部分で自分の位置をパンくずに反映しているようです。

// Set the next crumb position
GameObject nextCrumb = m_breadCrumbs[m_breadCrumbIndexCurr];
nextCrumb.transform.position = this.transform.position;

ソースコードを使い易いよう改造

StylusTip.csを複製して自分のプロジェクトで使い易いように改造しましょう。

パンくずの削除

パンくず関連のフィールドをコメントアウトし、エラーになった箇所を削除してしましょう。

// private const int MaxBreadCrumbs = 60;
// private const float BreadCrumbMinSize = 0.005f;
// private const float BreadCrumbMaxSize = 0.02f;

[Header("External")]
[SerializeField] private Transform m_trackingSpace;

[Header("Settings")]
[SerializeField] private OVRInput.Handedness m_handedness = OVRInput.Handedness.LeftHanded;

// [SerializeField] private GameObject m_breadCrumbPf;
//
// private GameObject m_breadCrumbContainer;
// private GameObject[] m_breadCrumbs;
//
// private int m_breadCrumbIndexPrev = -1;
// private int m_breadCrumbIndexCurr = 0;

private OVRInput.Controller m_controller;

コールバックの追加

パンくず生成の代わりにコールバックを呼べるようにしてみましょう。

  • 下記フィールドを追加
public Action<Vector3, Quaternion, float> OnStylusTouch;
  • Updateメソッドの末尾に以下の処理を追加
if (isStylusTipTouching)
{
    OnStylusTouch?.Invoke(transform.position, transform.rotation, stylusTipForce);
}

ペン先の表示更新

このままだと、コントローラが非表示になった際に、ペン先だけが表示されてしまいます。

  • 下記フィールドを追加
[SerializeField] private GameObject m_model;
  • Updateメソッドの末尾に以下の処理を追加
var isControllerConnected = OVRInput.IsControllerConnected(m_controller);
m_model.SetActive(isControllerConnected);

最終的なソースコード

using System;
using UnityEngine;

public class MyStylusTip : MonoBehaviour
{
    [Header("External")] [SerializeField] private Transform m_trackingSpace;

    [Header("Settings")] [SerializeField] private OVRInput.Handedness m_handedness = OVRInput.Handedness.LeftHanded;

    [SerializeField] private GameObject m_model;

    public Action<Vector3, Quaternion, float> OnStylusTouch;

    private OVRInput.Controller m_controller;

    private void Awake()
    {
        m_controller = m_handedness == OVRInput.Handedness.LeftHanded
            ? OVRInput.Controller.LTouch
            : OVRInput.Controller.RTouch;
    }

    private void Update()
    {
        // Update stylus tip position
        Pose T_device = new Pose(OVRInput.GetLocalControllerPosition(m_controller),
            OVRInput.GetLocalControllerRotation(m_controller));
        Pose T_world_device = T_device.GetTransformedBy(m_trackingSpace);
        Pose T_world_stylusTip = GetT_Device_StylusTip(m_controller).GetTransformedBy(T_world_device);
        this.transform.SetPositionAndRotation(T_world_stylusTip.position, T_world_stylusTip.rotation);

        // Get stylus tip data
        float stylusTipForce = OVRInput.Get(OVRInput.Axis1D.PrimaryStylusForce, m_controller);
        bool isStylusTipTouching = stylusTipForce > 0;

        // コントローラ接続状態をペン先の表示に反映
        var isControllerConnected = OVRInput.IsControllerConnected(m_controller);
        m_model.SetActive(isControllerConnected);

        // ペン先が何かに触れていたらコールバック
        if (isStylusTipTouching)
        {
            OnStylusTouch?.Invoke(transform.position, transform.rotation, stylusTipForce);
        }
    }

    private static Pose GetT_Device_StylusTip(OVRInput.Controller controller)
    {
        // @Note: Only the next controller supports the stylus tip, but we compute the
        // transforms for all controllers so we can draw the tip at the correct location.
        Pose T_device_stylusTip = Pose.identity;

        if (controller == OVRInput.Controller.LTouch || controller == OVRInput.Controller.RTouch)
        {
            T_device_stylusTip = new Pose(
                new Vector3(0.0094f, -0.07145f, -0.07565f),
                Quaternion.Euler(35.305f, 50.988f, 37.901f)
            );
        }

        if (controller == OVRInput.Controller.LTouch)
        {
            T_device_stylusTip.position.x *= -1;
            T_device_stylusTip.rotation.y *= -1;
            T_device_stylusTip.rotation.z *= -1;
        }

        return T_device_stylusTip;
    }
}

使用例

最終的なソースコードは単体では動作しませんので、テストシーンを作成して動作する事を確認しましょう。

  1. 新規シーンを作成
  2. Main Cameraを削除
  3. OVRCameraRigを追加
  4. LeftControllerAnchor以下にOVRControllerPrefabを追加し、ControllerをL Touchを設定
  5. 4と同様に右手も設定
  6. StylusTipPfを追加し、Inspectorで以下のように変更
    • 名前 : MyStylusTipPf (Left)
  7. 6で作成したMyStylusTipPf (Left)に、StylusTipコンポーネントをデタッチ
  8. 6で作成したMyStylusTipPf (Left)に、MyStylusTipコンポーネントをアタッチし、Inspectorで以下のように変更
    • Tracking Space : OVRCameraRigの子のTrackingSpace
    • Handedness : Left Handed
    • Model : 子のModel
  9. 6~8と同様に右手も設定
  10. 新規Particle Systemを作成し、Inspectorで以下のように変更
    • Start Speed : 0
    • Simulation Space : World
    • Play On Awake : Off
  11. 新規GameObjectを作成し、Inspectorで以下のように変更
    • 名前 : TrialManager
  12. 10で作成したTrialManagerに、後述するTrialManagerコンポーネントをアタッチし、Inspectorで以下のように変更
    • Left Stylus Tip : 6で作成したMyStylusTipPf (Left)
    • Right Stylus Tip : 9で作成したMyStylusTipPf (Right)
    • Particle System : 10で作成したParticle System
using UnityEngine;

public class TrialManager : MonoBehaviour
{
    [SerializeField] private MyStylusTip leftStylusTip;
    [SerializeField] private MyStylusTip rightStylusTip;
    [SerializeField] private ParticleSystem particleSystem;

    private void OnEnable()
    {
        leftStylusTip.OnStylusTouch += Emit;
        rightStylusTip.OnStylusTouch += Emit;
    }

    private void OnDisable()
    {
        leftStylusTip.OnStylusTouch -= Emit;
        rightStylusTip.OnStylusTouch -= Emit;
    }

    private void Emit(Vector3 position, Quaternion rotation, float force)
    {
        particleSystem.Emit(new ParticleSystem.EmitParams
        {
            position = position,
            startSize = force * 0.01f
        }, 1);
    }
}

実行すると、球の代わりにパーティクルが発生する事が確認出来ました。
これで、自分のプロジェクトに持っていっても使い易くなったのではないでしょうか?

まとめ

Meta Quest 3では非搭載となってしまったスタイラスペン機能ですが、筆圧感知に対応した非常に強力なものでした。
アート系や、コラボレーション系のVRコンテンツでの活用も含め、この強力な機能を使ってユニークなVRコンテンツを開発していただけたら幸いです。

おまけ

また、本記事の執筆中に試したところ、Meta Quest 3とTouch Proコントローラをペアリングして、スタイラスペン機能が動作する事が出来ました。

宣伝

最後に『星の欠片の物語。しかけ版』『星の欠片の物語、ひとかけら版』が各種プラットフォームで発売中です。
もし、ご興味を持って頂けたらポチッとお願いします。
star.anos.jp