しゅみぷろ

プログラミングとか

描画の効率化について

はじめに

今回の記事では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でもバッチングは有効になりますので安心してください。

最後に

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