Unity
入門
ECS

【Unity】Entity Component System入門(その1)【2018.2】

この記事はEntity Component System version 0.0.12-preview.18を対象とした初心者から中級者向け入門記事です。
preview11にてそれまでに比べかなり進歩したので新しく日本語資料を書くべきであると判断し、ここに書きます。

サンプルリポジトリです。
サンプルリポジトリは現在ECS version0.0.12-preview.16に対応しています。
Unity2018.3.0b5で動作確認しています。

続きです。

その1の記事内容

  • ECS・C# Job System・Burst Compilerの概説
  • ECSの基礎概念
  • ECS導入チュートリアル
  • Hello world的サンプル
  • 大量のオブジェクトを簡単に描画するサンプル

までを説明します。

ECS(Entity Component System)とは

C# Job Sytem, Burst Compilerと並びUnityが贈るCPUを極限まで使い倒す三本の矢の3本目です。
脱Mono Behaviour/Componentの結果、エクストリーム物量ゲーをエクストリーム高速に動作させることが可能になる凄い軽量な機能です。
現時点(2018年8月末)ではECSの全機能がPure C#で組まれているため、プログラマーが簡単に改造して更にエクストリーム最適化できる、そんな自由な機能です。
ECSはUnity 2018.2以上でしか動作しません。UWP(IL2CPP)においてはUnity 2018.3.0b4以後でしか動作しません。

ECSを使うと何ができる?

かなり最適化した場合は114514体のユニットが縦横無尽に動き回っても100FPS維持できたり、そこそこ最適化しなくても10万個のドカベンロゴを約40FPSでアニメーションさせたりできます。
往々にしてGame Objectの生成や削除は重いのでオブジェクトプーリングの仕組みを組み込んだりしてゲームロジックが複雑化しますが、ECSではGame Objectを一々生成しませんのでとても早くなります。

ECSだとどうしてGame Object/Component指向の従来のUnityより早くなる?

純粋オブジェクト指向を投げ捨てて構造体主体でプログラミングするからです。
オブジェクト指向は継承・ポリモーフィズム・カプセル化から成立しています。C#ではinterfaceやabstract/virtual class, overrideにgetter/setter, property/indexerなどで実現される機能ですね。

ECSではこれら要素をカプセル化以外豪快に投げ捨てます。
(書き換え可能な)構造体が主役になるからです。
構造体は継承できません。よって継承とポリモーフィズムが捨てられます。
インターフェースは使えますが、インターフェース型の変数を宣言してそれに代入するなんてことはオートボクシングが発生するのでしません。

オブジェクト指向の強みを捨ててまでして得られるもの――それは、構造体であることによる圧倒的なメモリ効率です。

キャッシュラインとキャッシュミスヒット

CPUの処理速度 >>> (越えられない壁) >>> メモリ転送速度 > SSDからの読み込み > HDDからの読み込み
CPUはメインメモリからデータをキャッシュに読み出して処理します。その際に必ず8~512byte程度の小さなメモリチャンク毎に切り分けて読み込みます。
メモリキャッシュ.png
C#のクラスは実質的にポインタのようなものです。クラスの配列に対してアクセスするということはメインメモリ上にランダムアクセスすることと同義です。
クラス配列.png
不連続なメモリアクセスはキャッシュの頻繁な再取得を促します(キャッシュミスヒット)。
メインメモリからの頻回の読み出しはCPUの処理性能に対してあまりにも遅いので結果的にCPUは何もすることがない暇な時間ばかりになってしまうのです。

それに対して構造体の配列はぎっちりとヒープ上に詰められて連続的に並びます。
キャッシュミスヒットが生じる可能性はかなり低いので、CPUの待ち時間が少なくなり、ガンガン処理できるようになっています。

C# Job Systemとは何か?

コンテキストスイッチを発生させないようにスレッド数を制限しながらマルチスレッド演算を行うための仕組みです。
UnityがこれまでC++レイヤーで使用していたWorker ThreadをC#レイヤーから触れるようにしました。
C# Job Systemを使用することでCPUを今までより100%に近い効率で馬車馬のごとく働かせることができます。
事前計算できるconstexprでCPUに楽させるほうが好きですが、C#では使えないのです。

C# Job Systemについての詳細な解説は公式のページ(英語)を見て、どうぞ。

Burst Compilerとは何か?

C# Job Systemと併用することで効力を発揮する新しいコンパイラです。
ギチギチに最適化を掛けるために凄まじい制約を課してきます。
その分素晴らしく高速化できるので積極的に使いたい機能ですね。

  • Jobのフィールドは全てBlittable型
    • Blittable型とはbool以外の値型と、Blittable型のみをフィールドに持つ構造体のこと
  • staticフィールドにアクセスしてはならない(Shouldn't)
    • Unityがクラッシュするため
    • だが、現在アクセスすること自体は可能(can)
    • staticプロパティやstaticメソッドにアクセスすることは可能
  • Job内部でヒープを確保できない
    • 参照型をnewできない
    • stackallocでスタック上に領域確保することは可能
    • Unity2018.3からAllocator.TempでNativeArray<T>を確保できるようになるらしい(が、まだ無理である)

ECSの構成要素

  • Entity
  • Component System/Job Component System/Entity Manager等のScriptBehaviourManager派生クラス
  • Component Data/Shared Component Data/Buffer Element等のComponentTypes
  • Component Group/Chunk Iteration等のフィルタリング
  • World

ECSは上記5要素から成立します。

Entityとは何か?

Game Objectに相当する実体を意味する構造体です。
ECSではEntityを実体1つとして計上し、それに対して処理をするという形になっています。

Entity.cs
public struct Entity : IEquatable<Entity>
{
    public int Index;
    public int Version;
    public static Entity Null => new Entity();
}

その本性は単なる8byteのタグ以上でも以下でもないです。
EntityはEntityManager型のオブジェクトの元で一元管理されています。

Entityの性質

EntityのGetHashCode()は単にreturn Indexとのみ書かれています。
不思議ですね?
Entityにはフィールドが2つあるのですからpublic override int GetHashCode() => Index ^ Versionあたりが妥当でしょうに。
これは1つのWorldにおいてIndexが同じEntityが複数個存在するということがありえないという前提があるからなのです。

Worldとは何か?

Component System/Job Component System/Entity Managerが所属する仮想空間です。
Component System/Job Component System/Entity ManagerなどのScriptBehaviourManager派生クラスのインスタンスはWorld1つにつき型ごとに1つずつ存在しています。そして必ずWorldに所属しています。

Component System/Job Component System/Entity Managerとは何か?

ComponentのUpdateやLateUpdate内の処理に相当します。
実質的に処理を行う存在です。
入力処理や描画処理、非同期通信だとかあらゆる処理を行う部分です。
通常Component Systemは特定の組み合わせのComponent Dataを持つEntityが存在する場合に動作します。
例外的に[AlwaysUpdateSystem]属性をComponent Systemに付与すると動作対象のEntityが存在せずとも動作し続けます。

Component System/Job Component SystemはScriptBehaviourManagerという基底クラスから派生しています。
継承していくと何故かサフィックスが変化します。
ComponentSystem以外にもScriptBehaviourManagerを継承したクラスにBarrierSystemやEntityManagerが存在します。
ScriptBehaviourManagerの子孫クラスでManagerというサフィックスを持つものはEntityManagerだけです。

ComponentSystem.cs(フィールド変数及びプロパティ抜粋)
public unsafe abstract class ComponentSystemBase : ScriptBehaviourManager
{
    // [Inject]記法にて使用
    InjectComponentGroupData[] m_InjectedComponentGroups;
    InjectFromEntityData m_InjectFromEntityData;

    // GetComponentGroup()で使用
    ComponentGroupArrayStaticCache[] m_CachedComponentGroupArrays;
    ComponentGroup[] m_ComponentGroups;
    public ComponentGroup[] ComponentGroups => m_ComponentGroups;

    // SafetyHandleによる安全な操作を実現するために必要なフィールド
    NativeList<int> m_JobDependencyForReadingManagers;
    NativeList<int> m_JobDependencyForWritingManagers;
    internal int* m_JobDependencyForReadingManagersPtr;
    internal int m_JobDependencyForReadingManagersLength;
    internal int* m_JobDependencyForWritingManagersPtr;
    internal int m_JobDependencyForWritingManagersLength;

    // Update後に1ずつincrementされるフィールド
    // 稼働フレーム数とか計測するのに多分役立つ。
    uint m_LastSystemVersion;

    // Job関連の安全な読み書きを司る。
    // SafetyHandleをこれから取得する。
    // 通常気にする必要はない。
    internal ComponentJobSafetyManager m_SafetyManager;

    // EntityとComponentData全てを実質的に掌握し司るフィールド
    internal EntityManager m_EntityManager;
    protected EntityManager EntityManager => m_EntityManager;

    // Component SystemはいずれかのWorldに所属する。
    World m_World;
    protected World World => m_World;

    // 通常特定の組み合わせのComponent Dataを持つEntityが存在する場合に動作するが、このフィールドがtrueの場合対象が存在せずとも動作する。
    bool m_AlwaysUpdateSystem;

    // このフィールドがtrueである間動作する。
    // 動作のOn/Offを切り替える最も勘弁で効率の良い方法。
    internal bool m_PreviouslyEnabled;
    public bool Enabled { get; set; } = true;

    // 1から始まりUpdate後に1ずつ増加する。
    public uint GlobalSystemVersion => m_EntityManager.GlobalSystemVersion;
}

Component Dataとは何か?

Mono Behaviourのフィールドに相当します。
Unity.Entities.IComponentDataというinterfaceを実装した構造体のことです。
Component Systemにおいて効率的なメモリフェッチからの爆速処理を実現できるため、細かい粒度で設定するのが流行りですし、奨励されています。
ちょくちょくECSの最適化が未熟なため内部で不要な構造体コピーが発生するので大きな構造体だとコピーコストが嵩むという面もあります。

ComponentData例
using Unity.Entities;
using Unity.Mathematics;
struct Velocity : IComponentData
{
    public float3 Value;
}

Component Group/Chunk Iterationとは何か?

Entityが持つべきComponent Dataの種類を設定し、Component Systemが処理する対象Entityを絞る働きを持つフィルターです。
Chunk Iterationの具体的方法はその2で解説しています。

ComponentGroup.cs(抜粋)
public unsafe class ComponentGroup : IDisposable
{
    // このクラスはこの構造体のラッパーである。
    ComponentGroupData m_ComponentGroupData;
    readonly ComponentJobSafetyManager m_SafetyManager;
    internal IDisposable m_CachedState;
    internal EntityDataManager* EntityDataManager { get; }
    public bool IsEmptyIgnoreFilter => m_ComponentGroupData.IsEmptyIgnoreFilter;
    public ComponentType[] Types => m_ComponentGroupData.Types;
    internal ArchetypeManager ArchetypeManager { get; }

#if ENABLE_UNITY_COLLECTIONS_CHECKS
    internal string DisallowDisposing = null;
#endif
}

ECSの動作

  • Entity(入れ物)とComponent System(動作)とComponent Data(データ)は分離され、別々に定義されます。
  • Entityは複数のComponent Dataを持ちます。
  • Component SystemはComponent Group/Chunk Iteration(フィルター)を複数持ちます。
  • Component Groupは特定のComponent Dataの組み合わせを持つEntityを列挙できます。
  • Component Systemは処理できるEntityが存在するか、[AlwaysUpdateSystem]が付与されている場合に、毎フレームOnUpdate()を実行します。

初心者は読み飛ばしていい解説

bPNDJXin4CVlVWehHoGElKO80IaWAa8gGk5GzU3ipXANZPtQOyIswhlNkrwy6Q-9qbjsvZSVdld7FhCe3BfDpL1Y3VnIeSI6p_HcgnKevCl68cnOKGjhU-_9YKNZ2ZmzPNyOlr4bl37-2CPAhTXVHjYvKE8PpBlnt1Hhm6OBrDx3dmMAta5hNDTw9UhGIb-8JxcJ2aCzceB63hp7-tPlr6pjr8.png

  • ComponentGroupはComponentTypes(ComponentDataの型に応じたint型の識別用タグ)の配列を持ち、初期化後変更されることはないです。
    • これまでComponentDataとのみ書いてきましたが、実際はIComponentData, ISharedComponentData, IBufferElementDataの3種類を実装した構造体がComponentTypesとして扱えます。
    • ComponentGroup.Typesは内部的にList.ToArray()しているのでGCが走るのでできる限り呼ばないようにしましょう。幸いなことにECS標準ではあまり呼ばれないため自分で使わなければ気にする必要はないです。
  • ComponentSystemはEntityManagerの参照を持っています。
  • EntityManagerはEntityやComponentDataの配列(Chunkと呼称され、長さは通常16kb)の配列へのポインタを保持します。
  • 各ChunkはComponentDataのうちどの種類のものを持っているかを保持するArchetype型の変数を持っています。
    • Archtype型はChunkのリンクリストを保持しているため、相互に参照可能です。
  • ArchetypeとComponentGroupのComponentTypesを線形探索で比較することによりComponentGroupはフィルタリングを行っています。
  • ComponentGroupは作成時にArchetypeManagerより合致するArchetype全てをリンクリスト(MatchingArchetype)に記憶します。

ECSの使い方

Unityはversion 0.0.12-preview.11からUnity 2018.1系のサポートを投げ捨てました。
今後もECSのバージョンアップに従って最新の系以外サポートを投げ捨てることが想定されますので、常に最新版を使うようにしましょう。

ただし、ネットで拾ったサンプルプロジェクトを自分で実行する場合、ECSのバージョンを上げようとしないでください。
慣れれば大したことはないですが、バージョンアップに伴うコードのフォローアップは初心者には無理です。

導入方法

  • Scripting Runtime Versionを.NET 4.xにします。(Unity2018.3からはデフォルトで.NET 4.xですので不要です)
  • エクストリーム性能を追い求める方はunsafeを使えるように設定するべきでしょう。
  • Package ManagerからEntitiesをインストールします。Entities.PNG
  • Scripting Define Symbolsに「UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP」を書き加えます。
  • ECSの導入完了です。

Unity 2018.3からはC# 7.3がデフォルトで使用可能です。
refローカル変数やstackalloc初期化子など構造体やポインタ周りの言語機能が強化されていますので、是非ufcppで学んでみてください。

すごくかんたんなECSのサンプル

完成品のgitリポジトリです。

ミッシングプレファブ.PNG

Assets/Samples/Scenes/CountUp.unityについて解説します。
このサンプルは左クリックしたらuGUIのカウントが増えます。右クリックするとカウントが0になります。

これをMonoBehaviourで実装すると次のようなコードになるでしょう。

MonoBehaviour
using TMPro;
using UnityEngine;

sealed class CountUp : MonoBehaviour {
    // uGUI標準Textだと文字が滲むのでTextMeshProを使用。
    [SerializeField] TMP_Text countText;
    uint count = 0;

    void Update() {
        if (Input.GetMouseButton(0))
            countText.text = (++count).ToString();
        else if(Input.GetMouseButton(1) && count != 0) {
            count = 0;
            countText.text = "0";
        }
    }
}

実に簡単ですね。このスクリプトを適当なGameObjectにアタッチしてcountTextにTextMeshProのTextを入れればすぐにちゃんと動きます。

これと同等の機能をまわりくどくECSで実装してみましょう。
下記3要素を実装しています。 MonoBehaviour版はカウント変更部分と表示部分を結合しているのですごく簡単に見せ掛けているのです。
慣れればMVVMパターンに当てはめることもできますから(言い訳)。

  • ECSのセットアップを行うManager_CountUp Component
  • マウス左ボタンが押されている限りEntityを毎フレーム生成するCountUpSystem ComponentSystem(右クリックで全Entity破棄をします。)
  • Entityの個数を数え上げて前フレームと変化があればTMP_Textに表示するClickSystem ComponentSystem
Manager_CountUp.cs
using TMPro;
using UnityEngine;
using Unity.Entities;

sealed class Manager_CountUp : MonoBehaviour
{
    [SerializeField] TMP_Text countText;
    void Start()
    {
        // Worldの作成
        var world = World.Active = new World("count up");

        // ComponentSystemの初期化
        // CountUpSystemのpublicコンストラクタに引数を渡せる。
        world.CreateManager(typeof(CountUpSystem), countText);
        world.CreateManager(typeof(ClickSystem));

        // PlayerLoopへのWorldの登録
        ScriptBehaviourUpdateOrder.UpdatePlayerLoop(world);
    }
}
CountUpSystem.cs
using TMPro;
using Unity.Entities;
using Unity.Jobs;

[AlwaysUpdateSystem]
sealed class CountUpSystem : ComponentSystem
{
    readonly TMP_Text countDownText;
    ComponentGroup g;
    uint cachedCount = 0;

    public CountUpSystem(TMP_Text countDownText) => this.countDownText = countDownText;

    // GetComponentGroupはコンストラクタから呼べないため、OnCreateManagerで呼び出すべし。
    protected override void OnCreateManager(int capacity) => g = GetComponentGroup(ComponentType.ReadOnly<Count>());

    protected override void OnUpdate()
    {
        uint current = (uint)g.CalculateLength();
        if (current == cachedCount) return;
        cachedCount = current;
        countDownText.text = cachedCount.ToString();
    }
}
ClickSystem
using Unity.Entities;
using Unity.Jobs;
using Unity.Collections;
using UnityEngine;

[AlwaysUpdateSystem]
sealed class ClickSystem : ComponentSystem
{
    EntityArchetype entityArchetype;
    ComponentGroup g;
    protected override void OnCreateManager()
    {
        // ComponentType構造体は必ずジェネリックメソッドを使用して作成するべきである。
        // Type型を引数に取るオーバーロードやType型からの暗黙的型変換も作成方法としてあるが、線形探索のためパフォーマンスが頗る悪い。
        // タグ用途のComponentDataは必ずReadOnly<T>()で作成するべきである。
        var componentTypes = new ComponentType[] { ComponentType.ReadOnly<Count>() };
        // Entityを作る際に最初から持つべきComponentTypeを設定する。
        entityArchetype = EntityManager.CreateArchetype(componentTypes);
        // 引数に与えられたComponentTypeと一致するEntityのみを処理対象とするComponentGroupを得る。
        // Getと書いてあるが実際はGetOrCreate相当の働きを持つ。
        g = GetComponentGroup(componentTypes);
    }

    protected override void OnUpdate()
    {
        if (Input.GetMouseButton(0))
            EntityManager.CreateEntity(entityArchetype);
        else if (Input.GetMouseButton(1))
        {
            var source = g.GetEntityArray();
            if (source.Length == 0)
                return;
            using (var results = new NativeArray<Entity>(source.Length, Allocator.TempJob, NativeArrayOptions.UninitializedMemory))
            {
                new CopyEntities
                {
                    Results = results,
                    Source = source,
                }.Schedule(source.Length, 256).Complete();
                EntityManager.DestroyEntity(results);
            }
        }
    }
}
Count.cs
// Component DataはEntityが持つデータの実体である。
// だが、別に中身を持たないタグ用途の実体であってもよい。
readonly struct Count : Unity.Entities.IComponentData { }

解説

ふるい.png
Entityが持つべき性質(ComponentData)についてノータッチな図ですが、わりとこんな感じで篩い分けています。
今回のサンプルではComponentSystemは磨棒でEntityを摩り下ろしたり(EntityManager.DestroyEntity)、数え上げたり(g.CalculateLength)します。

Entityが持つ性質であるComponentDataはUnity.Entities.IComponentDataを実装した構造体でなくてはなりません。クラスだと無視されます。
このIComponentDataを実装した構造体はフィールドを持っていてもいなくても構いません。

ComponentSystemはUnity.Entities.JobComponentSystemを継承したクラスを定義します。
ComponentSystemにはできる限りsealed修飾子を付与するべきです。

  • コンストラクタによる初期化
  • OnCreateManagerによる初期化
    • ComponentGroupの初期化
  • 毎フレームの処理(OnUpdate)

MonoBehaviourと異なり、ECSではコンストラクタが使用できます。readonlyが使える素晴らしさをUnity社に感謝します。
Manager_CountUpのWorld.CreateManagerの第2引数(paramas object[])に渡したものと型と長さが一致したコンストラクタが呼ばれます。
今回のサンプルではTMP_Textへの参照をフィールドに設定しました。
Manager役のMonoBehaviourから設定や参照をもらうためにコンストラクタを使用しましょう。
その後、OnCreateManagerが1度だけ呼ばれ、そこでフィルターであるComponentGroupを初期化します。
初期化する時はCreateComponentGroupにComponentType型の可変長引数配列を渡しましょう。

  • Create<T>
    • 読み書き両方行う場合。
  • ReadOnly<T>
    • 読み込みのみ、タグ用途のComponentTypeの場合。
  • Subtractive<T>
    • Tを持たないEntityが欲しい場合。

ComponentTypeはCreate<T>, ReadOnly<T>, Subtractive<T>の3種類を使い分けて作成し、ComponentGroupによるフィルタリングを上手に行いましょう。(WriteOnlyは)ないです。

注意事項

サンプルリポジトリでは専用の処理を別ファイルに書き出しているのでコード例に含んでいない重要な手順をここで補足します。

少なくともゲーム終了時、大体はScene遷移時にWorldが不要になりますよね?
その時に必ずWorld.DisposeAllWorlds()を呼び出さねばなりません。
World.DisposeAllWorldsは全てのWorldに対してDispose()を呼び出すだけのメソッドですので、1つのみWorldを作成したという人はそのWorldだけDisposeしてもいいでしょう。
WorldをDisposeするとWorldは自身の管理する全てのScriptBehaviourManager継承オブジェクト(ComponentSystemのことです)のOnDestroyManagerメソッドを呼びます。
ここで後始末しないとメモリリークを起こすことがあります。特にChunkIterationではNativeList<EntityArchetype>型フィールドを保持することになります。

ManagerとなるMonoBehaviourのOnDestroyマジックメソッドとかでWorld.DisposeAllWorlds()しましょう。

初心者は読み飛ばしていい解説

hLLBJnin4Bxlhx1IgKfR1QbNX10auK6bbehGze0uJ7O7ujPhh-nPr8ZotsjdjjvrMQCUUa8xtyE3lnddo15OQcjLa2I5ObyiJDqOZPh4Qki8Q_5QV3DMLKLHAd2k-tWin3ln2UgWS6IblW-5hsYTDDhNfAPGk70BYq1O-TBQ61LgI-aQut3wnjSUZAn2ROMqn3je5IsWWLBITkeTXempZD2fiT.png

ComponentSystemにPostUpdateCommandsというEntityCommandBuffer型のプロパティが存在しますがこれが曲者です。
毎フレームnew EntityCommandBuffer()されて確保されています。NativeContainerが複数個Allocator.TempJobで確保されるなどメモリアロケーション的に嬉しくない存在です。
OnUpdate後にEntityを操作できるという便利なプロパティですが、UIに情報を同期的に表示させたい時などEntity操作が不要な場合には無駄アロケーションの極みなのです。
PostUpdateCommandsの無いComponentSystemをECSが標準で用意してくれると嬉しいですね。

話は変わりますが、ComponentSystemはエディター上では毎フレーム約0.1kbのGCを発生させますが、これはDisposeSentinelをnewすることによるもので、実機上では発生しません。

World.ActiveによってComponentSystemが所属するWorldを得るという手法を一部記事で紹介しているようですが、それはやめましょう。
World.Activeは本当に薄いラッパープロパティで、自由に設定できるものですので。

World.cs
public class World : IDisposable
{
    public static World Active { get; set; }
}

ComponentType取得効率について

ComponentType.cs
public struct ComponentType
{
    public enum AccessMode
    {
        ReadWrite,
        ReadOnly,
        Subtractive
    }

    public int TypeIndex;
    public AccessMode AccessModeType;
    public int BufferCapacity;

    public static ComponentType Create<T>() => FromTypeIndex(TypeManager.GetTypeIndex<T>());

    public static ComponentType FromTypeIndex(int typeIndex)
    {
        TypeManager.ComponentType ct = TypeManager.GetComponentType(typeIndex);
        ComponentType type;
        type.TypeIndex = typeIndex;
        type.AccessModeType = AccessMode.ReadWrite;
        type.BufferCapacity = ct.BufferCapacity;
        return type;
    }

    public static ComponentType ReadOnly(Type type)
    {
        ComponentType t = FromTypeIndex(TypeManager.GetTypeIndex(type));
        t.AccessModeType = AccessMode.ReadOnly;
        return t;
    }

    public static ComponentType ReadOnly<T>()
    {
        ComponentType t = Create<T>();
        t.AccessModeType = AccessMode.ReadOnly;
        return t;
    }

    public static ComponentType Subtractive(Type type)
    {
        ComponentType t = FromTypeIndex(TypeManager.GetTypeIndex(type));
        t.AccessModeType = AccessMode.Subtractive;
        return t;
    }

    public static ComponentType Subtractive<T>()
    {
        ComponentType t = Create<T>();
        t.AccessModeType = AccessMode.Subtractive;
        return t;
    }

    public static implicit operator ComponentType(Type type) => new ComponentType(type, AccessMode.ReadWrite);

    public ComponentType(Type type, AccessMode accessModeType = AccessMode.ReadWrite)
    {
        TypeIndex = TypeManager.GetTypeIndex(type);
        var ct = TypeManager.GetComponentType(TypeIndex);
        BufferCapacity = ct.BufferCapacity;
        AccessModeType = accessModeType;
    }
}
TypeManager.cs(抜粋)
// これまでTypeManagerで検索された型の総数。
private static volatile int s_Count;
private static TypeManager.ComponentType[] s_Types;

public static int GetTypeIndex(Type type)
{
    var index = FindTypeIndex(type, s_Count);
    return index != -1 ? index : CreateTypeIndexThreadSafe(type);
}
private static int FindTypeIndex(Type type, int count)
{
    for (var i = 0; i != count; i++)
        if (s_Types[i].Type == type)
            return i;
    return -1;
}

public struct ComponentType
{
    public readonly Type Type;
    // Note that this includes internal capacity and header overhead for buffers.
    public readonly int SizeInChunk;
    public readonly int ElementSize;
    public readonly int BufferCapacity;
    public readonly FastEquality.TypeInfo FastEqualityTypeInfo;
    public readonly TypeCategory Category;
    public readonly EntityOffsetInfo[] EntityOffsets;
    public readonly UInt64 MemoryOrdering;
}

はい。長々と抜粋しましたが、結論は1つです。
ComponentTypeを作る際には必ずジェネリクスメソッドを使いましょう。
非ジェネリックなTypeオブジェクトを引数に取る方は線形探索していて絶対非効率です。
また、implicitな型変換も同じく線形探索するので絶対使わないでください。

とてもかんたんなECSのサンプル

ミッシングプレファブ.PNG

Assets/Samples/Scenes/MovingCubesについて解説します。
このサンプルはシーンを開始すると11451個のSphereが出現し、好き勝手な方角に移動します。

ECSではないMonoBehaviourとかならCubeを表すGameObjectをPrefabにし、11451個Instantiateするのが標準的でしょう。
やってみるといいですが、絶対に重すぎてまともに動かないでしょうね。

さて、ECSには標準でMeshを描画するための仕組みが用意されています。
MeshInstanceRendererというComponentDataと、それを描画するためのMeshInstanceRendererSystemというComponentSystemを使用することで手軽かつ高速に大量のオブジェクトを描画できます。
ただし、これにはGPU Instancingが有効になっている必要があります。
なお、次のサンプルではプログラマーにかかるコーディングの負担こそ増えますが、より効率的に描画する方法を伝授します。

Manager_MovingCubes.cs
[RequireComponent(typeof(Camera))]
sealed class Manager_MovingCubes : MonoBehaviour
{
    [SerializeField] MeshInstanceRenderer[] renderers;

    void Start()
    {
        World.Active = new World("move cube");
        World.Active.SetDefaultCapacity(11451);
        manager = World.Active.CreateManager<EntityManager>();
        World.Active.CreateManager(typeof(EndFrameTransformSystem));
        World.Active.CreateManager<MeshInstanceRendererSystem>().ActiveCamera = GetComponent<Camera>();
        World.Active.CreateManager(typeof(MoveSystem));
        ScriptBehaviourUpdateOrder.UpdatePlayerLoop(World.Active);

        archetype = manager.CreateArchetype(ComponentType.Create<Position>(), ComponentType.Create<Velocity>(), ComponentType.Create<MeshInstanceRenderer>());

        var src = manager.CreateEntity(archetype);
        renderers[0].material.enableInstancing = true;
        manager.SetSharedComponentData(src, renderers[0]);
        Set(src);
        using (var _ = new NativeArray<Entity>(11450, Allocator.Temp, NativeArrayOptions.UninitializedMemory))
        {
            manager.Instantiate(src, _);
            for (int i = 0; i < _.Length; i++)
                Set(_[i]);
        }
    }

    EntityManager manager;
    EntityArchetype archetype;

    private void Set(in Entity e)
    {
        manager.SetComponentData(e, new Position { Value = new Unity.Mathematics.float3((Random.value - 0.5f) * 40, (Random.value - 0.5f) * 40, (Random.value - 0.5f) * 40) });
        manager.SetComponentData(e, new Velocity { Value = new Unity.Mathematics.float3((Random.value - 0.5f) * 40, (Random.value - 0.5f) * 40, (Random.value - 0.5f) * 40) });
    }
}
MeshInstanceRenderer.cs
public struct MeshInstanceRenderer : Unity.Entities.ISharedComponentData
{
    public UnityEngine.Mesh mesh;
    public UnityEngine.Material material;
    public int subMesh;

    public UnityEngine.Rendering.ShadowCastingMode castShadows;
    public bool receiveShadows;
}

Unity.Entities.ISharedComponentDataというインターフェースが初めて出てきましたね。
これはIComponentDataと大体同じ働きをするComponentDataです。

  • IComponentData
    • Entity個々で値が千差万別
    • 座標や速度、残りHPなど時々刻々と変化するデータを表現するのに適している
  • ISharedComponentData
    • Entity間でかなり共通化され、値を共有している
    • 一般に数種類(精々数百種類)しか値を取り得ない
    • スプライトやメッシュ、マテリアル、音源など個数が限られているデータを表す際にこのインターフェースを実装した構造体を用いる

IComponentDataはEntityの数だけメモリが確保されますが、ISharedComponentDataはメモリ消費量が段違いに少ないです(そのかわりアクセスするのは非効率ですが)。
大量のオブジェクトを描画する際には共通のメッシュやマテリアルを纏めるのは当然ですからMeshInstanceRenderer構造体がIComponentDataを実装することで省メモリするのは当然ですね。

World.SetDefaultCapacityをnew Worldした後すぐに行うことでEntityManagerがメモリリアロケーションをせずに自由に扱えるEntity数を適正な値にできます。
なにせデフォルトのcapacityは10ですから、何も対策をしなければメモリアロケーションが頻発します。

World.CreateManager(Type t, params object[] p)とWorld.CreateManager<T>(params object[] p)と二種類を使い分けていますが、後者は前者の単なるラッパーです。T型のScriptBehaviourManagerを欲しいのでなければ非ジェネリック版を使った方が微小なれどもパフォーマンスによいです。

EntityにIComponentDataやISharedComponentDataを同期的に設定する場合、EntityManager.SetComponentData(Entity, T)やSetSharedComponentData(Entity, T)を使用します。
IComponentDataに書き込むのはローコストですが、ISharedComponentDataに値を設定するのはかなり高コストです。
故に、ECSはPrototypeデザインパターンを採用しています。
具体的にはEntityManager.Instantiate(Entity, NativeArray)メソッドですね。
これは第一引数のEntityのComponentData全てをコピーしたEntityを新たに作成し、第二引数に詰めるというものです。
EntityManager.CreateEntityを何万回と繰り返して個別にSetSharedComponentDataするより遥かにメモリ効率もCPU効率も優れています。
ISharedComponentDataを扱う際はなるべくこれを使いましょう。

読み飛ばして良い解説

性能的に微妙に残念な産物ですので、MeshInstanceRendererを使うのはカジュアルに手っ取り早く組みたいという場合のみでしょう。
MeshInstanceRendererSystemを十全に扱うための補助的なComponentSystemにEndFrameTransformSystemがあります。これがまた無駄に高機能でメモリアロケーションを多々する存在です。
ECSに慣れたら必要最小限に機能を削ぎ落としたものを自作しましょう。
EndFrameTransformSystem(ひいてはその親クラスであるTransformSystem<T>)はPosition, Rotation, Scaleを基にfloat4x4をC# Job Systemを使用して計算していますが、行列演算こそGPU、ComputeShaderの出番でしょう。

Deep Dive into ISharedComponentData

ISharedComponentDataはメモリ消費量に関してだけはよい。
ただ、その読み書きは耐え難い醜さで彩られている。
C#大統一理論さん!その無敵の最適化ハックでどうにかしてください!!

ISharedComponentDataはWorldに対してシングルトンなSharedComponentDataManagerクラスによって管理されています。

SharedComponentDataManager.cs(抜粋)
internal class SharedComponentDataManager
{
    // ECS独自定義のオレオレハッシュコードをKey、m_SharedComponent〇〇のインデックスをValueとしたマルチハッシュマップ
    private NativeMultiHashMap<int, int> m_HashLookup = new NativeMultiHashMap<int, int>(128, Allocator.Persistent);

    private List<object> m_SharedComponentData = new List<object>();
    // 以下3つのリストの長さは一致する
    private NativeList<int> m_SharedComponentRefCount = new NativeList<int>(0, Allocator.Persistent);
    private NativeList<int> m_SharedComponentType = new NativeList<int>(0, Allocator.Persistent);
    private NativeList<int> m_SharedComponentVersion = new NativeList<int>(0, Allocator.Persistent);
    private int m_FreeListIndex;
}

ISharedComponentDataを実装した構造体の値がEntityにSetSharedComponentDataされた時、その構造体はどこへ行くのでしょうか?
答えは「非default値の場合、 ボクシングされて m_SharedComponentDataにAddされる」でした。default値だった場合無視されます。

m_SharedComponentRefCountは各IComponentDataの個々の値に対する参照カウントを保持します。参照カウントが0になった場合m_HashLoopupやm_SharedComponent〇〇から対象の値が削除されます。
m_SharedComponentTypeはTypeManager.GetTypeIndex<T>で得られるtypeIndexを格納したリストです。default値は-1として扱われます。型検査はこのリストを使用して行われます。
m_SharedComponentVersionはm_FreeListIndexと合わせて単方向リンクリストを実現するための仕掛けです。
下図において左側の長方形の連なりはm_SharedComponentVersionを表しています。
水色のセルは参照カウントが0でない 生きている IComponentDataを表しています。水色のセルに格納されている値は大した意味を持たないので無視していいでしょう。
重要なのは参照カウントが0となることで値が削除された赤いセルです。
このセルには次の死んだセルのインデックスが格納されています。m_FreeListIndexから辿ることで死んだセル全てがわかります。
Untitled Diagram.png
新たにIComponentDataをAddする際には既にListに含んでいるか検査した後、m_FreeListIndexが-1と等しいか調べ、Listに空き部屋があるかを検査します。

さて、私がSharedComponentDataManagerを嫌う理由を説明しましょう。
FastEquality.EqualsというECS内部で使用するstaticメソッドのみを使用して等価性評価をします。つまり、IEquatable<T>やIEqualityComparer<T>を無視します。
また、m_HashLookupのKeyとして使用するハッシュコードもGetHashCodeではなくFastEquality.GetHashCodeというstaticメソッドを利用します。
この2つのメソッドは全フィールドを舐めて等価であるか調べます。
幸いな事にリフレクションはComponentTypeをCreateした時に1度のみ実行され、比較用の情報(TypeInfo構造体)が作成されるので、性能が悲惨になることはないです。
しかし、等価性評価に全フィールドを舐める必要がない場合、特に共用体を扱う場合に無駄が大きいので正直どげんかせんといかんでしょう。

ECSのバージョンが上がることで使用機会が大幅に減ったメソッドにGetAllUniqueSharedComponents<T>(List<T>)があります。

SharedComponentDataManager.GetAllUniqueSharedComponents
public void GetAllUniqueSharedComponents<T>(List<T> sharedComponentValues) where T : struct, ISharedComponentData
{
    sharedComponentValues.Add(default(T));
    for (var i = 1; i != m_SharedComponentData.Count; i++)
    {
        var data = m_SharedComponentData[i];
        if (data != null && data.GetType() == typeof(T))
            sharedComponentValues.Add((T)m_SharedComponentData[i]);
    }
}

GetTypeヤメロォ(建前)! ヤメロォ(本音)!
アンボクシングは軽いのでさておくとしてもGetType()は許されません。

次へのリンク

次回記事です。