VRChat の MirrorReflection について

https://vrchat.com/home/launch?worldId=wrld_f668e805-7b03-4ee2-a255-4c0ff0ad50a5

ワールドをつくりました。きてね。(宣伝)

ご覧のとおり、床や天井の反射は VRCMirrorReflection コンポーネントを使っています。これを作っているときに知ったことを自分用にまとめます(※2024年2月時点の情報です)。触ったことのある方々からすれば新しい情報はないと思いますが、この手の情報がまとまったところが見当たらなかったために書き残してる感もあります。




基本的なこと

VRChat における Mirror (以下、VRCMirror と言います。)は、 Transform 上の回転がない状態で面の向きが -Z 方向を向いているメッシュを前提としています。早い話が Unity のデフォルトメッシュの Quad です。これに VRCMirrorReflection コンポーネントをアタッチすることで鏡として機能します。つまり、このメッシュの仕様を守れば、任意のメッシュを鏡として機能させることができます。丸型とか星形とか。以下のページも参照するとよいでしょう。

VRCMirror に使用されている Shader は以下の場所にあります。これを改変することで、カスタム Shader による VRCMirror を作ることができます。

Shader の場所はここ

例えば NormalMap を使って歪んだ鏡を作るならこうなります。

Shader "FX/MirrorReflection_useNormal"
{
    Properties
    {
        _MainTex ("Base (RGB)", 2D) = "white" { }
        _BumpPower ("Normal Offset Power", Range(-1, 1)) = 0.0
        _BumpScale ("Scale", Float) = 1.0
        [Normal] _BumpMap ("Normal Map", 2D) = "bump" { }
        [HideInInspector] _ReflectionTex0 ("", 2D) = "white" { }
        [HideInInspector] _ReflectionTex1 ("", 2D) = "white" { }
    }
    SubShader
    {
        Tags { "RenderType" = "Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            #include "UnityInstancing.cginc"
            #include "UnityStandardUtils.cginc"

            sampler2D _MainTex;
            float4 _MainTex_ST;
            sampler2D _BumpMap;
            float4 _BumpMap_ST;
            float _BumpScale;
            float _BumpPower;
            sampler2D _ReflectionTex0;
            sampler2D _ReflectionTex1;

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 refl : TEXCOORD1;
                float4 pos : SV_POSITION;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            v2f vert(appdata v)
            {
                v2f o;

                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_OUTPUT(v2f, o);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                o.refl = ComputeNonStereoScreenPos(o.pos);

                return o;
            }

            half4 frag(v2f i) : SV_Target
            {
                UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
                float3 normal = UnpackScaleNormal(tex2D(_BumpMap, i.uv * _BumpMap_ST.xy + _BumpMap_ST.zw), _BumpScale);
                i.refl.xyz += normal * _BumpPower;

                half4 tex = tex2D(_MainTex, i.uv);
                half4 refl = unity_StereoEyeIndex == 0 ? tex2Dproj(_ReflectionTex0, UNITY_PROJ_COORD(i.refl)) : tex2Dproj(_ReflectionTex1, UNITY_PROJ_COORD(i.refl));
                return tex * refl;
            }
            ENDCG
        }
    }
}

続いて MeshRenderer に、カスタム Shader を使用したマテリアルを、VRCMirrorReflection コンポーネントの "Custom Shader" に自作の Mirror 用 Shader をアタッチすれば、 VRCMirror に適用させることができます。

こんな見た目になります Powerは-0.1 ~ +0.1 くらいがオススメ


VRCMirrorReflection コンポーネントでは、 VRCMirror がある平面の反対側に別のカメラを生成し、そのカメラが見ている映像をテクスチャとして _ReflectionTex0 に渡しています。VR の右目なら _ReflectionTex1 へ。そうして渡されたテクスチャを Shader でいい感じに変換して鏡っぽく描画しているということのようです。
Udon を扱える方なら、MaterialPropertyBlock を使って _ReflectionTex0 や _ReflectionTex1 を引っこ抜くことができるので、実際にどんなテクスチャが入力されているのか見てみてはいかがでしょうか。

Shader だけを読んでいる限りでは、どのカメラが見ている映像なのかを区別していません。カメラ毎に異なる RenderTexture をすべて _ReflectionTex0 へ入力しているように見えます。その辺は VRCMirrorReflection コンポーネントがいい感じにやっているのでしょう。 Shader ファイルをコンポーネントに直接アタッチしていることもあり、ランタイムに中身を書き換えている可能性もありますが、実態は知りようがないと思います。



鏡の向こう側の深度情報を取得する

_CameraDepthTexture を使ってカメラに映っているオブジェクトの深度情報を取得できますが、鏡の向こう側のオブジェクトの深度情報を取得するのは大変でした。

https://github.com/netri/Neitri-Unity-Shaders/blob/master/World%20Position.shader

上記の Shader は、深度情報からワールド座標を復元するものですが、これを使うと鏡の向こう側の depth 取得でき、ワールド座標を復元できました。

出力をちょっと変えてるけどちゃんと復元できてることがわかります

ワールド座標を復元する Shader をアタッチしたメッシュを、 VRCMirror のちょっと手前に置くといいです。ついでに Mirror 内のみで描画されるようにするか、 Cull Front すればいいです。


// 3種類ありますがどれを使ってもいいです.
inline float isInMirror()
{
    return unity_CameraProjection[2][0] != 0.f || unity_CameraProjection[2][1] != 0.f;
    // or
    return (0 < dot(cross(UNITY_MATRIX_V[0], UNITY_MATRIX_V[1]), UNITY_MATRIX_V[2]));
    // or
    return _VRChatMirrorMode != 0; // https://creators.vrchat.com/worlds/vrc-graphics/vrchat-shader-globals/
}

弊ワールドでは遠くのオブジェクト(床の Mirror からみた天井、またはその逆)が映らないよう、アルファブレンドで打ち消すようにしています。
VRCMirror 側の Shader では Blend SrcAlpha OneMinusSrcAlpha 、復元側の Shader では Blend OneMinusSrcAlpha SrcAlpha とします。これで Mirror 本体から離れたオブジェクトは映らない鏡が作れます。

このままだと使える場面は限られますが、普通に鏡の向こう側の depth もちゃんととれるようになるのでかなり有用だと思います。例えば depth をよく使っている水 Shader とかに組み込めばよさそうですね。



余談:鏡の向こう側のカメラ

VRCMirrorReflection コンポーネントが生成する、鏡の向こう側からこちら側を映しているカメラですが、なんと OnWillRenderObject() で取得できるようです。

2つ目のページは suzukiさんに教えてもらいました。この記事が書かれたのが2年前ですから、以前から知られていた手法だということがわかるかと思います。

private Camera mirrorCamera;
private void OnWillRenderObject()
{
    if (mirrorCamera == null)
    {
        mirrorCamera = GameObject.Find(string.Format("/MirrorCam{0}", name)).GetComponent<Camera>();
        if (mirrorCamera == null)
        {
            return;
        }
    }
    // その後の処理...
}

カメラにアクセスできるんですからいろいろできそうな気がします。実際、現状の Udon ではできない、 VRChat 内の写真を撮るカメラの Transform を疑似的にですが知ることができます。

このカメラのこと

鏡の向こう側のカメラの Transform から、 VRCMirror を基準にこちら側のカメラの Transform を逆計算するだけです。

これは suzuki さんのアセットに組み込んだ例です。

注意点としては、自分のカメラが VRCMirror の裏側に回ったときや VRCMirror がカメラの描画範囲外にいるときには、鏡の向こう側のカメラは動作しなくなる(消えてしまう)ということくらいです。ですのでそれ用の対策をすればいいだけだと思います。

ただし、このカメラかどうかを正確に判定するのは難しそうです。鏡の向こう側のカメラの名前は、こちら側のカメラがいくつあってもすべて同じ名前のようです。また、 RenderTexture の解像度を取得したとしても、 Desktop モードにおいては描画 Window の解像度がこのカメラと同じである可能性もありますし、 VR 用と割り切ってアスペクト比から判定しようとしても config からカメラ解像度を自由に設定できる現状ですので確実ではありません。
他に方法があるのかもしれませんが、自分は分かりませんでした。ご存知の方がいたら教えてください。



おわり

以上です。お疲れ様でした。
この記事の内容を参照して何かされる方がいたとしても、この記事や筆者について触れる必要はありません。よしなに扱ってください。

拙文ですが最後までお読みいただきありがとうございました。