【VRChat】レイマーチングのQuest対応(モバイルVR対応)
概要
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のドキュメントを参考に、コードを数行追加するだけです。
- appdata構造体に
UNITY_VERTEX_INPUT_INSTANCE_ID
を挿入 - v2f構造体に
UNITY_VERTEX_OUTPUT_STEREO
を挿入 - 頂点シェーダーに以下の3行を挿入
UNITY_SETUP_INSTANCE_ID(v)
UNITY_INITIALIZE_OUTPUT(v2f, o)
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o)
- フラグメントシェーダーに
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
は正常に左目・右目で切り替わります。念のため記しておきます。
参考
②深度の計算がおかしくなる問題の解決
これはクリップ空間での深度が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
深度の計算がおかしくなった際のスクリーンショット
参考
Discussion