Edited at

そのシェーダー、本当にVR対応できてますか? ~Pimax、広視野角ヘッドセットへの対応~

皆さんはシェーダーのVR対応、やったことありますか?

「シングルパスステレオレンダリング完全に理解した」

「マルチパス使えばええやん」

「OculusとViveで動作確認してます!」

などの声が返ってきそうですが、これら全てを行なっているにも関わらず自分の環境で正常に動作しないシェーダーを私はVRChatで複数目撃しています。


Pimax

Pimax。私が使っているヘッドセットの機種です。(より正確にはPimax 5K XR)

Pimaxでうまく見えない!というとVRC パリピ砲界隈 の方々は


  • Pimaxは不安定だから

  • Pimaxは中国製だから

  • Pimaxなんて知らん

  • VRChatにPimaxは向いていない

などの反応をなさりますが、実際の理由としては


  • Pimaxは両目のカメラがそれぞれ顔の外側を向いているから

というのが直接的な理由である場合が大半です。

Pimaxのメインユーザーは人間を想定しており、魚ではありません。何故?


広視野角VRHMD(Pimax)におけるレンダリングの違い

VRHMDでの立体的な視野表現は基本的に右目と左目にカメラを設置してそれぞれ対応する目に映像を表示することで実現されています。

(実際はリアルタイムシャドウ生成処理の共通化、錐体カリングの共通化、シングルパスステレオによるレンダーターゲットの共通化、などを用いた最適化が行われる場合があるためこの表現は正確ではありませんが、今回の話には関係ないので割愛します。)

ところで、UnityのカメラのField Of View(画角、視野角)って弄ったことあるでしょうか?

この値は$0<FoV<180$の範囲で指定することができ、

180に近い値を指定すると外周部の歪みが加速度的に大きくなっていきます。

Pimaxの紛れもない優位性として200°を超えるその広大な視野角があります。

視野角が110°程度しかない従来のヘッドセットでは、

顔正面を向いた110°のカメラを2つ用いればFoV110°程度のカメラなら外周部の歪みも許容できるレベルであり、このような手法が用いられていましたが、

視野角200°を超えるとこの方法ではどうしようもなく、2つのカメラを顔の外側に向けることで対応した、というわけです。

この手法、不安定で互換性が低いような手法でしょうか?個人的には互換性もパフォーマンスもかなり良く、今後の広視野角VRHMDではみんなこぞって使いそうな手法であるように思えます。(ValveIndexもこの方法を使っているという噂あり)

VRにおける没入感を向上させる上で視野角の広さは重要な要素の一つです。

VRHMDの視野角は100~110度で長らく停滞していましたが、

2019年6月29日に国外では発売されたValveIndexは視野角が130°となっており、確実に広視野角化の流れは来ています。


レンダリングの違いを受けるシェーダー

本題に入りましょう。

左右の目が別方向を向いているとどのようなシェーダーが影響を受けるのでしょうか?

確実に見分けられる方法というのはありませんが、よくある例を挙げていきます。


視界ハック、ビルボードシェーダー

この問題が一番認知度が高いと思います。一般的にこれらの表現はビュー行列、モデル行列による回転を一部無視することで実装されていますが、両目が別の向きを向いている場合は当然それぞれの目での向きに矛盾が生じます。


スペキュラーありのトゥーンシェーダー

ここで言うトゥーンシェーダーとはライティングによる陰影の付け方を連続的ではなく断続的なものにすることで、

くっきりとしたアニメのような陰影を表現するシェーダーのことを指します。

スペキュラー反射はワールド空間の光の方向、ワールド空間のモデルの法線、ワールド空間のカメラから見たモデルの向きなどを使って計算されます。

カメラから見たモデルの向きはWorldSpaceViewDirを使うことで左右でそれぞれ別の物理的に正しいスペキュラーを計算できますが、左右で別の結果が得られるということはトゥーンシェーダーでは左右で陰影の境界線が違う、ということになり、多くの場合表現したいものと異なります。

自分のことをVR対応だと思いこんでいる多くの一般トゥーンスペキュラーシェーダーは、カメラから見たモデルの向きをワールド空間のカメラの向きで代用することで対策しています。

(カメラの向きを変えるだけでスペキュラーが変わってしまうので物理的に正しくないですが元からトゥーンなので物理的に厳密に正しくなくともあまり気にならない)

当然、左右でカメラの向きが違うとこの方法でも左右で違った結果になってしまいます。


対策法


視界ハック、ビルボードシェーダー

ワールド空間における顔の正面の座標、向きに変換する行列を用いることでこれは解決できます。普通のUnityではShaderのグローバル変数で用意すれば良いでしょう。

VRChatにおいてはシングルパスステレオレンダリングが採用されているため、unity_StereoMatrixInvV unity_StereoCameraToWorld を使って両目の 逆ビュー行列カメラを親としたワールド座標系への変換行列を取得、補間することでこの行列をシェーダーのみで求めることができます。(理論上InvVでも別に可能ですがUnityのビュー行列は座標系の狭間で闇を抱えているのでこれらと無関係なCameraToWorldを使いましょう)

行列の補間についてはこちらのサイトが参考になりました。


float3 ScaleOf(in float3x3 mat){
return float3(length(mat._m00_m10_m20),length(mat._m01_m11_m21),length(mat._m02_m12_m22));
}
float3 ScaleOf(in float4x4 mat){
return ScaleOf((float3x3)mat);
}
float3 PositionOf(in float4x4 mat){
return mat._m03_m13_m23;
}
float3x3 RotationOf(float3x3 mat,float3 scale){
mat._m00_m10_m20 /= scale.x;
mat._m01_m11_m21 /= scale.y;
mat._m02_m12_m22 /= scale.z;
return mat;

}
float3x3 RotationOf(float4x4 mat,float3 scale){
return RotationOf((float3x3)mat,scale);
}

float4x4 BuildMatrix(in float3x3 mat,in float3 offset)
{
return float4x4(
float4(mat[0],offset.x),
float4(mat[1],offset.y),
float4(mat[2],offset.z),
float4(0,0,0,1)
);
}
float3x3 Columns(float3 column0,float3 column1,float3 column2){
float3x3 ret;
ret._m00_m10_m20 = column0;
ret._m01_m11_m21 = column1;
ret._m02_m12_m22 = column2;
return ret;
}
//ベクトルを線形球面補間しますが、入力ベクトルは正規化されている必要があり、戻り値のベクトルは正規化されていません。
//Abnormal・・・?
float3 SlerpAbnormal(float3 normalizedA,float3 normalizedB,float t){
float angle = acos(dot(normalizedA,normalizedB));//acosクッソ重い
//float _sin = sin(angle);
float aP = sin(mad(-angle,t,angle));
float bP = sin(angle * t);
return angle ? (aP * normalizedA + bP * normalizedB) : normalizedA;
}
//ベクトルを球面線形補間します。
float3 Slerp(float3 a,float3 b,float t){
return normalize(SlerpAbnormal(normalize(a),normalize(b),t));
}
//回転行列を球面線形補間します。
float3x3 Slerp(in float3x3 a ,in float3x3 b,in float t){
float3 iy = SlerpAbnormal(a._m01_m11_m21 , b._m01_m11_m21,t);//回転行列の軸ベクトルは当然正規化済み
float3 iz = SlerpAbnormal(a._m02_m12_m22 , b._m02_m12_m22,t);//回転行列の軸ベクトルは当然正規化済み
float3 ix = normalize(cross(iy,iz));
iz = normalize(iz);
iy = cross(iz,ix);
return Columns(ix,iy,iz);

}

//移動、回転行列の回転をSlerp、移動をLerpします。
float4x4 InterpolateTRMatrix(in float4x4 a,in float4x4 b,in float t){
return BuildMatrix(Slerp((float3x3)a,(float3x3)b,t), lerp(Position(a),Position(b),t));
}

InterpolateTRMatrixa,b に両目の行列、t に0.5を与えれば顔の変換行列が求まりますが、

acosなんてものが含まれた関数がただでさえGPUを酷使するVRのあっちこっちで使われたらGPUの発熱で火事が多発し、公衆の場にこんなコードを解き放った私は無差別放火魔として知られるようになるでしょう。

最適化をしましょう。


最適化

3次元ベクトルのSlerpですが、acosなんてやばいものを使わないといけない理由は球面線形補間にするためです。球面補間でよければ単にベクトルをlerpしてnormalizeすれば良い話・・・

そして$t=0.5$のとき、この方法での球面補間と球面線形補間の結果は常に等しくなります。

あと、DirectXは行優先のメモリレイアウトなので、できれば列ベースの計算が多いのも避けたいです。

せっかくなのでこれも最適化しましょう。


//回転行列を球面線形補間します。
float3x3 Slerp(in float3x3 a ,in float3x3 b,in float t){
//OpenGLは列優先メモリレイアウトなのでこのままでOK
#if SHADER_TARGET_GLSL
float3 iy = SlerpAbnormal(a._m01_m11_m21,b._m01_m11_m21,t);//回転行列の軸ベクトルは当然正規化済み
float3 iz = SlerpAbnormal(a._m02_m12_m22,b._m02_m12_m22,t);//回転行列の軸ベクトルは当然正規化済み
float3 ix = normalize(cross(iy,iz));//クロス積のベクトルの向きに絶対値は関係ない
iz = normalize(iz);
iy = cross(iz,ix);//直交する正規化ベクトル同士のクロス積も正規化されている
return Columns(ix,iy,iz);
#else
//DirectXは行優先のメモリレイアウトなので、できれば行ベースで計算したい・・・
//ところで回転行列って直交行列ですね?
//回転行列の0,1,2列=この行列で回転をした後のX,Y,Z軸ベクトル
//回転行列の0,1,2行=回転行列の転置行列の0,1,2列
// =回転行列の逆行列の0,1,2列
// =逆回転の回転行列の0,1,2列
// =この行列の逆回転の行列で回転をしたあとのX,Y,Z軸ベクトル
//ということで、この関数の中では終始逆回転、かつ転置した状態として取り扱ってるのでこの計算の結果は正しいです。
float3 iy = SlerpAbnormal(a[1],b[1],t);//回転行列の軸ベクトルは当然正規化済み
float3 iz = SlerpAbnormal(a[2],b[2],t);//回転行列の軸ベクトルは当然正規化済み
float3 ix = normalize(cross(iy,iz));//クロス積のベクトルの向きに絶対値は関係ない
iz = normalize(iz);
iy = cross(iz,ix);//直交する正規化ベクトル同士のクロス積も正規化されている
return float3x3(ix,iy,iz);
#endif
}

//移動、回転行列の回転をSlerp、移動をLerpします。
float4x4 InterpolateTRMatrix(in float4x4 a,in float4x4 b,in float t){
return BuildMatrix(Slerp((float3x3)a,(float3x3)b,t), lerp(PositionOf(a),PositionOf(b),t));
}
//回転行列の平均を求めます。Slerp(a,b,0.5)より遥かに高速です。
float3x3 RMatrixAverage(in float3x3 a,in float3x3 b){
//OpenGLは列優先メモリレイアウトなのでこのままでOK
#if SHADER_TARGET_GLSL

float3 iy = (a._m01_m11_m21 + b._m01_m11_m21)*0.5;//回転行列の軸ベクトルは当然正規化済み
float3 iz = (a._m02_m12_m22 + b._m02_m12_m22)*0.5;//回転行列の軸ベクトルは当然正規化済み
float3 ix = normalize(cross(iy,iz));//クロス積のベクトルの向きに絶対値は関係ない
iz = normalize(iz);
iy = cross(iz,ix);//直交する正規化ベクトル同士のクロス積も正規化されている
return Columns(ix,iy,iz);
#else
//DirectXは行優先のメモリレイアウトなので、できれば行ベースで計算したい・・・
//ところで回転行列って直交行列ですね?
//回転行列の0,1,2列=この行列で回転をした後のX,Y,Z軸ベクトル
//回転行列の0,1,2行=回転行列の転置行列の0,1,2列
// =回転行列の逆行列の0,1,2列
// =逆回転の回転行列の0,1,2列
// =この行列の逆回転の行列で回転をしたあとのX,Y,Z軸ベクトル
//ということで、この関数の中では終始逆回転、かつ転置した状態として取り扱ってるのでこの計算の結果は正しいです。
float3 iy = (a[1] + b[1])*0.5;//回転行列の軸ベクトルは当然正規化済み
float3 iz = (a[2] + b[2])*0.5;//回転行列の軸ベクトルは当然正規化済み
float3 ix = normalize(cross(iy,iz));//クロス積のベクトルの向きに絶対値は関係ない
iz = normalize(iz);
iy = cross(iz,ix); //直交する正規化ベクトル同士のクロス積も正規化されている
return float3x3(ix,iy,iz);
#endif

}
//移動、回転行列の平均を求めます。InterpolateTRMatrix(a,b,0.5)より遥かに高速です。
float4x4 TRMatrixAverage(in float4x4 a,in float4x4 b){
return BuildMatrix(RMatrixAverage((float3x3)a,(float3x3)b),(PositionOf(a)+PositionOf(b))*0.5);
}
#if defined(USING_STEREO_MATRICES)
#define StereoWorldSpaceEyeRotation (float3x3)unity_StereoCameraToWorld
#define FaceToWorld TRMatrixAverage(unity_StereoCameraToWorld[0],unity_StereoCameraToWorld[1])
#define WorldSpaceFaceRotation RMatrixAverage(StereoWorldSpaceEyeRotation[0],StereoWorldSpaceEyeRotation[1])

float3 FaceToWorldPos(float3 pos) {return mul(FaceToWorld,float4(pos,1)).xyz;}

float3 ObjectToFaceAlignedWorldPosUnscaled(in float3 pos)
{
float3 ret = mul(WorldSpaceFaceRotation,pos);//ワールド空間でのカメラの向きに回転
ret += PositionOf(unity_ObjectToWorld);//オブジェクトのワールド座標を加算
return ret;
}
float3 ObjectToFaceAlignedWorldPos(float3 pos)
{
pos *= ScaleOf(unity_ObjectToWorld);//unity_ObjectToWorldからのスケールの抽出、適応
return ObjectToFaceAlignedWorldPosUnscaled(pos);
}
#endif


マルチパスレンダリングではもう片方の目の行列が取得できないためシェーダーだけでは不可能です。

双方で共用したい場合はマクロなどを使うといいでしょう。


#if defined(USING_STEREO_MATRICES)
#define FaceToWorld TRMatrixAverage(unity_StereoCameraToWorld[0],unity_StereoCameraToWorld[1])
#else
//マルチパスではそもそもシェーダー側のみではどうしようもない
float4x4 FaceToWorld;
#endif


unity_Stereo~について

さっきから出てきているStereoなんとかくん。お仲間がいっぱいいていろいろと便利です。

配列の0番目が左目、1番目が右目用の値で、

現在レンダリングしている対象の目のインデックスは unity_StereoEyeIndex で取得できます。

ただしマルチパスレンダリング、及び当然非VRでは使えないので注意です。

VRに限らない複数視点のレンダリングに使えるので(フラスタムカリングの問題はありますが)

将来的に非VRにも拡張される可能性はあるかも?


UnityShaderVariables.cginc(UnityCG.cgincをincludeすればついてきます)

#if defined(UNITY_SINGLE_PASS_STEREO) || defined(UNITY_STEREO_INSTANCING_ENABLED) || defined(UNITY_STEREO_MULTIVIEW_ENABLED)

#define USING_STEREO_MATRICES
#endif

//...

#if defined(USING_STEREO_MATRICES)
GLOBAL_CBUFFER_START(UnityStereoGlobals)
float4x4 unity_StereoMatrixP[2];
float4x4 unity_StereoMatrixV[2];
float4x4 unity_StereoMatrixInvV[2];
float4x4 unity_StereoMatrixVP[2];

float4x4 unity_StereoCameraProjection[2];
float4x4 unity_StereoCameraInvProjection[2];
float4x4 unity_StereoWorldToCamera[2];
float4x4 unity_StereoCameraToWorld[2];

float3 unity_StereoWorldSpaceCameraPos[2];
float4 unity_StereoScaleOffset[2];
GLOBAL_CBUFFER_END
#endif



スペキュラーありのトゥーンシェーダー

上で書いたのと同じ方法で顔の正面方向を求めるのも良いですが、こちらの場合もう1段階問題を掘り返すことで軽量かつ根本的に問題を解決できます。

本来使用するべきであるWorldSpaceViewDirはカメラのワールド座標とレンダリング対象のピクセルのワールド座標の差分ベクトルを求めるものでしたが、カメラのワールド座標が左右の目で違うため違う結果になるのが問題でした。


UnityCG.cginc


// Computes world space view direction, from object space position
inline float3 UnityWorldSpaceViewDir( in float3 worldPos )
{
return _WorldSpaceCameraPos.xyz - worldPos;///本題とは関係ないですがこれ正規化されてないので注意しましょう
}

なので、左右の目の中心座標を使ってしまいましょう。これで顔の向きを変えるだけでスペキュラ反射が変わってしまう問題も直ります。


#if defined(USING_STEREO_MATRICES)
#define _StereoWorldSpaceCameraPos unity_StereoWorldSpaceCameraPos
#elif !defined(USING_STEREO_MATRICES) && VR
float3 _StereoWorldSpaceCameraPos[2];
#endif
inline float3 WorldSpaceFaceDir(in float3 worldPos)
{
#if defined(USING_STEREO_MATRICES) || VR
return (_StereoWorldSpaceCameraPos[0] + _StereoWorldSpaceCameraPos[1])*0.5 -worldPos;
#else
return UnityWorldSpaceViewDir(worldPos);
#endif
}


雑記

Vケット3のマップ、Vケット2の吹き出しがPimaxでバグってるのを見て、

Vケット3に合わせて1万円以上割引セールやってたPimaxに対して何だその態度はと抑えれられない憤りを感じて

将来のヘッドセットでいろんなコンテンツが壊れてしまうのは悲しいと思って初めてですが投稿しました。

マルチパスレンダリングのVRはシェーダー側で判別できないのでこういうのやるとシングルパスステレオより面倒ですね・・・

あと、VRCの鏡の中は今回の方法だとどうにもならないです・・・


VRCの鏡について

シェーダーが動く環境としてかなりクソでどうしようもないことも多いです。


シェーダーのみで反対側の目の情報が取得できない上にスクリプトで与えることもできない

糞。


LinearEyeDepthが使えない

これはどう考えてもUnityが悪いんですが、_CameraDepthTextureからEyeDepthを復元するための関数である LinearEyeDepthが鏡の中では機能しません。

これはLinearEyeDepthが内部で _ZBufferParams を参照しており、_ZBufferParams はNear、FarClip距離から算出されることに起因します。

VRCの鏡の中のレンダリング用のカメラは鏡付近の物体が正常にレンダリングされるように傾いたNearClipPlaneを用いているため、NearClip距離が定義されず、結果として何の予告もなく _ZBufferParamsが未定義値になります。(大体の場合直前にレンダリングを行ったカメラのZBufferParamsがそのまま入っている)

自前でこれに対応する関数を作ったので、(DirectX11、DirectX12でのみ動作確認)興味がある方は連絡してください。


1ピクセルでも画面に映ると左右の目それぞれで画面全体分のレンダリングが行われる

これが最高にクソです。プレイヤー視点のレンダリングが視界全体のシングルパスステレオレンダリングなのに対し、鏡の中は左右それぞれの視界全体分のレンダリングがマルチカメラで走るので、

画面に鏡が1ピクセルでも映るとCPUもGPUもレンダリング負荷が2倍以上になります

Cannyに軽量化リクエスト上げようと思うので、よかったらVoteしてください