6.8K Views
September 25, 19
スライド概要
2019/9/25-6に開催されたUnite Tokyo 2019の講演スライドです。
Kim Minhyuk(株式会社 Aiming)
こんな人におすすめ
・アプリケーション最適化の必要性を感じている方
・低スペック端末でアプリケーションが落ちてしまう方
・パフォーマンスも重視しながら他人が読みやすいコードを書きたい方
受講者が得られる知見
・知らない間に増加しているアプリケーションメモリ使用量の改善策
・ビルドサイズ・ロード時間の改善策
・読みやすくGCを走らせないC#コードの書き方
Unityのイベント資料はこちらから:
https://www.slideshare.net/UnityTechnologiesJapan/clipboards
リアルタイム3Dコンテンツを制作・運用するための世界的にリードするプラットフォームである「Unity」の日本国内における販売、サポート、コミュニティ活動、研究開発、教育支援を行っています。ゲーム開発者からアーティスト、建築家、自動車デザイナー、映画製作者など、さまざまなクリエイターがUnityを使い想像力を発揮しています。
今すぐ現場で覚えておきたい最適化技法 ~「ゲシュタルト・オーディン」開発における最適化事例~ 株式会社Aiming / Aiming Inc. 金 敏赫 / Minhyuk Kim
目次 • 講演者プロフィール • 「ゲシュタルト・オーディン」について • 調査 • リソース管理 • シェーダー • スクリプティング 2
Minhyuk Kim 株式会社Aiming /ソフトウェアエンジニア 2014~ Aerowhale Studio 2016~ 株式会社Aiming 味噌カツが大好きです 主にクライアントサイドを担当しています 3
「ゲシュタルト・オーディン」 (C)Aiming Inc. /SQUARE ENIX CO., LTD. All Rights Reserved. 株式会社スクウェア・エニックス/株式会社Aimingの共同プロジェクト 2018年10月18日 サービス開始 2019年09月26日 サービス終了 iOS/Android 向け MMORPG クライアントサイド - Unity 2017 - UniRx - C# 4.0 4
2018 5 のある
様々な問題が幅広い範囲で起こっている • 低スペック端末ではタイトルからアプリケーションがクラッシュする • 繰り返し戦闘を行うとクラッシュする • アプリケーションビルド時間が長い・ビルドサイズが大きい • アプリケーション全般ローディング時間が長い 6
調査 •調査に使用したツール - UnityProfiler (CPU/GPU) - Xcode Instruments (iOS GPU) - Adreno GPU Profiler (Android GPU) •プロファイラーで大部分の問題が見える - リソースのメモリ使用率が常に高いことが判明した - バトルで激しくGCが発生した(ローディング時間が長い) - ヒープ確保量が多いことが判明した 7
1.リソース管理 8
現象 •タイトルから本ゲームに進めない •繰り返しの戦闘・移動でアプリが落ちる • メモリが足りなくクラッシュする問題の8割はリソース問題 - 普通のコードミスでメモリが足りなくなることは少ないと思われる - ヒープメモリ確保を数百個減らすより、テクスチャ―メモリを一枚減らすのが効果的 9
AssetBundle •時代はAddressable Asset System (Unity 2018.2 ) • Editor/実機、 ローカル/リモート関係なく同じ仕組みに載せられる • 現場の開発の度合上今すぐ入れられない・まだpreviewなどのいくつか懸念点がある • 明示できていないアセットが含まれた際の重複に関してはケアする必要がある • 今後の開発に関しては強力な考慮対象 10
AssetBundle •AssetBundle Browser • バンドル・依存の可視化、重複アセット検知可能 11
AssetBundle •AssetBundle Graph • バンドル構成の可視化・構築可能 12
AssetBundle •明示的に含まれてないアセットは重複してメモリに載ってしまう • プレハブ・シーンなどから暗黙的に参照されてしまったアセットはそれぞれコピーされてしまい、 同時にロードされると重複する(別インスタンス) • 頻繁に参照されるものはバンドルに明示的にパッキングする必要がある (特に別インスタンスになると問題になるシェーダー・テクスチャー・マテリアル周りは注意) • 全部別バンドルにしてしまうと、ファイルIO等によりロードに時間がかかるため対策が必要となる -> テクスチャーは用途に応じてアトラス化し、アトラスごとにバンドルにする -> シェーダーは一個のバンドルにまとめる 13
AssetBundle •ロード・アンロードにおける注意事項 • 別バンドルからロードしたアセットもしくはバンドルをアンロードしてから再度ロードした アセットは、別インスタンスになるのに注意(重複) • 正しくReferenceのCountを数えて参照のなくなり次第Unload(false)を呼ぶのが 望ましい 14
Built-in Resources •とにかくビルドサイズに影響を与える • Build Settingsに含まれたシーン・シーンに参照されるアセットも同じく含まれることに注意 • AndroidだとApkサイズ100mb制限が存在するので極力必要ないものは除いておきたい 15
Built-in Resources •AssetBundleとの相性が悪い • AssetBundleからロードしたアセットとは別インスタンス扱いになるので重複する • Built-inシーンに参照されたアセット + Resourcesフォルダーアセット = OK • Resourcesフォルダーアセット + AssetBundleアセット = 重複 • Built-inシーンに参照されたアセット + AssetBundleアセット = 重複 16
Built-in Resources •ビルドに含めないといけないアセットがあるなら • シーンとリソースを最低限にし、AssetBundleに載せてStreamingAssetsを使う • ダウンロードシーン -> タイトルシーン順になっている事例もある • 一部の外部アセットはResourcesの仕組みに依存するものが存在する(TextMeshPro) 17
その他のリソース管理 •RenderTexture • 自前で生成せず、常にRenderTexture.GetTemporary->ReleaseTemporaryを使う • 同じ解像度・フォーマットであれば同じフレームでも内部ObjectPoolにより何度も使い回せる • メンバー変数に保存せず、使い終えたら即時返却し同一フレームの他の処理で使えるようにする • 何フレームか使われなかったら自動でメモリから外れる • 複数枚使う可能性があるならフォーマットと解像度を共通化すると一枚分しかメモリを確保しない • MonoBehaviour.OnRenderImageはRenderTextureを確保するので注意 • 用途に応じてTextureのサイズ・フォーマットを調整する • ただ機種により動かないフォーマットがあるのでSupportsRenderTextureFormatで チェックし、Fallback処理を入れる 18
まとめ • Assetbundle • Addressable Asset Systemが安定化されたら考慮する • 明示できてないAssetは別バンドルになると重複することに注意 (特にShader・Texture・Material) • Unloadを呼んだあと再びLoadしたアセットバンドルでのアセットは別アセット扱いになることに注意 • Built-in Resources • Resourcesフォルダーだけではなく、Build settingsのシーンに含まれた明示できてないアセットも 含まれることに注意 • Built-in Resourcesに含めたものの上にAssetbundleをロードすると重複することに注意 • RenderTexture 19 • 自前で管理よりGetTemporaryを使った方がメモリに優しくなることが多い • フォーマットや解像度によりサイズを減らす・ただし、複数使う想定だったら共通化できるものは共通化する
2.シェーダー 20
現象 •ビルド時にShaderCompileに大幅に時間がかかってしまう •シーン・プレハブなど、ロード全般が遅い •エフェクト表示時に一瞬画面が固まってしまう •初動時に特に遅い • Shader.Parse • Shader.CreateGPUProgram 21
とにかくVariantが多すぎる • #pragma multi_compile -> shader_feature + skip_variants ビルドに含まない ビルドに含まない • shader_featureを使う際、AssetBundleには明示的にShaderVariantCollectionとして 含まないと該当Variantが効かない 22
ShaderVariantCollection • WarmUp() • WarmUp関数を呼ぶことで、事前にシェーダーのコンパイルを走らせることが出来る • 事前に準備したShaderVariantCollectionを初期起動時のローディング画面でロードしておく • 常に使われるShaderのVariantCollectionは数が限られていたため、全部メモリに保持して Parseが走らないようにした •複数のシェーダーが含まれたCollectionのWarmUp時に画面が固まってしまった • ShaderVariantCollection一つに全ての組み合わせを登録したことで、 同期関数のWarmUpを呼ぶと画面が固まったまま時間がかかってしまった 23 • ShaderVariantCollectionを適切に分割し、少しずつWarmUpを呼ぶことで解決
ShaderVariantCollectionの •更新の自動化 • バンドルに含まれる予定のシーンのマテリアル情報を参照し、 material.shaderKeywordsからキーワード獲得 • Editor内部関数のGetShaderVariantEntriesをReflectionで 呼び出し、含まれているPassType/Keyword組み合わせを獲得 • material.shaderKeywordsから獲得したKeywordと組み合わ せマップを基に実際使われる想定のShaderVariantCollection 作成 24
まとめ • multi_compileよりはshader_featureを使う • shader_featureを使った際にアセットバンドルに含めないといけないものは ShaderVariantCollectionにまとめる、自動化で解決 • WarmUp()は同期関数なので数をばらしてShaderVariantCollectionに登録し、 固まらないようにする 25
3.スクリプティング 26
現象 •ある手順によりアプリが必ず落ちるようになる(メモリ) •ロード時間が遅い、アプリケーション動作中に一瞬止まることがある(GC) 27
原因になったLINQ To Objects • データ集合(Enumerable)に対して標準化された方法でデータを問い合わせるクエリ • Referenceが公開されているので内部実装が見れる • 一応言語バージョンが上がることで様々な最適化が入ってきている 28
何が問題だったのか // 遅延関数でIOにアクセス等重たい処理を行い、そのまま下流に流してしまう private IEnumerable<Player> LoadPlayers(IEnumerable<long> playerIds) { return playerIds.Select(x => LoadPlayers(x)); } // LoadしたプレイヤーからヒーラーだけをFilterする private IEnumerable<Player> GetHealerDefeatedPlayers(IEnumerable<Player> players) { var healerIds = players .Where(x => x.Job == JobType.Healer) .Select(x => x.Id); return players.Where(x => x.DefeatedPlayerIds.Any(id => healerIds.Contains(id))); } 29 // 報酬を配る private void GiveReward(IEnumerable<Player> players) { foreach (var healerDefeatedPlayer in healerDefeatedPlayers) { healerDefeatedPlayer.GiveReward(); } }
繰り返して実行関数が呼ばれてしまっている // LoadしたプレイヤーからヒーラーだけをFilterする private IEnumerable<Player> GetHealerDefeatedPlayers(IEnumerable<Player> players) { var healerIds = players .Where(x => x.Job == JobType.Healer) .Select(x => x.Id); return players.Where(x => x.DefeatedPlayerIds.Any(id => healerIds.Contains(id))); } • 実行関数が呼ばれるたびに遅延関数が実行される • LoadPlayerの作者はplayer id数分だけLoadが走ることを期待したが、 IEnumerableを回す瞬間 player id数 * ヒーラー数 * プレイヤーが倒したプレイヤー数の 合計分Loadが走ってしまう 30
即時評価(実行関数) •今まで遅延していた処理を含めて即時実行する •新しくICollection<T>を返す拡張メソッド群(即時評価) • • • • • • ToArray ToDictionary ToLookup … メソッドの名前にToが付いているメソッドは即時評価 別のCollectionを作ることでヒープアロケーションが発生する •その他の即時評価拡張メソッド群 31 • • • • • Count Min(By)/Max(By) Any/All … 戻り値がIEnumerable<T>でないメソッドは即時評価
遅延評価 •遅延評価する拡張メソッド群 • Where • Select • GroupBy •… • 戻り値がIEnumerable<T>(IGrouping<T>等の継承関係も含む)であるメソッドは遅延評価 32
遅延関数を溜めすぎない • IEnumerable引数に対して同じ層で実行関数を何度も使う必要がある場合 • 使う用途に応じてまず一回ICollection化して使う • ICollectionにしたものはそのままICollectionとして返した方が使う側で選択肢が多くなる • 大きく流れる可能性がある情報をずっとIEnumerableのまま渡さない • 色んな層で実行関数を呼ぶたびに重なった遅延処理が走る • どれぐらいの重たい遅延処理がいくつ重なるのか予測できない • ある関数の実行に関係ない遅延処理のコストが足されるため問題になった際に探しにくい 33 33
現象 •コード把握・レビューに時間がかかる •修正を加えることでエンバグが頻発する •コードの流れを追いづらい 34
35
コードに必要な最低限の情報を正しく伝える •公開範囲 public class Misokatsu { // 実は外でSetしてなかった (private set) public int Price { get; set; } // 実は外で使われてなかった (private) public void Cook() { … } } 36
•不変性 (Immutable) public class Monster { private readonly int code; private string Name { get; } private readonly IReadOnlyList<int> defeatedMonsterCodes; private readonly IImmutableList<int> affectedPassiveCodes; } public readonly struct MonsterPosition { } • readonly struct(C# 7.2) 不変ということをコンパイラーにも伝えることで、 structに対してのDefensive Copyが発生しない 37
•副作用・状態変化 public class Hoge { private int a; private int b; public int Sum() => a + b; } 38 public (static) class Hoge { public (static) int Sum(int a, int b) => a + b; }
•副作用・状態変化 public class Shop { private Menu menu; public void Open() => menu = new Menu(); public void Close() => menu = null; public async Task WaitUntilDayPassesAsync() { await Task.Wait (TimeSpan.FromDays(1)) menu?.Prepare(); … } • ここでのmenuのOrderは正常に処理されるのだろうか? public void Order() => menu?.Order(); } 39 • 該当関数がOpenとCloseに依存している
•関数の副作用はできるだけ除く (関数型思考) • あるクラスに対してメンバー変数が多用され、非同期・マルチスレッドと混ぜて 頻繁に状態を変化させると非常に流れを追いづらい • ある機能を持つ関数に対して引数・戻り値を明確にし、副作用を除くことで コードの流れが直観的になる • クラス生成時に使いたくない処理なら使う際に渡す、もしくはLazy<T>を使う •メモリにも優しくなる • static function (静的関数)・副作用がないラムダ式 -> 関数内の副作用を取り除くことで、ラムダ式のキャプチャーが発生しない 40
まとめ •Linq to objects • 実行関数(即時評価)を呼ぶと今まで溜めた遅延関数の処理が全て走る • 戻り値がIEnumerable<T>なら遅延処理、そうではないなら即時処理 • IEnumerable引数に対して同じ層で実行関数を何度も使う必要がある場合、Collection化する • 大きく流れる可能性がある情報をずっとIEnumerableのまま渡さない •読み易いコード • 公開範囲、不変性等伝えられる情報はできるだけ多く正しく伝える • 副作用・状態はできるだけ除く • 関数型思考を意識したコードを書く(同じ入力に対して同じ出力をする流れを作る) • readonly struct・静的関数等を使うことで自然にメモリにも優しくなる 41
ご清聴ありがとうございました 42