【Unity】シーンを汚さず1回のDrawCallで動くデバグUI

この記事はカヤックUnityアドベントカレンダー2018の1日目の記事です。ここでは、

f:id:hirasho0:20181127182140j:plain

こんな感じのデバグ用UIについて、 3年目平山がお話いたします。 これは、弊社東京プリズンのデバグビルドの画面写真です。

この機能を実装するライブラリはgithubで公開しております。 実際の製品で使用していますので、 そのまま持っていってお使いになることもできるかとは思いますが、 何しろデバグ用と割り切って作りましたので、諸々テキトーです。 あくまでサンプルということで、よろしくおねがいいたします。

動機

実機上にデバグ機能が欲しいことは多々あります。 無敵ボタン、本来見えない敵の状態表示、通信切断状態にするボタン、 サウンドの状態表示、本来存在しないステージセレクト、 等々、挙げればキリがありません。 こういうものを作るにあたって、unityでは何を使うのがいいでしょうか。

UnityEngine.UI

デバグ用に限らずUIと言えば、最初に検討すべきはUnityEngine.UIです。 シーンにボタンを置けばすぐ動きます。

f:id:hirasho0:20181127182137j:plain

これですね。

まあこれでいいと言えばいいのですが、 シーンに物を置く以上、普通にやれば製品にも入ってしまいます。 disableにしておけばいいとも言えますが、 何かの拍子に有効になってしまうかもしれない、と不安になる方もいるでしょう。 シーンの場合、

#if IS_DEBUG

のようなものを使ってコンパイル時に外す、ということはできないわけで、 もっと面倒で凝った仕組みを作らない限り製品で外すことはできません。

また、シーンを複数人でいじるとマージが困難になりがちです。 シーンごとに担当者が一人だけ決まっていて、他の人はいじらない、 というならいいのですが、そうできない現場もあるでしょう。 となると、たかがデバグ機能のためにシーンをいじりたくありません。 どうにかコードだけでやれないか?という話になってきます。

UnityEngine.GUI

もちろん、コードでやる機能もUnityにはあります。

f:id:hirasho0:20181127182125j:plain

これですね。

void OnGUI()
{
    GUI.Button(new Rect(10, 10, 100, 50), "ボタン");
}

これでボタンが出せ、IS_DEBUGみたいなものでくくって製品で外すこともできます。 しかしこいつは私にとっては耐え難い欠点を三つも持っているのです。

  • 性能がヤバい
  • OnGUIに書かないといけない
  • 端末の解像度に合わせて拡縮しない

まず性能ですが、こいつは毎フレームGC Allocします。 このインターフェイスからして仕方ない感じはしますよね。 Buttonという関数を毎フレーム呼んで、 "ボタン"なんていう文字列を毎フレーム渡していれば、 当然フレームごとにオブジェクトの生成が行われるでしょう。

加えて、ボタン1個につきDrawCallが2回増えます。 これはちょっとないんじゃないでしょうか。 機種によってはDrawCall一回につき0.1msくらい持っていかれるわけで、 デバグ機能でボタン10個置いたら1ms遅くなる、ということもありえます。

「デバグビルドで速度なんて気にしない」という流儀もおありかとは思いますが、 私は逆でして、「デバグビルドとリリースビルドの速度は極力近づけるべき」 と考えております。そうでないと、デバグビルドの価値が下がってしまうからです。

「結局性能を見るならリリースビルドだよね」となると、 それだけリリースビルドが必要になる頻度が増えて面倒くさくなります。 そして、リリースビルドで何か起きてもデバグできません。 Unity以前の話ですが、デバグビルドがゲームにならないほど遅いために、 みんな普段からリリースビルドを使っていて、そのために全くバグに気づかない、 なんて例もありました。そんなこともあって、 デバグ機能であっても十分に速い方が良いと思います。 初期化時以外GC Allocはゼロで、DrawCallは何個ボタンがあってもせいぜい数回、 という程度にしたいものです。

次に「OnGUIで書かないといけない」件ですが、「Updateに書けたら何でいけないの?」 と思ってしまいます。他の処理に混ぜて書けないのは不便です。

そして最悪なのが解像度です。座標とサイズの指定が実解像度なので、 解像度が異なる端末ではサイズが変わってしまいます。 960x540だと全画面だけど、1920x1080だと左上1/4しか使わない、 みたいなのはちょっと許せません。ましてスマホです。 小さくなりすぎてボタンが押せなくなったら困るでしょう。 仮想的な解像度で指定して、ほどよく拡大縮小してくれないと使えません。

と、そういうわけで、自作に走ることになったわけです。

自作で達成したかったこと

今回やりたかったことを箇条書きにしてみます。

  • コードだけでやりたい(#if IS_DEBUGなどで製品から消したい)
  • gameObjectを作りたくない
  • できればDrawCall一回で行きたい
  • できるだけGC Allocしたくない
  • ボタンやスライダーにはコールバックを渡したい
  • インスタンスは前もって作るようにして、毎フレーム作り直したくない
  • ウィンドウ、ゲージ、スライダー、トグル、ラジオボタン、ログウィンドウ、テーブル、が欲しい
  • ウィンドウはドラッグしたいし、最小化したい
  • ほどよく拡大縮小される

結果的には、ほぼほぼこれらを満たすものができました。 最初にお見せした画面はDrawCallが1回で、gameObjectはありません。

ウィンドウの作り方

では、このライブラリを使ってデバグUIを出す手順を見てみましょう。 試しにこんな画面を作ってみます。

f:id:hirasho0:20181127182134j:plain

これは以下のようなコードで生成できます。

public class SampleWindow : DebugUiWindow
{
    DebugUiLogWindow _log;

    public SampleWindow(DebugUiManager manager) : base(manager, "SampleWindow")
    {
        var button = new DebugUiButton("ボタン", 100f);
        button.onClick = () => { _log.Add("ボタンが押された!"); };
        AddChildAuto(button);

        var toggleGroup = new DebugUiToggleGroup(); // グループ化を行うなら必要
        var toggles = new DebugUiToggle[2];
        toggles[0] = new DebugUiToggle("トグルA", 100f, 50f, toggleGroup);
        toggles[0].onChangeToOn = () => { _log.Add("Aが有効になった"); };
        AddChildAuto(toggles[0]);
        toggles[1] = new DebugUiToggle("トグルB", 100f, 50f, toggleGroup);
        toggles[1].onChangeToOn = () => { _log.Add("Bが有効になった"); };
        AddChildAuto(toggles[1]);

        var text = new DebugUiText("テキスト", fontSize: 20f, width: 80f, height: 20f);
        AddChildAuto(text);

        BreakLine();

        _log = new DebugUiLogWindow(fontSize: 20f, lineHeight: 22f, lineCount: 10, width: 800f);
        AddChildAuto(_log);

        BreakLine();

        var frameTimeGauge = new FrameTimeGauge(200f, 30f, null);
        AddChildAuto(frameTimeGauge);

        var slider = new DebugUiSlider("スライダー", -100f, 100f, 400f);
        slider.onDragEnd = () => { _log.Add("スライダーが" + slider.value + "に変更された"); };
        AddChildAuto(slider);

        BreakLine();

        var table = new DebugUiTable(
            16f,
            new List<float>(){ 80f, 80f, 120f },
            3,
            20f);
        table.cells[0, 0] = "列A";
        // (中略)
        table.cells[2, 2] = "データ23";
        AddChildAuto(table);

        AdjustSize();
    }
}

DebugUiWindow なるクラスがライブラリに用意されており、使用者はその派生を用意します。 DebugUiControl を継承した諸々のコントロール(ボタンやテキストなどのUIの部品)をnewしては、 AddChildAuto()でウィンドウの中に足していきます。BreakLine()は改行です。 最後にAdjustSize()するとウィンドウのサイズが自動で決定されます。

ボタンやトグル、スライダーには、状態変更時のコールバックを設定できます。 上の例ではonClick、onChangeToOn、onDragEndといったものです。

初期化でインスタンスを生成してから使う形式ですが、 コールバックを設定する程度であれば、 newしたインスタンスの参照を持っておく必要はありません。 大抵の場合は上のようにフィールド追加なしでやれます。

シーン側の準備

次に、シーン側です。 DebugUiManager をAddComponentするGameObjectを、canvasの下に用意します。 専用である必要はありません。

そして、初期化をどこかのスクリプトで行います。 サンプルではSampleというクラスが行っており、 これはカメラがついているgameObjectにくっつけてあります。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Kayac;

public class Sample : MonoBehaviour
{
    [SerializeField]
    GameObject _gameObjectForDebugUiManager;
    [SerializeField]
    Shader _textShader;
    [SerializeField]
    Shader _texturedShader;
    [SerializeField]
    Font _font;
    [SerializeField]
    Camera _camera;

    DebugUiManager _debugUi;
    DebugPrimitiveRenderer2D _renderer;
    SampleWindow _sampleWindow;

    void Start()
    {
        _renderer = new DebugPrimitiveRenderer2D(
            _textShader,
            _texturedShader,
            _font,
            _camera,
         capacity: 8192); // 最大の三角形数
        _debugUi = DebugUiManager.Create(
            _gameObjectForDebugUiManager, // ここにAddComponenしたものが返される
            _renderer);
        _sampleWindow = new SampleWindow(_debugUi);
        _debugUi.Add(_sampleWindow);
    }
    void Update()
    {
        _debugUi.ManualUpdate(Time.deltaTime);
    }
    void LateUpdate()
    {
        _renderer.LateUpdate();
    }
}

テキスト用のシェーダ、テクスチャを貼るためのシェーダ、 フォント、カメラ、を指定して、 DebugPrimitiveRenderer2D をnewします。シェーダはライブラリに同梱されています。 そして、このDebugPrimitiveRenderer2Dと、DebugUiManagerをつけるgameObjectを指定して、 DebugUiManagerを生成します。

あとは上記の感じで初期化して、 自分のウィンドウクラスをnewしてManagerにAdd()し、 Update()とLateUpdate()で上に書いた感じのことをします。

DebugUiManagerにManualUpdate()を用意して自力で呼ばないといけない作りにしたのは、 標準のUpdateだと実行順が不定で困ったのと、 Unityのイベント関数は負荷の面でタダではないからです。 東京プリズンで私が担当した部分においては、UpdateやAwake、Startは極力使わず、 手動で初期化や更新関数を呼ぶ作りにしていました。そうすれば全機種で同一の実行順になり、 オーバーヘッドもないからです。オブジェクトの親子関係を素直にコードで表現できます。 デバグUIもその設計に合わせてこうなっています。

しかしながら、Updateのオーバーヘッドを気にせず、 実行順がどうなっても動くように書いていれば、 普通のUpdateにした方がUnityとしては正しいかもしれません。 それならシーンの中に置いておくだけで動作します。

描画について

では、描画をどうしているのかについて、粗く説明しましょう。 描画を司るのはDebugPrimitiveRenderer2Dと、 その基底である DebugPrimitiveRenderer です。2Dがついた方には「長方形」「円」「線」といった2D特有の図形を生成する機能を用意し、 基底が毎フレーム Mesh に頂点データを設定して CommandBuffer で描画しています。 Mesh.SetVertices()Mesh.SetUVs()Mesh.SetColors()、 といった関数はお使いでしょうか? 1文字書く度に6頂点を生成して配列に詰め、これらの関数でMeshに渡します。 なお、CommandBufferを使っているのは単なる趣味というか勉強で、あまり意味はありません。 普通に MeshRenderer で描いても良いと思います。

さて、「DrawCallは1回」と申しましたが、これはテクスチャを貼らない場合の数字です。 上で紹介したSampleWindowの場合、文字描画と線しかないので、この場合はいくら描いてもDrawCallは1回です。 線や塗りつぶしなどの描画は、フォントに含まれている四角い文字、つまり'■' の中央部分のテクセルを使うことで実現しているため、テクスチャを切り換える必要がなく、 結果DrawCallが増えずに済んでいます。 四角の真ん中のテクスチャ座標を取ってくるコードはこんな感じです。

CharacterInfo ch;
_font.GetCharacterInfo('■', out ch);
_whiteUv = ch.uvTopLeft;
_whiteUv += ch.uvTopRight;
_whiteUv += ch.uvBottomLeft;
_whiteUv += ch.uvBottomRight;
_whiteUv *= 0.25f; // 4頂点平均すれば中央になる

フォントテクスチャの一部に任意のテクスチャを貼りつけられれば こんな裏技めいたことをしなくて良いのですが、やり方が見つかりませんでした。

なお、DebugPrimitiveRendererを継承したクラスには、 別にDebugPrimitiveRenderer3D もあります。 今回の話とは関係ありませんが、3Dのゲーム画面中に矢印や長方形、文字列、ビルボードなどを描画するのに使っています。 カメラのフォーカス範囲を描画する、キャラクターに追加情報を表示する、 地面のマスごとに情報を表示する、といった用途では、3D空間内に文字や図形を描けると便利です。 今回公開したサンプルに使う側のコードはありませんが、使い方は簡単なので、よろしければお試しください。 DebugUiManagerとは関係なく、DebugPrimitiveRenderer3D単体で使えます。

入力イベントについて

ボタンなどを押すには、入力を取る必要があります。 しかし、今回自作したものはgameObjectを使っていないので、 通常のUnityEngine.UI.Graphicの機能は使えません。 とはいえ、できればUnityEngine.UI.Graphicの機能を使って入力処理ができた方が、 他のUI要素との親和性も良く、実装も楽そうです。 そこで、DebugUiManagerだけは、UnityEngine.UI.Graphicを継承しています。 このRectTransformを画面全域にしてraycastを有効にすることで、 入力を取得します。

とはいえ全画面で入力に反応してしまうと、他のUI要素(ボタン等)の操作を妨げてしまうため、 実際にデバグUIの要素がない場所では反応しないようにする必要があります。 これには UnityEngine.ICanvasRaycastFilter.IsRaycastLocationValid() を実装します。 UpdateやAwakeと同じく、overrideをつけないで実装すると何故か有効になる関数です。

public bool IsRaycastLocationValid(Vector2 sp, Camera eventCamera)
{
    if ((_renderer == null) || !inputEnabled)
    {
        return false;
    }
    // 何かに当たるならイベントを取り、何にも当たらないならスルーする
    float x = sp.x;
    float y = sp.y;
    ConvertCoordFromUnityScreen(ref x, ref y);
    bool ret = false;

    // ドラッグ中ならtrueにする。でないと諸々のイベントが取れなくなる
    if (isDragging)
    {
        ret = true;
    }
    // 何かに当たればtrue
    else if (_root.RaycastRecursive(0, 0, x, y))
    {
        ret = true;
    }
    else
    {
        // 外れたら離したものとみなす。
        _input.isPointerDown = false;
        _input.pointerX = -float.MaxValue;
        _input.pointerY = -float.MaxValue;
        ret = false;
    }
    return ret;
}

コントロールは木構造をなしているので、根(_root)から葉に向かって深さ優先で辿っていき、 どれかのコントロールの矩形にポインターが含まれればtrueを返します。 それを行っているのがDebugUiControl.RaycastRecursive()です。 ただし、ドラッグ中はコントロール矩形からはみ出していてもtrueを返す必要があります。

また、座標はスクリーン座標から、内部の仮想座標系に変換してから判定しています(ConvertCoordFromUnityScreen)。 仮想解像度は例えば1136x640などに設定しておき、 実機が1920x1080であろうと、854x480であろうと、画面に対する比率が同じになるようにしています。 アスペクト比が異なる場合は小さい方で合わせ、仮想解像度の内部のものは必ず画面内に入ります。

ところで、今にして考えれば、入力イベントを取るだけのためにCanvasを必要とするのも面倒くさいですね。 実際Canvasを描画のためには使っていないわけで、非効率でもあります。 単純に板状のColliderを用意して、 PhysicsRaycasterを用いてEventSystemにつないでも良かったのかもしれません。

派生コントロールの例

ボタンもトグルも全てDebugUiControlを継承しており、 これを継承すれば新たなコントロールを作れます。 また、 DebugUiPanel というコントロールがあり、ここには複数の子コントロールを配置できます (DebugUiWindowはDebugUiPanelにタイトルバーをつけたものです)。

というわけで、結構いろいろ作れます。 ここでは一例として、「フレーム時間ゲージ」をご紹介しましょう。

f:id:hirasho0:20181127182743j:plain

これです。 緑のバーが左の数字に、赤いバーが右の数字に対応しています。 21は21ミリ秒を意味し、「最近60フレームの平均所要時間」です。 44は44ミリ秒を意味し、「最近60フレームで最も時間がかかったフレームの時間」です。 つまり、処理速度や処理落ちの状況をゲージにして表したものです。

フレームレートを監視するのにどういうツールを作ってどう表示するか、 というのはいろんな流儀がありますが、私はこのやり方が気にいっています。 FPS(Frames Per Seconds)、つまり1秒間あたりのフレーム数は、 大きいほど良いということもあってわかりやすいのですが、 「つまりあと何ミリ秒速くすればいいのか」がわかりにくいという欠点もあります。 「FPSが25で5足りない」時と「FPSが55で5足りない」時では、 実際に削減すべきミリ秒は全く異なります。前者は後者の倍以上大変です。 「60FPSが目標なら16に抑えねばならず、今22なのであればあと6ミリ秒足りない」 とすぐわかる方が私は便利だと感じます。なのでミリ秒で出しているわけです。 マイクロ秒で出す方もいらっしゃるかと思いますが、 リアルタイム表示する数値にそこまで精度はいらないでしょう。

また、「最近60フレームの最大フレーム時間」を出しておくことで、 負荷スパイクに簡単に気づくことができます。 単に平均しか出していないと、「大半大丈夫だけど1フレームだけ遅い」と「平均的に遅い」 の区別がつきません。対処は全く異なりますから、どちらなのかわかる方が良いわけです。

この FrameTimeGaugeDebugUiDualGauge という基本コントロールを継承しており、 結構簡単に作れます。サンプルに入っているので、よろしければ中身も見てみてください。

なお、記事冒頭に挙げた製品のスクリーンショットにもこのゲージが映っております。

f:id:hirasho0:20181127182131j:plain

サンプルで示したものと違い、「API」という2本目のゲージがあります。これは、 「最近10APIの通信所要時間」「最近10APIの最大所要時間」です。 何か起こった時に通信状況に由来するバグである 可能性があるかどうかがすぐわかります。

もし使用メモリ量が取れるなら、同様にゲージにすると便利でしょう。 Unityの機能では取れませんが、ネイティブプラグインを書けば取れるはずで、 その手間をかける価値はあると思います。

なお、その他の機能としては、 現在のサウンド状態をBGM、SE、ボイス等のチャネルごとに表示するウィンドウがあったり(これも冒頭の画像に映っています)、 Debug.LogやDebug.LogWarningの出力が出てくるウィンドウが DebugUiLogWindow で実装されていたり(これも映っています)、 バッテリー量の推移をグラフ化する機能があったりと、 結構いろいろ作りました。他のモジュールに依存しているものが多くサンプルコードには 入れられませんでしたが、ご要望があれば、 うまく切り出して追加公開できればと思います。

まとめ

というわけで、自作してみましたが案外動きます。 チーム内で自分専用に作っていたので、社内の他のチームに共有はしていないのですが、 隣に座っていたしはん は結構使ってくれて、リストボックスなども作ってもらいました。 社内で共有する前にこういう形で社外に出てしまう、というのは面白いですね。

なお、現状の機能制限や不具合は以下のような感じです。

  • イマイチかっこ悪い。
  • 配置にアンカーが欲しい。右とか左とか中央とか。
  • フォントサイズの自動決定、文字列に合わせてサイズ決定、などの機能が貧弱。
  • フォントテクスチャが一杯になって作り直したフレームに描画が崩れる。
  • 全く更新していなくても丸ごと描画をやり直すので負荷が減らない。
  • 見栄えのいいグラフが欲しい。
  • 最小化や位置変更などに関して、スマホ操作に適した形を模索したい。

いずれも、時間があればできそうですが、時間次第です。

あと、いつかまとまった時間をもらえればやりたいこととして、 「表示を別の機械のブラウザ上に持っていく」ということがあります。 スマホ実機は画面が小さく、画面上にこういったデバグ情報を出していると邪魔です。 httpサーバをスマホの中に実装して外部のブラウザからつなぎ、 そのブラウザ上にこのUIを描画すれば、この問題を軽減できます。 QRコードにurlを入れて実機上に出して、それを使って別のタブレット等のブラウザにUIを出し、 ゲーム内情報をリアルタイム表示しながらテストプレイする、というようなことも考えられます。 httpサーバを実機に入れ、通信仕様を決め、表示のJSを書く、 という工程は見えているので、やればできるかなという思いはあります。

また、今回やった描画の方式をデバグUIで終わらせず、製品に入る機能にも応用する、 という方向もあります。例えばAfterEffectsやFlashで作ったデータを再生する ランタイムを実装する際に今回の作りを応用できます。 UnityEngine.UIは「あまり動かない」場合に最適化されており、 頂点が動くとひどく性能が落ちます。 しかし、今回作ったものは「毎フレーム全頂点を詰め直す」作りなので、 いくら動いても負荷が変わりません。部分的に頂点を詰めたまま取っておく機能を作れば、 さらに性能を上げられます。例えば文字列のレイアウトをキャッシュするだけでも 相当速くなるはずです。 そして、頂点を生成する処理はUnityEngineの関数を使わないため、 別のスレッドに持っていって並列化することもできます。 性能を改善する手はいくらでもありそうです。

ただ、本番の描画に使おうと思うと、 テクスチャでのソートによってDrawCallを減らさないといけなかったり、 カスタムシェーダやマスク等々への対応が必要だったりと、 必要な実装保守コストが跳ね上がります。 「自作しないで既存のものを使え」と怒られそうな気もしますね。 一定以上凝ったことは標準のSpriteRendererに任せるのがUnity的には正しい作り方でしょう。 今回のデバグ描画はUnityの妥当な使い方からすれば、少々外れているのかもしれません。

さて、次回はごんによるARKitのお話です。 お楽しみに!