自分用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.ForEach や IAspect が非推奨になっており、今から覚えるなら SystemAPI.Query と IJobEntity を中心にしたほうがよい。古い記事では Entities.ForEach や Aspect が出てくることがあるが、そこを最初の基本形にするのは少し危ない。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 を付けるより、MoveSpeed や MoveDirection を持っていること自体を条件にしたほうが自然な場合もある。
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を扱う場合などに出番があるが、最初から両方を混ぜると混乱する。
まずは IComponentData、ISystem、SystemAPI.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;
}
LocalTransform と MoveSpeed の両方を持つ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 は、ISystem と SystemBase の両方で使える、Component列挙用のAPIだ。Unity公式ドキュメントでも、メインスレッドでデータの集合を列挙するために foreach 構文で使えると説明されている。
条件を足したいときは、WithAll や WithNone を使う。
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);
authoring は Bake メソッドに渡される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変換を自分で完全に制御したい場合に使う。通常は使わない。 |
よく使うのは Dynamic、Renderable、None だ。
// 動く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 を使う。Dynamic と WorldSpace が同じ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を追加する前提になる。よほど明確な理由がない限り、Dynamic、Renderable、None のどれかから選ぶべきだ。
複数の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。
最初は Dynamic と None だけで進めても動くことは多い。だが辞書として使うなら、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コマンドの再生順を安定させるために使う。ParallelWriter の DestroyEntity、AddComponent、Instantiate などでは、第1引数にこのsort keyを渡す。
一方、SystemAPI.Query の foreach でメインスレッド上から記録するだけなら、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
});
AddComponent、RemoveComponent、SetComponent の使い分けは単純だ。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フレーム遅れる可能性がある。
同じフレームで確実に処理したいなら、UpdateBefore や UpdateAfter で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.Query の foreach でメインスレッド上からイベントを書くなら、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が先に実行されると、イベントの処理が次フレームになることがある。同じフレームで処理したいなら、UpdateBefore や UpdateAfter を使う。
ただし、最初から属性を増やしすぎると読みにくい。順序が必要な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 は移動方向を持つ。
EnemyMoveSystem は LocalTransform を書き換えて敵を移動させる。ApplyDamageSystem は DamageEvent を読み、対象Entityの Health を減らす。DeadEnemyDestroySystem はHPが0以下になったEntityをECBで破棄する。
ここで大事なのは、状態とイベントを分けていることだ。
Health は状態なのでComponentにする。DamageEvent は一回限りの通知なのでEntitiesEventsで流す。敵を消す操作は構造変更なのでECBに積む。
最初に覚える優先順位を絞るなら、IComponentData、ISystem、SystemAPI.Query、RefRO、RefRW、Authoring、内部 Baker、GetEntity、TransformUsageFlags、EntityCommandBuffer、RequireForUpdate。イベント通知まで扱うなら、そこに RegisterEvent、EventWriter、EventReader を足す。
逆に、最初から IJobChunk、BlobAsset、Custom Baking System、Netcode for Entities、Entities Graphicsの細かい描画設定まで触る必要はない。
ECSは難しいのではなく、入口が散らかっている。まずは「Componentはデータ」「Systemは処理」「BakerはGameObjectからEntityへの変換」「Eventは一回限りの通知」と分けて読むこと。この四つが分かれば、少なくとも最初の壁は越えられる。