Meta QuestのMRモードで掃除した場所を可視化する方法

# はじめに
クイックルワイパーで部屋を掃除する際、どこが既にきれい/汚いのか分からなくて効率が悪いなと普段から思っていました。
そこで、Meta QuestのMRモードで掃除した場所を可視化するアプリを作ってみました。

# 動作の様子
Twitterに上げたらバズったので思ったよりみんなやってみたいんだなーと思いました

https://x.com/noritsunetsune/status/1843299987276628255

# 使い方
1. コントローラーをクイックルワイパーの軸に固定する
    - 今回は養生テープを使いました
1. アプリを起動する
1. クイックルワイパーの仮想ヘッドに現実ヘッドを合わせる
1. コントローラーのメニューボタンを押す
    - これによって仮想ヘッドが現実ヘッドに追従するようになります
1. クイックルワイパーをかける

# 想定読者
UnityでのQuestのアプリを作ってみたい or 軽く作ったことがある方

# 先行事例
## QuestのMRモードで掃除機をかけた場所を可視化
https://x.com/pushmatrix/status/1749797146961006716

https://x.com/pushmatrix/status/1753055948459946193

## iPhoneのAR機能で掃除機を書けた場所を可視化
https://magazine.cainz.com/article/96638

<iframe width="560" height="315" src="https://www.youtube.com/embed/V6hdZLrK8SU?si=WBc9pTsbhj6QK52B" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

<iframe width="560" height="315" src="https://www.youtube.com/embed/IEu-ZxohY1E?si=y8gGcFZqy4P4hA5d" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

# 作り方
## プレーンなQuestのMRアプリを作る
以下の手順は細かいところを端折った大雑把なものです。
詳細を知りたい方は[こちらのページ](https://tech.framesynthesis.co.jp/unity/metaquest/)を見ることをおすすめします。
逆に細けえことは良いから楽をしたい方は[こちらの記事](https://qiita.com/noritsune/items/8b1ef9e99e668916e202)のテンプレートプロジェクトを複製するといいです。

1. Unityの3Dの空プロジェクトを立ち上げる
1. [Meta XR Interaction SDK](https://assetstore.unity.com/packages/tools/integration/meta-xr-interaction-sdk-265014?srsltid=AfmBOoppwROlf-75ekzJ1NN8RyQbxStNXJvg-quSIqRMBDO5QfLgD0iR)をインポートする
1. 画面上部のメニューのMeta → ツール → Building Blocksを開く
1. Camera rigとPassthroughを追加する
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/33a5d04b-d6d1-0c09-1c5b-de6b48f49ae6.png)

## クイックルワイパーの仮想ヘッドで軌跡を描けるようにする
1. 半透明でそれっぽい見た目の豆腐を作る
    - これと現実のクイックルワイパーのヘッドを揃えることで位置合わせをする
    - 幅はクイックルワイパーの幅に合わせて0.25mとする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/de2d20d1-143b-4181-82e8-bca3d108a14a.png)
1. 子オブジェクトを作ってTrailRendererをアタッチする
    - X軸で90度回転させて、更にTrailRendererの整列をトランスフォームZにすることで常に水平な軌跡になる
    - 幅を0.25にしたり、生存時間を十分長くしたり、半透明マテリアルにしたりする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/d1a7ba34-43e6-9b3c-e3a0-aef8f5609011.png)

## クイックルワイパーの現実ヘッドに仮想ヘッドが追従するようにする
1. 仮想クリーナーヘッドのルートオブジェクトにParentConstraintコンポーネントをアタッチする
    - `CameraRig/TrackingSpace/RightHandAnchor`に追従するようにする
    - Y軸の位置及びX軸とZ軸は追従しないようにする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/b56dd0f0-8c93-53ec-ed76-435778aace50.png)
1. 以下のスクリプトをアタッチする
    - 追従開始時に位置と向きのオフセットを再計算しているのが肝です。これがないと右コントローラーと仮想ヘッドオブジェクトの相対的な位置と向きが正しくなりません。
    - 
```CleanerHead.cs
using UnityEngine;
using UnityEngine.Animations;

public class CleanerHead : MonoBehaviour
{
    [SerializeField] TrailRenderer _trailRenderer;
    [SerializeField] ParentConstraint _parentConstraint;

    bool _isOperating;

    void Awake()
    {
        StopOperation();
    }

    void Update()
    {
        // デバッグ用にキーボードでも操作できるようにする
        if (OVRInput.GetDown(OVRInput.Button.Start) || Input.GetKeyDown(KeyCode.Space))
        {
            ToggleOperation();
        }
    }

    void ToggleOperation()
    {
        if (_isOperating)
        {
            StopOperation();
        }
        else
        {
            StartOperation();
        }
    }

    void StartOperation()
    {
        _isOperating = true;
        _trailRenderer.emitting = true;
        _parentConstraint.constraintActive = true;

        // 位置と向きのオフセットを再計算
        var targetTrans = _parentConstraint.GetSource(0).sourceTransform;
        var posOffset = targetTrans.InverseTransformPoint(transform.position);
        var rotOffset = Quaternion.Inverse(targetTrans.rotation) * transform.rotation;
        _parentConstraint.SetTranslationOffset(0, posOffset);
        _parentConstraint.SetRotationOffset(0, rotOffset.eulerAngles);
    }

    void StopOperation()
    {
        _isOperating = false;
        _trailRenderer.emitting = false;
        _parentConstraint.constraintActive = false;
    }
}
```

仮想ヘッドオブジェクトは最終的に以下の様になります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/1c2dc829-d8b7-2bf5-80b1-3bfb46e72b7f.png)


# 今後の課題
- ハンドトラッキングでやりたい
  - 試したが、精度が低くてだめだった
  - コントローラーをクイックルワイパーに固定する準備作業がめんどくさすぎる
- 軌跡の描画が汚い
  - 重なっている部分も1枚分だけ描画としたい
  - 折り返し部分が角々している

# おわりに
MRアプリ開発は楽しいです！現実を拡張して便利にしていきましょう。