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