【Unity】パフォーマンスチューニング

はじめに

はめまして、カヤックの技術基盤チームの Unity エンジニアのアファトです。この記事はカヤックUnityアドベントカレンダー2016の21日目の記事になります。

ゲーム開発には機能やゲームプレイを実装するたびにシーンやコードだんだんコンプレックスになって、ターゲットFPSまでパフォーマンスが出なくるというパターンは開発中にはよくありますが、リリースするまではターゲットデバイスのスペックに応じて、特に低スペックなデバイスでゲームが遊べないほどもっさりしてる可能性があります。そのためにパフォーマンスチューニングが必要です。なぜパフォーマンスが出ないか、どうやって改善できるか、軽く紹介します。

FPSについて

Frame per secondsというい用語は1秒の中にフレームが何回か更新されるという意味です。一般的なゲームには 30FPS か 60FPS か その間でフレームが更新されますので、1フレームの枠は数ミリセカンドぐらいしかありません(30FPS は 1s/30 = 33.3ms, 60FPS なら 1s/60 = 16.7ms)。ゲームがスムーズに見えるために、そういうターゲットFPSを守って、1フレームの中行なう処理全てがその枠に納めないといけません。

FPSが落ちる理由

1フレームに全て行った処理の時間がターゲットFPSの枠を超えたら、FPSが落ちると言います。よくあるパターンとして、だんだん処理が重くなってFPS落ちてる、一瞬的なフリーズ(コマ飛び・コマ落ち)、最初からFPSが低い、といったものがあります。そこで、ゲームに行なう処理は全体的に2種類があります。CPUの処理とGPUの処理です。CPU bound的なネック(CPU処理が重い)か、GPU bound的なネック(GPU処理が重い)か、両方とも処理が重いかどちらかになります。そのネックを把握するために、Unity Profilerが便利です。テラシュールウェアブログの『【Unity】CPUプロファイラでパフォーマンスを改善する 前編』が参考になるのでよろしければご覧下さい。CPU boundか、GPU boundか、そのネックを把握してから適切な最適化を実施した方が良いです。

GPUネック

Unity ProfilerのCPU Usage - Overviewのところにある Gfx.WaitForPresent はGPU boundの良い指標です。この処理時間が長いければ長いほど、GPU boundの可能性が高くなります。GPUの処理にどこの処理が重いのか、Unity Profilerで調べにくいことがありますが、一番簡単な方法はシーンにある GameObjects を disable/enable して、FPSがどう変わるかをチェックします。そこでFPSに一番大きな影響が出る GameObject から調査します。

image

グラフィック関連のチューニングについて

  • Dynamic batchingを増やして、DrawCall数を減らします。Drawcall batchingについて。Spriteを使ってるならpacking tagを利用して、DrawCallが減らせます。それについてはこのテクニカルブログ 『【Unity開発】Sprite画像とSprite Packerまとめ【ひよこエッセンス】』が参考になります。

  • Texture サイズやフォーマットを調整します。 適切なサイズやフォーマットはどのようなTextureを使うかによって違います。パーティクル用のTextureはできるだけ小さくします。サイズの調整はTexture Import Settingsのmax sizeで調整した方が良いです。適切なテクスチャーフォーマットはターゲットプラットフォームや画像の模様によります。テクスチャーフォーマットについて

image

  • もっとシンプルなShaderを使います。Unityのシェーダーを使うのであれば、一番シンプルなシェーダーグループ Unlit 又は Mobile のシェーダーを使います。

  • Quality Settingsを調整します。使ってないところや必要ないところの設定に disable または 0 にセットします。

image

CPUネック

CPUでプロセスする処理:ゲームコード、物理シミュレーション、パーティクル、スキニングなどの重さを Unity Profiler で調べて、一番重い(時間かかる)処理から調査します。対策はどんな処理かによって異なります。

ゲームコードでよくある原因とその対策

GC処理発生しないように

Garbage Collector, ゴミ掃除的な処理。GC処理はコストが高くて、コードを書くときに気をつけないとゴミ発生しやすいので、FPS落ちる理由になりがちな原因の一つです。可能な限りゴミ発生しないように(特に毎フレームに発生するゴミ)コードを書いた方が良いです。以下はゴミが発生することによくあるパターン:

毎フレームに呼ばれるメソッド (UpdateLateUpdateFixedUpdateCoroutine) に入るとよくないもの:

  • foreachのloop

Unityが使ってるMonoのバージョンがかなり古いので、foreachを使うたびに数bytesのゴミが発生します。代わりに IEnumerator を取得して while で loop します。詳しくはカヤックUnityアドベントカレンダー2016の7日目の記事が参考になります。

  • 文字列の変更や結合

文字列が長くて更新頻度が高い場合 (結合含めて)、StringBuilderを使った方が良いです。ミリセカンドタイマーとか毎フレーム更新必要な文字列はキャッシュできるならキャッシュします。

const int MaxNumber = 100;
string[] numberCache;
void GenerateNumberCache()
{
    numberCache = new string[MaxNumber];
    for (int i = 0; i < MaxNumber; ++i)
    {
        numberCache[i] = i.ToString();
    }
}

void Start()
{
    GenerateNumberCache();
}

void Update()
{
    timerMsecLabel.text = numberCache[currentTimerMsec];
}
  • オブジェクトまたは配列のallocation

一時的なリストならキャッシュ又はバッファーを作るか、可能であればclassをstruct化します。

// ----------- NG -----------
void LateUpdate()
{
    var tempHogeObject = new HogeClass();
    var tempScore = new int[10];
    var tempHogeList = new List<Hoge>();
}

// ----------- OK -----------
HogeClass _tempHogeObject = new HogeClass();
int[] _tempScore          = new int[10];
List<Hoge> _tempHogeList  = new List<Hoge>();
void LateUpdate()
{
    // バッファーやキャッシュをリセットする
}

// ----------- OK -----------
void LateUpdate()
{
    var tempHogeObject = new HogeStruct();
}
  • LINQを避けます

  • boxing キャスト (struct => object のキャスト)を避けます

interface IHoge
{
    void DoHoge();
}

public class HogeClass : IHoge
{
    public void DoHoge()
    {
        // ...
    }
}

public struct HogeStruct : IHoge
{
    public void DoHoge()
    {
        // ...
    }
}

public void DoHogeSafely(IHoge[] hogeList)
{
    for (int i = 0; i < hogeList.Length; ++i)
    {
        if (hogeList != null) // NG: HogeStructのobjectの場合、objectにキャストされる (boxing)
        {
            hogeList[i].DoHoge();
        }
    }
}

Unityの重いメソッドをできるだけ少なくする

  • InstantiateDestroy (Object Poolingテクニックで対策する)
  • GameObject.Find*Resources.Find*系なAPI。可能な限りマネージャー的なコンポーネントで参照を管理します

読み込みタイミングを調整して、ウォーミングアップする

リソースを使う直前にResources.Loadで読み込みはしないで、シーンロードなどに特定なタイミングで行なうようにします。テクスチャーがあるオブジェクトは事前にInsantiateしてウォーミングアップしないと、最初に画面に描画される時にコマ落ち発生する可能性があるので、メモリーが許す限りウォーミングアップした方が良いです。

public class GameScene : MonoBehaviour
{
    public GameObject hogeSkillEffectPrefab; // 大きなテクスチャーを使ってるオブジェクトのプレハブ
    public Vector3 warmingUpPos; // カメラのfrustumに入ってるけど画面に見えないところ

    void Start()
    {
        // シーンの読み込み
        var hogeSkillEffect = Instantiate(hogeSkillEffectPrefab, warmingUpPos, Quaternion.identity) as GameObject;
    }
}

参照キャッシュできるものをキャッシュする

参照をキープできるものは、毎回GetComponentをせず、一回getして参照を保存します。

物理シミュレーションが原因の場合

image

  • Physics Manager の Physics.solverIterationCount 数を下げます。
  • Collidersの関係性でレイアーを分けて、Physics Manager の Layer Collision Matrix を設定します。

終わりに

ゲームがサクサク動けるならきっとプレイヤーが喜んで遊べますので、FPSが落ちないように、パフォーマンスチューニングをしましょう。ただし、最初からずっとパフォーマンスのことを考えていたら、開発のペースが落ちる可能性があるので、特定の期間でやった方が良いです。

パフォーマンスチューニングはかなりでかいトピックなので、それについての記事がたくさんあります (例: Unite 2016 Optimizing Mobile Applications) 。ぜひ参考にしてください。

明日はGitでUnityプロジェクトの管理についての記事になります。担当は mada です。お楽しみに!

ソーシャルゲームのカスタマーサポートを支える行動ログとredash

この記事はTech KAYAC Advent Calendar 2016の21日目の記事です。

こんにちは、ソーシャルゲーム事業部ぼくらの甲子園!ポケットのサーバサイド開発・運用を担当しておりますマコピーことid:mackee_wです。

↑のヘッダ画像の人物はワタクシ、という噂があります。みなさま答えはあっていましたでしょうか

ぼくらの甲子園!ポケットとは

  • 2014年9月にリリースされた共闘スポーツRPGのスマートフォン向けゲーム
    • 現在3年目でございます!!!
  • 甲子園をモチーフにしてプレイヤーとプレイヤーが協力して別のチーム(CPUではない)と対戦して甲子園の頂点を目指す
    • チーム(高校)の部員がプレイヤー9人揃わなければ試合が始まらない縛りとかも特徴です

そんな感じで運営しておりますので興味を持たれましたらこちらからダウンロードのほどよろしくお願いしますm( )m。

ちなみにこの記事はぼくポケの記事の第1弾のつもりで、今回はカスタマーサポート・ログ編という感じです。あと「試合がつらいけれど試合は面白い編」と「同時に10個ぐらい複数イベント開発運用回していくウェイウェイウェイ〜〜!!!編」の用意がありますが来年にやっていきます。

この記事の対象者

  • ソーシャルゲームやウェブサービスでユーザ様の行動ログを元に調査をしている人
  • 運営しているサービスの機能が多すぎて都度管理画面を作るのがめんどくさい人
  • 一部の人はSQLを叩けるが、大体の人はSQLを知らないチームに所属している人

TL;DR

  • 行動ログをこまめに吐くことがカスタマーサポートの品質向上につながる
  • redashは集計や解析だけではなく行動ログを調査する簡易的な管理画面としても使用可能である
  • 一度SQLを登録しておけばフォームから調査クエリを投げることができる
  • redash自体は安定しているしAPIをもあるので皆さん活用していきましょう

お問い合わせからの調査

ぼくらの甲子園!ポケット(以下ぼくポケ)では新機能の開発やイベントの運用の他にもカスタマーサポートという形でユーザ様からお問い合わせをいただくことがあります。例としてお問い合わせには以下の様なものがあります。

  • ◯◯ということがしたい。どうすればよいか?
  • スターティングメンバーが思った通りになっていない。説明して欲しい。
  • とある操作をしたらゲーム内通貨が意図せず消えたようにみえる。何が起こったのか?

1個目に関しては説明すればいいのですが、2個目や3個目などはユーザ様の高校内の状況や、前後の行動から調べる必要があります。

そのための行動ログ

そのためにサーバのアプリ内でAPIが叩かれた際にいくつかの行動ログを仕込んでいます。

ぼくポケのサーバはPerlで書かれているのですが以下のような感じで何かやったらログを吐くようにしています。(以下のコードはあくまで例です! 実際とは結構違います)

sub subtract_item_amount {
    # 保持数チェックとかロック取ったりとか

    $user_item_for_updated->update({
        amount => $before_amount - $subtraction_amount,
    });

    $user_action_logger->submit({
        type          => "subtract_item_amount",
        item_id       => $user_item_for_updated->item_id,
        before_amount => $before_amount,
        after_amount  => $user_item_for_updated->amount,
    });

    # 残数返したりとか
}

以上はアイテムを消費した時に出しているログのコードです。DB上で引いた後に$user_loggerというobjectのメソッドでログを送っています。

このクエリはトランザクション内で実行されていますが、ログが実際に送られることが確定するのはこのトランザクションが完了した際です。仕組みとしてはsubmit自体はこのログが送られるような無名関数をトランザクション成功時のフックに登録しているという感じです。 これによりロールバックした際はログが送られず実際にDBに反映された場合にのみ送られることを保証しています。(他にも色々考えることがありますがここでは語りません!)

さてこのログの送り方には特徴があります。

  • $user_action_loggerはどのユーザの行動によりログが送られたかを知るために前もってユーザ情報を持っています。なのでこのログにはユーザを識別するためのIDが自動的に埋め込まれます。
  • typeはログの種別です。typeの内容は他の箇所で発行されている行動ログとは違う識別子である必要があります
    • 現在この識別子の発行は人が手で判断して行っています。ですが人間なのでダブっちゃうことがありそういうときは困ることもあります。。。
    • 今思いましたがcallerを使ってソースコード上の位置で一意にする方法があるなと思いましたが後述するクエリで引く時に困りそうな感じがしますね
  • その他の要素はスキーマレスです。何故ならば行動ログとして保存しておきたい情報やキー名というのは機能や行動ごとに違うからです

行動ログの保存

行動ログは各PerlのAPIサーバからfluentdによってログ集約サーバに送られます。(インスタンス数や名前などは実際とは違います)

f:id:mackee_w:20161221125914p:plain

さらにlog-aggregatorに集約されたログはfluentd-plugin-s3で行動ログ1つが1行のJSONになったファイルとしてアップロードされます。

f:id:mackee_w:20161221130519p:plain

S3へのアップロードされるとそれがSQSによってlog-aggregatorにいるRinというデーモンがRedshiftに対してクエリを投げます。RedshiftはS3からログをスキーマ内に格納します。

f:id:mackee_w:20161221131800p:plain

Rinにつきましてはid:sfujiwaraさんの記事が参考になります。

sfujiwara.hatenablog.com

Redshiftに保存する際のログのスキーマは以下のようになります。

CREATE TABLE action_logs (
  uid         INTEGER DISTKEY encode delta32k,
  time        INTEGER SORTKEY encode delta,
  type        VARCHAR(65535)  encode lzo,
  json        VARCHAR(65535)  encode lzo,
  created_at  DATETIME        encode delta32k,
  req_id      BIGINT          encode delta32k
);

uidが行動ログを発行したユーザの識別子でcreated_atがログが発行された日時、typeが先程紹介したログの種別ごとの識別子、jsonがその他のキーを格納しているスキーマレスの部分です。req_idはAPIリクエストごとに発行されるIDですがこれについては後述します。

行動ログを検索する

例えばお問合わせで「何故アイテムが減ったのかを教えてほしい』というのが遭った場合に検索する場合にはまず以下のようなクエリで調べます。

SELECT * FROM action_logs
    WHERE uid = <ユーザ様のID> AND created_at BETWEEN <検索開始日時> AND <検索終了日時>
        AND type = 'subtract_item_amount';

日時で絞っているのはRedshiftで出てくるログは大量なのである程度当たりをつけるためにお問い合わせなどから推定して絞り込みます。ユーザ様のIDを調べるのはお問い合わせ時についてくるので簡単なのですが、時間はお問い合わせに書かれている時間が曖昧であったり、明確であっても人間の記憶なので前後してしまっていることもあります。経験から2,3時間前後の時間帯をいつも調べるようにしています。

typeに関しては過去のクエリなどを見たりとかソースコードを見たりなどして「あーアレね」など毎回やってます。後述のredashなので「こういうときはこれを見る」みたいなのが分かっているのでいいのですが、一覧にした方がいいのかもなーとも思っています。

さらに特定のアイテムであることが分かっている場合はJSON_EXTRACT_PATH_TEXTを用いて絞り込みが出来ます。

SELECT * FROM action_logs
    WHERE uid = <ユーザ様のID> AND created_at BETWEEN <検索開始日時> AND <検索終了日時>
         AND type = 'subtract_item_amount'
         AND JSON_EXTRACT_PATH_TEXT(json, 'item_id') = '<特定できているアイテムのID>';

JSON_EXTRACT_PATH_TEXTは入れ子になっていても検索することが出来るので便利です。さらに結果をわかりやすくしたいときにも使えます。

SELECT
    uid,
    created_at,
    JSON_EXTRACT_PATH_TEXT(json, 'item_id') AS item_id,
    JSON_EXTRACT_PATH_TEXT(json, 'before_amount') AS before_amount,
    JSON_EXTRACT_PATH_TEXT(json, 'after_amount') AS after_amount
FROM action_logs
    WHERE uid = <ユーザ様のID> AND created_at BETWEEN <検索開始日時> AND <検索終了日時>
        AND type = 'subtract_item_amount'
        AND JSON_EXTRACT_PATH_TEXT(json, 'item_id') = '<特定できているアイテムのID>';

さて、これだけではアイテムが減ったことだけがわかりますが、何故減ったのかがわかりません。ここでreq_idが効果を発揮します。

req_id

req_idはAPIリクエストごとに付けられた一意のIDです。具体的な発行方法としてはロードバランサーからやってきたリクエストをnginxで受ける時にkatsubushiを用いてsnowflake風味の64bitのIDをHTTPヘッダーに入れてそれをPerl側で読み取ってログに埋め込んでいます。

Perlでやらずにnginxでやっている理由としてはアクセスログにも同じIDを埋め込むためです。また、文字列(例えばUUIDなど)ではなくsnowflakeにしている理由としては数字でのソートが可能である点や発行された日時がIDからも容易に推測できる点などからです。

また、アクセスログも行動ログと同様の仕組みでredshiftに入れているのでJOINやサブクエリなどで一緒に検索することが出来ます。

SELECT * FROM access_logs
WHERE req_id IN (
    SELECT req_id FROM action_logs
        WHERE uid = <ユーザ様のID> AND created_at BETWEEN <検索開始日時> AND <検索終了日時>
            AND type = 'subtract_item_amount'
            AND JSON_EXTRACT_PATH_TEXT(json, 'item_id') = '<特定できているアイテムのID>'
);

これによりどのAPIアクセスでアイテムが減ったのかがわかります。また、前後のログにも同じreq_idが入っているので、

SELECT * FROM action_logs
WHERE req_id IN (
    SELECT req_id FROM action_logs
        WHERE uid = <ユーザ様のID> AND created_at BETWEEN <検索開始日時> AND <検索終了日時>
            AND type = 'subtract_item_amount'
            AND JSON_EXTRACT_PATH_TEXT(json, 'item_id') = '<特定できているアイテムのID>'
);

とすることも出来ます。これによりAPIの種類や前後のログからどのような操作が行われてアイテムが減ったかが分かります。

redashを用いて行動ログを他の人にも利用してもらう

redashについてはid:handlenameさんが書かれたこちらの記事に詳細な記述があります。

techblog.kayac.com

簡単に言えば様々なデータストアに対してクエリを投げて結果を保持しビジュアライズする機能を持ったソフトウェアです。

さて、redash導入以前にはSQLを知っているサーバサイドエンジニアしか調査が出来ていませんでした。お問い合わせ数の多い事例に関してはもともとあった管理画面からPostgreSQLのドライバを入れてRedshiftを叩いて表示するなどしていましたが、機能やイベント数が多いため管理画面の更新が追いついていないのが現状でした。

それとは別件で統計などを取るケースも多いため、Lobi事業部のほうで導入されているredashの噂を聞きつけて統計・解析用にredashを導入したところ、これはカスタマーサポートにも使えるぞ?と気が付きました。

以下にredashがカスタマーサポートに向いている点について挙げてみます。

WebUIでクエリを投稿してボタンポチッで投げることが出来る

redashの機能そのものですが、redashが来るまでは調査用に立てているRedshiftや本番DBのスレーブのMySQLにつなぐことの出来るサーバにログインしてそこからクエリを投げていたわけです。もちろん管理画面に自由にSQLを入れられるようなUIを作ることも出来ますが、調査用のクエリは実行時間が長いことも多く、ジョブキュー的なしくみを作り込まなければなりません。

redashはそのあたりがちゃんと作りこんであり、またクエリエディタやカラム名の補完(最近付きました)、forkなどの機能もあり、クエリを登録しておいて他の人に投げたり結果を見せたりするのには非常に便利なソフトウェアであると言えます。

またSQLを知らなくてもSQLを投げることが出来るというメリットがあります。なのでカスタマーサポートに関わる人に対して「このような問い合わせにはこのクエリを使ってみてください」とredashに登録したクエリへのリンクを提示することが出来ます。

フォームでクエリに文字列の埋め込みが出来る

redashで感動したのは{{hogehoge}}というふうに波括弧で囲った文字列をクエリに埋め込むとシュッとフォームが出来上がることです。

例えば先程のクエリであれば、

f:id:mackee_w:20161221140546p:plain

このようにフォームが現れます。クエリの部分は人に見せるときには隠れていますから、フォームにIDや日時などを入れてもらうだけで様々なユーザ様の問い合わせに対応することが出来ます。

その他やっていること

  • redashのドキュメントにはない気がしますがAPI Keyを発行してAPIを投げることが出来ます。redashは定期的に投げたり一日の特定の時間にスケジューリングすることは出来ますが、1ヶ月に1回投げて結果を保存しておくという事はできないので、Jenkinsにタスクを登録してAPIを投げてSlackに通知するというジョブを作っています
  • アラート機能を使ってデータの変化を知ることが出来ます。これを用いてバッチの実行の確認などをSlackに登録しています

まとめ

だいたいTL;DRに書いてあることですが、redashで業務を改善したことでサーバサイドエンジニアもコーディングに集中出来るようになりましたし、驚いたのはカスタマーサポートを担当する企画部の方から「redashの登場は本当にイノベーションです」と言われたことです。

そんな感じで業務を良くしていって良いゲームを作る手助けをしていただけるエンジニアをカヤックでは募集しております!