Unity3D
Unity

【Unity】ECS + JobSystemでライフゲームを実装してみた

先日勉強序にPureECSでライフゲームを実装してみたので内容をメモ。

今回実装したパターンとしては「Conway's Game of Life」と「Wave Game of Life」の2点実装しており、 前者の「Conway's Game of Life」をベースに説明の方に入っていければと思います。

プロジェクト一式についてはGitHubにアップしてあるので必要に応じてご覧ください。

  • mao-test-h/ECS-GameOfLife

  • ※以下、この記事に於ける注意点です。

    • この記事中ではECSについての基礎的な部分についての説明は致しません。(最後に参考リンクを載せてあるのでそちらを御覧下さい。)
    • ECSの実装についてはまだまだ手探りな所が多く、正しいかどうかは微妙なのであくまで実装の一例程度に留めて頂けると幸いです。。
    • ECS自体についてもまだまだPreviewな状態であり、将来的には実装が変わってくる可能性もあります。その点を踏まえて参考にして頂ければと思います。

実装/動作環境

  • Unity version
    • Unity2018.3.0b2
  • 依存パッケージ
    • com.unity.entities@0.0.12-preview.14
      • ※ECSはPreviewの為か、バージョンによっては破壊的な変更が入るのでバージョン間に置ける互換性が保証されておりません。
        • → 従ってこちらのプロジェクトを確認する際にはパッケージのバージョンを変更しない事をオススメします。

「Conway's Game of Life」とは

詳細な説明やルール等はWikipediaに譲るとして、簡単に説明するとイギリスの数学者であるコンウェイさんが考案した生命の誕生/進化/淘汰などのプロセスを簡易的なモデルで再現したシミュレーションゲームです。(ほぼWikipediaの冒頭を引用)

※少し分かり辛いかもしれませんが、以下の物は1920x1080で実行した物となります。

CGOL.gif

実装について

やっている事を大まかに纏めると以下の内容となります。

  1. MonoBehaviour側でライフゲーム用のWorldを生成/必要なComponentSystemを登録 + 結果としてマテリアルに渡す為のComputeBufferの確保(以降バッファと省略)
  2. ECS側(上記で立ち上げたWorld/ComponentSystem)にてセルの状態をチェック。 → 結果をポインタ経由でバッファに書き込む。
  3. MonoBehaviour.OnRenderImageで書き込まれたバッファを参照し結果をImageEffectとして描画。

MonoBehaviour側の実装について

以下のコードは初期化周りのコードです。
バッファの確保やWorldの生成/Entityの生成などを行っております。

実装要点としては幾つかあるので箇条書きで纏めます。

  • コメントの所にも記載しておりますが、こちらのプロジェクトでは予めScripting Define Symbolsに「UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP」を登録しておいてDefaultWorldを生成されないようにしております。
    • ※Default Worldについて簡単に説明すると標準で自動生成されるWorldであり、内部的には存在するComponentSystemBaseを全てくっつけて動作させているものとなります。
      • その性質上、何も考えずにComponentSystemを動かす分には自動で登録されて動かせるので分かりやすいかもしれませんが、場合によっては動作させる想定の無いComponentSystemも動いてしまうので、思わぬ副作用(ComoonentSystem側で行われているメモリ確保によって頻発するGCなど)が発生する可能性があります。
      • ※後は個人的にはWorldが持つ責務(どのComponentSystemを持って仕事するか)を明示的にしたいという理由もあるので、その意図もあって自前で生成しております。(多分人によって意見が別れてくる所かもしれない)
    • 後は@pCYSl5EDgoさんの記事でも解説されておりますが、HybridECSで実装される方は「UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP」でDefaultWorldの生成を阻止することにより、HybridECSで使用されるデータが認識されなくなると言った悪影響が出るとのことなので注意する必要があるかもしれません。。(ちなみに今回の実装としてはPureECSでの実装となるので影響の無いお話にはなります。)
  • こちらも同じくコメントの所にも記載しておりますが、World.Active.CreateManagerでComponentSystemを登録する際にはコンストラクタ経由で引数を渡すことが可能です。(内部的にはCreateManager → Activator.CreateInstanceと呼び出されている)
    • 今回の実装ではコンストラクタ経由で解像度及び書き込み先バッファを渡しております。
      • ※余談ですが、コンストラクタを経由させずにデータを渡す方法としてはstatic経由で渡すと言った方法もあります。但し設計の観点やBurstCompilerでstaticを参照できないと言った制約諸々がある為に実装としては考えどころではあるかもしれません。。

▼ GameOfLife.cs

// 解像度
[System.Serializable]
public struct Resolution
{
    public int Width;
    public int Height;
}

// Shaderに渡すデータ
public struct MaterialData
{
    public float State;
}

/// <summary>
/// MonoBehaviour.Start
/// </summary>
void Start()
{
    // MaterialのIDを拾っておく
    this._widthId = Shader.PropertyToID("_Width");
    this._heightId = Shader.PropertyToID("_Height");
    this._buffId = Shader.PropertyToID("_MaterialBuff");

    // 最大セル数
    this._maxCellsNum = this._resolution.Width * this._resolution.Height;
    // マテリアル及びバッファの生成
    this._materialInstance = new Material(this._material);
    this._writeMaterialbuffs = new ComputeBuffer(this._maxCellsNum, Marshal.SizeOf(typeof(MaterialData)));
    // ECS & JobSystem側で書き込むためのバッファを確保
    this._writeMaterialData = new NativeArray<MaterialData>(this._maxCellsNum, Allocator.Persistent);


    // ------------------------------
    // ECS関連の初期化

    // メモ:
    // ・DefaultWorldは生成時の負荷が高い上に使わなくても生かしておく事で余計な副作用(GCなど)が出る可能性がある。
    //  こちらは「UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP」を定義することで自動生成を止めることが可能。
    // ・PureECSで実装するならDefaultWorldを消しても特に問題はないが、HybridECSで実装される方は以下のForumの内容に注意。
    //  https://forum.unity.com/threads/disabling-automaticworldbootstrap-but-keeping-hybrid-injection-hooks.529675/

    // GOL専用のWorldを作成し必要なComponentSystemを登録していく
    World.Active = new World("GOL World");
    var entityManager = World.Active.CreateManager<EntityManager>();
    // ComponentSystemはCreateManager経由でコンストラクタを呼び出すことが可能。(CreateManager → Activator.CreateInstanceと呼び出されている)
    // その際に引数も渡すことが可能。
    if (this._isConwayGameOfLife)
    {
        World.Active.CreateManager(typeof(ConwayGOLSystem), this._writeMaterialData, this._resolution);
    }
    else
    {
        World.Active.CreateManager(typeof(WaveGOLSystem), this._writeMaterialData, this._resolution);
    }
    ScriptBehaviourUpdateOrder.UpdatePlayerLoop(World.Active);

    // セル(Entity)の生成
    var cellArcheyype = this._isConwayGameOfLife
        ? entityManager.CreateArchetype(ComponentType.Create<ConwayCellData>())
        : entityManager.CreateArchetype(ComponentType.Create<WaveCellData>());
    for (int i = 0; i < this._maxCellsNum; ++i)
    {
        var x = i % this._resolution.Width;
        var y = i / this._resolution.Width;
        var entity = entityManager.CreateEntity(cellArcheyype);

        if (this._isConwayGameOfLife)
        {
            // Conway's Game of Life
            byte ret = (byte)Random.Range(0, 2);
            entityManager.SetComponentData(entity, new ConwayCellData
            {
                NextState = ret,
                State = ret,
                Index = i,
            });
        }
        else
        {
            // Wave Game of Life
            int rand = Random.Range(0, 32);
            float nextState = (((float)x / (float)this._resolution.Width) + ((float)y / (float)this._resolution.Height) * rand);
            entityManager.SetComponentData(entity, new WaveCellData
            {
                NextState = nextState,
                State = nextState,
                LastState = 0f,
                Index = i,
            });
        }
    }
}

ECS側の実装について

ComponentDataについて

今回必要としているComponentDataは以下の構造体1つのみとなります。
内容としてはセルのインデックスや生死の状態などを保持するものとなります。

なお、一部の変数についてはbool型で持たせられるものではありますが、制約としてBlittable型で持たせる必要があるためにbyteと言った別の型で代用しております。

▼ GameOfLife.cs

// Conway's Game of Life Data
public struct ConwayCellData : IComponentData
{
    public int Index;
    public byte State;
    public byte NextState;
    public int LiveCount;
}

ComonentSystemについて

今回実装したライフゲームでは以下のComponentSystem1つで完結するようにしております。
システムを動かすためのComponentDataとしては上述の「ConwayCellData」1つのみとなるので、Entity生成時にこちらをArchetypeに登録しておくことでシステムに載せることが出来ます。

こちらについても実装要点としては幾つかあるので箇条書きで纏めます。

  • 近傍セルの調査について
    • Job内には要素1つ分の参照しか渡ってこないので、近傍セルを参照することが出来ません。
      • その為、今回の実装としては予め全Entityのセル情報をInjectで取得し、そのポインタをJob生成時に渡すことでポインタ経由で近傍ピクセルを調査するようにしております。
  • バッファへの書き込みについて
    • バッファの方はコンストラクタで受け取った配列のポインタ経由で書き込んでおります。
  • ちなみに以下のコード中で多用している「UnsafeUtility」と言う物は、UnityのAPI側で用意されているunsafeな操作(ポインタ操作など)を行う際のユーティリティクラスです。
    • その他にも「com.unity.collections@0.0.9-preview.*」と言うパッケージ内にも「UnsafeUtilityEx」と言うクラスが存在します。(こちらのパッケージ自体はentitiesパッケージを入れた際にセットでついてくる)
      • ※但しバージョンによって内容が色々と変動しているので注意。

▼ CalcCell.Conway.ECS.cs

// ComponentSystem
public unsafe sealed class ConwayGOLSystem : JobComponentSystem
{
    // 近傍セル調査用に全Entityのセルデータを取得する
    struct DataGroup
    {
        public readonly int Length;
        [ReadOnly] public ComponentDataArray<ConwayCellData> CellData;
    }
    [Inject] DataGroup _dataGroup;

    // 近傍調査用セルデータの格納先及びJob内で参照するためのポインタ
    NativeArray<ConwayCellData> _cells;
    void* _cellsPtr = null;

    // 書き込み先のバッファのポインタ
    void* _writeDataPrt = null;

    // 解像度
    Resolution _resolution;

    public ConwayGOLSystem(NativeArray<MaterialData> writeMaterialData, Resolution resolution)
    {
        // 前途の通りComponentSystemはCreateManager経由でコンストラクタを呼び出すことが可能。
        // (CreateManager → Activator.CreateInstanceと呼び出されている)
        // その際に引数も渡すことが可能なので、それ経由で解像度と書き込み先バッファのポインタを取得。
        this._writeDataPrt = NativeArrayUnsafeUtility.GetUnsafePtr(writeMaterialData);
        this._resolution = resolution;
    }

    protected override void OnCreateManager()
    {
        base.OnCreateManager();
    }

    protected override void OnDestroyManager()
    {
        if (this._cells.IsCreated) { this._cells.Dispose(); }
        this._cellsPtr = null;
        this._writeDataPrt = null;
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        // 近傍調査用のセルデータと書き込み先バッファのポインタを取得
        if (this._cellsPtr == null)
        {
            this._cells = new NativeArray<ConwayCellData>(this._dataGroup.CellData.Length, Allocator.Persistent);
            this._cellsPtr = NativeArrayUnsafeUtility.GetUnsafePtr(this._cells);
        }
        this._dataGroup.CellData.CopyTo(this._cells, 0);

        // Jobの実行
        var job = new CalcCellJob(this._cellsPtr, this._writeDataPrt, this._resolution);
        return job.Schedule(this, inputDeps);
    }

    [BurstCompile]
    unsafe struct CalcCellJob : IJobProcessComponentData<ConwayCellData>
    {
        int _width, _height;

        // 近傍調査用セルデータのポインタ
        [NativeDisableUnsafePtrRestriction] void* _cellsPrt;

        // 書き込み先バッファのポインタ
        [NativeDisableUnsafePtrRestriction] void* _writeDataPrt;

        public CalcCellJob(void* cellsPrt, void* writeDataPrt, Resolution resolution)
        {
            this._width = resolution.Width;
            this._height = resolution.Height;
            this._cellsPrt = cellsPrt;
            this._writeDataPrt = writeDataPrt;
        }

        public void Execute(ref ConwayCellData data)
        {
            int i = data.Index;

            int x = i % this._width;
            int y = i / this._width;

            // 自身のindexに対する8方のindexを取得
            int above = y - 1;
            int below = y + 1;
            int left = x - 1;
            int right = x + 1;

            if (above < 0) { above = this._height - 1; }
            if (below == this._height) { below = 0; }
            if (left < 0) { left = this._width - 1; }
            if (right == this._width) { right = 0; }

            // 近傍8方向を調査
            int liveCount = 0;
            if (UnsafeUtility.ReadArrayElement<ConwayCellData>(this._cellsPrt, above * this._width + left).State == 1) { ++liveCount; }
            if (UnsafeUtility.ReadArrayElement<ConwayCellData>(this._cellsPrt, y * this._width + left).State == 1) { ++liveCount; }
            if (UnsafeUtility.ReadArrayElement<ConwayCellData>(this._cellsPrt, below * this._width + left).State == 1) { ++liveCount; }
            if (UnsafeUtility.ReadArrayElement<ConwayCellData>(this._cellsPrt, below * this._width + x).State == 1) { ++liveCount; }
            if (UnsafeUtility.ReadArrayElement<ConwayCellData>(this._cellsPrt, below * this._width + right).State == 1) { ++liveCount; }
            if (UnsafeUtility.ReadArrayElement<ConwayCellData>(this._cellsPrt, y * this._width + right).State == 1) { ++liveCount; }
            if (UnsafeUtility.ReadArrayElement<ConwayCellData>(this._cellsPrt, above * this._width + right).State == 1) { ++liveCount; }
            if (UnsafeUtility.ReadArrayElement<ConwayCellData>(this._cellsPrt, above * this._width + x).State == 1) { ++liveCount; }
            data.LiveCount = liveCount;

            if (data.State == 1)
            {
                if ((data.LiveCount == 2) || (data.LiveCount == 3))
                {
                    data.NextState = 1;
                }
                else
                {
                    data.NextState = 0;
                }
            }
            else
            {
                if (data.LiveCount == 3)
                {
                    data.NextState = 1;
                }
                else
                {
                    data.NextState = 0;
                }
            }
            data.State = data.NextState;

            // 結果を書き込む
            UnsafeUtility.WriteArrayElement(this._writeDataPrt, i, new MaterialData { State = (data.State == 1) ? 255f : 0f });
        }
    }
}

Shader側の実装について

冒頭でも記載しておりますが、結果の描画についてはOnRenderImageからGraphics.Blitを呼び出してイメージエフェクトとして描画してます。
やっている事は各ピクセルの位置に対応するセルの結果を見て生死に応じた色を塗っている感じです。

※参考 : 「ComputeShaderで巨大ライフゲームを作る -> 描画処理

▼ GameOfLife.cs

/// <summary>
/// MonoBehaviour.OnRenderImage
/// </summary>
void OnRenderImage(RenderTexture src, RenderTexture dest)
{
    if (this._writeMaterialbuffs != null)
    {
        this._writeMaterialbuffs.SetData(WriteMaterialData);
        this._materialInstance.SetInt(this._widthId, Resolution.Width);
        this._materialInstance.SetInt(this._heightId, Resolution.Height);
        this._materialInstance.SetBuffer(this._buffId, this._writeMaterialbuffs);
        Graphics.Blit(src, dest, this._materialInstance);
    }
}

▼ LifeGame.shader

Shader "Custom/LifeGame"
{
    SubShader
    {
        Cull Off
        ZWrite Off
        ZTest Off
        Lighting Off

        Pass
        {
            CGPROGRAM
            #pragma vertex vert_img
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct Data { float state; };
            int _Width; int _Height;
            StructuredBuffer<Data> _MaterialBuff;

            fixed4 frag (v2f_img i) : SV_Target
            {
                int2 xy = int2(_Width, _Height) * i.uv;
                Data data = _MaterialBuff[xy.y * _Width  + xy.x];
                fixed pixel = data.state;
                return fixed4((pixel).xxx, 1);
            }
            ENDCG
        }
    }
}

おまけ「Wave Game of Life」について

ジェネラティブ・アート―Processingによる実践ガイド」と言う本を参考に実装したライフゲームの変動パターンになります。
隣接するセルの状態の平均値をみて状態を決定する事により液体の様な動きを表現しております。

WGOL.gif

SampleSceneシーンにある「Main Camera -> GameOfLife -> IsConwayGameOfLife」のチェックを外すことで実行可能です。
ComponentSystemとなるソースの方は「CalcCell.Wave.ECS.cs」をご確認下さい。

参考/関連サイト

GitHub