e.blog

主にUnity/UE周りのことについてまとめていきます

【XR】URP向けのマルチビュー対応イメージエフェクトシェーダの書き方

概要

前回書いた「URPで背景をぼかしてuGUIの背景にする」で書いたことの続編です。

edom18.hateblo.jp

具体的には、前回の実装のままでVRのマルチビュー(やSingle Pass Instanced)に変更すると正常に描画されないという問題があったのでそれへの対応方法がメインの内容となります。

マルチビューで動いているかどうか伝わらないですが(w)、動作した動画をアップしました。

今回の問題を対処したものはGitHubリポジトリにマージ済みです。

github.com



マルチビューに対応する

イメージエフェクト(ブラー)については前回の記事とほぼ同じです。
それをベースにいくつかの部分をマルチビュー対応していきます。

なお、マルチビューなどのステレオレンダリングについては凹みさんの以下の記事が超絶詳しく解説してくれているので興味がある方はそちらを参考にしてみてください。

tips.hecomi.com

そもそもマルチビューとは

マルチビューとは一言で言うとOpenGLが持つOVR_multiviewという拡張機能です。
VRの場合、両目にレンダリングする必要があるためどうしても処理負荷が高くなりがちです。

そこで様々な方法が考え出されました。(それらについては前述の凹みさんの記事を参照ください)
それに合わせてGPUベンダー側も要望に答える形で新しい機能を追加したりしています。

このOpenGL拡張機能もそうした新機能を使うためのものです。
自分もまだ正確に理解しきれてはいないのですが、VRの両目レンダリングの負荷を下げるために、一度だけレンダリングのコマンドを送信すると、それをよしなに複製して両目分にレンダリングしてくれる、というような機能です。

Oculusのドキュメントから引用すると以下のように説明されています。

マルチビューを有効化すると、オブジェクトは一度左のアイバッファーにレンダリングされた後、頂点位置と視覚依存変数(反射など)に適切な変更が加えられて、自動的に右のバッファーに複製されます。

また、OculusのWebGL版のドキュメントでは以下のように説明されています。

マルチビュー拡張機能では、ドローコールがテクスチャー配列の対応する各エレメントにインスタンス化されます。頂点プログラムは、新しいViewID変数を使用して、ビューごとの値(通常は頂点位置と反射などの視覚依存変数)を計算します。

ドローコールがテクスチャ配列ごと(つまり両目のふたつ)にインスタンス化されることで実現しているようですね。

そしてこれを実現しているのがレンダーターゲットアレイと呼ばれる、レンダーターゲット(ビュー)を配列にしたものです。
なので「マルチビュー」なんですね。

そしてこの「レンダーターゲットアレイ」というのが今回の修正のキモです。
どういうことかと言うと、前回の実装ではレンダーターゲットアレイではなく、あくまで片目用の通常のレンダリングにのみ対応した書き方をしていました。
(マルチパスの場合は片目ずつそれぞれレンダリングしてくれていたので問題にならなかった)

だからマルチビューにした途端に正常に動かなくなっていたというわけです。
しかし、マルチビューかどうかを判定して色々処理を書くのはとても骨が折れます。

そこでUnityは、どちらの設定であっても正しく処理を行えるようにするためのマクロをたくさん用意してくれています。
今回の修正は主に、それらマクロを使ってどう記述したらいいかということの説明になります。

その他、マルチビューについての解説は以下の記事を参照ください。

blogs.unity3d.com

マルチビュー対応マクロを使う

前述のように、それぞれの処理はマクロを使うことでマルチビューでもそうでなくても正常に動作するコードを書くことができます。

それほど長いコードではないので実際に適用済みのコードをまず貼ってしまいましょう。

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_IDUNITY_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_StereoEyeIndexSLICE_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_StereoEyeIndexunity_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が取得されるというわけです。

参考にした記事