sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

Unity アセット管理 Addressables の基本的な考えと使い方

f:id:shikaku_sh:20210205155735p:plain

画像などのアセットデータをプロジェクトに追加して利用する方法として、以下のような手法が主に利用されると思います。

  • Resources
  • Asset Bundle
  • Addressables

Resources はお手軽な手法でアセットを追加できる最も手っ取り早い構築パターンで、初心者向けの参考書で利用される定番のリソース管理手法です。しかし、Resources フォルダーはそれなりのゲームを作成するとなると、もはやあまり推奨される手法ではありません。

以下の公式チュートリアルで解説があります(3章の内容です):

Don't use it. と強いメッセージが書いてあります。

Resources がダメが理由は重要なので、個人的に読んだときにメモした内容を(ミスがあるかもしれませんが)公開しています:

そうなると、AssetBundle か Addressables という話になるのですが、 歴史的には Asset Bundle のつぎに Addressables が登場しています。しかも Addressables は内部の仕組みに AssetBundle を利用しているので、まったくの別物というわけではないです。このあたりは、公式ブログの記事にて:

結論として 2021 年の現在は AssetBundle を管理するツールであった AssetBundle Manager が非推奨になったことから、Addressables がベターな選択になりそうです。

Addressables の難しいというのかややこしいところは、Unity のリソース管理のテクニックにも歴史が出来ていて、その歴史の中で新しい手法だ、ということ。なんで、過去の手法の欠点を補うものだったりするので、強みになっている部分の設計は丁寧/複雑な感じです。

このあたりを整理/勉強した内容をメモした記事です。

Addressables の仕組み is なに?

Addressables もリソース管理の仕組みのひとつなので「アセットを保存してカタログ化して、あとから検索して呼び出せる」機能です。

リソース管理の仕組みとして、Addressables の重要なポイントは:

  • 読み込まれるアセットの内容
  • アセットの読み込み元
  • アセットの読み込み方法

この3つを分離することです。この拡張機能「Addressables」は、どんなアセットでも Addressables(アドレス可能)なマーク(固有の名前)を与えること、というのが一番の特徴になっています。これは「関心の分離」と説明されています。

関心の分離が何をしたいのかというと、コードを修正・変更することなく、コンテンツ(アセット)のパッケージングと配布をやりやすくすること、開発の後期までパッケージデータを保留できるようにしたい、ということです。

過去の手法は、物理的なパッケージの都合(影響)を受けやすかったということ。

ともかく「分離」というのが Addressables のキーワードになりそうです。具体的には、コンテンツをアプリのインストールからも分離できるので、アプリ公開時の OTA (over-the-air) の制限を受けずに、公開済のアプリへ定期的なコンテンツ更新をかけることができるようです。

基本的な考え方は「Addressableアセットシステム入門 - 3つのメリットと基本的な使い方を紹介」の記事がわかりやすいと思いました。

Addressables の使い方

f:id:shikaku_sh:20210322125633p:plain:w600

Package Manager から Addressables をインストールします。

追加するとインスペクターウィンドウの一番上に Addressable というパラメーターが追加されます。これが「固有の名前」となるアドレスです。

f:id:shikaku_sh:20210322125549p:plain:h600

デフォルトだと、アセットのパスが設定されていますが、名前空間のような規則性をつけて再設定したほうがよいと思います。

アドレスをつけたアセットは Addressables の管理用ウィンドウで確認することができます。

  • Window > Asset Manager > Addressables > Groups

f:id:shikaku_sh:20210322125308p:plain

一番重要なのは、ウィンドウの最初の列 Group Name \ Addressable Name に表示された「固有の名前」となるアドレスです。

f:id:shikaku_sh:20210322125331p:plain:w600

Addressables は、具体的な名称である AssetBundle の名前やファイルパスではなくて、アドレスを介してアセットをロードします。(なので、アドレスをデフォルトのまま、アセットパスにするのはイマイチ特徴を活かしきれないと思います)

テスト

Sprite を読み込む一例です。

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.UI;

namespace Sample
{
    public class ImageAddressaableLoader : MonoBehaviour
    {
        [SerializeField]
        private Image Image;

        [SerializeField]
        private AssetReference _TestCharactor;

        [SerializeField]
        private AssetLabelReference _LabelTest;

        private void Start()
        {
            Addressables.LoadAssetAsync<Sprite>(_TestCharactor).Completed += sprite =>
            {
                Image.sprite = sprite.Result;
            };

            Addressables.LoadAssetsAsync<Sprite>(_LabelTest, null).Completed += sprites =>
            {
                foreach (var sprite in sprites.Result)
                {
                    Debug.Log(sprite.name);
                }
            };
        }
    }
}

Addressables.LoadAssetAsync<Sprite>(_TestCharactor) の部分は _TestCharactor ではなくて、「アドレス」をテキストで直接指定してもよいです(たとえば "images.sample1" のように)が、AssetReference を介することでインスペクターウィンドウからアセットを設定しておけます。コード指定かインスペクター指定かの違いってだけですね。

f:id:shikaku_sh:20210322125434p:plain

機能として、デフォルトで Async 読み込みできるのは便利なんだけど、通常の破棄ではないので注意が必要。

使い終わったときに Addressables.Release() で開放しないとダメ。これは、アセットの開放を EventViewer で確認したほうが正確です。あとの章で Viewer を取り上げます。

開放しないとダメっていうのは、シーンを切り替えたときにリソースの開放ができているかどうかをチェックしてみました。(後述の「ロード/アンロードを可視化する」を参照)

Event Viewer

Addressables は専用の Viewer を持っています。ただ、この Viewer はプレビュー時代に名前が RM Profiler と呼ばれている時期があったみたいで、情報がすこしややこしくなっているので注意が必要です。

f:id:shikaku_sh:20210322125228p:plain:w600

利用するために以下の設定が必要です:

  • AddressableAssetSettings 「Send Prfiler Events」を ON
  • Addressable/Groups から「Play Mode Script」を Simulate Groups に変更

f:id:shikaku_sh:20210322095751p:plain:w600 f:id:shikaku_sh:20210322125957p:plain:w600

設定の手順をとばすと、Viewer には何も表示されません。

ロード/アンロードを可視化する

Event Viewer を利用するケースの1つとして、シーンが切り替わったときに正しくアセットが開放されているのかチェックをすることができます。

f:id:shikaku_sh:20210322095124g:plain:w600
開放のチェック

図は、アセットの開放ができていないため、 reset.png という画像の参照数が重なっていくことがわかります。このあたりが Release() メソッドでアセットを開放しなければいけないポイントとしてチェックすることができます。

Viewer の練習として:

  • 参照カウント数の確認
  • いつロード/アンロードされたかを可視化する

このあたりから始めるとよいと思いました。

徐々にアプリが重たくなる要因として、アセット(メモリー)の管理が不十分だった、というのは Unity に限らず DirectX 開発時代よりも前、ポインターから続くような古典的ミスです。十分に注意しておいて損はなさそう。

参考

Unity Assets, Resources and AssetBundles の記事(雑訳)

f:id:shikaku_sh:20210205155735p:plain

この記事は Unity Learn「Assets, Resources and AssetBundles」の内容を個人的に学習したときのメモを公開しています。

AssetBundle はすこし古いテクニックになっていますが、Resources は今でも初心者向けの教科書では常用されているかと思います。このあたりのリソース管理について、基本的な理解をするのに役立ちそうな1~3章を(おかしなところがあると思うけど、ざっくりと)和訳。

4、5 章の AssetBundle の解説が抜けていますので、そっちも気になるなら元記事参照のこと。

1. AsesetBundle と Resources のガイド

Unity エンジンにおけるアセットとリソース管理についての突っ込んだ討論を提供する連載記事です。

エキスパート開発者に Unity のアセットとシリアライズについて深いレベルのノウハウを提供することを目的としています。Unity の AssetBundle システムとそれを採用するための現在のベストプラクティスの両方を検証します。

ガイドは4つの章に分けれています:

  • Assets, Objects and serialization
    Unity がアセットをどのようにシリアライズしてアセット間の参照をハンドルするか low レベルな詳細を検討します。この章は本ガイドを通して使用される用語が定義されているため、この章からはじめることを強くオススメします。
  • Resources folder
    ビルドインの Resources API について説明します。
  • AssetBundle Fundamentals
    1章の話を基にして、AssetBundle がどのように動作するのか、AssetBundle のロード、AssetBundle からアセットをロードする両方のやり方を説明します。
  • AssetBundle usage patterns
    AssetBundle の実用的な使用方法についていろいろなトピックを検討した長い記事です。AssetBundle のアセット割り当て、ロードされたアセットの管理についてなど、AssetBundle を使用している開発者がおちいる共通した落とし穴の多くについて説明します。

Note: 本ガイドのオブジェクトとアセットの用語は Unity の public API命名規則と異なることがあります。

このガイドでオブジェクトと呼ぶデータは、AssetBundle.LoadAsset や Resources.UnloadUnusedAssets など Unity の public API では Assets と呼ばれています。本ガイドで Assets と呼ぶファイルが、public API でそのように表現されることは稀です。

通常、AssetDatabase や BuildPipeline などのビルド関連のコード内でのみです。それらの場合、これらファイルは public API ファイルと呼ばれます。

2. Assets, Objects and serialization

この章では Unity のシリアライズの内部と Unity エディターとランタイムの両方で Unity が異なるオブジェクト間で強固に参照を維持する方法について説明します。

また、オブジェクトとアセットの技術的な区別についても説明をします。トピックは、Unity でアセットを効率的にロード・アンロードする方法の基礎をカバーします。適切なアセット管理は、ロード時間を短縮しメモリー使用量を低下させるために重要です。

2.1 アセットとオブジェクトの内部

Unity でデータを適切に管理する方法を理解するためには、Unity がどのようにデータを認識しシリアライズするのかを理解することが重要です。最初のポイントは AssetsUnityEngine.Objects の区別です。

Assets とは、Unity の Assets フォルダーの中に保存されているディスク上にあるファイルのことです。テクスチャ、3Dモデル、Audio Clip などがアセットの一般的な型です。アセットの中には material などの Unity のネイティブなフォーマットのデータを含んでいるものもあります。その他のアセットは、FBX ファイルのようにネイティブなフォーマットに処理する必要があります。

UnityEngine.Object(大文字の「O」がついたオブジェクト)は、特定のインスタンスをまとめて記述したシリアル化されたデータセットです。これは、mesh, sprite, AudioClip, AnimationClip など、Unity Engine が使用するあらゆるタイプのリソースです。すべてのオブジェクトは UnityEngine.Object 基底クラスのサブクラスになります。

ほとんどのオブジェクトの型はビルドインされていますが、2つの特別な型があります。

  • ScriptableObject
    開発者が独自のデータ型を定義するために便利な機能を提供します。Unity によってネイティブにシリアライズ・デシリアライズされ、また、Unity エディターのインスペクターウィンドウで操作することができます。
  • MonoBehaviour
    MonoScript にリンクするラッパーを提供します。MonoScriptは、Unity が特定のアセンブリやネームスペース内の特的のスクリプトクラスへの参照を保持するために使用する内部のデータ型です。MonoScript は実際の実行コードは含まれていません。

アセットとオブジェクトの間には「1対多」の関係があり、アセットファイルには1つ以上のオブジェクトが含まれます。

2.2 オブジェクト間の参照

すべての UnityEngine.Object は、他の UnityEngine.Object を参照することができます。他のオブジェクトらは、同じアセットファイル内に含まれることや、他のアセットファイルからインポートされる場合もあります。

たとえば、マテリアルのオブジェクトは、通常だとテクスチャのオブジェクトに参照を1つ以上持っています。これらのテクスチャオブジェクトは、1つ以上のテクスチャのアセットファイル(PNG, JPG など)からインポートされいます。

シリアライズされるとき、これらの参照は、「File GUID」「Local ID」という2つの別々のデータから構成されます。

  • File GUID
    ターゲットリソースが格納されているアセットファイルを識別します。
  • Local ID アセットファイルには複数のオブジェクトが含まれている場合があるので、アセットファイル内の各オブジェクトを識別します。

ローカル ID は、同じアセットファイルの中で他のすべてのオブジェクトのローカル ID において一意です。

File GUID は、.meta ファイルに格納されます。これらの .meta ファイルは Unity がアセットを最初にインポートするときに生成され、アセットと同じディレクトリーに格納されます。

fileID = local identifier in file。どっちも meta ファイルに含まれているはずです。

上述の識別・参照の仕組みは、テキストエディターでも確認することができます:

  1. 新しい Unity プロジェクトを作成して、エディター設定を変更
  2. Visible Meta Files を有効化(外部のバージョン管理システムのサポートを有効化します)
  3. アセットをテキストとしてシリアライズ
  4. マテリアルを作成してプロジェクトにテクスチャをインポート
  5. マテリアルをシーンのキューブに割り当ててシーンを保存

テキストエディターを使用してマテリアルに関連づけられた .meta ファイルを開きます。ファイルの上のほうに "guid" Local ID を見つけるには、テキストエディターでマテリアルファイルを開きます。マテリアルオブジェクトの定義は以下のようになっています:

f:id:shikaku_sh:20210309184357p:plain
サンプル

--- !u!21 &2100000
Material:
 serializedVersion: 3
 ... more data …

上述の例では、「&」ではじまる数字はマテリアルの Local ID です。このマテリアルのオブジェクトがファイル GUID「abcdefg」で識別されているアセット内にあったなら、マテリアルのオブジェクトはファイル GUID「abcdefg」+Local ID「2100000」の組み合わせで一意に識別されます。

2.3 なぜファイル GUID とローカル ID か?

なぜ Unity の File GUID と Local ID の仕組みが必要なのでしょうか? その答えは、堅牢性とプラットフォームに依存しない柔軟なワークフローを提供するためです。

File GUID は、ファイルを特定する位置の抽象化を提供します。File GUID が具体的なファイルに関連づけられる限り、そのファイルはディスク上の位置と無関係です。ファイルを参照しているすべてのオブジェクトを更新することなく、ファイルを自由に移動させることができます。

アセットファイルには複数の UnityEngine.Object リソースが含まれている(あるいはインポートで生成される)可能性があるので、各オブジェクトを明確に区別するためにローカル ID が必要になります。

アセットファイルに関連づけられた File GUID が失われると、そのアセットファイル内のすべてのオブジェクトへの参照も失われます。このため .meta ファイルは関連するアセットファイルと同じフォルダーに同じファイル名で保存しておくことが重要です。Unity は削除・配置に失敗した .meta ファイルを再生成することに注意します。

Unity エディターは、既知の File GUID から具体的なファイルパスへのマップがあります。アセットをロードまたはインポートするたびにマップエントリーが記録されます。マップエントリーは、アセットの具体的なパスとアセットの File GUID をリンクしています。

Unity エディターを開いている間に、アセットのパスを変更せずに、.meta ファイルが失われたときは、エディターはアセットが同じ File GUID を保持しています。

Unity エディターを閉じている間に、.meta ファイルが失われた場合、アセットのパスが .meta ファイルと一緒に移動させなかった場合、アセット内のオブジェクトへの参照はすべて破棄されます。

2.4. 合成アセットとインポーター

「アセットとオブジェクトの内部」のセクションで述べたように、ネイティブではないアセット型は Unity にインポートする必要があります。これはアセットインポーターを介して実行されます。これらのインポーターは通常では自動的に呼び出されますが、AssetImporter API を介するスクリプトも公開されています。

たとえば、TextureImporter API は、PNG ファイルなどの個々のテクスチャアセットをインポートする際に使用する設定にアクセスすることができます。

インポート処理の結果、1つまたは複数の UnityEngine.Objects が生成されます。たとえば、スプライトアトラスとしてインポートされたテクスチャのアセットの中で入れ子になっている複数のスプライトなどは、親アセットの中にある複数のサブアセットとして Unity エディターに表示されます。

これらのオブジェクトは、ソースデータが同じアセットファイル内に保存されているためファイル GUID を共有しますが、インポートされたテクスチャアセット内で Local ID によって区別されます。

インポートの過程では、ソースアセットを Unity エディターで選択されたターゲットプラットフォームに適した形式に変換します。(テクスチャの圧縮など、重たい操作の多くを実行できます)ここは時間のかかるプロセスが多いので、インポートされたアセットはライブラリーフォルダーにキャッシュされるので、次回のエディター起動時にアセットを再インポートする必要がなくなります。

具体的には、インポートのプロセスの結果はアセットの File GUID の最初の2桁の数字に対応するフォルダーに保存されます。このフォルダーは Library\metadata\ の中に保存されます。アセットの個別オブジェクトは、アセットの File GUID と同じ名前を持つ単一のバイナリファイルとしてシリアライズされます。

このプロセスは、非ネイティブアセットに限らず、すべてのアセットに適用されます。ネイティブアセットは、長い変換処理や再シリアライズを必要としません。

2.5 シリアライズインスタンス

File GUID と Local ID は堅牢ですが、GUID の比較は時間がかかり、実行時にはよりパフォーマンスの高いシステムが必要になります。Unity は File GUID と Local ID を単純なセッション固有の整数に変換するキャッシュを内部的に維持しています。(このキャッシュは内部的に PersistentManager と呼ばれています)これらの整数は Instance ID と呼ばれ、新しいオブジェクトがキャッシュに追加されると単純な一本調子で増加する番号を割り当てられます。

キャッシュはオブジェクトのソースデータの位置を定義する Instance ID、File GUID, Local ID とメモリ上のオブジェクトのインスタンス(存在する場合)の間のマッピングを保持します。これにより、UnityEngine.Objects はお互いの参照を強固に維持することができます。

Instance ID の参照を解決すると、その Instance ID で表されるロードされているオブジェクトを素早く返却することができます。対象のオブジェクトがまだロードされていないときは、File GUID と Local ID でオブジェクトのソースデータを解決することで、ジャストインタイム(必要なものを、必要なときに、必要なぶんだけ)ロードすることができます。

AssetBundle のアンロードの意味については「AssetBundle Usage Patterns」ステップの「Managing Loaded Assets」セクションを参照してください。

プラットフォームによっては、特定のイベントによってオブジェクトが強制的にメモリーからアンロードされることがあります。たとえば iOS では、アプリがサスペンドされると画像のアセットがグラフィックメモリーからアンロードされることがあります。

これらのオブジェクトがアンロードされた AssetBundle に由来する場合、Unity はオブジェクトのソースデータをリロードすることができませんし、オブジェクトへの既存の参照も無効になります。たとえば、シーンにメッシュやテクスチャが見えないように表示されるかもしれません。

実装上の注意として、実行時には、上述の制御フローは文字通り正確ではないです。実行時に File GUID と Local ID を比較しても十分なパフォーマンスは得られません。Unity プロジェクトを構築する際には、File GUID と Local ID はより単純な形式でマッピングされます。しかし、概念は変わらないので、File GUID と Local ID の観点であれば、実行時の有用なアナロジーであることに変わりありません。(これは、アセットの File GUID は実行時に問い合わせできない理由でもあります)

MonoScript

MonoBehaviour は MonoScript への参照を持ち、MonoScript は特定のスクリプトクラスを見つけるために必要な情報を含んでいるだけである、と理解することが重要です。どちらのオブジェクトにもスクリプトクラスの実行できるコードは含まれていません。

MonoScript には3つの string が含まれています:

プロジェクトのビルド中に Unity は Assets フォルダー内のすべての自由なスクリプトファイルを Mono アセンブリーにコンパイルします。Plugins フォルダー外の C# スクリプトAssembly-CSharp.dll に配置されます。Plugins サブフォルダー内のスクリプトAssembly-CSharp-firstpass.dll に配置されます。さらに Unity 2017.3 では「Assembly Definition」の機能が導入されています。 これらのアセンブリーはビルド済のアセンブリ DLL ファイルと同様に Unity アプリケーションの最終ビルドに含まれます。これらのアセンブリーは MonoScript が参照するアセンブリーでもあります。他のリソースとは異なり Unity アプリケーションに含まれるすべてのアセンブリーはアプリケーションの起動時にロードされます。

MonoScript オブジェクトは、AssetBundle(または Scene や Prefab)が AssetBundle、Scene、プレハブのいずれの MonoBehaviour コンポーネントにも実行可能なコードを含まない理由になっています。これにより、MonoBehaviour が異なる AssetBundle にある場合でも異なる MonoBehaviours が特定の共有クラスを参照することができます。

2.7 リソースのライフサイクル

ロード時間を短縮し、アプリケーションのメモリーのフットプリントを管理するためには UnityEngine.Objects のリソースのライフサイクルを理解することが重要です。オブジェクトは特定の時間にメモリー上にロード/アンロードされたりします。

オブジェクトは次のようなときに自動的にロードされます:

  1. オブジェクトにマッピングされた Instance ID が参照された
  2. オブジェクトがメモリーに現在ロードされていない
  3. オブジェクトのソースデータを配置することができる

オブジェクトはスクリプトで明示的にロードすることもできます。オブジェクトを作成するか Resource-Loding API(AssetBundle.LoadAsset など)を呼び出してロードします。オブジェクトがロードされると、Unity は各参照の File GUID と Local ID を Instance ID に変換することで参照の解決をテストします。

最初に Instance ID が参照されたとき、2つの基準が true ならオブジェクトはオンデマンド(ユーザー要求があった際にその要求に応じてサービス提供をすること)でロードされます:

  • Instance ID は、現在ロードされていないオブジェクトを参照している
  • Instance ID はキャッシュに登録されている有効な File GUID と Local ID がある

通常だと、参照自体がロード/解決した直後に発生します。

File GUID と Local ID が Instance ID を持たない、または、ロードされていないオブジェクトの持つ Instance ID が無効な File GUID と Local ID を参照している場合(参照は保存されますが)実際のオブジェクトはロードされません。

Unity エディターの画面では (Missing) と表示されます。実行中のアプリケーションやシーンビューでも (Missing) と表示されます。オブジェクトは、そのタイプに応じて異なる表示になります。たとえば、メッシュは見えないように表示され、テクスチャはマゼンタの単色になって見えるかもしれません。

オブジェクトは3つのシナリオでアンロードされます:

  • 未使用のアセットのクリーンアップが発生すると、オブジェクトは自動的にアンロードされます。このプロセスは、シーンに破壊的な変更があったとき(SceneManager.LoadScene が呼び出されたときなど)、または、スクリプトResources.UnloadUnusedAssets API を呼び出したときに自動的にトリガーが発生します。
    このプロセスは参照されていないオブジェクトのみをアンロードします。オブジェクトは Mono 変数がオブジェクトへの参照を保持しておらず、オブジェクトへの参照を保持している他のオブジェクトが存在しない場合にのみアンロードされます。
    さらに、HideFlags.DontUnloadUnusedAsset および HideFlags.HideAndDontSave がマークされたものはアンロードされません。
  • Resources フォルダーから取得したオブジェクトは、Resources.UnloadAsset API を呼び出すことで明示的にアンロードすることができます。これらのオブジェクトの Instance ID は有効なままなので、有効な File GUID と Local ID が含まれますが、Mono 変数などのオブジェクトが Resources.UnloadAsset でアンロードされたオブジェクトへの参照を保持している場合、そのオブジェクトは参照のいずれかが逆参照されるとすぐに再ロードされます。
  • AssetBundle.Unload(true) API を呼び出すと AssetBundle からソースを取得したオブジェクトは自動的にアンロードされます。これによって、オブジェクトの Instance ID の File GUID と Local ID が無効になり、アンロードされたオブジェクトの参照は (missing) になります。C# スクリプトからアンロードされたオブジェクト、メソッド、プロパティにアクセスすると NullReferenceException が発生します。

AssetBundle.Unload(false) が呼び出されたときは、アンロードされた AssetBundle からソースを取得したオブジェクトは破棄されませんが、Unity はその Instance ID の File GUID と Local ID 参照を無効にします。オブジェクトが後でメモリーからアンロードされ、アンロードされたオブジェクトの参照が残っている場合、Unity はオブジェクトをリロードすることができません。

注意:実行時にオブジェクトがアンロードされずにメモリーから削除される最も一般的なケースは、Unity がグラフィックコンテキストの制御を失ったときに発生します。これは、モバイルアプリがサスペンドされ、アプリが強制的にバックグラウンドに移行されたときに発生することがあります。この場合、モバイル OS は、GPUモリーからすべてのグラフィックリソースを削除してしまいます。アプリがフォアグラウンドに戻ってきたときに、シーンのレンダリングを再開するまえに、Unity は必要なすべてのテクスチャ、シェーダ、メッシュを GPU に再ロードしなければいけないです。

2.8 大きな階層を持つゲームオブジェクトのローディング

Prefab のシリアライズのように Unity のゲームオブジェクトの階層をシリアル化するとき、階層全体が完全にシリアライズされることになる、と理解することは重要です。つまり、階層内のすべての GameObject とコンポーネントはシリアル化されたデータで個別に表現されるので、GameObject の階層をロードしてインスタンス化するために必要な時間に重要な影響を与えます。

任意の GameObject の階層を作成する場合、CPU 時間はいくつかの異なる事情で消費されます:

  • ソースデータの読み込み(ストレージ・AssetBundle・別の GameObject などから)
  • 新しい Transform 間の親子関係を設定する
  • メインスレッドに新しい GameObject とコンポーネントを目覚める

後ろ3つの時間のコストは、(階層が既存の階層からクローン化されているか、ストレージからロードされているかに関わらず)一般的に不変です。しかし、ソースデータの読み込みにかかる時間は、階層にシリアル化されたコンポーネントや GameObject の数に比例して直線的に増加し、データソースの速度にも乗算されます。

現在のすべてのプラットフォームでは、ストレージデバイスからデータを読み込むよりもメモリー内の場所からデータを読み込むほうが高速です。さらに、利用可能な記憶媒体の性能特性はプラットフォームごとに大きく異なります。なので、ストレージが遅いプラットフォーム上で Prefab をロードする場合、ストレージから Prefab のシリアル化されたデータを読みだすために消費される時間は、Prefab をインスタンス化するために消費される時間を上回ることがあります。つまり、ロード操作の時間コストはストレージの I/O 時間に拘束されます。

前に述べたように、モノリシック(巨大な)Prefab をシリアル化する際には、GameObject やコンポーネントのデータをそれぞれシリアル化するため、データが重複している可能性があります。たとえば、30 個の同じ要素を持つ UI 画面では、同じ要素が 30 回シリアル化され、バイナリデータの巨大な blob が生成されます。

ロード時には、それら 30 子の重複要素のそれぞれにある GameObject とコンポーネントのすべてのデータをディスクから読み込んでから、新しくインスタンス化されたオブジェクトに転送しなければいけません。このファイルの読み込み時間は、大きな Prefab のインスタンス化の全体的な時間コストに大きな影響を及ぼします。大規模な階層では、モジュラーをチャンク単位でインスタンス化して、実行時に繋ぎ合わせる必要があります。

Unity 5.4 では、メモリー内の変換の表現が変更されました。各ルートの Transform の子階層全体がメモリーの連続したコンパクトな領域に格納されるようになりました。別の階層にすぐ交換するような新しい GameObject のインスタンスを作成する場合は、親引数を受け入れる新しい GameObject.Instantiateオーバーロード (overloaded variants) の使用を検討してください。このオーバーロードを使用することで、新しい GameObject のルート変換の階層割り当てを回避することができます。テストでは、これによりインスタンス化処理に要する時間が 5-10% 程度短縮しました。

Resources folder

この章では Resources の仕組みについて説明します。これは開発者が Resources という名前のフォルダー内にアセットを保存して実行時に Resources API を使用してアセットからオブジェクトをロード/アンロードできる仕組みのことです。

3.1 Resources を使った仕組みのベストプラクティス

使わない。(Don't use it.)

使わないことを強く推奨するには、いくつかの理由があります:

  • Resources フォルダーを使用すると、細かいメモリー管理が難しくなる
  • Resources フォルダーを不適切に使用すると、アプリケーションの起動時間とビルドの時間が長くなります
    • Resources フォルダーの数が増えると、アセットの管理がとても難しくなります
  • Resources の仕組みは、特定のプラットフォームでコンテンツを配信する能力を低下させ、また、コンテンツをアップデートで追加する余地を排除します
    • AssetBundle はデバイスごとにコンテンツを調整する Unity の主要なツールです

3.2 Resources の仕組みの適切な利用

適切な開発プラクティスの邪魔をせずに Resources の仕組みが役立つ可能性がある具体的なユースケースは2つあります:

  • Resources フォルダーは使い勝手がよいので、手早くプロトタイプを作るために優れた仕組みです。しかしながら、プロジェクトが本格的な開発に移ったときは、Resources フォルダーの使用を排除する必要があります。
  • Resources フォルダーは、コンテンツの内容がすくない場合には有用なことがあります:
    • 通常、プロジェクトのライフタイムを通じて必要
    • コンテンツのパッチ追加をしない、または、プラットフォームやデバイスに違いがない
    • ミニマムなブートストラップに使用する(アプリケーションを起動してから利用可能な状態になるまでの処理で利用すること?)

2つ目のケース例を挙げると、Prefab をホストするために使用される MonoBehaviour (singleton) や、FaceBook App ID などのようなサードパーティの設定データを含む ScriptableObject です。

3.3 Resources のシリアライズ

プロジェクトがビルドされると Resorces という名前のフォルダーにあるアセットとオブジェクトは、1つのシリアル化されたファイルにまとめられます。このファイルには、AssetBundle と同じでメタデータとインデックスの情報が含まれています。

AssetBundle のドキュメントで説明されているように、このインデックスにはシリアル化されたルックアップの木構造(lookup tree)が含まれているので、これを使用してオブジェクトの名前から File ID と Local ID を解決します。また、シリアル化されたファイルの中の特定のバイトオフセットからオブジェクトを見つけるためにも使用されます。

ほとんどのプラットフォームでは、ルックアップのデータ構造は平衡二分探索木であり、構築時間は O(log({n}) ) のレートで大きくなります。これは、Resources フォルダー内のオブジェクトの数が増えると、読み込み時間は直線的異常に大きくなる原因です。

この時間はスキップできず、アプリケーションの起動時となる最初のスプラッシュ画面を表示されている間に発生します。10,000 個のアセットを含む Resources フォルダーに含まれるオブジェクトのほとんどは最初のシーンで必要となることはありませんが、Resources の仕組みの初期化では、アセットをロードをするために(ローエンドのモバイルデバイスでは)数秒の時間を消費することが確認されています。

あとの2章は、AssetBundle についての記事なので割愛しています。

参考

Unity ゲームエフェクト マスターガイド

Unity ゲームエフェクト マスターガイド

  • 作者:秋山 高廣
  • 発売日: 2019/07/19
  • メディア: 単行本(ソフトカバー)

UnityC#ゲームプログラミング入門 2020対応

UnityC#ゲームプログラミング入門 2020対応

Unity MVP パターンを利用した UI 設計

f:id:shikaku_sh:20210205155735p:plain

この記事は torisoup さんの「UniRX/UniTask 完全理解」P318 の MVP パターンについて個人的に学習内容をメモする記事です。

MVP パターン is なに?

UI を実装するときの実装パターンです。WPF だと MVVM とかを利用しているやつです。

Virtual Cast の記事だと、「クラスの責務や依存関係が整理される」みたいな話からになっていますが、あくまで目的は「UI を実装するときの実装パターン」のはずです。

UI を実装するときに、実装を3つのタイプで役割を分類します:

  • Model「UI に表示するデータの実体(クラス)」
  • View「uGUI コンポーネントってことでいい(見た目)」
  • Presenter「Model と View の仲介(クラス)」

相互参照を避けるために M と V は P を知らず、P は M と V を知っています。理屈では、バインディングの仕組み(が無いのにそれ)をやる以上、どこかでだれかが V を知っている必要があって(View の更新をするために)、そのために Presenter という概念が用意されています。

この結果、それぞれの役割が整理されて「コードの保守性・生産性を保つ」「改変(変更)に対しての柔軟性を保つ」ようにしようね、という設計デザイン。

Model は POCO なオブジェクトか?

Model は Plain Old CLR Object (POCO) じゃないとモヤッとする、というような記事もありました。(MVP の考え方についての記事です)

MVPはMonobehaviourの依存から解き放たれた設計ができるのに、ModelやPresenterも全てMonobehaviourに依存してしまっているのが勿体無いと思いました。(記事中より)

Unity の Model(M) は特定のフレームワーク、大体は UniRx と結婚しています。(ReactiveProperty を使うわけで)なので、その時点で、Model は Unity に依存したゲームオブジェクトだと思っておいて差し支えないと思います。なんで、いまさら Monobehaviour がどうだと話をしてもという気がしますし、そもそも Presenter はそういう役としか。(下図は「CA.Unity #1 - Unityにおける設計パターン」)

おそらく(上の「どれが正しいんだ?」記事で暗に)意図しているのは「Unity の API」と「C# の言語機能」で切りかけて設計をしたいようにも思います。(思いました)



でも実際は大体こうなると思う。


ハイブリッドなやり方として、左と右にわけると:

  • 左辺は Unity 領域。疎結合な Game Object が書いてある。
  • 右辺はゲームロジック。ロジックなので、POCO でピュアな C# で書いてある。

そんなわけで、MVP の基本要素は左辺中心ってことでいいと思う。ピュアなゲームロジックは ReactiveProperty で書きませんし必要としないです。ピュアなゲームロジックを提供する部分をライブラリーなんかにするのはよいと思う。(けど、M は UI に表示するデータの実体なので、UI を構成するデータ同士は疎結合になっても、Unity のフレームワークとは結婚せざるをえないのではないかと)

neuecc さんのモデル例(enemy)もライブラリーと密です。(文中の内容だと「Model自体はPresenterにも依存しないし、Viewは知らない。ただしViewまで伝搬するため通知は可能でなければならない。」)そもそもこの種の手法はクラスの数が結構と増える設計なんで、M をゲームロジックのクラスじゃなくてもいいじゃん、ってなる。無駄にインターフェースを定義するのだ、ってなる。

より疎結合化を図ると、M は V だけではなくゲームロジックとも密結合にせずに、画面で必要になるパラメーターをインターフェース経由で取得するようと思うかも。でも、変更に対してある程度の柔軟さがあるなら、その必要の有無は考えるところ。実際的に、なにが正しいのかという答えに唯一神はいない。


MVP は「UI を実装するときの実装パターン」というところから、(すくなくとも個人的には)シンプルなオブジェクトをモデルの基本で考えることは(できるのかもしれないけど)拡張性に乏しく難しいと思うし、パターンとするにはあまりよい対応ではない印象です。

このあたりは WPF なんかの MVVM でもそうだし、モデルはモデルとして設計することになると思います。

サンプル

f:id:shikaku_sh:20210301120652p:plain
サンプルコード

個人的にもサンプルをコードに起こしましたが、一応公開しないようにします。

参考

Unity アプリ名、アイコン、スプラッシュスクリーンなどアプリ開発の初期設定まとめ(Android)

アプリを作成するときに最低限やったほうがいいと思った設定をまとめます。(Android 対応)

アプリの名称の設定

f:id:shikaku_sh:20210224161340p:plain:w600

メニュー「Edit > Project Settings > Player」をカスタマイズすることで変更することができます。

うしろのほうにコードから設定する方法も記載しています。

デフォルトの名前空間設定

f:id:shikaku_sh:20210224161450p:plain:h500

開発では、ついでに 「Editor > C# Project Generation > Root namespace」も設定しておこう。

名前空間のないプログラムは、外部パッケージの追加で支障をきたす問題や、コードを再利用するときにも面倒をおこす恐れがあります。というか、「ダメ絶対」でいいと思います。

アイコンの設定(Android

現在のアイコンは Adaptive Icons に対応しているほうがよいようです。メニュー「Edit > Project Settings > Player > Android > Adaptive Icons」をカスタマイズすることで、アイコンを変更することができます。

f:id:shikaku_sh:20210224161831p:plain

設定するアイコンは xxxhdpi (432x432px) に対応するところからです。それよりも小さいサイズ対応の必要は必須ではありません。(自動的に縮小表示もできる)

とりあえず、正しく設定できているかどうかは「テスト」しながら進めたほうがよいです。アイコンは背景サイズいっぱいに描くことができません。個人的な感覚ですが、432x432 の場合は 320x320 くらいのサイズ感だと思いました。背景画像の 74~70% くらいかと。

f:id:shikaku_sh:20210224161655p:plain:w300f:id:shikaku_sh:20210224161659p:plain:w300
432x432 に収まる画像でもアイコン内に収まらない例

もしも、アイコンがぼやける場合はアイコンの Texture Type の設定を「Editor GUI and Legacy GUI」にする、という記事もありました。先に Filter Mode や Format の設定値を確認したほうがよいかもしれないです。

スプラッシュスクリーンの設定

Unity の無料版 (Personal) だとスプラッシュスクリーン表示は OFF にできませんが、カスタマイズができるようです。(Unity 5.5 Beta3 より)

メニュー「Edit > Project Settings > Player > Player > Splash Image」をカスタマイズすることで、見た目を変更することができます。

f:id:shikaku_sh:20210224161912p:plain:w500

Preview ボタンを押下すると、Unity 上でスプラッシュスクリーンのプレビューをすることができました。手早くチェックできるので便利。

f:id:shikaku_sh:20210224162755g:plain:w600
スプラッシュスクリーンのサンプル

スプラッシュスクリーンのオプションは Unity DOCUMENT によくまとまっているのでそちらを参照。

f:id:shikaku_sh:20210224162103p:plain:w300f:id:shikaku_sh:20210224162108p:plain:w300
動作サンプル

アプリケーションのコードビルド

自動化や利便性のために、コードから Unity のビルドができるように(apk ファイルを作成できるように)しておきます。

メニューからすぐにビルドすることもできるようになるし、ビルドしたファイルを指定パスに出力できる。しかも、ビルド設定のパターンをメソッド単位でいくつも作り置きすることができるので便利です。

細かいところでは、アプリファイルをビルドしたあとにコピーして、配布する・バックアップする、というようなニーズにも対応できるので準備しておいて損がない。

サンプルプログラムレベルだと、DropBox にファイルコピーするコードを追加しておくと便利です。

f:id:shikaku_sh:20210224161946p:plain
メニューからビルドできるのも便利

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEditor;

namespace Sh1ch.Editor
{
    public class AppBuilder
    {
        private const string OUTPUT_DIR = "";

        [MenuItem("Build/Android")]
        public static void BuildAndroid()
        {
            // ビルドに含めるシーン選択
            if (EditorUserBuildSettings.activeBuildTarget != BuildTarget.Android)
            {
                EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.Android, BuildTarget.Android);
            }

            var targetScenes = GetBuildScenes().ToArray();
            var buildOption = GetBuildOption();

            // アプリの最低限の設定をコードでする場合
            PlayerSettings.companyName = "sh1";
            PlayerSettings.productName = "app-name";
            PlayerSettings.SetApplicationIdentifier(BuildTargetGroup.Android, $"com.{PlayerSettings.companyName}.{PlayerSettings.productName}");

            // スプラッシュスクリーンの設定
            // PlayerSettings.SplashScreen.show = false;
            // PlayerSettings.SplashScreen.showUnityLogo = false;

            // Appbundle を出力 (本番ビルド時: true)
            EditorUserBuildSettings.buildAppBundle = false;

            var extension = EditorUserBuildSettings.buildAppBundle ? "aab" : "apk";

            if (extension == "aab" && buildOption.HasFlag(BuildOptions.Development))
            {
                Debug.Log("本番ビルド時に BuildOptions.Development オプションを有効にすることはできません。");
                return;
            }

            BuildPipeline.BuildPlayer(targetScenes, System.IO.Path.Combine(OUTPUT_DIR, $"app-name.{extension}"), BuildTarget.Android, buildOption);
        }

        private static IEnumerable<string> GetBuildScenes()
        {
            var scenes = EditorBuildSettings.scenes
                .Where(scene => scene.enabled)
                .Select(scene => scene.path)
                .ToArray();

            return scenes;
        }

        private static BuildOptions GetBuildOption()
        {
            var option = BuildOptions.None;

            if (System.Environment.GetCommandLineArgs().Contains("-development"))
            {
                option |= BuildOptions.Development;
            }

            return option;
        }
    }
}

自動化するときは、コマンドラインから次のコマンドを送る。

"Unity.exe パス" -batchmode -quit \
-logFile "ログ出力 パス" \
-projectPath "Unityプロジェクト パス" \
-executeMethod AppBuilder.BuildAndroid
# -development などのオプション

毎回このクラスを用意するのも面倒なので、テンプレートとしてプラグインパッケージにしておくのも便利だと思います。

f:id:shikaku_sh:20210224162011p:plain

参考

楽しく学ぶ Unity2D超入門講座

楽しく学ぶ Unity2D超入門講座

Unity HP スライダーのサンプル

テストしていたプログラムの副産物として HP スライダーが出来たのでメモ。 EventTrigger のちょうどいいテストデータでもあります。

f:id:shikaku_sh:20210217173050g:plain:w450
HP スライダーのサンプル

HP を割合(0.0F~1.0F)で簡単に表示できました。サンプルは大好きな EFZ 風にしてみました。

f:id:shikaku_sh:20210217174810p:plain:w600
神ゲー

スライダーを HP(体力)ゲージにする

  • Unity の UI コンポーネントから Slider を生成
  • Handle Slide Area を削除
  • Slider > Background
    • Image
      • Source Image をゲージの背景画像(ダメージを受けた後の色)
      • Raycast Target を false
  • Slider > Fill Area > Fill
    • Image
      • Source Image をゲージの背景画像(ダメージを受ける前の色)

これでグラフィックの設定は完了です。あとは Slider.value のプロパティに従って、割合表示させることが可能です。オプションはこちら:

  • Slider
    • Min Value
    • Max Value
    • Value

体力 33% 以下 で Fill Area を差し替える

f:id:shikaku_sh:20210217173446p:plain

Image コンポーネントoverrideSprite というデフォルトの Source Image を別の画像に切り替えるという簡易ロジックを持っているので、これを活用するとシンプル。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class HpSliderBehaviour : MonoBehaviour
{
    [SerializeField]
    private Slider _Slider = null;

    [SerializeField]
    private GameObject _Fill = null;

    [SerializeField]
    private Sprite _OverrideSprite = null;

    private Image _Progress;

    // Start is called before the first frame update
    void Start()
    {
        _Progress = _Fill.GetComponent<Image>();
    }

    // Update is called once per frame
    void Update()
    {
        if (_Slider == null || _Progress == null) return;

        if (_Slider.value <= 0.33F)
        {
            _Progress.overrideSprite = _OverrideSprite;
        }
        else
        {
            _Progress.overrideSprite = null;
        }
    }
}

HP(体力)ゲージを減らすサンプル

f:id:shikaku_sh:20210217173508p:plain

ゲージを減らす効果はボタンを押下している間だけ動作するようなパターンがわかりやすいです。デフォルトのボタン機能である On Click だと、思ったようにするのは難しいです。

EventTrigger で Pointer Down, Up を実装して、それぞれにゲージの減少開始と、減少停止を設定します。

ここは簡単なので割愛。もしものときは、サンプルを参照してみてください。

サンプル

GitHub に今回のサンプルを公開しています。

参考

Unity2020入門

Unity2020入門

Dyson Sphere Program ガイド訳

ゲームを始めるにあたって、ガイドをがんばって読んだのでメモ。英語が得意というわけではないので、意訳すくなめのつもりです。間違いも多いと思いますが助けになれば幸いです。

Background(背景)

f:id:shikaku_sh:20210209164330p:plain:w600

未来のこと、人類は高度な文明の域に入り、科学とテクノロジーの力は、急速な発展をもたらします。人々は、仮想現実を使って時間と空間をイテレートし、現実世界よりもはるかに大きな仮想宇宙を作りだし、徐々に人間の意識は仮想宇宙へと遷ります。

仮想世界の運用には、スーパーコンピューターの力が必要です。そのため、人類は太陽を一周するスーパーコンピューター CentreBrain を作り、その強力な演算能力で仮想空間を展開している。人の意識の数が増え続けているため、CentreBrain の演算能力は徐々に限界に達しています。より多くの人の意識を収容するためには、より多くのエネルギー供給が必要です。そこで、COSMO は Dyson Sphere Program と名付けたエンジニアリング プログラムを立ち上げました。エンジニアを現実世界に派遣して、Dyson 球を作り CentreBrain に安定したエネルギーを断続的に供給します。

COSMO のメンバーであり、Dyson Sphere Program のパイオニアであるあなたは、未知への旅に出るメカを端末を使って操作しましょう。そして、リソースを収集して、生産ラインを計画・設計し、徐々に完全な自動化を実現することを、ゼロから出発します。小さなワークショップから壮大な銀河の中の産業帝国へと発展させ、最終的には偉大な神託の地、Dyson Sphere を築きましょう。

What Is A Dyson Sphere?(Dyson Sphere is なに?)

f:id:shikaku_sh:20210209164400p:plain:w600

文明が発展していけば、広大な宇宙の中でエネルギーの需要は増え続けます。(自分の住む)惑星でのエネルギーがもう維持できなくなるとき、星々でのエネルギー開発は、文明を発展させるために避けられない選択です。

1960 年、Freeman Dyson は、伝説的な概念である「ダイソン球体理論」を提唱しました。Dyson Sphere は、恒星の全体を取り囲み、エネルギー出力のほとんどすべてを手に入れる仮説上の巨大な人工物です。

カルダシェフ・スケール の文明レベルの分類によると:

  • 文明1(惑星文明)は、自分の住む惑星のすべての資源を開発、活用することができること
  • 文明2(恒星文明)は、恒星系全体のエネルギーを集めて、物質が光の速度を超えること
  • 文明3(銀河文明)は、銀河全体のエネルギーを物質化、使うことができること

Dyson Sphere を作る能力を持つことは、人類が文明2に進むことを示しています。

Gather Resource Command(リソースを収集するコマンド)

f:id:shikaku_sh:20210209164500p:plain:w600

マウスの「右クリック」で地上の場所をクリックすると、メカをその場所に命令することができます。もし、その場所に資源 (resource) があれば、メカに収集することを命令できます。

また、より便利な操作として、「SHIFT + 右クリック」で、木を伐採して、伐採した後に別の木に移動するといったような、連続している一連のコマンドを与えることができます。

Build Mining Machine and Vein Coverage(マイニングマシンの生成と採掘範囲)

f:id:shikaku_sh:20210209164612p:plain:w600

マイニングマシンは自動的に鉱石を集めることができます。R キーでマイニングマシンを90度回転させ、扇形の採掘範囲が鉱脈をカバーするようにします。 SHIFT キーを押したまま配置操作をすると、Construction Grid へのスナップは無視されます。

マイニングマシンがより多くの鉱脈をカバーするとき、(リソースを)収集する速度が速くなります。それぞれのカバーされた鉱脈は 30/min の基本出力を提供します。マイニングマシンがカバーする鉱脈は順番に消費していくので、各鉱脈の消費率は同じになります。カバーする鉱脈のひとつが枯れ果てたとき、マイニングマシンの生産能力は低下します。

鉱脈のカバーする範囲は、多ければベターとはなりません。多くの場合、出力と消費のバランスに応じて鉱脈をカバーする数を決める必要があります。

ひとつの鉱脈は複数のマイニングマシンでカバーすることができます。鉱脈をカバーしているマイニングマシンが多いほど、(鉱脈の)消費は速くなります。

技術ツリー(T キー)、アップグレード セクションから「Mineral Utilization Technology(鉱物利用技術)」をアップグレードし続けることで、マイニングマシンの採掘する速度を上げることができ、採掘による鉱石の損失を下げることができます。

Mecha Core Energy(メカのコアエネルギー)

f:id:shikaku_sh:20210209164656p:plain:w600

メカは移動、収集、ビルディング、アイテムの複製、技術リサーチの使用時にコアエネルギーを消費します。

コアエネルギーが不足すると、移動速度が遅くなり研究することができなくなるなど、すべての行動が制限されます。なので、どんな時もメカのコアエネルギーの使用状態に注意が必要です。コアエネルギーが不足したら、画面右下のメカパネル(C キー)をクリックするとメカパネルが開き、ロボのチャンバーに燃料を再補給します。

チャンバーを介して燃料のエネルギーをコアエネルギーに変換することで、メカのコアエネルギーを回復することができます。燃料の力が高くなると、エネルギーの回復は速くなります。

序盤のうちは、惑星上の樹木や植物を集めることで、2つの主要な燃料を得ることができます:

  • Log
  • Plant

Coals はよい燃料になります。 高いグレードの燃料は低いグレードの燃料より多くのエネルギーを含んでいるだけではなく、燃料の力が上がるため、より速くメカのエネルギーを回復できるようになります。

燃料に加えて、メカのコアは小さな発電能力を付属しています。

コアのエネルギーバーの右側にある値にマウスを移動すると、具体的な使用詳細を表示します。

Basic Operation Guide(基本操作のガイド)

f:id:shikaku_sh:20210209164736p:plain:w600

「WASD」方向キーを使うとメカの動きをコントロールする、または、移動する先をマウス「右クリック」をすると、メカに移動するコマンドを送ることができます。

Drive Engine Level 1 をアンロックすると、飛行アビリティを取得します。「SPACE」キーのダブルクリックでリフトオフ(離陸)して、「WASD」方向キーを使って低空での飛行をコントロールします。

飛行中に「SPACE」キーで飛行の高度を上げ、「ALT」キーで高度を下げたり、メカを地上におろします。

Drive Engine Level 2 をアンロックすると、帆走アビリティを取得します。飛行モードで「↑ (Forward)」+「SPACE」キーを押すと、一定の高度に到達したときに帆走モードになります。

参考

火星年代記 (ハヤカワ文庫SF)

火星年代記 (ハヤカワ文庫SF)

Unity 放置ゲームにおける「日付と時刻変更」基本的な対策

f:id:shikaku_sh:20210205155735p:plain

スマートフォンの放置ゲームを作ろうと思ったら、開発者が必ず注意することになる問題のひとつに、端末の時刻設定を故意に変更するチート行為が挙がると思います。

対応策として、NTP サーバーのようなサーバー時間を取得してローカル時間を比較する方法が最も安全ですが、サーバーを用意するものコストが掛かりますし、大学などの公開 NTP サーバーを(ゲームのような)私的に利用し続ける設計も、第一からしてモラルに欠ける感じです。

そんなわけで、スマートフォンの環境設定「日付と時刻」が自動的に設定されているかどうかを確認する、というのがインディーズのゲームだとよくある対策かと思います。Android 端末においてのやり方をメモします。

f:id:shikaku_sh:20210205154244p:plain:w600
これがゴール(左:ゲーム画面、右:Android 設定画面)

対策不足の一例を挙げると、放置系ハクスラモンスターズもリリース直後は、このあたりの対策が不十分だったため、端末内の時間を進ませるだけで、大きな放置時間を作りだすチート行為が横行し、Twitter の雰囲気は淀みました。また、対策を加えていく中で、チーターも周囲のチート行為に便乗する形でチートに手を染めだしたプレイヤーもまとめてアカウント BAN(プレイ制限)になり、スタート時点の混乱は見苦しいものだった記憶があります。
ゲームの本質的な部分でないといえば、そのとおりなのですが、ゲームを楽しめる環境を維持するために必要な開発コストなのかな、とそのとき思いました。

日付と時刻の設定値を取得するネイティブプラグイン

そんなわけなので、Unity というよりも Android ネイティブプラグインを作って、Android 本体の設定値を取得します。

Android ネイティブプラグインの作り方は過去の記事「Unity アプリケーションのメモリー使用量を可視化する」などを参照してください。

今回は Android の設定「日付と時刻」から次の2つの設定値を取得するようにします。

  • 日付と時刻の自動設定
  • タイムゾーンの自動設定
  • (ここでは省略しますが、ネットワーク接続の有無も必要かと)

とりあえず、これが自動設定になっていれば、端末の放置時間を直接編集するようなチートを防ぐことができそうです。

package com.sh1.androidnativeutil;

import com.unity3d.player.UnityPlayer;
import android.os.Build;
import android.os.Debug;
import android.os.Process;
import android.app.ActivityManager;
import android.content.Context;
import android.provider.Settings;

public class SystemSettings
{
    /**
     * ユーザーが日付、時刻、タイムゾーンの自動取得設定をしているかどうかを示す値を取得します。
     * @return true = ON, false = OFF
     */
    public static boolean IsAutoTime()
    {
        final Context context = UnityPlayer.currentActivity.getApplication().getApplicationContext();
        boolean isResult = Settings.Global.getInt(context.getContentResolver(), Settings.Global.AUTO_TIME, 0) == 1;
        /*
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1)
        {
            isResult = Settings.Global.getInt(context.getContentResolver(), Settings.Global.AUTO_TIME, 0) == 1;
        }
        else
        {
            // API level 17
            isResult = Settings.System.getInt(context.getContentResolver(), Settings.System.AUTO_TIME, 0) == 1;
        }
         */
        return isResult;
    }

    /**
     * ユーザーがタイムゾーンを自動取得設定をしているかどうかを示す値を取得します。
     * @return true = ON, false = OFF
     */
    public static boolean IsAutoTimeZone()
    {
        final Context context = UnityPlayer.currentActivity.getApplication().getApplicationContext();
        boolean isResult = Settings.Global.getInt(context.getContentResolver(), Settings.Global.AUTO_TIME_ZONE, 0) == 1;

        return isResult;
    }
}

用意したプラグインを Unity に設定します。「Android」専用のプラグインを使うときは、Android というフォルダー名にします。詳細は「AAR plug-ins and Android Libraries」を確認します。

Assets\Plugins\Android\プラグインファイル

f:id:shikaku_sh:20210205153738p:plain
直下に置いても動きます

ネイティブプラグインを利用する Unity スプリプト

Unity の UI Text コンポーネントに結果を単純に表示するだけのサンプルです。

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Profiling;
using UnityEngine.UI;

public class TimeNetworkChecker : MonoBehaviour
{
    private AndroidJavaClass _NativeClass = null;

    private const string PACKAGE_NAME = "com.sh1.androidnativeutil";
    private const string CLASS_NAME = "SystemSettings";
    private const string METHOD_AUTO_TIME = "IsAutoTime";
    private const string METHOD_AUTO_TIME_ZONE = "IsAutoTimeZone";

    [SerializeField]
    private Text _Text = null;

    private void Start()
    {
        _NativeClass = new AndroidJavaClass($"{PACKAGE_NAME}.{CLASS_NAME}");
    }

    private void OnDestroy()
    {
        _NativeClass?.Dispose();
        _NativeClass = null;
    }

    private void Update()
    {
        var text = "";

        try
        {
            var isAutoTime = false; 
            var isAutoTimeZone = false;
#if UNITY_ANDROID
            isAutoTime = _NativeClass.CallStatic<bool>(METHOD_AUTO_TIME);
            isAutoTimeZone = _NativeClass.CallStatic<bool>(METHOD_AUTO_TIME_ZONE);
#endif
            text = $"Time:{isAutoTime}, TimeZone:{isAutoTimeZone}";
        }
        catch(Exception ex)
        {
            text = ex.Message;
        }

        if (_Text != null)
        {
            _Text.text = text;
        }
    }
}

Android の APK をうまく出力できないとき

f:id:shikaku_sh:20210205153427p:plain:w600
エラーウィンドウ

Native プラグインは、指定する APK のバージョンでないと出力の際にエラーになります。Unity かプラグインのバージョン指定を変更します。

f:id:shikaku_sh:20210205153642p:plain:w600
プラグインの最小バージョンを変更する例

動作のテスト

こんな感じです。各画像の左がアプリの動作画面で右が設定。

f:id:shikaku_sh:20210205154234p:plain:w600f:id:shikaku_sh:20210205154239p:plain:w600f:id:shikaku_sh:20210205154244p:plain:w600
動作テスト

環境設定に応じて、表示の true, false が切り替わっています。わりとよさそうですね。

対策の発展形

日付と時刻の設定が自動であること+ネットワーク接続が出来ているなら時刻はおよそ正常です。しかし、ゲームを起動するときに必ずネットワーク接続が必要になるのも困りものです。地下鉄に乗る合間にプレイしたいといったニーズに不都合です。

なので、オフライン状態でも特定条件なら可とするアイデアを考えてみます:

  • オンライン状態の確認を取得している時刻がある
  • その時刻から 24 時間以内であること(またはオフライン状態になって 24 時間以内)

フローチャートにするとこんな感じでどうでしょうか。仮のログイン期間を 24 時間以内としたのは、日本を基準にした世界のタイムゾーン)の差ぐらいまで、というアイデアから。

f:id:shikaku_sh:20210205155515p:plain

タイムゾーンの変化で時間が進んだときは、それだけ放置したと認めて、また日本に戻ってきたときに戻った時間だけ停止時間が発生する。 時間が戻ったときは、その時間だけ停止時間が発生するけど、また日本に戻ってきたときに停止時間分は放置した時間として戻ってくるはずです。およそ問題なさそう。

国外に退出して戻ってこない、またはその逆みたいなケースを例外としています。

サンプル

GitHub に「unity-network-time-check」というサンプルを公開しています。

参考