MessagePackのデータをUnityのTreeViewで表示する

f:id:enrike3:20190210162406p:plain

MessagePack-CSharpは機能も性能も充実しているのでとにかく問答無用で使うわけですがJsonと違って中のデータを汎用エディタでさくっと覗けるわけではありません。 しかしマスタデータや、ユーザーの起動時ダウンロードデータなどをブレイクポイントをはって眺めるのもたいがい非効率なので、UnityにTreeViewも来たことだし、UnityのTreeViewでMessagePack-CSharpのデータを表示できるようにしてみました。まあどこも似たようなものは作っていると思うんですが…

github.com

使い方は、MessagePackGridViewWindowにMessagePackなObjectを放りこむだけです。 ただし、Deserialize時にFormatterが登録されている必要はあるので、非Playモードで使用するならInitializeOnLoadあたりでMessagePackのFormatterの登録は必要になります。

var msgPackObj = MessagePackSerializer.Deserialize<ExampleType>(bytes);
var window = EditorWindow.GetWindow<MessagePackGridViewWindow>();
window.SetData(msgPackObj);

現時点では表示のみだしソートもフィルタもできませんしUnion非対応だし、とアルファ以前ぐらいの出来ですがあまり気にせずに公開してみました。 おいおい機能は強化していく、と思います。

C#で確保済みbyte配列でmallocモドキ

GC任せにしてbyte配列何度も確保したくないし、Spanが来るから積極的に部分配列を使いたい。 byteに対して標準Cライブラリのmallocみたいなことできないかなーとほんのり思っていました。

mallocぽいというのはこういう感じですね。フラグメンテーション上等! f:id:enrike3:20180710220418p:plain

やってみたらSortedSetが優秀だったのでえらくあっさり作れました… スレッドセーフではないですけどね。シリアライズ用にちょいちょいSpanを借りるなら ArrayPoolからbyte借りてくるよりも向くのではないかなーとかとか。

using System;
using System.Collections.Generic;

public struct ByteSegment : IComparable<ByteSegment>
{
    public int Offset { get; }
    public int Length { get; }

    public ByteSegment(int offset, int length)
    {
        Offset = offset;
        Length = length;
    }

    public int CompareTo(ByteSegment other)
    {
        return Offset - other.Offset;
    }

    public override string ToString()
    {
        return $"{Offset},{Length}";
    }
}

public class BytePool
{
    SortedSet<ByteSegment> _segments = new SortedSet<ByteSegment>();
    public byte[] Buffer { get; private set; }

    public BytePool(int capacity)
    {
        Buffer = new byte[capacity];
    }

    public ByteSegment Alloc(int length)
    {
        int offset = 0;
        if(_segments.Count != 0)
        {
            foreach(var s in _segments)
            {
                var desired = offset + length;
                if(s.Offset < desired)
                {
                    offset = s.Offset + s.Length;
                    continue;
                }
                else
                {
                    break;
                }
            }
        }

        if (Buffer.Length < (offset + length))
            throw new Exception("無理ぽ");

        var newSegment = new ByteSegment(offset, length);
        _segments.Add(newSegment);

        return newSegment;
    }

    public void Release(ByteSegment segment)
    {
        _segments.Remove(segment);
    }

    public Span<byte> ToSpan(ByteSegment segment)
    {
        return new Span<byte>(Buffer, segment.Offset, segment.Length);
    }
}

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