Shaderを一切いじらずにPortal表現を行う

あけましておめでとうございます。
でもわたしのクリスマスはまだ来てないので[AR Advent Calendar 2019](https://qiita.com/advent-calendar/2019/ar)に投稿します。

## Portal
[Augmented Reality Portal - ARKIT](https://youtu.be/371ZQW_Yzck)

これです。別の世界を現実の空間に繋げることで現実を拡張する、まさにARならではの表現なので、これを実装できれば表現の幅が大きくなって楽しいはずです。
ですが、ネットで調べる限り、みんな`shader`を理解して変更することでこれを実現しています。

**い…嫌じゃ…`shader`など書きとうない…**

## 環境づくり
`shader`をなにがなんでも書きたくないわたしのような人や、なんらかの事情でPortalの向こうに映す`GameObject`の`shader`をいじれないという人もいるでしょう。
というわけで、今回はそういう人のために`shader`を一切いじらずにPortal表現を実現してみたいと思います。
今回実現するPortalは「タップした場所の壁に穴を開けて、そこから向こう側の世界が覗ける」という一般的なやつです。

完成品はこんな感じ。[^room]
![ezgif-2-2cd789a7b79f.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146245/ca394a3d-4118-662f-feac-6048188d5d6a.gif)

リポジトリはこちら。
[Hole](https://github.com/nekomimi-daimao/Hole)


環境は以下のとおりです。

`Unity 2019.2.17`
`AR Foundation 2.0.2`
`Android 9`

`RenderTexture`と`Layer`と`Culling Musk`を使って実現します。
わかる人ならたぶんこれだけでもうわかるはず。

### RenderTexture

まず`RenderTexture`を新規作成します。
<img width="339" alt="スクリーンショット 2020-01-01 5.28.37.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146245/d42c720b-ba63-030b-c845-10abdb916ba7.png">

わたしはなんとなくsizeを2倍にしましたが、基本的にはなにもパラメータをいじらなくても動作しました。

### Layer
続いてLayerを設定します。
<img width="337" alt="スクリーンショット 2020-01-01 5.32.57.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146245/abda9859-0c79-866d-07e8-b3aaab30f0ec.png">
「Hole」というLayerを設定しました。

### Scene
そしてシーンの編集に入ります。`ARFoundation`の基礎的なコンポーネント作成についてはいろんな人が書いているのでここでは省きます。
<img width="337" alt="スクリーンショット 2020-01-01 5.34.42.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146245/983fb1f4-b3c4-f335-4271-737a076fef27.png">
タップ位置検出のために`ARRaycastManager`を、平面描画のために`ARPlaneManager`を`AR Session Origin`に追加します。

AR描画部分は終わったので次はPortalの中の描画に必要なコンポーネントをSceneに作っていきます。

#### camera
まず穴の中を映すためのHoleCameraを作ります。このCameraはUnityのメニューから新規作成するのではなく、AR Session Originの子オブジェクトになっているAR Cameraを`Copy Component`して、 空オブジェクトに`Paste As New`することで作成しましょう。`FoV`などの数値は`ARFoundation`のCameraと合わせておいたほうが見え方の不自然さが減ります。

そして、今回の要諦である、カメラの`Culling Musk`を設定します。
まずAR Cameraの`Culling Musk`から`Hole`を除外します。
<img width="191" alt="スクリーンショット 2020-01-01 23.47.42.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146245/ddb34bcc-f737-b648-873b-fc9400a7b80a.png">
続いてHoleCameraの`Culling Musk`を`Hole`だけに設定します。
<img width="191" alt="スクリーンショット 2020-01-01 23.48.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146245/1ac9f82d-7319-8a37-904a-21b0981cd6aa.png">

これでAR Cameraには`Hole`レイヤに設定されたもの以外のすべてが映るようになりました。
逆にHoleCameraには`Hole`レイヤに設定されていないものは一切映りません。
*「そこにあるのに映らない」*ものを専用のカメラで映し、専用のカメラの映像を`RenderTexture`に投影することで*「壁の穴から向こうの世界が見える」*ようになったわけです。

#### 仮想空間
続いて穴の中として映す仮想空間。
これはUnity上でだけ動くものなのでもうなんでもいいのですが、自作するのが面倒なのでフリーのAssetのサンプルシーンをそのまま拝借してきました。`git`に上げているものは`gitignore`でこのアセットを除外してあるので、`clone`したらこのAssetをインポートしてください。
[POLYGON - Starter Pack](https://assetstore.unity.com/packages/3d/props/polygon-starter-pack-156819)
<img width="188" alt="スクリーンショット 2020-01-01 5.46.57.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146245/5349a3de-e92d-6c2d-31c2-2ceef6aa9875.png">
配置してあるオブジェクトとライトだけ引っこ抜いて、管理用の空オブジェクトの子オブジェクトにします。
負荷軽減のために`static`にして`light`をbakeしておきます。
そして`Layer`を`Hole`に設定。これで引っこ抜いてきたものはHoleCameraにだけ映るようになりました。

以上でシーンの作成は終わりです。最初は使わないので、穴の中関連の`GameObject`はすべてdisableしておきます。

`Hierarchy`はこんな感じになりました。
<img width="153" alt="スクリーンショット 2020-01-01 23.38.32.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146245/3746945d-2830-4db8-6b94-dc3d9663f473.png">

### script

ひとまずscriptの全文を載せます。

```c#:HolePlaceManager
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;

namespace NekomimiDaimao
{
    public class HolePlaceManager : MonoBehaviour
    {
        [SerializeField]
        private Transform _mainCamera;

        [SerializeField]
        private ARRaycastManager _raycastManager;

        private bool _holePlaced = false;

        public bool HolePlaced
        {
            get => _holePlaced;
            set
            {
                if (_holePlaced == value)
                {
                    return;
                }

                _holePlaced = value;
                _otherWorld.SetActive(value);
                _holeCanvas.gameObject.SetActive(value);
                _holeCamera.gameObject.SetActive(value);
            }
        }

        [SerializeField]
        private GameObject _otherWorld;

        [SerializeField]
        private Transform _holeCamera;

        private readonly Vector3 AppendHeight = Vector3.up * 1.5f;

        [SerializeField]
        private Transform _holeCanvas;

        private readonly List<ARRaycastHit> _hitResults = new List<ARRaycastHit>();

        private void Start()
        {
            HolePlaced = false;
        }

        private void Update()
        {
            if (HolePlaced)
            {
                _holeCamera.SetPositionAndRotation(
                    _mainCamera.position + AppendHeight,
                    _mainCamera.rotation
                );
            }

            PlaceHole();
        }

        private void PlaceHole()
        {
            if (Input.touchCount < 1)
            {
                return;
            }

            var touch = Input.GetTouch(0);
            if (touch.phase != TouchPhase.Began)
            {
                return;
            }

            _hitResults.Clear();
            if (!_raycastManager.Raycast(touch.position, _hitResults, TrackableType.Planes))
            {
                return;
            }

            var p = _hitResults[0].pose;
            _holeCanvas.SetPositionAndRotation(p.position, p.rotation);
            // 壁の場合, transform.upがnormalになる
            var normal = _holeCanvas.up;
            _holeCanvas.forward = normal;
            // eularAngleのxz成分を0にすることで穴の向きを合わせる
            var euler = _holeCanvas.eulerAngles;
            _holeCanvas.eulerAngles = new Vector3(0f, euler.y, 0f);
            HolePlaced = true;
        }
    }
}

```

#### 穴の中向けカメラの位置合わせ

```c#
private readonly Vector3 AppendHeight = Vector3.up * 1.5f;

private void Update()
{
    if (HolePlaced)
    {
        _holeCamera.SetPositionAndRotation(
            _mainCamera.position + AppendHeight,
            _mainCamera.rotation
        );
    }

    PlaceHole();
}
```

`Update`で毎フレームHoleCameraの位置と向きをAR Cameraの位置と向きに合わせています。
基本的にUnityのシーンは地面の中心をワールド座標の原点として作成されている……はずです。
一方、`AR Foundation`のワールド座標系はアプリ起動時の位置を原点とします。そのため、HoleCameraをそっくりそのままAR Cameraと一致させるとHoleCameraが床の中を掘り進んだりしてつらいことになります。

ゆえに、なんらかの補正をかけて現実空間の床の高さと仮想空間の床の高さを一致させる必要があります。

今回はサンプルなのとめんどくさいなので固定値で高さを補正していますが、本来ならばあらかじめAR Cameraの床面からの高さを取得しておき、HoleCameraを有効にした時点での値を常に反映する、などするのがよいと思います。
HoleCameraではなく仮想空間の座標を下げる、という手段もありますが、今回は仮想空間をまるごとぜんぶ`static`にしてしまったのでHoleCameraの方に補正をかけました。あと個人的にカメラ動かすほうがなんか感覚に合ってて好きです。
あと、当然ですが、HoleCameraをAR Cameraの子オブジェクトにして`LocalPosition`でどうにかしようとすると、`Rotation`の影響を受けて座標がぶっ飛びます。

## もろもろのメリットとか注意点とか
### platoformへの依存が少ない   
`Unity`層で話が完結しているため、どのようなARデバイス・フレームワーク上でも動くはずです。`ARCore`や`ARKit`などのスマホARはなんだかんだ練れているので変な目に合うことも少ないのですが、ARグラスはまだまだ開発中の物も多いので……。
### `Lighting`気にしなくていい  
環境光の反映してえなぁ……してぇよなぁ！　なぁ！　ってなると大変な目に合いますが、そうでなければ、Portalに投影しているのは純Unity空間の映像なのでAR要素は気にせず好きに`Lighting`をいじれます。   
### 分業・切り替え
Portalに映す仮想空間を別シーンとして追加ロードすることもできます。これによってPortal内は別の人に作ってもらって、自分はAR部分を作り込むことが可能です。
また、仮想空間のシーンを切り替えてロードすることでPortalの向こうを動的に切り替えることも可能です。`AssetBundle`で後から追加とかも。
### 重いかも
なんとなく重いような気配です。`RenderTexture`が重いんだと思いますが、ARは基本かつかつなので常時表示しているとまずいかもしれません。

## まとめ
`ARFoundation`部分はあえて解説を飛ばして、Portal部分に絞りました。
**`shader`書きたくない**という一念で試してみたらさっくりできたのでよかったです。
ネットで調べても同じことをしているらしき人がいません。初心者だとそもそもPortal知らないし、知ってる人は`shader`さくっと書けるのでそっちでやっちゃうんでしょうか。なぞ。

Portalするだけならノンコーディングで実現できるので、AR教材のチュートリアルに組み込んだりすると見た目が楽しいなのでいいかもしれません。

おしまい。


## リポジトリ
[Hole](https://github.com/nekomimi-daimao/Hole)

## 参考
[レンダーテクスチャを使ってカメラに映る映像をリアルタイムに描写する](https://gametukurikata.com/camera/rendertexture)

[Unityで認識した平面をポータル化して異世界を覗く【ARFoundation編】](https://qiita.com/ippo/items/d9c046bb365e2666ffdc)


[^room]: 部屋見えちゃうので左右は勘弁しておくんなさい。あとなんでかカーテンしか認識してくれなくて、肝心の壁にはPortalがうまく貼れませんでした。