11
9

【VRChat】プレイヤーカメラを再現する

Last updated at Posted at 2024-07-07

2024/07 時点の内容です.今後のアップデートで変更される可能性があります.

概要

VRChat において,プレイヤーのローカル環境には次のようなカメラがあります(たぶん).

  • Player view (※正式名称がわからなかった)
  • Handheld camera
  • Screenshot
  • Face mirror
  • Personal mirror

このうちの Player view, Handheld camera, Screeenshot のレンダリング結果の再現方法をまとめます.本当は Camera コンポーネントを取得したいのですが,できないので妥協してレンダリング結果を再現します.

VRC local cameras.png

Player veiw の再現

Transform の設定

プレイヤー視点のカメラの Transform は VRCSDK で取得できます.

var tracker = Networking.LocalPlayer.GetTrackingData(VRCPlayerAPI.TrackingDataType.Head);
camera.transform.SetPositionAndRotation(tracker.position, tracker.rotation);

しかし,VR で正しい player view を取得するためには,カメラオブジェクトの上に1段 GameObject をかませてスケールを調整する(瞳孔間距離を合わせる)必要があります.

PlayerViewTracker_Hierarchy.png

スケールは AudioListenerで取得できます1.原理はよくわかりません.

var playspace = camera.transform.parent;
var tracker = Networking.LocalPlayer.GetTrackingData(VRCPlayerAPI.TrackingDataType.Head);
playspace.SetPositionAndRotation(tracker.position, tracker.rotation);
playspace.localScale = (1.0f / audioListener.transform.localScale.x) * Vector3.one;

Update タイミング

Player view の transform は OnPreCull() のタイミングでセットします.LateUpdate() でやったら視界が振動しました.OnPreCull() は Camera がアタッチされた GameObject(レンダリングを実行するオブジェクト)でしか呼ばれないので,スクリプトは Camera オブジェクトにアタッチします.

Culling matrix の更新

※ 2024/09/01 追記
Culling とは,カメラから見えない場所にあるオブジェクトを描画対象から除外する工程で,この「カメラから見えるか」を計算するのに Culling matrix が使われます.
Culling matrix は毎フレーム自動更新されているはずですが,自動更新のタイミングは OnPreCull() より前のようなので,OnPreCull() で Camera の transform を変更した場合は手動で更新する必要があります.

camera.ResetWorldToCameraMatrix();  // 先にビュー行列を更新しておく
camera.ResetCullingMatrix();

これをしなくても滅多に問題は生じませんが,カメラを瞬間移動させた場合(プレイヤーをテレポートさせた場合)に,移動直後の1フレームだけ何も映らなくなります.

Camera コンポーネントの設定

用途に合わせて Camera のプロパティを設定します.

  • Clipping Planes
    Main Camera にあわせてください
  • Depth
    レンダリングの実行順を設定する項目です.Player view に上書きしたい場合は 1 以上に,影響を与えたくない場合は -2 以下に設定します.
  • Target Texture
    何も入れないでください(入れると VR で機能しなくなります).

その他(Clear Flag とか)は目的に応じてに設定してください.

PlayerViewCameraSetting.png

レンダリング

Player view のレンダリングは明示的には実行しません.camera.Render() だと Single Pass Stereo Rendering ができないからです.
Camera コンポーネントをアクティブにしておくと,Depth に設定した順番に従って自動的にレンダリングが実行されます.Target Texture が null の Camera についてはメインスクリーンに描画されるので,VRの場合はVR用に,デスクトップの場合はデスクトップ用に,いい感じに描画してくれます.

レンダリング結果の取得

Player view に直接上書きする場合は必要ありませんが,レンダリング結果を他で利用したい場合(マテリアルにセットしたい,とか)は,OnRenderImage() を利用します.この関数も Camera コンポーネントがついていないと呼ばれません.
Blit() に使う RenderTexture は VRCRenderTexture.GetTemporary() でも作れますが,それだとVRCMirrorReflection に破壊されるので new RenderTexture() で作成します.

RenderTexture renderTexture;
[SerializeField] Renderer renderer;

void OnRenderImage(RenderTexture source, RenderTexture destination)
{
    if (renderTexture == null)
    {
        renderTexture = new RenderTexture(source.descriptor);
    }
    VRCGraphics.Blit(source, renderTexture);
    renderer.material.SetTexture("_MainTex", renderTexture);
    // メインスクリーンにも描画したい場合は destination にも Blit する.
    VRCGraphics.Blit(source, destination);
}

ただ, OnRenderImage() は VRChat Client Simulator ではうまく動きません.Build & Test するとちゃんと動きます.なんで?

実装例

こんな感じで実装します.せっかくなので特殊シェーダ―でレンダリングしてみます.

Udon スクリプト

※ 2024/09/01 Culling matrix の更新処理を追加

PlayerViewTrackerUdon.cs
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;

[RequireComponent(typeof(Camera))]
[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class PlayerViewTrackerUdon : UdonSharpBehaviour
{
    [SerializeField] Transform audioListener;
    [SerializeField] string globalTextureName;
    int globalTextureId;
    [HideInInspector] public Camera thisCamera;
    [SerializeField] Material replacementMaterial;
    RenderTexture renderTexture;
    int lastFrameCount = -1;

    void Start()
    {
        // 自動的に disable されてしまうので手動で enable する.
        thisCamera = this.GetComponent<Camera>();
        thisCamera.enabled = true;
        audioListener.gameObject.SetActive(true);

        // 今回は Global Texture にセットしてみる.
        globalTextureId = VRCShader.PropertyToID(globalTextureName);

        // レンダリングに使うシェーダーを変えてみる.
        replacementMaterial.enableInstancing = true;
        thisCamera.SetReplacementShader(replacementMaterial.shader, null);
    }

    void OnPreCull()
    {
        SetCameraTransform();
        // OnPreCull() でカメラを動かした場合は手動で更新が必要
        thisCamera.ResetWorldToCameraMatrix();
        thisCamera.ResetCullingMatrix();
    }
    
    // VRChat Client Simulator だとうまく動かない.なんで?
    void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        // GetTemporary/ReleaseTemporary を使うと VRCMirrorReflection に破壊される.
        if (null == renderTexture)
        {
            renderTexture = new RenderTexture(source.descriptor);
        }
        VRCGraphics.Blit(source, renderTexture);
        VRCShader.SetGlobalTexture(globalTextureId, renderTexture);

        if (0 <= thisCamera.depth)
        {
            // depth が 0 以上の場合は destination に Blit しないと画面が null になる.
            VRCGraphics.Blit(source, destination);
        }
    }

    public void SetCameraTransform()
    {
        if (Time.frameCount == lastFrameCount)
        {
            return;
        }
        lastFrameCount = Time.frameCount;

        var localPlayer = Networking.LocalPlayer;
        if (null == localPlayer)
        {
            return;
        }

        var playspace = this.transform.parent;

        var tracker = localPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Head);
        playspace.SetPositionAndRotation(tracker.position, tracker.rotation);

        if (localPlayer.IsUserInVR())
        {
            playspace.localScale = (1.0f / audioListener.localScale.x) * Vector3.one;
        }
    }
}
レンダリングに使うシェーダー

ワールド座標を返すシェーダーです.

Position.shader
Shader "Test/Position"
{
    SubShader
    {
        Tags { "Queue"="Geometry" "RenderType"="Opaque" }
        Cull Off

        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_instancing

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float3 worldPos : TEXCOORD0;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            v2f vert (appdata v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
                o.pos = UnityObjectToClipPos(v.vertex);
                o.worldPos = mul(unity_ObjectToWorld, v.vertex);
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                return float4(i.worldPos, 1);
            }
            ENDHLSL
        }
    }
}

表示用のシェーダー
Texture-Screenspace.shader
Shader "Test/Texture-Screenspace"
{
    SubShader
    {
        Tags { "Queue"="Geometry" "RenderType"="Opaque" }
        Cull Off

        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_instancing

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float4 projPos : TEXCOORD0;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            // "_Udon" プリフィックスが必須
            sampler2D _UdonMyGlobalTexture;

            v2f vert (appdata v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
                o.pos = UnityObjectToClipPos(v.vertex);
                o.projPos = ComputeScreenPos(o.pos);

                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                return frac(tex2Dproj(_UdonMyGlobalTexture, UNITY_PROJ_COORD(i.projPos)));
            }
            ENDHLSL
        }
    }
}

オブジェクト構成

表示用の Quad (Texture-Screenspace) は Layer を Water にします.Personal mirror に映るのを避けるためです.

PlayerViewTrackerSettings2.png

カメラの Culling Mask で Water を除外しておきます(必須ではない).

PlayerViewCameraCullingMask.png

実行結果

こんな感じ.

preview2.png

まあ座標を取るだけならシェーダーでできるんですけどね(ZWrite Off がなければ).

Handheld camera と Screenshot の再現

ハンドカメラの Transform や FoV は VRCMirrorReflection で取得します.

VRCMirrorReflection について

VRCMirrorReflection はミラー内のカメラが撮影した画像を表示することで実現しています.この "ミラー内のカメラ" は,ミラー自体を撮影しようとしているカメラに対して鏡合わせの位置に置かれるので,このカメラを取得すれば元のカメラの位置が分かります23.また, "ミラー内のカメラ" は元のカメラの FoV を反映しているので,FoV の取得も可能です.

VRCMirrorReflection のカメラを取得する

VRCMirrorReflection のカメラは,
 "MirrorCam" + VRCMirrorReflection がアタッチされているオブジェクトの名前
という名前でワールド直下に生成されるので,名前で検索して取得します.

var mirrorCamera = GameObject.Find("/MirrorCam" + this.gameObject.name).GetComponent<Camera>();

Handheld camera の Transform の取得

OnWillRenderObject() で実行します.

[SerializeField] Camera targetCamera;
Camera mirrorCamera;

void OnWillRenderObject()
{
    if(null == mirrorCamera)
    {
        mirrorCamera = GameObject.Find("/MirrorCam" + this.gameObject.name).GetComponent<Camera>();
        if (null == mirrorCamera)
        {
            return;
        }
    }
    
    // Mirror のローカルZ軸で Transform を反転する
    var cameraPosition = Vector3.Reflect(mirrorCamera.transform.position - this.transform.position, this.transform.forward) + this.transform.position;
    var euler = (Quaternion.Inverse(this.transform.rotation) * mirrorCamera.transform.rotation).eulerAngles;
    var cameraRotation = this.transform.rotation * Quaternion.Euler(-euler.x, -euler.y, euler.z);
    targetCamera.transform.SetPositionAndRotation(cameraPosition, cameraRotation);
}   

FoV の取得

ミラーのカメラのプロパティにある fieldOfView は,撮影に使われる FoV より若干小さいです.Projection 行列には実際の値が入っているので,これを使います45

float fov = 2 * Mathf.Atan(1.0f / Mathf.Abs(mirrorCam.projectionMatrix.m11)) * Mathf.Rad2Deg;
targetCam.fieldOfView = fov;

どのカメラか判定する

OnWillRenderObject() はすべてのカメラから1度ずつ呼ばれるので,今どのカメラから呼ばれているのかを判定する必要があります.

  1. Personal mirror の除外
    判定方法がわからなかったので,VRCMirrorReflection オブジェクトの Layer を Water にして Personal mirror から呼ばれないようにします.
  2. Handheld camera の判定
    カメラの座標をトラッキングデータと比較して閾値より遠ければ Handheld camera とします.
  3. Screenshot と Player view の判別
    • VRの場合
      カメラの座標を右目の座標と比較して閾値より近ければ Player view,遠ければ Screenshot とします.
    • Desktopの場合
      判定方法がわからなかったので描画順で判断します.Player view の方が先にレンダリングされます(たぶん).

実装例

あとはコードを見たほうがはやいです.(めんどくさくなっちゃった☆)

スクリプト
PlayerCameraTrackerUdon.cs
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.SDK3.Components;

[RequireComponent(typeof(VRCMirrorReflection))]
[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class PlayerCameraTrackerUdon : UdonSharpBehaviour
{
    [SerializeField] Camera targetCamera;
    Camera mirrorCamera;
    [SerializeField] PlayerViewTrackerUdon playerViewTracker;
    Camera playerViewCamera;
    [SerializeField] string globalTextureName;
    int globalTextureId;
    [SerializeField] Material replacementMaterial;
    int playerViewCaptureCount;
    MeshRenderer thisMeshRenderer;
    int cullingMaskPlayerLocal;
    int cullingMaskMirrorReflection;

    void Start()
    {
        targetCamera.enabled = false;
        thisMeshRenderer = this.GetComponent<MeshRenderer>();
        globalTextureId = VRCShader.PropertyToID(globalTextureName);

        // レンダリングに使うシェーダーを変えてみる
        replacementMaterial.enableInstancing = true;
        targetCamera.SetReplacementShader(replacementMaterial.shader, null);

        cullingMaskPlayerLocal = (1 << LayerMask.NameToLayer("PlayerLocal"));
        cullingMaskMirrorReflection = (1 << LayerMask.NameToLayer("MirrorReflection"));
    }

    void Update()
    {
        playerViewCaptureCount = 0;

        // カメラが VRCMirrorReflection の範囲外に出ないようにプレイヤーに追従させる.
        var localPlayer = Networking.LocalPlayer;
        if (null != localPlayer)
        {
            var tracker = localPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Head);
            this.transform.parent.SetPositionAndRotation(tracker.position, Quaternion.identity);
        }
    }

	void OnWillRenderObject()
    {
		if(null == mirrorCamera)
        {
			mirrorCamera = GameObject.Find("/MirrorCam" + this.gameObject.name).GetComponent<Camera>();
            if (null == mirrorCamera)
            {
                return;
            }
		}
        
        // ミラーのローカル Z 軸で Transform を反転する
        var cameraPosition = Vector3.Reflect(mirrorCamera.transform.position - this.transform.position, this.transform.forward) + this.transform.position;
        var euler = (Quaternion.Inverse(this.transform.rotation) * mirrorCamera.transform.rotation).eulerAngles;
        var cameraRotation = this.transform.rotation * Quaternion.Euler(-euler.x, -euler.y, euler.z);
        targetCamera.transform.SetPositionAndRotation(cameraPosition, cameraRotation);

        // 後で使う
        int cullingMask = targetCamera.cullingMask;

        // PlayerView, Handheld camera, Screenshot を判別する.
        const float epsilon = 1e-4f;
        var localPlayer = Networking.LocalPlayer;
        if (null == localPlayer)
        {
            return;
        }
        var tracker = localPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Head);
        float proximity = Vector3.Distance(cameraPosition, tracker.position);
        if (localPlayer.IsUserInVR())
        {
            if (epsilon > proximity)
            {
                // Screenshot
                SetCullingMaskFirstPersonView(targetCamera);
            }
            else
            {
                // VR の場合,右目の位置がミラーのカメラ位置に対応する.
                playerViewTracker.SetCameraTransform();
                if (null == playerViewCamera)
                {
                    playerViewCamera = playerViewTracker.thisCamera;
                }
                proximity = playerViewCamera.GetStereoViewMatrix(Camera.StereoscopicEye.Right).MultiplyPoint3x4(cameraPosition).magnitude;
                if (epsilon > proximity)
                {
                    // Player view
                    return;
                }
                else
                {
                    // Handheld camera
                    SetCullingMaskThirdPersonView(targetCamera);
                }
            }
        }
        else
        {
            if (epsilon > proximity)
            {
                // PlayerView と Screenshot の判別方法がわからないので,とりあえず描画順で判定する.
                playerViewCaptureCount++;
                if (1 == playerViewCaptureCount)
                {
                    // Player view
                    return;
                }
                else
                {
                    // Screen Shot
                    SetCullingMaskFirstPersonView(targetCamera);
                }
            }
            else
            {
                // Handheld camera
                SetCullingMaskThirdPersonView(targetCamera);
            }
        }
        
        // RenderTexture のサイズを補正する
        if (null == mirrorCamera.targetTexture)
        {
            return;
        }
        var desc = mirrorCamera.targetTexture.descriptor;
        if (2048 <= desc.width)
        {
            // VRCMirrorReflection は解像度 2048 x 2048 が上限なので,QHD 以上の場合に縦横比が崩れる.
            if (1440 >= desc.height)
            {
                // QHD にする.
                desc.width = 2560;
                desc.height = 1440;
            }
            else
            {
                // 4K にする.8K は検出できなさそう.
                desc.width = 3840;
                desc.height = 2160;
            }
            // Custom Configへの対応は諦める.
        }

        // mirrorCamera.fieldOfView は撮影時に使われる FoV より若干小さい.Projection 行列には実際の値が入っている.
        float fov = 2 * Mathf.Atan(1.0f / Mathf.Abs(mirrorCamera.projectionMatrix.m11)) * Mathf.Rad2Deg;

        // 自分自身の Renderer が有効なままだとレイヤーの設定によっては無限ループしてしまう.
        thisMeshRenderer.enabled = false;

        targetCamera.transform.SetPositionAndRotation(cameraPosition, cameraRotation);
        targetCamera.nearClipPlane = mirrorCamera.nearClipPlane;
        targetCamera.farClipPlane = mirrorCamera.farClipPlane;
        RenderTexture rt = VRCRenderTexture.GetTemporary(desc);
        targetCamera.targetTexture = rt;
        // VR の場合,targetTexture に何もセットされていないと,FoV を設定できない(VR の設定に戻される).
        targetCamera.fieldOfView = fov;
        targetCamera.Render();
        VRCShader.SetGlobalTexture(globalTextureId, rt);
        targetCamera.targetTexture = null;
        VRCRenderTexture.ReleaseTemporary(rt);

        thisMeshRenderer.enabled = true;
        targetCamera.cullingMask = cullingMask;
	}

    void SetCullingMaskFirstPersonView(Camera camera)
    {
        camera.cullingMask |=  cullingMaskPlayerLocal;
        camera.cullingMask &= ~cullingMaskMirrorReflection;
    }

    void SetCullingMaskThirdPersonView(Camera camera)
    {
        camera.cullingMask &= ~cullingMaskPlayerLocal;
        camera.cullingMask |=  cullingMaskMirrorReflection;
    }
}
VRCMirrorReflectionにセットするシェーダー

VRCMirrorReflection のメッシュは描画したくないので描画されないシェーダーを用意します.

NotRendered.shader
Shader "Test/NotRendered"
{
    SubShader
    {
        Tags { "Queue"="Geometry" "RenderType"="Opaque" }
        Pass
        {
            HLSLPROGRAM
            #pragma vertex nop
            #pragma fragment nop
            void nop() {}
            ENDHLSL
        }
    }
}
オブジェクト構成

VRCMirrorReflection のメッシュは Quad ではなく Cube を使います.カメラの解像度を取得したいので Mirror Resolution は Auto にします.
さっき作った Texture-Screenspace をカメラ越しに見るとカメラ用のテクスチャが映ります.

PlayerCameraTrackerSetting2.png

カメラの Culling Mask で Water を除外しておきます.

PlayerCameraCullingMask.png

実行結果

カメラ越しでも正しい画像がとれるようになります.
result_camera.png

雑記

  • 高負荷注意
  • 悪用しないでね

もともとは,服が透けない / 浮かない水シェーダ―をつくりたくていろいろ調べたんですが,描画負荷が高すぎて断念しました.もったいないので知見の共有です.だれか,すごいものを作ってください.

デモをつくりました.ワールド用に少し手を加えています.FPSの低下を楽しんでください.

  1. VRChatワールド Friendly Crane 実装詳細解説記事

  2. VRChat の MirrorReflection について

  3. MirrorCameraTracker

  4. その70 完全ホワイトボックスなパースペクティブ射影変換行列

  5. Unityの射影行列UNITY_MATRIX_Pの中身

11
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
9