Unity2019.3 AddressableとURPでAndroid iPhone Windowsゲーム開発事例

Unity
Not Found

インディー開発者向けの開発事例です。Android版はベータリリース。iPhoneはEasyMobile対応待ち、Windowsは冬コミ向けです

開発環境

  • unity2019.3.0f1(開発自体はb3から
  • github private repository
  • sourcetree
  • dropbox
  • jenkins

バージョン管理について

昔はさくらvpsにsubversionいれてましたが、githubの方が便利なので移行しました。

Sign in for Software Support and Product Help - GitHub Support
Access your support options and sign in to your account for GitHub software support and product assistance. Get the help you need from our dedicated support tea...

ただしファイルサイズ100mb制限があるのでそこだけ神経質になってます。alembicなどは簡単に大きくなるので尺を制限したり、30fpsにするなどで小さくします

dropboxはpsdやblendなどのグラフィック系の中間ファイルの簡易バージョン管理として使ってます。githubだとすぐにファイル制限にひっかかりますが、dropboxならその心配はありません。たまに上書きしてファイル台無しになったりしたので、バージョン管理しておいた方が安全です。

ブランチについて

develop一本にどんどんコミットしています。大きい破壊的仕様変更があった時は一時的にブランチ分けました。個人開発なので変にブランチ分けた運用はしないです。

グラッフィク仕様

モバイル版とデスクトップ版で一部のグラフィックリソースに違いがあります。

モバイル版

キャラクター 15000△までに抑える

敵 10000△までに抑える、少なければ少ないほどよい

  • テクスチャは一律1k以下。
  • ShaderはSimpleLitが基準

デスクトップ版

  • 頂点数に制約無し
  • LODは今のところ無し、負荷が厳しいようであればモバイル版をLowモデルとして使う
  • テクスチャは2k以下
  • ShaderはキャラのみリッチなLitベース

フォルダ構成

DropBox管理

Designer
Planner

出力したpngやfbxやluaやjsonはrubyスクリプトでunity projectのAssetsに変更があった場合のみコピーします。

Git管理

Addressable
Game
MKFW(便利クラスまとめた自作フレームワーク
無数の外部アセット

失敗

外部アセットは自由に散らばせていたのですが無駄にHierarchyが長くなるので、せめてExternalAssets直下にまとめるなどしておけばよかったです。特にグラフィックやサウンド系はほぼ更新することはないので好き勝てに移動させてよいと思います。

Asset Import自動設定

【Unity】テクスチャーのインポート設定を自動で行う - Qiita
Unityでは以下のように、テクスチャー(画像ファイル)のインポート設定があります。これを上手く設定しないと画像がぼやける、容量が無駄に増える、そもそも表示されないなどの問題が生じます。見ての通り…

Editor拡張でwindows, android, iphoneを設定するようにしmasita

windows開発機でエラー

しかしwindows開発機でiphoneの設定をしようとしたら、iphoneモジュールインストールしていないためエラーになった。そこでiphone設定部分だけ#if UNITY_IPHONEで囲いました。

全体設定の危険性

フォルダ指定ではなくプロジェクト全体で共通のテクスチャ設定にしていました。しかしそうするとPackageにあるtextureまで勝手に設定しました。必ず対象フォルダを設定しておくことをおすすめします(どうしても全体にするならせめてAssets文字が含まれているか判定しましょう

ローカライズ対応

About Localization | Package Manager UI website

unityにはpreviewですが一応ローカライズ機能あります。さすがにpreviewすぎるので使いませんでした。

自作のローカライズ系を使いました。

今回は、動的にフォントを切り替える方式を採用しました。各言語ごとTextMeshProフォントアセット作りました。そして任意のタイミングでフォント(言語)を切り替えれるようにしました。しかし、この運用かなりめんどくさくて後悔してます。タイトルシーン以外での言語切り替え禁止方式にすればよかったです。

また言語ごとにTextmeshProフォントアセット作って都度読み込むのもめんどくさいです。全言語分ひとまとめにしてしまえばよかったです。googleが出してるNotoSansCJKであれば日本語、簡体字、繫体字、英語をサポートできるし、TextmeshProのfallbackを使えば追加で足せます。もちろんTMProの動的に文字追加するモードでの運用もアリです。

動的フォント切り替え方式のメリットとしてスクショ撮るときにフォントを一括で差し替えて全言語分のスクショを一度にとることができそう…なんですが、滅多に出番がないです。このためだけに動的フォント切り替えの仕組みでコード書くのはつらすぎます。

URP(Universal Rendering Pipeline

キャラクターだけSimpleLitにリムライトを追加した自作シェーダーです。それ以外は全てSimpleLitです。

最も基本的なメインカメラ一個、UIはUGUIのCamera overrideです

パフォーマンスを最適化するならUIはSpriteRendererベースがよいのですが、URPはカメラスタック未対応なので諦めました。

SRP Batcherを活かす

SRP Batcher:レンダリングをスピードアップ
2018 年、Unity はスクリプタブルレンダーパイプライン(SRP)というカスタマイズ性に優れたレンダリングテクノロジーを導入しました。この中には、ローレベルエンジン向けの新しいレンダリングループ「SRP Batcher」が用意されています。SRP Batcher を使うと、CPU によるレンダリング処理の速度がシ...

自作シェーダーもSRP Compatibleにしました。

詳細はこちらの記事参考に

今作は1マテリアル(1texture)多数メッシュという背景づくりをしたため、かなり高速に描画できたんじゃないかなと思います。

キャラクターもたくさん表示されるのですが、SRP BatchがSkinningRendererにも対応したのでNPCの描画が効果的におこなわれています(学生が多くみんな制服という仕様も活きてます)。そのため当初予定していたMeshBakerによるNPCのメッシュ結合で1set pass目指すはやめました。

背景も考慮するとこのゲームはローポリゴンが超多いのですがBatchが効きまくるグラフィックリソースなのでさほど問題になりませんでした。

個人開発であまり最適化に時間とられるのもキツイので労せずパフォーマンス出る仕様が一番効きます

GPU Instancingは未使用

SRP Batchとの相性が悪いとのことでOFFにしました。Unity Blogにも書いてある通り、自動GPU instancing対応中とのことなのでそのうち自然にパフォーマンス上がりそうです。

Static batching, dynamic batching OFF

ロード時間が劇的に増えたり、SRP Batchrと相性悪いので無効化しました。

AssetStore素材

今回使ったアセットはURP未対応のアセット素材でした。

Edit>RenderPipeline>UniversalRenderingPipeline>Upgrade Selected Material to URP Material

これで変換することでURPで使えるようになりました。

しかし対応しているのがStandardshader→Litくらいでした。パーティクルは未対応だったので手動で変換しました

Addressable

エクスプローラー上はAddressable管理のリソースを全てAddressableというフォルダにまとめました。その中に色々なサブフォルダある感じです。

Residentは常駐用の一個の巨大なassetbundleです。フォルダを一個登録してあります。フォルダに新しいファイル追加しても自動的にaddressableに含まれるので楽です。

自動命名

今回はファイルたくさんではないのでAddressable Pathの自動設定系は使いませんでした。しかし衣装が増えてきそうなので自動登録できるようにしたくなってきました。

Unity Addressable Asset Systemの使用方法と自動化の機能調査 - Qiita
#はじめに実装したソースはこちらにまとめています。…

Shaderの重複

Unityのハマりどころです。今回URPのシェーダーはPackageの中にあります。PackageはAddressableの管理外のため何もしないと全てのAssetBundlelit.shaderなどが含まれてしまいます。

SBPのCreateBuiltInShaderBundleを使う

ありがたいことにプロ技で解説ありましたね

//DefaultBuildTasks.Create(DefaultBuildTasks.Preset.AssetBundleCompatible);
var taskList = DefaultBuildTasks.Create(DefaultBuildTasks.Preset.AssetBundleBuiltInShaderExtraction);

こうするだけですね

間違いました。URP.Lit.shaderはbuild-in shaderではありません

そのため上記BuildInShaderのタスクは役に立ちません。ちなみに2019.4 VerifiedのAddressableであれば標準でBuildInShader.bundleを作っています。SBPの改造は必要ありません。

ShaderをAssetsに持ってくる

最も単純な対応です。今回はこっちにしました。(というよりSBPで用意されてるのに昨日まで気づきませんでした、後で変える予定です

ShaderVariantCollectionは使いませんでした

Unity - Manual: Unity Manual
The Unity Manual helps you learn and use the Unity engine. With the Unity engine you can create 2D and 3D games, apps and experiences.

ShaderVariantCollectionは動的に発生するShader Compileによるカクツキを防止する手段です。今回はシェーダーの数が少なくカクツキも気にならないので使いませんでした。

無策にProject SettingsのGraphicsタブのSave to assetを使うと全shader varinatが参照されてしまい。巨大なアセットバンドルが生成されます。(僕の場合は20MB増えました。メモリに丸々のります。真面目にやるなら細かくShaderVarinatCollectionを分割します。

不要なリソースの削除

モバイルではリッチなデスクトップ版のリソースは必要ありません。デスクトップ版では体験版を作る必要があり要らないリソースがあります。またリリースではSRDebuggerは入れたくないです。今回は二種類の方法を組み合わせて対応しました。

ビルド前にファイルを直接削除

リリースビルドではruby scriptでSRDebuggerのフォルダを直接削除してます。これはjenkinsが行っています

SRPを改造しアセットを除く

pipelineの最初期ほど難易度が低いです。最初はアセットバンドルをStreamingAssetsにコピーするところではじこうかなと調べていたのですが、不可能っぽいので諦めました

ContentPipeline.BuildAssetBundlesの箇所で使用アセットを除くことでAssetBundleの作成自体をされないようにしました。

 public static ReturnCode BuildAssetBundles(IBundleBuildParameters parameters, IBundleBuildContent content, out IBundleBuildResults result, IList<IBuildTask> taskList, params IContextObject[] contextObjects)
        {
            // Avoid throwing exceptions in here as we don't want them bubbling up to calling user code
            if (parameters == null)
            {
                result = null;
                BuildLogger.LogException(new ArgumentNullException("parameters"));
                return ReturnCode.Exception;
            }

            // Avoid throwing exceptions in here as we don't want them bubbling up to calling user code
            if (taskList.IsNullOrEmpty())
            {
                result = null;
                BuildLogger.LogException(new ArgumentException("Argument cannot be null or empty.", "taskList"));
                return ReturnCode.Exception;
            }

            // Don't run if there are unsaved changes
            if (ValidationMethods.HasDirtyScenes())
            {
                result = null;
                return ReturnCode.UnsavedChanges;
            }
            // ここが改造部分、この中で要らないアセットを除く処理をする note本当はIBuildTaskを継承したTaskにすべき
            var config = new AssetConfigulation();
            config.Config(content);

            AssetDatabase.SaveAssets();

            ReturnCode exitCode;
            result = new BundleBuildResults();
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using UnityEditor.Build.Pipeline.Interfaces;
using UnityEditor.Build.Pipeline.Utilities;
using UnityEditor.Build.Utilities;
using System.Text.RegularExpressions;
namespace UnityEditor.Build.Pipeline
{

    public class AssetConfigulation
    {
        public static BuildTargetGroup BuildTargetGroup;
        public static bool Trial;
        public static bool R18;

        private readonly List<Regex> _ignoreRules = new List<Regex>();
        private readonly List<GUID> _validAssets = new List<GUID>();// 作業用のバッファ
        private readonly HashSet<GUID> _removedAssets = new HashSet<GUID>();


        public void Config(IBundleBuildContent content)
        {
            InitIgnoreRules();

            ParseAssets(content);

            ParseBundleLayouts(content);
        }

        private void InitIgnoreRules()
        {
            _ignoreRules.Clear();
            var paths = new List<string>();
            if(Trial)
            {
                paths.Add(Application.dataPath + "/../_AB_trial_ignore_contents.txt");
            }
            if( BuildTargetGroup == BuildTargetGroup.Android || BuildTargetGroup == BuildTargetGroup.iOS)
            {
                paths.Add(Application.dataPath + "/../_AB_mobile_ignore_contents.txt");
            }
            foreach(var path in paths)
            {
                var rules = File.ReadAllLines(path);
                foreach(string rule in rules)
                {
                   var r = new Regex(rule);
                    _ignoreRules.Add(r);
                }
            }
        }

        /// <summary>
        /// IBundleBuildContent.Assetsを構成に基づき間引く
        /// </summary>
        /// <param name="content"></param>
        private void ParseAssets(IBundleBuildContent content)
        {
            _validAssets.Clear();
            _removedAssets.Clear();
            var assets = content.Assets;
            foreach (var guid in assets)
            {
                var assetPath = AssetDatabase.GUIDToAssetPath(guid.ToString());
                if (IsValidAssetPath(assetPath))
                {
                    _validAssets.Add(guid);
                }
                else
                {
                    _removedAssets.Add(guid);
                }
            }

            assets.Clear();
            assets.AddRange(_validAssets);
        }

        private bool IsValidAssetPath(string assetPath)
        {
            foreach(var rule in _ignoreRules)
            {
                if (rule.IsMatch(assetPath))
                {
                    return false;
                }
            }

            return true;
        }

        /// <summary>
        /// BundleLayoutから要らないguidを除く
        /// </summary>
        /// <param name="content"></param>
        private void ParseBundleLayouts(IBundleBuildContent content)
        {

            var bundleLayout = content.BundleLayout;
            foreach (var kv in bundleLayout)
            {
                _validAssets.Clear();

                var guids = kv.Value;
                foreach (var guid in guids)
                {
                    if (!_removedAssets.Contains(guid))
                    {
                        _validAssets.Add(guid);
                    }
                }


                guids.Clear();
                guids.AddRange(_validAssets);
            }
        }
    }
}

除くファイルについては正規表現一覧をtxtファイルにまとめました。

しかしDefaultBuildTaskを見ればわかる通り、SBPの作法としてはIBuildTaskを継承してこの処理を実装するのがよいみたいです。少し失敗しました

InputManager

New Input manager?

https://blogs.unity3d.com/jp/2019/10/14/introducing-the-new-input-system/

検証しました。xbox360コントローラーとPS4コントローラーを何もしなくても扱えるというのが神でした。Rewiredを使ったことある人ならこのありがたみが分かると思います。

でも結局Legacy Input Manager?

previewでバグが怖かったのと、メインプラットフォームがモバイルでバーチャルパッド操作だったのが理由です。input managerのunity blog記事読んだときは基盤システムをもう固定してたので今更感もありました。

設計

ざっくりこんな感じです。最終的に”button5″とかの文字列をLegacyInputManagerに問い合わせます

実際はモバイルでのバーチャルパッドやタップやマウスクリック操作をも抽象化するため、間にもう1レイヤーあります。

そのためInputManagerの設定はシンプルです。だいたいこの状態から変わることはありません。

ゲーム初回起動時にキーコンフィグさせるという仕様は、他の同人ゲームサークルでやっていたので、それにならいました。

失敗

コード上ではPad.GetButtonDown(Button.A)のように使いました。

しかしこれは失敗でした。ボタンの割り当ては開発中に何度も変わります。その都度コードを修正するのは苦痛でした。既存の流儀に従いPad.GetButtonDown(“Attack”)みたいに具体的なボタンに依存しない実装が正しいです。

【Unity】XBOXコントローラの左右トリガー入力を別々に取得する - Qiita
結論Windowsなら、9th-axisと10th-axisを使えば左右別々に取得できます。Androidだと14th-axisと15th-axisです。概要Unityでゲームパッド(XBO…

記事執筆中にXboxController LRトリガー同時押しバグを引いてしまい修正に苦労しました。

Xbox系コントローラーとPS4コントローラー両対応作業はかなり苦しいものでした。まだバグがあるかもしれません。正直ここまでキツイのであれば大人しくNew Input Managerに移行した方がよかったです。Xbox PS4コントローラーを何もせずに同等に扱えるだけでめっちゃ楽に開発できます。

ゲーム全体の設計について

unityでreduxアーキテクチャを使った堅牢なシーケンス制御の実装 - Qiita
#はじめにリアクティブスパゲッティ好きですか?僕は苦手です!Reduxアーキテクチャをゲーム用にアレンジしてバグのないゲーム制作を楽しみましょう!Reduxアーキテクチャって何?という人はこち…

UI系はクリーンアーキテクチャとReduxアーキテクチャのハイブリッドです。

TPS系はさらにエージェントアーキテクチャが追加されます。

Jenkins

build jobはプラットフォームごとに分けました。プラットフォーム切り替えはだいたい時間かかるのでこういう実装にしました。ただUnity2019.3から高速になりました。僕のwindows環境では45秒Defineが切り替わりコンパイルがはしる)でした!

また単体テストや自動プレイといったものは実装してません。単体テストは余計に開発時間かかるため。自動プレイも実装コストが高すぎるのでやめました。

処理の流れは以下の通り

ruby script

SRDebuggerフォルダを削除したり、ファイルの書き換えなど軽い処理を担当してます。

ScriptingDefineSymbols設定

  • プラットフォーム
  • 体験版フラグ
  • リリースフラグ

これらに基づいて適切なdefineを設定します。これはコマンドラインからの呼び出しです。

public static void Build(){
PlayerSettings.SetScriptingDefineSymbolsForGroup(hogehoge
BuildPipeline.BuildPlayer

こういう風にすると正常にビルドに反映されません、別分け必須です

AssetBundleビルド

体験版かもしれないのでここでビルドして間引きします。あとビルドが必要とするアセットバンドルのバージョンと差異が出ては困るのでまとめました。

Playerビルド

BuildPipeline.BuildPlayer呼ぶだけです。本当はここでAssetBundleのコピーをフックして不要な物はコピーしないとか仕込めればよかったのですが不可能っぽいです。

この後Standalone環境であればdocや実行ファイルなどをzip圧縮します。

Android iphoneであればDeployGateへアップロードしたりもします。

考察

プラットフォームごとだけでなく、体験版によってもジョブを分けてもよかったかなと思いました。なぜなら体験版作った後にリリース版作ると、一度アセットバンドルが体験版用に間引かれているため再ビルド発生しビルド時間が延びるようになります。

最適化

現在Android版絶賛最適化中です。

視界判定などで使うPhysics.Overlap系の数が多すぎるため高負荷になってます。

FixedUpdateが重すぎる罠

Character Movement Fundamentals | Physics | Unity Asset Store
Get the Character Movement Fundamentals package from Jan Ott and speed up your game development process. Find this & other Physics options on the Unity Asset St...

上記アセットのような自作のRigidbodyベースのキャラクター制御系を使っています。当然FixedUpdateで更新するのですが、ここで重い処理をいれすぎたため低スペック端末で極度の処理落ちが発生しました。

【Unity検証】FixedUpdateは本当に一定間隔なのか
FixedUpdateは一定間隔で呼ばれると言われていますが、処理が重い時などは一体どういった挙動になるのでしょうか?なんとなくわかっているフリをしていたUpdateとFixedUpdateの挙動について徹底検証していきたいと思います。
Unity - Manual: Time

FixedUpdateが重すぎるとUpdateとレンダリングされません。そしておそらくmaximum allowed timestepに達するまでレンダリングされません。また処理が重すぎてこの値を超えそうな場合は物理演算とFixedUpdate処理が打ち切られます。ゲームとしては致命的です。

対策

NPC全員Update処理に切り替えました。もともとNavMeshに沿って動いていたのでFixedUpdateで動作させる必要はありません。

SphereCastやBoxOverlapなど処理がNPC全員分呼ばれ超重かったです。今は一時的に1フレームに呼ぶNPC数を制限してます。将来的には可能な限りJobSystemで別スレッドに逃がす予定です。

Animator負荷が高い

NPCが多くwindows版と同じ骨構造をしているためかなりリッチです。CPU負荷もかなりのものでした

対策

Animator.CullingMode CullCompletelyにしました。映っていない場合のCPU負荷が大幅に改善されました。しかしたくさん画面内に映った場合はどうしようもないです。骨の少ないLODモデルを用意するくらいしかありません。

GPU負荷が高い

CPUほどではないですが低スペック端末では描画負荷も高かったです。

固定解像度

基準となる横解像度は1280としました。タブレットでも同様です。横解像度1920のnexus5xでGPU負荷が半分になりました(

動的に3D部分の解像度を変える

オプションのクオリティ設定に応じてスケールかけるようにしました。一番修正工数が少ないからそうしたのですが、専用オプションでユーザーに設定させるでもよかったかなと思いました。

ポストプロセス

ON/OFFくらいは入れてもよかったかもしれないです

OcclusionCulling

学園内でオブジェクト数を大幅に減らせました。窓はもともと不透明なので学園内に入った時かなり効果的です

レイヤーカリング

机やいすなどの小物は壁に比べて早く消えてほしかったので専用レイヤーにし早めにカリングされるようにしました。効果的です