Unity ECSでEventを使いたかった話
先日、Unity ECSを勉強しました。基本的なことから学んでいって、System間でちょっとした通知を投げたくなりました。
GameObjectなら、C# eventでもMessagePipeでもR3でも、とりあえず何かしらの手段で通知できますが、Unity ECSでそれをやろうとすると、急に話が面倒になります。
「イベント用のEntityを作って、Componentを付けて、読んだら消す」みたいなことをすれば一応できるらしいです。
おいおい、毎回それを手で書くのはさすがにしんどい。
というわけで、Unity ECSでEventっぽいことをしたくなりました。
そして、ありがたいことに既に良いものがありました。
EntitiesEvents です。
ただ、自分の環境では Unity 6 と Entities 1.4 系で使いたかったので、フォークして調整しました。
作ったものはこちらです。
作ったもの
Unity ECS向けのSystem間メッセージングライブラリです。
イベント用のstructを定義して、EventWriter<T> で書き込み、EventReader<T> で読み取ります。
System A
↓
EventWriter<T> でイベントを書く
↓
System B / System C / System D
↓
EventReader<T> でイベントを読む
こういうやつです。
なぜEventが欲しいのか
ECSでは、基本的にデータはComponentとしてEntityに載せます。
なので「ダメージが発生した」という情報も、ECSの思想に素直に従うなら、イベント用のEntityを作って、そこに DamageEvent Componentを付ける、みたいな形になります。
public struct DamageEvent : IComponentData
{
public Entity Target;
public int Value;
}
そして受信側のSystemでは、それをQueryして処理します。
foreach (var damageEvent in SystemAPI.Query<RefRO<DamageEvent>>())
{
// ダメージイベントを処理する
}
まあこれでも動きますが、この形だとイベントEntityの生成、破棄、実行順、読み取りタイミング、複数Systemから読む場合の扱いなどを毎回考える必要があります。
面倒!!
EntityCommandBufferを使えば構造変更も扱えますが、それはそれでSystemの実行順や再生タイミングを考える必要があります。
ECSは速い。
それはそう。
だけども!というもどかしい状況。
EntitiesEvents
元になったEntitiesEventsでは、イベント型を登録しておくと、EventWriter<T> と EventReader<T> を使ってSystem間メッセージングができます。
まずイベント用のstructを定義します。
using EntitiesEvents;
[assembly: RegisterEvent(typeof(MyEvent))]
public struct MyEvent
{
public int Value;
}
送信側はこんな感じ。
using EntitiesEvents;
using Unity.Burst;
using Unity.Entities;
[BurstCompile]
public partial struct WriteEventSystem : ISystem
{
private EventWriter<MyEvent> _writer;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
_writer = state.GetEventWriter<MyEvent>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
_writer.Write(new MyEvent
{
Value = 1
});
}
}
受信側はこんな感じ。
using EntitiesEvents;
using Unity.Entities;
public partial struct ReadEventSystem : ISystem
{
private EventReader<MyEvent> _reader;
public void OnCreate(ref SystemState state)
{
_reader = state.GetEventReader<MyEvent>();
}
public void OnUpdate(ref SystemState state)
{
foreach (var eventData in _reader.Read())
{
// eventData を処理する
}
}
}
フォークした理由
元のEntitiesEventsは、Unity 2022.3 以上、Entities 1.0.0 以上を対象にしたライブラリでした。
それに対して、自分が使いたかったのはUnity 6とEntities 1.4系です。
SystemBaseで書かれていたのもBurstCompileできないしなあとか思ってました。
というわけで、フォークしました。
このフォークでは、対象をUnity 6以降に絞っています。
Unity 6.0 / 6000.0 以上
Entities 1.4.7 以上
Source Generatorを整理する
EntitiesEventsでは、RegisterEvent 属性を使ってイベント型を登録します。
[assembly: RegisterEvent(typeof(MyEvent))]
この登録情報を元に、Source Generatorが必要なコードを生成します。
ざっくり言うと、イベントごとのcleanup systemや、generic component registrationまわりのコードを生成しています。
このフォークでは、Source Generator側のソースを SourceGenerators/EntitiesEvents.Generator に置いています。
SourceGenerators/EntitiesEvents.Generator
↓
Assets/EntitiesEvents/Generator/EntitiesEventsGenerator.dll
Generatorを変更したら、ビルドしてPackage側のDLLへコピーします。
cleanup systemをunmanaged ISystemにする
フォーク版では、生成されるcleanup systemをunmanaged ISystem ベースにしています。
古い経路としての SystemBase は切り捨てる判断。
イベントの寿命
このライブラリで書き込んだイベントは、同じフレームと次のフレームで読めます。
つまり、受信Systemが送信Systemより先に実行された場合でも、次のフレームで読めるようになっています。
ありがたいですね。
ただし、同一フレーム内で必ず処理したいイベントはSystemの実行順を明示しましょう。
[UpdateAfter(typeof(WriteEventSystem))]
public partial struct ReadEventSystem : ISystem
{
}
Readerはキャッシュする
EventReader<T> は読み取り位置を持っています。
なので、毎フレーム作り直すと同じイベントを重複して読む可能性があります。
WriterとReaderは OnCreate で取得して、Systemのフィールドにキャッシュします。
public partial struct ReadEventSystem : ISystem
{
private EventReader<MyEvent> _reader;
public void OnCreate(ref SystemState state)
{
_reader = state.GetEventReader<MyEvent>();
}
public void OnUpdate(ref SystemState state)
{
foreach (var eventData in _reader.Read())
{
// 読む
}
}
}
Readのスナップショット
フォーク版では、EventReader<T>.Read() が呼ばれた時点の安定したスナップショットを返すようにしています。
つまり、Read() したあとに書き込まれたイベントが、今まさに回しているiteratorへ混ざらないようにしています。
foreach (var eventData in _reader.Read())
{
// このループ中に別のイベントが書かれても、
// 今回の読み取りには混ざらない
}
イベントを読んでいる最中に、新しいイベントが同じ読み取りへ混ざってしまったら処理順とかの問題発生しそうだなあという思想です。
並列書き込み
Jobからイベントを書き込みたい場合は、EventParallelWriter<T> を使います。
ただし、並列書き込み中のリサイズは競合が起きたりで危険なので、並列writerは内部バッファをリサイズしません。
using EntitiesEvents;
using Unity.Burst;
using Unity.Entities;
[BurstCompile]
public partial struct ParallelWriteEventSystem : ISystem
{
private EventParallelWriter<MyEvent> _writer;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.EnsureEventCapacity<MyEvent>(1024);
_writer = state.GetEventParallelWriter<MyEvent>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// Jobに _writer を渡して、Execute内で WriteNoResize を呼ぶ
}
}
容量確保には EnsureEventCapacity<T> と EnsureAdditionalEventCapacity<T> を使えます。
state.EnsureEventCapacity<MyEvent>(1024);
state.EnsureAdditionalEventCapacity<MyEvent>(128);
EnsureEventCapacity<T>(capacity) は絶対容量を確保します。
EnsureAdditionalEventCapacity<T>(additionalCapacity) は、現在フレームで既に書き込まれている数を基準に追加容量を確保します。
まとめ
Unity ECSでSystem間イベントを使いたかったので、AnnulusGamesさんのEntitiesEventsをフォークして、Unity 6 / Entities 1.4向けに調整しました。
Source Generatorまわりを整理し、cleanup systemをunmanaged ISystem ベースにし、容量確保APIや並列writer、Readのスナップショットまわりも扱いやすくしました。
ECSでゲームを作っていて、「このSystemから別のSystemへちょっと通知したいだけなのに面倒なんだが?」となっている人には、こういうEventの仕組みがあるとかなり楽になるんじゃないでしょうか。
知らんけど。