今更ながらUnityのECSについて学習したので、ECSで簡単なゲームを作ってみました。
巷ではECSが一種の高速化手法のように使われているサンプルが多いので、
ECSはアーキテクチャなんだぞ、ということを示すためにGameObject
とComponent
禁止縛りで書くことにしました。
いわゆるPure ECSですが、今回はシーン内のGameObject
を全て削除するためCamera
も使用禁止です。
ついでにECSとセットで語られがちなC# Job Systemも見かけ上*1禁止しておきます。
当然Burstコンパイラも使いません。
ECSとその他のあれこれをごっちゃにしないためのサンプル……ということにでもしておきます (そこまで考えて作ってなかったけど)。
<19/04/17 更新>
Unity 2019.1とECS preview.30対応のコードに更新。補足へのリンクを追加。
<19/09/23 更新>
Unity 2019.2とECS 0.1.1対応のコードに更新。
ただし、記事内容との乖離が大きいため記事中のコードはそのままに、GitHub上のコードのみ更新。
インクリメンタルGCの登場に伴い、一時的メモリ確保を許容して簡潔な記述優先に。
ECSのまとめ記事へのリンクを追加。
<20/05/28 更新>
Unity 2019.3とECS 0.11.0対応のコードに更新。
GitHub上のコードのみの更新。
C# Job SystemとBurstコンパイラを解禁。
成果物
シンプルな球拾いゲームです。
Enter/Returnで開始して、左右キーでバーを操作します。
画像がカクカクなのはアニメーションGIFのFPSを抑えているからで、実行時にはもう少し滑らかに動きます。
リポジトリをクローンした後、Emptyシーンを開いて*2再生するとゲームが始まります。
実行時に"Display N No cameras rendering"と表示される場合には、 Gameビューのタブを右クリックして"Warn if No Cameras Rendering"のチェックを外すと消えます。
解説
ECS自体については解説しません。
そのうち解説記事を書くかもしれませんが、今のところは他所の記事を参照してください。
<19/09/23 追記>
ECSに関するまとめ記事を書きました。
構成
記事中で各ソースコードについて詳細な説明はしませんが、ここで構成についてだけざっとまとめておきます。 ソースコードを読む場合は参考にしてください。
ゲームを構成する各システムの概要はこんな感じです。
クラス名 | 説明 |
---|---|
BallGenerationSystem |
時間経過で球を生成 |
BallMotionSystem |
速度に応じて球を移動 |
CollisionSystem |
球の衝突判定と衝突時の処理 |
GameOverSystem |
ゲームオーバー時の処理 |
GameStartSystem |
ゲーム開始時の処理 |
InputSystem |
各入力への対応 |
RenderSystem |
球・バー・UIの描画 |
ResetSystem |
リセット時の処理 |
ScoreUISystem |
スコア表示の更新 |
TranslationSystem |
表示位置の更新 |
VisibilitySystem |
UI表示のON/OFF |
その他のファイルについてはこんな感じです。
ファイル名 | 説明 |
---|---|
Boot.cs | 起動処理 |
Components.cs | コンポーネント一覧 |
DynamicTextMeshBuilder.cs | 書き換え可能なテキストメッシュの生成 |
ECSRenderPipelineAsset.cs | SRPによる描画処理 |
Groups.cs | 更新順序の指定のためのグループ一覧 |
ResourceUtility.cs | マテリアルやメッシュの生成 |
ゲームの起動
ECSの起動にはWorld
を生成してそれをPlayerLoopに登録する必要がありますが、
今回はUnityがデフォルトで生成してくれるWorld
をそのまま使うことにしました。
このWorld
には定義したすべてのシステムが含まれます。
ただシステムだけあっても処理する対象がいなければ動かないため、
その辺りのセットアップをRuntimeInitializeOnLoadMethod
属性によって行います。
using Unity.Entities; using Unity.Mathematics; using UnityEngine; public static class Boot { [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] public static void OnLoad() { World world = World.Active; EntityManager manager = world.EntityManager; Font font = ResourceUtility.CreateAsciiFont(48); Entity game = manager.CreateEntity(typeof(Game)); manager.SetComponentData(game, new Game { State = GameState.Ready, TotalTime = 0f, ElapsedTime = 0f, Score = 0, }); Entity player = manager.CreateEntity(typeof(Player), typeof(Position), typeof(Size), typeof(Visual)); manager.SetComponentData(player, new Position { X = Line.Center, Y = 0f }); manager.SetComponentData(player, new Size { Height = 0.5f }); manager.SetSharedComponentData(player, new Visual { Mesh = ResourceUtility.CreateQuad(), Material = ResourceUtility.CreateMaterial(Color.white), Scale = new float2(1f, 0.25f), }); Entity ball = manager.CreateEntity(typeof(Prefab), typeof(Ball), typeof(Position), typeof(Velocity), typeof(Size), typeof(Visual)); manager.SetComponentData(ball, new Size { Height = 0.5f }); manager.SetSharedComponentData(ball, new Visual { Mesh = ResourceUtility.CreateCircle(), Material = ResourceUtility.CreateMaterial(Color.white), Scale = new float2(0.5f, 0.5f), }); Entity generator = manager.CreateEntity(typeof(BallGenerator)); manager.SetComponentData<BallGenerator>(generator, new BallGenerator { NextTime = 0f }); Entity readyUI = manager.CreateEntity(typeof(ReadyUI), typeof(UIPosition), typeof(Visual)); manager.SetComponentData(readyUI, new UIPosition { X = 0f, Y = 4f }); manager.SetSharedComponentData(readyUI, new Visual { Mesh = ResourceUtility.CreateTextMesh(font, "Ready?"), Material = font.material, Scale = new float2(1f, 1f), }); Entity gameoverUI = manager.CreateEntity(typeof(GameOverUI), typeof(UIPosition), typeof(Visual)); manager.SetComponentData(gameoverUI, new UIPosition { X = 0f, Y = 4f }); manager.SetSharedComponentData(gameoverUI, new Visual { Mesh = ResourceUtility.CreateTextMesh(font, "Game Over"), Material = font.material, Scale = new float2(1f, 1f), }); Entity scoreUI = manager.CreateEntity(typeof(ScoreUI), typeof(UIPosition), typeof(Visual), typeof(DynamicText)); manager.SetComponentData(scoreUI, new UIPosition { X = 0f, Y = 3f }); manager.SetSharedComponentData(scoreUI, new Visual { Mesh = ResourceUtility.CreateDynamicMesh(), Material = font.material, Scale = new float2(0.5f, 0.5f), }); manager.SetSharedComponentData(scoreUI, new DynamicText { Font = font }); } }
長いですがやっていることはシンプルで、 エンティティを作ってコンポーネントにデータを詰めているだけです。 本来はエディタ上でする作業かなと思います。
描画
Pure ECSの記事を見るとわりとMeshInstanceRenderer
で描画していたりするのですが、
これは内部でGraphics.DrawMesh
やGraphics.DrawMeshInstanced
を呼びだしているようで、
Camera
コンポーネントの存在を仮定しているためイマイチPureじゃありません。
最近パッケージが分かれたようなのですが、そのパッケージ名も"Hybrid Renderer"です。
そこで代わりにGraphics.DrawMeshNow
やGraphics.ExecuteCommandBuffer
で描画しようと思ったのですが、
描画先をスクリーンにする方法がわからず断念。
いっそ仕組みごと変えてしまおうとSRP(Scriptable Render Pipeline)で描くことにしました。
using Unity.Entities; using UnityEngine; using UnityEngine.Rendering; [CreateAssetMenu(menuName = nameof(ECSRenderPipeline))] public class ECSRenderPipelineAsset : RenderPipelineAsset { protected override RenderPipeline CreatePipeline() { return new ECSRenderPipeline(); } } public struct RenderRequest { public Mesh Mesh; public Material Material; public Vector2 Scale; public Vector2 Position; } class ECSRenderPipeline : RenderPipeline { private const float AspectRatio = 10f / 16f; private const float Width = 5f; private const float Height = Width / AspectRatio; private const float Baseline = 0.9f; private CommandBuffer commands = new CommandBuffer(); protected override void Render(ScriptableRenderContext context, Camera[] cameras) { CalculateProjection(out Matrix4x4 projection); CalculateViewportRect(out Rect viewport); commands.Clear(); commands.ClearRenderTarget(true, true, Color.black); commands.SetViewMatrix(Matrix4x4.identity); commands.SetProjectionMatrix(projection); commands.SetViewport(viewport); World world = World.Active; if (world != null) { RenderSystem system = world.GetExistingSystem<RenderSystem>(); foreach (RenderRequest request in system.RenderQueue) { Vector3 position = new Vector3(request.Position.x, request.Position.y, 0f); Vector3 scale = new Vector3(request.Scale.x, request.Scale.y, 1f); Matrix4x4 matrix = Matrix4x4.TRS(position, Quaternion.identity, scale); commands.DrawMesh(request.Mesh, matrix, request.Material); } } context.ExecuteCommandBuffer(commands); context.Submit(); } private void CalculateProjection(out Matrix4x4 projection) { float left = -Width / 2f; float right = Width / 2f; float bottom = -Height * (1f - Baseline); float top = Height * Baseline; projection = Matrix4x4.Ortho(left, right, bottom, top, 0f, 1f); } private void CalculateViewportRect(out Rect rect) { float height = Screen.height; float width = height * AspectRatio; float x = (Screen.width - width) / 2f; float y = 0; rect = new Rect(x, y, width, height); } }
APIの都合上Camera
の配列が渡されてきますが、
これをガン無視してCommandBuffer
のみで描画します。
特に汎用性を持たせる必要もないので各種設定はかなりてきとーです。
描画対象についてはRenderSystem
内でフィールドにリクエストを詰めて、
それを直接読みだして描いています。
ECS的にはリクエストをIComponentData
にするか、リクエストのキューをISharedComponentData
にしたほうがよかったのかもしれませんが、
ECS外部からこれらをどうにかするのが面倒だったので手抜きしました。
シングルトン
シングルトン*3については少し悩みました。
一応ComponentSystem
にGetSingleton<T>
というメソッドが用意されているのですが、
そのシグネチャは次のようになっています。
T GetSingleton<T>() where T : IComponentData;
これだとISharedComponentData
は取れませんし、
あるタグコンポーネントを付けたエンティティの別のコンポーネントも取れません。
仕方ないので現在はシングルトンなエンティティを記憶するためのコンポーネントを作って、 それを参照する方式をとっています。
Entity player = GetSingleton<Singleton>().Player; Position playerPosition = EntityManager.GetComponentData<Position>(player); Size playerSize = EntityManager.GetComponentData<Size>(player);
もう少し簡単に記述できる方法が欲しいところです。
<19/04/17 追記>
GetSingletonEntity<T>
が追加されたことにより、
タグコンポーネントによってシングルトンなエンティティを取得できるようになりました。
Entity player = GetSingletonEntity<Player>(); Position playerPosition = EntityManager.GetComponentData<Position>(player); Size playerSize = EntityManager.GetComponentData<Size>(player);
イベント
イベントは今回一番難しいなと感じた仕組みです。
C#にはイベントを扱う機構がありますが、これはコールバックによる方式でデータ化できないためECS向きではありません。 システムに対してシステムがコールバックを登録するくらいならいいかなとも考えましたが、 システム間に順序関係以外の依存関係を作るのも複雑化しそうで怖いのでやめました。
そこで少し調べてみたところ、 コミュニティではイベントデータをコンポーネントとして追加してそれを後続のシステムが処理するという仕組みを推奨しているみたいです。 今回もこの仕組みで組んでみました。
ただ問題なのは使い終わった後のイベントデータを破棄するためのシステムが必要となることと、 それを含む各システムの順序を正しく定義する必要があることです。
つまり、
- イベントデータを追加するシステム
- イベントデータに反応するシステム
- イベントデータを削除するシステム
をそれぞれ定義して、必ずこの順番に実行されるように設定してやる必要があります。 それ自体が特別難しいというわけではありませんが、 イベントは頻繁に使う仕組みですし、数が増えてくると管理が結構面倒なんじゃないかと思います。
別のECSライブラリでは、 先にシステムがReader登録をしておいて全Readerが読み終えたら自動的に削除するという仕組みをとっていました。 一人でも読まないとイベントデータが削除されないというリスクはありますが記述は楽そうな気がします。 Unityでも何かしらのサポートが加わるといいのですが。
コンポーネントの列挙
Inject
とComponentDataArray
は非推奨とのことだったので、コンポーネントの列挙にはchunk iterationを使いました。
using Unity.Collections; using Unity.Entities; [UpdateInGroup(typeof(UpdateSystemGroup))] public class BallMotionSystem : ComponentSystem { private EntityQuery query; protected override void OnCreate() { this.query = GetEntityQuery(new EntityQueryDesc { All = new[] { ComponentType.ReadOnly<Ball>(), ComponentType.ReadWrite<Position>(), ComponentType.ReadOnly<Velocity>() }, None = new[] { ComponentType.ReadWrite<Frozen>() }, }); } protected override void OnUpdate() { Game game = GetSingleton<Game>(); ArchetypeChunkComponentType<Position> positionType = GetArchetypeChunkComponentType<Position>(); ArchetypeChunkComponentType<Velocity> velocityType = GetArchetypeChunkComponentType<Velocity>(true); using (NativeArray<ArchetypeChunk> chunks = this.query.CreateArchetypeChunkArray(Allocator.TempJob)) { foreach (ArchetypeChunk chunk in chunks) { NativeArray<Position> positions = chunk.GetNativeArray(positionType); NativeArray<Velocity> velocities = chunk.GetNativeArray(velocityType); for (int i = 0; i < positions.Length; i++) { Position position = positions[i]; position.Y += velocities[i].Y * game.ElapsedTime; positions[i] = position; } } } } }
ただこれがめちゃくちゃ長い……。
どうもこれは低レベルAPIだからで、UnityとしてはForEach
を使ってねということみたいです。
using Unity.Entities; [UpdateInGroup(typeof(UpdateSystemGroup))] public class BallMotionForEachSystem : ComponentSystem { protected override void OnUpdate() { Game game = GetSingleton<Game>(); Entities .WithAll<Ball>() .WithNone<Frozen>() .ForEach((ref Position position, ref Velocity velocity) => { position.Y += velocity.Y * game.ElapsedTime; }); } }
すっきりしました。
ただ少し問題があって、実はラムダ式の部分でメモリアロケーションが発生しています。
C#は直接関数ポインタを扱うことができず、デリゲートを生成する必要があるためラムダ式にはメモリの確保が伴います。
一応キャプチャのない関数(this
も含む)の場合には最適化が働いてキャッシュされるのですが、
ForEach
の用途を考えるとそうなり得るケースはかなり限られると思います。
自力でキャッシュすることもできなくはありませんが、
ForEach
が入れ子になったりすると相当苦しいコードになってきます(上記の例ではGetSingleton
を使っているのでそう見えませんが本来は二重のForEach
です)。
この辺りはUnityがどう考えているのか気になるところです。 考えられるのは、
- システム当たり数個の一時的確保なら速度上問題ない
- IL2CPPを使うと最適化により消える
- 気付いていない
辺りでしょうか。
IL2CPPについては未検証ですが可能性はあるかな、と。
ECS周りのAPIにparams
を使った可変長引数が多いのもこの辺で消えるのかなと勝手に推測しています。
何にせよエディタ上で観測されると気になるのでどうにかならないかなというのが個人的な希望です。 コンテキストを引数として渡してそれをラムダ式で受け取れればとりあえずキャッシュは効きそうですが、 それはそれでややこしくなりそうな気も……。
<19/04/17 追記>
ForEach
に関する補足記事を書きました。
<19/09/23 追記>
インクリメンタルガベージコレクションの登場により、
一時的なメモリアロケーションがゲームに及ぼす影響はかなり小さくなると予想されます。
従って、今後はForEach
を積極的に用いるのがよいと思われます。
おわりに
小さなゲームとはいえECS特有の設計方法についていろいろと考えさせられるものがあって面白かったです。 Unity ECSを触った感想としてはこれ単体で書いていくのはまだまだ厳しいなといった感じですね。 しばらくは単なる高速化手法として使われていくんじゃないかと思います。
活動支援の件、まったく集まらずに困っているので検討してもらえると助かります。
執筆時のUnityのバージョン:2018.3.5f1
執筆時のECSのバージョン:0.0.12-preview.23
更新時のUnityのバージョン:2019.2.6f1
更新時のECSのバージョン:0.1.1