凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity の XR 向けシングルパスステレオレンダリングについて調べてみた

はじめに

Unity での XR Settings に含まれる Stereo Rendering Method ですが、みなさんは理解されていますか?ちなみに私は理解していませんでした。。

f:id:hecomi:20181028165338p:plain

なんとなく マルチパス は遅くて シングルパス にすると速い、しかしながらシングルパスにするにはシェーダの対応が必要、といった知識はあったのですが、では具体的に中で何が起きているのかはこれまで深く見てきませんでした。そこで本エントリでは以下の公式ブログ記事を参考に、両眼立体視系(2 枚のレンダーターゲットが必要とされる状況)の XR でのレンダリング手法の概要を見ると共に、ユーザがシェーダでどういった対応を行わなければならないのか、またそれは何故なのか、といった点を解説していきたいと思います。

blogs.unity3d.com

XR レンダリング概要

Unity の公式ブログでは マルチカメラマルチパスシングルパスシングルパス(インスタンシング版) の、4 つのレンダリング方法が順を追って紹介されています。詳細は本家を見ていただきたいですが、ここでは簡単に要点を掻い摘んで説明します。

マルチカメラ

f:id:hecomi:20181027170242p:plain

マルチカメラはその名の通り単純に左右の目のカメラを用意し、それぞれのカメラで別々にシーンをレンダリングする方法です。例えば Oculus Rift DK1 の頃は、カメラリグ下の左右の目の位置にそれぞれカメラコンポーネントが合計 2 つぶら下がっており、左右のビューポートへ割り振られていました。

しかし図を見ても分かるように、この方式では全てのレンダリングパイプラインの処理を文字通り二度行っており、とても無駄の多いものになっています。ここから様々な最適化が行われていきます。

マルチパス

f:id:hecomi:20181027170443p:plain

f:id:hecomi:20181027231312g:plain

最初の最適化として、マルチカメラののレンダリングパイプラインの中から 2 つのカメラに共通する処理を抜き出して一本化することが考え出されました。一つはシャドウ、もう一つはビューカリングの処理です。

はじめにシャドウに関してです。影を生成する際はまずはシャドウマップ(参考)を事前に作成します。このシャドウマップにはライトの位置から見えるシーンのデプスが格納されています。このデプスとカメラから見たシーンのデプスを比較することで光の当たる、当たらないを判別できるのでした。影そのものの描画は、各カメラでのオブジェクト描画時にスクリーンスペースでライティングと同時に行われ、カメラの場所に依存しますが、シャドウマップの生成はライトの位置からのレンダリングになるので、左右のカメラの位置とは関係なく行うことができます。このことから、2 つのカメラで共通のシャドウマップを生成して使うことが出来ます。

次にビューカリングです。これはカメラから見た視錐台の内側にあるオブジェクトをフィルタリングし、外側にあるオブジェクトの描画をスキップする処理です。ビューカリングは SRP(Scriptable Render Pipeline)導入後は C# からも制御できるようになりますが、通常は Unity 内部で実行されています(CPU 処理)。さて、2 つのカメラの位置は左右の目の位置にそれぞれセットされているので視錐台が異なるはずですが、こちらの記事に書かれているように、カメラを後ろに引いて 2 つの視錐台を結合した少し大きなものを用意してあげると、ビューカリングをひとまとめにすることができます。この場合、本来カリングによって除外される領域も含む形になるため、領域内にオブジェクトがたくさんある場合は描画処理でのオーバヘッドが大きくなってしまいます。しかしながら、IPD が平均で 64 mm 前後の小さな領域になるため、さほど問題とはなりません。それよりもシーンを走査する回数を 1 回にできる方が CPU 計算のコスト削減につながるケースの方が圧倒的に多いです。

シングルパス

f:id:hecomi:20181027171323p:plain

f:id:hecomi:20181027231427g:plain

さて、シャドウとカリングを 1 回の処理に追いやることができましたが、続く描画のループ処理はまだ 2 回行われています。コンスタントバッファのセットなど描画するオブジェクト毎に必要な処理にはオーバーヘッドがあるため、2 周するとこのオーバーヘッドも 2 倍になってしまいます。なのでループを 1 回にまとめ、その中で連続して 2 回ドローコールを発行して描画すればこのオーバーヘッドを避けることができます。

しかしマルチパスでは左目用、右目用のオブジェクトは描画先(レンダーターゲット)が異なっていました。同じようにレンダーターゲットを 2 個用意し、1 回にまとめたループのなかでスイッチして描画してしまうと、レンダーターゲットの切り替えがオブジェクト数分発生し、2 回のループに分けてオブジェクトをスイッチするときよりもレンダーターゲットのスイッチのオーバーヘッドが大きいため、より時間がかかってしまいます。スイッチを避けるためにレンダーターゲットアレイを使えばオーバーヘッドを避けられますが、この場合はジオメトリシェーダが必要になってしまい、ジオメトリシェーダを経由することによる全体のスループット低下もある上、シェーダ全体にそこそこ手を加えなければなりません。

そこで Unity では別のアプローチとして、2 倍の横幅をもつ 1 枚のレンダーターゲット(Double-Wide render target、両目のレンダーターゲットをまとめた領域)を用意し、ビューポートをスイッチする方法を採用しました。ビューポートの切替にもコストがかかりますが、レンダーターゲットの切替コストよりも小さく、またジオメトリシェーダも必要としないため、シェーダの変更も少なくてすみます。ビューポートの切り替えも、オブジェクト A、B の描画を左(A)、右(A)、左(B)、右(B)...ではなく、左(A)、右(A)、右(B)、左(B)...とすることで切替オーバーヘッドを半分にすることができます。なお、ビューポートアレイを使う方法もありますが、こちらはレンダーターゲットアレイと同じくジオメトリシェーダが必要になってしまうので採用されていません。

シェーダの変更も少なくて済むとは言いましたが変更は必要です。左右のカメラで異なるパラメタとして、ビュー・プロジェクション行列があります。コンスタントバッファの切り替えは避けたいため、左右両方のビュー・プロジェクション行列を配列に詰めておきます。そして unity_StereoEyeIndex という左右の目で切り替わるインデックスを与えて取り出す形にしています。これにより、UnityObjectToClipPos() などの既存のコードの変更が必要になるのですが、これは後のシェーダの項で解説します。

マルチパスで 2 回やっていたリスト処理が 1 回になることによって、CPU / GPU 共にマルチパスと比べて恩恵を受けることが出来ます。シャドウとビューカリングの最適化をマルチパスでは既に行っていたので単純に 2 倍早くなるわけではないですが、かなりの改善が見込めます。本機能は 5.4 より追加され、PCVR や PS4 を対象にしています。

シングルパス(インスタンシング版)

f:id:hecomi:20181027231513g:plain

シングルパスでは左右のビューポート切替ごとにドローコールを発行していました。つまり 1 つのオブジェクトに対して 2 回ドローコールが呼ばれているわけです。このドローコールを 1 回にしたい...つまり、同じオブジェクトを複数個一度に描画、といえば GPU インスタンシング です。しかしながらシングルパスの方法ではビューポートが分かれてしまっているので、インスタンシングを使うことが出来ません。そこでレンダーターゲットアレイを使うことを再度考えます。

レンダーターゲットアレイを使用する場合はジオメトリシェーダが必要と述べましたが、Direct3D 11.3 の VPAndRTArrayIndexFromAnyShaderFeedingRasterizer を有効にすると SV_RenderTargetArrayIndexSV_ViewportArrayIndex セマンティクスがラスタライザにデータを供給するどのシェーダでも使えるようになるようです。つまり頂点シェーダでも使えるということになります。これによりジオメトリシェーダを使用することによるスループットの低下およびレンダーターゲットの切替コストを避けることが出来ます。こうして同一のビューポートに描画できるようになったので、インスタンシングで描画できるようになりました。しかしながら既存のインスタンシングとバッティングするので、全てのオブジェクトのインスタンス数を 2 倍にするという対応が必要です。N 個インスタンシングで描画するオブジェクトがあれば 2 * N 個描画するようにし、通常の単一のオブジェクトはインスタンス数を 2 にする形です。

ただ、上記 D3D のオプションやインスタンシングができるプラットフォームは限られるため、現在のところステレオインスタンシングが可能なのは、最新のグラフィックドライバのインストールされた Windows 10、PSVR、HoloLens、Magic Leapになります。本機能は 2017.2 より追加されました。

シングルパスマルチビュー

OpenGL / GL ES で動作する場合、Multi-View 拡張(GL_OVR_multiview 拡張)が有効なデバイスにおいてはドライバ自身が両目のレンダーターゲット(テクスチャアレイ)に描画してくれます。

この場合は gl_ViewID がビュー毎の変数を管理するために使えるのですが、Unity ではこれを隠蔽して、ユーザからはシングルパスインスタンシングと同じ実装で使えるようにしてくれています。

パフォーマンス

f:id:hecomi:20181027203136p:plain

概要の最後にパフォーマンスを見てみましょう。これは Unity Austin 2017 で発表された XR Graphics Overview and Best Graphics にて紹介された資料の抜粋で、Viking Village という Unity 社制のアセットでの測定結果です。本来はディファードのシーンですがフォワードにして測定しています。グラフの横軸が 3 ~ 7 なので幅がそのままスケールでない点に注意ですが...、まず CPU に関してみてみると順に小さくなっていることが分かります。マルチパスからシングルパスへの移行でおよそ 20% 改善し、さらに僅かですがドローコール削減によりインスタンシング版で速くなります。一方 GPU に関しては、シングルパスにすることで僅かだけ速くなります。インスタンシングの利用ではほぼ変わらないようです。あくまで本結果はサンプルでの測定結果だとは思いますが、シェーダのコストが支配的なのかどうかといったケースで、かなり測定結果も変わってくると思われますので、採用は自プロジェクトで計測したり、後の項で述べるシェーダの変更などのメリット・デメリットを考慮したりしながら検討するのが良いでしょう。

レンダリングの流れから見てみる

シェーダの解説に入る前に、これまで見てきたレンダリングの流れを Unity 上で見ていきましょう。

マルチパス

f:id:hecomi:20181028010926p:plain

マルチパスを見てみると、

  • UpdateDepthTexture(左)
  • Drawing(左)
    • RenderShadowMap(両方)
    • CollectShadows(左)
    • RenderLoopJob(左)
  • Image Effects(左)
  • UpdateDepthTexture(右)
  • Drawing(右)
    • CollectShadows(左)
    • RenderLoopJob(左)
  • Image Effects(右)

という流れになっています。左右で順番に一連のフォワードのレンダリングパイプラインが流れているのが分かります。また、シャドウマップの作成は左の描画時に 1 回のみになっています。

シングルパス

f:id:hecomi:20181028012522p:plain

シングルパスでは 2 回あったループが 1 回になっています。また、レンダーターゲットの横幅が 2 倍のサイズになっており(右ペーン上部)、左目・右目両方の結果がそこに描画されているのも分かります。Frame Debugger では個々のドローコールは見えないので、一度に左右のビューポートに描画がされているように見えますが、内部的には 2 回呼ばれています。また、スクショでは切れてしまっていますが、UNITY_SINGLE_PASS_STEREO のフラグが立っています。

f:id:hecomi:20181028012826p:plain

最後の XR.Blit のところで、このテクスチャを半分に分けてそれぞれのレンダーターゲット(RTDeviceEyeTextureLeft / Right)に描画しています。

シングルパス(インスタンシング版)

f:id:hecomi:20181028104415p:plain

レンダーターゲットのサイズが片目のサイズに戻りました。Frame Debugger ではレンダーターゲットがアレイかどうかは見えないようです。また、こちらもスクショでは切れてしまっていますが...、STEREO_INSTANCING_ON のフラグが立っています。

f:id:hecomi:20181028104801p:plain

最後の XR.Blit のところで、テクスチャアレイからデバイスのレンダーターゲットへコピーしているのが分かります。

マルチパスでのシェーダ対応

さて、概要が掴めたと思いますので実践編としてシェーダの中でどのような対応がなされているか、またユーザがどのような対応をしないとならないかを見ていきましょう。

まず、マルチパスからですが、こちらはなんと頂点/フラグメントシェーダ、ポストプロセス用のシェーダどちらに対しても何もする必要がありません。というのも、見てきたように通常の単一レンダーターゲット時と同じパイプラインをたどるからです。これがマルチパスの最大の利点で、デフォルトのレンダリングメソッドとしてマルチパスが指定されている理由です。

シングルパスでの頂点/フラグメントシェーダ

シングルパスステレオレンダリングについてのシェーダの説明は以下に書いてあります。

docs.unity3d.com

ここではドキュメントでは触れられていない、内部で暗黙的に何が起きているか、という点と、ドキュメントに記述されているヘルパ関数の意味の解説を行います。まずは座標変換からです。

座標変換

GPU インスタンシングの時もそうでしたが、Unity ではクリップ空間への変換を行う UnityWorldToClipPos() で色々なトリックを使ってユーザの頂点シェーダの記述を簡単にしてくれています。今回も同じようにビューポートのインデックスである unity_StereoEyeIndex を使ったトリックが本関数に隠蔽されています。まず、UnityShaderUtilities.cginc に書いてある関数の定義を見てみましょう。

inline float4 UnityObjectToClipPos(in float3 pos)
{
    return mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(pos, 1.0)));
}

中では頂点座標 posunity_ObjectToWorld でワールド座標変換した後、ビュー・プロジェクション行列の UNITY_MATRIX_VP を掛けています。ワールド座標は左右の目で変わるものではありませんが、左右でカメラ位置は異なります(むしろカメラ位置のみが異なります)。つまりこのカメラに関連したパラメタである UNITY_MATRIX_VP が鍵を握っています。これはシングルパスステレオに限らず、シングルパスインスタンシングやシングルパスマルチビューでも同じです。これを解き明かすために UnityShaderVariables.cginc を見てみましょう。

まず、UNITY_MATRIX_VP などカメラ関連のパラメタは次のように define されています。

#define UNITY_MATRIX_P glstate_matrix_projection
#define UNITY_MATRIX_V unity_MatrixV
#define UNITY_MATRIX_I_V unity_MatrixInvV
#define UNITY_MATRIX_VP unity_MatrixVP
#define UNITY_MATRIX_M unity_ObjectToWorld

unity_MatrixVP を一層かませています。これはステレオと非ステレオで内容が変わる変数になっているのですが、その分岐には USING_STEREO_MATRICES を使っています。

#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

#if defined(USING_STEREO_MATRICES)
    #define glstate_matrix_projection unity_StereoMatrixP[unity_StereoEyeIndex]
    #define unity_MatrixV unity_StereoMatrixV[unity_StereoEyeIndex]
    #define unity_MatrixInvV unity_StereoMatrixInvV[unity_StereoEyeIndex]
    #define unity_MatrixVP unity_StereoMatrixVP[unity_StereoEyeIndex]

    #define unity_CameraProjection unity_StereoCameraProjection[unity_StereoEyeIndex]
    #define unity_CameraInvProjection unity_StereoCameraInvProjection[unity_StereoEyeIndex]
    #define unity_WorldToCamera unity_StereoWorldToCamera[unity_StereoEyeIndex]
    #define unity_CameraToWorld unity_StereoCameraToWorld[unity_StereoEyeIndex]
    #define _WorldSpaceCameraPos unity_StereoWorldSpaceCameraPos[unity_StereoEyeIndex]
#endif

コンスタントバッファを経由して unity_StereoMatrixVP[2] に左右の目のマトリクスの配列が入ってきた後に、unity_StereoEyeIndex を使って unity_MatrixVP に入れるというクッションをかませています。一方、非ステレオ時は直接コンスタントバッファ内の変数名として値が渡ってきます。

CBUFFER_START(UnityPerFrame)
    ...
#if !defined(USING_STEREO_MATRICES)
    float4x4 glstate_matrix_projection;
    float4x4 unity_MatrixV;
    float4x4 unity_MatrixInvV;
    float4x4 unity_MatrixVP;
    int unity_StereoEyeIndex;
#endif
    ...
CBUFFER_END

カメラに関連するパラメタはすべて同じ方法で展開されるようになっています。では、最後に unity_StereoEyeIndex はどうやって渡ってきているか見てみましょう。

#if defined(UNITY_STEREO_MULTIVIEW_ENABLED) && defined(SHADER_STAGE_VERTEX)
    #define unity_StereoEyeIndex UNITY_VIEWID
    UNITY_DECLARE_MULTIVIEW(2);
#elif defined(UNITY_STEREO_INSTANCING_ENABLED) || defined(UNITY_STEREO_MULTIVIEW_ENABLED)
    static uint unity_StereoEyeIndex;
#elif defined(UNITY_SINGLE_PASS_STEREO)
    GLOBAL_CBUFFER_START(UnityStereoEyeIndex)
        int unity_StereoEyeIndex;
    GLOBAL_CBUFFER_END
#endif

3 種類の分岐があります。上から、見ていきましょう。

マルチビューのときは UNITY_VIEWID を直接使っています。これは gl_ViewID を単にリネームしたものです。マルチビューの説明の時に見たようにドライバ側の仕組みでこの値が受け取れるのでした。なお、マルチビューに関してはステージごとにもう少し細かく見ていかないとならないのですが今回は割愛します。。

次にステレオインスタンシングの時ですが、static 変数として宣言されています。では代入はどこで行われるかというと頂点シェーダ内で行われます。先のシングルパスステレオインスタンシング自体のところでも見たようにオプションが有効になっていれば頂点シェーダで値が取得できるようになるのでした。詳しくは後述します。

最後に今回見ていくシングルパスステレオのときですが、コンスタントバッファ経由の変数として渡ってきます。2 回に分かれているドローコールで別々の値がここに入ってくるようになるわけですね。

さて、こうして左右の目の別々のビュー・プロジェクション行列が得られるようになりました。注目したいのは、これは UnityObjectToClipPos() が暗黙的に処理してくれる内容です。つまり、シングルパスステレオでは、座標変換に関してはユーザ側では UnityObjectToClipPos() を使っている限り修正は要らないということになります。

シングルパスでのポストプロセスシェーダ

座標変換は影響を受けませんでしたが、一方でレンダーターゲットの横幅が 2 倍になっていることからポストプロセス(イメージエフェクト)は大きな影響を受けます。

Blit ベースのイメージエフェクトでの対応

まず、簡単なイメージエフェクトを見てみましょう。次のようなスクリプトを用意します。

using UnityEngine;

[ExecuteInEditMode]
public class SPSR_ImageEffect : MonoBehaviour
{
    [SerializeField]
    Material material;

    void OnRenderImage(RenderTexture src, RenderTexture dst)
    {
        if (material)
        {
            Graphics.Blit(src, dst, material);
        }
    }
}

指定されたマテリアルで Graphics.Blit() を行うだけのものです。では次のようなシェーダを書いてマテリアルとして割り当ててみます。

Shader "Hidden/SPSR_ImageEffect"
{

Properties
{
    _MainTex ("Texture", 2D) = "white" {}
}

SubShader
{

Cull Off ZWrite Off ZTest Always

CGINCLUDE
            
#include "UnityCG.cginc"

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

struct v2f
{
    float4 vertex : SV_POSITION;
    float2 uv : TEXCOORD0;
};

v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = v.uv;
    return o;
}

sampler2D _MainTex;

fixed4 frag (v2f i) : SV_Target
{
    fixed4 col = tex2D(_MainTex, i.uv);
    col = 1 - col;
    return col;
}

ENDCG

Pass
{
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    ENDCG
}

}

}

これは色を反転させるコードになっています。では結果を見てみましょう。

f:id:hecomi:20181104185950p:plain

なんと 4 眼対応になってしまいます(違)。これは、イメージエフェクトはシングルパスステレオでは左右の目のビューポートで 1 回ずつ、合計 2 回呼ばれるからです(Frame Debugger で見ることが出来ます)。一方でイメージエフェクトのシェーダに入ってくる _MainTex はレンダーターゲット全体、つまり左右の目が統合されたテクスチャなので、0 - 1 でそのままサンプリングしてしまうと、左目に両目分テクスチャ、右目に両目分テクスチャ、となってしまい、合計 4 つのビューが現れたように見える形になってしまいます(こういったシングルパスステレオ未対応のイメージエフェクトを重ねれば重ねるほど倍々で増えていきます)。

これを避けるために、UnityStereoScreenSpaceUVAdjust() という関数が用意されています。これを次のように使います。

sampler2D _MainTex;
half4 _MainTex_ST;

fixed4 frag (v2f_img i) : SV_Target
{
    fixed4 col = tex2D(_MainTex, UnityStereoScreenSpaceUVAdjust(i.uv, _MainTex_ST));
    ...
}

すると正常な画が表示されるようになります。

f:id:hecomi:20181104204724p:plain

UnityStereoScreenSpaceUVAdjust() は次のように展開されます。

inline float2 UnityStereoScreenSpaceUVAdjustInternal(float2 uv, float4 scaleAndOffset)
{
    return uv.xy * scaleAndOffset.xy + scaleAndOffset.zw;
}

inline float4 UnityStereoScreenSpaceUVAdjustInternal(float4 uv, float4 scaleAndOffset)
{
    return float4(UnityStereoScreenSpaceUVAdjustInternal(uv.xy, scaleAndOffset), UnityStereoScreenSpaceUVAdjustInternal(uv.zw, scaleAndOffset));
}

#define UnityStereoScreenSpaceUVAdjust(x, y) UnityStereoScreenSpaceUVAdjustInternal(x, y)

簡単に説明すると、左右のイメージエフェクトの適用で _MainTex_ST に異なる Tiling と Offset の値が入ってくるので、これを考慮してあげれば左右の目それぞれの領域を取り出せることになります。具体的には、左目では _MainTex_ST(0.5, 1.0, 0.0, 0.0)、右目では 0.5, 1.0, 0.5, 0.0) が入ってきます。左右を 2 倍に引き伸ばして右目の時には 0.5 だけオフセットを持たせる形ですね。

メッシュ描画によるポストプロセス処理対応

イメージエフェクトとしては、Graphics.Blit() するもの以外にも、例えば CommandBuffer や低レベルネイティブプラグインインターフェースを使ってメッシュ描画してあげるみたいなものもあります。こういったメッシュを描画するときは逆に UnityStereoTransformScreenSpaceTex() を使って UV を調整して上げる必要があります。

#if defined(UNITY_SINGLE_PASS_STEREO)
inline float2 UnityStereoTransformScreenSpaceTex(float2 uv)
{
    return TransformStereoScreenSpaceTex(saturate(uv), 1.0);
}
#else
#define UnityStereoTransformScreenSpaceTex(uv) uv
#endif

こちらはまだ試せていないので使用する機会が出てきたら別途解説を書きます。

スクリーンスペース座標の対応

同様に スクリーン座標を扱うオブジェクトやポストエフェクトが影響を受けます。どっちも同じ対応になるので、ここでは(説明が簡単なので)オブジェクトでのスクリーン座標の使用について説明したいと思います。まず、コードと結果を見てみましょう。次のようにスクリーン座標に応じた色を出力してみるのを考えてみます(あくまで参考例で、左右の目で同じ場所の色が異なってしまう点は大目に見てください)。

Shader "SPSR/ScreenUvExample"
{

SubShader
{

Tags { "RenderType"="Opaque" }

CGINCLUDE

#include "UnityCG.cginc"

struct appdata
{
    float4 vertex : POSITION;
};

struct v2f
{
    float4 vertex : SV_POSITION;
    float4 projPos : TEXCOORD0;
};

v2f vert(appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.projPos = ComputeScreenPos(o.vertex);
    return o;
}

fixed4 frag(v2f i) : SV_Target
{
    float2 screenUV = i.projPos.xy / i.projPos.w;
    fixed4 col = float4(screenUV, 0, 1);
    return col;
}

ENDCG

Pass
{
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    ENDCG
}

}

}

これを実行してみると次のようになります。

f:id:hecomi:20181104212108p:plain

各目のスクリーン座標ではなく、左右が統合された下でのスクリーン座標になってしまっています。これは ComputeScreenPos() の中でこうするような計算が入っているからです。この件に関しては、以前ソフトパーティクルの記事の中で触れました。

tips.hecomi.com

おさらいすると、ComputeScreenPos() は次のように展開されます。

inline float4 ComputeScreenPos(float4 pos)
{
    float4 o = ComputeNonStereoScreenPos(pos);
#if defined(UNITY_SINGLE_PASS_STEREO)
    o.xy = TransformStereoScreenSpaceTex(o.xy, pos.w);
#endif
    return o;
}

ComputeNonStereoScreenPos() はクリップ空間のプラットフォーム間の差異を吸収し、0 ~ w の値に xy を押し込めてくれるものでした。そして、TransformStereoScreenSpaceTex() はシングルパスステレオの 2 倍幅によるオフセットを吸収する役割をするものでした。

#if defined(UNITY_SINGLE_PASS_STEREO)
float2 TransformStereoScreenSpaceTex(float2 uv, float w)
{
    float4 scaleOffset = unity_StereoScaleOffset[unity_StereoEyeIndex];
    return uv.xy * scaleOffset.xy + scaleOffset.zw * w;
}
#else
#define TransformStereoScreenSpaceTex(uv, w) uv
#endif

なので、ここで左右それぞれで 0 ~ 1 になるようなスクリーン座標がほしいときは、ComputeNonStereoScreenPos() のみを実行するようにすれば良い形になります。

v2f vert(appdata v)
{
    ...
    //o.projPos = ComputeScreenPos(o.vertex);
    o.projPos = ComputeNonStereoScreenPos(o.vertex);
    ...
}

f:id:hecomi:20181104213108p:plain

しかしながら、サーフェスシェーダを使うときは頂点シェーダからは ComputeScreenPos() された値が渡ってきてしまいます。そのため次のようにサーフェスシェーダ内で戻して上げる必要があります。

Shader "SPSR/ScreenUvExample_SurfaceShader" 
{

Properties 
{
    _Glossiness("Smoothness", Range(0,1)) = 0.5
    _Metallic("Metallic", Range(0,1)) = 0.0
}

SubShader 
{

Tags { "RenderType"="Opaque" }

CGPROGRAM
#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

sampler2D _MainTex;

struct Input 
{
    float2 uv_MainTex;
    float4 screenPos;
};

half _Glossiness;
half _Metallic;

void surf(Input IN, inout SurfaceOutputStandard o) 
{
    float2 screenUV = IN.screenPos.xy / IN.screenPos.w;
#if UNITY_SINGLE_PASS_STEREO
    float4 scaleOffset = unity_StereoScaleOffset[unity_StereoEyeIndex];
    screenUV = (screenUV - scaleOffset.zw) / scaleOffset.xy;
#endif
    o.Albedo = fixed3(screenUV, 0);
    o.Metallic = _Metallic;
    o.Smoothness = _Glossiness;
}
ENDCG

}

FallBack "Diffuse"

}

f:id:hecomi:20181104213557p:plain

シングルパスインスタンシングでの頂点/フラグメントシェーダ対応

非インスタンシング版の説明が長くなりましたが、まだまだ続きます。続いてはインスタンシング版のお話です。シングルパスステレオインスタンシング(またはマルチビュー、以下略)では対応をしないシェーダを使うと片目のみにしかモデルが表示されなくなります。

f:id:hecomi:20181104220711p:plain

これはインスタンシングの ID を考慮していないからです。シングルパスステレオインスタンシングの場合は先の通常の GPU インスタンシングの記事で解説したのと同じように、いくつかインスタンス ID の取り扱い回りでシェーダへの追記が必要になります。併せて読んでいただくとより理解が深まると思います(逆に先に解説した内容についてはサラッと流します)。

tips.hecomi.com

しかしながらステレオな分、追記する量も少し増えます。以下のマニュアルを参考に解説していきます。

docs.unity3d.com

必要な変更は 5 行だけです。それでは見ていきましょう。

頂点シェーダ入力構造体

まずは入力構造体を見てみます。

struct appdata
{
    float4 vertex : POSITION;
    float2 uv : TEXCOORD0;
    ...
    UNITY_VERTEX_INPUT_INSTANCE_ID // 追加
};

まず、通常の GPU インスタンシング同様、入力構造体に UNITY_VERTEX_INPUT_INSTANCE_ID を追加します。これは SV_InstanceID セマンティクスで与えられる instanceID メンバを追加するものでした。ここに左右の目の ID がインスタンシングによって入ってきます。

頂点シェーダ出力構造体

次に出力構造体を見てみます。

struct v2f
{
    float2 uv : TEXCOORD0;
    float4 vertex : SV_POSITION;
    ...
    UNITY_VERTEX_OUTPUT_STEREO // 追加
};

出力構造体には UNITY_VERTEX_OUTPUT_STEREO が追加されています。これは次のようにレンダーターゲットのインデックスを格納する stereoTargetEyeIndex メンバへと展開されます。

#ifdef UNITY_STEREO_INSTANCING_ENABLED
    #define DEFAULT_UNITY_VERTEX_OUTPUT_STEREO uint stereoTargetEyeIndex : SV_RenderTargetArrayIndex;
#elif defined(UNITY_STEREO_MULTIVIEW_ENABLED)
    #define DEFAULT_UNITY_VERTEX_OUTPUT_STEREO float stereoTargetEyeIndex : BLENDWEIGHT0;
#else
    #define DEFAULT_UNITY_VERTEX_OUTPUT_STEREO
#endif
頂点シェーダ

ではこれらを扱う頂点シェーダを見てみます。

v2f vert (appdata v)
{
    v2f o;

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

    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = v.uv;

    return o;
}

まず、UNITY_SETUP_INSTANCE_IDGPU インスタンシングのときとは違う形で展開されます。

#if defined(UNITY_STEREO_INSTANCING_ENABLED)
    #define DEFAULT_UNITY_SETUP_INSTANCE_ID(input) \
        { \
            UnitySetupInstanceID(UNITY_GET_INSTANCE_ID(input)); \
            UnitySetupCompoundMatrices(); \
        }
#endif

#if !defined(UNITY_SETUP_INSTANCE_ID)
    #define UNITY_SETUP_INSTANCE_ID(input) DEFAULT_UNITY_SETUP_INSTANCE_ID(input)
#endif

UNITY_SETUP_INSTANCE_IDDEFAULT_UNITY_SETUP_INSTANCE_ID を再定義しているのは同じ流れです。が、中では UnitySetupInstanceID()UnitySetupCompoundMatrices() を読んでいます。それぞれ見ていきましょう。

#define UNITY_GET_INSTANCE_ID(input) input.instanceID

void UnitySetupInstanceID(uint inputInstanceID)
{
    unity_StereoEyeIndex = inputInstanceID & 0x01;
    unity_InstanceID = unity_BaseInstanceID + (inputInstanceID >> 1);
}

unity_StereoEyeIndex は入力構造体から instanceID を引いてくるものですが、0x01 を掛けています。これはステレオインスタンシングと通常のインスタンシング併用時に、インスタンス数が 2 倍になっているその ID から奇数・偶数を分離して左・右に割り振っています。同様に、unity_InstanceID は 2 で割った(= 1 bit シフトした)数字でステレオ分を省いた形にしています。

次にマトリックス周りです。

static float4x4 unity_MatrixMVP_Instanced;
static float4x4 unity_MatrixMV_Instanced;
static float4x4 unity_MatrixTMV_Instanced;
static float4x4 unity_MatrixITMV_Instanced;

void UnitySetupCompoundMatrices()
{
    unity_MatrixMVP_Instanced = mul(unity_MatrixVP, unity_ObjectToWorld);
    unity_MatrixMV_Instanced = mul(unity_MatrixV, unity_ObjectToWorld);
    unity_MatrixTMV_Instanced = transpose(unity_MatrixMV_Instanced);
    unity_MatrixITMV_Instanced = transpose(mul(unity_WorldToObject, unity_MatrixInvV));
}

#undef UNITY_MATRIX_MVP
#undef UNITY_MATRIX_MV
#undef UNITY_MATRIX_T_MV
#undef UNITY_MATRIX_IT_MV
#define UNITY_MATRIX_MVP    unity_MatrixMVP_Instanced
#define UNITY_MATRIX_MV     unity_MatrixMV_Instanced
#define UNITY_MATRIX_T_MV   unity_MatrixTMV_Instanced
#define UNITY_MATRIX_IT_MV  unity_MatrixITMV_Instanced

マトリクス周りはインスタンシング版の static 変数を用意して、それを従来のものと置き換える処理をしています。非インスタンシング版のシングルパスステレオのときのシェーダの説明を思い出してほしいのですが、unity_MatrixVP などは unity_StereoEyeIndex を使用して配列からそれぞれの目に応じたマトリクスを取り出しているマクロなのでした。非ステレオ時はコンスタントバッファを通じて CPU 側から与えられた unity_StereoEyeIndex を取得できていたので何もする必要がなかったのですが、ここでは先の UnitySetupInstanceID() を実行して初めて unity_StereoEyeIndex が取得できます。こうして取得した ID を使って、ここで再定義することで、適切な座標変換が UnityObjectToClipPos() で行えるようになる仕組みになっています。

ここまでが 1 行目...、結構中でやっていることは濃いですね。次に 2 行目の UNITY_INITIALIZE_OUTPUT です。こちらは至って簡単で、単に構造体を 0 初期化しているだけです。HLSLSupport.cginc で定義されています。

#if defined(UNITY_COMPILER_HLSL) || defined(SHADER_API_PSSL) || defined(UNITY_COMPILER_HLSLCC)
#define UNITY_INITIALIZE_OUTPUT(type,name) name = (type)0;
#else
#define UNITY_INITIALIZE_OUTPUT(type,name)
#endif

サポートしている環境下でのみ 0 初期化されるようになっています。

最後は 3 行目の UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO です。こちらは出力構造体 v2f にレンダーターゲットのインデックスを出力するマクロになっています。

#ifdef UNITY_STEREO_INSTANCING_ENABLED
    #define DEFAULT_UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output) \
        output.stereoTargetEyeIndex = unity_StereoEyeIndex
#elif defined(UNITY_STEREO_MULTIVIEW_ENABLED)
    // HACK: Workaround for Mali shader compiler issues with 
    //       directly using GL_ViewID_OVR (GL_OVR_multiview). 
    //       This array just contains the values 0 and 1.
    #define DEFAULT_UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output) \
        output.stereoTargetEyeIndex = unity_StereoEyeIndices[unity_StereoEyeIndex].x;
#else
    #define DEFAULT_UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output)
#endif

#if !defined(UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO)
    #define UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output) \
        DEFAULT_UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output)
#endif

マルチビューの場合は、ARM の Mali GPU 用のシェーダコンパイラ対応のためのワークアラウンドが入っていてアレですが、要は unity_StereoEyeIndex を出力構造体につめているだけです。

こうしてフラグメントシェーダへと送られたインデックスは、フラグメントシェーダ内で UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX を使えば、フラグメントシェーダ内でも unity_StereoEyeIndex を使えるようになります。

#ifdef UNITY_STEREO_INSTANCING_ENABLED
    #define DEFAULT_UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input) \
        unity_StereoEyeIndex = input.stereoTargetEyeIndex;
#elif defined(UNITY_STEREO_MULTIVIEW_ENABLED)
    #if defined(SHADER_STAGE_VERTEX)
        #define DEFAULT_UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input)
    #else
        #define DEFAULT_UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input) \
            unity_StereoEyeIndex = (uint)input.stereoTargetEyeIndex;
    #endif
#else
    #define DEFAULT_UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input)
#endif

#if !defined(UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX)
    #define UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input) \
        DEFAULT_UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input)
#endif

例えば UNITY_DECLARE_DEPTH_TEXTURE を通じて宣言された _CameraDepthTexture などはテクスチャアレイになり、SAMPLE_DEPTH_TEXTURE_PROJ で取り出す時にテクスチャアレイから unity_StereoEyeIndex を使って取り出す形になります。

と思ったのですが、Particle 系のシェーダでのソフトパーティクルのコードだとこの UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX を使っていないのですが、どうやって取り出しているんでしょうか...。どなたかご存知の方は教えていただけると嬉しいです。。

シングルパスインスタンシングでのポストプロセスシェーダ

ポストプロセスでも対応が必要です。というのも、入力されるテクスチャ(_MainTex)が左右の目のものを格納したテクスチャアレイになっているからです。以下にシングルパスインスタンシング対応のポストプロセスシェーダの例を示します。

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

struct v2f
{
    float2 uv : TEXCOORD0;
    float4 vertex : 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.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = v.uv;
    return o;
}

UNITY_DECLARE_SCREENSPACE_TEXTURE(_MainTex);

fixed4 frag (v2f i) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);

    fixed4 col = UNITY_SAMPLE_SCREENSPACE_TEXTURE(_MainTex, i.uv);

    // col を使った処理(ここでは色反転処理)
    col = 1 - col;

    return col;
}

基本的にはモデル用のコードと同じです。ポストプロセス専用のコードとしては 2 つ、UNITY_DECLARE_SCREENSPACE_TEXTUREUNITY_SAMPLE_SCREENSPACE_TEXTURE です。

#define UNITY_DECLARE_TEX2DARRAY(tex) \
    Texture2DArray tex; \
    SamplerState sampler##tex

#define UNITY_SAMPLE_TEX2DARRAY(tex, coord) \
    tex.Sample(sampler##tex, coord)

#if defined(UNITY_STEREO_INSTANCING_ENABLED) || defined(UNITY_STEREO_MULTIVIEW_ENABLED)
    #define UNITY_DECLARE_SCREENSPACE_TEXTURE \
        UNITY_DECLARE_TEX2DARRAY
    #define UNITY_SAMPLE_SCREENSPACE_TEXTURE(tex, uv) \
        UNITY_SAMPLE_TEX2DARRAY(tex, float3((uv).xy, (float)unity_StereoEyeIndex))
#else
    #define UNITY_DECLARE_SCREENSPACE_TEXTURE(tex) \
        sampler2D tex;
    #define UNITY_SAMPLE_SCREENSPACE_TEXTURE(tex, uv) \
        tex2D(tex, uv)
#endif

unity_StereoEyeIndexcoord の第3引数に指定してテクスチャアレイからサンプリングする形へと変換されます。また、非インスタンシング時は従来のポストプロセスと同じ単一のテクスチャのサンプルのコードになるように変換されます。

他にもステレオになっているテクスチャの宣言(デプスなど)や色の取得はこういったマクロ経由で行うことにより同じ仕組みでステレオインスタンシング対応になります。

シングルパスインスタンシングのシェーダのデバッグ

フラグメントシェーダ内で左右の目で独立した処理を書いてデバッグしたい際は、UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX をした上で、unity_StereoEyeIndex を使えば良いです。

UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
return lerp(_LeftEyeColor, _RightEyeColor, unity_StereoEyeIndex);

こうすると左右で違う色を表示することが出来ます。

シングルパスインスタンシングのスクリプトへの影響

完全にインスタンス数も含めコンピュートバッファを経由して GPU サイドで完結するインスタンシングを行う Graphics.DrawProceduralIndirect() および CommandBuffer.DrawProceduralIndirect() の場合は、手動でインスタンス数を増やしてあげなければなりません。具体例がなかったので、別の機会に試してみようと思います。

XR 向けの話ではありませんが、DrawProceduralIndirect() 自身については以下に解説を書きましたのでご参考まで。

tips.hecomi.com

おわりに

今回は Unity の XR 向けレンダリングの解説を行いました。「シングルパス」とは良く聞きますが、具体的にどうなっているのか、ユーザレベルで何を行わないといけないのか、またそれはどうしてなのか、なかなか情報がまとまっておらず私のように理解できていなかった方も多かったのではないでしょうか。私自身も不明瞭だった部分をこうしてまとめることで良い勉強になりました。

ステレオレンダリングについて更に勉強したい方は、NVIDIA の VRWorks で、Multi-View ShadingVR SLI など、より進んだ VR レンダリング手法を見ることができます。

加えて、ステレオに限らなければ Variable Rate ShadingLens Matched ShadingMulti-Res Shading といった手法もあります。MRS は Oculus Go の Snapdragon 821 の Tiled Renderer 機能を使った Fixed Foveated Rendering(FFR)も同じ概念の手法だと思います。

何が行われているか概要を知っていれば SDK 側で使えるようにしてくれているこれらの機能ですが、今回のようにちょっとだけ中を覗いてみるのもなかなかに面白いと思いますので、ぜひ興味を持たれたら上記技術についてもまとめてもらえると嬉しいです。

参考