Unityのデバグ作業をSlackで効率化する

f:id:hirasho0:20190215190609p:plain

この記事では弊社東京プリズンでの Slackを使ったデバグ支援について、技術部平山が紹介いたします。

上の画像は今回のために作った 仮のワークスペースのスクリーンショットです。 以下のようなものがUnity側から投稿されていることがわかります。

  • 「スニペットテスト」とあるのは、スニペット扱いで投稿したもので、長めのテキストに適します。もちろん単なるメッセージも投稿できます。
  • 次がスクリーンショットです。
  • 「またね」と書いてある画像は、任意のテクスチャを投稿する機能によります。主にRenderTarget描画の確認に用いました。
  • 最後は任意のファイルを投稿する機能です。ここではUnityのログを記録したものを投稿しています。

Slack側の登録が済んでいれば動くコードを、 サンプルプロジェクトごとgithubに置いておきました。 東プリで用いたものを整理し、一部削ったもので、そのままではありません。 このままでもある程度は使えると思いますが、 製品ごとの事情に合わせていじった方が、より効率の良い開発体制が作れるでしょう。

なお、これら機能の多くは先輩方によって書かれたものであり、 平山はこの紹介記事を担当しただけです。 便利な機能を作ってくださった先輩方に感謝いたします。

動機

チームで開発していると、何らかの不具合が起きた時に、 その状況をチームで共有したくなります。 カヤックではコミュニケーションに主としてSlackが用いられていますので、 不具合に関する情報もSlackに集約するのが自然です。

不具合に関する情報と言えば、

  • スクリーンショット
  • 起きた端末の情報
  • 時刻
  • それまでに溜まっているログ
  • 現在開いているシーン

といったものでしょう。これらを、 UnityEditorからに限らず、実機たるスマホ端末からであっても ボタン一つで送信して集積したい、と思うのは自然なことです。 そうすれば、多数のスマホ実機でテストプレイをする際に 出た不具合情報も集積できます。

幸い、そのために必要な実装はさほど大きくありません。 ただし、運用においては少々気をつけるべきこともあります。

Slack側の準備

まずSlackのアカウントやAppの準備が必要です。 これに関しては他に良い記事がたくさんあります。 例えばこちらが参考になります(私もこれを見てやりました)。 概要を示せば、

  • Workspaceを作る(製品の開発ならもうありますよね)
  • Appを作って権限を付与し、トークン(token)を得る

となります。

なお、スクリーンショットなどのファイルをアップロードしたい場合、 権限には"Send messages as ユーザ名(chat:write:bot)"だけでなく、 "Upload and modify files as user(files:write:user)"も必要です。

f:id:hirasho0:20190215190606p:plain

なお、このトークンは秘密にしないといけません。 漏らしてしまうと、わけのわからないところから書きこみが飛んでくる恐れがあります。 とはいえ、厳重にやればやるほど面倒くさくなりますので、 そこは匙加減でしょう。さしあたり、

  • gitには入れない
  • 会社で一個作って使い回し、でなく、製品ごとにする

といったあたりは守っておいて良いのではないかと思います。

今回のサンプルでは、.gitignoreに書いてgitに入らなくした テキストファイルをStreamingAssetsの下に置き、 そこにトークンを書いてあります。 ビルドに入ってしまいますので、 不特定多数に配布する場合は、ファイルを削除してからビルドし、 Slack関連機能も無効化する必要があります。

東京プリズンでは一段進めて、トークンを暗号化しています。

実装

ではUnity側です。これに関しても良い記事は多数あります。

最後は弊社の記事でして、東京プリズンで最後まで一緒に過ごした趙さん のものです。

とはいえ概要はこの記事だけでわかった方が便利でしょう。以下にて粗く説明いたします。

概要と単純投稿

要するに特定の作法でhttpを叩けばいいわけで、手順を粗く示せば、

となります。単純な投稿の場合をコードで書けば、

IEnumerator CoPost(string channel, string message)
{
    var form = new WWWForm();
    form.AddField("token", _apiToken); // どうにかしてトークンを渡す
    form.AddField("channel", channel); // 送りたいチャネル名。channelsでなくchannel。単数形。
    form.AddField("text", message);
    var req = UnityWebRequest.Post("https://slack.com/api/chat.postMessage", form);
    var operation = www.SendWebRequest();
    while (!operation.isDone)
    {
        yield return null;
    }
}

という具合です。これをStartCoroutineに渡すか、UpdateでMoveNext()するなりすれば、 そのうち送信が終わります (自力でMoveNext()する選択肢を残すために、isDoneを見ながらyield return null;しています)。

スニペット投稿

少し長くなると、チャネルにベタでテキストが貼られると邪魔なことがあります。 こういう時はスニペット投稿です。 普通に使う時は、書き込み欄の左にある+マークを押すと出てきます。 これと同じことをUnityから行います。

f:id:hirasho0:20190215190613p:plain

単純なメッセージ投稿と違うのは以下です。

  • 送り先がchat.postMessageでなく、files.upload。
  • textでなくcontentに中身を入れる。
  • filenameを指定することもできる。しなくてもいい。
  • channelでなくchannels。単数形じゃなくて複数形。

コードで書けば、

var form = new WWWForm();
form.AddField("token", _apiToken); // どうにかしてトークンを渡す
form.AddField("channels", channel); // 複数形だよー
form.AddField("content", message);
form.AddField("filename", filename);
var req = UnityWebRequest.Post("https://slack.com/api/files.upload", form);
...(以下省略)...

こういう具合になります。スニペットはファイルのアップロードという扱いなわけです。 スクリーンショットを送るのも、ログを送るのも同様です。

任意ファイル投稿

今度は任意のファイルを投稿しましょう。 スニペットもつまりはファイルのアップロードなので同じなのですが、 渡すものがバイト列(byte[])になるので、データの詰め方が変わります。

  • 中身はAddBynaryData()でfileに入れる。
  • メッセージはinitial_commentに入れる。
var form = new WWWForm();
form.AddField("token", _apiToken); // どうにかしてトークンを渡す
form.AddField("channels", channel); // 複数形だよー
form.AddBinaryData("file", binary, filename);
form.AddField("initial_comment", message);
var req = UnityWebRequest.Post("https://slack.com/api/files.upload", form);
...(以下省略)...

AddBinaryDatabyte[]とファイル名を渡し, メッセージはinitial_commentに入れます。 これで好きなファイルを投稿できます。

スクリーンショット

さていよいよスクリーンショットです。手順は、

  • 関数をコルーチン(IEnumeratorを返す関数)として用意する。
  • yield return new WaitForEndOfFrame();で描画終了時に以後が実行されるようにする。
  • 受け取り側Texture2Dを生成。
  • Teture2D.ReadPixelsで描画結果をもらう。
  • ImageConversion.EncodeToPNGでPNG形式のファイルデータを得る。
  • さっきの「任意ファイル投稿」で送る。

コードはこんな感じです。

public IEnumerator CoPostScreenshot()
{
    yield return new WaitForEndOfFrame();
    var width = Screen.width;
    var height = Screen.height;
    var tex = new Texture2D(width, height, TextureFormat.RGB24, false);
    tex.ReadPixels(new Rect(0, 0, width, height), 0, 0);
    var pngBytes = tex.EncodeToPNG();
    yield return CoPostBinary(pngBytes); // 任意ファイル送信関数で送る
}

WaitForEndOfFrameを使っているので、StartCoroutineで呼ぶ必要があります。 自前でMoveNextしても正常に動かないでしょう。

多くの記事ではReadPixelsの後でTexture2D.Apply を呼んでいますし、東京プリズンでも書いてあったのですが、必要なのかどうかがよくわかりません。 ReadPixelsのマニュアルを見た感じ ミップマップへの反映が不要ならいらない気がしますし、 実際エディタとStandaloneビルドでは動いています (ApplyってGPUへの転送やミップマップ生成を予約するだけですよね?)。 とはいえ今回実機では試してないので、もしかしたら書かないと動かないのかもしれません。 その時は教えてください。

運用

さて、機能自体はできたわけですが、問題はこれをどう開発に使うかです。 今回のサンプルでは、これらのSlackへの投稿機能を DebugSlackクラスとして用意しました。 少しコードを眺めていただけるとわかるのですが、それほど小さくはありません。 以下では運用のために行った工夫について紹介します。

クラス設計

クラスはMonoBehaviourではない普通のクラスです。newして使います。

public DebugSlack(string apiToken, string defaultChannel)

サンプルではこのnewを行うコードはMain クラスにあり、

Kayac.DebugSlack _slack;

void Start()
{
    var tokenFilePath = Application.streamingAssetsPath + "/slackToken.txt";
    var tokenFile = new System.IO.StreamReader(tokenFilePath); // 暗号化しといた方がいいよ!
    var token = tokenFile.ReadToEnd();
    tokenFile.Close();
    _slack = new Kayac.DebugSlack(token, _defaultChannel);
}

といった感じです。

余談: なぜシングルトンでないか

さてこのDebugSlackはどこからでも使いたい機能であり、インスタンスは一個で足ります。 となるとシングルトンにしたくなりますが、ここではしていません。 私は「オブジェクトの所有関係が木になっていること」に喜びを感じるタイプなので、 木から外れるシングルトンが嫌いなのです。

それに、シングルトンは後からinterfaceを用いて抽象化したくなってもできない、 という理由もあります。

例えば、DebugSlackクラスを使っているクラスなりシーンなりを 切り出して単体テストしたくなったとします。 DebugSlackがシングルトンなら、DebugSlackクラスなしではテストができません。 しかし、シングルトンでなければIDebugSlackみたいなinterfaceを作って 空の実装を用意すれば本物のDebugSlackなしでテストができます。

とは言え、シングルトンの「参照がなくても使える」という便利さは魅力的です。 そこで、 「世界の根本に一つシングルトンクラスがあって、それが全てをインスタンス変数として持つ」 という妥協が生まれます。

東京プリズンの場合、起動時にロードされて永遠に破棄されないBaseSceneというシーンがあり、 そこには同名のBaseSceneというMonoBehaviourクラスのインスタンスがあります。 これをシングルトンとし、 「なんとかマネージャー」の多くはそのインスタンス変数としています。例えば、

BaseScene.instance.soundManager.PlayBgm("hoge");
BaseScene.instance.debugUi.AddButton("ボタン");

といった具合にアクセスできます。 このままでは切り出せないので台無しなんですが、利便性優先です。 実際問題、切り出してテストすることなんてあまりなく、 テスト時にBaseSceneもロードしちゃう方が楽だったりします。

しかし、シングルトンなのがBaseSceneだけであれば、 本当に切り出したくなった時に選択肢が残ります。 BaseSceneを見ないように直すだけで済むからです。例えば、

ISoundManager _soundManager;
IDebugUi _debugUi;
public void Init(ISoundManager soundManager, IDebugUi debugUi)
{
    _soundManager = soundManager;
    _debugUi = debugUi;
}

//使うところ
_soundManager.PlayBgm("hoge");
_debugUi.AddButton("ボタン");

みたいな感じに変形すれば本物のSoundManagerやDebugUiがなくても、 空の偽実装を用意すれば単体テストできます。 何もかもがシングルトンだったら、もうどうにもなりません。

まとめると、

  • オブジェクトの所有関係を木に保つため
  • いつか切り出してテストしたくなるかもしれないから

の2点が、私がシングルトンをあまり使わない理由です。

スクリーンショット

スクリーンショットの関数は以下のような引数構成を持っています。

public IEnumerator CoPostScreenshot(
    string message = null,
    System.Action onImageCaptured = null,
    OnComplete onComplete = null,
    string channel = null,
    int waitFrameCount = 0)

メッセージが省略されれば、自動で機種とOS種別をつけます。 SystemInfo.deviceModelとSystemInfo.operatingSystemです。

onImageCapturedは、画面データの取得が完了した時に呼ばれます。 例えば、スクリーンショット撮影前にデバグUIを消してある場合、 これを復活させる処理などが考えられます。 送信には時間がかかりますので、送信完了のonCompleteとは 別に用意してあります。

そしてonCompleteは送信完了時の処理です。ポップアップを出したいならここに指定するのが便利でしょう。

最後のwaitFrameCountは、画面データを取る前に何フレーム待つかを指定します。 例えばデバグUIを消す場合、Updateの順序や内部処理によっては、 何フレームか待たないとデバグUIを消したことが画面に反映されないかもしれません。 テキトーに3くらいを指定しておけば、おそらくどこでも動くのではないかと思いますが、 そのせいで決定的瞬間を逃す場合もあるかと思いますので、実際の環境で決めてみてください。

なおファイル名は時刻から自動生成しています。

テクスチャ投稿

任意のテクスチャを送信する機能です。 といっても、前もって用意したテクスチャに関して使いたくなることは稀でして、 主眼はRenderTextureにあります。 東京プリズンでは結構いろんなところでRenderTextureを使っているのです。

例えばキャラクターはいろんな服に着替えるのですが、 パーツごとにテクスチャがバラバラだとDrawCallが増えまくって遅いので、 その時の服の組み合わせを1枚のテクスチャに集めてしまうのです。

他にも、複雑な画面では、負荷を下げるために一旦RenderTextureに描画し、 次のフレームからはそれを使う、ということをしていたりもします。

このRenderTextureがまともに動いているか、というのは結構デバグしにくくて、 とりわけスマホ実機でしかバグらない場合は非常に厄介です。 であればSlackに投稿してしまうのが良いでしょう。

   var tex2d = texture as Texture2D;
#if UNITY_2018_1_OR_NEWER
    if ((tex2d == null) || !tex2d.isReadable) // 2Dでない場合及び、そのまま読めない場合
#else
    if (true) // 2018より前にはisReadableがなく判定できないため、常にRenderTextureを経由する
#endif
    {
        RenderTexture renderTexture = texture as RenderTexture;
        // 来たのがRenderTextureでないならRenderTexture生成してそこにコピー
        if (renderTexture == null)
        {
            renderTexture = new RenderTexture(texture.width, texture.height, 0);
            Graphics.Blit(texture, renderTexture);
        }

        // 読み出し用テクスチャを生成して差し換え
        tex2d = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false);
        RenderTexture.active = renderTexture;
        tex2d.ReadPixels(new Rect(0, 0, tex2d.width, tex2d.height), 0, 0);
    }
    var pnbBytes = tex2d.EncodeToPNG();

textureが引数でもらったテクスチャです。 2018以降であればTexture2D.isReadable を使って、直接EncodeToPNGできるかどうかがわかるのですが、 以前のバージョンではわかりません。 そこで、まずそれがRenderTextureかどうか判定します。 RenderTextureであれば、これをRenderTexture.activeに設定してしまえば、 「画面に今それが映っている」かのようにReadPixelsでデータが取れます。

RenderTextureでない場合、一旦RenderTexture を生成し、そこにGraphics.Blit でコピーし、ここから受け取り用のTexture2DにReadPixelsする、という順序でデータを取得します。

あとは任意ファイル送信と同じです。

ログ送信

Debug.LogやDebug.LogWarningはデバグにおいて欠かせないツールですが、 実機においてはこの助けを得にくくなります。 実機の画面に表示させる、というアプローチもあり、 東京プリズンもそうしているのですが、 なにせスマホは狭いので、大した量は表示できません。 スクロール可能にすればそこの問題はなくなりますが、 あまり蓄積すればメモリを圧迫します。

そこで、東京プリズンではPersistentDataにログファイルを生成して、 そこに書きこむようにしました。 ログは複数配置でき、「0番はシーン遷移関連、1番は戦闘演出関連...」 のような使い分けがあります。

しかし、これを端末から取り出すのは面倒ですから、当然Slackに送りたくなるわけです。 複数のログをzipしてSlackに送る機能を用意していました。

ただ、これを丸ごとサンプルに入れるとzip関連まで入って大きくなりますし、 多くの開発では標準のDebug.Logを基本にしているでしょう。 今回のサンプルでは、「Debug.Logの出力を捕捉してメモリに蓄積し、 それをSlackに送る」というところまでとしました。 MemoryLogHandler をご覧ください。

Unityのログを捕捉する方法

Debug.Logなど、 エディタのコンソールに出てくるものを捕捉するには、 Application.logMessageReceivedThreaded にコールバック関数を追加します。 Application.logMessageReceivedでもかまいませんが、 別のスレッドでDebug.Logしても来なくなるので、実用性は落ちます。

Debug.unityLogger が持つlogHandler を差し換える方法もありますが、今の用途では適しません。 ILogHandler.LogException はnull死などの例外では呼ばれず、例外だけ別経路を通す必要がありますし、 標準のlogHandlerに渡し直す処理も必要で(でないとコンソールに出てこなくなる)、 文字列フォーマットの負荷も二重にかかります。

public MemoryLogHandler(int lineCapacity)
{
    ...(省略)...
    Application.logMessageReceivedThreaded += HandleLog;
}

void HandleLog(string logString, string stackTrace, LogType type)
{
    var message = DateTime.Now.ToString("MM/dd HH:mm:ss.fff") + " : " + type.ToString() + " : " + logString;
    // コールスタックはError系でだけ吐くことにする。設定可能にしても良いかもしれない。
    if ((type == LogType.Exception) || (type == LogType.Error) || (type == LogType.Assert))
    {
        message += "\n" + stackTrace;
    }
    Add(message);
}

MemoryLogHandlerも普通のクラスで、newして使います。 シングルトンにすれば間違って複数作ってしまって メモリとCPUを無駄にする危険をなくせますが、 その価値はないように感じます。お好みでどうぞ。

ただし、もしunityLogger.logHandlerをいじる実装をする場合は、 二度呼ぶことが致命傷になるかもしれませんので、 何らかの方法でインスタンスが複数作られないようにしておく方が良いかと思います。 シングルトンはその方法の一つです。 今回の実装はApplication.logMessageReceivedThreadedを使っており、 イベントへの+=を用いているので、複数追加しても誤作動は起こりません。

さて処理の本体はHandleLog()です。時刻とLog,Warning,Assertといった種別を追加し、 重要度が高いものではコールスタックも追加した上で、 Add()で内部メモリに蓄積しています。

Add()の中身は見ていただければわかりますが、string[]を循環バッファとして用意し、 そこにどんどん記録しているだけです。 ただし、Application.logMessageReceivedThreadedは 別のスレッドからも飛んできまので、中はスレッドセーフにする必要があります。 単にlock (何か){ ... }でくくるだけです。

必要な時にはTail()で必要な行数だけ取り出したり、GetBytes()で丸ごとbyte[] に変換したりできます。これを任意ファイル投稿機能でSlackに投げればいいわけです。

なお、BuildSettingsにてDevelopment Buildを有効にしないと、 ビルドでコールスタック情報が取れませんので、ご注意ください。

バグ報告

さて、スクリーンショットとログ送信があるならば、それを一緒にして 1行で不具合報告できる方がいいですよね。 実機にはそれを駆動するデバグボタンを用意しておけばいいですし、 Exceptionが出た時には自動で駆動する、という考えもあるでしょう (おすすめしません。バグり方によっては例外が毎フレーム出て投稿量がえらいことになります)。

今回はSlack系の機能とログの機能は別で作っており、 それを束ねるクラスは用意していませんが、 製品においては「バグ報告を統括するクラス」は用意すべきでしょう。 今回のサンプルでは、 Main.csReportError という関数を用意していまして、 画面上の「バグ報告ボタン」を押すと呼ばれるようになっています。

void ReportError()
{
    _onGuiDisabled = true;
    StartCoroutine(_slack.CoPostScreenshot(
        () => _onGuiDisabled = false,
        "エラー報告",
        null,
     channel: null,
     waitFrameCount: 1)); // 次のフレームでOnGUIで何もしない状態にしてから撮影
    var log = _logHandler.GetString();
    var sb = new System.Text.StringBuilder();
    var now = System.Datetime.Now;
    sb.Append("----SystemInfo----\n");
    sb.AppendFormat("[ErrorLog] {0}\n", now.ToString("yyyy/MM/dd HH:mm:ss.fff"));
    sb.AppendFormat("device: {0} {1} {2} Memory:{3}\n", SystemInfo.deviceModel, SystemInfo.deviceName, SystemInfo.deviceType, SystemInfo.systemMemorySize);
    ...(省略)...
    sb.Append("----SceneInfo----\n");
    for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.sceneCount; i++)
    {
        sb.AppendFormat("{0}\n", UnityEngine.SceneManagement.SceneManager.GetSceneAt(i).name);
    }
    sb.Append("----Log----\n");
    var bytes = System.Text.Encoding.UTF8.GetBytes(sb.ToString() + log);
    StartCoroutine(_slack.CoPostBinary(
        bytes,
        "errorLog" + now.ToString("yyyy_MM_dd__HH_mm_ss_fff") + ".txt"));
}

ここではまずデバグ用のボタン類を無効化しています。 製品においても、デバグUIが邪魔なことは多いでしょう。 フラグをtrueにするとOnGUIの先頭で抜けるようにしてありますので、 1フレーム待ってからスクリーンショットを撮影します(CoPostScreenshot)。 ただし、ログに出ない重要情報がデバグUIに出ている場合など、 デバグUI込みの方が良いこともあるとは思います。選択式にするか、両方吐くのが良いでしょう。

次に、ログを用意します。MemoryLogHandler.GetString()で 全量もらい、そこにSystemInfo から有用な情報を抜いて付加します。

  • deiceModel,deviceName,operatingSystemは誰のどの端末かを特定するのに便利です。
  • graphicsDeviceNameは特定のGPUだけ絵がおかしい、という時に便利です。
  • graphicsDeviceTypeはOpenGLなのかMetalなのか、の切り分けに使えます。
  • graphicsDeviceVersionは、例えば「想定外にOpenGL3に対応してない機械がいる」ことを知るのに使えます。
  • graphicsMemorySizeやsystemMeorySizeは、「これがこの数字だとここで落ちる」というようなことがわかりますから、メモリの使用量の限界を定めるのに使えます。
  • batteryLevelは、変に処理落ちしている時にバッテリーのせいかを疑うのに使えます。一定以上の処理落ちはログに吐くようにしておくと良いでしょう。
  • maxTextureSize,npotSupportあたりは、絵が変だった時に原因を調べるのに使えます。特にnpotSupportは「テクスチャをリピートにしてるはずなのに伸びた変な絵が出てる」という時には疑いましょう。「2のべき乗でない限りリピートできない機械」はまだあります。

さらに、現在ロードされているシーンの一覧も書きこみます。 東京プリズンの場合は、独自のシーン管理機構があり、 より詳しい情報を吐くことができましたし、 サーバ名、ユーザ名、なども追加していました。

あとはログを連結して、UTF8にエンコードし、任意ファイル投稿機能で投げます。

余談: ログに記録する時刻について

実時刻でなく、サーバの設定時刻をログに記録したいことがあります。 「8月からのイベントのテストを7月にする」「21時からマルチプレイなのでそのテストをする」 といった具合です。この場合はサーバを偽の時刻に設定してテストをします。 この場合、サーバ側のログと照合するためにも、 Unityから吐くログの時刻はサーバ時刻にしておく方が便利なのです。 そういった機能も用意する必要があるでしょう。選択式にしても良いかと思います。

もちろん、ログ自体につける時刻は実時刻が良いでしょう。

おわりに

テストプレイ情報をどう集め、どう蓄積するか、というのは決定的に大事です。 そこの効率が悪いと、開発終盤のバグつぶしでかかる時間が予想外に伸び、 発売を遅らせる危険が増します。

Slackを上手に利用することで、データの収集と蓄積がかなり楽になります。 バグ情報蓄積サーバ、みたいなものを持っている所もあると思いますが、 案外Slackでも行けます。日々Slackをお使いであれば、 そこに統合されて便利です。ただ、どうしても流れてしまいますので、 そこは工夫が必要でしょう。

あと重要なのはログの品質です。 「ログに何が出ているか」がデバッグの効率を大きく左右します。 個人的には、Debug.Logがあまり大量に出ると速度的にもキツいし、 エディタ実行時に邪魔になるので、 「ファイルにだけ書かれる」という選択肢を別途設けることをおすすめします。 Debug.LogやDebug.LogWarningは当然ファイルにも書かれるのですが、 ファイルにしか出てこないログ関数も作る、ということです。 それなら、かなりの量吐いても速度に影響が出ず、 かなり詳細な情報を吐き出す気になります。

東京プリズンの開発中は、試合中のサーバとの同期バグを取るために、 1試合あたり数百KBの巨大なログを吐いており、 それを一晩自動で回していました。 無事朝まで動いていれば、だいたい150MBくらいになります。 異常を検出するために大量のassertが埋め込まれており、 全てがログに吐かれていました。 そして毎朝assert経由のメッセージをログから検索し、 見つけたら必死で辿ってバグを直すのです。 これを何週間か続けました。 Slackで投げられるようになってからは、複数台でテストをすることも苦でなくなり、 Amazon EC2で仮想マシンを何台も借りて、自動テストを回すこともできました。

このように、作業の手間が減れば、それだけ強力なデバッグをすることができ、 より早く楽に品質を上げることができます。 Slackはそのための一つの道具として有効かと思います。

輝度+色差でテクスチャ圧縮(YUVあるいはYCbCr)

f:id:hirasho0:20190218134125p:plain

こんにちは。技術部平山です。

今回は以前書いたインデクスカラー画像に関する記事、及び、 16bitカラー画像に関する記事の続編です。 今回はRGBを「輝度」と「色差」に変換してから圧縮する技法を紹介します。特徴は、

  • GLES2で出せる
  • そこそこ良好な画質
  • 不透明で12bit/画素(実質3/8の圧縮率)、アルファ対応すれば20bit/画素(5/8の圧縮率)。
  • 圧縮は高速でツールを選ばない
  • 変なシェーダなしでバイリニアフィルタ可能
  • 専用コンポーネント/シェーダで面倒くさいのはインデクスカラーと一緒

といったところで、結論は変わらず ETC2で良くない? となります。

なお、すでにUnity用の実装が公開されている ChromaPackと だいたい同じもので、

  • 元テクスチャが2のべき乗(POT:PowerOfTwo)であれば、それを維持できる
  • アルファチャネルへの配慮

といったあたりが少し異なるだけです。

例によってソースはgithubに公開しております。 また、画質比較がすぐできるように動くものをWebGLで置いておきました

解像度を削る、という選択肢

インデクスカラー圧縮は「色の数を減らしてテーブル参照に置き換えること」で、 16bit化は「色に割り当てるビット数を減らすこと」で圧縮を図りました。 いずれも、画素の数、つまり解像度には手をつけていません。

しかし、画素の数を減らせば、つまり解像度を落とせば容量は減るわけで、 それも当然考えるべきでしょう。

実際、現場においては、フォーマットを云々する以前に、 解像度が適正かを真っ先に検討すべきです。 動かす機械の解像度が1920x1080だからといって、 画像の解像度を1920x1080にせねばならないわけではありませんし、 描画する解像度自体をハードウェア解像度より落とすこともよく行われました (個人的にはPS3時代が思い出深いです。性能が足りなくて解像度落としたことを明かす記事はたくさん残っています。これとかこれとか)。

弊社東京プリズンにおいては、 1136x640を標準解像度とし、 画像の多くはその解像度で等倍で出るサイズで用意していました。 1920x1080の機械では拡大されます。 スマホの小さな画面で出すのにそれ以上に精彩な画像を用意しても、 ダウンロード時間、ストレージ使用量、メモリ使用量、処理負荷、 開発時のコスト、等々の面で良くないと考えたからです。

そういうわけで解像度を削るのは大切なことなんですが、 ただ削るのは技術でなく商売の話です。 この記事では技術で解像度を削るお話をいたします。

色ごとに解像度を変える、という発想

人間の視覚にはムラがあります。

例えば、赤、緑、青に関して言えば、人間の視覚は緑に敏感で、青には鈍感です。 以前の記事で触れたRGB565という圧縮形式では、赤と青は32段階なのに、緑には64段階用意しています。 人間の視覚特性を考慮してのことです。 では、これを解像度に応用してみましょう。緑だけは元の解像度を保ち、 赤と青は半分に落としてしまう、というのはどうでしょうか?

f:id:hirasho0:20190218134120p:plain

左端が元画像、中央が今回の実験、右が単純に縦横半分にしたものです。

輪郭に緑が出てますね。使い物になりません。 そりゃそうか、という感じはありますが、 右の単純に半分にしたもののボケっぷりと比べてみてください。 元の容量の半分(RGBで24bit→12bit)に落とした割には、全体としての精彩感が 保たれている感じがしませんか?

YUVというやり方

さて、このアプローチをもっと賢くやったものが、YUVです(YCbCrとも)。

YUVでは、RGBを、輝度Yと、色差U及びVに変換します。 人間の視覚は明るさ、つまり輝度には敏感なので、 Yは元の解像度を残します。 一方、色の変化である色差には鈍感なので、 UやVは解像度を落としてしまいます。 大抵の動画がこうして圧縮されており、静止画ではjpegがこの手法を用いています。 由緒正しい手法です。

実装

エンコード

まず、RとGとBをほどよく混ぜて、明るさを表す輝度Yを作り出します。

ほどよくの具体的な方法はいろいろ提唱されていますが、 今回は古くから使われている ITU-R BT.601という方式を使いました。

static void ToYuv(ref Color32 pixel)
{
    const float yr = 0.299f;
    const float yb = 0.114f;
    const float yg = 1f - yr - yb;

    float y = (yr * pixel.r) + (yg * pixel.g) + (yb * pixel.b);

赤を29.9%、青を11.4%、残りは緑で58.7%混ぜて輝度Yを作ります。 人間の目は最も緑に敏感で、次が赤、青には鈍感、ということを反映しています。 Yの範囲は0から255です。

次は色差です。色差UとVは、それぞれ「青-輝度」「赤-輝度」 で計算し、これを-(255/2)から(255/2)の範囲になるようにほどよい値を掛け、 最後に(255/2)を足して0から255の値を作ります。

   const float uScale = 0.5f / (1f - yb);
    const float vScale = 0.5f / (1f - yr);
    float u = (pixel.b - y) * uScale;
    float v = (pixel.r - y) * vScale;
    u += 255f / 2f;
    v += 255f / 2f;
}

Uを例に見てみましょう。(b-y)で色差を作り、 これが(-255/2)から(255/2)の範囲を持つように、uScaleという値を掛けています。

uScaleはどう計算できるでしょうか。例えば「すごく青い色」を考えます。 bは255で、rとgは0とします。すごく青いですね。 すると、yは255*ybで、bは255なので、(b-y)255*(1-yb)です。 これが255/2になればいいのですから、uScaleを(255/2) / (255*(1-yb))にすればいいですね。 255が分子と分母にあるので消えて0.5/(1-yb)となり、コードと一致します。

あとは、0から255の範囲になるように、255/2を加えて完成です。 Color32に格納する場合は、四捨五入してbyteにキャストすれば良いでしょう。 四捨五入は「0.5を加えてから整数に切り捨て」で行えます。

なお、webでよく見かける、

Y =  0.299R + 0.587G + 0.114B
U = -0.169R - 0.331G + 0.500B
V =  0.500R - 0.419G - 0.081B

という式は、r,g,bそれぞれに掛かる定数を前もって計算すれば出てきます。 若干速くなると思いますが、定数が9個もあり、書き写しそこねてバグるのも嫌なので、 今回は定数2個で済む書き方にしました。

エンコード結果

こうして分解したテクスチャが以下です。

f:id:hirasho0:20190218134132p:plainf:id:hirasho0:20190218134129p:plain

左がY、右がUとVです。 元々275x108で、yはそのままですが、uvは半分にしたものを連結してあります。

3枚に分けてもいいのですが、ファイル数が増えて邪魔なのでuvはくっつけておきました。 ChromaPack ではyuv全てを1枚にくっつけていますが、 そのためにテクスチャの幅が元の1.5倍になっており、 テクスチャのサイズを2のべき乗に保ちたい、という場合にそれが崩れてしまいます。 私は2のべき乗テクスチャが大好きですので、このような実装にしました (2のべき乗の方が描画が速い機械が過去にあったのです。現在の機械がどうかは調べていません)。 もし、テクスチャをリピートさせたい場合には、uvも分離した方が良いでしょう。

デコード

テクスチャができたら、今度は描画側です。シェーダでRGBに戻します。

v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uvY = TRANSFORM_TEX(v.uv, _MainTex);
    o.uvU = float2(o.uvY.x * 0.5, o.uvY.y);
    o.uvV = float2(o.uvU.x + 0.5, o.uvU.y);
    return o;
}

fixed4 frag (v2f i) : SV_Target
{
    half3 yuv;
    yuv.x = tex2D(_MainTex, i.uvY).a;
    yuv.y = tex2D(_UvTex, i.uvU).a;
    yuv.z = tex2D(_UvTex, i.uvV).a;
    half4 rgba;
    rgba.xyz = yuvToRgb(yuv);
    rgba.a = 1.0;
    return rgba;
}

half3 yuvToRgb(half3 yuv)
{
    const float wr = 0.299;
    const float wb = 0.114;
    const float uScale = 0.5 / (1.0 - wb);
    const float vScale = 0.5 / (1.0 - wr);
    const float wg = 1.0 - wr - wb;

    half3 rgb;
    rgb.b = ((yuv.g - 0.5) / uScale) + yuv.r;
    rgb.r = ((yuv.b - 0.5) / vScale) + yuv.r;
    rgb.g = (yuv.r - (yr * rgb.r) - (yb * rgb.b)) / yg;
    return saturate(rgb);
}

頂点シェーダではY,U,V用のテクスチャ座標を生成します。 フラグメントシェーダでやるよりも頂点シェーダの方が計算が減りますし、 フラグメントシェーダでテクスチャ座標を計算すると著しく遅くなる機械を 見たことがあります。

フラグメントシェーダでは、もらったテクスチャ座標で Y,U,Vをバラバラに3回tex2Dし、 yuvToRgb()でYUVからRGBに戻します。

さてyuvToRgbの中身ですが、これはインポート時の逆変換です。 シェーダでは0から255でなく、0から1なので、 先程(255/2)だった定数が0.5になっていたりはしますが、 基本は単なる逆変換です。計算を逆順に辿っただけです。 なお、バイリニアフィルタの結果元々なかった色が生まれ、 変換結果が0から1に収まらないことがあるので、saturateで0から1にクランプしています。

なお、これも前もってy,u,vに掛ける定数を計算しておけば、

rgb.r = yuv.r + (1.402 * yuv.b) - 0.701;
rgb.g = yuv.r - (0.344 * yuv.g) - (0.714 * yuv.b) + 0.529;
rgb.b = yuv.r + (1.772 * yuv.g) - 0.886;

といった具合に書けます。この方が速いでしょう。

結果

では結果を貼りましょう。記事冒頭の画像です。

f:id:hirasho0:20190218134125p:plain

中央が圧縮したものですが、私には左の無圧縮との違いがわかりません。 それで容量が半分になるなら、結構おいしいんじゃないでしょうか?

なお、比較のために、ディザ入り565で圧縮したものも右に用意しておきました。 これも結構綺麗で、拡大しない限りほとんど差がわかりません。 差がわからないなら容量が小さい方が良いでしょう。

YUVの弱点

さて、今の画像では圧縮によってあまり劣化せず、かなり使えそうな印象ですが、 弱点はないのでしょうか?

ないはずがありませんね。以下の画像をご覧ください。

f:id:hirasho0:20190218134135p:plain

4つの派手な色の領域がある画像です。 左が元画像、右が圧縮したものです。

字の輪郭が怪しいですね。縁取りをした覚えはありません。 赤と緑の境界あたりもおかしい気がします。 色の変化は解像度が半分なので、 急に色が変わるとおかしな色が出てしまうわけです。 最近のBC7やASTCなどと違って画一的な圧縮ですから、 そういう所のケアはできません。

アルファの問題

なお、YUVはRGB部分をどうにかする方法であり、 アルファチャネルは別です。 ChromaPackでも「透けているかいないか」の1bitだけをサポートしています。

しかし、「アルファチャネルを別のテクスチャにしてしまう」 という単純な方法で良ければ、対応は容易です。 今回の実装にはそれも用意しておきました。

f:id:hirasho0:20190218134116p:plain

左がRGBA32の元画像、 中央は、元画像のRGBをYUV化し、Aはそのまま別テクスチャに分離して、 シェーダでこれらを合成したものです。 右は各4bit(ARGB16)に減色した画像です。

「単純な16bit化は許容できないが、RGBA32そのままでは容量が辛い」 というケースでは使える局面もあるかと思います。

まとめ

YUV化は画素あたり12bitと、中途半端な圧縮率です。 インポータもコンポーネントもシェーダも専用であり、 インデクスカラーの時と同様の面倒くささがあります。

しかしインデクスカラーに比べると、以下の点で優れます。

  • バイリニアが重くならない
  • バイリニア用のシェーダが必要ない
  • 品質が減色に使うソフトの出来に左右されない

インデクスカラーでは、バイリニアはシェーダで自前で やらねばなりませんでした。 しかし、今回は単にテクスチャのバイリニアフィルタ設定を有効にするだけで バイリニアがかかります。バイリニアのために別のシェーダが必要になることもありません。

そして、品質はおおよそ良好で、特に弱点を攻めるような画像でない限り、 気にせずに使えます。特別なソフトも不要であり、 インポータに任せるだけでもそれなりな質になります。

圧縮率はRGB24bitに比べれば半分にしかなりませんが、 大抵のGPUは24bitテクスチャをメモリ内で32bitにふくらませますので、 メモリ消費で考えれば3/8になり、そう悪くはありません。 また、アルファチャネルが必要であれば、足すこともできます。

以上から、「品質で悩みたくはないが、減らないよりは減る方がよく、 使いどころがはっきりしていて別コンポーネントでも許せる用途」 が向く、ということになります。

まあ、ETC2で良くない? と思いますよね?