UnityのAddressablesのRemoteコンテンツ運用 - ContentCatalog

こんにちは、エンジニアリングマネージャーの渡辺(@mochi_neko_7)です。

今回は前回の記事の続きとして、UnityのAddressablesのCatalog(Content Catalog)の詳しい紹介をします。

前回の記事はこちら。

synamon.hatenablog.com

RemoteからAssetBundleをダウンロードして使用するだけなら前回記事の内容でも十分なのですが、アプリケーションのリリース後にRemoteでコンテンツの追加をするためにはそれだけでは不十分で、Catalogをきちんと管理してあげる必要があります。

Localのみでアセットを管理している場合はCatalogの存在はほとんど意識する必要はないのですが、Remoteでアセットを管理する場合には非常に重要なものになります。

本記事ではその理由も含めて、Catalogがどういったものなのか、どうやって使用するのか、どのように運用できるのかを体系的に整理して紹介します。

少し深めの内容になるため初心者向けではなくなってしまいますが、Catalogに関して公式のドキュメントも情報が少ないですし、体系的に説明してくれている情報も見当たらないため、しっかり目に紹介したいと思います。

今回も前回に引き続き使用しているバージョンは以下になります。

  • Unity 2021.3.0f1
  • Addressables 1.19.19
    • 公式ドキュメントは 1.20 の方が情報が多いのでリンクは1.20を使っています

Catalogの役割

前回記事で紹介したAddressablesにおけるアセットのロードフローをおさらいしましょう。

IResourceLocator にAddressを渡して、IResourceLocation を取得し、それから 指定されている IResourceProvider を使用してAssetBundleやアセットをロードする形でした。

synamon.hatenablog.com

この IResourceLocator の役割をし、Addressablesで管理している全てのAssetBundle、アセットの IResourceLocation を内包しているデータがCatalog(ContentCatalog)です。

つまり、AddressからロードするAssetBundleやアセットを特定し、それらをロードするために必要な情報を提供するのがCatalogの役割になります。

当然ですがCatalogに載っていないAssetBundleやアセットはロードすることができませんので、使用しているCatalogにどのAssetBundleやアセットの情報が載っているのかというのを把握することは重要です。

Catalogの中身

Catalogの大まかな役割を把握したところで、実際にCatalogの中身を覗いてみましょう。

Json

catalog.json の拡張子からも分かるように、Catalogの出力されるデータはJsonで記述されているので、中身をテキストで見ることができます。

Mac版のUnityで新規プロジェクトを作成して、Addressablesのパッケージを追加し、設定の初期化をしてからURPのLit ShaderだけAddressableに設定した状態で生成されるCatalogはこのような中身になっています。

{
    "m_LocatorId": "AddressablesMainContentCatalog",
    "m_InstanceProviderData": {
        "m_Id": "UnityEngine.ResourceManagement.ResourceProviders.InstanceProvider",
        "m_ObjectType": {
            "m_AssemblyName": "Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
            "m_ClassName": "UnityEngine.ResourceManagement.ResourceProviders.InstanceProvider"
        },
        "m_Data": ""
    },
    "m_SceneProviderData": {
        "m_Id": "UnityEngine.ResourceManagement.ResourceProviders.SceneProvider",
        "m_ObjectType": {
            "m_AssemblyName": "Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
            "m_ClassName": "UnityEngine.ResourceManagement.ResourceProviders.SceneProvider"
        },
        "m_Data": ""
    },
    "m_ResourceProviderData": [
        {
            "m_Id": "UnityEngine.ResourceManagement.ResourceProviders.LegacyResourcesProvider",
            "m_ObjectType": {
                "m_AssemblyName": "Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
                "m_ClassName": "UnityEngine.ResourceManagement.ResourceProviders.LegacyResourcesProvider"
            },
            "m_Data": ""
        },
        {
            "m_Id": "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider",
            "m_ObjectType": {
                "m_AssemblyName": "Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
                "m_ClassName": "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider"
            },
            "m_Data": ""
        },
        {
            "m_Id": "UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider",
            "m_ObjectType": {
                "m_AssemblyName": "Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
                "m_ClassName": "UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider"
            },
            "m_Data": ""
        },
        {
            "m_Id": "UnityEngine.ResourceManagement.ResourceProviders.LegacyResourcesProvider",
            "m_ObjectType": {
                "m_AssemblyName": "Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
                "m_ClassName": "UnityEngine.ResourceManagement.ResourceProviders.LegacyResourcesProvider"
            },
            "m_Data": ""
        },
        {
            "m_Id": "UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider",
            "m_ObjectType": {
                "m_AssemblyName": "Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
                "m_ClassName": "UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider"
            },
            "m_Data": ""
        }
    ],
    "m_ProviderIds": [
        "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider",
        "UnityEngine.ResourceManagement.ResourceProviders.LegacyResourcesProvider",
        "UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider",
        ""
    ],
    "m_InternalIds": [
        "{UnityEngine.AddressableAssets.Addressables.RuntimePath}/StandaloneOSX/defaultlocalgroup_assets_all_cf7c3574ff06b3d38474c7759827fb82.bundle",
        "DebugUICanvas",
        "DebugUIPersistentCanvas",
        "Packages/com.unity.render-pipelines.universal/Shaders/Lit.shader",
        "Scenes/SampleScene"
    ],
    "m_KeyDataString": "CgAAAABEAAAAZGVmYXVsdGxvY2FsZ3JvdXBfYXNzZXRzX2FsbF9jZjdjMzU3NGZmMDZiM2QzODQ3NGM3NzU5ODI3ZmI4Mi5idW5kbGUADQAAAERlYnVnVUlDYW52YXMAIAAAAGNmNmNiZGQ2NzIwODlhODQ3OTZlNTVhMjFmZWQxY2JlABcAAABEZWJ1Z1VJUGVyc2lzdGVudENhbnZhcwAgAAAAZjZiMWEwZmU3NWQ1MDA5NDQ5Y2Y1NWFlNzYyMjBlMmIAQAAAAFBhY2thZ2VzL2NvbS51bml0eS5yZW5kZXItcGlwZWxpbmVzLnVuaXZlcnNhbC9TaGFkZXJzL0xpdC5zaGFkZXIAIAAAADkzMzUzMmE0ZmNjOWJhZjRmYTA0OTFkZTE0ZDA4ZWQ3AAsAAABTYW1wbGVTY2VuZQAgAAAAOTljOTcyMGFiMzU2YTA2NDJhNzcxYmVhMTM5NjlhMDUEAAAAAA==",
    "m_BucketDataString": "CgAAAAQAAAABAAAAAAAAAE0AAAACAAAAAQAAAAIAAABfAAAAAgAAAAEAAAACAAAAhAAAAAQAAAADAAAABAAAAAUAAAAGAAAAoAAAAAQAAAADAAAABAAAAAUAAAAGAAAAxQAAAAEAAAAHAAAACgEAAAEAAAAHAAAALwEAAAEAAAAIAAAAPwEAAAEAAAAIAAAAZAEAAAEAAAAIAAAA",
    "m_EntryDataString": "CQAAAAAAAAAAAAAA/////wAAAAAAAAAAAAAAAAAAAAABAAAAAQAAAP////8AAAAA/////wEAAAABAAAAAQAAAAEAAAD/////AAAAAP////8BAAAAAgAAAAIAAAABAAAA/////wAAAAD/////AwAAAAEAAAACAAAAAQAAAP////8AAAAA/////wMAAAADAAAAAgAAAAEAAAD/////AAAAAP////8DAAAABAAAAAIAAAABAAAA/////wAAAAD/////AwAAAAUAAAADAAAAAgAAAAAAAACF+Vmw/////wUAAAAGAAAABAAAAAMAAAD/////AAAAAP////8HAAAABwAAAA==",
    "m_ExtraDataString": "B0xVbml0eS5SZXNvdXJjZU1hbmFnZXIsIFZlcnNpb249MC4wLjAuMCwgQ3VsdHVyZT1uZXV0cmFsLCBQdWJsaWNLZXlUb2tlbj1udWxsSlVuaXR5RW5naW5lLlJlc291cmNlTWFuYWdlbWVudC5SZXNvdXJjZVByb3ZpZGVycy5Bc3NldEJ1bmRsZVJlcXVlc3RPcHRpb25zrAIAAHsAIgBtAF8ASABhAHMAaAAiADoAIgBjAGYANwBjADMANQA3ADQAZgBmADAANgBiADMAZAAzADgANAA3ADQAYwA3ADcANQA5ADgAMgA3AGYAYgA4ADIAIgAsACIAbQBfAEMAcgBjACIAOgAyADIAMQAxADUAOQAzADAAMAAxACwAIgBtAF8AVABpAG0AZQBvAHUAdAAiADoAMAAsACIAbQBfAEMAaAB1AG4AawBlAGQAVAByAGEAbgBzAGYAZQByACIAOgBmAGEAbABzAGUALAAiAG0AXwBSAGUAZABpAHIAZQBjAHQATABpAG0AaQB0ACIAOgAtADEALAAiAG0AXwBSAGUAdAByAHkAQwBvAHUAbgB0ACIAOgAwACwAIgBtAF8AQgB1AG4AZABsAGUATgBhAG0AZQAiADoAIgA1AGQANgA0ADEANgBlADgAYwAwADkANgA0ADEAZQBkADMANgA1ADkANABmADAAZAA0AGUAZgBlADIAMQBkADEAIgAsACIAbQBfAEEAcwBzAGUAdABMAG8AYQBkAE0AbwBkAGUAIgA6ADAALAAiAG0AXwBCAHUAbgBkAGwAZQBTAGkAegBlACIAOgA2ADMAMgAwADEALAAiAG0AXwBVAHMAZQBDAHIAYwBGAG8AcgBDAGEAYwBoAGUAZABCAHUAbgBkAGwAZQBzACIAOgB0AHIAdQBlACwAIgBtAF8AVQBzAGUAVQBXAFIARgBvAHIATABvAGMAYQBsAEIAdQBuAGQAbABlAHMAIgA6AGYAYQBsAHMAZQAsACIAbQBfAEMAbABlAGEAcgBPAHQAaABlAHIAQwBhAGMAaABlAGQAVgBlAHIAcwBpAG8AbgBzAFcAaABlAG4ATABvAGEAZABlAGQAIgA6AGYAYQBsAHMAZQB9AA==",
    "m_resourceTypes": [
        {
            "m_AssemblyName": "Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
            "m_ClassName": "UnityEngine.ResourceManagement.ResourceProviders.IAssetBundleResource"
        },
        {
            "m_AssemblyName": "UnityEngine.CoreModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
            "m_ClassName": "UnityEngine.GameObject"
        },
        {
            "m_AssemblyName": "Unity.RenderPipelines.Core.Runtime, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
            "m_ClassName": "UnityEngine.Rendering.UI.DebugUIPrefabBundle"
        },
        {
            "m_AssemblyName": "UnityEngine.CoreModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
            "m_ClassName": "UnityEngine.RectOffset"
        },
        {
            "m_AssemblyName": "UnityEngine.UI, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
            "m_ClassName": "UnityEngine.UI.MaskableGraphic+CullStateChangedEvent"
        },
        {
            "m_AssemblyName": "UnityEngine.CoreModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
            "m_ClassName": "UnityEngine.Events.PersistentCallGroup"
        },
        {
            "m_AssemblyName": "UnityEngine.CoreModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
            "m_ClassName": "UnityEngine.Shader"
        },
        {
            "m_AssemblyName": "Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
            "m_ClassName": "UnityEngine.ResourceManagement.ResourceProviders.SceneInstance"
        }
    ],
    "m_InternalIdPrefixes": []
}

実際のプロジェクトではAddressablesで管理されているアセットの情報がさらに追加されます。

ここでJsonを見て分かる注意点が3つあります。

  1. InternalIdは直接見ることができる
    • m_InternalIds
    • 設定でInternalIdに何を使うのか制御できるので、どんなアセットを使用しているのか知られたくない場合にはFullPathではなくGUIDを使うのがおすすめです
    • ただしSceneはFullPathにしないとSceneのロードができなくなることに注意が必要です
  2. Base64エンコードされていて直接見ることができないデータもある
    • m_KeyDataStringm_BucketDataStringm_EntryDataStringm_ExtraDataString
    • ちゃんとBase64デコードすれば見ることもできます
  3. InternalIdに StandaloneOSX などのプラットフォーム固有の情報を含んでいる
    • LocalのAssetBundleのInternalId(Path)
    • このためCatalogはAssetBundleと同様にビルドするプラットフォーム別にファイルが必要になることが分かります

インターフェース

Base64エンコードされていることにより具体的な内部の情報が分かりづらいので、IResourceLocator の定義を確認してみましょう。

docs.unity3d.com

namespace UnityEngine.AddressableAssets.ResourceLocators
{
    /// <summary>
    /// Interface used by the Addressables system to find the locations of a given key.
    /// </summary>
    public interface IResourceLocator
    {
        /// <summary>
        /// The id for this locator.
        /// </summary>
        string LocatorId { get; }
        /// <summary>
        /// The keys defined by this locator.
        /// </summary>
        IEnumerable<object> Keys { get; }
        /// <summary>
        /// Retrieve the locations from a specified key.
        /// </summary>
        /// <param name="key">The key to use.</param>
        /// <param name="type">The resource type.</param>
        /// <param name="locations">The resulting set of locations for the key.</param>
        /// <returns>True if any locations were found with the specified key.</returns>
        bool Locate(object key, Type type, out IList<IResourceLocation> locations);
    }
}

Keys はそのCatalogに記載されているAddressなどの一覧です。

そのKeyを Locate(...) で指定することで、指定したKeyが含まれている場合は IResourceLocatioin (のリスト)を返してくれます。

次に IResourceLocation の定義を見てみましょう。

docs.unity3d.com

namespace UnityEngine.ResourceManagement.ResourceLocations
{
    /// <summary>
    /// Contains enough information to load an asset (what/where/how/dependencies)
    /// </summary>
    public interface IResourceLocation
    {
        /// <summary>
        /// Internal name used by the provider to load this location
        /// </summary>
        /// <value>The identifier.</value>
        string InternalId { get; }

        /// <summary>
        /// Matches the provider used to provide/load this location
        /// </summary>
        /// <value>The provider id.</value>
        string ProviderId { get; }

        /// <summary>
        /// Gets the dependencies to other IResourceLocations
        /// </summary>
        /// <value>The dependencies.</value>
        IList<IResourceLocation> Dependencies { get; }

        /// <summary>
        /// The hash of this location combined with the specified type.
        /// </summary>
        /// <param name="resultType">The type of the result.</param>
        /// <returns>The combined hash of the location and the type.</returns>
        int Hash(Type resultType);

        /// <summary>
        /// The precomputed hash code of the dependencies.
        /// </summary>
        int DependencyHashCode { get; }

        /// <summary>
        /// Gets the dependencies to other IResourceLocations
        /// </summary>
        /// <value>The dependencies.</value>
        bool HasDependencies { get; }

        /// <summary>
        /// Gets any data object associated with this locations
        /// </summary>
        /// <value>The object.</value>
        object Data { get; }

        /// <summary>
        /// Primary address for this location.
        /// </summary>
        string PrimaryKey { get; }

        /// <summary>
        /// The type of the resource for th location.
        /// </summary>
        Type ResourceType { get; }
    }
}

InternalId はJsonでも見える形で含まれていましたね。

Dependencies があることから、アセットやAssetBundleの依存関係の情報も持っていることが分かります。

Data は前回記事でも説明したようにCRCなどの情報を含む AssetBundleRequestOptions という型にキャストして利用できます。

docs.unity3d.com

PrimaryKey はAddressに相当するものになります。*1

重要な情報のみ解説しましたが、これらの情報がBase64エンコードされてCatalogに含まれていることになっています。

以上がCatalogの中身になります。

CRCに関する補足

CRC(Cyclic Redundancy Check)に関して少し補足説明します。

CRC(Cyclic Redundancy Check)はAssetBundleの内容の差異を検知するためのHash値のような役割をする情報です。

AssetBundleをロードする際にオプションで指定することができ、期待されるCRCの値と実際にロードしたAssetBundleから計算したCRCの値を比較して、そのAssetBundleが期待している内容であるかを判別できます。

docs.unity3d.com

これはダウンロードしたデータの破損や古くなったキャッシュの検出などに利用できます。*2

特にRemoteのコンテンツをストレージにキャッシュしたり、その中身を更新したりする際にはCRCを利用することになるため、期待されるCRCの情報がCatalogに載っていることも知っておくべき事項になります。

Catalogのロード方法

Catalogには大きく分けて3つのロード方法があります。

  1. Local
  2. Remote
  3. API(URL)

1. Local

アプリケーション本体をビルドすると、StreamingAssets/aa のフォルダ内に

  • settings.json
  • catalog.json(圧縮設定している場合は catalog.bundle

の2つのファイルが作成されます。

アプリケーション起動中のAddressablesの初期化時(明示的に初期化した時 or 初めてAddressablesにアクセスする時)に、まず settings.json をロードします。

中にはLocalのCatalogのPath、次で説明するRemoteのCatalogのPath or URLが書かれていて、それを元に catalog.json をロードします。

このLocalのCatalogにはGroupの設定で Include in Build = true になっているものがアプリケーションのビルド時の状態で全て含まれます。

Addressablesの内部的には AddressablesMainContentCatalog というLocatorIdの IResourceLocator としてロードされます。

当然ですがこのLocalのCatalogの中身を更新するためにはアプリケーション本体をビルドし直す必要があります。

2. Remote

AddressablesAssetSettingsContent Update の項目で Build Remote Catalog をtrueに設定すると、Addressablesのビルド時にCatalogを出力するようになります。

そのすぐ下の Build & Load Paths の設定で、出力先のPathとロード先のPathを指定できます。

Load Path はLocalの settings.json にも記載されて、Addressablesの初期化時に特別な操作をしなくても自動でロードをしてくれます。*3

ただしPathを固定するのが難しい場合には、次で説明するようにAPIを叩いて手動でロードしてあげる必要があります。

3. API(URL)

AddressablesにはRuntimeで手動でCatalogをロード・アンロードするAPIが用意されています。

docs.unity3d.com

PathにはURLを指定しても問題ありません。

docs.unity3d.com

現在ロードしているCatalogの一覧も取得できます。

docs.unity3d.com

自分でロードしたCatalogをきちんとアンロードするためには IResourceLocator のインスタンスを保持しておく必要があります。

それは LoadContentCatalogAsync の戻り値で取得できるのですが、メソッドのパラメータの autoReleaseHandle = true に指定していると null になってしまうため、アンロードも細かく管理する場合には autoReleaseHandle = false を指定するようにしましょう。

docs.unity3d.com

Catalogの運用

RemoteからCatalogを指定して使用したい場合には、先ほど紹介した Build Remote Catalog の設定が必要になります。

それを含めた全般的な話は公式のドキュメントにも大まかな説明はありますので、一読しておくことをお勧めします。

docs.unity3d.com

それ以外にCatalogを運用する上で知っておくと良い話も紹介します。

Catalogの更新

アプリケーション外からコンテンツを追加して使用したい場合には、AssetBundleをサーバーなどに配置するだけではなく、Addressablesのビルド時に同時に生成されるCatalogもサーバーなどに配置してあげる必要があります。

そのためAssetBundleを追加したり更新する際にはCatalogも更新しなければなりません。

AssetBundleの中身を更新したい場合にも、CRCのチェックをする場合にはCatalogにはCRCの情報が記載されているため、Catalogも更新してあげる必要があることには注意が必要です。

Catalogの分割

CatalogのAPIの説明からも分かるかもしれませんが、CatalogはLocalのものだけではなく任意に追加して複数のCatalogをロードして使用することができます。

前回の記事でも少し触れましたが、Addressablesでアセットをロードするときには IResourceLocator の列挙を foreach で回して、初めに取得できた IResourceLocation を使用してロードするということがソースコードから分かりました。

このため、複数のCatalogを使用してもどれかのCatalogから正常にアセットをロードできる状態であれさえすれば問題ないことが分かります。

この性質を考慮すると、少し凝った形のアセット管理の方法にもAddressablesを適用できます。

そもそものテーマであるRemoteでのアセット管理をする上では、アプリケーションのリリース後にAssetBundleを更新したり追加して使用したいという話でした。

そのようなケースではアプリケーション本体に含まれているLocalのCatalogには更新後のAssetBundleや追加したいAssetBundleの情報は当然持っていません。

そのため、それら更新したいAssetBundle、追加したいAssetBundleの情報を持っているCatalogをRuntimeで外からロードする必要があります。

従って最低でも2種類のCatalogを使用することになります。

  • LocalのCatalog
    • アプリケーション本体に含まれている、アプリケーションビルド時に生成されるCatalog
  • RemoteのCatalog
    • アプリケーションの外からロードして使用する、アプリケーションのビルド後に追加して使用したいAssetBundleの情報を含んでいるCatalog

もちろん数の制限もないので、もっとたくさんのRemoteのCatalogを使い分けることもできます。

例として、Remoteから3つのSceneのAssetBundleを追加したい場合には、以下のようなCatalogを作成して運用することも可能です。

  • LocalのCatalog
    • 追加したいScene以外の情報を持っている
  • SceneAのCatalog
    • LocalのCatalogの情報に加えて、SceneAのAssetBundleの情報を持っている
  • SceneBのCatalog
    • LocalのCatalogの情報に加えて、SceneBのAssetBundleの情報を持っている
  • SceneCのCatalog
    • LocalのCatalogの情報に加えて、SceneCのAssetBundleの情報を持っている

ただしこのようなことをするためにはアセットの運用面で下記などの注意が必要です。

  • LocalのCatalogの情報の部分はアプリケーションのリリース時の内容とは変わっている可能性があるため、LocalのAssetBundleのCRCのチェックをすると失敗する場合がある
  • SceneA~Cの間に依存関係を持たせることができないので、多重参照しているアセットは複製して各AssetBundleに含める必要がある
  • Addressablesのビルド時にGroupの設定で Include in Build = true にしているものがCatalogに含まれるため、1つのUnityProjectでSceneを作成している場合にはビルド時に設定を切り替えてあげる必要がある
  • Catalogのアンロードのタイミングでは使用しているアセットもちゃんとアンロードしてあげる

もちろんScene単位でなくても任意の単位でcatalogを分割して運用することも可能です。

公式のドキュメントを読む感じでは、大規模なプロジェクトで大量のアセットを管理する場合を想定してこのような運用ができるようにしているようです。(どのページに書いてあったか忘れてしまったのですが...)

UnityProjectの分割

やや強引な言い方をするなら、AddressablesはCatalogから正しくアセットが読み込める状態を作ってあげればどうにかなるものです。

Catalogが分割できるので、UnityProjectを分割する運用、例えばアプリケーション本体を開発するUnityProjectと、Remoteから追加したいアセットを作成するUnityProjectを分けて運用することも可能です。

もちろん注意事項はあります。

  • C#のScriptをComponentとして使用したい場合には、アセット作成側のUnityProjectにも同じScriptを含んでおく必要がある
    • そもそもScriptが絡むと運用が大変なのでおすすめはしないのですが...
    • 厳密に言うとAssembly名、NameSpaceを含むClass名、SerilaizedObjectの構造が一致していればソースコードに細かい差分があっても問題ないです
    • UPMのPackageとして共有できるのが理想です
  • UnityProjectを跨いで共有したいアセットがある場合には、それぞれのProjectで全く同じ設定のGroupを用意する必要がある
    • 設定内容にもよるかもしれませんが、GroupのGUIDも強制的に揃えておく必要がある場合もあります
    • 特に Universal Render Pipeline/Lit など標準的なShaderなどは使いまわすケースが多いので気を付ける必要があります
  • 共有するGroupの設定の Internal Bundle Id Mode やAddressablesAssetSettingsの Shader Bundle Naming Prefix などでProjectIdを使用することはできません
    • ProjectIdはおそらくUnityProject固有のものなので、これを使用してしまうと差分が発生してしまいます

一応公式のドキュメントにも少し説明があります。

docs.unity3d.com

終わりに

以上、Catalogに関する詳しい紹介をしました。

  • Catalogはどういった役割をするものなのか
  • Catalogが実際に持っている情報は何なのか
  • Catalogはどこからロードして使用するのか
  • Catalogがどのように運用できるか

こちらの情報を参考にしつつ、実際にご自身で色々触ってみるとより理解が深まると思います。

公式のドキュメントにもあまり体系的な説明がないため、Catalogの実態が掴みづらいかと思いますが、分かってしまえばそれほど複雑なものでもありませんし、意外と緩い部分もあって融通が効く部分もあります。

実際のプロジェクトでは実現したい運用に合わせて、開発体制を整えたり、細かい機能を調整したり、CIを組んだり、デリバリーの体制を整えたりと細かい調整をしていくことになるかと思います。

Remoteでのアセット管理はニーズがある一方で導入ハードルがやや高いと思いますが、前回と今回の記事の内容で基本的な内容は抑えられているかと思いますので、チャレンジしたい方に参考になれば幸いです。

記述内容には気を配ってはいますが、もし本記事の内容に間違っている記述や不適切な記述がある場合にはコメントやTwitterDMなどでお知らせいただけると幸いです。

今後は社内の開発のために作成したデバッグに便利なCatalogBrowser的なEditor拡張や、細かい運用面で苦労した話のTips集なども公開できればと思いますが、余力があればということで。

*1:AssetBundleの場合はAddressではなくAssetBundle名だったりしますが

*2:データの改竄も同じ理屈で検出できそうに思えますが、CRCの計算ロジックはその桁数からしても暗号学的にセキュアなものではなさそうなため、そこまで信頼しない方が良いかもしれません

*3:CatalogのUpdateの操作が必要だったかもしれません

テックブログを週1回・1年間更新継続するための3ステップ



Synamon採用担当の熊谷(@spirea_xxx)です!

なんと、弊社のテックブログが週1回・連続1年更新を達成しました~!!!



弊社のテックブログ記事や1年更新のことををSNSで皆さんに紹介する中で「テックブログの週1回更新を1年間続けるって、相当すごいんじゃないか?」と思い立ち、せっかくなのでテックブログにてその運用の仕組みやコツを極力シンプルな形(3ステップ!)にして、公開することとしました。
弊社エンジニアへのヒアリング&校閲を経ての公開ですので、人事が理想論交じりに好き勝手書いた記事ではないことを最初に申し添えておきます。人事が代筆した、弊社エンジニアの汗と涙の結晶です。
どこまで汎用性がある仕組みかはわかりませんが、この記事が技術広報に前向きな皆さんの手助けとなることを願います。



STEP1 : 全体ヒアリング

テックブログ更新が定着する前のSynamon(2018~2020)のテックブログ更新数は年に5本。出せているだけでもすごい、けれど、「もっと出したい」という漠然とした思いもありました。
それに加え、当時Synamonがやりたかったこと(今もですが)が「会社認知度の向上」と「会社ブランディング」。これらを推し進める手段の一つとして「テックブログ運用」がしっかりと方針として持ち上がりました。

テックブログを運用するためには、まず今理想通りの運用ができていない理由を明らかにするのが大事では?ということで、熱量の高いエンジニアを起点に、エンジニア全員対象のヒアリングを実施。ブレスト形式で、テックブログ運営における課題を一気に洗い出しました。
その結果見えてきたSynamonの課題は、ざっっっくりと以下の2点(もう少し分解して分析しましたが一旦割愛)。

  • 書く人がいない
  • 更新が続かない(継続性のある運営施策が立てられていない)

これらが解決されればテックブログは安定的に更新されるはず!…ということで、対応する施策を検討&実行していくのが基本的な思想です。



STEP2:「テックブログ運営チーム」の立ち上げ

Synamonの課題、「書く人がいない」を解決するために考案された仕組みが、「テックブログ運営チーム」です。
人間、一人で何かをやろうとしても心が折れてしまうというもの。
実際、ずっと不定期更新が続いていた弊社のテックブログ運営が再スタートを切れたのは、「技術広報をやりたい(やるべきだ)」という強い想いを持ったエンジニアが声を上げ、それに呼応するように熱量の高い仲間が現れたことが大きかったと思います。
Synamonではテックブログ運営を再始動にあたって、熱量が比較的高いメンバーで構成された「テックブログ運営チーム」を立ち上げました。編集長を置くわけでなく、チームで運営することで得られたメリットは以下の通りです。

  • モチベーション維持

→一人だけで頑張るのではなく、一緒に頑張る仲間がいるのは大きな支えと責任感になる

  • 継続性の担保

→「今週出す予定だったけど、業務の都合で厳しい…」というときに、他の運営メンバーが助け船を出せる
→有志(熱量が高いメンバー)で運営チームを構成することで、安定した更新ができる

振り返ってみても、モチベーション維持の効果は大きかったと思います。
自分だけじゃないというのは、「支え(気軽さ)」と「プレッシャー」両軸で更新を後押ししてくれました。
「自分ばっかりがテックブログ書けおじさん(おばさん)にならなきゃいけない…」と思うと、気も滅入るというものです。一人が心細いなら、みんなでテックブログ書けおじさん(おばさん)になればいいじゃない!

TIPS : 仲間作りには積極性が大事

Synamonテックブログ運営チームは、全体ヒアリングの後に行われた、個別ヒアリングがあってやっと立ち上がりました。
やる気のある誰かが、燻っている人の目の前まで行って、「やらない?」と声をかける。手を挙げないのはやる気がないからではなく、勇気が出ないから…というケースは往々にしてあります。
「やれる人いたらやろう」ではなく、「やりたいと思ってるんだけど、一緒にやってみない?」と周りを巻き込む。結果、それに応えて腰を上げた仲間が加入し、テックブログ運営チームは4人になり、Synamonのテックブログは1年連続更新を達成しました。
もちろん、個別で聞いてみた時に「運営チームという形で関わるのは難しい」というメンバーもいます。そういう人に無理強いはせず、あくまでやりたい人が頑張ってやろう、というスタンスをとりました。(結果、運営チーム以外のメンバーも寄稿してくれるようになりました。その背景にある仕組みは後述します)



STEP3:「記事管理シート」の作成と「運営定例MTG」の実施

Synamonの課題、「更新が続かない(継続性のある運営施策が立てられていない)」を解決するために考案された仕組みが、「テックブログ記事管理シート」&「テックブログ運営定例MTG」です。

テックブログ記事管理シート

社員全員がアクセスできるスプレッドシート。

公開ステータス、担当、タイトル、公開予定日、下書きプレビューURL、Twitter投稿用おすすめポイント、公開日、公開URL、備考欄で成り立つシンプルな管理シートです。
出した記事の管理だけでなく、「これから出す記事」にフォーカスが当たるように、かつ運用はシンプルに…という想いで作成されました。

【項目説明】

  • 公開ステータス
    ネタ出し~公開済みの中から現在のステータスを選択。公開済みに設定すると自動塗りつぶし
  • 担当
    担当者を選択
  • タイトル
    記事タイトルを記入
  • 公開予定日
    公開予定日を記入
  • 下書きプレビューURL
    下書き完成時にURLを記入。公開前に内容を社内でレビューできるようにすることで、公開する前の不安を軽減するねらい
  • Twitter投稿用おすすめポイント
    誰向けの記事なのか、どの点が面白いのか、担当視点でおすすめポイントを記入。
    広報担当(非エンジニア)でも魅力的な紹介文が書けるようにするねらい
  • 公開日
    実際の公開日を記入
  • 公開URL
    公開URLを記入。広報担当がSNS発信で使う
  • 備考欄
    その他書きたいことがあれば記入

    今回はこの記事を読んでいる皆様が社内ですぐ動き出せるように、Synamonで使用しているテンプレートを公開します。
    使いやすいようにカスタマイズしてより良いテックブログ更新ライフをお過ごしください!

    テックブログ管理シートテンプレ - Google スプレッドシート

    テックブログ運営定例MTG

    上記テックブログ記事管理シートを活用しながら、週次で30分間行われる定例MTGです(Synamonでは毎週水曜17:30-18:00開催)。
    STEP2で結成されたテックブログ運用チームが必須で参加し、その他メンバーは自由参加となります。
    この定例では以下の確認や相談が行われます。

  • 今週/来週公開予定の記事は順調か?公開まで問題なくいけそうか?
    →開発進捗がヤバい場合はここで素直に相談、対策をみんなで考える

  • なにか面白いネタはないか?
    →「そういえば業務で使ってるあの技術はいいネタになるんじゃないか」など、自分にない観点からネタが降ってくる場合がある
    →「(運営チームじゃないメンバー)が「テックブログ書けそうかも」と言っていた」などの社内情報シェア&当該メンバーへの後押し(ここで後押しすることで、運営メンバー以外も寄稿してくれることがままあります)

    ちなみに、Synamonのテックブログ運営チームは複数ある開発チームを横断して結成されているため、見えなくなってしまいがちなそれぞれの状況のシェア&交流の場になっているのも副産物として良いな、と感じています。



    その他、テックブログ運用を継続する上で効果を感じたもの

外部からのリアクション

やはり、記事を書いたエンジニア視点で嬉しいのは「その記事を読んだ人の反応」。
RTが伸びればそれだけ困った人に届くということ、いいねが伸びればそれだけ役に立てた、興味を持ってもらえる記事を書けたということ。
外部からのリアクションは、記事の影響力や役に立ち度合いを測る一番直接的な指標であり、やる気に繋がるという意見は社内でも多かったです。

テックブログの反応はスター数、View数、RT数、いいね数…と数値で追えるものもありますが、そうでないものも。
Synamonではカジュアル面談や、採用イベントに参加してくれたエンジニアが「いつもテックブログ見てます」「発信力高いですね!」と好意的なコメントをくれることが非常に多く、人事はこの意見をメモし、即社内に展開することを心がけています。
エンジニアメンバーから人事には「伝えてくれてありがとう」と感謝が伝えられ、人事も自社のエンジニアが喜ぶ姿が見たいので共有する…という良いサイクルが築けており、これも運用を支える一つの要素になっています。

個人のモチベーション

チームを立ち上げたからといって、結局書くか書かないかはその個人に委ねられてしまう。
そんな時背中を押すのは自分自身のモチベーションであり、Synamon社内では以下のようなモチベーションが多く伺えました。

「まだ世の中にない情報を言語化して残したい」
「自分が困るということは、世の中に困っている人が他にもいるということ。自分がエンジニアになったばかりのころ、たくさんのテックブログに助けられたから、自分が書く側になることでエンジニアコミュニティに貢献したい」
「自分自身のアウトプットや実績として残るので、今後のキャリアに繋がる(運用も、執筆も)」

仲間づくりをする上では、上記個人観点のモチベーションも刺激してあげると、スムーズに回ることもあるかもしれませんね。

目的の共有

「あれ、俺、何のためにわざわざ会社のテックブログ書いてるんだっけ…?いらなくない?」
実はSynamonにも当初「なんで書くのかわからない」という意見は一定数あり、一度テックブログを書く目的をエンジニア主体で整理&共有しました。
Synamonの場合はざっくり以下の形。

【会社視点】
  • 外部のエンジニアに対する認知向上
  • 採用面で有用
【個人視点】
  • エンジニア個人のブランディング
  • アウトプット能力の向上
【会社&個人視点】
  • 普段つくっているものに価値があるということの再認識

これに、上記の外部リアクションが結果として返ってきて、「目的が達成されている!」という達成感に繋がったと考えています。

「ここまで続けたんだ…!」という実績

これは言わずもがななところもありますが、週1更新が半年を超えたあたりから、明らかに社内の空気が変わりました
具体的に言うと、明確に担当者に「ここまで続いたものを、自分の番で落とすわけにはいかない…」という使命感が現れるようになりました(現にわたしも今その使命感を感じています)。

加えて、Synamonの場合だと毎週金曜に全社で実施している社内MTG内に「バリュー&winセッション」というコーナーがあり、ここで毎週テックブログの更新が祝われます。
エンジニア、人事以外のデザイナーやBizDev、経営陣も「すごいね」と拍手し、もはや全社的な習慣と文化になりつつあるのも使命感を強くします。
ここで来週の担当者も発表されるのですが、ここで流れる空気は「まさかお前、落とさないよな…!?」というギスギスしたものでなく、「自分、来週落とさないように頑張ります!!ww」「頑張れ~!ww」という前向きなものなのも大切な点。
週1更新を1年続けてこれましたが、あくまで最優先は通常業務という共通認識があるので、テックブログに関してはプラスアルファで頑張ろうの雰囲気が醸成されているのかなと思います。


週1更新を1年続けてよかったこと

認知・ブランディングに効いた

ここまでにも何度か触れましたが、まず「テックブログを頑張っている会社」という認知・ブランディングに効いたのはとても良かったと思っています。
数値としても徐々に出始めていて、積極的な運用を始めた21年の7月と比較すると、ユーザー数が178%アップ、セッション数が221%アップ、直帰率が3.6%ダウン、セッション継続時間が37.5%アップ…と、すべての数値が良い方向に伸びています
とはいえテックブログの運用はかなりの長期施策。今後もコツコツと積み重ねていきます。

技術者コミュニティへの貢献

外部のリアクションからも、我々の記事が技術者界隈に届き、良い影響を与えられたことが伺えて、テックカンパニーとして非常に誇らしい気持ちです。

テックブログが社内のナレッジ倉庫に

社内にもいい影響は出ていて、大体テックブログのネタはSynamonのエンジニアが躓くポイントになっているので、エンジニアが参照する良いナレッジのストック場所となりました。
Synamonをすでに卒業したメンバーの記事に助けられたりすることもしばしば。Synamonエンジニアチームの歴史が詰まったブログになっています。

情報発信に積極的な社内文化の形成

文化についてもいい影響の方が大きく、テックブログに感化されて、コーポレートチームもこの形式に則って「週1でnote出そう」という動きが起こり始めました。
情報発信に強い会社が今後の採用市場で優位なポジションを取っていくことは揺るがないでしょうし、今後も部署問わずに無理ない範囲でどんどん社内にいい影響を与えながらテックブログを育てていければと考えています。


今の課題と今後の取り組み

一番大きな課題は何といってもその自転車操業ぶり
ここまで読んでお気づきの方もいらっしゃるかもしれませんが、「来週も落とさないように頑張ります」=「まだ書けてない」ということ。今は毎週担当者が、仕組みと、使命感、モチベーションに突き動かされて書いています。
もうすこし余裕を持った運営にしたいので、現在はストック記事の準備に取り組んでいます(そして、この記事がストック記事第一弾)。

また、寄稿は度々あるものの、やはりまだテックブログ運営チームの執筆比率が高く負担が大きいので、無理のない範囲で社内メンバーの巻き込みもどんどん進めていきたいと考えています。
現時点でもSlack上の「テックブログで」絵文字、運営定例時の「あの人良いネタありそう」のシェアで巻き込みを行っていますが、もっと加速できる仕組みを模索中です。読んでいる方でワークした仕組みがある方はぜひ教えてください。


おわりに

本記事は、過去のテックブログ運営に関する記事を元に、エンジニアへのヒアリングをもって1年の振り返りとして書かせていただきました。
もっと初期~運用半年くらいの泥臭く手触り感ある話も詳しく聞きたい!という方はぜひ以下の記事もご参照ください(星影先生、本当にありがとうございました!!!)。

synamon.hatenablog.com

synamon.hatenablog.com

また、Synamonは現在幅広い職種で積極的に採用を行っております。
メタバースプラットフォーム開発の会社ですが、Unityだけでなく、バックエンド、フロントエンド、ネットワーク、ブロックチェーン…と幅広くエンジニアを募集しています。
少しでもご興味ある方は以下からご確認ください!

▼採用総合ページ(各種イベント案内もあります)

www.notion.so
▼カジュアル面談

meety.net

皆さんのテックブログライフがより良いものとなれば幸いです!

UnityのBuildSettingsウィンドウの表記とスクリプティングAPIの対応を纏めました(Unity 2021.3.0f1)


エンジニアの岡村です。

Unityのアプリ開発をチームで行う際は、Git等のバージョン管理ツールがほぼ必ず使われます。これを使って他のメンバーとプロジェクトフォルダを共有するのですが、Unityをビルドする際に使われる設定の中にはプロジェクトフォルダの外に保存されるものがあります。

Editorの再生ボタンを叩いている間であれば問題になる事は少ないです。ですが、ビルドしたときの挙動を検証したい時など、作業者それぞれのPCでビルド設定が異なっていると正しく現象を再現できないことがあります。

また、CIで自動ビルドを行う際にも、ビルドマシン上でGUIを開き設定を変えなければなりませんし、普段確認できないために設定が変わっても気づかず、そうするといつの間にかおかしな状態に……という事もあります。これらの問題を解決するためにプロジェクト外に保存される設定も、少なくとも社内テスト版やリリース版に関しては設定内容のバージョン管理をしたくなります。

そこで、弊社のプロジェクトではそれらの設定をC#スクリプトから上書きすることにより、CIワークフローの一部としてプロジェクトフォルダ内で(コードとして)バージョン管理出来るようにしています。


プロジェクトフォルダに保存されない設定は、外部アプリケーションや実行環境に依存するようなものを除き、基本的にはBuild Settingsウィンドウに集約されています。このウィンドウはGUIベースで操作する分には幾分直感的なのですが、GUIの表記とC#のAPIが一致していなかったり名前空間が別の場所にあったり等、内部はかなりカオスになっています。公式ドキュメントもあまり詳しい情報が乗っていません。自分がBuild Settingsをコードから触ろうとした時には項目一つ一つについて調べる必要があった為そこそこ時間を取られました。

なので、今後自分や他の人が参考にするために、Build SettingsウィンドウとC#スクリプトの対応付けを今回テックブログの記事として紹介することにしました。

前提

環境はUnity 2021.3.0f1です。バージョンが前後するとBuild Settingsの内容も変化している可能性がありますのでご了承ください。

Build Settingsの多くの設定項目のAPIは EditorUserBuildSettingsBuildPlayerOptions に含まれています。それらのAPI呼び出しを以下の様なC#コードで記述し検証しました。

using System.Linq;
using UnityEditor;
using UnityEngine;

public static class BuildScript
{
    [MenuItem("Build/Windows")]
    public static void BuildWindows()
    {
        var buildPlayerOptions = new BuildPlayerOptions();

        // ここにBuild Settingsの内容をコードで書く

        var report = BuildPipeline.BuildPlayer(buildPlayerOptions);

        Debug.Log(report.summary.result);
    }
}

ビルドプラットフォームについて

Build Settingsウィンドウにはビルドターゲットのプラットフォーム指定をするUI(以下の画像における黄色の領域)があるのですが、ちょっと複雑なので事前に説明します。

ビルドプラットフォームの指定は、UIにおいてはPlatform, Target Platform, Architectureという表示になっていますが、それぞれBuildTargetGroup, BuildTarget, BuildSubtargetに(一部)対応しています。

それぞれ別のEnumの値を指定する必要があり、かなり複雑に見えますが、実際の所、中身はStandalone周りが多少パターンが多いくらいで、全体として有効な組み合わせの数は少なくなっています。

BuildTargetGroup BuildTarget BuildSubtarget
Unknown
Standalone StandaloneWindows StandaloneBuildSubtarget.Player
Standalone StandaloneWindows StandaloneBuildSubtarget.Server
Standalone StandaloneWindows64 StandaloneBuildSubtarget.Player
Standalone StandaloneWindows64 StandaloneBuildSubtarget.Server
Standalone StandaloneOSX StandaloneBuildSubtarget.Player
Standalone StandaloneOSX StandaloneBuildSubtarget.Server
Standalone StandaloneLinux64 StandaloneBuildSubtarget.Player
Standalone StandaloneLinux64 StandaloneBuildSubtarget.Server
iOS iOS
Android Android
WebGL WebGL
WSA WSAPlayer
PS4 PS4
XboxOne XboxOne
tvOS tvOS
Switch Switch
Stadia Stadia
CloudRendering CloudRendering
PS5 PS5

C#で実際に指定する場合は以下のように書きます。

buildPlayerOptions.targetGroup = BuildTargetGroup.Standalone;
buildPlayerOptions.target = BuildTarget.StandaloneWindows64;
buildPlayerOptions.subtarget = (int)StandaloneBuildSubtarget.Player; // デフォルトがPlayerなので省略可能

また、NamedBuildTarget というenumを使ったプラットフォーム指定方法もありますが、こちらは後に紹介するコードでは使っていないため詳細は割愛します。Dedicated ServerへのSwitch Platformにはこれを使います。

EditorUserBuildSettings.SwitchActiveBuildTarget(NamedBuildTarget.Server, BuildTarget.StandaloneWindows64);

Architectureの指定について

WindowsのArchitectureはBuildTargetに含まれていますが、macOSでは指定方法が異なります。

Windows

buildPlayerOptions.target = BuildTarget.StandaloneWindows64;

macOS

#if UNITY_STANDALONE_OSX
UnityEditor.OSXStandalone.UserBuildSettings.architecture = UnityEditor.OSXStandalone.MacOSArchitecture.x64ARM64;
#endif

余談:ビルド時にプラットフォームを切り替えてもビルドスクリプト中の#if分岐は有効にならない

本筋とはあまり関係ありません。

前述のmacOSにおけるアーキテクチャの指定は、UnityEditorにmacOSビルド用のモジュールをインストールしていないとコンパイルエラーになってしまいます。UNITY_STANDALONE_OSXシンボルが有効になっているならばmacOSプラットフォームにSwitch Platform済、つまりmacOSビルド用のモジュールがインストール済みであるので、これを特定プラットフォーム限定のビルド設定にアクセスする為の判定に使っています。しかし、ビルドスクリプト内でSwitch Platformを呼び出していると、ビルドスクリプト自身に対してシンボルの切り替えが反映されず、#ifで囲まれた部分がスキップされてしまうという問題をはらんでいます。

解決するにはコマンドライン引数の -buildTarget を使い、最初からプラットフォームを指定して起動するか、SwitchPlatformを実行するだけのステップと、ビルドを行うステップに分けて、2回スクリプトを実行する等の対策が必要になります。

もしくは、もう一つの解決策として、SetPlatformSettingsを使うことで特定モジュールに依存せずにプラットフォーム限定の設定を行うことも可能ですが、こちらはどんなキーがありどんな値があるのかのドキュメントが存在しておらずあまり触れていません。一応以下の様に記述することでmacOSのアーキテクチャ指定が機能することは確認しています。

EditorUserBuildSettings.SetPlatformSettings("Standalone", "OSXUniversal", "Architecture", "x64");

Build Settings 一覧

本題のBuild SettingsとC#の対応一覧です。

Windows, Mac, Linux, (Dedicated Server), Android, iOS, WebGLの調査を行いました。スクリーンショットと比較して扱いやすいよう、かなり冗長に記載しています。また、外部のデバイスやプログラムに強く依存するような(手動でビルドするときに使うような)項目は調査していません。

ちなみに、Dedicated ServerのBuild SettingsはWindows, Mac, Linuxとの違いはありませんでした。

Windows

var buildPlayerOptions = new BuildPlayerOptions();

// Platform
buildPlayerOptions.targetGroup = BuildTargetGroup.Standalone;
buildPlayerOptions.subtarget = (int)StandaloneBuildSubtarget.Player;

// Target Platform & Architecture
buildPlayerOptions.target = BuildTarget.StandaloneWindows;
buildPlayerOptions.target = BuildTarget.StandaloneWindows64;

#if UNITY_STANDALONE_WIN

// Copy PDB files
UnityEditor.WindowsStandalone.UserBuildSettings.copyPDBFiles = false;

// Create Visual Studio Solution
UnityEditor.WindowsStandalone.UserBuildSettings.createSolution = false;

#endif

// Development Build
buildPlayerOptions.options |= BuildOptions.Development;

// Autoconnect Profiler
buildPlayerOptions.options |= BuildOptions.ConnectWithProfiler;

// Deep Profiling
buildPlayerOptions.options |= BuildOptions.EnableDeepProfilingSupport;

// Script Debugging
buildPlayerOptions.options |= BuildOptions.AllowDebugging;

// IL2CPP Code Generation = Faster runtime
EditorUserBuildSettings.il2CppCodeGeneration = Il2CppCodeGeneration.OptimizeSpeed;

// IL2CPP Code Generation = Faster (smaller) builds
EditorUserBuildSettings.il2CppCodeGeneration = Il2CppCodeGeneration.OptimizeSize;

// Compression Method = LZ4
buildPlayerOptions.options |= BuildOptions.CompressWithLz4;

// Compression Method = LZ4HC
buildPlayerOptions.options |= BuildOptions.CompressWithLz4HC;

// createSolution=trueの場合は拡張子に.slnを指定する
buildPlayerOptions.locationPathName = "Builds/Build.exe";
var report = BuildPipeline.BuildPlayer(buildPlayerOptions);
Debug.Log(report.summary.result);

macOS

var buildPlayerOptions = new BuildPlayerOptions();

// Platform
buildPlayerOptions.targetGroup = BuildTargetGroup.Standalone;
buildPlayerOptions.subtarget = (int)StandaloneBuildSubtarget.Player;

// Target Platform
buildPlayerOptions.target = BuildTarget.StandaloneOSX;

#if UNITY_STANDALONE_OSX

// Architecture
UnityEditor.OSXStandalone.UserBuildSettings.architecture = UnityEditor.OSXStandalone.MacOSArchitecture.x64ARM64;

// Create Xcode Project
UnityEditor.OSXStandalone.UserBuildSettings.createXcodeProject = false;

#endif

// Development Build
buildPlayerOptions.options |= BuildOptions.Development;

// Autoconnect Profiler
buildPlayerOptions.options |= BuildOptions.ConnectWithProfiler;

// Deep Profiling
buildPlayerOptions.options |= BuildOptions.EnableDeepProfilingSupport;

// Script Debugging
buildPlayerOptions.options |= BuildOptions.AllowDebugging;

// IL2CPP Code Generation = Faster runtime
EditorUserBuildSettings.il2CppCodeGeneration = Il2CppCodeGeneration.OptimizeSpeed;

// IL2CPP Code Generation = Faster (smaller) builds
EditorUserBuildSettings.il2CppCodeGeneration = Il2CppCodeGeneration.OptimizeSize;

// Compression Method = LZ4
buildPlayerOptions.options |= BuildOptions.CompressWithLz4;

// Compression Method = LZ4HC
buildPlayerOptions.options |= BuildOptions.CompressWithLz4HC;

// createXcodeProject=trueにした場合は拡張子に.xcodeprojを指定する
buildPlayerOptions.locationPathName = "Builds/Build.app";
var report = BuildPipeline.BuildPlayer(buildPlayerOptions);

Debug.Log(report.summary.result);

Linux

var buildPlayerOptions = new BuildPlayerOptions();

// Platform
buildPlayerOptions.targetGroup = BuildTargetGroup.Standalone;
buildPlayerOptions.subtarget = (int)StandaloneBuildSubtarget.Player;

// Target Platform
buildPlayerOptions.target = BuildTarget.StandaloneLinux64;

// Development Build
buildPlayerOptions.options |= BuildOptions.Development;

// Autoconnect Profiler
buildPlayerOptions.options |= BuildOptions.ConnectWithProfiler;

// Deep Profiling
buildPlayerOptions.options |= BuildOptions.EnableDeepProfilingSupport;

// Script Debugging
buildPlayerOptions.options |= BuildOptions.AllowDebugging;

// IL2CPP Code Generation = Faster runtime
EditorUserBuildSettings.il2CppCodeGeneration = Il2CppCodeGeneration.OptimizeSpeed;

// IL2CPP Code Generation = Faster (smaller) builds
EditorUserBuildSettings.il2CppCodeGeneration = Il2CppCodeGeneration.OptimizeSize;

// Compression Method = LZ4
buildPlayerOptions.options |= BuildOptions.CompressWithLz4;

// Compression Method = LZ4HC
buildPlayerOptions.options |= BuildOptions.CompressWithLz4HC;

buildPlayerOptions.locationPathName = "Builds/Build.x86_64";
var report = BuildPipeline.BuildPlayer(buildPlayerOptions);

Debug.Log(report.summary.result);

Android

var buildPlayerOptions = new BuildPlayerOptions();

// Platform
buildPlayerOptions.targetGroup = BuildTargetGroup.Android;
buildPlayerOptions.target = BuildTarget.Android;

// Texture Compression = Use Player Settings
EditorUserBuildSettings.androidBuildSubtarget = MobileTextureSubtarget.Generic;

// ETC2 fallback
EditorUserBuildSettings.androidETC2Fallback = AndroidETC2Fallback.Quality32Bit;

// Export Project
EditorUserBuildSettings.exportAsGoogleAndroidProject = false;

// Symlink Sources
EditorUserBuildSettings.symlinkSources = false;

// Build App Bundle (Google Play)
EditorUserBuildSettings.buildAppBundle = true;

// Create symbols.zip
EditorUserBuildSettings.androidCreateSymbols = AndroidCreateSymbols.Disabled;

// Run Device及びBuild to Deviceは未調査

// Development Build
buildPlayerOptions.options |= BuildOptions.Development;

// Autoconnect Profiler
buildPlayerOptions.options |= BuildOptions.ConnectWithProfiler;

// Deep Profiling
buildPlayerOptions.options |= BuildOptions.EnableDeepProfilingSupport;

// Script Debugging
buildPlayerOptions.options |= BuildOptions.AllowDebugging;

// IL2CPP Code Generation = Faster runtime
EditorUserBuildSettings.il2CppCodeGeneration = Il2CppCodeGeneration.OptimizeSpeed;

// IL2CPP Code Generation = Faster (smaller) builds
EditorUserBuildSettings.il2CppCodeGeneration = Il2CppCodeGeneration.OptimizeSize;

// Compression Method = LZ4
buildPlayerOptions.options |= BuildOptions.CompressWithLz4;

// Compression Method = LZ4HC
buildPlayerOptions.options |= BuildOptions.CompressWithLz4HC;

// buildAppBundle=trueにした場合は拡張子に.aabを指定する
buildPlayerOptions.locationPathName = "Builds/Build.apk";
var report = BuildPipeline.BuildPlayer(buildPlayerOptions);

Debug.Log(report.summary.result);

iOS

var buildPlayerOptions = new BuildPlayerOptions();

// Platform
buildPlayerOptions.targetGroup = BuildTargetGroup.iOS;
buildPlayerOptions.target = BuildTarget.iOS;

// Run in Xcodeは未調査

// Run in Xcode as
EditorUserBuildSettings.iOSXcodeBuildConfig = XcodeBuildConfig.Release;

// Symlink Sources
EditorUserBuildSettings.symlinkSources = false;

// Development Build
buildPlayerOptions.options |= BuildOptions.Development;

// Autoconnect Profiler
buildPlayerOptions.options |= BuildOptions.ConnectWithProfiler;

// Deep Profiling
buildPlayerOptions.options |= BuildOptions.EnableDeepProfilingSupport;

// Script Debugging
buildPlayerOptions.options |= BuildOptions.AllowDebugging;

// IL2CPP Code Generation = Faster runtime
EditorUserBuildSettings.il2CppCodeGeneration = Il2CppCodeGeneration.OptimizeSpeed;

// IL2CPP Code Generation = Faster (smaller) builds
EditorUserBuildSettings.il2CppCodeGeneration = Il2CppCodeGeneration.OptimizeSize;

// Compression Method = LZ4
buildPlayerOptions.options |= BuildOptions.CompressWithLz4;

// Compression Method = LZ4HC
buildPlayerOptions.options |= BuildOptions.CompressWithLz4HC;

buildPlayerOptions.locationPathName = "Builds/Build.xcodeproj";
var report = BuildPipeline.BuildPlayer(buildPlayerOptions);

Debug.Log(report.summary.result);

WebGL

var buildPlayerOptions = new BuildPlayerOptions();

// Platform
buildPlayerOptions.targetGroup = BuildTargetGroup.WebGL;
buildPlayerOptions.target = BuildTarget.WebGL;

// Texture Compression = Use default format(DXT)
EditorUserBuildSettings.webGLBuildSubtarget = WebGLTextureSubtarget.Generic;

// Development Build
buildPlayerOptions.options |= BuildOptions.Development;

#if UNITY_WEBGL

// Code Optimization
UnityEditor.WebGL.UserBuildSettings.codeOptimization = UnityEditor.WebGL.CodeOptimization.Speed;

#endif

// Autoconnect Profiler
buildPlayerOptions.options |= BuildOptions.ConnectWithProfiler;

// Deep Profiling
buildPlayerOptions.options |= BuildOptions.EnableDeepProfilingSupport;

// IL2CPP Code Generation = Faster runtime
EditorUserBuildSettings.il2CppCodeGeneration = Il2CppCodeGeneration.OptimizeSpeed;

// IL2CPP Code Generation = Faster (smaller) builds
EditorUserBuildSettings.il2CppCodeGeneration = Il2CppCodeGeneration.OptimizeSize;

buildPlayerOptions.locationPathName = "Builds/Build";
var report = BuildPipeline.BuildPlayer(buildPlayerOptions);

Debug.Log(report.summary.result);

以上

以上の調査結果については、一応一通り叩いてはみていますが、社内のプロジェクトでは深く触っていない項目もある為、間違っている情報もあるかもしれません。何か気付いたところがあればタレコミをお願いします。

UnityのAddressablesのRemoteコンテンツ運用 - ResourceProviderのカスタマイズ

こんにちは、エンジニアリングマネージャーの渡辺(@mochi_neko_7)です。

皆さんはUnityのAddressables(Addressable Asset System)を使っていますか?

自分はベータ版の頃に不安定だったという印象でこれまであまり触ってこなかったのですが、いざ触っているとかなり便利で現在は正式版になっているため不安定なところもそれほどありませんでした。

今後は積極的に使っていきたいところですが、Addressablesを使ったRemote(アプリケーション外)でのコンテンツ管理に関する情報が公式のドキュメント以外(英語しかない)にまとまったものがあまり見つからなかったため、取っ掛かりで少し苦労をしました。

docs.unity3d.com

ですので本記事とそれ以降の記事で、AddressablesでのRemoteコンテンツ管理の運用に関するノウハウや注意点などを紹介していきたいと思います。

Addressables自体の紹介やAPIの触り方に関しては弊社テックブログのこちらの記事

synamon.hatenablog.com

をはじめ他にも検索するといくつも入門の記事が出てくると思います。

まだAddressablesを触ったことないよ、という方はまずはこちらを参照してAddressablesの導入をしてみて、実際にアセットのロード・アンロードのAPIを触ってみてください。

ここでは入門の内容から一歩踏み込んで、Remoteでのコンテンツ管理、つまりアプリケーションのリリース後にコンテンツの更新や追加ができることを目指して、実装すべき内容、Addressablesの内部使用や運用上注意すべき点などを紹介します。

公式のドキュメントを読んでもRemoteでのコンテンツ管理の全体像がいまいち掴めない方の参考になれば幸いです。

今回使用しているバージョンは以下になります。

  • Unity 2021.3.0f1
  • Addressables 1.19.19
    • 公式ドキュメントは 1.20 の方が情報が多いのでリンクは1.20を使っています

前提

まずAddressablesを採用することで得られるメリットを簡単におさらいします。

アセットのメモリ使用量を最適化することができる

Built-inのアセットはアプリケーション起動時に全てメモリ上に展開されますが、Addressablesでは使用しているアセットのみメモリに載せるような管理をすることができる。

blog.unity.com

docs.unity3d.com

アセットのロードを非同期的に行うことができる

Addressablesはストレージやネットワークからアセット(正確にはAssetBundle)をロードしますが、そのAPIはawaitで非同期*1に呼び出すことができます*2

docs.unity3d.com

アセットをロードするときにそのアセットがLocalにあるかRemoteにあるかを意識しなくてよい

PlayModeScriptという機能を使用することで、開発中はEditor上で直接ロードし*3素早くデバッグしながら、本番ではLocalやRemoteにあるビルド済みのAssetBundleからロードすることができ、その使い分けをコードを変更することなく実現できます。

tsubakit1.hateblo.jp

アプリケーション本体のデータ量を削減することができる

一部のアセットをRemoteから取得するようにすると、その分アプリケーション本体に同梱するアセットの量を減らすことができ、アプリのデータサイズを小さくすることができます。

アプリケーションのバージョンを上げずに外部からのコンテンツの追加や更新をすることができる

Remoteから取得するようにしたアセットはアプリケーションのリリース後に変更できる設定にすることができます。

それによりアプリケーションの機能開発とコンテンツの開発運用を切り分けた柔軟な運用をすることができます。

docs.unity3d.com

こちらのドキュメントには重要なことがたくさん書かれているため、機械翻訳でも良いので一読することをお勧めします。

ちなみにAddressablesではLocalのアセット管理に限定したとしても十分有用なので、ほとんどのプロジェクトで導入することを個人的にはお勧めできます。

アセットのロードフロー

Remoteのコンテンツのダウンロード処理のカスタマイズ方法を紹介する前に、Addressableにおけるアセットのロードフローの全体像を理解しておいた方がイメージが掴みやすいと思いますので簡単に紹介します。

おそらく最も標準的なAPIの Addressables.LoadAssetAsync(...) を起点として、ソースコードを追ってみましょう。

public static AsyncOperationHandle<TObject> LoadAssetAsync<TObject>(object key)
{
    return m_Addressables.LoadAssetAsync<TObject>(key);
}

すると処理自体は内部の AddressablesImpl に移譲されているので、そちらを見てみます。

public AsyncOperationHandle<TObject> LoadAssetAsync<TObject>(object key)
{
    QueueEditorUpdateIfNeeded();
    if (ShouldChainRequest)
        return TrackHandle(LoadAssetWithChain<TObject>(ChainOperation, key));

    key = EvaluateKey(key);

    IList<IResourceLocation> locs;
    var t = typeof(TObject);
    if (t.IsArray)
        t = t.GetElementType();
    else if (t.IsGenericType && typeof(IList<>) == t.GetGenericTypeDefinition())
        t = t.GetGenericArguments()[0];
    foreach (var locatorInfo in m_ResourceLocators)
    {
        var locator = locatorInfo.Locator;
        if (locator.Locate(key, t, out locs))
        {
            foreach (var loc in locs)
            {
                var provider = ResourceManager.GetResourceProvider(typeof(TObject), loc);
                if (provider != null)
                    return TrackHandle(ResourceManager.ProvideResource<TObject>(loc));
            }
        }
    }
    return ResourceManager.CreateCompletedOperationWithException<TObject>(default(TObject), new InvalidKeyException(key, t, this));
}

やや追いづらいところもあるかもしれませんが、基本的な処理は以下になります。

1: 引数の key が何か特別なものなら変換をし、

key = EvaluateKey(key);

2: IResourceLocator の列挙をforeachで回して、

foreach (var locatorInfo in m_ResourceLocators)

3: keyを使ってアセットが特定できたら、

if (locator.Locate(key, t, out locs))

4: IResourceLocationで指定されている IResourceProviderを取得して AyncOperationHandle を生成する。

var provider = ResourceManager.GetResourceProvider(typeof(TObject), loc);
if (provider != null)
  return TrackHandle(ResourceManager.ProvideResource<TObject>(loc));

2の IResourceLocator というのはkey(通常はAddress)とそれに紐づくアセットの対応関係の情報を持っているものです。

docs.unity3d.com

ちなみにContentCatalogはメモリ上ではこの IResourceLocator として扱われますので、Catalogを知っている方はどんな情報を持っているかイメージできるかと思います。*4

3の Locate(...) の戻り値は IResourceLocation というもので、アセットをロードするために必要な情報を持っているものです。

docs.unity3d.com

4の IResourceProvider は実際にアセットをロードする機能を持っているものです。

docs.unity3d.com

アセットのロード処理に登場する主な登場人物は、

  • key(Address)
  • IResourceLocator
  • IResourceLocation
  • IResourceProvider

となりますので、これらの役割を理解しておくと良いと思います。

ResourceProviderのカスタマイズ

Remoteで管理しているコンテンツを実行時に取得して利用するということは、アプリケーションの実行時にネットワーク経由で特定のサーバーで管理されているAssetBundle(とCatalog)をダウンロードして利用するということになります。

Unityが提供しているクラウドサービスを利用することもできるのですが、自社のサーバーで管理したいというケースもあり、後者は自分でダウンロード処理をカスタマイズする必要があります。

ここでは後者の場合で、特にAssetBundleのダウンロード処理をカスタマイズする方法を紹介します。

前節で紹介したので予想ができるかもしれませんが、AssetBundleのダウンロード処理のカスタマイズは先ほど紹介した IResourceProvider を自作することで可能になります。

ベースクラスの ResourceProviderBase が用意されているので、これを継承して実装することができます。

docs.unity3d.com

基本的な操作はロード時に IResourceProvider.Provide(...) 、アンロード時に IResourceProvider.Provide(...) が呼ばれるイメージです。

Provide(...) メソッドの引数の ProvideHandle には ProvideHandle.Location というPropertyで IResourceLocation を持っているため、ここに書かれている情報を元にAssetBundleのロード処理をカスタマイズすることができます。

特に IResourceLocation.InternalId というPropertyはURLを書き込んだり、WebAPIのアクセスに必要なパラメータを持たせたりある程度柔軟に使って良いとされています。

docs.unity3d.com

例えばGroupの設定に独自のSchemaを用意してURLやパラメータを持っておき、カスタマイズしたBuild Scriptでビルド時にInternalIdを書き換えて使用することも可能です。

もう一つ IResourceLocation.Data というobject型の一見どう使っていいか分からないPropertyもありますが、AssetBundleRequestOptions というTypeにCastすることができ、HashやCRCなどの情報を取得できるので意外と重要です。

docs.unity3d.com

Remoteに置きたいGroupの設定を変更する

カスタマイズしたResourceProviderはAddressables Groups WindowのGroupの設定で使用するProviderに指定してあげる必要があります。

その設定も含めて、Groupの設定でRemoteに置く場合に気を付けるべき項目を紹介します。

  • Build & Load Paths
    • ここは Remote に設定する必要があります
  • Asset Bundle Compression
    • Remoteから取得するGroupは圧縮率の大きい LZMA が推奨されています
    • docs.unity3d.com
  • Internal Asset Naming Mode
    • 含まれているアセットの InternalId の持ち方を設定できます
    • 基本的には任意の設定で良いのですが、Sceneを含む場合は Full Path に設定しないとSceneのロードができなくなるので注意が必要です*5
    • docs.unity3d.com
  • Bundle Mode
    • Pack Together の場合でもSceneを含むときはSceneのAssetBundleとそれ以外のアセットを含むAssetBundleの2つに分割されることに注意してください
    • docs.unity3d.com
  • Bundle Naming Mode
    • ビルドしたAssetBundleのファイル名の指定方法を設定できます
    • 名前を隠したい場合はHashに設定することも有効ですが、ビルドした後にサーバーにアップロードしたり管理する際にはファイル名が残っているほうが管理しやすいかもしれません
    • docs.unity3d.com
  • Asset Provider
    • 含まれる個別のアセットのロード方法をカスタマイズするときにはここでProviderを指定します
  • Asset Bundle Provider
    • 前節で作成したAssetBundleのロード方法をカスタマイズしたProviderはここで指定します

おわりに

ResourceProviderをカスタマイズをすることでAssetBundleを任意の場所からロードして使用することができるようになります。

その内部にはCRCのチェック、ストレージへのキャッシュの生成、データの暗号化などプロジェクトの性質に合わせてさらにカスタマイズを加えることもできます。

ただ、AssetBundleをアプリのリリース後に更新したり追加したりするためにはこれだけでは不十分です。

アセットのロードフローを見返してみると、IResourceProviderIResourceLocation で指定されているもので、それは IResourceLocator (ContentCatalog)から取得できるものでした。

つまりContentCatalogに更新した、あるいは追加したAssetBundleの情報が含まれていないと原理的にロードできないことが分かります。

ですのでContentCatalogの運用もとても重要なのですが、これもあまりドキュメントの情報量がなく仕様が掴みづらいものですので、また次回の記事で詳しく紹介したいと思います。

プロジェクトの性質によって変わる部分は省略してしまったので、適宜設定や実装を補完してください。

本記事でAddressablesのアセットのロードフローの全体像、RemoteからAssetBundleを取得する方法のとっかかりを掴んでいただけると幸いです。

*1:AyncOperationHandle.WaitForCompletion()で同期的に呼ぶことも可能です

*2:CancellationTokenを渡す口を生やしてくれるとなお良いのですが...

*3:正確にはAssetDatabase経由で直接ロードしたり、Groupの構造をエミュレートしたりしてくれます

*4:Catalogの詳しい説明をするときに紹介しますが、foreachで回しているだけ、という仕様は意外と重要なポイントになります。

*5:SceneProviderの実装を追うと、InternalIdをSceneのPathに使用していることが分かると思います

Terraform&Github ActionsのCI/CD環境でECSタスク定義の二重管理を解消する方法

こんにちは、エンジニアのクロ(@kro96_xr)です。バックエンドを中心にフロントエンドやらインフラやら色々担当しています。

今回はTerraformとGithub Actionsを組み合わせてECS Fargateを運用していくにあたり、

  • タスク定義の二重管理が発生していた話
  • 二重管理を解消するために行った対応

について書いていきたいと思います。

なぜ二重管理が発生したのか

今回の環境ではTerraformでECSのリソースを作成、Github Actionsでデプロイする形で開発しており、リソース作成時、デプロイ時の両方でタスク定義が必要なため二重管理が発生していました。

具体的には、リポジトリ上にタスク定義のjsonファイルを置いて、下記のようなGithubActionsで指定する形を取っていました。

      - name: Render Amazon ECS task definition
        id: render-container
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: task.json
          container-name: container_name
          image: 000000000000.dkr.ecr.ap-northeast-1.amazonaws.com/repo:latest

      - name: Deploy to Amazon ECS service
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.render-container.outputs.task-definition }}
          service: sample-service
          cluster: sample-cluster

このままだとTerraformからタスク定義を修正した際に、リポジトリ上のファイルの修正を忘れると大変なことになります。

実際に発生する可能性は低いとは思いますが、こういったリスクは少しでも減らしておきたいですね。

対応

対応方法はamazon-ecs-deploy-task-definitionに記載がありました。

GitHub - aws-actions/amazon-ecs-deploy-task-definition: Registers an Amazon ECS task definition and deploys it to an ECS service.

If you do not wish to store your task definition as a file in your git repository, your GitHub Actions workflow can download the existing task definition.

    - name: Download task definition
      run: |
        aws ecs describe-task-definition --task-definition my-task-definition-family --query taskDefinition > task-definition.json

というわけで、修正後のGithub Actionsは以下のようになります。

      - name: Download task definition
        run: |
          aws ecs describe-task-definition --task-definition sample-definition --query taskDefinition > task-definition.json

      - name: Deploy to Amazon ECS service
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: task-definition.json
          service: sample-service
          cluster: sample-cluster

これでタスク定義の一元管理が行えるようになりました。

おわりに

ECSにGithub Actionsからデプロイする方法を調べると、リポジトリでタスク定義ファイルを管理する方法の方が多くヒットするため、あまり同じ悩みを抱えている方はいないのかもしれませんが参考になれば幸いです。

あるいはこの方法の問題点などございましたらTwitter等でご連絡いただけると幸いです!

GoでHTTP/3サーバーを試してみる

はじめに

エンジニアの松原です。今年6月にHTTP/3がRFC 9114として標準化されましたが、主要なWebフレームワークで対応するのはまだ時間がかかりそうなイメージがあります。
この背景にHTTP/3ではQUICという新しいプロトコルを利用すること、HTTP/3で通信するためにはTLSのバージョン1.3が必要など、大幅な変更を取り込む必要があるため、実開発にHTTP/3を用いるにはもう少し先になりそうです。

今回の記事ではとりあえずHTTP/3を触ってみるということを優先して、quic-goを利用してHTTP/3が試せるGoのサーバーを構築してみました。今回はGoでサーバーとクライアントのアプリケーションを簡易的に作って試してみます。

github.com

以下の記事では、go version 1.18 以降が入っていることを前提に書いています。

簡易HTTP/3サーバーを作る

まずは quic-goをインストールするために、以下のコマンドを実行します。

go mod init server
go get github.com/lucas-clemente/quic-go

上記コマンドにより、go.mod と go.sum というファイルが作成され、パッケージの依存解決してくれるようになります。 次に main.go ファイルを作成し、以下のように記述します。 ./localhost.crt./localhost.key に関しては、こちらのQiitaの記事(OpenSSLコマンドの備忘録)を参考にしました。

package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/lucas-clemente/quic-go/http3"
)

func main() {
    fmt.Println("The server is started.")

    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
        fmt.Printf("remote: %s\n", req.RemoteAddr)
        fmt.Fprint(w, "hello world!")
    })

    err := http3.ListenAndServeQUIC(
        "localhost:18443",
        "./localhost.crt",
        "./localhost.key",
        mux,
    )
    if err != nil {
        log.Fatal(err)
    }
}

qiita.com

サーバー側は以下のコマンドで実行します。実行後は立ち上がりっぱなしになるため、終了したい場合はCtrl+Cを押します。

go run main.go

簡易HTTP/3クライアントを作る

サーバーと同様、簡易のクライアントを作成します。サーバーとは異なるディレクトリで作成していきます。
サーバーアプリと同じように quic-goをインストールするために、以下のコマンドを実行します。

go mod init client
go get github.com/lucas-clemente/quic-go

次に main.go ファイルを作成し、以下のように記述します。

package main

import (
    "fmt"
    "io/ioutil"
    "log"
    "net/http"

    "github.com/lucas-clemente/quic-go/http3"
)

func main() {
    rt := http3.RoundTripper{}
    defer rt.Close()
    client := &http.Client{
        Transport: &rt,
    }

    res, err := client.Get("https://localhost:18443/")
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("protocol: %s\n", res.Proto)
    fmt.Printf("status code: %d\n", res.StatusCode)

    body, _ := ioutil.ReadAll(res.Body)
    fmt.Printf("body: %s\n", string(body))
}

実行してみる

簡易HTTP/3クライアントを実行します。

go run main.go

うまくいけば以下のようなメッセージが標準入出力に出てくると思います。

protocol: HTTP/3.0
status code: 200
body: hello world!

簡単な解説

サーバー側実装

今回はquic-goを利用して簡易的なHTTP/3サーバーを構築しています。以下のコードは、Go公式のhttp.ListenAndServeTLS()をquic-goの機能に置き換えて、HTTP/3サーバーとして動作させています。このメソッドを利用するとサーバーがTCPではなく、UDPで待ち受けるようになるため、クライアント側のHTTP/3用のプロトコル対応が必須になります。

   err := http3.ListenAndServeQUIC(
        "localhost:18443",
        "./localhost.crt",
        "./localhost.key",
        mux,
    )

クライアント側実装

クライアント側では上記の設定に対応するために、Go公式のhttp.ClientTransport にquic-goで用意されている http3.RoundTripperを指定することで、HTTP/3対応のHTTPクライアントとして動作させています。ただし、HTTP/1.1やHTTP/2には対応しなくなるため、HTTP/3非対応のページは読み込めなくなります(www.google.comはHTTP/3対応のため読み込めますが、www.yahoo.co.jpなどHTTP/2のサイトはエラーが発生します)。

   rt := http3.RoundTripper{}
    defer rt.Close()
    client := &http.Client{
        Transport: &rt,
    }

まとめ

この記事では、quic-goを利用してHTTP/3のサーバークライアントのHelloWorldを体験してみました。quic-goを使うと非常に簡素に書けるので、あまりGoに触り慣れていない方にもコードは追いやすくなっていると思います。

まだ実サービスレベルではHTTP/3は扱いづらい印象はありますが、gRPCがHTTP/3(QUIC)対応に動いているところを見ても、今年から来年にかけて利用する機会が増えてくるのではないかと思います。 次回以降はgRPCも試して、記事にできればと思います。

unitypackage形式のアセットをupmパッケージに改造する

エンジニアの岡村です。

Unity2020.1から実装されたupmはバージョンアップを重ね、GUIでのインストールやカスタムレジストリの登録まで出来るようになり、かなり実用的といえる機能になってきています。

しかし、Unity AssetStoreで配布されているアセットは.unitypackage形式での配布形態しかサポートしておらず、未だに多くのアセットがAssetsフォルダに直接インストールされる仕様になっています。

社内のプロジェクトにおいてはUnity向けに作成した作成したライブラリはそのほとんどがupm化して管理されているので、Assetsに直接インポートしなければならないアセットも同様に管理できないか試してみた、というのが今回の趣旨となります。

当然ながら、Unityもしくはアセット提供者が公式で対応してもらえたら不要となるので、あまり長く使えるテクニックではありませんが、自作ライブラリを後からupm対応する時などに参考にしていただければ幸いです。

⚠️注意!⚠️

Asset Storeで販売されているアセットを含め、誰かが制作したアセットをupm化して第三者がダウンロードできる様な状態にする事は、ライセンスによって認められていない限り著作権の侵害にあたります。必ずアセットの改変が許されているかどうかを確認し、upm化する場合はプライベートリポジトリやLAN内など、第三者がアクセスできない場所に置いてください。

特にAssetStoreのアセットの場合、アドオン>サービス のカテゴリに含まれるアセットは標準Unity Asset Store EULAにて改変自体が禁止されている為注意が必要です。

アセットをupm化するメリット

  • Assetsフォルダ内に第三者の作ったアセットが入らないので、プロジェクトの見通しが良くなります。

  • プロジェクトの領域ではないコードやアセットをReadOnlyにし、リファクタ等による思わぬ変更の発生を防ぐことが出来ます。

  • アセットのバージョン情報がPackages/manifest.json及びPackages/packages-lock.jsonに残るので、現在プロジェクトで利用しているアセットのバージョンを一元管理できます。

  • プロジェクトのgitリポジトリサイズが小さくなります。

upm化に向かないパターン

Assetsフォルダ以下に直接置く場合とパッケージとして参照する場合とでは仕様が異なる部分があるため、upm化が不可能、もしくはメンテコストを考えた場合に釣り合わないアセットもあります。

  • 複雑な処理をするEditor拡張を含んでいる場合、ReadOnlyになること、本来のAssetsフォルダ内ではない場所にアセットが配置されることにより、正しく動作しなくなる可能性があります。

  • アセットがnpm semverに準拠しないバージョンを付けている場合、そのままではパッケージレジストリでは管理出来ないので、独自のバージョンを付けるなどの対応が必要です。

  • 3Dモデルやテンプレートなど、改変を前提としたアセット。

upm化する判断基準

以下に当てはまるパッケージはupm化の適性が高いです。

  • C#やdll等のプラグインがメインのパッケージ
  • Editor拡張がメインではない
  • AssemblyDefinitionや名前空間が定義されている

アセットをupm化するワークフロー

大まかな手順は以下の通りです。

  1. 対象アセットをパッケージとして管理する為の専用プロジェクトを作る

  2. プロジェクト内に対象アセットをインポートする

  3. パッケージ管理したい単位でpackage.jsonを作成する(記法はこちら

  4. 必要に応じてアセットを改変し、改変した個所をREADME.md等にメモしておく

  5. Unityで正常に動作するか確認し、ダメなら4を繰り返す

  6. バージョン名のタグをつけてプロジェクトで利用できるようにする

例えば PUN2 - FREE (2.40)をupm化するとなると、以下のようなプロジェクト構造になると思います。(一例)

Assets
└─Photon
   ├─PhotonChat
   ├─PhotonLibs
   │  └─package.json
   ├─PhotonRealtime
   │  ├─Code
   │  │  └─package.json
   │  └─Demos
   └─PhotonUnityNetworking
      └─package.json

package.jsonを配置した個所がパッケージとして管理するようにした部分です。PUNはChat, Libs, Realtime, PUNの4つのライブラリが一つになったアセットとして配布されて いるので、その中でRealtime, PhotonLib, PUNを個別にパッケージ化しています。

またPUNに関しては、PunSceneSettingsPhotonNetwork.LoadOrCreateSettings() などのプロジェクト固有の設定ファイルを自動生成するスクリプトが含まれている為、それらのコードを改造する必要があります。簡単な対処をするならば、これらのコードにおけるアセットファイルの生成パスをAssets以下の固定パスに強制し、利用先プロジェクト内で生成されたアセットを管理していく形になると思います。下のコードは改変場所と改変例です。

// Assets\Photon\PhotonUnityNetworking\Code\Editor\PunSceneSettings.cs:50
public static string PunSceneSettingsCsPath => "Assets/Photon/" + SceneSettingsFileName

// Assets\Photon\PhotonUnityNetworking\Code\PhotonNetwork.cs:3212-3214
string punResourcesDirectory = "Assets/Photon/Resources/";
string serverSettingsAssetPath = punResourcesDirectory + PhotonNetwork.ServerSettingsFileName + ".asset";
string serverSettingsDirectory = Path.GetDirectoryName(serverSettingsAssetPath);

ただし、これらの改変を施した場合は今後のアップデート時にも同様の修正をする必要があります。

作成したパッケージをプロジェクトから参照する

改変したアセットはGitHub上にプライベートリポジトリとして配置し、URLで直接参照してインポートすることができます。プライベートリポジトリに配置したパッケージを実際に利用するには、以前書いた以下の記事が参考になると思います。

synamon.hatenablog.com

この方式では依存関係の解決など高度なパッケージの機能は使えないのですが、.unitypackageからパッケージ化したアセットに関しては大きな問題にならず、パッケージレジストリを準備する必要もないため、このスタイルを採用しても問題は少なそうです。今後パッケージ数が多くなりメンテナンスが大変になれば社内専用パッケージレジストリを採用するかもしれません。ただ、便利になればなるほどライセンス数の管理などが大変になりそうな気がするので、その前にUnity AssetStoreで配布されているアセットがupmパッケージに対応してくれるといいなと思っています。