C#
Unity
VR
UnityDay 6

Unityで鏡を実装する方法

はじめに

この記事はUnity Advent Calendar 2018の5日目の記事です。
昨日は@Kan_KikuchiさんによるUnityで作ったアプリのサイズを減らす20の方法【Unity】【容量削減】でした。

Dec-06-2018 23-55-09 (1).gif

さて、Unityで鏡を使いたい場合は、皆さんはどうしますか?

1番シンプルなのは、面の奥側に手前側を写す鏡を置き、レンダーテクスチャを左右反転させて表示する方法です。ただし、この実装はカメラの位置などを考慮していないため、見え方としてはディスプレイに映ったウェブカメラの映像を見ているような感覚に近いです。

もしくはアセットを使う方法です。Vive Stereo Rendering Toolkitは両眼に対応した鏡アセットで、かなり正確に鏡としての振る舞いが再現されています。

ただし、上記のVive Stereo Rendering ToolkitはOculusプラットフォームではうまく表示されないという問題がありました。
そこで、今回は自前で鏡を実装する方法を紹介していこうと思います。

ソースコードは以下にアップしてあるので、参考にしてください。MITライセンスです。
両眼対応はしていませんが、OculusGo/OculusRiftで使用できることは確認済みです。

nkjzm/Mirror
https://github.com/nkjzm/Mirror

流れ

  1. 鏡面用カメラの準備
  2. 鏡面の描画
  3. 鏡の枠の設定

大まかな仕組みとしては、反射をRenderTextureを使って擬似的に再現してメインカメラに向けて描画、それを3D空間においてある鏡の形に切り取るという流れです。以降の章で詳しく説明していきます。

鏡面用カメラの準備

鏡面に描画するための映像を準備します。

鏡が反射する仕組みとして、鏡面で光が面対称に反射して目(今回はカメラ)に入ってくるという流れがあります。そのため、鏡面からカメラに反射する映像を、もう一つのカメラ(反射用カメラとする)を使って再現してみるところから始めて行きたいと思います。

無題のプレゼンテーション.png

鏡面からのカメラに反射する映像は、上図のように、鏡面と面対象なカメラの位置から見た映像で再現できます。まずは、カメラと面対象な位置を計算してみましょう。

// カメラから鏡面へのベクトル
var diff = transform.position - TrackingCamera.transform.position;
// 鏡面の垂直ベクトル
var normal = transform.forward;
// 鏡面からの反射ベクトル
var reflection = diff + 2 * (Vector3.Dot(-diff, normal)) * normal;
// 鏡面座標に反転させた反射ベクトルを加算する
ReflectionCamera.transform.position = transform.position - reflection;

カメラの鏡面ベクトルと、鏡面からカメラへの垂直方向への高さを使って反射ベクトルを求めています。図にすると以下の通りです。

無題のプレゼンテーション (2).png

最後に鏡の中心座標から反射ベクトルを引くと、反射用カメラの位置が計算できました。

次に反射用カメラの向きですが、これはUnityの便利関数LookAtを使って、鏡の中心座標に向けて上げるだけで完了です。

// 鏡面の方向に向ける
ReflectionCamera.transform.LookAt(Specular.position);

最後にnearClipPlaneの設定をします。

Dec-06-2018 22-04-51.gif

これは、描画対象の視錐台の最小距離を設定するための変数です。擬似的な描画をするために鏡の裏側に反射用カメラを置いているので、反射用カメラと鏡の間のオブジェクトによって遮蔽されないための設定です。

// カメラ設定の更新
var distance = Vector3.Distance(transform.position, ReflectionCamera.transform.position);
ReflectionCamera.nearClipPlane = distance* 0.9f;

今回は大まかな設定をしています。鏡の中心座標から反射用カメラまでの距離を計算し、0.9を掛けて最小距離としています。理想的には鏡面に並行に最小距離を置きたいところですが、視錐台を用いる以上、鏡面に対して垂直な場合以外では合わせることができないため、少し余裕を持った値にしています。

鏡面の描画

続いて、用意した反射用の映像を鏡面に反映させていきます。

ここではRenderTextureを用います。

スクリーンショット 2018-12-06 22.13.37.png

RenderTextureのサイズは任意ですが、パフォーマンスに影響するので必要最小限にするのが好ましいです。

スクリーンショット 2018-12-06 22.12.45.png

作成したRenderTextureを反射用カメラのTargetTextureに設定しました。

これを3D ObjectのQuadに表示させた結果がこちらです。(Scaleを(-1,1,0)にしています)

スクリーンショット 2018-12-06 22.20.24.png

オブジェクトが反転して映っているのは良いですが、スケール感がおかしいですね。
なぜかと言うと、焦点距離に対して画角が適切でないからです。

画角(rad)を求めるための近似式がこちらです。

スクリーンショット 2018-12-06 22.32.04.png
参考: 焦点距離と実視野の理解

実視野には鏡面としているQuadのサイズを、焦点距離には先程求めたdistanceを代入して求めた結果を反射用カメラに適用していきます。なお、単位はUnity座標系のものを使っています(分母と分子で一致していれば問題ない)

// 焦点距離と表示したい鏡面サイズから画角(FOV)を計算する
ReflectionCamera.fieldOfView = 2 * Mathf.Atan(Size / (2 * distance)) * Mathf.Rad2Deg;

fieldOfViewに代入する際、Mathf.Rad2Degを使ってラジアンから度数法に変換しているところに注意してください。

スクリーンショット 2018-12-06 22.38.19.png

適切な大きさで表示されるようになりました。

これで解決したかと思いきやですが、もう一つ課題があります。

Dec-06-2018 22-40-28.gif

鏡と右のキューブは平行に置かれているのに、視点を変えると相対的な角度が変化しています。これは正しい鏡の挙動ではないです。

どうしてこうなってしまうのか、鬼分かりやすい図を作りました。

無題のプレゼンテーション (4).png

本来カメラに対して平行に入ってくる映像は、図の上のような角度でカメラに移ります。
しかし現状では図の下のように、反射する映像を鏡面に並行に描画しています。つまり、②の段階で差が生まれてしまっているのです。②は、カメラが垂直方向に近ければどちらも鏡に対して平行な角度なので問題になりませんが、垂直な位置から離れるほど差異が大きくなってしまします。

解決方法としては、②の角度を本来のようにカメラに対して常に垂直に設定してあげます。

// 鏡面をカメラ方向に向ける
Specular.rotation = Quaternion.LookRotation(Specular.position - TrackingCamera.transform.position);

Dec-06-2018 23-00-39.gif

カメラを動かすたびに鏡の角度が変わってしまっていますが、反射の見え方としては正しいふるまいになりました。

鏡の枠の設定

動かすたびに鏡の角度が変わってしまう問題を解決していきます。
アプローチとしては、角度が変わってしまうのはどうしようもないので、変わってない風の見せ方にしていきます。

角度が変わって見える大きな要因はQuadの境界線が動いている点なので、そこを誤魔化します。本来鏡の場所に枠があるはずなので、その領域のみ鏡が表示されるようにします。
凹みさんの窓シェーダーを使って、窓領域の奥だけ描画するようにします。
参考: HoloLens で向こう側が見える窓を動的に追加してみる - 凹みTips

Dec-06-2018 23-16-16.gif
分かりやすくするため、鏡の奥に同じシェーダーで黒い板ポリを置いています

角度が変わっている感がほとんどなくなりました。もう一息です。
気になるのは、角度が変わった事によって本来鏡がある領域に隙間(gifでいう黒いエリア)が出来てしまっているところです。

角度によって現れるように見えるので、補正を掛けていきましょう。

// フレームのサイズを更新
Frame.localScale = new Vector3(Size, Size, 1);
// 鏡面のサイズを調整
var angle = Vector3.Angle(-transform.forward, ReflectionCamera.transform.forward);
var specularSize = Size + Mathf.Sin(angle * Mathf.Deg2Rad);
Specular.localScale = new Vector3(-specularSize, specularSize, 1);

フレームのサイズを基準にし、垂直方向からずれた角度θの分だけサイズを+Sin(θ)しています。

Dec-06-2018 23-25-34.gif

角度を変えても黒枠が目立たないようになりました!
(FOVが小さくなると目立つことを確認しているので、各自+する値は調整してください)

(おまけ) エディタ上で扱いやすくする工夫

お気づきの方がいるかもしれませんが、記事中のgifはSceneビュー上で撮影をしています。
今回の鏡を実装するに辺り、Editor上での確認をしやすいように工夫をしたので、その方法も紹介します。

シーン上のカメラを取得する

[SerializeField]
bool EnabledTargetCamera = false;

Camera TrackingCamera
{
    get
    {
        if (!EnabledTargetCamera)
        {
#if UNITY_EDITOR
            return SceneView.lastActiveSceneView ? SceneView.lastActiveSceneView.camera : null;
#endif
        }
        return TargetCamera;
    }
}

EnabledTargetCameraの値によって、Sceneビュー上のカメラを対象のカメラに設定できるようにしました。鏡のデバッグ中はSceneビューの方が視点の切り替えが楽なので、非常に役立ちました。
理想的にはSceneビューがアクティブな場合に自動的に切り替わるなどしたかったです。

エディタ上でも鏡の描画を更新する

void OnEnable()
{
#if UNITY_EDITOR
    EditorApplication.update += UpdateMirror;
#endif
}
void OnDisable()
{
#if UNITY_EDITOR
    EditorApplication.update -= UpdateMirror;
#endif
}
void Update()
{
#if !UNITY_EDITOR
    UpdateMirror();
#endif
}

今回紹介した処理はUpdateMirror()という関数内の処理だったのですが、Editor上でも実行されるようにEditorApplication.updateというイベントに追加するようにしていました。実行中はUpdate()で呼ぶようにしています。

また、UpdateMirror()の中でSceneビューに変更を加えても即時反映されない現象があったのですが、以下のメソッドで解決できました。

#if UNITY_EDITOR
// シーンビュー更新
SceneView.RepaintAll();
#endif

まとめ

鏡を実装することで、数学的な知識から、レンズの知識、Unityエディタの知識などを得ることが出来ました。
厳密なアプローチばかりではありませんでしたが、誰かの参考になると幸いです。

今回紹介した鏡の完成プロジェクトです(再掲)
nkjzm/Mirror
https://github.com/nkjzm/Mirror

明日のUnity Advent Calendar 2018@mao_さんによる「PureECSのアニメーションについて纏め直してみる予定です」です。

追記

参考