現実とVRを繋ぐポータルを作ってみた [PICO4] [MRTK3]

# はじめに

こんにちは。
ジョジョ好きなXRクリエイターの [もふるね](https://twitter.com/mofurune_k) です。

2023年が終わりそうなので、そろそろ「ポータルを通って現実世界とVR世界を行き来する」くらいできないといけない。
ので、作ります。


## 結果

<iframe width="600" height="315" src="https://www.youtube.com/embed/LSL9wJ1RdNM?si=63ICy0VW8uzBSDkM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

<font color="Red">見た感じ、どこでもドアっぽい</font>


## やりたいこと

- ポータルを通る前と後で「現実」と「VR」を切り替える
- 現実にいるとき、ポータルの向こう側にVR空間が見えて欲しい

"*Valveの開発したゲーム「[Portal](https://store.steampowered.com/app/400/Portal/?l=japanese)」を想像してみて。わかるかな？*"

<img width="500" alt="portal表現04.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3518924/717ce7bd-40fb-d5c9-c7fb-9242a468ef6e.jpeg">


## 前置き

この記事は、吉永さんの記事「[MRTKではじめるPortal表現](https://www.docswell.com/s/Tks_Yoshinaga/KER6G5-mrtk-portal)」を元に、PICO4に対応したポータル表現を行う試みです。
私はざっくりと解説をするので、詳しく知りたい方は吉永さんの記事を御覧ください。
PICO4に対応するにあたって、吉永さんの記事ではMRTK2であったものをMRTK3にしています。

また、MRTKはPICO4に正式対応していないため、自力で対応させる方法を解説している下部の記事も合わせて御覧ください。

https://qiita.com/mofurune/items/d12f7ae87b3fb482a861

開発環境
・Unity 2022.3.9f1
・PICO Unity Integration SDK v2.3.0
・MRTK3-18
使用機材
・PICO 4


# ポータルの向こうの景色を描画する

「現実にいるとき、ポータルの向こう側にVR空間が見えて欲しい」
そんな願いを実現してくれるナイスな手法、それが「**ステンシルバッファ**」

見る必要のない場所は描画しなければいいじゃないといった手法です。

<img width="600" alt="por01.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3518924/d1c5cf21-05fb-8a6a-7d30-e9c2e7c851be.jpeg">

<font color="Red">全体画像（左）とマスク画像（真ん中）を組み合わせるとあら不思議、向こう側からタレットちゃんが覗き込んでるように見えちゃう！</font>

「VR空間にいるとき、ポータルの向こう側に現実が見えて欲しい」場合は、マスク画像の白黒を逆にしちゃえば良いよ。


# Unityで実装

仕組みがわかったところで実装に入っていきます。
吉永さんの記事「[MRTKではじめるPortal表現](https://www.docswell.com/s/Tks_Yoshinaga/KER6G5-mrtk-portal)」を元に進めていきます。
PICO4対応にするために独自の改変を行った部分を主に書いていきます。


### VR空間のセットアップ

VR空間に無料アセットの[RPG Poly Pack - Lite](https://assetstore.unity.com/packages/3d/environments/landscapes/rpg-poly-pack-lite-148410?locale=ja-JP)を使用します。
ですが、[吉永さんのPortal表現のリポジトリ](https://github.com/TakashiYoshinaga/Portal-Expression-Sample-for-Unity)内にはすでにワールドの配置を済ませたプレファブ"World"があるため、私はそこからDLしました。
- 新しいシーンを作り、EventSystem, MRTK XR Rig, MRTKInputSimulatorをヒエラルキーに配置する
- ワールドの配置を済ませたプレファブ"World"をヒエラルキーに配置する
- 3DオブジェクトのQuadをヒエラルキーに追加し、名前を"PortalGate"にする
- Main Cameraを消す
- MRTK XR RigのMain Cameraのfarを1000にする


### ステンシルバッファ

MRTKのStanderd Shaderにはステンシル機能が付いています。
これを使うと、「現実にいるとき、ポータルの向こう側にVR空間が見えて欲しい」を実現することができちゃうんですね～
MRTK2ではStanderd Shaderが標準搭載していたのですが、MRTK3からは別途DLする必要があるようです。
- Package ManagerからMRTK Graphics ToolsのMaterial GalleryをDLする
- "PortalGate"のマテリアルを作成する。名前を"Portal Material"とする。Shaderを`Graphics Tools/Standerd`にして、Rendering Modeを`Fade`に、Call Modeを`Off`に、AlbedoのAlpha値を`0`（透明）に、Render Queue Overrideを`1999`に、Enable Stencil Testiongを`オン`に、Stencil Comparisonを`Always`に、Stencil Operationを`Replace`に、Stencil Referenceを`1`にする。
- ワールドのマテリアル３つを`Graphics Tools/Standerd`にする。Stencil Referenceを`1`に、Stencil Comparisonを`Equal`にする。


### 現実とVRの切り替え

MRTKのStanderd Shaderが変わったことで、コードも変える必要があります。
このコードで、VRと現実の切り替えが行われます。
- PortalManager.csの7行目を`using Microsoft.MixedReality.GraphicsTools;`に、46行目のclippingPlane.enableを`clippingPlane.enabled`にする。

<details><summary>コードPortalManager</summary>

```cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
//ステンシルの比較条件を操作する際に必要
using UnityEngine.Rendering;
//ClippingPlaneをこのスクリプトで操作するためのMRTKの関連機能を読み込み
using Microsoft.MixedReality.GraphicsTools;
public class PortalManager : MonoBehaviour
{
    //ClippingPlaneスクリプト
    [SerializeField] ClippingPlane clippingPlane;
    //移動先の世界の3Dモデルをまとめたオブジェクト
    [SerializeField] GameObject worldObject;
    //上記オブジェクトのマテリアル（描画設定ファイル）を保持するために使用
    List<Material> worldMaterials = new List<Material>();
    //現在の表示モード
    bool isARMode = true;
    //ゲートに表と裏どちらから入るか
    float enteringSide;
    void Start()
    {
        //移動先の3DモデルのRendererを取得
        Renderer[] renderers = worldObject.GetComponentsInChildren<Renderer>();
        foreach(Renderer renderer in renderers)
        {
            //clippingPlaneにWorld内のオブジェクトを登録（必ず先に実行すること）
            clippingPlane.AddRenderer(renderer);
            //マテリアルを取得
            Material material = renderer.sharedMaterial;
            //すでに他のオブジェクトから取得したマテリアルでなければリストに追加
            if(!worldMaterials.Contains(material)) {
                worldMaterials.Add(material);
            }
        }
        //デフォルトはクリッピングをオフ
        clippingPlane.enabled = false;
    }
    void Update()
    {
        
    }
    void OnTriggerEnter(Collider other) {
        Debug.Log("Portal Entered");
        //Alwaysを指定して常に表示
        SetStencilComparison(CompareFunction.Always);
        //ゲートに接触したらクリッピング処理をオン
        clippingPlane.enabled = true;
        //カメラの座標をゲートを原点にしたローカル座標に変換
        Vector3 localPos = transform.InverseTransformPoint(Camera.main.transform.position);
        //表か裏かを+-で表現
        enteringSide = Mathf.Sign(localPos.z);
        //条件に応じたクリッピング設定
        if((isARMode && enteringSide < 0) || (!isARMode && enteringSide > 0)) {
            clippingPlane.ClippingSide = ClippingPrimitive.Side.Outside;
        }
        else {
            clippingPlane.ClippingSide = ClippingPrimitive.Side.Inside;
        }
        //Clipping情報更新のフラグをOn
        clippingPlane.IsDirty = true;
    }
    void OnTriggerExit(Collider other) {
        Debug.Log("Portal Exited");
        //カメラの座標をゲートを原点にしたローカル座標に変換
        Vector3 localPos = transform.InverseTransformPoint(Camera.main.transform.position);
        //表か裏かを+-で表現
        float exitingSide = Mathf.Sign(localPos.z);
        if(isARMode) {//現在ARモード：VRモードにしてゲートの外側のみの表示
            if(exitingSide != enteringSide) {//入った方向と逆から出たならVRモードに切り替え
                SetStencilComparison(CompareFunction.NotEqual);
                isARMode = false;
            }
            else {
                SetStencilComparison(CompareFunction.Equal);
            }
        }
        else {//現在VRのモード：ARモードにしてゲートの内側のみを表示
            if(exitingSide != enteringSide) {//入った方向と逆から出たならARモードに切り替え
                SetStencilComparison(CompareFunction.Equal);
                isARMode = true;
            }
            else {
                SetStencilComparison(CompareFunction.NotEqual);
            }
        }
        //ゲートから離れたらクリッピング処理をオフ
        clippingPlane.enabled = false;
    }
    //アプリ終了時にEditor内の表示をARをモードに戻しておく
    void OnDestroy() {
        SetStencilComparison(CompareFunction.Equal);
    }
    //引数で受け取った設定でステンシル設定を変更
    void SetStencilComparison(CompareFunction mode) {
        //マテリアルを１つずつ取得してステンシル処理の比較条件を変更
        foreach(Material material in worldMaterials) {
            material.SetInt("_StencilComparison",(int)mode);
        }
    }
}
```
</details>

### カラーパススルー

カラーパススルーをしたいので、その設定を行います。
「[【Unity】PICO4 VRアプリ開発 パススルーアプリを作る](https://qiita.com/afjk/items/5392349e5f13271ef25b)」を参考にしています。
- MRTK XR Rig > Camera Offset > Main CameraのCamera Setting ManagerのOpaque DisplayのClear Modeを`Solid Color`に、Clear ColorのAlpha値を`0`にする
- ヒエラルキーに空のオブジェクトを追加し、Add Componentで新しいスクリプト"PassThroughSetter"を追加する。

<details><summary>コードPassThroughSetter</summary>

```cs
using Unity.XR.PXR;
using UnityEngine;

public class PassThroughSetter : MonoBehaviour
{
    void Start()
    {
        // シースルー設定
        PXR_Boundary.EnableSeeThroughManual(true);   
    }
    
    private void OnApplicationPause(bool pauseStatus)
    {
        // Pauseからの復帰時、再度シースルーにする
        if (!pauseStatus)
        {
            PXR_Boundary.EnableSeeThroughManual(true);
        }
    }
}
```
</details>


### バグ修正

これでやりたいことはできるようになったのですが、ポータルを視界から外すと、視界いっぱいにVR空間"World"が見えてしまうというバグが出てきました。
ここで心の中のジョースター卿が囁きます。
『逆に考えるんだ、「ポータルが常に視界内にあるようにしちゃったらいいさ」と考えるんだ。』
つまり、「Main Cameraの子に物凄い小さいポータルを追加して目の前方辺りに設置する」と解決です。~~脳筋的解法~~


# 振り返り

これをXRKaigi2023に展示したところ結構好評だった。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">XR Kaigi Award 2023 アクティビティ部門 最優秀賞を受賞した福岡XR部様の様々なコンテンツを体験できるブースは 4-318 です。<br>ぜひお立ち寄りください！<a href="https://twitter.com/hashtag/XRKaigi?src=hash&amp;ref_src=twsrc%5Etfw">#XRKaigi</a> <a href="https://t.co/vKgZWuZp4m">pic.twitter.com/vKgZWuZp4m</a></p>&mdash; XR Kaigi 2023 (@XRKaigi) <a href="https://twitter.com/XRKaigi/status/1738012662573711761?ref_src=twsrc%5Etfw">December 22, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>