🎨

【VRChat】レイマーチングのQuest対応(モバイルVR対応)

2024/12/07に公開

概要

VRChatのQuest単機環境でレイマーチング(Raymarching)シェーダーを正常に表示させる方法を紹介します。

ネット上で公開されているレイマーチングのサンプルコードの大半は、PC環境であるVRChatのPCVRモードでも正常に表示されると思います。しかし、モバイル環境であるQuest単機だと、深度の計算がおかしくなることに加えて、右目に左目と同じ映像が描画されるという問題が起こります。

レイマーチングをQuest対応させるポイントは以下の2点です

  • 右目に左目と同じ映像が描画される問題はシングルパスインスタンスレンダリング(SPS-I)対応で解決
  • 深度はモバイル環境に合わせて座標変換を行う

環境

  • Unity 2022.3.22f1
  • VRChat SDK - Base 3.6.1
  • VRChat SDK - Worlds 3.6.1
  • Meta Quest 2 (単機)
  • Android Phone (ALLDOCUBE iPlay50mini Pro NFE)

Quest対応レイマーチングサンプルコード

今回はブタジエン様がBOOTHに無料で公開されているレイマーチングの参考書VRChatでどうしても_レイマーチングをしたいあなたへ贈る本のp75で紹介されているレイマーチングのコード(パブリックドメインで公開されています)をQuest対応させてみます。

こちらのブタジエン様のシェーダーは、概要にも書いたようにVRChatのPCVRモードでは何も問題なく正常に見えますが、Quest単機だと深度の計算がおかしくなり、右目に左目と同じ映像が描画されてしまいます。

元コードからの変更箇所にはコメントで★マークがつけてあるので確認してみてください。

コード全文

Shader "Butadiene/raymarchTwistSample"
{
	
		SubShader
	{
		Tags { "RenderType" = "Opaque"  "LightMode" = "ForwardBase" }
		LOD 100
		Cull Front
		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			#include "UnityCG.cginc"
	
			float2 rot(float2 p,float r) {//回転のための関数
				float2x2 m = float2x2(cos(r),sin(r),-sin(r),cos(r));
				return mul(p, m);
			}

			float sphere(float3 p) //球の距離関数
			{
				return length(p) - 0.5;
			}
			
			float cube(float3 p) {//キューブの距離関数
				float3 s = float3(0.1,0.1,0.5);	// ★少し大きかったので小さくした
				float3 q = abs(p);
				float3 m = max(s-q, 0.0);
				return length(max(q-s, 0.0)) - min(min(m.x, m.y), m.z);
			}
		
			float dist(float3 p) {//最終的な距離関数
				p.xy = rot(p.xy,p.z*2.0);
				return cube(p);
			}

			float3 getnormal(float3 p)//法線を導出する関数
			{
				float d = 0.0001;
				return normalize(float3(
					dist(p + float3(d, 0.0, 0.0)) - dist(p + float3(-d, 0.0, 0.0)),
					dist(p + float3(0.0, d, 0.0)) - dist(p + float3(0.0, -d, 0.0)),
					dist(p + float3(0.0, 0.0, d)) - dist(p + float3(0.0, 0.0, -d))
				));
			}


			struct appdata
			{
				float4 vertex : POSITION;
				float2 uv : TEXCOORD0;
				UNITY_VERTEX_INPUT_INSTANCE_ID // ★SPS-I対応
			};

			struct v2f
			{
				float2 uv : TEXCOORD0;
				float3 pos : TEXCOORD1;
				float4 vertex : SV_POSITION;
				UNITY_VERTEX_OUTPUT_STEREO // ★SPS-I対応
			};

			struct pout
			{
				fixed4 color : SV_Target;
				float depth : SV_Depth;
			};

			v2f vert(appdata v)
			{
				v2f o;

				UNITY_SETUP_INSTANCE_ID(v); // ★SPS-I対応
				UNITY_INITIALIZE_OUTPUT(v2f, o); // ★SPS-I対応
				UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); // ★SPS-I対応

				o.vertex = UnityObjectToClipPos(v.vertex);
				o.pos = v.vertex.xyz;//メッシュのローカル座標を代入
				o.uv = v.uv;
				return o;
			}


			pout frag(v2f i)
			{
				UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i); // ★SPS-I対応

				//以下、ローカル座標で話が進む
				float3 ro = mul(unity_WorldToObject,float4(_WorldSpaceCameraPos,1)).xyz;//レイのスタート位置をカメラのローカル座標とする
				float3 rd = normalize(i.pos.xyz - ro);//メッシュのローカル座標の、視点のローカル座標からの方向を求めることでレイの方向を定義
		

				float d =0;
				float t=0;
				float3 p = float3(0, 0, 0);
				[unroll]//ループ展開
				for (int i = 0; i < 60; ++i) { //レイマーチングのループを実行
					p = ro + rd * t;
					d = dist(p);
					t += d;
					if (d < 0.001 || t>1000)break;//レイが遠くに行き過ぎたか衝突した場合ループを終える
				}
				p = ro + rd * t;
				float4 col = float4(0,0,0,1);
				if (d > 0.001) { //レイが衝突していないと判断すれば描画しない
					discard;
				}
				else {
					float3 normal = getnormal(p);
					float3 lightdir = normalize(mul(unity_WorldToObject, _WorldSpaceLightPos0).xyz);//ローカル座標で計算しているので、ディレクショナルライトの角度もローカル座標にする
					float NdotL = max(0, dot(normal, lightdir));//ランバート反射を計算
					col = float4(float3(1, 1, 1) * NdotL, 1);//描画
				}

				pout o;
				o.color = col;
				float4 projectionPos = UnityObjectToClipPos(float4(p, 1.0));
				o.depth = projectionPos.z / projectionPos.w;

				// ★モバイル環境のときはクリップ空間での深度値の範囲を-1~1から0~1に変換
				#if UNITY_REVERSED_Z
				#else
					o.depth = (0.5 * o.depth) + 0.5;
				#endif

				return o;

			}
			ENDCG
		}

	}
}

元コードからの変更箇所

①右目に左目と同じ映像が描画される問題の解決

コードをシングルパスインスタンスレンダリング(SPS-I)対応させることで解決できます。手順はUnityのドキュメントを参考に、コードを数行追加するだけです。

  1. appdata構造体にUNITY_VERTEX_INPUT_INSTANCE_IDを挿入
  2. v2f構造体にUNITY_VERTEX_OUTPUT_STEREOを挿入
  3. 頂点シェーダーに以下の3行を挿入
    • UNITY_SETUP_INSTANCE_ID(v)
    • UNITY_INITIALIZE_OUTPUT(v2f, o)
    • UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o)
  4. フラグメントシェーダーにUNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i)を挿入

なぜ右目に左目と同じ映像が描画されるかというと、Quest環境だとSPS-I対応をしないとフラグメントシェーダーの初めに使われている_WorldSpaceCameraPosで、左目・右目関係なく常に左目のカメラのワールド座標が使用されてしまうためです。

シェーダーのincludeファイルがまとめられているUnityShaderVariables.cgincの中身を見ると、_WorldSpaceCameraPosは、内部ではunity_StereoWorldSpaceCameraPos[unity_StereoEyeIndex]と書かれており、本来はunity_StereoEyeIndexの値が左目の場合0、右目の場合1と切り替わることで、左目・右目で別々のカメラのワールド座標になるところが、Quest環境だとunity_StereoEyeIndexの値が常に0(左目)となってしまい、右目に左目と同じ映像が描画されてしまいます。

しかし、Unityのドキュメントを見ると

UNITY_INITIALIZE_VERTEX_OUTPUT_STEREOは unity_StereoEyeIndex の値に基づいてレンダリング先になるテクスチャ配列の目を GPU に伝えます。このマクロは、unity_StereoEyeIndex の値を頂点シェーダーから転送し、フラグメントシェーダーの frag メソッドで UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEXが呼び出された場合にのみ、フラグメントシェーダーでアクセスできるようにします。

と書かれており、実はPCVRで何もせずにレイマーチングが正常に見えているのがイレギュラーで、本来はステレオレンダリング対応しないとフラグメントシェーダー内で_WorldSpaceCameraPosの座標が左目・右目で切り替わらないのが普通なのかもしれません(あくまで個人の感想です)

※頂点シェーダー内ではSPS-I対応しなくてもunity_StereoEyeIndexは正常に左目・右目で切り替わります。念のため記しておきます。

参考

https://docs.unity3d.com/ja/2022.3/Manual/SinglePassInstancing.html

②深度の計算がおかしくなる問題の解決

これはクリップ空間での深度がPC環境だと1~0の範囲なのに対して、モバイル環境だと-1~1の2倍の範囲になることが原因です。

この問題はQuest単機だけでなく、同じモバイル環境であるAndroid Phone環境でも起こるので、もしVRChatのワールドをQuest/Android対応する際は参考にしてみてください。

解決方法としてはモバイル環境の時だけ-1~1の範囲の深度値を0~1の範囲に変換してあげるだけです。モバイル環境の判定はUNITY_REVERSED_Zで行えます。

#if UNITY_REVERSED_Z
#else
    o.depth = (0.5 * o.depth) + 0.5;
#endif

深度の計算がおかしくなった際のスクリーンショット

参考

https://docs.unity3d.com/ja/2022.3/Manual/SL-PlatformDifferences.html

Discussion