概要
前回書いた「URPで背景をぼかしてuGUIの背景にする」で書いたことの続編です。
具体的には、前回の実装のままでVRのマルチビュー(やSingle Pass Instanced)に変更すると正常に描画されないという問題があったのでそれへの対応方法がメインの内容となります。
マルチビューで動いているかどうか伝わらないですが(w)、動作した動画をアップしました。
URPで実装したすりガラス風表現を、マルチビューにも対応させた。情報少なすぎてめちゃ苦労した・・。 #Unity #URP #madewithunity pic.twitter.com/jiHM2Ocado
— edom18@XR / MESON CTO (@edo_m18) 2020年11月8日
今回の問題を対処したものはGitHubのリポジトリにマージ済みです。
- 概要
- マルチビューに対応する
- ScriptableRenderPassでRenderTextureを生成する際の注意点
- 参考にした記事
マルチビューに対応する
イメージエフェクト(ブラー)については前回の記事とほぼ同じです。
それをベースにいくつかの部分をマルチビュー対応していきます。
なお、マルチビューなどのステレオレンダリングについては凹みさんの以下の記事が超絶詳しく解説してくれているので興味がある方はそちらを参考にしてみてください。
そもそもマルチビューとは
マルチビューとは一言で言うとOpenGLが持つOVR_multiviewという拡張機能です。
VRの場合、両目にレンダリングする必要があるためどうしても処理負荷が高くなりがちです。
そこで様々な方法が考え出されました。(それらについては前述の凹みさんの記事を参照ください)
それに合わせてGPUベンダー側も要望に答える形で新しい機能を追加したりしています。
このOpenGLの拡張機能もそうした新機能を使うためのものです。
自分もまだ正確に理解しきれてはいないのですが、VRの両目レンダリングの負荷を下げるために、一度だけレンダリングのコマンドを送信すると、それをよしなに複製して両目分にレンダリングしてくれる、というような機能です。
Oculusのドキュメントから引用すると以下のように説明されています。
マルチビューを有効化すると、オブジェクトは一度左のアイバッファーにレンダリングされた後、頂点位置と視覚依存変数(反射など)に適切な変更が加えられて、自動的に右のバッファーに複製されます。
また、OculusのWebGL版のドキュメントでは以下のように説明されています。
マルチビュー拡張機能では、ドローコールがテクスチャー配列の対応する各エレメントにインスタンス化されます。頂点プログラムは、新しいViewID変数を使用して、ビューごとの値(通常は頂点位置と反射などの視覚依存変数)を計算します。
ドローコールがテクスチャ配列ごと(つまり両目のふたつ)にインスタンス化されることで実現しているようですね。
そしてこれを実現しているのがレンダーターゲットアレイと呼ばれる、レンダーターゲット(ビュー)を配列にしたものです。
なので「マルチビュー」なんですね。
そしてこの「レンダーターゲットアレイ」というのが今回の修正のキモです。
どういうことかと言うと、前回の実装ではレンダーターゲットアレイではなく、あくまで片目用の通常のレンダリングにのみ対応した書き方をしていました。
(マルチパスの場合は片目ずつそれぞれレンダリングしてくれていたので問題にならなかった)
だからマルチビューにした途端に正常に動かなくなっていたというわけです。
しかし、マルチビューかどうかを判定して色々処理を書くのはとても骨が折れます。
そこでUnityは、どちらの設定であっても正しく処理を行えるようにするためのマクロをたくさん用意してくれています。
今回の修正は主に、それらマクロを使ってどう記述したらいいかということの説明になります。
その他、マルチビューについての解説は以下の記事を参照ください。
マルチビュー対応マクロを使う
前述のように、それぞれの処理はマクロを使うことでマルチビューでもそうでなくても正常に動作するコードを書くことができます。
それほど長いコードではないので実際に適用済みのコードをまず貼ってしまいましょう。
Shader "Custom/BlurEffect_Adapted" { Properties { _MainTex("Texture", 2D) = "white" {} } HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" static const int samplingCount = 10; TEXTURE2D_X(_MainTex); SAMPLER(sampler_MainTex); uniform half4 _Offsets; uniform half _Weights[samplingCount]; struct appdata { half4 pos : POSITION; half2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct v2f { half4 pos : SV_POSITION; half2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID UNITY_VERTEX_OUTPUT_STEREO }; v2f vert(appdata v) { v2f o; UNITY_SETUP_INSTANCE_ID(v); UNITY_TRANSFER_INSTANCE_ID(v, o); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); o.pos = mul(unity_MatrixVP, mul(unity_ObjectToWorld, half4(v.pos.xyz, 1.0h))); o.uv = UnityStereoTransformScreenSpaceTex(v.uv); return o; } half4 frag(v2f i) : SV_Target { UNITY_SETUP_INSTANCE_ID(i); UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i); half4 col = 0; [unroll] for (int j = samplingCount - 1; j > 0; j--) { col += SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, i.uv - (_Offsets.xy * j)) * _Weights[j]; } [unroll] for (int k = 0; k < samplingCount; k++) { col += SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, i.uv + (_Offsets.xy * k)) * _Weights[k]; } half3 grad1 = half3(1.0, 0.95, 0.98); half3 grad2 = half3(0.95, 0.95, 1.0); half3 grad = lerp(grad1, grad2, i.uv.y); col.rgb *= grad; col *= 1.15; return col; } ENDHLSL SubShader { Pass { ZTest Off ZWrite Off Cull Back Fog { Mode Off } HLSLPROGRAM #pragma fragmentoption ARB_precision_hint_fastest #pragma vertex vert #pragma fragment frag ENDHLSL } } }
ビルトインのレンダーパイプライン向けにイメージエフェクトを書かれたことがある人であればちょっとした違いに気付くかと思います。
まず大きな違いはHLSLで記述することです。なのでCGPROGRAM
ではなくHLSLPROGRAM
で始まっているのが分かるかと思います。
そしてこれから紹介するマクロは以下の.hlsl
ファイルに定義されています。
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
これをインクルードすることで以下のマクロたちが使えるようになります。
マクロを使ってテクスチャを宣言する
ではさっそく上から見ていきましょう。
まずはテクスチャの宣言です。
前述したように、マルチビューでない場合は通常のテクスチャで、マルチビューの場合は配列として処理を行う必要があります。
ということで、それを設定に応じてよしなにしてくれるマクロを使って書くと以下のようになります。
TEXTURE2D_X(_MainTex); SAMPLER(sampler_MainTex);
TEXTURE2D_X
の_X
が次元を表していると考えると覚えやすいかと思います。
そして以前は必要なかったサンプラの宣言も合わせて行っています。
修正前は以下のようになっていました。
sampler2D _MainTex;
マクロを使ってテクスチャからフェッチする
次はテクスチャの使い方です。
まず、UVの座標空間が若干異なるため、それを変換するための関数を実行して変換してやります。
// そのままフラグメントシェーダに渡すのではなく、関数を通して変換する o.uv = UnityStereoTransformScreenSpaceTex(v.uv);
テクスチャフェッチは以下のようにマクロを使います。
half4 col = SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, i.uv);
使う場合も同様にSAMPLE_TEXTURE2D_X
と_X
がついていますね。
そして第2引数にサンプラを指定します。それ以外の引数は普段見るものと違いはありません。
ビューIDを適切に取り扱う
実は上記マクロだけでは正常にレンダリングされません。
というのも、マルチビューは配列を利用して処理を最適化するものだと説明しました。
配列ということは「どちらのテクスチャにアクセスしたらいいか」という情報がなければなりません。
そしてそのセットアップはまた別のマクロを使って行います。
セットアップは構造体の宣言に手を加え、適切に初期化を行う必要があります。
ビューIDを追加するマクロ
新しくなった構造体の宣言は以下のようになります。
struct appdata { half4 pos : POSITION; half2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct v2f { half4 pos : SV_POSITION; half2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID UNITY_VERTEX_OUTPUT_STEREO };
appdata
、つまり頂点シェーダの入力にはUNITY_VERTEX_INPUT_INSTANCE_ID
を追加し、v2f
、つまりフラグメントシェーダの入力にはUNITY_VERTEX_INPUT_INSTANCE_ID
とUNITY_VERTEX_OUTPUT_STEREO
のふたつを追加します。
マクロの中身については後述しますが、こうすることで適切にインデックスを渡すことができるようになります。
ビューIDの初期化
続いてシェーダ関数内で値を適切に初期化します。具体的には以下のようにマクロを追加します。
v2f vert(appdata v) { v2f o; UNITY_SETUP_INSTANCE_ID(v); UNITY_TRANSFER_INSTANCE_ID(v, o); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); // 以下省略 }
頂点シェーダ関数の冒頭でマクロを利用して初期化を行います。
次はフラグメントシェーダ。
half4 frag(v2f i) : SV_Target { UNITY_SETUP_INSTANCE_ID(i); UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i); // 以下省略 }
処理についてはマクロを追加するだけなのでとても簡単ですね。
マクロの役割
さて、ではこれらマクロはなにをしてくれているのでしょうか。
先にざっくり説明してしまうと、前述した配列へ適切にアクセスできるようにインデックスを処理する、ということになります。
ということでそれぞれのマクロを紐解いていきましょう。
TEXTURE2D_XとSAMPLER
これはテクスチャの宣言時に用いるマクロです。これがどう展開されるか見ていきましょう。
宣言では以下のマクロによって分岐が発生します。
#if defined(UNITY_STEREO_INSTANCING_ENABLED) || defined(UNITY_STEREO_MULTIVIEW_ENABLED)
見ての通り、マルチビュー(かGPUインスタンシング)がオンの場合に異なる挙動になります。
それぞれの定義を見ていくと最終的に以下のようにそれぞれ展開されることが分かります。
// 通常 #define TEXTURE2D(textureName) Texture2D textureName // マルチビュー #define TEXTURE2D_ARRAY(textureName) Texture2DArray textureName
通常時はただのTexture2D
として宣言され、マルチビューの場合はTexture2DArray
として宣言されるのが分かりました。
続いてSAMPLER
です。こちらは素直に以下に展開されます。
#define SAMPLER(samplerName) SamplerState samplerName
SAMPLE_TEXTURE2D_X
次は実際に利用する際のマクロです。これもどう展開されるか見てみましょう。
こちらも同様にマルチビューか否かによって分岐されます。
分岐後はそれぞれ以下のように展開されます。
// 通常 #define SAMPLE_TEXTURE2D(textureName, samplerName, coord2) textureName.Sample(samplerName, coord2) // マルチビュー // 以下を経由して、 #define SAMPLE_TEXTURE2D_X(textureName, samplerName, coord2) SAMPLE_TEXTURE2D_ARRAY(textureName, samplerName, coord2, SLICE_ARRAY_INDEX) // 最終的にこう展開される #define SAMPLE_TEXTURE2D_ARRAY(textureName, samplerName, coord2, index) textureName.Sample(samplerName, float3(coord2, index))
こちらはマルチビューの場合は少しだけ複雑です。とはいえ、配列へアクセスするための添字を追加してアクセスしている部分だけが異なりますね。
そしてその添字はSLICE_ARRAY_INDEX
というマクロによってさらに展開されます。
SLICE_ARRAY_INDEXでテクスチャ配列の添字を得る
SLICE_ARRAY_INDEX
は以下のように定義されています。
#define SLICE_ARRAY_INDEX unity_StereoEyeIndex
XRっぽい記述が出てきました。次に説明するマクロによってこのインデックスが解決されます。
ここで大事な点は、マクロを利用することでテクスチャなのかテクスチャ配列なのかを気にせずに透過的に宣言が行えるという点です。
UNITY_VERTEX_OUTPUT_STEREO
構造体のところで使用したマクロです。名前からも分かるようにXR関連のレンダリングに関する設定になります。
UNITY_STEREO_MULTIVIEW_ENABLED
が定義されている場合に以下のように展開されます。
#define DEFAULT_UNITY_VERTEX_OUTPUT_STEREO float stereoTargetEyeIndexAsBlendIdx0 : BLENDWEIGHT0;
#define DEFAULT_UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input) unity_StereoEyeIndex = (uint) input.stereoTargetEyeIndexAsBlendIdx0;
unity_StereoEyeIndex
はSLICE_ARRAY_INDEX
マクロが展開されたときに使われているものでした。ここでまさに定義され、値が設定されているというわけです。
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output)
頂点シェーダで利用されているマクロです。これは、適切にoutput.stereoTargetEyeIndexAsBlendIdx0
の値を設定するために用いられます。
展開されたあとの状態を見てみましょう。
#define DEFAULT_UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output) output.stereoTargetEyeIndexAsBlendIdx0 = unity_StereoEyeIndices[unity_StereoEyeIndex].x;
フラグメントシェーダに渡す構造体に値が設定されているのが分かるかと思います。利用されているのは前述のunity_StereoEyeIndex
ですね。
こうしてマクロを通して匠に値が設定されていくわけです。
なお、マルチビューではない場合はマクロは空になっているのでなにも展開されません。
UNITY_SETUP_INSTANCE_ID / UNITY_VERTEX_INPUT_INSTANCE_ID / UNITY_TRANSFER_INSTANCE_ID
最後にインスタンスIDについて見ていきましょう。
これはGPUインスタンシングで利用されるものです。
(なのでマルチビューでは使用されません)
// これは構造体にインスタンスIDを宣言するもの #define DEFAULT_UNITY_VERTEX_INPUT_INSTANCE_ID uint instanceID : SV_InstanceID;
// これは頂点シェーダからフラグメントシェーダへインスタンスIDを渡すための処理 #define UNITY_TRANSFER_INSTANCE_ID(input, output) output.instanceID = UNITY_GET_INSTANCE_ID(input)
// 頂点シェーダ内でインスタンスIDをセットアップする #define DEFAULT_UNITY_SETUP_INSTANCE_ID(input) { UnitySetupInstanceID(UNITY_GET_INSTANCE_ID(input));}
最後のマクロだけ関数呼び出しが入ります。続けて関数UnitySetupInstanceID
も見てみましょう。
void UnitySetupInstanceID(uint inputInstanceID) { #ifdef UNITY_STEREO_INSTANCING_ENABLED #if !defined(SHADEROPTIONS_XR_MAX_VIEWS) || SHADEROPTIONS_XR_MAX_VIEWS <= 2 #if defined(SHADER_API_GLES3) // We must calculate the stereo eye index differently for GLES3 // because otherwise, the unity shader compiler will emit a bitfieldInsert function. // bitfieldInsert requires support for glsl version 400 or later. Therefore the // generated glsl code will fail to compile on lower end devices. By changing the // way we calculate the stereo eye index, we can help the shader compiler to avoid // emitting the bitfieldInsert function and thereby increase the number of devices we // can run stereo instancing on. unity_StereoEyeIndex = round(fmod(inputInstanceID, 2.0)); unity_InstanceID = unity_BaseInstanceID + (inputInstanceID >> 1); #else // stereo eye index is automatically figured out from the instance ID unity_StereoEyeIndex = inputInstanceID & 0x01; unity_InstanceID = unity_BaseInstanceID + (inputInstanceID >> 1); #endif #else unity_StereoEyeIndex = inputInstanceID % _XRViewCount; unity_InstanceID = unity_BaseInstanceID + (inputInstanceID / _XRViewCount); #endif #else unity_InstanceID = inputInstanceID + unity_BaseInstanceID; #endif }
だいぶ長いですね。ただ#if defined(SHADER_API_GLES3)
のほうはコメントにも書かれている通り、GLSL3以下のための回避策のようです。
ここで行っていることはそうしたデバイスの違いを吸収し、適切にunity_StereoEyeIndex
とunity_InstanceID
を設定することです。
長々とマクロを見てきましたが、行っていることを一言で言ってしまえば、マルチビュー(とGPUインスタンシング)の場合とそれ以外でエラーが出ないようにセットアップしてくれている、ということです。
そして大事な点はマルチビューなどの場合では「テクスチャ配列」を介して処理が行われるということです。
これを行わないと適切に描画されなくなってしまいます。
以上が、マルチビュー対応のためのシェーダの書き方でした。
ScriptableRenderPassでRenderTextureを生成する際の注意点
今回の修正の大半はシェーダでした。が、ひとつだけC#側でも対応しないとならない箇所があります。
それがRenderTextureDescriptor
の取得箇所です。
とはいえコードはめちゃ短いので見てもらうほうが早いでしょう。
RenderTextureDescriptor descriptor = XRSettings.enabled ? XRSettings.eyeTextureDesc : camData.cameraTargetDescriptor;
XRSettings.enabled
を見るとXRかどうかが判断できます。そしてその場合にはXRSettings.eyeTextureDesc
からdescriptorを取得することで適切なRenderTexture
を得られるというわけです。
ちなみにdescriptorは「記述子」と訳されます。これは「どんなRenderTexture
なのかを説明するもの」と考えるといいでしょう。
そしてそれを元にRenderTexture
が取得されるため、VRのマルチビューの場合はTextureArray
の形でRenderTexture
が取得されるというわけです。