Materialエディタの拡張

エフェクト統一シェーダーがあると捗るよね

の記事を読んで「よし、俺も統一シェーダ作るぞ!」と思って実際にやってみると、
シェーダのコードを#ifdefで切り刻むこと自体はそこまで大変ではないですが、
「エディタのほうはしんどそう」…と思っていました。

たとえば、画像を別画像の形状で切り抜くオプションを付けたとします。 f:id:enrike3:20180606230850p:plain

あくまでオプションなのでこの機能を使わないときはインスペクタにマスク画像を表示したくない。 機能をONにしたときだけ表示してほしい。

f:id:enrike3:20180606232215p:plain f:id:enrike3:20180606232152p:plain

オプションの機能が一つだけなら表示しちゃって気にしないという手はあるのですが、オプションが増えてきて、UVScrollだとかブレンド方式切り替えだとか、カラーチャンネルも切り替えだとかやりはじめるとやはり限界でして、表示のON/OFFを切り替えたい。ぜひに切り替えたい。

これをやるには、MaterialのInspectorを乗っ取る必要があるだろうから気が重かったのですが、Unity5以降のUnityEditor.ShaderGUIクラスを拡張すると意外にも簡単にできました。UnityEditor.MaterialEditorを継承する方式だと難しそうだったのに。ShaderGUI最高か。

方法

まずシェーダのCustomEditorに、ShaderGUIを継承するオレオレエディタのクラス名を名前空間つきで書きます。

Shader "Hoge/OreOreSprite"
{
    Properties
    {
        …
    }
    SubShader
    {
        …
    }
    CustomEditor "HogeHoge.OreOreShaderGUI"
}

MaterialEditorは名前空間使えなかったので、この時点で好感度急上昇。

そして、実装を書きます。

using UnityEditor;

namespace HogeHoge
{
    public class OreOreShaderGUI : ShaderGUI
    {
        public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties)
        {
        }
    }
}

これだけで、インスペクタの乗っ取りは済んでいます。OnGUIが空なのでインスペクタはまっさらになります。

f:id:enrike3:20180606233917p:plain

乗っ取れたので次は中身を書いていくわけですが、
引数で渡ってくるMaterialEditorとMaterialPropertyを使えばプロパティの表示はとても楽です。
たとえば、MainTexを表示するには、propertiesからMainTexを探してmaterialEditor.ShaderProperty()に食べさせるだけで済みます。

using System.Linq;
using UnityEditor;

namespace HogeHoge
{
    public class OreOreShaderGUI : ShaderGUI
    {
        public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties)
        {
            var mainTexProp = properties.First(x => x.name == "_MainTex");
            materialEditor.ShaderProperty(mainTexProp, mainTexProp.displayName);
        }
    }
}

f:id:enrike3:20180606234852p:plain

ここまでくれば、あとは普通のエディタ拡張と変わりません。
Toggleを使って表示非表示を制御するだけです。
色気をだして、Boxでかこったり、Toggleのラベルを太字にしたり、ということもできますね。

using System.Linq;
using UnityEngine;
using UnityEditor;

namespace HogeHoge
{
    public class OreOreShaderGUI : ShaderGUI
    {
        const string AlphaTexEnabledKeyword = "_ENABLE_ALPHA_TEX";

        public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties)
        {
            var material = (Material)materialEditor.target;

            var mainTexProp = properties.First(x => x.name == "_MainTex");
            materialEditor.ShaderProperty(mainTexProp, mainTexProp.displayName);

            var colorProp = properties.First(x => x.name == "_Color");
            materialEditor.ShaderProperty(colorProp, colorProp.displayName);

            //
            // Alpha Texture
            //
            using (new EditorGUILayout.VerticalScope("box"))
            {
                EditorGUI.BeginChangeCheck();

                var origFontStyle = EditorStyles.label.fontStyle;
                EditorStyles.label.fontStyle = FontStyle.Bold;
                var alphaTexEnabled = EditorGUILayout.Toggle("Enable Alpha Texture", material.IsKeywordEnabled(AlphaTexEnabledKeyword));
                EditorStyles.label.fontStyle = origFontStyle;

                if (EditorGUI.EndChangeCheck())
                {
                    if (alphaTexEnabled)
                        material.EnableKeyword(AlphaTexEnabledKeyword);
                    else
                        material.DisableKeyword(AlphaTexEnabledKeyword);
                }
                if (alphaTexEnabled)
                {
                    var alphaTexProp = properties.First(x => x.name == "_AlphaTex");
                    materialEditor.ShaderProperty(alphaTexProp, alphaTexProp.displayName);
                }
            }
        }
    }
}

f:id:enrike3:20180607000059p:plain

なんかテクスチャ設定するところが細長くなってるのが気になるけど、まあ普段使いには支障ないレベルでしょう。

Snapdragon Profiler

UnityのProfiler上ではGPUの細かいメトリクスはとれないので、描画負荷をはかるためには Androidなら各SoC向けのプロファイラ。iOSならInstrumentsを使って描画負荷を測ることになります。

自分は私物端末がSnapdragonなので、普段はSnapdragonProfilerを見ています。

Snapdragon Profiler - Qualcomm

ダウンロードするためにはQualcommにユーザー登録が必要です。

普段よくみる指標

仕事で2Dのゲームを作っていたので、見るところは

  • GPU Memory Stats
    • Texture Memory Read
    • Read Total
    • Write Total

と、テクスチャの読み込みやフィルの周りにしています。
次点で

  • GPU Stalls
    • Texture Fetch Stall
    • Texture L1 Miss
    • Texture L2 Miss

あたりです。

f:id:enrike3:20180428172246p:plain

↑の図は2048x2048の画像をTruecolorからETC2_4bppに切り替えた瞬間のナイアガラ
24bit per pixel → 4bppなので1/6ですが、実際のTexture ReadやReadTotalの測定結果は1/10近く行ったりします。 このあたりは測らないとわからない。

一方でWrite Totalは解像度とoverdraw依存、つまり塗りつぶすピクセルの量に依存するので 圧縮テクスチャを使っても下がらないことがわかります。

overdrawはUnityEditor上で視覚的に見ることができますが、 エフェクトを作成する人に「overdraw控えめにしてね!」とどんなに口酸っぱく言っても各人の感覚によってしまうので
「これぐらいはギリギリ許されるだろう」と許されざるエフェクトが上がってくることがあります。
こういうときはやっぱり数値で落とせると強い(はず) 。
「このシーンに置いたときにWrite Totalが500MB超えたらNGとします」 みたいなやりとりができるようになります。

この手の数値は普段からいろんなものを眺めていないと「1G超えはヤバい!」みたいな感覚が養われないので
性能面で追い詰められてからみても、なるほどよくわからん、にしかならないので
普段からいろいろ眺めておきたいなぁと思っております。

RenderDocがとても良い

RenderDoc神。
UnityEditor上でグラフィックがバグってて困ったときはまずFrameDebuggerでぱぱっと確認するわけですが、FrameDebuggerで原因をつき止められない場合はRenderDocが助けになります。

Visual Studioのグラフィック診断相当の機能が少ない手順でさくさくと使えます。

キャプチャはとても簡単。
RenderDocをインストールしたら、UnityのGameViewでPlay前にRenderDocをLoadしておきます。
あとは好きなタイミングでキャプチャボタンぽちるだけ

RenderDocの統合

画面

f:id:enrike3:20180425070935p:plain

PixelHistoryで、画面のポイントがどう塗りつぶされたかの履歴を見ることができたり

f:id:enrike3:20180425071045p:plain

Meshの中身の値を見ることができたりします。

f:id:enrike3:20180425071221p:plain

シェーダーのデバッグはかどる
サイコーです

ETC2はETC1と比べてカラー部分も改善されているという話

社内で圧縮テクスチャの話をしたのでブログでも少々。

DXT1からつらなる初期の圧縮テクスチャの系譜は4x4=16pxを1ブロックとして扱い
1ブロックあたりカラー(RGB)を64bit、すなわち64/16=4bpp (bit per pixel)としています。

これだとアルファがもてないので、1ブロックあたりアルファも64bit (4bpp)足して
- 不透明画像 4bpp - 透明度ありの画像 8bpp というのがDXT2~5とETC2の構成です。
なおPVRTCは4bppの中でアルファも表現しているので透明度をもつブロックはカラー品質が厳しいことになります。

RGB888(24bpp)に対する4bpp、すなわち圧縮比率1/6という厳しい制約の中で
どのようなアルゴリズムで圧縮をしているかは、OPTPiXのブログに素晴らしい解説があります。

ETCの改善

ETC1の利点は、輝度のテーブルで白と黒を作れるので、
DXT1の弱点であった1ブロックあたり傾向の異なる(代表色のブレンドで表現できない)色が3色以上あると詰む問題に対して、 白や黒ならば色が増えても上手くさばけること。サブブロック分割があるのでブロック感がDXT1よりやわらぐこと。

一方でETC1の弱点はOPTPiXのブログにあるとおり異なる色相が斜めっている場合で、
これはサブブロックを縦に割っても横に割ってもうまくいかないため圧縮テクスチャの弱点である色のはみ出しがとても顕著にでます。

f:id:enrike3:20180421202653p:plain

ETC1を見ればUnityちゃんの左腕や、bしてる右腕の袖のあたりに色のはみ出しが見えると思います。
素朴なDXT1では特に弱点にはならないのにETC1ではダメ、というのがなんとも切ない。

ETC2はETC1のもつ弱点にしっかり取り組んでいます。
サブブロック分割を伴わない三つのモードが新たに追加され、ETC1で弱かった絵が顕著に改善されています。
詳細は以下の資料に詳しい。
透明度を持てるようになっただけじゃないのよ。

ETC2: Texture Compression using Invalid Combinations

BC7やASTCのような、1ブロックあたり128bitの新世代の圧縮と比べればやや品質は落ちるようですが、 それでも2018年時点で広く使えてなかなかの品質、ということでETC2は良いフォーマットなんじゃないかなと思います。
AndroidにASTCが広まるのはまだ少し時間がかかると思うので、それまでの現実解として付き合っていくことになるでしょう。

©UTJ/UCL

CanvasUpdateRegistryとICanvasElement

UnityEngine.UI.dllの中身はBitbucketにあります。誰でも読めて素晴らしみあります。

uGUIを理解するのにあたって個人的にとても大事だと思っているのは、UnityEngine.UI.CanvasUpdateRegistryクラスです。
こいつは何者なのかというと、ざっくり言ってしまうと前回の記事に書いたCanvas.willRenderCanvasesイベントを、レイアウトとレンダリングの二つに分割するのが役割です。

uGUIにはVerticalLayoutGroupのようなレイアウトコンポーネントが存在します。
もし、描画コンポーネントがCanvasRendererにMeshを送り込んだあとでVerticalLayoutGroupがレイアウトを調整してしまった場合、1フレーム描画がおかしくなります。 したがってレイアウトは必ず描画より先に行われる必要があります。Canvas.willRenderCanvasesイベントより先に発火することが保証された別のレイアウト用イベントを用意するなど実現方法はいろいろあるでしょうが、uGUIはCanvas.willRenderCanvasesを二つにカチ割ることを選びました。

ICanvasElement

CanvasUpdateRegistryに登録できるのは、UnityEngine.UI.ICanvasElementインターフェースを実装したクラスならなんでもOKです。MonoBehaviourですらない、プレーンなC#クラスでOK。
(※ただしプレーンなクラスを用いる場合はtransformやIsDestroyedの実装で困らないようにMonoBehaviourをラップする形になるのがほとんどだと思われますが)

uGUIのコンポーネントは基底クラスのUnityEngine.UI.GraphicクラスがICanvasElementを実装していますし、レイアウトコンポーネントUnityEngine.UI.LayoutRebuilderクラスに勝手にラップされるので、uGUIを普段使用するうえでICanvasElementインターフェースの実装を意識することはまずありません。

ですが、「シーンのヒエラルキー上は親子関係にはないがuGUIのコンポーネントと位置を同期しなくてはいけない奴がいてしかもuGUI側が自動レイアウトされる」みたいな面倒ケースでは、ICanvasElementの実装もやむなし!となることもあります。僕は最近遭遇しましたよ、ええ…

ICanvasElementの実装で一番ポイントとなるのはICanvasElement.Rebuildメソッドです。これがレイアウトや再描画の本体になります。現在のRebuildがレイアウト用に呼ばれているかは、引数で渡ってくるUnityEngine.UI.CanvasUpdate列挙型を見ればわかります。わかるのですが、この列挙型はレイアウト用に三つ、描画用に二つのメンバーがいます。

わかりやすくはないのですが、RegisterCanvasElementForLayoutRebuildでコンポーネントを登録すると、1フレ中にPreLayout, Layout, PostLayoutで3回Rebuildが呼ばれ、RegisterCanvasElementForGraphicRebuildでコンポーネントを登録すると、PreRenderとLatePreRenderの2回Rebuildが呼ばれます。(PostRenderじゃないのかよという気もしますがまあMeshをCanvasRendererに突っ込む行為は厳密にはRenderingではないので。この記事内で「再描画」と書いているのも本当は正しくないですね)

実装

UnityEngine.UIを使わずにCanvasに描画するで書いたRawImageModokiを、ICanvasElement対応してみます。

using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(CanvasRenderer))]
public class RawImageModoki : MonoBehaviour, ICanvasElement
{
    [SerializeField]
    private Texture2D _texture = null;
    public Texture2D Texture
    {
        get { return _texture; }
        set
        {
            if(_texture != value)
            {
                _texture = value;
                SetDirty();
            }
        }
    }

    private CanvasRenderer _canvasRenderer = null;
    public CanvasRenderer CanvasRenderer
    {
        get { return _canvasRenderer != null ? _canvasRenderer : (_canvasRenderer = GetComponent<CanvasRenderer>()); }
    }

    void OnEnable()
    {
        SetDirty();
    }

    private void Start()
    {
        StartCoroutine(DelayRegister());
    }

    //なぜかIsDestroyedがtrueを返すタイミングがあるので少し遅らせて登録ナンデェ…
    private System.Collections.IEnumerator DelayRegister()
    {
        yield return null;
        SetDirty();
    }


    private void SetDirty()
    {
        CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
        Debug.Log("SetDirty");
    }

    private void Canvas_willRenderCanvases()
    {
        const float size = 200;

        var mesh = new Mesh();
        mesh.vertices = new Vector3[]
        {
            new Vector3(-size, -size),
            new Vector3(-size,  size),
            new Vector3( size,  size),
            new Vector3( size, -size),
        };
        mesh.uv = new Vector2[]
        {
            new Vector2(0, 0),
            new Vector2(0, 1),
            new Vector2(1, 1),
            new Vector2(1, 0),
        };
        mesh.triangles = new int[] { 0, 1, 2, 2, 3, 0 };

        var renderer = this.CanvasRenderer;

        renderer.SetMesh(mesh);
        renderer.materialCount = 1; //これ忘れると描画されないので注意
        renderer.SetMaterial(Canvas.GetDefaultCanvasMaterial(), 0);
        renderer.SetTexture(this.Texture);
        renderer.SetColor(Color.white);
    }

    #region ICanvasElement

    void ICanvasElement.Rebuild(CanvasUpdate executing)
    {
        Debug.LogFormat("Rebuild {0}", executing);
        if (executing == CanvasUpdate.PreRender)
            Canvas_willRenderCanvases();
    }

    public void LayoutComplete() { }
    public void GraphicUpdateComplete() { }

    public bool IsDestroyed()
    {
        var result = this == null;
        Debug.LogFormat("IsDestroyed {0}", result);
        return result;
    }

    #endregion

#if UNITY_EDITOR
    private void OnValidate()
    {
        SetDirty();
        if (!Application.isPlaying)
        {
            Canvas_willRenderCanvases();
        }
    }
#endif
}

f:id:enrike3:20180310190229p:plain

Canvas.willRenderCanvasesを直接購読しなくてもよくなったので、_renderRequiredフラグで毎フレームの処理をガードする必要はなくなりました。 Start付近に悲しい小細工がありますが…エディタのみの挙動ですかね…ちょっとよくわからない。

まとめ

  • uGUIのコンポーネントCanvas.willRenderCanvasesイベントを直接購読するのではなく、CanvasUpdateRegistryを経由することでレイアウトとレンダリングのタイミングを分離している。これによって柔軟なレンダリングが可能になる
  • ICanvaElementを実装すればCanvasUpdateRegistryに登録できて、uGUIのコンポーネントとイベントの足並みをそろえたり割り込んだりできる

@UTJ/UCL

UnityEngine.UIを使わずにCanvasに描画する

サンプルの実用度はまるで無いのですが、uGUIの理解のために。

UnityEngine.UI.dllはC#で書かれたManagedなdllです。
一方、Canvas, CanvasGroup, CanvasRendererなどはネイティブコンポーネントであり、名前空間もUnityEngineに属します。

CanvasはUnityEngine.UI.dllに依存を持たず、Canvasに描画したければCanvasRendererを使うだけでOKです。

using UnityEngine;

[RequireComponent(typeof(CanvasRenderer))]
public class RawImageModoki : MonoBehaviour
{
    [SerializeField]
    private Texture2D _texture = null;
    public Texture2D Texture
    {
        get { return _texture; }
        set
        {
            if(_texture != value)
            {
                _renderRequired = true;
                _texture = value;
            }
        }
    }

    private CanvasRenderer _canvasRenderer = null;
    public CanvasRenderer CanvasRenderer
    {
        get { return _canvasRenderer != null ? _canvasRenderer : (_canvasRenderer = GetComponent<CanvasRenderer>()); }
    }

    void Start ()
    {
        Canvas.willRenderCanvases += Canvas_willRenderCanvases;
    }

    private void OnDestroy()
    {
        Canvas.willRenderCanvases -= Canvas_willRenderCanvases;
    }

    private void Canvas_willRenderCanvases()
    {
        if (!_renderRequired)
            return;

        _renderRequired = false;

        const float size = 200;

        var mesh = new Mesh();
        mesh.vertices = new Vector3[]
        {
            new Vector3(-size, -size),
            new Vector3(-size,  size),
            new Vector3( size,  size),
            new Vector3( size, -size),
        };
        mesh.uv = new Vector2[]
        {
            new Vector2(0, 0),
            new Vector2(0, 1),
            new Vector2(1, 1),
            new Vector2(1, 0),
        };
        mesh.triangles = new int[] { 0, 1, 2, 2, 3, 0 };

        var renderer = this.CanvasRenderer;

        renderer.SetMesh(mesh);
        renderer.materialCount = 1; //これ忘れると描画されないので注意
        renderer.SetMaterial(Canvas.GetDefaultCanvasMaterial(), 0);
        renderer.SetTexture(this.Texture);
        renderer.SetColor(Color.white);
    }

    private bool _renderRequired = false;

#if UNITY_EDITOR
    private void OnValidate()
    {
        _renderRequired = true;
        if(!Application.isPlaying)
        {
            Canvas_willRenderCanvases();
        }
    }
#endif
}

f:id:enrike3:20180310152613p:plain

このコードで大事なところは描画のタイミングで、これはCanvas.willRenderCanvasesイベントを使います。厳密には調べていませんが、このイベントはLateUpdateより後に毎フレーム発火する雰囲気です。

void Start ()
{
    Canvas.willRenderCanvases += Canvas_willRenderCanvases;
}

あとは、このイベントの中でCanvasRendererに描画に必要なものを詰めるだけです。

var renderer = this.CanvasRenderer;

renderer.SetMesh(mesh);
renderer.materialCount = 1; //これ忘れると描画されないので注意
renderer.SetMaterial(Canvas.GetDefaultCanvasMaterial(), 0);
renderer.SetTexture(this.Texture);
renderer.SetColor(Color.white);

ただしCanvas.willRenderCanvasesは毎フレーム飛んでくるので、こちらに変更があるときだけCanvasRendererを更新するようなコードにします。

Canvasは、UnityのDynamicBatchingとは別枠でCanvasRenderer達の描画をバッチングしますが、バッチ条件は似たようなものなので、同一のMaterial、かつパラメータ一致意識しておけばOKです。CanvasRendererはMaterialPropertyBlockと似たような役割も果たすため、以下のメソッドはMaterialPropertyBlockと同じ感覚で使ってよいです。ただしシェーダパラメータ名は指定できず、適用先が固定です

  • CanvasRenderer.SetTexture ※_MainTexに挿さります
  • CanvasRenderer.SetAlphaTexture ※_AlphaTexに挿さります
  • CanvasRenderer.SetColor ※_Colorに適用されます
  • CanvasRenderer.SetAlpha ※_Colorのaにのみ適用されます

このあたりのパラメータがしっかり一致していればちゃんとバッチされます。 f:id:enrike3:20180310153819p:plain

Canvasは一度バッチした結果を毎フレーム使いまわして描画しますが、CanvasRendererに更新がかかればもちろん再度バッチする必要があります。
これはProfiler上はCanvas.BuildBatchで見えます。ですがバッチ処理はUnity5.2でかなり最適化されたようでCanvas.BuildBatchが大きな数字になることはさほどないように思います。

経験上、Profiler上で目立つのはCanvas.SendWillRenderCanvases()のほうです。
サンプルからもわかる通りこれはCanvasRendererに食べさせるMeshを生成するコードになりますが、UI要素が多いシーンの最初のフレームで顕著なスパイクを作ることがあります。UIもフレームを分けて生成できるように組んだ方がFPSの維持には良いであろうと思います。 もちろんフレームを分けることでBuildBatchの回数は増えますが、BuildBatchはかなり高速なので、初期化時の数回くらいは気にならないと思います。 さすがに毎フレーム変化するアニメーションモノは別Canvasに隔離したり、そもそもCanvasを使わずSpriteRendererにしたりすべきだとは思いますが。

まとめ

脱線はしましたが、まとめとしては

CanvasRendererだけあればCanvasに描画できますよ

ということだけです。
ではuGUIであるところのUnityEngine.UI.dllは一体何をしてくれるのかというと、CanvasRendererにMeshなどを食べさせるところをとても使いやすくしてくれているユーティリティないしフレームワークということになります。

@UTJ/UCL

DrawCall Batching

DrawCall Batching

Unityは描画効率のために、複数のRendererの描画を一つに束ねることがあります。 これをバッチングといい、 どのRendererの描画をまとめるかを事前に指定する方式をstatic batching Materialの状況をみてUnityがよしなに描画を束ねるかどうかを自動で判断するのをdynamic batchingといいます。 バッチングのON/OFFはPlayer Settingsで指定でき、デフォルトはONです。

f:id:enrike3:20180228223429p:plain

dyanmic batchingの条件

dynamic batchingの効く条件は、以下2点を満たすことです

  • 同一のマテリアルを使用していること
  • Material Property Blockの値が一致していること

あ、もちろん同一のCameraで映してくださいね。

実験

どシンプルなSprite風シェーダで実験です。 このシェーダは画像と乗算カラーのみを指定できます。

Shader "Hoge/OreOreSprite"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Color("Tint", Color) = (1,1,1,1)
    }
    SubShader
    {
        Cull Off ZWrite Off ZTest Always
        Blend One OneMinusSrcAlpha

        Tags
        {
            "Queue" = "Transparent"
            "PreviewType" = "Plane"
        }

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

            float4 _Color;

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

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

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                o.color = v.color * _Color;
                return o;
            }
            
            sampler2D _MainTex;

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv) * i.color;
                return col;
            }
            ENDCG
        }
    }
}

作ったShaderにunity-chanの画像でもぶっ挿して乗算カラーも指定しておきます。 f:id:enrike3:20180228225127p:plain

このMaterialを使用して適当にQuadメッシュを二つ描画してみます。 FrameDebuggerでみると、ちゃんとバッチが効いて1Drawで2つの描画が束ねられています。 f:id:enrike3:20180228225400p:plain

ここで、2体目のUnity-ChanのMaterialを差し替えてみましょう。 元のMaterialをコピーして、同じシェーダ、同じテクスチャ、同じ乗算カラーのMaterialを作成します。 中身は一緒だけどガワが違うとでもいいましょうか。

f:id:enrike3:20180228230008p:plain

描画は分かれてしまいました。 中身がどんなに等しくても、Materialが別だとバッチングは効かないことがわかります。

Material Property Block

Unityには、同一のMaterialを使用しつつも、Renderer単位でパラメータを変える仕組みとして Material Property Blockが存在します。 すんごい雑にStartでMaterialPropertyBlockをつっこむコンポーネントを書いてみます。

using UnityEngine;

[RequireComponent(typeof(MeshRenderer))]
public class OreOreSprite : MonoBehaviour
{
    [SerializeField]
    private Texture _texture = null;

    [SerializeField]
    private Color _color;

    private MeshRenderer _renderer = null;
    public MeshRenderer Renderer
    {
        get { return _renderer != null ? _renderer : (_renderer = GetComponent<MeshRenderer>()); }
    }

    private MaterialPropertyBlock _block = null;

    void Start ()
    {
        _block = new MaterialPropertyBlock();
        _block.SetTexture("_MainTex", _texture);
        _block.SetColor("_Color", _color);
        this.Renderer.SetPropertyBlock(_block);
    }
}

インスペクタでテクスチャとカラーを指定可能 f:id:enrike3:20180228231945p:plain

これで、同一のMaterialを使いながらもパラメータを分けることができるようになりました。 同一のマテリアルを使用していてもパラメータが異なることによりバッチングは効いていません。 f:id:enrike3:20180228232244p:plain

この状態で、Inspectorから同じ画像、同じ色を指定してみましょう。(Materialは同一のものを使用するとします)

f:id:enrike3:20180228233246p:plain

無事描画が一つにまとまりました。

まとめ

dynamic batchingの効く条件は、以下2点を満たすことです

  • 同一のマテリアルを使用していること
  • Material Property Blockの値が一致していること

今回はMeshRendererを使用しましたが、SpriteRendererやCanvasあたりも基本的には同じです。 (Canvasはdyanmic batchingというよりはCanvas独自バッチングになるのでむしろdynamic batchingからははずれますが、Materialが分かれたりパラメータがばらけるとバッチされないという点では一緒) MaterialPropertyBlockによって、同一Materialを使いまわしながらも、パラメータを変更できるようにし、 MaterialPropertyBlockのパラメータが(たまたまor狙って)一致した場合は描画が束ねられる、という形をとっています。 なおuGUIの場合はCanvasRendererがMaterialPropertyBlockに近い役割を担っています。

テクスチャなんて一致するのかよ、という点については、 複数の画像を一つにパッキングしておけば束ねられます。

f:id:enrike3:20180228234909p:plain

こうやってパックされたテクスチャを使って、Spriteによる範囲指定で抜けば、画像内のキャラを何体書いても同一Materialを使う限り1DrawCallで済みます。 キャラクターは画像が大きいことが多いので例として良くないですが、 小さい画像の多いUIではパッキングは効果があります。

余談:PerRendererDataについて

MaterialPropertyBlockやCanvasRendererによって、後から画像を挿す場合Materialに挿してある画像は無駄です。 消し忘れると、MaterialをLoadしただけで使う予定のない画像までついてくる。許せん、となります。 そのような想定の画像はshader側で [PerRendererData] 指定をしておくと良いです。

Shader "Hoge/OreOreSprite"
{
    Properties
    {
        [PerRendererData] _MainTex ("Texture", 2D) = "white" {}
        _Color("Tint", Color) = (1,1,1,1)
    }

これを書くと、Materialのインスペクタから _MainTexを指す箇所が消えるので事故防止になります。

© UTJ/UCL