エフアンダーバー

個人開発の記録

【Unity】 ECSだけしかいない世界

今更ながらUnityのECSについて学習したので、ECSで簡単なゲームを作ってみました。

巷ではECSが一種の高速化手法のように使われているサンプルが多いので、 ECSはアーキテクチャなんだぞ、ということを示すためにGameObjectComponent禁止縛りで書くことにしました。 いわゆる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のまとめ記事へのリンクを追加。

成果物

シンプルな球拾いゲームです。

Enter/Returnで開始して、左右キーでバーを操作します。

ゲームのアニメーションGIF

画像がカクカクなのはアニメーションGIFのFPSを抑えているからで、実行時にはもう少し滑らかに動きます。

github.com

リポジトリをクローンした後、Emptyシーンを開いて*2再生するとゲームが始まります。

実行時に"Display N No cameras rendering"と表示される場合には、 Gameビューのタブを右クリックして"Warn if No Cameras Rendering"のチェックを外すと消えます。

解説

ECS自体については解説しません。

そのうち解説記事を書くかもしれませんが、今のところは他所の記事を参照してください。

<19/09/23 追記>

ECSに関するまとめ記事を書きました。

www.f-sp.com

構成

記事中で各ソースコードについて詳細な説明はしませんが、ここで構成についてだけざっとまとめておきます。 ソースコードを読む場合は参考にしてください。

ゲームを構成する各システムの概要はこんな感じです。

クラス名 説明
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.DrawMeshGraphics.DrawMeshInstancedを呼びだしているようで、 Cameraコンポーネントの存在を仮定しているためイマイチPureじゃありません。 最近パッケージが分かれたようなのですが、そのパッケージ名も"Hybrid Renderer"です。

そこで代わりにGraphics.DrawMeshNowGraphics.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については少し悩みました。

一応ComponentSystemGetSingleton<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向きではありません。 システムに対してシステムがコールバックを登録するくらいならいいかなとも考えましたが、 システム間に順序関係以外の依存関係を作るのも複雑化しそうで怖いのでやめました。

そこで少し調べてみたところ、 コミュニティではイベントデータをコンポーネントとして追加してそれを後続のシステムが処理するという仕組みを推奨しているみたいです。 今回もこの仕組みで組んでみました。

ただ問題なのは使い終わった後のイベントデータを破棄するためのシステムが必要となることと、 それを含む各システムの順序を正しく定義する必要があることです。

つまり、

  1. イベントデータを追加するシステム
  2. イベントデータに反応するシステム
  3. イベントデータを削除するシステム

をそれぞれ定義して、必ずこの順番に実行されるように設定してやる必要があります。 それ自体が特別難しいというわけではありませんが、 イベントは頻繁に使う仕組みですし、数が増えてくると管理が結構面倒なんじゃないかと思います。

別のECSライブラリでは、 先にシステムがReader登録をしておいて全Readerが読み終えたら自動的に削除するという仕組みをとっていました。 一人でも読まないとイベントデータが削除されないというリスクはありますが記述は楽そうな気がします。 Unityでも何かしらのサポートが加わるといいのですが。

コンポーネントの列挙

InjectComponentDataArrayは非推奨とのことだったので、コンポーネントの列挙には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を使ってねということみたいです。

tsubakit1.hateblo.jp

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です)。

neue.cc

この辺りはUnityがどう考えているのか気になるところです。 考えられるのは、

  1. システム当たり数個の一時的確保なら速度上問題ない
  2. IL2CPPを使うと最適化により消える
  3. 気付いていない

辺りでしょうか。 IL2CPPについては未検証ですが可能性はあるかな、と。 ECS周りのAPIにparamsを使った可変長引数が多いのもこの辺で消えるのかなと勝手に推測しています。

何にせよエディタ上で観測されると気になるのでどうにかならないかなというのが個人的な希望です。 コンテキストを引数として渡してそれをラムダ式で受け取れればとりあえずキャッシュは効きそうですが、 それはそれでややこしくなりそうな気も……。

<19/04/17 追記>

ForEachに関する補足記事を書きました。

www.f-sp.com

<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

*1:ECSの内部で使われているため完全に禁止するのは難しい

*2:開かなくても動きますが空のシーンでも動くよという意味で

*3:デザインパターンの話ではなく単純にひとつしか存在しないことをプログラマが知っている対象のこと