1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自分用Unity ECS辞書

基本形

using Unity.Entities;
using Unity.Transforms;

public struct MoveSpeed : IComponentData
{
    public float Value;
}

public partial struct MoveSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        foreach (var (transform, speed) in
                 SystemAPI.Query<RefRW<LocalTransform>, RefRO<MoveSpeed>>())
        {
            transform.ValueRW.Position.x +=
                speed.ValueRO.Value * SystemAPI.Time.DeltaTime;
        }
    }
}

データを IComponentData として定義し、ISystem が条件に合うEntityを見つけて処理する、という順序で読むと理解しやすい。

Unity ECSでは、GameObjectに処理を書くのではなく、EntityにComponentというデータを付け、そのComponentを持つEntityをSystemがまとめて処理する。

Entities 1.4では Entities.ForEachIAspect が非推奨になっており、今から覚えるなら SystemAPI.QueryIJobEntity を中心にしたほうがよい。古い記事では Entities.ForEachAspect が出てくることがあるが、そこを最初の基本形にするのは少し危ない。Unity公式のEntities 1.4アップグレードガイドでも、Entities.ForEach の代わりに IJobEntity または SystemAPI.Query を使うよう案内されている。

索引

基本

用語 説明
Entity ECS世界の対象そのもの。IDに近い。
IComponentData Entityに付けるデータ。基本はstructで作る。
Tag Component フィールドを持たない目印用Component。
ISystem Componentを読み書きする処理を書く場所。
SystemAPI.Query 条件に合うEntityのComponentを列挙する。
RefRO / RefRW Componentを読み取り専用、または読み書き可能として扱う。
LocalTransform ECS側の位置、回転、スケールを扱うComponent。

Authoring・Baking系

用語 説明
Authoring Inspectorから値を設定するためのMonoBehaviour。
Baker Authoringの値をEntityのComponentへ変換する。
GetEntity Baker内でGameObjectに対応するEntityを取得する。
TransformUsageFlags EntityにどのTransform系Componentを持たせるかを指定する。
SubScene ECS用にBakeされるScene単位。

Entity操作系

用語 説明
EntityManager EntityやComponentを直接操作する入口。
EntityCommandBuffer Entityの生成・破棄・Component追加などを後でまとめて実行する。
Instantiate Entity prefabからEntityを複製する。
DestroyEntity Entityを破棄する。
AddComponent / RemoveComponent / SetComponent EntityのComponent構成を変更したり、既存Componentの値を更新したりする。

検索・状態系

用語 説明
RequireForUpdate 指定Componentが存在するときだけSystemを動かす。
Singleton 1つだけ存在するComponentを取得する。
DynamicBuffer Entityに可変長の配列的データを持たせる。
IEnableableComponent Componentを付け外しせずに有効・無効を切り替える。
EntityQuery 条件に合うEntity群を表す検索条件。

イベント系

用語 説明
EntitiesEvents System間で軽量なイベント通知を行うためのライブラリ。
RegisterEvent EntitiesEventsで使うイベント型を登録する。
EventWriter イベントを書き込む。
EventReader イベントを読み取る。
EventParallelWriter Jobなどから並列にイベントを書き込む。

Job・Burst系

用語 説明
IJobEntity Componentの処理をJobとして書く。
BurstCompile Burstコンパイル対象にする属性。
UpdateInGroup Systemが実行されるグループを指定する。

実戦的な組み合わせ

Entity

ECS世界の対象そのもの。GameObjectに少し似ているが、GameObjectのように機能を持った入れ物ではない。

Entity entity = state.EntityManager.CreateEntity();

Entity は実体というより、ECS内部のデータを指すIDに近い。位置、速度、HP、敵フラグなどの意味は、Entityそのものではなく、付いているComponentで決まる。

state.EntityManager.AddComponentData(entity, new MoveSpeed
{
    Value = 5f
});

ECSでは「Playerクラスが移動速度を持つ」と考えるより、「あるEntityに MoveSpeed が付いている」と考える。

ここを間違えると、ECSをMonoBehaviour風に書いてしまう。Entityは振る舞いを持つオブジェクトではなく、Componentを束ねる対象だ。

索引へ

IComponentData

Entityに付けるデータ。基本は struct で作る。

using Unity.Entities;

public struct MoveSpeed : IComponentData
{
    public float Value;
}

HPならこうなる。

public struct Health : IComponentData
{
    public int Current;
    public int Max;
}

移動方向ならこうなる。

using Unity.Mathematics;

public struct MoveDirection : IComponentData
{
    public float3 Value;
}

IComponentData は、Component型であることを示すためのインターフェースだ。基本的には処理を持たせず、状態を表すデータとして扱う。

つまり、こういう設計は避けたほうがいい。

public struct Health : IComponentData
{
    public int Current;
    public int Max;

    public void Damage(int amount)
    {
        Current -= amount;
    }
}

できなくはないが、ECSらしくはない。処理はSystemに書き、Componentはデータとして保つほうが読みやすい。

ここで少し疑問になるのが、「Componentはデータなのだから、完全にフィールドだけでなければならないのか」という点だ。

たとえば、次のような補助ゲッターはどうなのか。

using Unity.Entities;
using Unity.Mathematics;

public struct MoveDirection : IComponentData
{
    public float3 Value;

    public readonly float3 NormalizedValue =>
        math.normalizesafe(Value);
}

この程度なら付けてもよい。NormalizedValue は追加の状態を持っているわけではなく、Value から計算される読み取り用の補助にすぎない。

ただし、ECSのComponentはあくまでデータだ。補助ゲッターや小さな計算プロパティは許容できるが、ゲームロジックの本体を持たせる場所ではない。

たとえば「ダメージを受ける」「死亡判定をしてイベントを発行する」「移動量を計算してTransformへ反映する」といった処理までComponentに入れ始めると、Systemとの責務分担が崩れる。

つまり、Componentに書いてよいのは、データの見え方を少し整える程度までだ。振る舞いの中心はSystemに置く。

索引へ

Tag Component

フィールドを持たない目印用Component。

public struct PlayerTag : IComponentData
{
}

Playerだけを対象にしたい場合に使う。

public partial struct PlayerMoveSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        foreach (var transform in
                 SystemAPI.Query<RefRW<LocalTransform>>()
                     .WithAll<PlayerTag>())
        {
            transform.ValueRW.Position.x +=
                3f * SystemAPI.Time.DeltaTime;
        }
    }
}

タグは「このEntityは何者か」を表す目印として使いやすい。

public struct EnemyTag : IComponentData
{
}

public struct BulletTag : IComponentData
{
}

public struct DeadTag : IComponentData
{
}

ただし、何でもタグで分岐すると設計が荒れる。たとえば「移動できるもの」なら MovableTag を付けるより、MoveSpeedMoveDirection を持っていること自体を条件にしたほうが自然な場合もある。

索引へ

ISystem

Componentを読み書きする処理を書く場所。MonoBehaviourの Update() に近い役割を持つ。

using Unity.Entities;

public partial struct MoveSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        // 毎フレーム実行される
    }
}

初期化が必要なら OnCreate を使う。

public partial struct MoveSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<MoveSpeed>();
    }

    public void OnUpdate(ref SystemState state)
    {
        // MoveSpeedを持つEntityが存在するときだけ実行される
    }
}

終了時の後始末が必要なら OnDestroy を使う。

public partial struct SampleSystem : ISystem
{
    public void OnDestroy(ref SystemState state)
    {
        // 後始末
    }
}

初心者はまず ISystem で書けばよい。SystemBase はclassベースのSystemで、managed objectを扱う場合などに出番があるが、最初から両方を混ぜると混乱する。

まずは IComponentDataISystemSystemAPI.Query の三点セットで読むべきだ。

索引へ

SystemAPI.Query

条件に合うEntityのComponentを列挙する。

foreach (var (transform, speed) in
         SystemAPI.Query<RefRW<LocalTransform>, RefRO<MoveSpeed>>())
{
    transform.ValueRW.Position.x +=
        speed.ValueRO.Value * SystemAPI.Time.DeltaTime;
}

LocalTransformMoveSpeed の両方を持つEntityだけが対象になる。

複数のComponentを読む例。

foreach (var (transform, speed, direction) in
         SystemAPI.Query<
             RefRW<LocalTransform>,
             RefRO<MoveSpeed>,
             RefRO<MoveDirection>>())
{
    transform.ValueRW.Position +=
        direction.ValueRO.Value *
        speed.ValueRO.Value *
        SystemAPI.Time.DeltaTime;
}

SystemAPI.Query は、ISystemSystemBase の両方で使える、Component列挙用のAPIだ。Unity公式ドキュメントでも、メインスレッドでデータの集合を列挙するために foreach 構文で使えると説明されている。

条件を足したいときは、WithAllWithNone を使う。

foreach (var transform in
         SystemAPI.Query<RefRW<LocalTransform>>()
             .WithAll<EnemyTag>()
             .WithNone<DeadTag>())
{
    // EnemyTagを持ち、DeadTagを持たないEntityだけ
}

ECSの処理は「対象を探す → データを読む → データを書き換える」と読むと理解しやすい。

索引へ

RefRO / RefRW

Componentを読み取り専用、または読み書き可能として扱う。

RefRO<MoveSpeed> speed;
RefRW<LocalTransform> transform;

RefRO<T> は read only、RefRW<T> は read write と考えればよい。

foreach (var (transform, speed) in
         SystemAPI.Query<RefRW<LocalTransform>, RefRO<MoveSpeed>>())
{
    float value = speed.ValueRO.Value;

    transform.ValueRW.Position.x +=
        value * SystemAPI.Time.DeltaTime;
}

読み取りだけなら RefRO を使う。書き換えるなら RefRW を使う。

float speedValue = speed.ValueRO.Value;

transform.ValueRW.Position.y += 1f;

「読むだけなのに RefRW」は避けるべきだ。ECSはデータの読み書き情報をもとにSystem間の依存関係を判断する。読み取りだけなのか、書き込みもあるのかを型で表すこと。

索引へ

LocalTransform

ECS側の位置、回転、スケールを扱うComponent。

using Unity.Transforms;

foreach (var transform in SystemAPI.Query<RefRW<LocalTransform>>())
{
    transform.ValueRW.Position.x += 1f;
}

位置を直接設定する例。

transform.ValueRW.Position = new float3(0f, 1f, 0f);

まとめて作ることもできる。

transform.ValueRW = LocalTransform.FromPositionRotationScale(
    new float3(0f, 0f, 0f),
    quaternion.identity,
    1f);

MonoBehaviourの transform.position と似た気分で読めるが、完全に同じものではない。ECS側のTransformはComponentデータなので、Systemからまとめて処理する前提で考える。

LocalTransform が存在しないEntityもある。Bakerで TransformUsageFlags.None を指定したEntityなどは、Transform用Componentを持たないことがある。動かすEntityなら、Bakerで TransformUsageFlags.Dynamic を指定するのが基本だ。

索引へ

Authoring

Inspectorから値を設定するためのMonoBehaviour。

using UnityEngine;

public sealed class MoveSpeedAuthoring : MonoBehaviour
{
    public float Value = 3f;
}

ECSのComponentは基本的に struct なので、そのままInspectorで扱いにくい。そこで、Inspector入力用のMonoBehaviourを用意する。

ただし、Authoringは実行時ロジックを書く場所ではない。Authoringは「Editorで設定するための入口」と考えたほうがいい。

public sealed class EnemyAuthoring : MonoBehaviour
{
    public int MaxHp = 10;
    public float MoveSpeed = 2f;
}

この値をBakerでEntityのComponentへ変換する。

索引へ

Baker

Authoringの値をEntityのComponentへ変換する。

初心者向けには、まずAuthoringの内部 private sealed class として書く形を覚えるとよい。

using Unity.Entities;
using UnityEngine;

public sealed class MoveSpeedAuthoring : MonoBehaviour
{
    public float Value = 3f;

    private sealed class Baker : Baker<MoveSpeedAuthoring>
    {
        public override void Bake(MoveSpeedAuthoring authoring)
        {
            Entity entity = GetEntity(authoring, TransformUsageFlags.Dynamic);

            AddComponent(entity, new MoveSpeed
            {
                Value = authoring.Value
            });
        }
    }
}

Baker は外側に書いても動く。

public sealed class MoveSpeedAuthoring : MonoBehaviour
{
    public float Value = 3f;
}

public sealed class MoveSpeedBaker : Baker<MoveSpeedAuthoring>
{
    public override void Bake(MoveSpeedAuthoring authoring)
    {
        Entity entity = GetEntity(authoring, TransformUsageFlags.Dynamic);

        AddComponent(entity, new MoveSpeed
        {
            Value = authoring.Value
        });
    }
}

ただ、Authoring専用のBakerなら、内部クラスにしたほうが対応関係が分かりやすい。MoveSpeedAuthoring の中に Baker : Baker<MoveSpeedAuthoring> があるため、「このInspector入力は、このECS Componentへ変換される」という流れを追いやすい。

private sealed class Baker として問題ない。外から直接呼ぶものではなく、Baking時に使われる変換処理だからだ。

HPをBakeする例。

using Unity.Entities;
using UnityEngine;

public struct Health : IComponentData
{
    public int Current;
    public int Max;
}

public sealed class HealthAuthoring : MonoBehaviour
{
    public int Max = 100;

    private sealed class Baker : Baker<HealthAuthoring>
    {
        public override void Bake(HealthAuthoring authoring)
        {
            Entity entity = GetEntity(authoring, TransformUsageFlags.Dynamic);

            AddComponent(entity, new Health
            {
                Current = authoring.Max,
                Max = authoring.Max
            });
        }
    }
}

Bakerは初心者にとって面倒に見える。だが、ここを飛ばすと「Editorで置いたGameObject」と「実行時のEntity」がどうつながるのか分からなくなる。ECS入門では、SystemだけでなくBakerもかなり重要だ。

索引へ

GetEntity

Baker内でAuthoring自身のGameObjectに対応するEntityを取得する。

Entity entity = GetEntity(authoring, TransformUsageFlags.Dynamic);

authoringBake メソッドに渡されるAuthoring Componentであり、このComponentが付いているGameObjectに対応するEntityを取得している。

同じ場面では、短く次のようにも書ける。

Entity entity = GetEntity(TransformUsageFlags.Dynamic);

これはBakerの主Entityを取得する書き方だ。現在のAuthoring自身のEntityを取るだけなら、どちらもほぼ同じ意味になる。

取得したEntityに対して AddComponent でデータを付ける。

PrefabをEntityとして参照する場合にも使う。

using Unity.Entities;
using UnityEngine;

public struct Spawner : IComponentData
{
    public Entity Prefab;
}

public sealed class SpawnerAuthoring : MonoBehaviour
{
    public GameObject Prefab;

    private sealed class Baker : Baker<SpawnerAuthoring>
    {
        public override void Bake(SpawnerAuthoring authoring)
        {
            Entity entity = GetEntity(TransformUsageFlags.None);

            AddComponent(entity, new Spawner
            {
                Prefab = GetEntity(authoring.Prefab, TransformUsageFlags.Dynamic)
            });
        }
    }
}

GetEntity(TransformUsageFlags.None) は、このAuthoringが付いているGameObjectに対応するEntityを取得している。

GetEntity(authoring.Prefab, TransformUsageFlags.Dynamic) は、参照しているPrefabに対応するEntityを取得している。

Prefabや別のGameObjectに対応するEntityを取得する場合は、対象を明示する必要がある。

Prefab = GetEntity(authoring.Prefab, TransformUsageFlags.Dynamic)

この違いは大事だ。Spawner自身のEntityと、生成したいPrefabのEntityは別物だ。

索引へ

TransformUsageFlags

そのEntityがTransformをどう使うかを指定する。

Entity entity = GetEntity(authoring, TransformUsageFlags.Dynamic);

TransformUsageFlags は、GameObjectのTransformをEntityデータへ変換するとき、どのTransform系Componentを持たせるかを制御する。Baking時に追加されるTransform Componentを用途に応じて減らすためのフラグだ。

主な値は次の通り。

用途
None Transform Componentを要求しない。設定用Entityなどに使う。
Renderable 描画には必要だが、実行時に動かさないEntityに使う。
Dynamic 実行時に移動・回転・スケール変更するEntityに使う。
WorldSpace 親がDynamicでも、親子関係に入れずワールド空間に置きたい場合に使う。
NonUniformScale XYZで異なるスケールを扱いたい場合に使う。
ManualOverride Transform変換を自分で完全に制御したい場合に使う。通常は使わない。

よく使うのは DynamicRenderableNone だ。

// 動くEntity
GetEntity(authoring, TransformUsageFlags.Dynamic);

// 描画されるが、実行時には動かないEntity
GetEntity(authoring, TransformUsageFlags.Renderable);

// Transformが不要な設定用Entity
GetEntity(authoring, TransformUsageFlags.None);

Dynamic は、実行時に動かすEntityに使う。敵、弾、プレイヤー、移動するギミックなどは基本的にこれだ。

Entity enemy = GetEntity(authoring, TransformUsageFlags.Dynamic);

Renderable は、描画されるが実行時には動かさないEntityに使う。建物、岩、床、背景オブジェクトのような静的な表示物に向いている。Renderable は描画に必要なTransform情報を要求するが、実行時に移動するためのTransform Componentまでは要求しない。

Entity building = GetEntity(authoring, TransformUsageFlags.Renderable);

None は、Transformが要らないEntityに使う。ゲーム全体の設定、Spawnerの設定、入力状態のような位置を持たないデータならこれでよい。

Entity settings = GetEntity(authoring, TransformUsageFlags.None);

ただし、None は他に要求がなければ表示されないEntityとなるため、MeshRendererがあるEntityや、位置を使って処理したいEntityに None を指定すると、必要なTransform Componentがなくて困る。設定用Entityなら None、見えるものや動くものなら別の指定、と分ける。

WorldSpace は、親子関係の影響を受けず、ワールド空間に置きたい場合に使う。

Entity entity = GetEntity(
    authoring,
    TransformUsageFlags.Dynamic | TransformUsageFlags.WorldSpace);

たとえば、Authoring上では整理のためにGameObjectの子にしているが、実行時のEntityとしては親に追従させたくない場合がある。そのときに WorldSpace を使う。DynamicWorldSpace が同じEntityに指定された場合、そのEntityはDynamicかつWorldSpaceとして扱われる。

NonUniformScale は、XYZで異なるスケールを扱いたい場合に使う。

Entity entity = GetEntity(
    authoring,
    TransformUsageFlags.Dynamic | TransformUsageFlags.NonUniformScale);

LocalTransform のスケールは基本的に一様スケールを扱う。new float3(1f, 2f, 1f) のような非一様スケールが必要な場合は、NonUniformScale を指定する。非一様スケールはTransform構成が少し複雑になるため、必要なときだけ使う。

ManualOverride は、Transform変換を完全に自分で制御したい場合に使う。

Entity entity = GetEntity(authoring, TransformUsageFlags.ManualOverride);

これは普通のコードではほぼ使わない。指定すると他のTransformUsageFlagsによる自動Transform Component追加を無視し、自分で必要なTransform Componentを追加する前提になる。よほど明確な理由がない限り、DynamicRenderableNone のどれかから選ぶべきだ。

複数のflagは | で組み合わせられる。

Entity entity = GetEntity(
    authoring,
    TransformUsageFlags.Dynamic |
    TransformUsageFlags.NonUniformScale);

また、複数のBakerが同じGameObjectに対して異なる TransformUsageFlags を要求した場合、それらはまとめて考慮される。たとえば、あるBakerが Dynamic を要求し、別のBakerが WorldSpace を要求した場合、そのEntityはDynamicかつWorldSpaceとして扱われる。

選び方を雑にまとめると、実行時に動くなら Dynamic、描画だけで動かないなら Renderable、位置が不要な設定用Entityなら None、親子関係から切り離したいなら WorldSpace、非一様スケールが必要なら NonUniformScale、Transformを全部自分で組みたい特殊ケースなら ManualOverride

最初は DynamicNone だけで進めても動くことは多い。だが辞書として使うなら、Renderable を知らないのはまずい。動かない表示物まで全部 Dynamic にすると、余計なTransform Componentを持つEntityが増える。小規模なら誤差でも、大量の静的オブジェクトでは無視できない。

索引へ

SubScene

ECS用にBakeされるScene単位。

SubSceneに置いたGameObjectは、Bakingを通してEntityに変換される。普通のSceneにGameObjectを置いただけでは、ECS側のEntityとして扱われない。

SubSceneにはGameObjectやMonoBehaviour Componentを置くことができ、BakingによってそれらがEntityとECS Componentへ変換される。

作り方は大きく2つある。

空のSubSceneを作る場合は、Hierarchyで右クリックし、New Sub Scene > Empty Scene を選ぶ。

既存のGameObjectをSubSceneへ移す場合は、Hierarchyで対象のGameObjectを選択し、右クリックして New Sub Scene > From Selection を選ぶ。

手順としては、まず通常のSceneを開く。次にHierarchyで右クリックして New Sub Scene > Empty Scene を選び、作成されたSubSceneの中にAuthoring付きGameObjectを置く。すでに敵やSpawnerなどのGameObjectを作ってあるなら、それらを選択して New Sub Scene > From Selection を選べばよい。

SubSceneを作ったら、その中にAuthoring Componentを付けたGameObjectを配置する。

public sealed class EnemyAuthoring : MonoBehaviour
{
    public int MaxHp = 10;
    public float MoveSpeed = 2f;

    private sealed class Baker : Baker<EnemyAuthoring>
    {
        public override void Bake(EnemyAuthoring authoring)
        {
            Entity entity = GetEntity(authoring, TransformUsageFlags.Dynamic);

            AddComponent(entity, new EnemyTag());
            AddComponent(entity, new Health
            {
                Current = authoring.MaxHp,
                Max = authoring.MaxHp
            });
        }
    }
}

この EnemyAuthoring が付いたGameObjectをSubScene内に置くと、BakerによってEntityへ変換される。

初心者が最初につまずくのは、コードではなくここだ。

「Authoringを書いた」
「Bakerも書いた」
「Systemも書いた」
「でも動かない」

この場合、SubSceneに置いていない、Bakerが走っていない、対象Componentが付いていない、TransformUsageFlags が合っていない、という原因が多い。

SubSceneを作ったら、Entities HierarchyでBake後のEntityが存在するか確認するとよい。Systemを書く前に、まずデータが存在しているかを見るべきだ。

また、SubSceneが開いている状態ではLive Bakingが行われる。対応するAuthoring SceneのSubSceneが開いている場合、Unityは作業中にAuthoring DataをECS DataへBakeする。変更した内容が即座にECS側へ反映されるため、慣れないうちはSubSceneを開いた状態で確認したほうが分かりやすい。

索引へ

EntityManager

EntityやComponentを直接操作する入口。

Entity entity = state.EntityManager.CreateEntity();

state.EntityManager.AddComponentData(entity, new Health
{
    Current = 100,
    Max = 100
});

Entityを破棄することもできる。

state.EntityManager.DestroyEntity(entity);

EntityManager は強い。Entityの作成、破棄、Componentの追加、削除、取得ができる。

ただし、強いから何でも使えばいい、という話ではない。Entityの作成、破棄、Componentの追加や削除は構造変更になる。構造変更はEntityのComponent構成を変える操作なので、処理中の安全性や実行タイミングに気を使う必要がある。

まずは読み書きは SystemAPI.Query、構造変更は EntityCommandBuffer、どうしても直接操作したいときだけ EntityManager と考えるとよい。正直あんまりおすすめはしない。

索引へ

EntityCommandBuffer

Entityの生成・破棄・Component追加などを後でまとめて実行する。

Entityの作成、破棄、Componentの追加・削除は構造変更になる。処理中に直接実行すると都合が悪い場面があるため、EntityCommandBuffer、略してECBに命令を記録し、あとでまとめて再生する。

基本形は、EndSimulationEntityCommandBufferSystem.Singleton からECBを作る形だ。

var ecbSingleton =
    SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();

EntityCommandBuffer ecb =
    ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);

ここで出てくる state.WorldUnmanaged は、このSystemが属しているWorldのunmanaged側の情報を表す。何を言っているんだという感じだが。

EntityCommandBuffer ecb =
    ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);

Unity ECSには World という単位がある。WorldはEntity、Component、Systemをまとめて持つ実行環境のようなものだ。通常のゲームではデフォルトのWorldを意識しなくても動くが、ECS内部ではSystemは必ずどこかのWorldに所属している。

state.WorldUnmanaged は、そのWorldのunmanagedな部分にアクセスするための値だ。

なぜECB作成にこれを渡すのかというと、EndSimulationEntityCommandBufferSystem.Singleton が「どのWorldに対してECBを作るのか」を知る必要があるからだ。

EntityCommandBuffer ecb =
    ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);

このECBは、state.WorldUnmanaged が示すWorldに対して再生される。つまり、このSystemが動いているWorldに対して、Entityの生成、破棄、Component追加などの命令を予約する、という意味になる。

名前が少し怖いが、普通のコードでは深く考えすぎなくていい。ISystem の中でECB SystemのSingletonからECBを作るときは、基本的に state.WorldUnmanaged を渡す、と覚えればよい。

state.WorldUnmanaged は「unmanaged Worldを自分で操作するための便利道具」ではない。むやみに触るものではなく、ECB SystemやSystemHandleなど、unmanagedなECS APIがWorld情報を必要とする場面で渡すものだ。

閑話休題。

たとえば、HPが0以下になったEntityを破棄するならこう書ける。

using Unity.Entities;

public partial struct DeadEnemyDestroySystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<EndSimulationEntityCommandBufferSystem.Singleton>();
    }

    public void OnUpdate(ref SystemState state)
    {
        var ecbSingleton =
            SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();

        EntityCommandBuffer ecb =
            ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);

        foreach (var (health, entity) in
                 SystemAPI.Query<RefRO<Health>>()
                     .WithAll<EnemyTag>()
                     .WithEntityAccess())
        {
            if (health.ValueRO.Current <= 0)
            {
                ecb.DestroyEntity(entity);
            }
        }
    }
}

この例では、DestroyEntity はその場で実行されない。ECBに「このEntityを破棄する」という命令を記録しているだけだ。実際の破棄は EndSimulationEntityCommandBufferSystem のタイミングで行われる。

並列JobからECBへ書き込む場合は、AsParallelWriter() を使う。

var ecbSingleton =
    SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();

EntityCommandBuffer.ParallelWriter ecb =
    ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged).AsParallelWriter();

ParallelWriter を使う場合、各コマンドにはsort keyを渡す。

using Unity.Burst;
using Unity.Entities;

[BurstCompile]
public partial struct DeadEnemyDestroySystem : ISystem
{
    [BurstCompile]
    private partial struct DestroyDeadEnemyJob : IJobEntity
    {
        public EntityCommandBuffer.ParallelWriter Ecb;

        private void Execute(
            Entity entity,
            [ChunkIndexInQuery] int sortKey,
            in Health health,
            in EnemyTag enemyTag)
        {
            if (health.Current <= 0)
            {
                Ecb.DestroyEntity(sortKey, entity);
            }
        }
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var ecbSingleton =
            SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();

        EntityCommandBuffer.ParallelWriter ecb =
            ecbSingleton
                .CreateCommandBuffer(state.WorldUnmanaged)
                .AsParallelWriter();

        new DestroyDeadEnemyJob
        {
            Ecb = ecb
        }.ScheduleParallel();
    }
}

[ChunkIndexInQuery] int sortKey は、並列Jobで記録されたECBコマンドの再生順を安定させるために使う。ParallelWriterDestroyEntityAddComponentInstantiate などでは、第1引数にこのsort keyを渡す。

一方、SystemAPI.Queryforeach でメインスレッド上から記録するだけなら、ParallelWriter は不要だ。

EntityCommandBuffer ecb =
    ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);

ParallelWriter は「並列に書くためのECB」であって、常に使うものではない。

小さいサンプルや説明用コードでは、次のように自分でECBを作り、すぐに再生して破棄する書き方もできる。

var ecb = new EntityCommandBuffer(Allocator.Temp);

ecb.DestroyEntity(entity);

ecb.Playback(state.EntityManager);
ecb.Dispose();

ただし、基本形としては、ECB SystemのSingletonから作る形を先に覚えたほうがよい。手動 Playback は同期点を作りやすく、Job化したときにも書き換えが必要になる。

まとめると、メインスレッドで後回しにしたい構造変更なら CreateCommandBuffer(state.WorldUnmanaged)、並列Jobから書くなら AsParallelWriter()、説明用の小さい同期コードだけなら new EntityCommandBuffer(Allocator.Temp) でもよい。

索引へ

Instantiate

Entity prefabからEntityを複製する。

Entity instance = ecb.Instantiate(spawner.ValueRO.Prefab);

Spawnerの例。

public struct Spawner : IComponentData
{
    public Entity Prefab;
    public float Timer;
    public float Interval;
}

System側。

using Unity.Collections;
using Unity.Entities;

public partial struct SpawnSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<BeginSimulationEntityCommandBufferSystem.Singleton>();
    }

    public void OnUpdate(ref SystemState state)
    {
        var ecbSingleton =
            SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();

        EntityCommandBuffer ecb =
            ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);

        foreach (var spawner in SystemAPI.Query<RefRW<Spawner>>())
        {
            spawner.ValueRW.Timer -= SystemAPI.Time.DeltaTime;

            if (spawner.ValueRO.Timer > 0f)
            {
                continue;
            }

            spawner.ValueRW.Timer = spawner.ValueRO.Interval;
            ecb.Instantiate(spawner.ValueRO.Prefab);
        }
    }
}

GameObjectの Instantiate と似ているが、対象はEntity prefabだ。PrefabをEntityとして扱うには、Baker側で GetEntity(authoring.Prefab, TransformUsageFlags.Dynamic) のように変換しておく必要がある。

Prefabから生成したEntityの初期値を変えたい場合は、Instantiate のあとに SetComponent を書く。

Entity instance = ecb.Instantiate(spawner.ValueRO.Prefab);

ecb.SetComponent(instance, LocalTransform.FromPosition(spawnPosition));

この場合、Prefab側に LocalTransform が付いている前提だ。付いていないComponentを設定したいなら、SetComponent ではなく AddComponent を使う。

索引へ

DestroyEntity

Entityを破棄する。

ecb.DestroyEntity(entity);

HPが0以下になったEntityを消す例。

using Unity.Collections;
using Unity.Entities;

public partial struct DeadEnemyDestroySystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<EndSimulationEntityCommandBufferSystem.Singleton>();
    }

    public void OnUpdate(ref SystemState state)
    {
        var ecbSingleton =
            SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();

        EntityCommandBuffer ecb =
            ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);

        foreach (var (health, entity) in
                 SystemAPI.Query<RefRO<Health>>()
                     .WithAll<EnemyTag>()
                     .WithEntityAccess())
        {
            if (health.ValueRO.Current <= 0)
            {
                ecb.DestroyEntity(entity);
            }
        }
    }
}

ここで Health.Current <= 0 のEntityを見つけ、ECBへ破棄命令を積んでいる。

ただし、破棄は最後の手段でもある。大量に作って消す弾やエフェクトでは、生成・破棄の負担が問題になる場合がある。その場合は無効化、再利用、Pooling、IEnableableComponent なども検討する。

索引へ

AddComponent / RemoveComponent / SetComponent

EntityのComponent構成を変更したり、既存Componentの値を更新したりする。

ecb.AddComponent(entity, new DeadTag());
ecb.RemoveComponent<MoveSpeed>(entity);
ecb.SetComponent(entity, new Health
{
    Current = 50,
    Max = 100
});

AddComponent はComponentを追加する。RemoveComponent はComponentを外す。これらはEntityのComponent構成を変えるため、構造変更になる。

public struct DeadTag : IComponentData
{
}

死亡したEntityに DeadTag を付ける例。

foreach (var (health, entity) in
         SystemAPI.Query<RefRO<Health>>()
             .WithNone<DeadTag>()
             .WithEntityAccess())
{
    if (health.ValueRO.Current <= 0)
    {
        ecb.AddComponent<DeadTag>(entity);
    }
}

WithNone<DeadTag>() は、まだ DeadTag が付いていないEntityだけを対象にする。

一方、SetComponent は既に付いているComponentの値を変更する。

ecb.SetComponent(entity, new Health
{
    Current = 80,
    Max = 100
});

ここで重要なのは、SetComponent はComponentを追加しないということだ。対象Entityが Health を持っていないなら、SetComponent<Health> ではなく AddComponent を使う必要がある。

if (SystemAPI.HasComponent<Health>(entity))
{
    ecb.SetComponent(entity, new Health
    {
        Current = 80,
        Max = 100
    });
}
else
{
    ecb.AddComponent(entity, new Health
    {
        Current = 80,
        Max = 100
    });
}

ただし、既にComponentを持っているEntityを SystemAPI.Query で回しているなら、ECBの SetComponent を使う必要はないことが多い。その場で RefRW<T> を書き換えればよい。

foreach (var health in SystemAPI.Query<RefRW<Health>>())
{
    health.ValueRW.Current -= 10;
}

この場合はComponent構成を変えていない。ただ Health.Current の値を書き換えているだけだ。だから、ECBに命令を積むより直接 RefRW<Health> で書いたほうが単純だ。

SetComponent が便利なのは、ECBで生成・複製したEntityに値を設定したい場合だ。

Entity instance = ecb.Instantiate(prefab);

ecb.SetComponent(instance, new LocalTransform
{
    Position = spawnPosition,
    Rotation = quaternion.identity,
    Scale = 1f
});

Prefab側に LocalTransform が既に付いているなら、SetComponent で初期位置を上書きできる。逆に、そのComponentがPrefabに付いていないなら AddComponent が必要になる。

並列Jobから使う場合は、ParallelWriter の各コマンドにsort keyを渡す。

ecb.SetComponent(sortKey, entity, new Health
{
    Current = 80,
    Max = 100
});

AddComponentRemoveComponentSetComponent の使い分けは単純だ。Componentを付けるなら AddComponent、外すなら RemoveComponent、既にあるComponentの値を置き換えるなら SetComponent。ただし、通常の値変更ならまず RefRW<T> を考える。ECBの SetComponent は「あとで反映したい値変更」や「ECBで作ったEntityへの初期値設定」に向いている。

注意すべきなのは、Component追加・削除が構造変更であることだ。毎フレーム大量に追加・削除すると負担が大きくなりやすい。状態のオン・オフだけなら、後述の IEnableableComponent のほうが合う場合がある。

索引へ

RequireForUpdate

指定Componentが存在するときだけSystemを動かす。

public partial struct MoveSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<MoveSpeed>();
    }

    public void OnUpdate(ref SystemState state)
    {
        // MoveSpeedが存在するときだけ実行される
    }
}

Singleton設定が存在するときだけ動かす例。

using Unity.Entities;
using Unity.Transforms;

public struct GameSettings : IComponentData
{
    public float EnemySpeedMultiplier;
}

public partial struct EnemyMoveSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<GameSettings>();
    }

    public void OnUpdate(ref SystemState state)
    {
        GameSettings settings = SystemAPI.GetSingleton<GameSettings>();

        foreach (var (transform, speed, direction) in
                 SystemAPI.Query<
                     RefRW<LocalTransform>,
                     RefRO<MoveSpeed>,
                     RefRO<MoveDirection>>()
                     .WithAll<EnemyTag>())
        {
            transform.ValueRW.Position +=
                direction.ValueRO.Value *
                speed.ValueRO.Value *
                settings.EnemySpeedMultiplier *
                SystemAPI.Time.DeltaTime;
        }
    }
}

Systemが「必要なデータがない状態」で動くと、無駄な処理や例外の原因になる。RequireForUpdate は地味だが重要だ。

索引へ

Singleton

1つだけ存在するComponentを取得する。

GameSettings settings = SystemAPI.GetSingleton<GameSettings>();

設定値をSingletonとして持つ例。

public struct GameSettings : IComponentData
{
    public float PlayerSpeed;
    public float EnemySpeed;
}

Bakerで設定用Entityに付ける。

using Unity.Entities;
using UnityEngine;

public sealed class GameSettingsAuthoring : MonoBehaviour
{
    public float PlayerSpeed = 5f;
    public float EnemySpeed = 2f;

    private sealed class Baker : Baker<GameSettingsAuthoring>
    {
        public override void Bake(GameSettingsAuthoring authoring)
        {
            Entity entity = GetEntity(TransformUsageFlags.None);

            AddComponent(entity, new GameSettings
            {
                PlayerSpeed = authoring.PlayerSpeed,
                EnemySpeed = authoring.EnemySpeed
            });
        }
    }
}

Systemで読む。

public void OnUpdate(ref SystemState state)
{
    GameSettings settings = SystemAPI.GetSingleton<GameSettings>();

    // settings.PlayerSpeed を使う
}

Singletonは便利だが、乱用するとただのグローバル変数になる。ゲーム全体で1つしかない設定、入力状態、進行状態などに限定したほうが読みやすい。

索引へ

DynamicBuffer

Entityに可変長の配列的データを持たせる。

public struct DamageHistory : IBufferElementData
{
    public int Value;
}

Bufferを読む例。

foreach (var buffer in SystemAPI.Query<DynamicBuffer<DamageHistory>>())
{
    for (int i = 0; i < buffer.Length; i++)
    {
        int damage = buffer[i].Value;
    }
}

ただし、SystemAPI.Query<DynamicBuffer<T>>() は既定で読み書き扱いになる。

厳密にread-onlyとして扱いたい場合は、chunk単位で処理し、ArchetypeChunk.GetBufferAccessorRO(ref bufferTypeHandle) から読む。

BufferTypeHandle<T>state.GetBufferTypeHandle<T>(isReadOnly: true) で取得しておくと意図が分かりやすい。

using Unity.Collections;
using Unity.Entities;

public struct DamageHistory : IBufferElementData
{
    public int Value;
}

public partial struct ReadDamageHistorySystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        EntityQuery query = SystemAPI.QueryBuilder()
            .WithAll<DamageHistory>()
            .Build();

        BufferTypeHandle<DamageHistory> bufferTypeHandle =
            state.GetBufferTypeHandle<DamageHistory>(isReadOnly: true);

        using NativeArray<ArchetypeChunk> chunks =
            query.ToArchetypeChunkArray(Allocator.Temp);

        foreach (ArchetypeChunk chunk in chunks)
        {
            BufferAccessor<DamageHistory> buffers =
                chunk.GetBufferAccessorRO(ref bufferTypeHandle);

            for (int entityIndex = 0; entityIndex < chunk.Count; entityIndex++)
            {
                DynamicBuffer<DamageHistory> buffer = buffers[entityIndex];

                for (int i = 0; i < buffer.Length; i++)
                {
                    int damage = buffer[i].Value;
                    // 読むだけ
                }
            }
        }
    }
}

Bufferへ追加する例。

foreach (var buffer in SystemAPI.Query<DynamicBuffer<DamageHistory>>())
{
    buffer.Add(new DamageHistory
    {
        Value = 10
    });
}

通常の IComponentData は固定のフィールドを持つデータに向いている。一方、DynamicBuffer は「Entityごとに数が変わるデータ」に向いている。

たとえば、受けたダメージ履歴、持っているターゲット候補、経路のポイント列などだ。

ただし、何でもBufferに入れるとデータ構造が読みにくくなる。まずは普通のComponentで表せないか考えること。

索引へ

IEnableableComponent

Componentを付け外しせずに有効・無効を切り替える。

public struct ActiveBullet : IComponentData, IEnableableComponent
{
}

有効状態を切り替える。

SystemAPI.SetComponentEnabled<ActiveBullet>(entity, false);

弾を消す代わりに無効化する例。

foreach (var (lifetime, entity) in
         SystemAPI.Query<RefRW<Lifetime>>()
             .WithAll<ActiveBullet>()
             .WithEntityAccess())
{
    lifetime.ValueRW.Value -= SystemAPI.Time.DeltaTime;

    if (lifetime.ValueRO.Value <= 0f)
    {
        SystemAPI.SetComponentEnabled<ActiveBullet>(entity, false);
    }
}

単なるオン・オフを表すなら、Componentの追加・削除より IEnableableComponent のほうが合う場面がある。

ただし、検索条件の見え方が少し複雑になるため、最初は「頻繁に切り替える状態」に限定して使うとよい。

索引へ

EntityQuery

条件に合うEntity群を表す検索条件。

EntityQuery query = SystemAPI.QueryBuilder()
    .WithAll<MoveSpeed, LocalTransform>()
    .Build();

件数を調べる。

int count = query.CalculateEntityCount();

EntityQueryは、SystemがどのEntityを対象にするかを明示したいときに使う。SystemAPI.Query だけでも多くの処理は書けるが、件数を取る、まとめてComponentを追加する、Systemの実行条件に使う、といった場面で登場する。

public void OnCreate(ref SystemState state)
{
    EntityQuery query = SystemAPI.QueryBuilder()
        .WithAll<EnemyTag, Health>()
        .Build();

    state.RequireForUpdate(query);
}

初心者は最初からEntityQueryを多用しなくていい。まずは SystemAPI.Query で処理を書き、必要になったらEntityQueryを導入する順序で十分だ。

索引へ

EntitiesEvents

System間で軽量なイベント通知を行うためのライブラリ。

Unity ECSでは、Componentは基本的に「状態」を表す。では、「ダメージを受けた」「攻撃が発生した」「敵を倒した」のような一回限りの通知はどう扱うのか。

単純な場合は、DamageRequest のようなComponentを一時的に付けたり、DynamicBuffer にイベントを積んだり、EntityCommandBuffer で処理用Entityを作ったりできる。

ただし、それを毎回自作すると、イベントの寿命、読み取り位置、削除タイミングが散らかりやすい。

EntitiesEvents は、そのSystem間通知を扱いやすくするためのライブラリだ。seikasan/EntitiesEvents はUnity 6.0以降、Entities 1.4.7以降を対象にしたforkで、Roslyn Source Generatorを使ってイベント用のSystemや登録コードを生成する。

インストールはPackage ManagerのGit URLから行う。

https://github.com/seikasan/EntitiesEvents.git?path=Assets/EntitiesEvents

Packages/manifest.json に直接書くならこうなる。

{
  "dependencies": {
    "com.seikasan.entities-events": "https://github.com/seikasan/EntitiesEvents.git?path=Assets/EntitiesEvents"
  }
}

EntitiesEvents は、「状態」ではなく「一回限りの通知」を扱うときに便利だ。

HPそのものは Health Componentで持つべきだが、「このフレームにダメージが発生した」という通知は DamageEvent に向いている。

雑に言えば、Componentは名詞、Systemは動詞、Eventは発生した事実。

索引へ

RegisterEvent

EntitiesEventsで使うイベント型を登録する。

using EntitiesEvents;
using Unity.Entities;

// RegisterEvent はassembly属性なので、namespace の中には書けない。
[assembly: RegisterEvent(typeof(Game.Events.DamageEvent))]

namespace Game.Events
{
    public struct DamageEvent
    {
        public Entity Target;
        public int Amount;
    }
}

イベント型はunmanagedなstructにする。参照型を含めることはできない。

public struct DamageEvent
{
    public Entity Target;
    public int Amount;
}

RegisterEvent はassembly属性として付ける。

[assembly: RegisterEvent(typeof(DamageEvent))]

この登録によって、Source Generatorが必要なSystemや登録処理を生成する。イベント型を定義しただけでは使えない。登録を忘れると、WriterやReaderを取得する段階で詰まる。

索引へ

EventWriter

イベントを書き込む。

using EntitiesEvents;
using Unity.Burst;
using Unity.Entities;

[BurstCompile]
public partial struct DamageEventWriteSystem : ISystem
{
    private EventWriter<DamageEvent> _writer;

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        _writer = state.GetEventWriter<DamageEvent>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        foreach (var attack in SystemAPI.Query<RefRO<AttackRequest>>())
        {
            _writer.Write(new DamageEvent
            {
                Target = attack.ValueRO.Target,
                Amount = attack.ValueRO.Amount
            });
        }
    }
}

重要なのは、EventWriter<T>OnCreate で取得してキャッシュすることだ。

private EventWriter<DamageEvent> _writer;

public void OnCreate(ref SystemState state)
{
    _writer = state.GetEventWriter<DamageEvent>();
}

毎フレーム GetEventWriter すればいい、という発想はやめたほうがいい。WriterやReaderはSystem内に保持して使う。

イベントを書き込む側は、基本的には「何かが起きた」という事実だけを流す。HPを直接減らすのか、エフェクトを出すのか、ログを取るのかは、読む側のSystemに分けられる。

索引へ

EventReader

イベントを読み取る。

using EntitiesEvents;
using Unity.Entities;
using UnityEngine;

public partial struct DamageEventReadSystem : ISystem
{
    private EventReader<DamageEvent> _reader;

    public void OnCreate(ref SystemState state)
    {
        _reader = state.GetEventReader<DamageEvent>();
    }

    public void OnUpdate(ref SystemState state)
    {
        foreach (var damageEvent in _reader.Read())
        {
            Debug.Log($"Damage: {damageEvent.Amount}");
        }
    }
}

EventReader<T>OnCreate で取得してキャッシュする。

private EventReader<DamageEvent> _reader;

public void OnCreate(ref SystemState state)
{
    _reader = state.GetEventReader<DamageEvent>();
}

EventReader<T> は自分の読み取り位置を持つ。毎フレーム作り直すと、同じイベントを重複して読む原因になる。これはかなり重要だ。

READMEでも、WriterとReaderは OnCreate で取得してキャッシュするよう警告されている。特にReaderは読み取ったイベント数を保持するため、読み取りのたびに作り直すと重複読み取りが起きうる。

イベントの寿命にも注意する。書き込まれたイベントは同じフレームと次のフレームで読める。2回の Update 後には未読イベントも破棄される。送信側より受信側が先に実行された場合でも次のフレームで読めるが、その分1フレーム遅れる可能性がある。

同じフレームで確実に処理したいなら、UpdateBeforeUpdateAfter でSystemの実行順を明示する。

索引へ

EventParallelWriter

Jobなどから並列にイベントを書き込む。

using EntitiesEvents;
using Unity.Burst;
using Unity.Entities;

[BurstCompile]
public partial struct ParallelDamageEventSystem : ISystem
{
    private EventParallelWriter<DamageEvent> _writer;

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.EnsureEventCapacity<DamageEvent>(1024);
        _writer = state.GetEventParallelWriter<DamageEvent>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        new DamageEventWriteJob
        {
            Writer = _writer
        }.ScheduleParallel();
    }

    [BurstCompile]
    private partial struct DamageEventWriteJob : IJobEntity
    {
        public EventParallelWriter<DamageEvent> Writer;

        private void Execute(in AttackRequest attackRequest)
        {
            Writer.WriteNoResize(new DamageEvent
            {
                Target = attackRequest.Target,
                Amount = attackRequest.Amount
            });
        }
    }
}

ここでは、AttackRequest を持つEntityを並列Jobで処理し、その中から DamageEvent を書き込んでいる。

public struct AttackRequest : IComponentData
{
    public Entity Target;
    public int Amount;
}

EventParallelWriter<T> は、OnCreate で取得してSystemのフィールドに保持する。

private EventParallelWriter<DamageEvent> _writer;

public void OnCreate(ref SystemState state)
{
    state.EnsureEventCapacity<DamageEvent>(1024);
    _writer = state.GetEventParallelWriter<DamageEvent>();
}

並列書き込みでは、事前に十分な容量を確保する必要がある。

state.EnsureEventCapacity<DamageEvent>(1024);

EventParallelWriter<T> は内部バッファを自動拡張しない。そのため、Jobから書く前に容量を確保し、Job内では WriteNoResize を使う。

private void Execute(in AttackRequest attackRequest)
{
    Writer.WriteNoResize(new DamageEvent
    {
        Target = attackRequest.Target,
        Amount = attackRequest.Amount
    });
}

通常の EventWriter<T> では Write を使う。

_writer.Write(new DamageEvent
{
    Target = target,
    Amount = amount
});

一方、EventParallelWriter<T> では WriteNoResize を使う。

Writer.WriteNoResize(new DamageEvent
{
    Target = target,
    Amount = amount
});

名前の通り、WriteNoResize は容量を増やさずに書き込む。容量が足りない状態で書こうとすると問題になる。だから EnsureEventCapacity<T>EnsureAdditionalEventCapacity<T> で、あらかじめ必要な容量を確保しておく。

たとえば、そのフレームで最大1024件までイベントが発生すると見込むなら、絶対容量として確保する。

state.EnsureEventCapacity<DamageEvent>(1024);

すでに書き込まれている件数に対して、さらに追加分の容量を確保したいなら EnsureAdditionalEventCapacity<T> を使う。

state.EnsureAdditionalEventCapacity<DamageEvent>(128);

EnsureEventCapacity<T>(capacity) は、現在フレームと次フレームのバッファに対する絶対容量を保証し、EnsureAdditionalEventCapacity<T>(additionalCapacity) は現在フレームの書き込み数に対して相対的に容量を確保する。

HPが残っている対象にだけダメージイベントを書くならこうなる。

using EntitiesEvents;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;

public struct AttackRequest : IComponentData
{
    public Entity Target;
    public int Amount;
}

[BurstCompile]
public partial struct ParallelDamageEventSystem : ISystem
{
    private EventParallelWriter<DamageEvent> _writer;

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.EnsureEventCapacity<DamageEvent>(1024);
        _writer = state.GetEventParallelWriter<DamageEvent>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        ComponentLookup<Health> healthLookup =
            SystemAPI.GetComponentLookup<Health>(true);

        new DamageEventWriteJob
        {
            Writer = _writer,
            HealthLookup = healthLookup
        }.ScheduleParallel();
    }

    [BurstCompile]
    private partial struct DamageEventWriteJob : IJobEntity
    {
        public EventParallelWriter<DamageEvent> Writer;

        [ReadOnly]
        public ComponentLookup<Health> HealthLookup;

        private void Execute(in AttackRequest attackRequest)
        {
            if (!HealthLookup.HasComponent(attackRequest.Target))
            {
                return;
            }

            if (HealthLookup[attackRequest.Target].Current <= 0)
            {
                return;
            }

            Writer.WriteNoResize(new DamageEvent
            {
                Target = attackRequest.Target,
                Amount = attackRequest.Amount
            });
        }
    }
}

この例では、ComponentLookup<Health> を使って、攻撃対象が Health を持っているか、すでにHPが0以下ではないかを確認している。

ComponentLookup<Health> healthLookup =
    SystemAPI.GetComponentLookup<Health>(true);

true は読み取り専用として取得する指定だ。Job内でも [ReadOnly] を付ける。

[ReadOnly]
public ComponentLookup<Health> HealthLookup;

SystemAPI.Queryforeach でメインスレッド上からイベントを書くなら、EventParallelWriter<T> は不要だ。その場合は通常の EventWriter<T> でよい。

_writer.Write(new DamageEvent
{
    Target = target,
    Amount = amount
});

EventParallelWriter<T> は「Jobから並列にイベントを書くためのWriter」だ。常にこちらを使うものではない。通常のSystem内で1本の流れとして書くだけなら EventWriter<T>ScheduleParallel するJobから書くなら EventParallelWriter<T> と分けて考えるとよい。

索引へ

IJobEntity

Componentの処理をJobとして書く。

using Unity.Burst;
using Unity.Entities;
using Unity.Transforms;

[BurstCompile]
public partial struct MoveJob : IJobEntity
{
    public float DeltaTime;

    public void Execute(
        ref LocalTransform transform,
        in MoveSpeed speed,
        in MoveDirection direction)
    {
        transform.Position +=
            direction.Value * speed.Value * DeltaTime;
    }
}

Systemから実行する。

using Unity.Burst;
using Unity.Entities;

[BurstCompile]
public partial struct MoveSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        new MoveJob
        {
            DeltaTime = SystemAPI.Time.DeltaTime
        }.ScheduleParallel();
    }
}

IJobEntity は、ComponentDataをまたぐ処理をJobとして書くための仕組みだ。SystemAPI.Query は読みやすいが、並列処理したい場合は IJobEntity が候補になる。

最初から全部Jobにしようとしなくてよい。まず SystemAPI.Query で正しく書く。その後、大量のEntityを処理する箇所だけ IJobEntity にする。

順序を逆にすると、何が起きているのか追跡しにくい。

索引へ

BurstCompile

Burstコンパイル対象にする属性。

using Unity.Burst;

[BurstCompile]
public partial struct MoveSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
    }
}

Jobにも付ける。

[BurstCompile]
public partial struct MoveJob : IJobEntity
{
    public void Execute(ref LocalTransform transform)
    {
    }
}

BurstはECSと一緒に語られやすいが、ECSそのものとは別の仕組みだ。ECSはデータの持ち方と処理の分け方。Burstはその処理を高速なネイティブコードへコンパイルするためのもの。

Burst対象にするコードでは、managed object、通常の string、多くのUnityEngine APIなどが使えない。だから、ECSではComponentをunmanagedなデータとして持ち、SystemやJobでまとめて処理する設計が重要になる。

Debug.Log を書くような確認用Systemにまで、無理に BurstCompile を付ける必要はない。Burstを付ける場所と、付けない場所を分けること。

索引へ

UpdateInGroup

Systemが実行されるグループを指定する。

using Unity.Entities;

[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct MoveSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
    }
}

順序を指定したい場合。

[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(InputSystem))]
public partial struct PlayerMoveSystem : ISystem
{
}

Systemの実行順は、ECSでかなり重要だ。入力を読むSystem、移動するSystem、当たり判定をするSystem、破棄するSystemの順序が曖昧だと、1フレーム遅れや不安定な挙動につながる。

EntitiesEventsでも同じだ。送信側Systemより受信側Systemが先に実行されると、イベントの処理が次フレームになることがある。同じフレームで処理したいなら、UpdateBeforeUpdateAfter を使う。

ただし、最初から属性を増やしすぎると読みにくい。順序が必要なSystemだけに付けるべきだ。

索引へ

実戦的な組み合わせ

典型的な「敵を移動させ、ダメージイベントを読み、HPが0になったら消す」コードはこうなる。

まずComponent。

using Unity.Entities;
using Unity.Mathematics;

public struct EnemyTag : IComponentData
{
}

public struct MoveSpeed : IComponentData
{
    public float Value;
}

public struct MoveDirection : IComponentData
{
    public float3 Value;
    public readonly float3 NormalizedValue => math.normalizesafe(Value);
}

public struct Health : IComponentData
{
    public int Current;
    public int Max;
}

ダメージイベント。

using EntitiesEvents;
using Unity.Entities;

[assembly: RegisterEvent(typeof(DamageEvent))]

public struct DamageEvent
{
    public Entity Target;
    public int Amount;
}

AuthoringとBaker。

using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;

public sealed class EnemyAuthoring : MonoBehaviour
{
    public int MaxHp = 10;
    public float MoveSpeed = 2f;
    public Vector3 MoveDirection = Vector3.left;

    private sealed class Baker : Baker<EnemyAuthoring>
    {
        public override void Bake(EnemyAuthoring authoring)
        {
            Entity entity = GetEntity(TransformUsageFlags.Dynamic);

            AddComponent<EnemyTag>(entity);

            AddComponent(entity, new Health
            {
                Current = authoring.MaxHp,
                Max = authoring.MaxHp
            });

            AddComponent(entity, new MoveSpeed
            {
                Value = authoring.MoveSpeed
            });

            float3 direction = new float3(
                authoring.MoveDirection.x,
                authoring.MoveDirection.y,
                authoring.MoveDirection.z);

            AddComponent(entity, new MoveDirection
            {
                Value = math.normalizesafe(direction)
            });
        }
    }
}

移動System。

using Unity.Burst;
using Unity.Entities;
using Unity.Transforms;

[BurstCompile]
public partial struct EnemyMoveSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<EnemyTag>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        foreach (var (transform, speed, direction) in
                 SystemAPI.Query<
                     RefRW<LocalTransform>,
                     RefRO<MoveSpeed>,
                     RefRO<MoveDirection>>()
                     .WithAll<EnemyTag>())
        {
            transform.ValueRW.Position +=
                direction.ValueRO.Value *
                speed.ValueRO.Value *
                SystemAPI.Time.DeltaTime;
        }
    }
}

DamageEventを読んでHPを減らすSystem。

using EntitiesEvents;
using Unity.Entities;

public partial struct ApplyDamageSystem : ISystem
{
    private EventReader<DamageEvent> _reader;

    public void OnCreate(ref SystemState state)
    {
        _reader = state.GetEventReader<DamageEvent>();
    }

    public void OnUpdate(ref SystemState state)
    {
        foreach (var damageEvent in _reader.Read())
        {
            if (!SystemAPI.HasComponent<Health>(damageEvent.Target))
            {
                continue;
            }

            RefRW<Health> health =
                SystemAPI.GetComponentRW<Health>(damageEvent.Target);

            health.ValueRW.Current -= damageEvent.Amount;
        }
    }
}

死亡した敵を破棄するSystem。

using Unity.Collections;
using Unity.Entities;

[UpdateAfter(typeof(ApplyDamageSystem))]
public partial struct DeadEnemyDestroySystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<EndSimulationEntityCommandBufferSystem.Singleton>();
    }

    public void OnUpdate(ref SystemState state)
    {
        var ecbSingleton =
            SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();

        EntityCommandBuffer ecb =
            ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);

        foreach (var (health, entity) in
                 SystemAPI.Query<RefRO<Health>>()
                     .WithAll<EnemyTag>()
                     .WithEntityAccess())
        {
            if (health.ValueRO.Current <= 0)
            {
                ecb.DestroyEntity(entity);
            }
        }
    }
}

このコードで使っている役割は明確だ。

EnemyAuthoring がInspector入力を受け持ち、内部の Baker がGameObjectをEntityデータへ変換する。EnemyTag は敵であることを示し、Health はHP、MoveSpeed は速度、MoveDirection は移動方向を持つ。

EnemyMoveSystemLocalTransform を書き換えて敵を移動させる。ApplyDamageSystemDamageEvent を読み、対象Entityの Health を減らす。DeadEnemyDestroySystem はHPが0以下になったEntityをECBで破棄する。

ここで大事なのは、状態とイベントを分けていることだ。

Health は状態なのでComponentにする。DamageEvent は一回限りの通知なのでEntitiesEventsで流す。敵を消す操作は構造変更なのでECBに積む。

最初に覚える優先順位を絞るなら、IComponentDataISystemSystemAPI.QueryRefRORefRWAuthoring、内部 BakerGetEntityTransformUsageFlagsEntityCommandBufferRequireForUpdate。イベント通知まで扱うなら、そこに RegisterEventEventWriterEventReader を足す。

逆に、最初から IJobChunk、BlobAsset、Custom Baking System、Netcode for Entities、Entities Graphicsの細かい描画設定まで触る必要はない。

ECSは難しいのではなく、入口が散らかっている。まずは「Componentはデータ」「Systemは処理」「BakerはGameObjectからEntityへの変換」「Eventは一回限りの通知」と分けて読むこと。この四つが分かれば、少なくとも最初の壁は越えられる。

索引へ

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?