しゅみぷろ

プログラミングとか

描画の効率化について

はじめに

今回の記事ではUnite2018で得た有り余るやる気を発散するために、描画の効率化について書いていこうと思います。

また、MaterialPropertyBlockという機能が結構誤解されがちなので、これについても纏めていこうと思います。

バッチングを効かせる上で考慮すべきこと

ここで言うバッチングは静的なものではなく、動的に行われるバッチングを指すこととします。

バッチングはざっくり説明すると「同じマテリアルを使っているものの描画は出来る限り纏めちゃおうね」っていうやつです。

このバッチングはUnityのバッチング条件に該当する場合自動的に行ってくれます。

これからバッチングが切れる条件について見ていきます

頂点数多いとバッチングされない

メッシュ(サブメッシュ)の頂点数がある特定の数を超えるとバッチングが効かなくなります。 このある特定の数はShaderによって代わり、非常に面倒なのですが、以下のような仕様になっているそうです(この特定の数は今後変更される可能性があります)。

  • シェーダーが頂点位置や法線や一つの UV 情報を使っていたら300頂点までバッチングが行われる
  • シェーダーが頂点位置、法線、UV0、UV1、Tangentを使っていたら180頂点までバッチングが行われる
  • シェーダーがそれらの情報を使っていない場合は900頂点までバッチングが行われる

以下の画像を見てください。オブジェクトを大量に生成したときの負荷を検証している画像ですが、左はCylinderを、右はSphereを大量生成しています。 Cylinderの頂点数は88Sphereの頂点数は515です。この画像の例では、Shaderは一つの法線情報を使っているものを利用しているので、Sphereを描画している方はバッチングが効かず、Batchesの数がものすごいことになっています。

f:id:es_program:20180513000226p:plain:w600

Frame Debuggerでも、バッチングが効かない理由が表示されます。

f:id:es_program:20180513000814p:plain:w400

一度にバッチングできる頂点数の上限を超えるとバッチングされない

一度にバッチングできる頂点数および頂点インデックス数には上限があります。 多くのプラットフォームではこの数が64000に制限されます。 OpenGL ES では48000macOSでは32000に制限されるようです。

f:id:es_program:20180513041651g:plain:w600

上記のGifでは同じマテリアルを割り当てたCylinderをバッチ毎に徐々に表示させています。全て同じマテリアルを利用しているので1度に全て描画して欲しいところですが、バッチングには上記の通り上限があるため、数回に分けて描画が行われていることがわかると思います。macOS環境でチェックしたため、頂点インデックス数32000で頭打ちとなっています。

ちなみに、Frame Debuggerでバッチングが切れた理由が以下のように表示されます。

f:id:es_program:20180513042230p:plain:w400

スケールの設定値によってはバッチングされない

公式では

トランスフォームにミラーリングを含む場合は、オブジェクトはバッチされません。例えば、+1スケールのオブジェクト A と–1スケールのオブジェクト B は一緒にバッチできません。

と書かれていますが、ちょっとわかりにくいですね。 要するに、Scaleに奇数個の負数が指定されているとバッチングが効かなくなるようです。偶数個だけ負数にした場合は問題なくバッチングされました。

ちなみに親オブジェクトのTransformに設定されたScaleも考慮されます。つまり、ルートオブジェクトのScaleからRendererまでの全てのスケールを乗算し、最終的なスケールの(x, y, z)のうち奇数個が負数だった場合にバッチングが無効になります。 知らず知らずのうちに実は結構やらかしているタイプのやつですね。

ちなみに余談ですが、この時にFrame Debuggerで出てくるバッチングが効かなかった理由の表示が結構賢いです。

GPUInstancingが可能な環境の場合

f:id:es_program:20180513003412p:plain:w400

と表示されました。Instancingしてくれれば解決するよと。そしてGraphics EmulationでGPUInstancingが利用できない環境にエミュレートした場合

f:id:es_program:20180513004409p:plain:w400

と表示されます。Scaleに奇数個負数が入力されているよと。

マテリアルのインスタンスが違うとバッチングされない

これはSetPass callsも増えるので、普段から気をつけている方も多いと思います。 結構有名なやつですね。

既存のマテリアルをコピーするなどしてインスタンスを作成してRendererに対して割り当てた場合、たとえ同じシェーダー、同じプロパティであろうとも、「別のマテリアルを使っている」と判断されてしまい、バッチングが効かなくなります。

マテリアルをコピーして使うことが必要であるケースであれば、これは仕方がないことなのでまぁ良いのですが、問題はRenderer.materialプロパティの存在で、初学者にとってはかなり罠に近い仕様になっていることです。

Renderer.materialプロパティにアクセスしてマテリアルの変更を行うと、新しいマテリアルのインスタンスが作成されます。 Renderer.materialは、内部で

        public Material material
        {
            get { return GetMaterial(); }
            set { SetMaterial(value); }
        }

といった実装がされています(Unity Reference-Only Licenseに従い、本家のコードを参考に簡略化して書いています)。

GetMaterialSetMaterialはネイティブ側で実装されていて、それぞれマテリアルのコピーを行います。

マテリアルのコピーを行わずに参照を変更するには、Renderer.sharedMaterialを使用する必要があります。

Shaderがバッチングに対応していない場合はバッチングされない

Shaderによってはバッチングを阻害するものがあります。以下のケースです。

  1. ShaderTagにDisableBatchingが指定されている場合
  2. マルチパスのShaderの場合

1つ目の項目は、使用しているShader内のTagsにTags { "DisableBatching" = "True" }またはTags { "DisableBatching" = "LODFading" }が指定されていればバッチングが無効になる、という意味です。

バッチングされるとすべてのジオメトリがWorldSpaceに変換されるため、ModelSpaceでの座標系がShader内で利用できなくなります。それを防ぐために、ShaderLab内でバッチングが出来ないことを明示するタグがDisabledBatchingです。 なので、これらのタグはShader内でModelSpaceでの頂点変形を行う必要があるShaderで利用されます。

2つ目の項目は、Shaderがマルチパスレンダリングを行う場合です。複数のPassが存在する場合、バッチングは無効化されます。カスタムシェーダーではここらへんも考慮して、出来るのであればなるべくシングルパスが理想です。

さて、ここで頭に入れておいてほしいのは、「Unityが標準で提供するレンダリングパスを利用している場合、多くの場合バッチングを有効化出来る」ということです。 例えばUnity標準のShadowMapを利用したShadowCaster及びShadowReceiverパスはUnityが提供するShadowMap生成及び参照のためのパスであり、このパスを使う場合、自作であろうがなかろうがバッチングを有効にすることが出来ます(もちろんその他のバッチング条件に該当すればの話です)。その他にも、Unityが提供する様々なパスではバッチングが有効になります。

SetPass callsを増やさないために考慮すること

次はSetPass callsを増やさないためにはどうしたら良いかについて見ていきます。

Setpass callsはマテリアルのデータをCPUからGPUに受け渡す際に呼ばれるSetPass命令の呼び出し回数です。これが結構CPU側でボトルネックとなります。

Unity界隈で「SetPass callsは悪だ!なるべく潰していけ!」といろんな記事やら資料やらで書かれているため、これについては多くの人が知っている内容かもしれません。

SetPass callsを減らすために必要な一番基本的な事は、利用するマテリアルのインスタンスを減らすことです。

ここでは必要なマテリアルのインスタンスをなるべく減らし、SetPass callsを抑える手法についてざっくり見ていきます。

因みに、たとえマテリアルのインスタンスを少なくしたとしてもSetPass callsが絶対に減るというわけではありません。 マテリアルのインスタンスを共有したとしてもSetPass callsが増えてしまう場合もあります。 詳しくは「RenderQueueによるドローコルの変化」で書きます。

同じような描画を行うマテリアルをなるべく1つに纏める

  1. レンダリング対象に「Shaderもマテリアルのプロパティも同じ値なのに、マテリアルのインスタンスだけ違う」ようなものがある場合は迷わず1つのマテリアルを使いまわすようにしましょう。もしマテリアルを分けておいて、何かアクションがあった時にプロパティを変更したいのであれば、MaterialPropertyBlockの利用を検討します。
  2. 似たような処理を行い、一部分だけ処理が違うようなShaderを使っていて、マテリアルのプロパティを同じ値にできるようなものがある場合、Shaderを拡張して1つのShaderでうまいこと描画できないかを考えましょう。処理の分岐のようなものが必要になる場合、それを判断するパラメータはマテリアルのプロパティではなく頂点データに格納してやります。

頂点データをうまく活用する

マテリアルのプロパティでいろんなパラメータを設定する必要があり、その組み合わせ分マテリアルのインスタンスを増やす必要があるような場合、このパラメータを頂点データに設定することで解決できないかを考えましょう。

たとえばUnityのStandardShaderを使っていて「赤・緑・青」3種類のマテリアルを作るようなことがあるかもしれません。

f:id:es_program:20180513060910p:plain:w400

これらを使ってそれぞれ3つのオブジェクトを1つのカメラで描画する場合、SetPass callsは3になります。しかし、Shaderの処理自体は全て同じことをしていて、パラメータが違うだけです。これでは無駄が多いので、それぞれのオブジェクトのメッシュが持つ頂点データに、この色のデータを格納してやります。そして、StandardShaderを改造して、Albedoに格納しておいた色データを乗算して出力するようにします。

f:id:es_program:20180513063413p:plain:w600

上記の画像では、Blenderで頂点カラーを赤・緑・青に設定したCubeをそれぞれ出力してUnityにInportし、以下のシェーダーを適用したマテリアルを1つ作って、3つのCubeオブジェクト全てにこのマテリアルを割り当てたときの画像です。 このShaderはUnityのStandardSurfaceShaderに入力として頂点カラーを取ってくるようにして、Albedoに乗算しているだけです。 SetPass callsが1に抑えられていることがわかると思います。また、全く同じマテリアルのインスタンスを使用しているためバッチング条件に該当するようになり、Batchesも抑えられています。

Shader "Custom/NewSurfaceShader" {
    Properties {
        _Color ("Color", Color) = (1,1,1,1)
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _Glossiness ("Smoothness", Range(0,1)) = 0.5
        _Metallic ("Metallic", Range(0,1)) = 0.0
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200

        CGPROGRAM
        // Physically based Standard lighting model, and enable shadows on all light types
       #pragma surface surf Standard fullforwardshadows

        // Use shader model 3.0 target, to get nicer looking lighting
       #pragma target 3.0

        sampler2D _MainTex;

        struct Input {
            float2 uv_MainTex;
            float4 color : COLOR;
        };

        half _Glossiness;
        half _Metallic;
        fixed4 _Color;

        // Add instancing support for this shader. You need to check 'Enable Instancing' on materials that use the shader.
        // See https://docs.unity3d.com/Manual/GPUInstancing.html for more information about instancing.
        // #pragma instancing_options assumeuniformscaling
        UNITY_INSTANCING_BUFFER_START(Props)
            // put more per-instance properties here
        UNITY_INSTANCING_BUFFER_END(Props)

        void surf (Input IN, inout SurfaceOutputStandard o) {
            // Albedo comes from a texture tinted by color
            fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color * IN.color;
            o.Albedo = c.rgb;
            // Metallic and smoothness come from slider variables
            o.Metallic = _Metallic;
            o.Smoothness = _Glossiness;
            o.Alpha = c.a;
        }
        ENDCG
    }
    FallBack "Diffuse"
}

更にこの例の場合、どんな色を頂点カラーとして与えてもSetPass callsが増えません。

この要領で、必要なデータは可能であれば頂点データに入れてあげるようにしましょう。

今回は頂点カラーを使いましたが、他にも色々格納できるスペースはあるので、興味があれば「hlsl セマンティック」あたりで検索してみてください。

また、ちょっと応用編になりますが、テクスチャに細かなデータ列を格納してShaderでそれを参照できるように紐付ける情報を設定し、Textureを大量のデータ保管庫として利用することも出来ます。

uGUIのSetPass calls

ちょっとした番外編です。

uGUIのImageやRawImageで異なるテクスチャを描画する際、標準だとUnityのDefault UI Materialが使われます。 異なるテクスチャを描画するので勿論マテリアルのプロパティを変更する必要があるはずなのですが、SetPass callsは増えません(Batchesはちゃんと増えている)。

以下の画像では、異なる2枚のテクスチャをuGUIで描画しています。SetPass callsは1です。

f:id:es_program:20180513072201p:plain:w600

このカラクリは以下の記事で解説されています。

UIのバックエンドを高速化する – Unity Blog

CanvasRendererの描画処理(描画前の下準備も含む)はMainThreadから切り離され、別スレッドで並列に実行されていきます。実際にUIを描画していくフェーズになると、用意したUI描画用のバッファに全てのUI要素のレンダリング後、これを合成することで最終的な結果を得ています。

f:id:es_program:20180513082048p:plain:w600

以下はProfilerで確認したUI生成処理です。 Canvas毎に、各レンダラーの描画についての情報を見ることが出来ます。UI描画用バッファも確認できます。

uGUIに関して、Frame Debuggerではバッチングが無効化された理由を見ることは今のところ出来ず、UI描画用バッファの確認等も行えません そのため、uGUIの場合はFrame DebuggerではなくProfilerで色々チェックするのが良さそうです。

RenderQueueと複数オブジェクトによるドローコールの変化

マテリアル毎に割り当てることが出来るQueueに関しての描画順コントロールとパフォーマンスへの影響について考えていきます。

レンダリングを行うオブジェクトはマテリアルに割り当てられたQueue毎にソートされ、マテリアル毎に描画されていきます。 この描画は、基本的に不透明オブジェクトは手前にあるものから行われていき、透明オブジェクトは奥側から行われます(これらは変更可能です)。

不透明オブジェクトが手前から描画されていく理由は、一度描画を行ったピクセルに対しては計算をスキップし、効率よくフラグメントを求めることが出来るからです。 透明オブジェクトが奥側から描画されていく理由は、Blendingを正しく行うためです。

Unity内ではこの「透明か不透明か」の判断を、Queueが2500(2500以下なら不透明で2501以上なら透明)より大きいかどうかで判断しているっぽいです。

因みにQueueには特定の値を表す文字列があり、ではBackgroundは1000、Geometryは2000、AlphaTestは2450、Transparentは3000、Overlayは4000を表します。

f:id:es_program:20180513084713g:plain:w600

上記のGifでは、マテリアルのQueueが2500以下なら手前から、2500より大きければ奥から描画されている事が確認できます。

誤解の無いよう補足しておくと、Queueが2500以下でもアルファをつけることは可能です。ただ、レンダリングが手前から行われるので、深度テストを有効にしていると奥の不透明オブジェクトが描画されなくなります(それはそれで綺麗な半透明なオブジェクトの並びを表現できるので結構使うこともあります)。

試しに以下のように深度テストとBlendingを記述した適当なShaderを使って半透明Shaderのテストをしてみます。

Shader "Custom/Lambert"{
    Properties{
        _Diffuse("Color", COLOR) = (1, 1, 1, 1)
        _LightPos("Light position", Vector) = (0, 0, 0, 0)
    }
    SubShader{
        Tags
        {
            "Queue" = "AlphaTest"
        }
        ZWrite On
        ZTest On
        Cull Back
        Blend SrcAlpha OneMinusSrcAlpha
        Pass{

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct app_data {
                float4 vertex:POSITION;
                float3 normal:NORMAL;
            };
            struct v2f {
                float4 vertex:SV_POSITION;
                float4 color:COLOR0;
            };

            float4 _Diffuse;
            float4 _LightPos;

            v2f vert(app_data i) {
                v2f o;
                float3 vs_normal = mul(UNITY_MATRIX_IT_MV, float4(i.normal, 1));
                float3 vs_light = mul(UNITY_MATRIX_V, _LightPos);
                float3 vs_pos = UnityObjectToViewPos(i.vertex.xyz);
                float3 vs_l = normalize(vs_light - vs_pos);

                float r_diffuse = max(dot(vs_normal, vs_l), 0);

                o.vertex = UnityObjectToClipPos(i.vertex);
                o.color = _Diffuse * r_diffuse;
                o.color.a = _Diffuse.a;
                return o;
            }

            float4 frag(v2f i) :SV_TARGET{
                return i.color;
            }
            ENDCG
        }
    }
}

f:id:es_program:20180513100831p:plain:w600

上の画像はQueueを2500より大きな値に設定してレンダリングを行った結果です。 透明なオブジェクトは奥側から描画されるため、このようになります。

f:id:es_program:20180513100900p:plain:w600

そしてこちらの画像はQueueを2500以下の値でレンダリングを行った結果です。 手前の半透明のみが描画され、綺麗な透過に見えます。

さて、ここからが本題です。オブジェクトを複数描画するような場合に注意しなければならないことがあります。 異なるマテリアル(Queueの値は同じ)のインスタンスを持つオブジェクトを複数配置する場合です。 以下のGifを見てください。

f:id:es_program:20180513161714g:plain:w600

StandardShaderを割り当てた2つの色違いのマテリアルを作り、それぞれを適用したオブジェクトを交互に並べています。Queueがどちらも2000の時、SetPass calls及びBatchesが増えているのがわかると思います。その後、片方のマテリアルのQueueを2100(もう一方とは異なる適当な値)にするとSetPass calls及びBatchesが意図した値まで下がっています。

透明なオブジェクトの場合、より注意する必要があります。

f:id:es_program:20180513164511g:plain:w600

SetPass calls及びBatchesに注目してください。先程と比べて数値が跳ね上がったのが確認できると思います。この程度の数なら良いのですが、大量に透明なオブジェクトを描画する場合は細心の注意をはらいましょう。Queueに3000を指定した2つのマテリアルを適当に割り当てて大量のオブジェクトを描画した場合、以下のGifようになります。

f:id:es_program:20180513205235g:plain:w600

SetPass callsもBatchesもエグいですね。 奥側から破綻の無いようBlendingを行っていく必要があるためこんな事になってしまいます。

この負荷が問題となってしまう場合、応急処置としてQueueの値を変えることで対応できます(そのかわり意図したようにBlendingが行われなくなります)。

以下の画像では、片方のマテリアルのQueueを適当に3001として値をずらしています。

f:id:es_program:20180513210801p:plain:w600

MaterialPropertyBlock

そもそもMaterialPropertyBlockってなんでしょう。どんなときに役立つのでしょうか。 昔のバージョンのリファレンスに、多少詳しく書いてくれてます(現状最新バージョンのリファレンスだと説明が簡素なので注意)。

docs.unity3d.com

つまりMaterialPropertyBlockは

  • 特定のマテリアルのプロパティのみ変更して使いまわしたいときに利用できる(Materialを複数作る必要がない)
  • Graphics.DrawMeshでメッシュを描画する際のパラメーターとして指定できる
  • Renderer.SetPropertyBlockでレンダラーに設定できる

といった性質を持ちます。 注意しておきたいのは

  • MaterialPropertyBlockを使ったからといって、違うプロパティを与えた描画処理が1つにまとまってくれるとかではない
  • 全く同じプロパティを利用する描画が行われる場合には、Unityのバッチング条件に該当する場合に限りバッチングしてくれます(SetPass callsを無駄に増やすこともないので、CPU負荷を抑えることが出来ます)
  • 逆を言えば、違うプロパティを与えた場合は通常時(2つの別々のマテリアルを使ったとき)と同じようにSetPass callsが増えます

ということです。

ユースケースによりますが、MaterialPropertyBlockを扱うことでUnityEditor上で複数のマテリアルを作成する必要が無くなる場合もありますし、スクリプト上からは微妙にプロパティの値が異なるだけのマテリアル管理をスマートに行うことが出来ると考えれば良いでしょう。

さて、ここまでの説明で分かる通り、MaterialPropertyBlockはレンダリングの効率化を行う銀の弾丸ではありません。あくまで複数のRenderer(もしくは描画命令)間でマテリアルをなるべく共通化するための機能だと考えるべきです(Renderer単位でプロパティのオーバーライドを保持できるのでメモリ効率は良くなります)。

「MaterialPropertyBlockを使えばなんやかんやでSetPass callsが減らせてCPU負荷を減らせる」というわけではないので注意してください。MaterialPropertyBlock使っても必要なSetPass callsは減らせないし、バッチング条件を満たせなかったらバッチングも行われません。

以下はTransparentな1つのマテリアルに対して、ちょっと透明な赤と青のカラーをMaterialPripertyBlockで渡してやる例です。

f:id:es_program:20180513215507g:plain:w600

たった2色の透明なオブジェクトを描画しているだけですが、これだけでSetPass callsもBatchesも700を超えてしまいます。 「RenderQueueと複数オブジェクトによるドローコールの変化」でやったような応急処置を施せば多少マシにはなりますが、正しくBlendingが行われなくなります。

f:id:es_program:20180513234916g:plain:w600

因みにMaterialPropertyBlockについては以下の記事にとても詳細な解説が載っているので、使い方等は以下を参考にするといいです。

The magic of Material Property Blocks – Thomas Mountainborn

ただ、上記記事内の

There is but one disappointing element to an otherwise great tool: even though all objects share the same material, MaterialPropertyBlocks break dynamic batching.

といった説明ですが、おそらく間違いです。 上記記事内ではSphereを大量に描画していますが、Unity標準のSphereの場合頂点数が多く、バッチングが効かないので、それを勘違いしたのだと思われます。 ちゃんとMaterialPropertyBlockでもバッチングは有効になりますので安心してください。

最後に

何故か最初書こうと思っていたことと全く違うこと書いてました。 なんでこんなの書いてるんだろう。

投影手法について

はじめに

Shaderで投影を行う手法についてメモ用に書き残しておこうと思います。 投影の手法や原理を理解出来れば、例えば

  • 背面をGrabしオブジェクト背面の絵を取得しエフェクトを掛ける
  • Projectorの作成
  • DepthShadowによる影の描画
  • 反射の描画

みたいなことが出来るようになります。


f:id:es_program:20180112121206g:plain:h200
弾痕(投影+背景歪み)
f:id:es_program:20180109233123g:plain:h200
反射
f:id:es_program:20180112005401g:plain:h200
反射(水面)



投影の考え方は、グラフィックスの基礎知識さえあればそれほど難しくありません。 数式云々よりもイメージが大切です。座標変換とその座標系がどういった意味を持つのかをしっかりとイメージできれば理解しやすいと思います。 なので、投影について勉強する前にもう一度基礎を固めておくことをオススメします。

esprog.hatenablog.com

本記事では

  • GrabTextureのマッピング
    • 主にGrabPassを用いてオブジェクトの背面の絵を取得するのに使う
  • 投影マッピング
    • 主にProjectorやDepthShadow、反射の描画に使う

といった順番で纏めていきます。

どちらも最終的にやっていることはScreen Spaceで空間を統一し、その空間の座標で描画を行っているだけです。 GrabPassを用いてオブジェクトの背面の絵を取得して描画する処理も、DepthShadowのように別の視点からレンダリングされた画像を使って影を投影するのも本質的には同じことです。 後に紹介するShaderコードを見比べてみるとわかりやすいと思いますが、コード自体はほぼ同じになります。

GrabTextureのマッピング

GrabPassを用いてそれまでにレンダリングされたバッファを取得し、色を反転してオブジェクトに投影してみます。

f:id:es_program:20180221205230g:plain:w600

Sphereに以下のShaderを適用して動かしています。

Shader "Grab"
{
    Properties
    {
    }

    SubShader
    {
        Tags { "Queue" = "Transparent" }

        GrabPass
        {
            "_BGTex"
        }

        Pass
        {
            CGPROGRAM
           #pragma vertex vert
           #pragma fragment frag

           #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
            };

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

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.grabPos = o.vertex;
#if !UNITY_UV_START_AT_TOP
                o.grabPos.y *= -1;
#endif
                return o;
            }

            sampler2D _BGTex;

            fixed4 frag (v2f i) : SV_Target
            {
                float2 uv = i.grabPos / i.grabPos.w * 0.5 + 0.5;
                fixed4 col = tex2D(_BGTex, uv);

                col = 1 - col;
                return col;
            }
            ENDCG
        }
    }
}

まずGrabPassでバッファを_BGTexという名前で取得します。 で、今回の場合「Sphereの後ろにある部分の色を反転したい」ので、「後ろにある部分」の取得をどうにかする必要があります。 これはSphereの各頂点がスクリーン(_BGTex)のどこにあたるのかがわかれば良いので、Projection変換してUV座標に直せばOKです。

vertex shaderで頂点をProjection空間へ持っていきます。これをo.grabPosに保持してfragment shaderに渡します。

fragment shaderに渡されたProjection空間の座標は-1~1の値をとる同次座標です。 同次座標を通常座標に直すためにはw要素で各要素を割る必要があります。そして、値をUV座標として使うために0~1に変換する必要があるので、0.5を掛けて0.5足します。

これが投影の基本となるShaderです。 「背面の色を変えている」と考えるのではなく、「背面をテクスチャとして取得し、色を変えてオブジェクトに投影している」と考えて下さい。

因みに、上記のShaderコードには少し無駄があります。 「0.5を掛けて0.5足す」という処理をフラグメントシェーダーで行っている点です。 「0.5を掛けて0.5足す」は、言い換えれば「大きさを0.5倍し、0.5平行移動する」ということです。 なので、頂点シェーダーで

$$ \begin{bmatrix} 0.5 & 0 & 0 & 0.5 \\ 0 & 0.5 & 0 & 0.5 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} \begin{bmatrix} x \\ y \\ z \\ w \\ \end{bmatrix} = \begin{bmatrix} 0.5x + 0.5w \\ 0.5y + 0.5w \\ z \\ w \\ \end{bmatrix} $$

の計算を行うことで、多少最適化出来ます。

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.grabPos = o.vertex;
                o.grabPos.x = o.grabPos.x * 0.5 + o.grabPos.w * 0.5;
#if !UNITY_UV_START_AT_TOP
                o.grabPos.y = o.grabPos.y * -0.5 + o.grabPos.w * 0.5;
#else
                o.grabPos.y = o.grabPos.y * 0.5 + o.grabPos.w * 0.5;
#endif
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                float4 uv = i.grabPos;
                fixed4 col = tex2Dproj(_BGTex, uv);

                col = 1 - col;
                return col;
            }

これを行ってくれるのが、UnityCG.cgincのComputeGrabScreenPosです。 これを使うと以下のように簡略化出来ます。

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

            fixed4 frag (v2f i) : SV_Target
            {
                float4 uv = i.grabPos;
                fixed4 col = tex2Dproj(_BGTex, uv);

                col = 1 - col;
                return col;
            }

今回は実装例を簡単にするために色を反転するだけにしていますが、例えば法線マップで歪ませたり、それにリムライトをプラスして泡や水っぽいものを表現したりも出来ます。

投影マッピング

さて、本題。 といっても、Grabでの投影が理解できていれば難しくはありません。投影時の「視点」が変わるだけです。 具体的にはgrabPosを算出する時の変換行列が別の視点からのものに変わります。

esprog.hatenablog.com

上記の水面反射の記事で「頂点を反射用カメラの変換行列を用いて変換」の部分がこれに該当します。 反射のコードを一部抜粋してみると

        v2f vert (appdata v)
        {
            v2f o;
            o.vertex = UnityObjectToClipPos(v.vertex);
            o.ref = mul(_RefVP, mul(_RefW, v.vertex));
            return o;
        }
        
        fixed4 frag (v2f i) : SV_Target
        {
            return tex2D(_RefTex, i.ref.xy / i.ref.w * 0.5 + 0.5);
        }

となっています。 _RefVP_RefWは、反射描画用カメラの変換行列です。

このコード、GrabTextureの投影と似ていませんか? というより、同じです。同じ。vertex shaderで使っている変換行列が変わっている以外で全く差はありません(変数名等は別として)。

つまり、GrabTextureの投影では「同じ視点から見て特定の場所を投影する」のに対し、反射の投影では「別視点でのレンダリング結果を反映するために、一度それと同じ視点からみて、描画必要な箇所を投影する」形になっています。

自分の中でのイメージの話ですが、投影は「頂点の座標をKey、視点から見たViewSpaceへの変換行列をハッシュ関数としたテクスチャのマッピング」のような感覚を持っています(わかりにくい?)。

計算だけ見るとなんてことないですが、イメージ出来るといざ使う時に役立ちます。

最後に

とりあえず記事が長くなりそうなので、今回はここまでで。 GrabTextureで基礎を、反射の描画で実際の投影処理について書いてみました。

ここらへんのイメージを字で伝えるのはちょっとむずかしいなぁと思ったので、暇があればDepthShadowの自作を通してもっと詳細に解説する記事書きたいと思います。 といっても、実装自体はライトを視点としてレンダリングした結果(ShadowMap)を今回のように投影すれば終わりです(深度値の比較などもありますが..)。

その時に、一緒にUnity標準のShadowCast/ShadowReceiveといった、影を描画するための2パス構成(ShadowMapへ描画するためのパスと、ShadowMapを投影するパス)について再度、より詳細に掘り下げられたら良いなとも思います。SRPで自作パイプライン作る時にここらへんの知識は使えるかもしれないので。

esprog.hatenablog.com

兎にも角にも重要なのは「同じ視点から同じものを眺めてみる」ことです。