UnityのEntity Component System (ECS) 向けにイベント(メッセージング)機能を追加するライブラリ「Entities Events」を公開しました!
Unity ECS専用のライブラリという、なんともまあ…需要があるのかよくわからない雰囲気ですが、個人的に今回のEntites EventsはECSでゲームを作る際に必須となるレベルで強力なライブラリだと思ってます。
最近Unityが色々あった際に多くのゲームエンジンの話題がSNS上にあがり、その流れで私もUnity以外の色々なゲームエンジンを触って遊んでいたのですが、そのうち最も面白いなと思ったエンジンが「Bevy Engine」でした。
BevyはRustで実装されたゲームエンジンで、その特徴としてエンジンの基盤にECSを全面的に採用している点が挙げられます。このECSが非常に良くできていて最高なんですが、そのBevy ECSの機能であるEventsを使っているときに「これUnityのECSにも欲しいなぁ」と思ったわけなんですね。
ただ、Unity ECSにはこれに該当する機能は存在しません。じゃあなければ作ればいいじゃない、というわけでBevy ECSのEventを参考にイベントを実装したのがEntities Eventsになります。実際にBevyでも採用されていますし、よく使う機能であることは間違いないでしょう…!
System間メッセージング
ECSでゲームを作っていく際、Systemの数が増えていくと「System同士のやりとりをどう行うか」という問題にぶち当たります。Systemそのものは状態を保持できない(してはいけない)ため、他のSystemになんらかのデータを送ろうとすると
- 送信側でEntityを作成/取得し、Componentとしてデータを載せる
- 受信側でクエリからイベント用のComponentを取得してデータを読み取る
という手順を取らざるを得ず、なんとも冗長なコードが出来上がります。「ECSの思想的にそもそもイベントを使うべきではない」と言えばそれまでですが、実際ECSでゲームを作っていく上で、特定のイベントをサクッと処理する必要がある場面は結構多いでしょう。
「じゃあ適当にバッファに積んどけばいいじゃん」というとそう簡単ではなく、単純にバッファに積んでフレーム毎にクリアするだけでは、受信側のSystemが送信側よりも前に実行された場合にイベントを受け取ることができなくなってしまいます。実行順を全て明示的に指定しても良いですが、わざわざイベントの送信側を把握して実行順を設定するのは面倒です。
Entities Eventsを利用することで、これらの問題を一切気にすることなく自然な書き心地でイベントの送信/受信を行うことが可能になります。
using Unity.Burst;
using Unity.Entities;
using EntitiesEvents;
// イベントの送信/受信に利用する型
public struct MyEvent { }
// イベントの型はあらかじめRegisterEvent属性で登録しておく必要がある
[assembly: RegisterEvent(typeof(MyEvent))]
// 送信側のSystem
[BurstCompile]
public partial struct WriteEventSystem : ISystem
{
EventWriter<MyEvent> eventWriter;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
// GetEventWriterでEventWriterを取得する
eventWriter = state.GetEventWriter<MyEvent>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// eventWriter.Write()でイベントを発行
eventWriter.Write(new MyEvent());
}
}
// 受信側のSystem
[BurstCompile]
public partial struct ReadEventSystem : ISystem
{
EventReader<MyEvent> eventReader;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
// GetEventReaderでEventReaderを取得する
eventReader = state.GetEventReader<MyEvent>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// 未読のイベントをeventReader.Read()で読み取る
foreach (var eventData in eventReader.Read())
{
Debug.Log("received!");
}
}
}
EventWriter<T>でイベントを書き込み、EventReader<T>でイベントを読み取る、という流れが基本になります。イベントはブロードキャストされ、全てのSystemから受信が可能です。この手の仕組みを構築しようとするとバッファの管理が面倒になりますが、その辺は全てEntities Eventsがいい感じにやってくれるので、利用側は非常にシンプルで分かりやすく記述することができるようになります。
ただし、注意すべき点がいくつかあります。
// イベントの型はあらかじめRegisterEvent属性で登録しておく必要がある
[assembly: RegisterEvent(typeof(MyEvent))]
まずイベントに使う型はあらかじめ[RegisterEvent]で登録しておく必要があります。この属性をアセンブリに付加することで、付属するSourceGeneratorが必要な[RegisterComponentType]属性やSystemなどのコードを生成します。属性が必須というのは少々面倒ですが、このくらいであれば許容範囲でしょう。
[BurstCompile]
public partial struct ReadEventSystem : ISystem
{
// EventWriter/EventReaderは必ずSystem内にキャッシュすること
EventReader<MyEvent> eventReader;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
eventReader = state.GetEventReader<MyEvent>();
}
...
}
さらに、取得したEventWriter/EventReaderはSystem内にキャッシュする必要があります。これはパフォーマンス上の理由(バッファを保持するEntityを取得するためにEntityQueryを作成する)というのが一つと、さらにReaderの場合には内部に読み取ったイベントを記録する整数カウンタが存在するため、毎回新たに取得するとイベントを重複して読み取る可能性があるからです。
Unity ECSではComponentLookupやEntityQueryなどは全てキャッシュする(しないと警告が出る)ので、それと同じと思えば特に違和感はないでしょう。
また、Entities Eventsのイベントストレージは内部に2つのバッファを保持し、書き込み用のバッファはフレーム毎にスワップされます。送信済みのイベントは2フレームにまたがって保持されるため、もし受信側のSystemが送信側より先に実行されたとしても、次のフレームで読み取ることが可能です。(この辺もBevyのEventsと全く同じ)
このような仕組みになっているため、毎フレームOnUpdate内でReadによる処理を行わないと正しくイベントを読み取れない可能性があります。この仕様が気に入らない、という場合には次の項で説明するEvents<T>を使って独自のSystemを作成することができます。
Events<T>を利用する
Entities Eventsではイベントの情報を保持するために、内部でEvents<T>という独自のNativeContainerを使用します。こちらはEventWriter/EventReaderによるイベントの処理に特化したコレクションで、これを使うことで独自にイベント機能を作成できます。
// 新たなEventsを作成
var events = new Events<MyEvent>(32, Allocator.Temp);
// EventWriterを取得して書き込み行う
var eventWriter = events.GetWriter();
eventWriter.Write(new MyEvent());
// EventReaderを取得して読み取りを行う
var eventReader = events.GetReader();
foreach (var eventData in eventReader.Read())
{
Debug.Log("received!");
}
// Updateを呼び出してバッファのクリアとスワップを行う
events.Update();
// Disposeでコンテナを破棄し、メモリの解放を行う
events.Dispose();
他のNativeContainer同様、Eventsの作成にはcapacityとAllocatorを指定し、使用後にはDisposeでメモリの解放を行う必要があります。
書き込み/読み取りはEventWriter/EventReaderを介して行います。また、Updateを呼ぶことで古いイベントを削除します。通常の方法では自動的に毎フレームUpdateが呼ばれますが、独自にEventsを作成する際はどこかのタイミングでUpdateを呼び出す必要があります。
EventsはしっかりAtomicSafetyHandleで安全性が担保されているので、Dispose後にEventWriterで書き込んだりしようとしてもしっかりエラーを吐いてくれます。
var events = new Events<MyEvent>(32, Allocator.Temp);
var eventWriter = events.GetWriter();
events.Dispose();
// ObjectDisposedExceptionがスローされる
eventWriter.Write(new MyEvent());
また、これらの安全性チェックを持たないUnsafeEventsも存在します。
using EntitiesEvents.LowLevel.Unsafe;
// 安全性チェックを持たないため、上のようなことをやるとクラッシュするので注意
var events = new UnsafeEvents<MyEvent>(32, Allocator.Temp);
機能として用意はしてありますが、基本的にはSystem内でEventWriter/EventReaderを取得する方法で十分でしょう。Dispose呼んだりするのめんどくさいし。
まとめ
Unity ECS、基本的にはいい感じだし、何よりUnityでECSを書けるというのがとても良いんですが、現状機能としては本当に最低限しか用意されていないので、細かい部分で結構不便だったりするんですよね。
ECSという面で言えばBevyは本当によくできていて、Rustとの相性の良さもあってかなり書きやすい感じです。ただまあBevyはまだまだ1.0には遠いし、エディタも無いしで本格的に導入するのは時期尚早でしょう。あとRustも書いてて楽しいっちゃ楽しいんですが、C#のような分かりやすさはないので初心者にはちょっと厳しいかなという感じです。Rustむずかしい…
その点Unity ECSはすべてC#で書けるし、こちらも機能が揃ってないとはいえ、BurstとJobを犠牲にすればGameObjectを持ち込んでECSを書くことも可能なので、なんだかんだで割とやりたい放題できます。あとエディタの環境が充実しているのはやっぱり強い。EntityのHierarchyもSystemも全部エディタで確認できるので。
まあ足りない分はみんなでライブラリ作って補っていけばいいだろうということで、今回はECS向けに一本作ってみました。他にもいくつかアイデアはあるので、暇を見つけて作っていけたらなぁと。
というわけでEntities Events、ECSを書くうえで非常に便利なライブラリだと思っているので、是非触って試してみてください〜!