[Unity] HTC ViveでPortal的なものを作る

  • 17
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

概要

今モックを作ろうとしていて、それで必要な「ポータル」の表現をやろうと試行錯誤していました。
最初は RenderTexture 使えば楽勝っしょ? って思いながら作っていたら、VRの場合は視差があるため、それを考慮した実装が必要な点でハマりました。

いくつかの記事とプロジェクトを参考になんとか形になったのでメモしたいと思います。

ちなみに今回メモした内容のプロジェクトはGitHubにアップしてあります。(Unity5.4で動作確認してます)

ただ、**まだ違和感が残る実装になっているので、その他の実装方法も含めて現在さらに改良を加えようと思っています。**

[2016.09.13 追記] 違和感があったのは単純に自分のミスでした; 両目用にキャプチャを行う際に、オフセットを計算してカメラ位置を補正していたんですが、このオフセットの処理で、せっかく同期した位置情報を上書きしていたのが原因でした;

とはいえ、カメラの情報取得方法などいくつか学びがあったのでメモを残しておきます。

ポータルとは

元はゲームなのかな?
公式サイトから画像を引用させてもらうとこんな感じで、空間に空いた穴から別の空間が見える、みたいなやつです。

Portal-2-Screenshot-01_656x369.jpg

今回作ったやつはこんな感じのものです↓
PortalSample

おおまかな流れ

おおまかな流れは以下のようになります。

  1. ポータルの先のシーンを用意する
  2. それを専用のカメラで撮影し、 RenderTexture にレンダリングする
  3. レンダリングする際、VR特有の視差を生み出すために視差分、都合2回キャプチャを行う
  4. VRのカメラとポータルとの相対位置を、専用カメラにも伝えてカメラ位置・回転の同期を行う
  5. キャプチャしたテクスチャを利用したマテリアルをPlaneに設定する

という感じになります。
肝は視差をどうやってつけるか、それをどうレンダリングするか、という点でしょう。
次に、それらを順を追って説明していきます。

ポータルの先のシーンを用意

これは特にむずかしいところはありません。VRのカメラで撮影しているところから十分に離れた位置にオブジェクトを配置してレンダリングを行えるようにするだけです。

↓こんな感じ
20160911-022016.png

今回のサンプルでは、このオブジェクトが乱立した感じの部分を、離れたところにあるVRカメラから覗き見れる感じのものを作成します。

RenderTextureにキャプチャする

さて、シーンが用意できたらそれを撮影し、その結果を RenderTexture に書き出します。
ただ流れのところでも触れたように、視差を意識したキャプチャを行わないとなりません。
具体的には以下のようになります。

fields
// 設定されているnear/far clipの値などを利用します
public Camera VrEye;

// 左右の目の結果をそれぞれ個別に保持する
RenderTexture _leftEyeRenderTexture;
RenderTexture _rightEyeRenderTexture;

// ポータル用のカメラ
Camera _cameraForPortal;

// 左右の目の視差のオフセット
Vector3 _eyeOffset;
setup
void Awake() {
    // Cameraコンポーネントを取得
    _cameraForPortal = GetComponent<Camera>();
    _cameraForPortal.enabled = false;

    // 左右の目用の `RenderTexture` を生成する
    _leftEyeRenderTexture = new RenderTexture((int)SteamVR.instance.sceneWidth, (int)SteamVR.instance.sceneHeight, 24);
    _rightEyeRenderTexture = new RenderTexture((int)SteamVR.instance.sceneWidth, (int)SteamVR.instance.sceneHeight, 24);

    // アンチエイリアスの設定
    int aa = QualitySettings.antiAliasing == 0 ? 1 : QualitySettings.antiAliasing;
    _leftEyeRenderTexture.antiAliasing = aa;
    _rightEyeRenderTexture.antiAliasing = aa;
}

[2016.09.13 追記]
Viveで見た時にカメラのRotationはうまく行くのに、前後左右がうまくいかないなーと思っていたんですが、なんとう凡ミス、キャプチャをする際に localPosition の値を上書きしていたのが原因でした・・。(そら移動しないわ)
ので、とりあえずサンプルではコメントアウトしてあります。(TODOって書いてあるところです)

capture
// 左右の目の視差結果をマテリアルに反映する
public void RenderIntoMaterial(Material material)
{
    // ----------------------------------------
    // Left eye.

    // 左目のオフセットを取得
    _eyeOffset = SteamVR.instance.eyes[0].pos;
    _eyeOffset.z = 0f;

    // オフセットを反映
    // TODO: ここでせっかく同期した位置をキャンセルしてしまっていた
    // transform.localPosition = _eyeOffset;

    // 左目のプロジェクションマトリクスを取得し、Unityのマトリクスに変換してカメラに設定する
    Valve.VR.HmdMatrix44_t leftMatrix = SteamVR.instance.hmd.GetProjectionMatrix(Valve.VR.EVREye.Eye_Left, VrEye.nearClipPlane, VrEye.farClipPlane, Valve.VR.EGraphicsAPIConvention.API_DirectX);
    _cameraForPortal.projectionMatrix = HMDMatrix4x4ToMatrix4x4(leftMatrix);

    // RenderTextureを割り当てる
    _cameraForPortal.targetTexture = _leftEyeRenderTexture;

    // 割り当てたテクスチャに書き込み
    _cameraForPortal.Render();

    // マテリアルに設定
    material.SetTexture("_LeftEyeTexture", _leftEyeRenderTexture);

    // ----------------------------------------
    // Right eye.

    // 左目と同じことを右目にも行う
    _eyeOffset = SteamVR.instance.eyes[0].pos;
    _eyeOffset.z = 0f;
    // TODO: ここでせっかく同期した位置をキャンセルしてしまっていた
    // transform.localPosition = _eyeOffset;

    Valve.VR.HmdMatrix44_t rightMatrix = SteamVR.instance.hmd.GetProjectionMatrix(Valve.VR.EVREye.Eye_Right, VrEye.nearClipPlane, VrEye.farClipPlane, Valve.VR.EGraphicsAPIConvention.API_DirectX);
    _cameraForPortal.projectionMatrix = HMDMatrix4x4ToMatrix4x4(rightMatrix);
    _cameraForPortal.targetTexture = _rightEyeRenderTexture;
    _cameraForPortal.Render();
    material.SetTexture("_RightEyeTexture", _rightEyeRenderTexture);
}
convert-matrix
// HMDのカメラのマトリクスを、Unityのマトリクスに変換する
// (実際はただのコピー)
protected Matrix4x4 HMDMatrix4x4ToMatrix4x4(Valve.VR.HmdMatrix44_t input)
{
    var m = Matrix4x4.identity;

    m[0, 0] = input.m0;
    m[0, 1] = input.m1;
    m[0, 2] = input.m2;
    m[0, 3] = input.m3;

    m[1, 0] = input.m4;
    m[1, 1] = input.m5;
    m[1, 2] = input.m6;
    m[1, 3] = input.m7;

    m[2, 0] = input.m8;
    m[2, 1] = input.m9;
    m[2, 2] = input.m10;
    m[2, 3] = input.m11;

    m[3, 0] = input.m12;
    m[3, 1] = input.m13;
    m[3, 2] = input.m14;
    m[3, 3] = input.m15;

    return m;
}

SteamVR.instance.eyes[0].pos を使うことで、左右の目のオフセットを取得することができるので、これを、ポータル先の撮影用カメラのローカル位置座標として設定します。
つまり、左目から見た映像を取得する、ということですね。

これを左右ともに行うことで、視差効果を持ったレンダーテクスチャが得られます。

カメラ位置の同期

さて、キャプチャ自体は上記の方法でOKなのですが、HMDをつけている人は常に色々なところを見ています。
つまり「位置」と「回転」が常に行われていることになります。
これを、ポータル先を撮影しているカメラに同期してやらないとなりません。
そうしないと、ポータル先の映像が常に固定になってしまって、まるで鏡がそこにあるかのような見栄えになってしまいます。

void Update () {
    _portalIndex = 0;

    foreach(var camera in RenderTextureCameras) {
        camera.transform.localPosition = transform.position - Portals[_portalIndex].transform.position;
        camera.transform.localRotation = transform.localRotation;
        _portalIndex++;
    }
}

やっていることはむずかしくありません。
要はポータル先のカメラの位置と回転を、自身の Transform を使って同期しているだけです。

ただ注意しないとならないのは、カメラ自身の位置とポータルとの相対位置を計算して設定している点です。
こうすることで、ポータルに近づく=ポータル先のカメラが動いて、あたかもPlaneの先の空間がつながっているように見える、というわけです。

マテリアルにテクスチャを設定

さて、上記まででカメラの位置の同期と、ポータル先のカメラの映像の取得が終わりました。
あとはこれをマテリアルに設定してレンダリングしてやる必要があります。

設定自体はとてもシンプルで、ポータルにアタッチした以下のスクリプトを実行して、マテリアルにテクスチャを反映させるのみです。

public PortalCamera portalCamera;
private Material _portalMaterial;

void Awake () {
    _portalMaterial = GetComponent<MeshRenderer>().sharedMaterial;
}

private void OnWillRenderObject()
{
    portalCamera.RenderIntoMaterial(_portalMaterial);
}

OnWillRenderObject のタイミングで、ポータルカメラがキャプチャしたテクスチャを、自身のマテリアルに設定している、というわけです。

最後にシェーダのトリック

さて、いよいよ最後です。
諸々同期やらキャプチャやらを終えたら残すはレンダリングのみですね。

そして当然、レンダリングを受け持つのはシェーダの仕事です。
まずはシェーダコードから見てみましょう。

Shader "PortalShader" {
    Properties{
        _LeftEyeTexture("Left Eye Texture", 2D) = "white" {}
        _RightEyeTexture("Left Eye Texture", 2D) = "white" {}
    }

    SubShader{
        Tags{ "RenderType" = "Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.0

            #pragma multi_compile __ STEREO_RENDER

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv:TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
            };

            sampler2D _LeftEyeTexture;
            sampler2D _RightEyeTexture;

            v2f vert(appdata v, out float4 outpos : SV_POSITION)
            {
                v2f o;
                outpos = mul(UNITY_MATRIX_MVP, v.vertex);

                o.uv = v.uv;
                return o;
            }

            fixed4 frag(v2f i, UNITY_VPOS_TYPE screenPos : VPOS) : SV_Target
            {
                float2 sUV = screenPos.xy / _ScreenParams.xy;

                fixed4 col = fixed4(0.0, 0.0, 0.0, 0.0);
                if (unity_CameraProjection[0][2] < 0)
                {
                    col = tex2D(_LeftEyeTexture, sUV);
                }
                else {
                    col = tex2D(_RightEyeTexture, sUV);
                }

                return col;
            }
            ENDCG
        }
    }

    Fallback "Diffuse"
}

対して長くありませんね。
(そもそもが取得したテクスチャの内容をレンダリングするのみなので)

ポイントは unity_CameraProjection でしょう。
これを使うことで、左右どちらのレンダリング中なのかを判断することができます。

そして判断したあとは、割り当てられた左右の目のテクスチャから適切にテクセルを取り出してやれば終了です。

まとめ

ポイントはやはり視差を考慮したキャプチャを行う、というところでしょう。
特に HTC Vive ではどのスクリプトがどういう処理でHMDにレンダリングを行っているか、というのを知る必要があるので、そのあたりがちょこっとハマった部分になります。

参考にした記事など