Grani Engineering Blog

株式会社グラニはC#を中心として、ASP.NET、Unity、VR開発を行っています。

MessagePack for C#に見るC#でのバイナリの読み方と最適化法

CTOの河合(@neuecc)です。今回は、2017年3月3日に行った社内での資料を公開します。

グラニでは、そして最新作の黒騎士と白の魔王では、私の開発したMessagePack for C#を全面的に採用しています。採用範囲は、クライアントとサーバー間でのリクエスト/レスポンス通信の他に、サーバーサイドでもRedisへのシリアライズ等に利用しています。

MessagePack for C#は、グラニでの豊富なシリアライザへの知見に基づき開発された、パフォーマンスと機能拡張性の両面において優れた、C#でのバイナリシリアライザではベストといえる仕上がりになっています。そして、既に実プロダクトで動いているので、プロダクション環境で安心して使えるという実績も備えています。

C#においてバイナリの読み書きは基本的にBinaryReader/Writerを用いて行いますが、より変則的な、あるいはパフォーマンスへの最適化を考えていく場合、もう少し低レベルに降りていくのもよいでしょう。資料では、C#における低レベルな手法について幾つか挙げています。また、MessagePack for C#の実装において行った泥臭い最適化の小ネタ(LookupTableから引いて判定を抑える、デリゲートではなくあえての愚直なインスタンスメソッド、構造体の配列など)も、具体的な使用例から挙げています。

まとめ

MessagePack for C#の真の誕生理由はUnite 2017 Tokyo講演「「黒騎士と白の魔王」にみるC#で統一したサーバー/クライアント開発と現実的なUniRx使いこなし術」の資料にて解説しましたが、単純に言って追い詰められていたからです……!パフォーマンス問題を裏口突破でなんとかするという奇策。人間、追い詰められると良い仕事をしますね……!というのはともかく、機能面でも黒騎士と白の魔王におけるMessagePack-CSharpのUnionの活用事例のように、よりよく活用できているので、結果として開発したのは正解でした。

グラニでは社内勉強会を頻繁に、でもないですが不定期定期に開催しています。今回のように、社外に出しても問題がないものは公開していこうと考えていますので、今後も是非お楽しみに。

リアルタイム通信におけるC# - async-awaitによるサーバーサイドゲームループ

CTOの河合(@neuecc)です。Game Tech Session ~AWS Summit Tokyo 2017~にて「『黒騎士と白の魔王』の gRPC による HTTP/2 API/ストリーミング通信の実践」と題して登壇しました。参加いただいたみなさま、ありがとうございます。

4 月にリリースした「黒騎士と白の魔王」では、iOS/Android のモバイルアプリケーションからの全ての通信を gRPC による HTTP/2 で行っています。API リクエストからストリーミングまで、gRPC のあらゆる機能を使って実現した「黒騎士と白の魔王」のアーキテクチャについて、AWS 上でのスケーリングやデプロイを考慮した構成も含めてご紹介します。

5/10にUnite 2017 Tokyo講演「「黒騎士と白の魔王」にみるC#で統一したサーバー/クライアント開発と現実的なUniRx使いこなし術」として、クライアント側の話をしました。今回はサーバー側にフォーカスしたセッションになっています。

さて、この記事ではP.41に少し書いた、BattleEngineのサーバーサイドゲームループと、その実装について深掘りします。

サーバーサイドゲームループ

リアルタイム通信のサーバーといっても、様々です。クライアントから送られるコマンドを中継するだけのものもあれば、すべての処理をサーバー側で行うものもあります。「黒騎士と白の魔王」のバトルは、全てサーバー側のみで処理しています。なお、その辺の細かい分類は[CEDEC 2010]ネットゲームの裏で何が起こっているのか。ネットワークエンジニアから見た,ゲームデザインの大原則に詳しく、その中で言うと「非同期型/サーバー集中処理型」にあたります。

f:id:neuecc:20170602173641p:plain

サーバー側で処理を行うというのは、クライアントからのコマンドは一旦サーバー側で溜めておいて、何らかのタイミングで溜まったコマンドを処理して、クライアントを結果に送信する、という流れになります。サーバー側のクロックとクライアント側のクロックが別軸で動くことになりますが、ゲーム性として、そのズレをどこまで許容できるかがデザイン上の肝となるでしょう。黒騎士の場合は、最悪、数百ms遅れてもそこまで支障はないゲームデザインになっているため、極度なネットワーク遅延以外はユーザーにストレスを与えません。

さて、ここで問題になるのは、何らかのタイミングをどのように発火させればよいか。例えばJavaScriptではsetTimeoutを延々と繰り返すのも、一定間隔になるでしょうし、C#にも勿論Timerはあります。そのようにして構築された一定間隔の発火は、サーバーサイドにおけるゲームループといえるでしょう。

Dedicated Threadによるゲームループのホスト

スライド中でもコードを出しましたが、黒騎士と白の魔王は以下のようなコードでサーバーサイドでのゲームループを回しています。

var thread = GameLoopThreadPool.GetThread();

while (true)
{
    // frameActionの中でAIの行動選択処理や溜まったコマンドをもとにダメージ与えたり回復させたりのコードが動く
    var shouldContinue = frameAction(this);
    if (!shouldContinue) break;

    await thread.NextFrame();
}

コアの数だけ、スレッドを作成し、1スレッドにBattleField(黒騎士と白の魔王での1バトルのホスティング単位で、例えばクエストバトルの場合、最大4人のプレイヤーと敵モンスターが存在する)を複数詰め込みます。

f:id:neuecc:20170602173618p:plain

つまり、ゲームループ側のコードでは

// Thraed側の簡易コードイメージ
foreach (var field in battleFields)
{
    field.Invoke(); // 中身は while(true) { do(); await thread.NextFrame(); }
}

のようになっているわけです。

ポイントになるのは await thread.NextFrame() で、このawait可能なNextFrameは独自に定義したものです。C#のasync/await構文を活用して、所謂コルーチンを実現しています。

public class GameLoopThread
{
    const int InitialSize = 16;

    Thread thread; // 生Threadを使う。
    int threadNumber = 0;

    readonly object gate = new object();
    int frameMilliseconds;

    // 二本の配列(縮小しない)をキューとして使うことで
    // ロックを最小限にすることと配列拡縮のコスト、そしてループ速度を稼ぐ

    bool isQueueA = true;
    int queueACount = 0;
    Action[] queueA = new Action[InitialSize];
    int queueBCount = 0;
    Action[] queueB = new Action[InitialSize];

    public GameLoopThread(int frameMilliseconds, int threadNumber)
    {
        this.frameMilliseconds = frameMilliseconds;
        this.thread = new Thread(new ParameterizedThreadStart(RunInThread), 32 * 1024) // maxStackSizeは小さめで構わない
        {
            Name = "GameLoopThread" + threadNumber,
            Priority = ThreadPriority.Normal,
            IsBackground = true
        };
        this.thread.Start(this);
    }

    static void EnsureCapacity(ref Action[] array, int count)
    {
        if (count == array.Length)
        {
            var newLength = count * 2;
            var newArray = new Action[newLength];
            Array.Copy(array, newArray, array.Length);
            array = newArray;
        }
    }

    static void RunInThread(object objectSelf)
    {
        var self = (GameLoopThread)objectSelf;
        self.Run();
    }

    void Run()
    {
        var sw = Stopwatch.StartNew();
        while (true) // 実際のコードでは外から終了できるような口を用意してある
        {
            sw.Restart();
            Action[] useQueue;
            int useQueueCount;

            lock (gate)
            {
                if (isQueueA)
                {
                    useQueue = queueA;
                    useQueueCount = queueACount;
                    queueACount = 0;
                    isQueueA = false;
                }
                else
                {
                    useQueue = queueB;
                    useQueueCount = queueBCount;
                    queueBCount = 0;
                    isQueueA = true;
                }
            }

            for (int i = 0; i < useQueueCount; i++)
            {
                useQueue[i].Invoke(); // こいつが例外を吐くことは許さん。というのは呼び元で保証してくれ。
                useQueue[i] = null;
            }
            useQueue = null;

            sw.Stop();

            if (useQueueCount != 0)
            {
                // Datadogに処理時間のモニタリングを入れておく。
                var tags = new[] { "gameloopthreadnumber:no-" + threadNumber };
                DatadogStats.Default.Gauge("GameLoopThreadRun.avg", sw.Elapsed.TotalMilliseconds, sampleRate: 0.1, tags: tags);
                DatadogStats.Default.Increment("GameLoopThreadRun.count", sampleRate: 0.1, tags: tags);
                DatadogStats.Default.Gauge("GameLoopThreadRun.ProcessCount", useQueueCount, sampleRate: 0.1, tags: tags);
            }

            // 正確なゲームループを模す場合は、時間の差分を取りながらSleep(1) で処理回転に入るかどうかのチェックいれること
            // ループを回す間隔の曖昧さを許容する場合は、このままでも問題はない
            Thread.Sleep(frameMilliseconds);
        }
    }

    void RegisterCompletion(Action continuation)
    {
        lock (gate)
        {
            if (isQueueA)
            {
                EnsureCapacity(ref queueA, queueACount);
                queueA[queueACount++] = continuation;
            }
            else
            {
                EnsureCapacity(ref queueB, queueBCount);
                queueB[queueBCount++] = continuation;
            }
        }
    }

    // 外から取り出すあれそれ
    public GameLoopAwaiter NextFrame()
    {
        return new GameLoopAwaiter(this);
    }

    public struct GameLoopAwaiter : ICriticalNotifyCompletion, INotifyCompletion
    {
        GameLoopThread thread;

        public GameLoopAwaiter(GameLoopThread thread)
        {
            this.thread = thread;
        }

        public bool IsCompleted => false;

        public void GetResult()
        {
        }

        public GameLoopAwaiter GetAwaiter()
        {
            return this;
        }

        // awaitを呼ぶとOnCompletedシリーズが呼ばれて、GameLoopThreadにActionを詰む

        public void OnCompleted(Action continuation)
        {
            thread.RegisterCompletion(continuation);
        }

        public void UnsafeOnCompleted(Action continuation)
        {
            thread.RegisterCompletion(continuation);
        }
    }
}

キュー代わりの配列のせいで、ややコードがゴチャゴチャしてしまっていますが、基本的には「専用スレッドを作り」「キューにたまったActionを実行し」「Thread.Sleepして次のループを待つ」。という流れになっています。専用スレッドを使う理由は、まず、超長期(サーバーのライフサイクルと等しい)に実行されるものであることが一つです。このような場合、ThreadPoolが使われるのは向いていません。TaskにもLongRunningオプションがあり、専用スレッド上で動かすこと自体は可能であるが、Taskの機能性が不要であること(ただたんにループ回してSleepするだけ)から、Threadの単純さのほうを取りました。実際に、Threadに名前をつけることにより、アプリケーションのダンプから名寄せできたりなど、デバッグ性もむしろ向上します。

コード自体は単純なものですが、C#ならではのハックとして await thread.NextFrame(); により GameLoopAwaiter構造体経由で、キューに再登録しています。つまり

while (true)
{
    // GameLoopThread.Runにより定期的に呼ばれる
    var shouldContinue = frameAction(this);
    if (!shouldContinue) break;

    // awaitを呼ぶと一回のInvokeは終了する
    // GameLoopAwaiter.RegisterCompletion 経由でキューにこのActionを再登録する
    await thread.NextFrame(); 
}

という流れになっています。

パフォーマンス

実はリリース当初はこの構成になっていませんでした。なっていなかった結果、かなり不安定でして、それがこのDedicated Threadによるゲームループに変更後はようやく安定しました。

そして、この構成の良いところは、限界が読みやすいところにもあります。1台辺りの性能限界というのは(ほぼ)完全にCPUバウンドで、1スレッドにどれだけの数のBattleFieldをホストできるかで決まります。1フレーム100msで動かすとして、仮に1フィールドの演算処理が1msかかるなら100フィールドまでなら安全。それを、例えば16コアのサーバーで動かすなら16スレッドを並走させるので、1600フィールドということになります。1フィールド辺り4人まで格納可能で平均2.5人(実際の値は違います)ならば4000接続まで。という計算になります。ゲーム性的に、常にキューが溜まって全フィールドがピーク時の演算が走るわけではないので、1ループでの処理時間は均されるのと、たとえ1フレーム2フレーム遅延しても、その後のフレームで処理が間に合えばゲームとしてはユーザーに遅延を感じさせないため、実際はもう少し余裕があります。

また、ここで、1msかかる演算処理を0.5msにできれば、その2倍、3200フィールドホスティングできることになります。サーバーサイドのコードはどうしても甘くなりがち(どうせDBの処理時間のほうが大きいから誤差範囲でやっても意味がない、とか、困ったらサーバーを並べればスケールするでしょ、とか)ですが、「黒騎士と白の魔王」の、このBattleEngineのような純粋なCPU処理で回しているコードの場合は、相当大きな意味を持ってきます。そして、そういう視点で見ると「C#は決して速い言語ではない」し、1~2msぐらい重たくなる処理も簡単に書けてしまいます。

と、いうことを胸に刻んでおくと、疎かになりがちなサーバーサイドのCPU最適化のモチベーションがあがります。

実際の1ループでの処理速度はモニタリング指標として非常に重要なので、グラニではDatadogを用いて常に監視しています。Datadogに関しては黒騎士と白の魔王を支えるDatadogを使ったモニタリングも参照ください。

まとめ

サーバーサイドゲームループはかなり応用が効いて、位置情報を送るようなMMORPGにもできるし、CEDEC講演の資料にもあったようにQuakeのようなFPSでもありでしょう。gRPC、あるいはその高レベルフレームワークであるMagicOnionは、ゲーム的な処理は一切存在しません。一切存在しないが故に、工夫次第でなんでもやれます。その実証を、「黒騎士と白の魔王」のリリースにより、まず第一歩として行えました。

グラニでは引き続きモバイルゲームの開発を行っていくほか、VR Studioも立ち上げ、VR/AR/MRの開発にも乗り出しています。どちらにも欠かせないのがネットワーク技術で、グラニはgRPCを中核にして、今後もノウハウを築き上げていきます。

Prime31 Ver2.17以降のStoreKitプラグインを利用したiOS課金実装(レシートの取扱について)

 はじめまして!アプリケーション開発部の長内です。 突然ですが、UnityでiOSアプリ内課金を実装するとしたら何を利用しますか? Unity5.3以降であればUnity IAPを検討する方もいるでしょう。

 Unity IAPであれば、iOS App Store や GooglePlay だけでなく Windows Store や Amazon Appstore といった様々なストアなどに対応しており、さらにプラットフォーム毎の固有実装を気にせず共通のインターフェイスでゲーム側に組み込めるのも魅力的です。

実際は?

 しかし、現状の殆どの場合はUnityアセットストアで手に入るプラグインを利用する方が多いかと思います。

 そのアセットストアでiOSのアプリ内課金といえば、Prime31 さんからリリースされていますiOS StoreKit In App Purchase Pluginではないでしょうか。

 このアセットは、Unityがまだ3.2、3.3のバージョン頃からあらゆるスタジオなどでも採用され続けている古株のアセットです。

 やはり、昔から利用されているだけあってGoogleでUnityによるiOSアプリ内課金を検索すると候補として上がる程です。

見えないユーザーとの売買

 脱線したお話になりますが、通常レシート(以下領収書データ)は、プラットフォームストア(ここではGooglePlayStoreやiTunesStoreを指します)からユーザーの端末に直接送信されます。

 この時には既に決済は完了しているので、ゲームとしては対応する商品を付与してしまっても、売買のやり取りとしては成立しているのでOKです。

リアル環境なら

 では、簡単なシーケンス図で表してみましょう。

f:id:yosagrapon:20170525153637p:plain

 これが、現実世界でコンビニやスーパーマーケットなどであれば全く問題はありませんね、直接お客様とお店との取引をしていますので、これで成立します。

領収書をもらったことにしよう!

 しかし、このやり取りが電子世界になった途端、そうではいけなくなってしまいます。例えば次のパターンの時はどうでしょうか。

f:id:yosagrapon:20170525153319p:plain

 マズイです、実にマズイです。お客様はなんの売買取引をしていないのに、ゲームサーバーに領収書をもらったとしか言っていないだけでゲームサーバーは言われたアイテムを付与してしまっています。

貰ったことを言うのではなく、その物を渡しましょう

 では、領収書をもらったと言うのではなく、領収書そのものを渡しましょう。

f:id:yosagrapon:20170525153323p:plain

 お客様はちゃんと売買取引を行い、プラットフォームストアから受け取った領収書を渡しているので、ゲームサーバーとしても領収書を受け取っているのなら問題はなさそうです。

はたしてそれは本物?

 しかし、お客様の中でとてつもなくプラットフォームストアの領収書データに精通し有りもしない領収書データを作れた場合はどうなるでしょうか?

f:id:yosagrapon:20170525153327p:plain

 ダメです。実にダメです。結局、お客様は売買取引をせず偽物の領収書データを作って、あたかも売買取引したかのように振る舞った領収書をゲームサーバーに送りつけゲームサーバーは領収書があるから大丈夫と思い、アイテムを付与してしまいました。

本人確認は重要ですね

 では、この問題に対してどう対処するかというと、至極単純です。その領収書を発行した本人に確認を取れば良いのです。あらゆるサイトでも紹介されていてデファクトスタンダードな実装の簡易シーケンスを見てみましょう

f:id:yosagrapon:20170525153925p:plain

 本来はもっと複雑なフローなのですが、簡略的に書いた場合はだいたいこうなります。

また、これが本題では無いのでまたいつかということでおひとつ。

本題

 では、そんな昔からあるアセットの実装方法を今更何を書くのかというと、レシートの取り扱い方が変わったことについてです。

 Prime31さん公式サイトのあらゆるアセットのリリースノートに以下のような事が書かれています。

iOS StoreKit09-23-2016

Version 2.17

  • rebuilt against iOS 10 SDK

  • removed base64 encoded receipt as it has been deprecated by Apple since iOS 7

  • bug fix: avoid restoring the same transactions multiple times even if Apple sends them over and over again

このリリースノートでもっとも重要な内容は

removed base64 encoded receipt as it has been deprecated by Apple since iOS 7

の部分です。

 そうです。この base64 encoded receipt がiTunesStoreから送られてくるレシートそのものなのです。

これが削除されたということは、正しいレシートをゲームサーバーに送ることが出来ませんし Unityマニュアルでもこんな(base64エンコードされたレシートを送信)ことを書いているので削除されたら何も出来ません と見せかけて、そんなことはありませんでした。

 そのかわりのAPIとして

public static string getAppStoreReceiptLocation()

が増えましたので、こちらを使います。

 この関数は、iTunesStoreから受け取ったレシートデータをローカルストレージに保存した"生のレシートデータ"へのファイルパスが返されます。

 なので、このままファイルを読み込んでそのまま送ったとしても結局不正なレシートとして扱われてしまいますのでBase64エンコードから送信までこちらで実装します。

/// <summary>
/// StoreKitManager の purchaseSuccessfulEvent イベントから監視オブジェクトとして使います
/// </summary>
/// <returns>purchaseSuccessfulEvent イベントの監視オブジェクトを返します</returns>
private static IObservable<StoreKitTransaction> PurchaseSuccessfulEventAsObservable()
{
    // event機構からObservableへ
    return Observable.FromEvent<StoreKitTransaction>(x => StoreKitManager.purchaseSuccessfulEvent += x, x => StoreKitManager.purchaseSuccessfulEvent -= x);
}

グラニでは、UniRxを使った実装をしていますので、StoreKitライブラリのpurchaseSuccessfulEventを監視するようにします。

var successObservable = PurchaseSuccessfulEventAsObservable().First().PublishLast().RefCount()
    .SelectMany(transaction =>
    {
        // レシートデータを読み込んでBase64エンコードする
        var receiptUri = new Uri(StoreKitBinding.getAppStoreReceiptLocation());
        var rawReceipt = File.ReadAllBytes(receiptUri.LocalPath);
        var base64Receipt = Convert.ToBase64String(rawReceipt);


        // レシートを生成して検証する
        var receipt = new PlatformStoreReceipt(storeItem, base64Receipt, string.Empty, userOption);
        return ValidateReceiptAsync(receipt);
    })

 そうです。ここで実装している"base64Receipt"こそが、2.17より前のバージョンで存在していた"base64EncodedTransactionReceipt"の写し身です。

 あとは、ゲームサーバーとゲームクライアントの検証結果を元にどうするかという実装を、ゲーム側仕様に則って実装します。

 以上で、まさかの本題より前書きのほうが長い(尺稼ぎ)記事になってしまいましたが、iOS10であるとか新しいバージョンにしなければいけなくなったが突如APIインターフェイスが変わった! と慌てず、まずは今回の実装を参考に実装を修正してみて下さい。殆どのケースでは上手くいきます。(絶対にとは言っていない)

黒騎士と白の魔王におけるMessagePack-CSharpのUnionの活用事例

こんにちは、アプリケーション部の松田(@kimika127)です。

今回はMessagePack-CSharpのUnionを活用した事例をご紹介します。 MessagePack-CSharpそのものについては

neue cc - C#(.NET, .NET Core, Unity, Xamarin)用の新しい高速なMessagePack実装

こちらに詳しくありますのでご覧ください。

Unionについて

MessagePack-CSharpのUnionを噛み砕いていうと「インターフェイスでシリアライズできる機能」になります。 とだけ聞くと便利そうに思えますが、実際にプロジェクトを進めるうえでこの機能を有効的に活用できる場面はそう多くはないでしょう。

今回、黒騎士と白の魔王(以下、黒騎士)では、トーク機能でこのUnionを使うことにしました。

メッセージの種類

トークではたくさんのメッセージを流します。 黒騎士ではメッセージの種類も多岐にわたり、例えば

  • 単純な文字列
  • スタンプ

のほかに、トーク機能を持ったRPGゲーム特有の事例として

  • トークからクエストへみんなで出発する時の募集文

なども要件として出てきます。

メッセージで送る情報

単純にメッセージを送るといっても、その中には

  • 誰が送信したか
  • どこに送信したか(具体的にはトークルームと呼ばれる集団)
  • いつ送信したか
  • どのような内容か

など、これら以外にも多数の情報を持っています。

これらのうち、発言者やルーム、時刻などはメッセージの種類に関係なく、同じものを使うことができます。 しかし内容に関しては、文字列、スタンプ、ロビーへの招待など全く違う情報をクライアントへ送信する必要性が出てきます。 ここでUnionの出番になります。

具体的な実装例

// インターフェイスを用意
// 各種具象型をUnion属性で指定
[Union(0, typeof(TextMessageBody))]
[Union(1, typeof(StampMessageBody))]
[Union(2, typeof(QuestMessageBody))]
public interface IMessageBody { }

// 文字列を送る
[MessagePackObject]
public class TextMessageBody : IMessageBody 
{
    [Key(0)]
    public string Text { get; set; }
}

// スタンプを送る
[MessagePackObject]
public class StampMessageBody : IMessageBody 
{
    [Key(0)]
    public int StampId { get; set; }
}

// クエストへの募集を送る
[MessagePackObject]
public class QuestMessageBody : IMessageBody 
{
    [Key(0)]
    public int QuestId { get; set; }
    [Key(1)]
    public string Text { get; set; } // ユーザが入力できる募集文言とか…
}

具体的な利用例

サーバ側

var textMessage = new TextMessageBody { Text = "グラニのエンジニアブログへようこそ!" };

// シリアライズして送信
var bin = MessagePackSerializer.Serialize<IMessageBody>(textMessage);

クライアント側

// 受信してデシリアライズ
var body = MessagePackSerializer.Deserialize<IMessageBody>(bin);

// C# 7だと型switchが使えるので便利
// が、Unityだとasかインターフェイスに識別子を持たせて分岐
switch (body)
{
    // 文字列が来た
    case TextMessageBody x:
        Debug.Log(x.Text);
        break;
    // スタンプが来た
    case StampMessageBody x:
        Debug.Log(x.StampId);
        break;
    // クエストへの募集が来た
    case QuestMessageBody x:
        Debug.Log(x.QuestId + ":" + x.Text);
        break;
    default:
        break;
}

まとめ

実はMessagePack-CSharpを導入する以前は、本文の全ての要素をCSVにし、string型でクライアントに送ってからパースをしていました。

しかし、iOS/Androidアプリの運用を続けていくにあたって障害の一つとなるのが、「更新されていないかもしれないユーザのクライアント」と「更新しなければいけないサーバ」間の通信の整合性を保つことです。 そこでMessagePackを使えば、要素の追加に強く、既存のクライアントに影響を出しにくく、保守もしやすい設計にすることができます。

今回は黒騎士の中でもUnionという機能を有効に使えた例をご紹介させていただきました。

黒騎士と白の魔王のキャラクターの作りについて

こんにちは、アプリケーション部の松田(@kimika127)です。

Unite 2017 Tokyo講演「「黒騎士と白の魔王」にみるC#で統一したサーバー/クライアント開発と現実的なUniRx使いこなし術」 - Grani Engineering Blog

先日のUnite 2017 Tokyoでも弊社CTOの河合が触れたものになりますが、今回は黒騎士と白の魔王(以下、黒騎士)のキャラクターの作りについてより詳しくお話をしようと思います。

Spine

まず、黒騎士ではキャラクターのアニメーションにSpineを全面的に採用しています。 みなさんのアバター、戦闘中に出てくる敵、マイタウンのスプリやリゼット等、全てSpineで作られています。

Spineにした理由は単純なもので、黒騎士の開発が始まった当初、Unityに対応していることは当然、その上で表現力や開発のしやすさなどを加味した結果によるものです。 現在はUnityからAnima2Dが無償で提供されており、Spineと同じく積極的な更新もされていますので、これから開発をされる方はAnima2Dも選択肢としてあげてよいと思います。

今回はこのSpineで作られた中でもホーム画面等で表示されるキャラクターについて説明しようと思います。

Spectacle

今回ご紹介するホーム画面等で表示されるキャラクターは、Spineの周りに多数のParticleSystemをまとい、タップをするとボイスを喋りながらアピールモーションをする機能です。

先ほどもお話した通り、黒騎士では各所でSpineが使われているうえ、それらの作りが細かく違っています。 例えば、アバターであれはジョブによる着せ替え機能があったり、敵であれば色味の変更や必殺技演出等、必要な要件が異なるからです。 このようなものを全て「Spine」や「キャラ」などで表現するとあいまいになってしまうので、これらを提供する機能群をまとめて「Spectacle」というコードネームで呼び、他と区別するようにしています。

SkeletonAssetの準備

ここは私の専門外ではありますが、キャラクターが動くまでの過程をご紹介します。

まず実際に動かすためにはイラストとアニメーションが必要になります。 最初にアートディレクターが指示書を作ります。 黒騎士ではキャラクターに進化という概念がありますので、イラストレータはその指示書を基に進化前、進化後の2点のイラストを用意します。 アニメータはそこからさらに「待機中」「アピール中」の2つのモーションが必要ですので、計4つのコンテを作成します。

イラストが仕上がると、次に動きを付けるためにイラストを分割します。

f:id:ykimisaki:20170516154324p:plain

分割されたイラストはSpineに取り込まれ、アニメータの手によってボーンとウェイトメッシュによりコンテに従った動きが付けられます。

エフェクトを付ける

Spectacleの特徴はSpineで作られたアニメーションの周りを、パーティクルで飾り付けていることです。 実はアニメータがボーンを組み込む際に、パーティクルを表示する場所に隠れたボーンを組み込んでいます。 例えば以下の写真は杖の先端のボーンに、エフェクタが作成したパーティクルを表示しています。

f:id:ykimisaki:20170516153936p:plain

各パーティクルは指定されたボーンに対して配置されます。

また、ルートボーンに対してCurvyを使用したトレイルが渦を巻くように配置されています。 普段は正面からしか見えませんが、これを上から撮影するとこのように見えます。

f:id:ykimisaki:20170516184819p:plain

これらのパーティクルは動きに合わせた最適なタイミングで出現/消失が制御できるように、Spineのアニメーションタイムライン上でイベントを組み込んでいます。 各イベントはエンジニアとアニメータの間で、どのような名前にするか、どのようなパラメータが渡されるか等の規約があらかじめ決められています。 エフェクト001の出現タイミングだと以下のようなイベントがSpine上で設定されます。

f:id:ykimisaki:20170516154749p:plain

SpectacleはSpineのSkeletonAssetに埋め込まれたボーン情報とイベント情報を読み込み、それに合わせてエフェクトの制御を行っています。

ボーンとエフェクトとイベントを関連付ける

さて、せっかくエフェクトを用意しても、それらがどのボーンに関連付けられているかを知らなければ配置することができません。 SpectacleではDBで定義されたマスタをクライアントに送信し、組み立てます。

重要なことは、各ボーンそのものにエフェクトの情報を直接持っていないことです。 ボーンの番号とエフェクトのPrefabの関連をマスタデータで仲介することにより、Spineで再度アニメーションを弄らなくても、エフェクトの差し替えが容易にできるようにしています。

子Spectacle

Spectacleはそのボーンの先に別のSkeletonAssetを表示することができます。 こちらのSpectacleの周りにいる犬、猫、小鳥は、すべて本体とは切り離された別のSpineです。

f:id:ykimisaki:20170516190218p:plain

これらもまた、エフェクトと同じようにアニメータはSpectacleで表示することができます。

SpectacleEditor

さて、現在リリースされているSpectacleは全部で80体以上に及び、エフェクトはSpectacle1体に付き10個つくこともあります。 そしてこれはおそらく今後も追加がされていくことでしょう。

これだけのSpectacleを円滑に用意するためには、我々エンジニアが関与せず、アートディレクタのもとでイラストレータやアニメータの手ですべて行える環境が必要になります。

上記の工程のうち、

  1. 指示書の作成
  2. コンテの作成
  3. イラストの作成、分割 → Photoshop/sai/CLIP STUDIO/他
  4. アニメーションの作成 → Spine Editor
  5. パーティクルの作成 → Unity

ここまではエンジニアの手がかからないものです。

しかし、ボーンとエフェクトとイベントを関連付ける部分はDBを介しているので、何もしなければアニメータが使いやすいものとは言えず、ミスも起こるでしょう。 そこで、Unity上でボーンとイベント、エフェクトを関連付け、APIを通してDBへ保存するエディタ拡張を用意しています。

f:id:ykimisaki:20170516174437p:plain

SpectacleEditorの役目は、ボーンとエフェクトとイベントの関連付けをアニメータが行えるようにすることです。

ただし実際にアニメーションを組み込み、エフェクトをまとわせて表示できる状態で再生したり、たくさんのSpectacleを並べて再生すると、アートディレクターがバランスの悪さを指摘することがあります。 そのため、SpectacleEditorでは再度SpineやPhotoshopを開かなくても微調整が行える機能を用意しています。

具体的には

  1. Spineのリサイズ
  2. Spineの上下左右の位置の調整
  3. Spineやエフェクトの反転
  4. 子Spectacleの深度調整、反転
  5. アニメーションごとのエフェクトの表示/非表示

です。 これらのデータはすべてDBに保存され、みなさんのお手元では調整されたものが表示されるようになっています。

ボイス

ボイスに関しては、全くの別ラインでの作業になっています。 黒騎士ではCRIを使用しており、Spineのイベントなどを使用せずともアピール開始時に再生処理を呼び出すだけで適切なボイスが呼び出されるように、サウンドエンジニアがミドルウェアを使い用意をしてくれています。

Spectacleの仕様要求と問題点の妥協点

Spectacleはその見た目から、ガチャで排出されるキャラとされるなど黒騎士でも重要な要素になっています。 当然、リッチな見た目への要求は大きく、中には大きな問題になってしまったものもありました。

最後にそれをご紹介しようと思います。

Spectacle for UI

Spectacleはホーム画面やバトル中の召喚演出の他にも、様々な場所で再生が行われます。 例えば

  • マップ上の宝箱、ギフト、ガチャ等でのキャラ取得
  • デッキ編成や強化、図鑑、進化でのキャラ表示
  • ガチャ画面でピックアップ

などです。

f:id:ykimisaki:20170516181326p:plain

詳細は割愛させていただきますが、黒騎士ではuGUIの上に厚いレイヤーを重ねた独自のUIシステムがあります。 このUIシステムとSpectacleを重ねて綺麗に表示させるためには、UI上にうまく組み込ませる必要があります。 そのため、メモリや描画に対する負荷の少ない一部のUI部分では、RenderTextureに一度投影し、RawImageとして取り扱っています。

ただしRawImageをそのまま描画するとエフェクトが正しく描画されないため、専用のシェーダを差し込むことでこの問題を回避しています。

f:id:ykimisaki:20170516182330p:plain

エフェクトの裏への回り込み

Spineはメッシュを変形して表示させるのですが、元の画像の形により、実際の輪郭に比べポリゴンが大きくなることがあります。 以下の青いポリゴンを見ると、周りに大きく余白があることがわかります。

f:id:ykimisaki:20170516184206p:plain

これは3Dでは起こらない問題ですが、黒騎士ではエフェクトがSpineの周りをトレイルが回っているため、奥にあるエフェクトをSpineの余白が透明に塗りつぶしてしまう問題が起こります。 そこでこちらも専用のシェーダを用意し、先に描画されたSpineの透明ピクセルをclipすることで、本来奥にあるはずのエフェクトが見えるようにしています。

f:id:ykimisaki:20170516183905p:plain

ゆがみシェーダ

黒騎士の一部のSpectacleでは、より迫力を出すためにアピール時に波を打つようなゆがみシェーダを取り入れています。 以下の画面の、特に文字部分ではよく目立ちます。

f:id:ykimisaki:20170516190704p:plain

これらのシェーダは基本的にShader Forgeによって作成されたものですが、iOS Metal環境でCameraのRectを弄ると正しくGrabTextureが取得できないという不具合があります。

f:id:ykimisaki:20170516191550p:plain

この不具合は見た目がひどく崩れるので、黒騎士ではシェーダ側でCameraのRectが弄られている際は歪ませない、という苦肉の策を入れています。

アルファマスク化

Spineからエクスポートされる形式はいくつかありますが、黒騎士ではBinary+PNGで吐き出しています。 BinaryはJSONよりも読み込み速度が速いので、特にJSONにこだわらない限りはBinaryをオススメします。

さて問題は画像です。 Spectacleで吐き出されるPNGは主に1024x1024サイズになります。 これを黒騎士の仕様上、プレイヤー4人×召喚2枠=計8体を描画しなければいけません。 つまり、真っ先に削るべきはテクスチャのメモリ使用量という事になります、それもクオリティを下げない方法で。

黒騎士ではこの問題を解決するために、以下の形式をとりました。

  • アルファマスクを使い、透明を含むテクスチャを2枚に分ける
  • iOSはRGB Compressed PVRTC 4bitのBest画質
  • AndroidはRGB Compressed ETC 4bitsのNormal画質

アルファマスクを使用したのは、透明ピクセルを含むテクスチャをPVRTCで圧縮すると画質が大きく下がってしまうためです。 また、Androidの画質がNormalなのは、Best画質にすると途方もない変換時間が掛かってしまい、AssetBundleを生成するCIがタイムアウトしてしまうためです。

アルファマスクを描画するためには、専用のシェーダを用意し、全てのSpine用マテリアルにアルファマスクを差し込み、画像の圧縮設定を行わなければなりません。 これも当然Spectacleの機能で自動化されており、読み込んだSpineから、右クリックのエディタ拡張を使用すると、クリック1回で

  1. アルファマスクの生成
  2. マテリアルに専用シェーダの設定
  3. テクスチャの圧縮設定の変更
  4. アセットバンドルの設定

をすべて一括で行うようになっています。 アルファマスクの生成では、元のPNGデータを壊さずに不透明データを生成するようになっています。

f:id:ykimisaki:20170516193214p:plain

まとめ

以上のように、黒騎士では2D描画もかなりこだわって作っています。

演出のこだわりを達成するために私が大事だと思っていることは、エンジニアの手をできるだけ介さずに、アートディレクタ、イラストレータ、アニメータ、エフェクタの作業の、1から10までの流れを妨げないことだと思っています。 エンジニアは現在Spectacleにはほぼ関わっていない状態ですが、指示書から納品まで、求められるリリースサイクルに耐えうるチームができあがっており、アニメ部との協力体制と的確なフィードバックの賜物であると思っています。

今後も黒騎士と白の魔王を通じて、より良い体験を多くみなさんに届けられるように頑張っていきますので、よろしくお願いします。

iOSで音楽を聴いているときにゲームの音楽を止める

こんにちは、開発部の@mayukiです。

今回はUnityでのゲーム音楽の再生とバックグラウンドアプリケーションによる音楽の再生のお話です。

ゲームの音楽とバックグラウンドの音楽、二つの音楽はなぜぶつかり合うのか

ゲームではゲーム固有の音楽が流れるのが一般的ですが、それとは別にスマートフォンを音楽プレイヤー代わりとして使うシーンが多々あります。iPhoneをお持ちの方は特にそういった使い方が多いでしょう。

ところがそれらの状況は同時に起こり、そのままでは両方の音楽が重なって結果どっちもまともに聞こえないという問題が発生することがしばしばです。特にスマートフォンのゲームは一回の滞在時間が短い場合もあり、そのような状況が発生しやすいでしょう。

対応方針

二つの音楽がぶつかり合ったとき、何とか救う方法はないかそもそもどうすればいいのか…を考えてみます。

  • 上記のとおり、スマートフォンのゲームは一時的に起動される機会が多い
  • 音楽を聴いているままゲームを起動したということは音楽を聴いていたいという可能性が高い
  • ゲームの音量設定を手動でミュートにするよりもバックグラウンドの音楽を止める操作のほうが簡単
    • プレイヤーがゲーム内の音量を上げたり下げたりするのはiOSのコントロールセンターから音楽を操作する簡単さには勝てない

といったことからゲーム側がバックグラウンドのアプリケーションによる音楽再生を尊重したほうがよさそうにおもえます。実際この時の挙動のおすすめはAppleのプログラミングガイドにも記載されています。

ユーザがアプリケーションを起動したとき、既に音声が再生中かも知れません。たとえばその時点で、「ミュージック(Music)」で音楽を再生していた、Safariで音声ストリーミングをしていた、などが考えられます。起動したのがゲームであれば、これは特に重要です。多くのゲームがサウンドトラックや効果音を活用しています。『iOS Human Interface Guidelines』の「Sound」で述べているように、ゲームの場合、その効果音は再生しつつ、起動前に再生していた音声もそのまま再生されるものとユーザは想定しています。 アプリケーションを起動する際、otherAudioPlayingプロパティで、音声が再生中であるかどうか調べてください。他の音声が再生中であれば、ゲーム側のサウンドトラックを無音にし、 AVAudioSessionCategorySoloAmbientカテゴリを指定します。カテゴリについて詳しくは、“カテゴリの取り扱い”(16 ページ)を参照してください。

「バックグラウンドのアプリケーションが音楽を流していたら、ゲーム中のBGMはミュートする(SEはそのまま)」のがよいということですね。ボイスやSEを止めるか止めないかはアプリ次第ですね。

なおこのパターンのデメリットとしてバックグラウンドの音楽とゲームの音楽を重ねられない点がありますが、その挙動で得するパターンはほとんどないので気にしなくてよいと思います。

実装

ではUnityで「バックグラウンドのアプリケーションが音楽を流していたら、ゲーム中のBGMはミュートする(SEはそのまま)」にするための実装をしましょう。

まずゲームBGMのミュートは大抵ゲームのシステムにある何らかの方法(マスターボリューム管理など)でできるようになっているかと思います。つまり「バックグラウンドのアプリケーションが音楽を流しているかどうか」さえ取得できれば、あとはそれに応じてボリュームをコントロールしてあげればよいわけです。

バックグラウンドで音楽が流れているかどうかに関しては、iOSでは先ほど引用したガイドにAVAudioSessionのotherAudioPlayingプロパティを確認してくださいと書いてありますが、iOS 8以降ではAVAudioSessionのsecondaryAudioShouldBeSilencedHintプロパティを利用することが推奨されています。secondaryAudioShouldBeSilencedHintプロパティはセカンダリオーディオ、つまり音楽再生アプリのようなプライマリではないもの(AVAudioSessionCategoryAmbient)はミュートになるべきかどうかを返します。ともあれこれが使えそうです。

Objective-C

チェックすべきものがわかったところで、iOSのAPIをUnityから直接呼び出すことはできないのでObjective-Cのコードを書いてあげます。

#import <AVFoundation/AVFoundation.h>

extern "C" {
    unsigned int avAudioSession_secondaryAudioShouldBeSilencedHint() {
        return [[AVAudioSession sharedInstance] secondaryAudioShouldBeSilencedHint];
    }
}

と言ってもAVAudioSessionsharedInstanceからsecondaryAudioShouldBeSilencedHintを取得したものをそのまま返すだけです。お手軽。AVAudioSession.mmなどの名前でPlugins/iOSなどに保存しておきます。

C# (Unity)

そしてC#での呼び出し側です。これも単純に先ほど書いた関数を呼び出すだけです。

using System;
using System.Runtime.InteropServices;

#if UNITY_IPHONE
public static class AVAudioSession
{
    [DllImport("__Internal", EntryPoint = "avAudioSession_secondaryAudioShouldBeSilencedHint")]
    private extern static int _SecondaryAudioShouldBeSilencedHint();

    /// <summary>
    /// セカンダリオーディオ(ゲームのBGMなど)をオフにすべきかどうかを返します。
    /// </summary>
    public static bool SecondaryAudioShouldBeSilencedHint
    {
        get { return _SecondaryAudioShouldBeSilencedHint() == 1; }
    }
}
#endif

あとはこれを監視して、プロパティの変更に応じて処理してあげればよいでしょう。毎フレーム見るほどでもないし呼び出し負荷ももったいないのでタイマーぐらいでちょうどいいかもしれません。ここはUniRxでちょちょいとやるとベンリですね。

Observable.Interval(TimeSpan.FromSecond(1))
    .Select(_ => AVAudioSession.SecondaryAudioShouldBeSilencedHint)
    .DistinctUntilChanged()
    .Subscribe(x =>
    {
        if (x)
        {
            // ミュートする処理をここに書く
        }
        else
        {
            // ミュート解除する処理をここに書く
        }
    })
    .AddTo(this);

まとめ

こういった普段スマートフォンを利用している中でゲームが邪魔にならない、ということはユーザーの体験に地味によい効果があると思うので実装をおすすめします。