凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity で Boids シミュレーションを作成して Entity Component System(ECS)を学んでみた

本エントリは Unity Advent Calendar 2018 23 日目の記事になります。

qiita.com

はじめに

ECS(Entity Component System)の概要は読んだり、勉強会で話を聞いてはいたのですが、自分では書いたことがなかったので勉強してやってみることにしました。既に ECS の概要などは様々なサイトで紹介されています。そのまま解説を書いても焼き増しになってしまうので、別のアプローチでの解説を考えたいと思い、本エントリでは実際に既存の MonoBehaviour でいったんモノを作ってから、それを元に ECS 対応をする、という形式を取ることにしました。その題材としてシンプルな Boids シミュレーションを作ってみます。

Boids とはクレイグ・レイノルズさんによって 1986 年に考案された魚や鳥等の群体の動きを模倣する手法です。

www.red3d.com

分離(Separation, 近すぎる個体から離れたがる)、整列(Alignment, 周りと同じ方向・速度で進みたがる)、結合(Cohesion, 周りの個体の中心に行きたがる)という 3 つの挙動を作成し、それぞれの重みを適当にコントロールすることで群れの動きを模倣することが出来ます。

まずこれを慣れ親しんだ MonoBehaviour を使ったオブジェクト指向ベースで作成し、このパフォーマンスを改善するためにデータ指向設計な ECS を使った使ったコードに移行し、それを Job SystemBurst Compiler を利用して最適化していく、という流れで説明していきたいと思います。

ちなみに公式の ECS のサンプルにも Boids は含まれていますが、こちらは完成版で、ECS でどこまで出来るのかのデモになっています。本エントリの目的はサンプルのような出来る限り効率的な Boids シミュレーションを作成するというものではなく、最適化もある程度出来る程度に勘所を掴むまでを目標としていますので、Boids の実装もわかりやすい範囲で最小限にとどめます(別記事でこれらは扱いたいと思います)。それでは見ていきましょう。

デモ

f:id:hecomi:20181223194055g:plain

目次

環境

ECS はまだ preview の機能なので、今後のアップデート次第では本エントリのコードが動作しなくなる可能性があることにご注意ください。

ダウンロード

github.com

修正履歴 / 追記

頂いたコメントを元に加筆修正した項目や、気づいたミスを直した内容を以下の Gist にまとめています。修正履歴をご参照ください。

追記(2021/04/24)

ECS 周りは継続的に破壊的変更が行われている関係で、執筆当時よりも仕組みが大きく変化しています。そのため、最新のバージョンの Unity ではそのままのコードでは動かなくなっています。差分を kawai125 さんにご執筆いただいたので新しいバージョン(Unity 2020.3.4f)で動かす際は以下の記事を併せてご参照ください:

qiita.com

MonoBehaviour で作ってみる

まずは ECS は脇に置いて Boids シミュレーションを作ることだけ考えていきましょう。設計としてはオブジェク指向ベースな発想で行うことにします。「魚」という個体が自身で分離、整列、結合といった挙動を行う機能を持っていると考えて、それぞれの魚ゲームオブジェクトにこれらの動きやパラメタを備えた Boid という MonoBehaviour を継承したコンポーネントをアタッチし、この魚達をマネージャである Simulation クラスが生成する、という形にしてみます。パラメタ群はリアルタイムに全体の制御を変えられるよう ScriptableObjectParam というクラスをインスタンス化して利用する形にします。

サンプル

サンプルは Boids-MonoBehaviour シーンになります。

Param.cs

パラメタは次のようなものを用意しておきます。メニューからアセットも作成しておきましょう。

using UnityEngine;

[CreateAssetMenu(menuName = "Boid/Param")]
public class Param : ScriptableObject
{
    public float initSpeed = 2f;
    public float minSpeed = 2f;
    public float maxSpeed = 5f;
    public float neighborDistance = 1f;
    public float neighborFov = 90f;
    public float separationWeight = 5f;
    public float wallScale = 5f;
    public float wallDistance = 3f;
    public float wallWeight = 1f;
    public float alignmentWeight = 2f;
    public float cohesionWeight = 3f;
}

f:id:hecomi:20181223001111p:plain

Simulation.cs

次のような指定された個数だけ Prefab を生成・そこにアタッチされた Boid クラスを保持するクラスを用意します。シミュレーションの範囲も描画できるように OnDrawGizmo() も利用しておきます。

using UnityEngine;
using System.Collections.Generic;
using System.Collections.ObjectModel;

public class Simulation : MonoBehaviour
{
    [SerializeField]
    int boidCount = 100;

    [SerializeField]
    GameObject boidPrefab;

    [SerializeField]
    Param param;

    List<Boid> boids_ = new List<Boid>();
    public ReadOnlyCollection<Boid> boids
    {
        get { return boids_.AsReadOnly(); }
    }

    void AddBoid()
    {
        var go = Instantiate(boidPrefab, Random.insideUnitSphere, Random.rotation);
        go.transform.SetParent(transform);
        var boid = go.GetComponent<Boid>();
        boid.simulation = this;
        boid.param = param;
        boids_.Add(boid);
    }

    void RemoveBoid()
    {
        if (boids_.Count == 0) return;

        var lastIndex = boids_.Count - 1;
        var boid = boids_[lastIndex];
        Destroy(boid.gameObject);
        boids_.RemoveAt(lastIndex);
    }

    void Update()
    {
        while (boids_.Count < boidCount)
        {
            AddBoid();
        }
        while (boids_.Count > boidCount)
        {
            RemoveBoid();
        }
    }

    void OnDrawGizmos()
    {
        if (!param) return;
        Gizmos.color = Color.green;
        Gizmos.DrawWireCube(Vector3.zero, Vector3.one * param.wallScale);
    }
}

インスペクタから各フィールドにメッシュやマテリアルをセットしておきます。

f:id:hecomi:20181223001224p:plain

Boid.cs

では魚の動きのシミュレーションに取り掛かりましょう。次のようなクラスを用意します。

using UnityEngine;
using System.Collections.Generic;

public class Boid : MonoBehaviour
{
    public Simulation simulation { get; set; }
    public Param param { get; set; }
    public Vector3 pos { get; private set; }
    public Vector3 velocity { get; private set; }
    Vector3 accel = Vector3.zero;
    List<Boid> neighbors = new List<Boid>();

    void Start()
    {
        pos = transform.position;
        velocity = transform.forward * param.initSpeed;
    }

    void Update()
    {
        // 近隣の個体を探して neighbors リストを更新
        UpdateNeighbors();

        // 壁に当たりそうになったら向きを変える
        UpdateWalls();

        // 近隣の個体から離れる
        UpdateSeparation();

        // 近隣の個体と速度を合わせる
        UpdateAlignment();

        // 近隣の個体の中心に移動する
        UpdateCohesion();

        // 上記 4 つの結果更新された accel を velocity に反映して位置を動かす
        UpdateMove();
    }

    ...
}

Update() 中のコメントに記載したように、色々な更新処理があります。最初に近隣の個体を探しリストに入れて、それを考慮しながら accel(加速度 ≒ 力)を更新していきます。最後に UpdateMove() でこの accelvelocity に反映、pos を更新する処理を行う流れです。それぞれの処理をざっくり説明するので見ていきましょう。

UpdateMove()

まずは移動処理から書いてしまいましょう。 accel を使って速度・位置を求めて、transform へと反映を行います。最低速度と最高速度を決めておくのがキモで、それっぽく見えるようになります。

void UpdateMove()
{
    var dt = Time.deltaTime;

    velocity += accel * dt;
    var dir = velocity.normalized;
    var speed = velocity.magnitude;
    velocity = Mathf.Clamp(speed, param.minSpeed, param.maxSpeed) * dir;
    pos += velocity * dt;

    var rot = Quaternion.LookRotation(velocity);
    transform.SetPositionAndRotation(pos, rot);

    accel = Vector3.zero;
}

他のアップデート処理はコメントアウトしてまずは移動処理だけ実行してみましょう。accel が計算されてないので等速直線運動します。

f:id:hecomi:20181223183222g:plain

UpdateWalls()

範囲内に留めるために壁には近づけば近づくほど離れる方向(壁の内側方向)の力を受けることにして accel を更新します。wallScale の立方体の内側にいる想定で、各 6 面の壁から受ける力を計算しています。

f:id:hecomi:20181223175400p:plain

void UpdateWalls()
{
    if (!simulation) return;

    var scale = param.wallScale * 0.5f;
    accel +=
        CalcAccelAgainstWall(-scale - pos.x, Vector3.right) +
        CalcAccelAgainstWall(-scale - pos.y, Vector3.up) +
        CalcAccelAgainstWall(-scale - pos.z, Vector3.forward) +
        CalcAccelAgainstWall(+scale - pos.x, Vector3.left) +
        CalcAccelAgainstWall(+scale - pos.y, Vector3.down) +
        CalcAccelAgainstWall(+scale - pos.z, Vector3.back);
}

Vector3 CalcAccelAgainstWall(float distance, Vector3 dir)
{
    if (distance < param.wallDistance)
    {
        return dir * (param.wallWeight / Mathf.Abs(distance / param.wallDistance));
    }
    return Vector3.zero;
}

ここまでで次のような結果になります。壁で跳ね返っているのがわかりますね。

f:id:hecomi:20181223182621g:plain

UpdateNeighbors()

分離・整列・結合には近隣の個体の情報が必要です。Simulation クラスから Boids のリストを貰ってきて総当たりで、ある距離(neighborDistance)以内かつある角度(neighborFov)以内にいる個体を全部集めることにします。

f:id:hecomi:20181223175222p:plain

void UpdateNeighbors()
{
    neighbors.Clear();

    if (!simulation) return;

    var prodThresh = Mathf.Cos(param.neighborFov * Mathf.Deg2Rad);
    var distThresh = param.neighborDistance;

    foreach (var other in simulation.boids)
    {
        if (other == this) continue;

        var to = other.pos - pos;
        var dist = to.magnitude;
        if (dist < distThresh)
        {
            var dir = to.normalized;
            var fwd = velocity.normalized;
            var prod = Vector3.Dot(fwd, dir);
            if (prod > prodThresh)
            {
                neighbors.Add(other);
            }
        }
    }
}

これにより、本 Boids シミュレーションは O(n^2) オーダーになってしまいます。これを高速化するにはシミュレーション範囲をグリッドに区切って各個体がどのグリッドに所属しているかというハッシュマップのようなものを作成する方法が取られたりしますが、後の ECS 化が複雑になるので、本エントリではこのシンプルな総当たりをベースに考えることにします。

UpdateSeparation()

残りの Boids に必要な 3 つのコードはとても簡単です。まずは分離のコードですが、これは近隣の個体から離れる方向に力を加えます。かかる力は雑ですが簡単のために一定とします。

f:id:hecomi:20181223175418p:plain

void UpdateSeparation()
{
    if (neighbors.Count == 0) return;

    Vector3 force = Vector3.zero;
    foreach (var neighbor in neighbors)
    {
        force += (pos - neighbor.pos).normalized;
    }
    force /= neighbors.Count;

    accel += force * param.separationWeight;
}

分かりやすいように数を減らし、separationWeight を強めにすると次のように壁に当たる前にも別の個体に当たらないように向きを変えているのがわかります。

f:id:hecomi:20181223182729g:plain

UpdateAlignment()

整列は近隣の個体の速度平均を求め、それに近づくように accel にフィードバックをします。

f:id:hecomi:20181223175442p:plain

void UpdateAlignment()
{
    if (neighbors.Count == 0) return;

    var averageVelocity = Vector3.zero;
    foreach (var neighbor in neighbors)
    {
        averageVelocity += neighbor.velocity;
    }
    averageVelocity /= neighbors.Count;

    accel += (averageVelocity - velocity) * param.alignmentWeight;
}

実行すると向きが揃ってだいぶ群れっぽくなりました。

f:id:hecomi:20181223182834g:plain

UpdateCohesion()

最後の結合は近隣の個体の中心方向へ accel を増やすように更新します。

f:id:hecomi:20181223175458p:plain

void UpdateCohesion()
{
    if (neighbors.Count == 0) return;

    var averagePos = Vector3.zero;
    foreach (var neighbor in neighbors)
    {
        averagePos += neighbor.pos;
    }
    averagePos /= neighbors.Count;

    accel += (averagePos - pos) * param.cohesionWeight;
}

魚の群れのような挙動になりました。

f:id:hecomi:20181223183459g:plain

パフォーマンスの問題点

しかしながら、200 体、300 体と数を増やしていくとパフォーマンスが顕著に落ちていきます。この最大数をもっと増やしたい...、または Boids シミュレーションが全体のゲームに割くリソースを削減したい...となった場合に、この処理のパフォーマンスを上げることが必要になってきます。

パフォーマンスを上げる方法は大きく 2 つあります。1 つは UpdateNeighbors() の項で述べたようにアルゴリズムを改善する方法です。もう 1 つがアルゴリズムは(基本的には大きくは)変えずに計算そのものを効率化して高速化する方法です。この後者側を比較的簡単に行ってくれる仕組みが ECS と Job System、Burst Compiler の組み合わせになります。

ECS / Job System / Burst Compiler

大体テラシュールブログさんにて図付きで説明されているので熟読しましょう。CEDEC 2018 の安原さんの講演も図が豊富かつ実践的でとてもわかり易いです(後半はちょっと難しいですが)。ここではサラッとだけおさらいします。

ECS

tsubakit1.hateblo.jp

tsubakit1.hateblo.jp

www.slideshare.net

オブジェクト指向で言うところのゲームオブジェクトと MonoBehaviour のフィールドおよびメソッドを、それぞれエンティティ(Entity)、コンポーネントComponentData)、システム(ComponentSystem)というものに変換します。MonoBehaviour では様々なデータを一元管理している関係上、それらがメモリ上に並んでいる形になりメモリ配置的にキャッシュ効率が悪いです。一方、ECS ではエンティティはただの ID で、この ID に紐づく形でデータのみを保持するコンポーネントが複数あるのですが、これらコンポーネントは種類別に配列として連続してメモリ上に並んでいます。そしてシステムで必要なコンポーネントのデータ配列のみを読み込んで計算することでキャッシュ効率を高めています。また、Job System や Burst Compiler とも相性の良いコードになり、並列処理やコードの最適化も行いやすいです。

こういったメモリ配置の最適化によるキャッシュ効率の向上や、各エンティティに紐付いたコンポーネントアーキタイプにしたがって具体的にどのようにメモリ上に配置されるかといった話については、テラシュールブログさんに素晴らしい解説があるのでそちらをご参照ください。

tsubakit1.hateblo.jp

実行効率だけでなく設計面から見ても、コードの再利用がしやすい設計にもなりやすい特徴があったりします(もちろん場合によりけりですが)。順を追って説明するので必須ではないですが、登場人物一覧については公式ドキュメントをザッと見ておくと参考になると思います。

https://github.com/Unity-Technologies/EntityComponentSystemSamples/blob/master/Documentation/content/ecs_concepts.mdgithub.com

Job System

tsubakit1.hateblo.jp

Job System を使うと、マルチスレッド化に伴う諸々の煩雑なコード(ロック処理など)を意識せずに、マルチコアを安全に利用した並列処理を簡単に記述することができます。ECS との組み合わせでは ComponentSystem 部分を簡単に Job System 化することができます(具体的にどうやるかは記事の後半で説明します)。

Burst Compiler

Mono とは異なる LLVM ベースのコンパイラです。GC なし、値型のみ等といった大きな制約の下、Unity.Mathematics と組み合わせたコードを書くことで、SIMD の効いた高速なアセンブリを吐き出す事ができます(アセンブリは後述しますがエディタ上から見ることもできます)。

github.com

ECS と組み合わせた使い方はシステムの計算部に [BurstCompiler] アトリビュートをつけるだけで簡単です(こちらも詳しくは後述)。

これら 3 種の神器を決められたルールの下で使うことで、キャッシュ効率がよく最適化された速いコードをマルチスレッドで比較的かんたんに動かすことができるようになるわけです。

ECS のセットアップ

では MonoBehaviour ベースの Boids シミュレーションのコードを ECS にしていく前に、ECS のセットアップをしておきましょう。

ECS のインストール

まずプロジェクトの Edit > Project Settings > Player > Other Settings > Scripting Runtime Version.NET 3.5 Equivalent から .NET 4.x Equivalent に変更しておきましょう。次に Window > Package Manager を開くと、Entities という項目があるので、ここで Install を押下してプロジェクトで ECS を使えるようにしましょう。

f:id:hecomi:20181208153913p:plain

余談になりますが、Package Manager とは Unity の機能を分離し、本体のリリースサイクルと個別に各機能にアクセスできるようにすることで、修正版を簡単に取り入れたり、プレビュー版にアクセスできるようにするものです。将来的にはアセットストアや .unitypackage との統合や、GitHub で配布しているプラグインの更新みたいなものも計画されているようです。

blogs.unity3d.com

さて、Install が完了すると ECS がプロジェクトで使えるようになり、manifest.json に次のように 1 行追加されます。

{
  "dependencies": {
    "com.unity.ads": "2.0.8",
    "com.unity.analytics": "2.0.16",
    "com.unity.entities": "0.0.12-preview.21", // 追加
    "com.unity.package-manager-ui": "1.9.11",
    ...

執筆時点で最新の Unity では、このインストールしたパッケージのコードは Editor 上からファイル一覧として見れるようになっています。ファイルをダブルクリックすれば中のソースコードも見れるので、分からない挙動が合ったときに中を見れるというわけです。中身が見れるのはかなり強力なので、要所要所で ECS パッケージ中のコードにも言及していきます。

ECS 対応① - UpdateWalls() まで

一気に作ると大変なので、個体同士のインタラクションは置いておいて、まずは壁で反転するところまでを作ってみましょう。コードは以下になります。

メンバ変数をばらす

オブジェクト指向では各オブジェクトがフィールドやメソッドという形でメンバを持っていました。データ指向設計ではこれらをばらして扱います。具体的には、Boid クラスのメンバ変数はすべてコンポーネントComponentData)になり、メンバ関数はシステム(ComponentSystem)となります。そしてゲームオブジェクトがエンティティ(Entity)になります。

まずはメンバ変数をコンポーネントへとばらしていきます。バラす粒度なのですが、これは意味を持つ最小単位にばらしたほうがメモリ効率が良くなるので、今回のケースでは Boid という位置や速度を持つ大きいコンポーネントを作成するのではなく、PositionVelocityAcceleration といった単位で分解するのが良いと思います。言い換えると、各種システムが必要な情報をグルーピングしてくるときに本当に必要な最小の粒度で分解した方が良い、という形です。作る物によってはもっと大きくても問題ないかもしれません。今回は、後に位置と加速度しか使わない UpdateNeighbors() や速度と加速度しか見ない UpdateAlignment() がシステム化されることを念頭に置いて、こういう細かい粒度で分解します。

では具体的な記述を見てみましょう。

ComponentData.cs
using Unity.Entities;
using Unity.Mathematics;

public struct Velocity : IComponentData
{
    public float3 Value;
}

public struct Acceleration : IComponentData
{
    public float3 Value;
}

コンポーネントはすべて IComponentData という interface を継承した構造体になります。

IComponentData 自体は空実装なのですが、ECS の内部的に型情報が利用されています。また、メンバは UnityEngine.Vector3 ではなく、Unity.Mathematics.float3 を用います。このライブラリはシェーダライクなシンタックスで書ける型や関数を提供していて、Burst Compiler を利用することで SIMD を使った効率的なコードへと変換してくれます。基本的に ComponentData で使える型は、Blittable 型になります(Unity.Mathematics の型は問題なく、例えば float3 などは float を 3 つ持つ構造体で、Blittable 型です)。

エンティティを生成する

では、次にこのコンポーネントを持ったエンティティを作成してみましょう。次のコードは、エンティティを生成して先程作成したコンポーネントを関連付けるコードになります。

Bootstrap.cs
using UnityEngine;
using UnityEngine.Rendering;
using Unity.Entities;
using Unity.Transforms;
using Unity.Mathematics;
using Unity.Rendering;

public class Bootstrap : MonoBehaviour 
{
    public static Bootstrap Instance { get; private set; }
    public static Param Param { get { return Instance.param; } }

    [SerializeField] int boidCount = 100;
    [SerializeField] Vector3 boidScale = new Vector3(0.1f, 0.1f, 0.3f);
    [SerializeField] Param param;
    [SerializeField] Mesh mesh;
    [SerializeField] Material material;

    void Awake()
    {
        Instance = this;
    }

    void Start()
    {
        // Entity マネージャの取得
        var manager = World.Active.GetOrCreateManager<EntityManager>();

        // アーキタイプの作成
        var archetype = manager.CreateArchetype(
            typeof(Position),
            typeof(Rotation),
            typeof(Scale),
            typeof(Velocity),
            typeof(Acceleration),
            typeof(MeshInstanceRenderer));

        // 各エンティティで共通で使うレンダラの作成
        var renderer = new MeshInstanceRenderer {
            castShadows = ShadowCastingMode.On,
            receiveShadows = true,
            mesh = mesh,
            material = material
        };

        // ランダムの初期化(Unity.Mathematics の利用)
        var random = new Unity.Mathematics.Random(853);

        // カウント分、エンティティの生成とコンポーネントの初期化
        for (int i = 0; i < boidCount; ++i)
        {
            var entity = manager.CreateEntity(archetype);
            manager.SetComponentData(entity, new Position { Value = random.NextFloat3(1f) });
            manager.SetComponentData(entity, new Rotation { Value = quaternion.identity });
            manager.SetComponentData(entity, new Scale { Value = new float3(boidScale.x, boidScale.y, boidScale.z) });
            manager.SetComponentData(entity, new Velocity { Value = random.NextFloat3Direction() * param.initSpeed });
            manager.SetComponentData(entity, new Acceleration { Value = float3.zero });
            manager.SetSharedComponentData(entity, renderer);
        }
    }

    void OnDrawGizmos()
    {
        if (!param) return;
        Gizmos.color = Color.green;
        Gizmos.DrawWireCube(Vector3.zero, Vector3.one * param.wallScale);
    }
}

注目するのは Start() の中身です。次のような流れになっています。

  1. エンティティマネージャの取得
  2. アーキタイプの作成
  3. 共通のレンダラの作成
  4. ランダムの初期化
  5. エンティティの生成とコンポーネントの初期化

まずエンティティマネージャの取得から見ていきましょう。

// Entity マネージャの取得
var manager = World.Active.GetOrCreateManager<EntityManager>();

エンティティマネージャはその名の通りエンティティを管理するクラスです。エンティティ以外にも、一部後述しますが、アーキタイプEntityArchetype)や、共有のコンポーネントISharedComponentData)、コンポーネントグループ(ComponentGroup)を管理しています。

そんなマネージャを World から取得しています。World クラスはエンティティマネージャとシステム(ComponentSystem)たちを管理するものです。Unity ではデフォルトで 1 つのワールドを生成し、この中で(後述しますがなんと型情報から自動的に)各システムを登録、PlayerLoop API を利用して自動的にそれらのアップデートを行う、ということをします。デフォルトではこのような挙動なのですが、自分でワールドを増やすことも出来ますし、デフォルトのワールド生成をさせないようにすることもできます。

tsubakit1.hateblo.jp tsubakit1.hateblo.jp

World.Active にはこうして自動で生成されたワールドが入っています。このワールドを通じて GetOrCreateManager() でエンティティマネージャを作成します。

次にアーキタイプの作成です。

// アーキタイプの作成
var archetype = manager.CreateArchetype(
    typeof(Position),
    typeof(Rotation),
    typeof(Scale),
    typeof(Velocity),
    typeof(Acceleration),
    typeof(MeshInstanceRenderer));

アーキタイプコンポーネントの型の配列です。

抽象化されたレイヤの話をすると、アーキタイプをエンティティマネージャに渡してエンティティを作成すると、指定した型のコンポーネントがついたエンティティが生成される、という感じになります。メモリ的な観点からの話をすると、アーキタイプ毎にチャンクと呼ばれる領域が確保され、各コンポーネントがそれぞれ同じサイズの配列になる並びで格納されます。詳細は再びテラシュールブログさんをご参照ください。

ちなみに、VelocityAcceleration というコンポーネントは自前で作りましたが、いくつか Unity が Unity.Transforms に用意してくれているコンポーネントもあります。ここでは、PositionRotationScale を付与しています。また描画に必要な MeshInstanceRenderer を追加していますがこちらは後述します。

ちょっと飛ばしてエンティティ生成とコンポーネントの初期化部分を見てみましょう。

// カウント分、エンティティの生成とコンポーネントの初期化
for (int i = 0; i < boidCount; ++i)
{
    var entity = manager.CreateEntity(archetype);
    manager.SetComponentData(entity, new Position { Value = ... });
    manager.SetComponentData(entity, new Rotation { Value = ...});
    ...
}

エンティティマネージャにアーキタイプを渡してエンティティを生成すると、エンティティがコンポーネントを伴って生成されます。オブジェクト指向と異なるのは、コンポーネントデータの初期化(SeComponentData())はマネージャを通じて行っている点です。これは、エンティティは単なる ID でしかなく、第 2 引数で指定された型の配列からエンティティに対応するインデックスの場所を塗り替えている、というイメージです。

初期化値は適当なランダムなものを入れたいのですが、Unity.Mathematics の値を提供するために、Unity.Mathematics.Random を使って提供します。これを使うにはインスタンスを適当なシード値で作成する必要があるため、for ループに入る前に次のようにしています。

// ランダムの初期化(Unity.Mathematics の利用)
var random = new Unity.Mathematics.Random(853);

float3 randomValues = random.NextFloat3(1f);
float3 randomDir = random.NextFloat3Direction();

ランダム値生成のメソッド名は UnityEngine.Random とは異なるので、適宜補完を見たりドキュメントを見て適切なものを使用してください。

説明が後回しになりましたが、最後に描画のコンポーネントについてです。作成したエンティティを指定したメッシュおよびマテリアルで描画するためのコンポーネントは予め用意されており、それが MeshInstanceRenderer です。

// 各エンティティで共通で使うレンダラの作成
var renderer = new MeshInstanceRenderer {
    castShadows = ShadowCastingMode.On,
    receiveShadows = true,
    mesh = mesh,
    material = material
};

このコンポーネントは他のコンポーネントと異なり、中に参照型(meshmaterial)も含んでいます。また、これらの値は各エンティティで共通して使われるので、それぞれのエンティティ毎に確保していては無駄になってしまいます。そこで、IComponentData ではなく、ISharedComponentData という仕組みが用意されていて、エンティティ単位ではゼロコストになるようにコンポーネント保有することが出来ます。

そのため、MeshInstanceRendererSetComponentData() ではなく、SetSharedComponentData() でセットしています。

for (int i = 0; i < boidCount; ++i)
{
    ...
    manager.SetSharedComponentData(entity, renderer);
}

このコンポーネントが付与されたエンティティは自動的に MeshInstanceRendererSystem というシステムによってハンドルされるようになり、PositionRotation、および Scale を見て指定したメッシュとマテリアルで描画されるようになります。ちなみに、この MeshInstanceRendererシリアライズ可能なオブジェクトになっているため、インスペクタに表示することも出来ます。

[SerializeField]
MeshInstanceRenderer renderer;

...
for (int i = 0; i < boidCount; ++i)
{
    ...
    manager.SetSharedComponentData(entity, renderer);
}

f:id:hecomi:20181231135559p:plain

ではシステムの話が出てきたので、実際に自分でシステムを作るところを見ていきましょう。

移動するシステムを作る

与えた AccelerationVelocity に反映させ、Position を更新するシステムを見てみましょう。

public class MoveSystem : ComponentSystem
{
    struct Data
    {
        public readonly int Length;
        public ComponentDataArray<Position> positions;
        [WriteOnly] public ComponentDataArray<Rotation> rotations;
        public ComponentDataArray<Velocity> velocities;
        public ComponentDataArray<Acceleration> accelerations;
    }

    [Inject] Data data;

    protected override void OnUpdate()
    {
        var dt = Time.deltaTime;
        var minSpeed = Bootstrap.Param.minSpeed;
        var maxSpeed = Bootstrap.Param.maxSpeed;

        for (int i = 0; i < data.Length; ++i)
        {
            var velocity = data.velocities[i].Value;
            var pos = data.positions[i].Value;
            var accel = data.accelerations[i].Value;

            velocity += accel * dt;
            var dir = math.normalize(velocity);
            var speed = math.length(velocity);
            velocity = math.clamp(speed, minSpeed, maxSpeed) * dir;
            pos += velocity * dt;

            var rot = quaternion.LookRotationSafe(dir, new float3(0, 1, 0));

            data.velocities[i] = new Velocity { Value = velocity };
            data.positions[i] = new Position { Value = pos };
            data.rotations[i] = new Rotation { Value = rot };
            data.accelerations[i] = new Acceleration { Value = float3.zero };
        }
    }
}

Data という型を作成し [Inject] アトリビュートをつけるというコードがあります。はじめは抽象化されていてムムッとなってしまうところですが、結構すごい仕組みになっていて、[Inject] をつけた Data の中に記述された型を全て持つエンティティを自動で探してきて、OnUpdate() 実行前に指定されたメンバ変数に注入してくれます。具体的には、ここでは PositionRotationVelocity、および Acceleration を全て持つエンティティ群をフィルタリングして引っ張ってきます。メモリの配置が異なるいろんなアーキタイプによるチャンクがあったとしても、それをよしなにマージして持ってきてくれます。このよしなにマージして配列に見せてくれる容れ物が ComponentDataArray です。入っている数は Length で取得できます。

ちなみに Datadata といった型、名前部分はどんな名前でも構いません。内部的には ComponentGroup という仕組みを利用しているので、Group と名付ける方が適切かもしれません。

また、ComponentDataArray[ReadOnly][WriteOnly] といったアトリビュートをつけることで、誤って代入していたり読み込んでいたりするミスを防ぐことができます。これはユーザ側のミス検出に役に立つだけでなく、後にマルチスレッド化したときにシステムが同時にこれらのコンポーネントを読み書きできるのか、といったことにも役立つので、極力指定するようにしておきましょう。

あとはこの ComponentDataArray を単にただの配列として解釈し、OnUpdate() の中に MonoBehaviour で書いたときの UpdateMove() を移植してくれば、全てのエンティティが移動してくれるようになります。

インジェクション

読み飛ばしても良い内容ですが、私も含め「よしなにやってくれる」という所でグヌヌッとなってしまう人も多いと思うので、この [Inject] の仕組みをちょっとだけ追ってみようと思います。システムの基底クラスである ComponentSystem では次のような処理が行われています。

protected override void OnBeforeCreateManagerInternal(World world)
{
    ...
    // [Inject] のついたフィールドをかき集めてくる
    ComponentSystemInjection.Inject(
        this, 
        world, 
        m_EntityManager, 
        out m_InjectedComponentGroups, 
        out m_InjectFromEntityData);
    ...
    // OnCreateManager() の前にデータのインジェクションを行う
    UpdateInjectedComponentGroups();
}

internal sealed override void InternalUpdate()
{
    ...
    // OnUpdate() の前にデータのインジェクションを行う
    BeforeOnUpdate();
    ...
    OnUpdate();
    ...
}

unsafe void BeforeOnUpdate()
{
    ...
    UpdateInjectedComponentGroups();
    ...
}

protected void UpdateInjectedComponentGroups()
{
    ...
    ulong gchandle;
    var pinnedSystemPtr = (byte*)UnsafeUtility.PinGCObjectAndGetAddress(this, out gchandle);
    ...
    foreach (var group in m_InjectedComponentGroups)
    {
        group.UpdateInjection(pinnedSystemPtr);
    }
    ...
}

ComponentSystemInjection.Inject() では m_InjectedComponentGroups[Inject] がついたフィールドをかき集めてくる処理が行われます(後述)。UpdateInjectedComponentGroups() でユーザがオーバーライドできるシステムの初期化メソッドの OnCreateManager() や更新処理の OnUpdate() の直前でインジェクションが走るようになっています。

かき集める処理は Unity.Entities/Injection/ComponentSystemInjection.cs に記述されています。

public static void Inject(
    ComponentSystemBase componentSystem, 
    World world, 
    EntityManager entityManager,
    out InjectComponentGroupData[] outInjectGroups, 
    out InjectFromEntityData outInjectFromEntityData)
{
    ...
    InjectFields(componentSystem, world, entityManager, out outInjectGroups, out outInjectFromEntityData);
}

static void InjectFields(
    ComponentSystemBase componentSystem, 
    World world, 
    EntityManager entityManager,
    out InjectComponentGroupData[] outInjectGroups, 
    out InjectFromEntityData outInjectFromEntityData)
{
    // 与えられたクラスの型からフィールドを全部集めてくる
    var componentSystemType = componentSystem.GetType();
    var fields = componentSystemType.GetFields(
        BindingFlags.Instance | 
        BindingFlags.NonPublic | 
        BindingFlags.Public);
    ...
    foreach (var field in fields)
    {
     // 全フィールドのうち [Inject] がついたもののみ取り出す
        var attr = field.GetCustomAttributes(typeof(InjectAttribute), true);
        if (attr.Length == 0) continue;

        // [Inject] がつけられた型に応じたインジェクション処理
    }
    ...
}

コードを見るとフィールドを全部かき集めてきて、foreach 文の中で [Inject] がついたフィールドだけを抽出、そしてその型に応じて色々なインジェクションの追加処理が走るようになっています。実際にインジェクションを行う UpdateInjection()Unity.Entities/Injection/InjectComponentGroup.cs に記述されており、ポインタを直接ゴニョゴニョする操作が記述されています。こういった直接型から色々な情報を取り出してキャッシュしておく事前準備、およびその情報を使った直前の注入といった経路を経て、我々は各システムでオーバーライドした OnUpdate() の中で値が注入されたコンポーネントの配列を扱える仕組みになっています。

ただし、こういったユーザ側でのシンプルな記述を実現する方法については議論中のようで、今後も変更される可能性があるようです。

壁で跳ね返るシステムを作る

では話を戻して UpdateWalls() も作ってしまいましょう。

public class WallSystem : ComponentSystem
{
    struct Data
    {
        public readonly int Length;
        [ReadOnly] public ComponentDataArray<Position> positions;
        public ComponentDataArray<Acceleration> accelerations;
    }

    [Inject] Data data;

    protected override void OnUpdate()
    {
        var param = Bootstrap.Param;
        var scale = param.wallScale * 0.5f;
        var thresh = param.wallDistance;
        var weight = param.wallWeight;

        var r = new float3(+1, 0, 0);
        var u = new float3(0, +1, 0);
        var f = new float3(0, 0, +1);
        var l = new float3(-1, 0, 0);
        var d = new float3(0, -1, 0);
        var b = new float3(0, 0, -1);

        for (int i = 0; i < data.Length; ++i)
        {
            float3 pos = data.positions[i].Value;
            float3 accel = data.accelerations[i].Value;
            accel +=
                GetAccelAgainstWall(-scale - pos.x, r, thresh, weight) +
                GetAccelAgainstWall(-scale - pos.y, u, thresh, weight) +
                GetAccelAgainstWall(-scale - pos.z, f, thresh, weight) +
                GetAccelAgainstWall(+scale - pos.x, l, thresh, weight) +
                GetAccelAgainstWall(+scale - pos.y, d, thresh, weight) +
                GetAccelAgainstWall(+scale - pos.z, b, thresh, weight);
            data.accelerations[i] = new Acceleration { Value = accel };
        }
    }

    float3 GetAccelAgainstWall(float dist, float3 dir, float thresh, float weight)
    {
        if (dist < thresh)
        {
            return dir * (weight / math.abs(dist / thresh));
        }
        return float3.zero;
    }
}

今度は Position が壁までの距離を測るのだけに使われるので [ReadOnly] がついていますね。コードの中身はほとんど MonoBehaviour のものと同じで、苦労せずに移植できると思います。

システムの実行順

さて、2 つシステムが出来たのですが、実行順としては UpdateWalls() で加速度を更新してから UpdateMove() で処理して欲しくなります。この場合はシステムにアトリビュートをつけて制御することが出来ます。

public class WallSystem : ComponentSystem
{
    ...
}

[UpdateAfter(typeof(WallSystem))]
public class MoveSystem : ComponentSystem
{
    ...
}

今のところはこれで OK ですが、次の章ではシステムも増えてくるので、そのタイミングでより複雑な順序制御を解説します。

システムの登録

ではこれでシステムを作成したので、システムを管理しているワールドにこれらを登録して実行してみよう、となると思います。しかしながら実は現在利用しているデフォルトのワールドでは、システムは自動で登録されます。なのでもう実行すれば動きます。知らないとちょっと気持ち悪く感じる挙動ですが、デフォルトのワールドではプロトタイピングを早くするためか、このような仕組みが用意されています。

登録は、Unity.Entities.HybridInjection/DefaultWorldInitialization.cs で次のように行われています。

var world = new World(worldName);
World.Active = world;

// 全ての型を見る
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
    // ComponentSystemBase を継承し、抽象クラスでなくジェネリックパラメタを含まず、
    // DisableAutoCreation アトリビュートがついていない型を探す
    var systemTypes = allTypes.Where(t =>
        t.IsSubclassOf(typeof(ComponentSystemBase)) &&
        !t.IsAbstract &&
        !t.ContainsGenericParameters &&
        t.GetCustomAttributes(typeof(DisableAutoCreationAttribute), true).Length == 0);

    foreach (var type in systemTypes)
    {
        world.GetOrCreateManager(type);
    }
}

ScriptBehaviourUpdateOrder.UpdatePlayerLoop(world);

GetAssemblies() で全ての型をかき集めてきて、ComponentSystemBase を継承したクラスを探しだし、それをワールドに登録しています。条件文にもあるように [DisableAutoCreation] というアトリビュートを付与すると、この自動追加の対象外になります。

ちなみに、ComponentSystemEntityManager と同じく ScriptBehaviourManager を継承しているので、GetOrCreateManager() で追加しています(System やら Manager やら変わってややこしい)。

この自動で行われるデフォルトワールドにおけるシステム追加処理は、AutomaticWorldBootstrap.cs で次のように実行されます。

#if !UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP
static class AutomaticWorldBootstrap
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void Initialize()
    {
        DefaultWorldInitialization.Initialize("Default World", false);
    }
}
#endif

UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP というキーワードが定義されてなければ自動でこの登録処理が実行されるようになっています。このキーワードは Player SettingsScripting Define Symbols に記述しておくと定義されるようになります。

f:id:hecomi:20181223142856p:plain

自分でワールドを作った場合はシステムの登録は手動で行わければなりません。デフォルトだとこのように全ての型を総ざらいして問答無用に登録するので、システムが複雑になってきた場合やアセットとして配布する場合は、個別のワールドを作成して自前で管理するほうが素性が良いと思われます。

tsubakit1.hateblo.jp

結果

では実行してみましょう。次のように壁で跳ね返るブロックたちが見えれば成功です。

ECS 対応② - 分離・整列・結合まで

では ECS 対応の後半です。お互いに避けたり集まったりするようにしていきましょう。Boids シミュレーションを行うには、近隣の個体の位置や速度にアクセスしなければなりません。これを行うには①で見てきた内容に加え、DynamicBuffer という仕組みを利用します。サンプル 2 が本内容になります。

ご近所さんを覚えておく

まず、UpdateNeighbors() に対応する仕組みを作成します。MonoBehaviour のときは List<Boid> を作成し、ここに近隣の個体をリストで格納していました。ECS では可変長配列の仕組みとして、IBufferElementData という型を提供しています。

詳細は再びテラシュールブログさんに書いてあります。

tsubakit1.hateblo.jp

次のような型を定義します。

// ComponentData.cs
[InternalBufferCapacity(8)]
public struct NeighborsEntityBuffer : IBufferElementData
{
    public Entity Value;
}

InternalBufferCapacity アトリビュートに指定する数をオーバーするとヒープに行ってしまう(= 遅くなる)ので多くしたいですが、多くしすぎるとチャンクを圧迫して格納できるエンティティの数が少なくなる、という問題があるようなので適当な値を選びましょう。

この型はアーキタイプに含めることが出来ます。次のようにアーキタイプに追加してエンティティを生成するようにします。

// Bootstrap.cs
var archetype = manager.CreateArchetype(
    typeof(Position),
    typeof(Rotation),
    typeof(Scale),
    typeof(Velocity),
    typeof(Acceleration),
    typeof(NeighborsEntityBuffer)); // 追加
    ...

こうするとインジェクションでそれぞれのエンティティに紐付いたこの配列をとってこれるようになります。

それでは近隣の個体を見つけてこの配列に格納するコードを見てみましょう。

public class NeighborDetectionSystem : ComponentSystem
{
    struct Data
    {
        public readonly int Length;
        [ReadOnly] public ComponentDataArray<Position> positions;
        [ReadOnly] public EntityArray entities;
        public ComponentDataArray<Velocity> velocities;
        [WriteOnly] public BufferArray<NeighborsEntityBuffer> neighbors;
    }

    [Inject] Data data;

    protected override void OnUpdate()
    {
        var param = Bootstrap.Param;
        float prodThresh = math.cos(math.radians(param.neighborFov));
        float distThresh = param.neighborDistance;

        for (int i = 0; i < data.Length; ++i)
        {
            data.neighbors[i].Clear();

            float3 pos0 = data.positions[i].Value;
            float3 fwd0 = math.normalize(data.velocities[i].Value);

            for (int j = 0; j < data.Length; ++j)
            {
                if (i == j) continue;

                float3 pos1 = data.positions[j].Value;
                var to = pos1 - pos0;
                var dist = math.length(to);

                if (dist < distThresh)
                {
                    var dir = math.normalize(to);
                    var prod = Vector3.Dot(dir, fwd0);
                    if (prod > prodThresh)
                    {
                        data.neighbors[i].Add(new NeighborsEntityBuffer { Value = data.entities[j] });
                    }
                }
            }
        }
    }
}

ちょっと長いですが、近隣の個体の探索のコード自体は MonoBehaviour のときと同じです。注目するのは、Data のメンバです。

struct Data
{
    public readonly int Length;
    [ReadOnly] public ComponentDataArray<Position> positions;
    [ReadOnly] public EntityArray entities;
    public ComponentDataArray<Velocity> velocities;
    [WriteOnly] public BufferArray<NeighborsEntityBuffer> neighbors;
}

ComponentDataArray だけでなく、EntityArray というエンティティ(ID)の配列をインジェクションして引っ張ってこれる型が用意されています。2 重ループで近くにいる個体を見つけたら、そのエンティティを用意した NeighborsEntityBuffer に追加する、という処理が行われています。

また、この NeighborsEntityBuffer は、BufferArray<> で包んであります。IComponentData の場合は ComponentDataArrray でしたが、IBufferElementData はこれでエンティティごとのデータを取得します。インジェクション処理も ComoponentData とは別の方法でよしなに走って所望の値がやってくるようになります。

分離を実装する

これで近隣の個体が誰か分かったので、早速この配列を使って分離のコードを実装してみましょう。

public class SeparationSystem : ComponentSystem
{
    struct Data
    {
        public readonly int Length;
        [ReadOnly] public ComponentDataArray<Position> positions;
        public ComponentDataArray<Acceleration> accelerations;
        [ReadOnly] public BufferArray<NeighborsEntityBuffer> neighbors;
    }

    [Inject] Data data;

    protected override void OnUpdate()
    {
        var param = Bootstrap.Param;

        for (int i = 0; i < data.Length; ++i)
        {
            var neighbors = data.neighbors[i].Reinterpret<Entity>();
            if (neighbors.Length == 0) continue;

            var force = float3.zero;
            var pos0 = data.positions[i].Value;
            var accel = data.accelerations[i].Value;

            for (int j = 0; j < neighbors.Length; ++j)
            {
                var pos1 = EntityManager.GetComponentData<Position>(neighbors[j]).Value;
                force += math.normalize(pos0 - pos1);
            }

            force /= neighbors.Length;
            var dAccel = force * param.separationWeight;
            data.accelerations[i] = new Acceleration { Value = accel + dAccel };
        }
    }
}

まず、フィルタリングを行う Data の中の要素ですが、先程のBufferArray<> を今度は [ReadOnly] で持ってきます。こうして持ってきた近隣の個体配列は、以下のように取り出すことが出来ます。

var neighbors = data.neighbors[i].Reinterpret<Entity>();
if (neighbors.Length == 0) continue;

Reinterpret<>() を行うと、配列を別の型の配列に再解釈してくれます。今回は NeighborsBufferElement の中身は Entity だけなので、こうして Entity の配列として扱うことが出来ます。

Entity から所属するコンポーネントデータを取得するには、継承している ComponentSystem が所属している EntityManager を持っているのでこれを通じて GetComponentData<>() をすれば良い形です。

for (int j = 0; j < neighbors.Length; ++j)
{
    var pos1 = EntityManager.GetComponentData<Position>(neighbors[j]).Value;
    ...
}

こうして全ての情報が得られたので、UpdateSeparation() と同じように計算を行えば完成です。

整列と結合

Boids シミュレーションの整列と結合も、分離と同じように変換してあげれば良いです。新しい知識は必要ありません。少し長いですがコードは以下になります。

public class AlignmentSystem : ComponentSystem
{
    struct Data
    {
        public readonly int Length;
        [ReadOnly] public ComponentDataArray<Velocity> velocities;
        public ComponentDataArray<Acceleration> accelerations;
        [ReadOnly] public BufferArray<NeighborsEntityBuffer> neighbors;
    }

    [Inject] Data data;

    protected override void OnUpdate()
    {
        var param = Bootstrap.Param;

        for (int i = 0; i < data.Length; ++i)
        {
            var neighbors = data.neighbors[i].Reinterpret<Entity>();
            if (neighbors.Length == 0) continue;

            var averageVelocity = float3.zero;
            var velocity = data.velocities[i].Value;
            var accel = data.accelerations[i].Value;

            for (int j = 0; j < neighbors.Length; ++j)
            {
                averageVelocity += EntityManager.GetComponentData<Velocity>(neighbors[j]).Value;
            }

            averageVelocity /= neighbors.Length;
            var dAccel = (averageVelocity - velocity) * param.alignmentWeight;
            data.accelerations[i] = new Acceleration { Value = accel + dAccel };
        }
    }
}

public class CohesionSystem : ComponentSystem
{
    struct Data
    {
        public readonly int Length;
        [ReadOnly] public ComponentDataArray<Position> positions;
        public ComponentDataArray<Acceleration> accelerations;
        [ReadOnly] public BufferArray<NeighborsEntityBuffer> neighbors;
    }

    [Inject] Data data;

    protected override void OnUpdate()
    {
        var param = Bootstrap.Param;

        for (int i = 0; i < data.Length; ++i)
        {
            var neighbors = data.neighbors[i].Reinterpret<Entity>();
            if (neighbors.Length == 0) continue;

            var averagePos = float3.zero;
            var pos = data.positions[i].Value;
            var accel = data.accelerations[i].Value;

            for (int j = 0; j < neighbors.Length; ++j)
            {
                averagePos += EntityManager.GetComponentData<Position>(neighbors[j]).Value;
            }

            averagePos /= neighbors.Length;
            var dAccel = (averagePos - pos) * param.cohesionWeight;

            data.accelerations[i] = new Acceleration { Value = accel + dAccel };
        }
    }
}

実行順

さて、システムが増え実行の依存関係も複雑になってきました。先程は [UpdateAfter()] アトリビュートを使って順番を制御しましたが、システムが増え、壁での反転や分離・整列・結合は順不同ですが、NeighborsDetectionSystem よりは後、MoveSystem よりは前、といった形になりました。また、本エントリでは触れませんが Boids シミュレーションに今後新しいルール(e.g. 障害物を避ける)が加わる可能性もあります。

こういったことを考慮するために、[UpdateInGroup()] というアトリビュートが用意されています。使い方は次のとおりです。

public class BoidsSystemGroup {}

[UpdateBefore(typeof(BoidsSystemGroup))]
public class NeighborsDetectionSystem : ComponentSystem { ... }

[UpdateInGroup(typeof(BoidsSystemGroup))]
public class WallSystem : ComponentSystem { ... }
[UpdateInGroup(typeof(BoidsSystemGroup))]
public class SeparationSystem : ComponentSystem { ... }
[UpdateInGroup(typeof(BoidsSystemGroup))]
public class AlignmentSystem : ComponentSystem { ... }
[UpdateInGroup(typeof(BoidsSystemGroup))]
public class CohesionSystem : ComponentSystem { ... }

[UpdateAfter(typeof(BoidsSystemGroup))]
public class MoveSystem : ComponentSystem { ... }

以上のように複数のシステムをグルーピングして、それより前([UpdateBefore()])、後([UpdateAfter()])と設定することで、順番を柔軟に制御することが出来ます。

結果

実行すると、MonoBehaviour 版と同じ様に動くようになりました!

Entity Debugger

さて、動いている結果だけ見ているとバグったときになぜバグっているのか分からなくなることがあります。そこで Unity は各エンティティやコンポーネントを見れる仕組みとして、Entity Debugger というものを用意してくれています。これは Window > Analysis > Entity Debugger から開くことが出来ます。

f:id:hecomi:20181223160846p:plain

実行するとスクショのように中を見ることが出来ます。またエンティティを選択するとインスペクタで各コンポーネントの値も見ることが出来ます。

f:id:hecomi:20181223161051p:plain

直接編集することは出来ませんが、どのシステムが重いか、適切に値は更新されているか、変なシステムによって管理されてないか、IBufferElementData のキャパシティは適切か、など確認することが出来て便利なので、逐次確認するようにしましょう。

Job System 対応

さて、デバッグ表示を見たりしていても気づくかも知れませんが、パフォーマンスはどうかと boidCount を増やしてみると、残念なことにあまり改善していないようです。。

この原因は、このままでは全ての処理がメインスレッドで実行されており、かつコードの最適化もされておらず ECS 化した恩恵をあまり受けることが出来ていないからです。ECS 化することによる真の恩恵は、並列化を行う Job System の対応を行いやすい点と、SIMD 等の最適化を最大限効かせることが可能な Burst Compiler を簡単に利用できる点にあります。

そこで、まずは CPU コアを複数使い処理を並列化できるようにするためにジョブ化してみましょう。Job System については以下の記事をご参照ください。

tsubakit1.hateblo.jp

本章のコードは以下になります。

IJobParallelFor でジョブ化

試しに WallSystem を例に処理をジョブで実行できるように願って IJobParallelFor を使ったこんなコードにしてみましょう。ジョブ化は機械的に行うことが出来ます。

public class WallSystem : JobComponentSystem
{
    struct Data
    {
        public readonly int Length;
        [ReadOnly] public ComponentDataArray<Position> positions;
        public ComponentDataArray<Acceleration> accelerations;
    }

    [Inject] Data data;

    // ジョブを作成
    public struct Job : IJobParallelFor
    {
        // OnUpdate() から渡してもらう
        [ReadOnly] public ComponentDataArray<Position> positions;
        public ComponentDataArray<Acceleration> accelerations;
        [ReadOnly] public Param param;

        // OnUpdate() の処理を移植
        public void Execute(int index)
        {
            var scale = param.wallScale * 0.5f;
            var thresh = param.wallDistance;
            var weight = param.wallWeight;

            float3 pos = positions[index].Value;
            float3 accel = accelerations[index].Value;
            accel +=
                GetAccelAgainstWall(-scale - pos.x, new float3(+1, 0, 0), thresh, weight) +
                GetAccelAgainstWall(-scale - pos.y, new float3(0, +1, 0), thresh, weight) +
                GetAccelAgainstWall(-scale - pos.z, new float3(0, 0, +1), thresh, weight) +
                GetAccelAgainstWall(+scale - pos.x, new float3(-1, 0, 0), thresh, weight) +
                GetAccelAgainstWall(+scale - pos.y, new float3(0, -1, 0), thresh, weight) +
                GetAccelAgainstWall(+scale - pos.z, new float3(0, 0, -1), thresh, weight);
            accelerations[index] = new Acceleration { Value = accel };
        }

        float3 GetAccelAgainstWall(float dist, float3 dir, float thresh, float weight)
        {
            if (dist < thresh)
            {
                return dir * (weight / math.abs(dist / thresh));
            }
            return float3.zero;
        }
    }

    // OnUpdate() の処理は Job を作成して返すだけにする
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var job = new Job 
        {
            positions = data.positions,
            accelerations = data.accelerations,
            param = Bootstrap.Param,
        };
        return job.Schedule(data.Length, 32, inputDeps);
    }
}

内容としては、これまで OnUpdate() で実行していた処理を IJobParallelFor でラップしています。しかしながら、このまま実行してみるとInvalidOperationException: Job.param is not a value type. Job structs may not contain any reference types. という形でエラーが出てしまいます。

f:id:hecomi:20181216142207p:plain

これは、ジョブの中では参照型は使えないことに因ります。この問題を解決する手段は色々あり、例えば Param クラスを ISharedComponentDataにしてしまうなどもありますが、ここでは必要なパラメタだけ Param から取り出してジョブに渡す形にしてみます。ジョブの発行はメインスレッドからしか出来ない、という安全性を保証するジョブの縛りによる恩恵、という感じです。

    public struct Job : IJobParallelFor
    {
        ...
        [ReadOnly] public float scale;
        [ReadOnly] public float thresh;
        [ReadOnly] public float weight;

        public void Execute(int index)
        {
            ...
        }
        ...
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var job = new Job 
        {
            ...
            scale = Bootstrap.Param.wallScale * 0.5f,
            thresh = Bootstrap.Param.wallDistance,
            weight = Bootstrap.Param.wallWeight,
        };
        return job.Schedule(data.Length, 32, inputDeps);
    }
}

これで実行してプロファイラを見てみると次のように WallSystem が並列に実行されるようになっていることが分かります。

f:id:hecomi:20181216154406p:plain

IJobProcessComponentData を使う

全てをこのまま IJobParallelFor で並列化していっても良いのですが、ECS で ComponentData を並列化していくに当たっては、より賢く且つシンプルに書ける Job として、IJobProcessComponentData というものが用意されています。引数のフラグ([ReadOnly][WriteOnly] かなど)を見て、並列処理の順序を適当に調整してくれるようです。

tsubakit1.hateblo.jp

コードを見てみましょう。

public class WallSystem : JobComponentSystem
{
    public struct Job : IJobProcessComponentData<Position, Acceleration>
    {
        [ReadOnly] public float scale;
        [ReadOnly] public float thresh;
        [ReadOnly] public float weight;

        public void Execute([ReadOnly] ref Position pos, ref Acceleration accel)
        {
            accel = new Acceleration
            {
                Value = accel.Value +
                    GetAccelAgainstWall(-scale - pos.Value.x, new float3(+1, 0, 0), thresh, weight) +
                    GetAccelAgainstWall(-scale - pos.Value.y, new float3(0, +1, 0), thresh, weight) +
                    GetAccelAgainstWall(-scale - pos.Value.z, new float3(0, 0, +1), thresh, weight) +
                    GetAccelAgainstWall(+scale - pos.Value.x, new float3(-1, 0, 0), thresh, weight) +
                    GetAccelAgainstWall(+scale - pos.Value.y, new float3(0, -1, 0), thresh, weight) +
                    GetAccelAgainstWall(+scale - pos.Value.z, new float3(0, 0, -1), thresh, weight)
            };
        }

        float3 GetAccelAgainstWall(float dist, float3 dir, float thresh, float weight)
        {
            if (dist < thresh)
            {
                return dir * (weight / math.abs(dist / thresh));
            }
            return float3.zero;
        }
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var job = new Job 
        {
            scale = Bootstrap.Param.wallScale * 0.5f,
            thresh = Bootstrap.Param.wallDistance,
            weight = Bootstrap.Param.wallWeight,
        };
        return job.Schedule(this, inputDeps);
    }
}

インジェクションのコードが要らなくなり、フィルタリング用の構造体もなくなるため、先程よりもかなりシンプルに記述できるようになりました。コンポーネントをシンプルに処理するときは IJobProcessComponentData、込み入ったときは IJobParallelFor 等の別の手段を取る、といった流れで、基本はこちらを使うと良いようです。

MoveSystem の実装

IJobProcessComponentData は引数を 4 つまで取ることが出来ます。MoveSystem はちょうど 4 個使っているので間に合います。

public class MoveSystem : JobComponentSystem
{
    public struct Job : IJobProcessComponentData<Position, Rotation, Velocity, Acceleration>
    {
        [ReadOnly] public float dt;
        [ReadOnly] public float minSpeed;
        [ReadOnly] public float maxSpeed;

        public void Execute(
            ref Position pos,
            [WriteOnly] ref Rotation rot,
            ref Velocity velocity,
            ref Acceleration accel)
        {
            var v = velocity.Value;
            v += accel.Value * dt;
            var dir = math.normalize(v);
            var speed = math.length(v);
            v = math.clamp(speed, minSpeed, maxSpeed) * dir;

            pos = new Position { Value = pos.Value + v * dt };
            rot = new Rotation { Value = quaternion.LookRotationSafe(dir, new float3(0, 1, 0)) };
            velocity = new Velocity { Value = v };
            accel = new Acceleration { Value = float3.zero };
        }
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var job = new Job
        {
            dt = Time.deltaTime,
            minSpeed = Bootstrap.Param.minSpeed,
            maxSpeed = Bootstrap.Param.maxSpeed,
        };
        return job.Schedule(this, inputDeps);
    }
}

こちらもシンプルになりました。

分離・整列・結合の Job 化

ご近所探しの NeighborsDetectionSystem は最後にして、他の Boids シミュレーションの部分のジョブ化してみましょう。ただ 1 つ問題があり、先程の MoveSystemWallSystem では IJobProcessComponentData を使用していましたが、このジェネリクス引数には IBufferElementData を指定することが出来ません。更にこのバッファの中に格納されたエンティティから、紐付いた位置や速度といったコンポーネントの値にアクセスしないとなりません。

では、これらをどうやるのかコードを見ていきましょう。

public class SeparationSystem : JobComponentSystem
{
    public struct Job : IJobProcessComponentDataWithEntity<Position, Acceleration>
    {
        [ReadOnly] public float separationWeight;
        [ReadOnly] public BufferFromEntity<NeighborsEntityBuffer> neighborsFromEntity;
        [ReadOnly] public ComponentDataFromEntity<Position> positionFromEntity;

        public void Execute(
            Entity entity, 
            int index, 
            [ReadOnly] ref Position pos, 
            ref Acceleration accel)
        {
            var neighbors = neighborsFromEntity[entity].Reinterpret<Entity>();
            if (neighbors.Length == 0) return;

            var pos0 = pos.Value;

            var force = float3.zero;
            for (int i = 0; i < neighbors.Length; ++i)
            {
                var pos1 = positionFromEntity[neighbors[i]].Value;
                force += math.normalize(pos0 - pos1);
            }
            force /= neighbors.Length;

            var dAccel = force * separationWeight;
            accel = new Acceleration { Value = accel.Value + dAccel };
        }
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var job = new Job 
        {
            separationWeight = Bootstrap.Param.separationWeight,
            neighborsFromEntity = GetBufferFromEntity<NeighborsEntityBuffer>(true),
            positionFromEntity = GetComponentDataFromEntity<Position>(true),
        };
        return job.Schedule(this, inputDeps);
    }
}

まず、IJobProcessComponentData の代わりに IJobProcessComponentDataWithEntity というジョブの基底クラスを使います。これは Execute() する際に、ジェネリクスで渡した型に加えて、エンティティとそのインデックスを渡してくれるというものです。

その上で、ジョブ生成時にメインスレッド(OnUpdate())から、GetBufferFromEntity<>()GetComponentDataFromEntity<>() を渡す形にします。これらはジョブの中で、BufferFromEntity<>ComponentDataFromEntity<> として受け取ることが出来ます。これらのコンテナは、Entity を渡してアクセスするとそのエンティティの持つコンポーネントやバッファにアクセスできる、というものになっています。以上の流れに該当する箇所だけまとめると次のようになります。

public struct Job : IJobProcessComponentDataWithEntity<...>
{
    ...
    [ReadOnly] public BufferFromEntity<NeighborsEntityBuffer> neighborsFromEntity;
    [ReadOnly] public ComponentDataFromEntity<Position> positionFromEntity;
    ...
    public void Execute(Entity entity, ...)
    {
        var neighbors = neighborsFromEntity[entity].Reinterpret<Entity>();
        ...
        for (int i = 0; i < neighbors.Length; ++i)
        {
            var neighborEntity = neighbors[i];
            var pos1 = positionFromEntity[neighborEntity].Value;
            ...
        }
        ...
    }
}

var job = new Job 
{
    ...
    // 引数は readonly か否か
    neighborsFromEntity = GetBufferFromEntity<NeighborsEntityBuffer>(true),
    positionFromEntity = GetComponentDataFromEntity<Position>(true),
};

IJobProcessComponentDataWithEntity によって貰ってきた自身のエンティティ ID を利用して、BufferFromEntity<NeighborsEntityBuffer> を通じて自身の持つご近所さんエンティティリストにアクセスします。次に、このご近所さんリストを回して近隣の個体のエンティティを取得、それを使って ComponentDataFromEntity<Position> を通じてそれぞれの位置を取得しています。

ちなみにキャッシュミスの多くなってしまう手法なので、より最適化するには NeighborsEntityBuffer の中身をエンティティでなく、直接 PositionVelocity にしてしまう方が良いと思いますが、こちらの方が他の要素が増えてもアクセスできる柔軟性はあるのでケースバイケースでしょう。

残りの AlignmentSystemCohesionSystem も同じように対応をします。詳しくは GitHub のプロジェクトを御覧ください。

NeighborsDetectionSystem の Job 化

では最後に残ったご近所さん探しのシステムもジョブ化しましょう。こちらの問題は、Boids に対応するエンティティを全走査しなければならない点です。しかしながらジョブの中のワーカスレッドではエンティティ一覧を取得できません。そこで外側から与えます。

public class NeighborDetectionSystem : JobComponentSystem
{
    public struct Job : IJobProcessComponentDataWithEntity<Position, Velocity>
    {
        [ReadOnly] public float prodThresh;
        [ReadOnly] public float distThresh;
        [ReadOnly] public ComponentDataFromEntity<Position> positionFromEntity;
        [ReadOnly] public BufferFromEntity<NeighborsEntityBuffer> neighborsFromEntity;
        [ReadOnly] public EntityArray entities;

        public void Execute(
            Entity entity,
            int index,
            [ReadOnly] ref Position pos,
            [ReadOnly] ref Velocity velocity)
        {
            neighborsFromEntity[entity].Clear();

            float3 pos0 = pos.Value;
            float3 fwd0 = math.normalize(velocity.Value);

            for (int i = 0; i < entities.Length; ++i)
            {
                var neighbor = entities[i];
                if (neighbor == entity) continue;

                float3 pos1 = positionFromEntity[neighbor].Value;
                var to = pos1 - pos0;
                var dist = math.length(to);

                if (dist < distThresh)
                {
                    var dir = math.normalize(to);
                    var prod = Vector3.Dot(dir, fwd0);
                    if (prod > prodThresh)
                    {
                        neighborsFromEntity[entity].Add(new NeighborsEntityBuffer { Value = neighbor });
                    }
                }
            }
        }
    }

    ComponentGroup group;

    protected override void OnCreateManager()
    {
        group = GetComponentGroup(
            typeof(Position), 
            typeof(Velocity), 
            typeof(NeighborsEntityBuffer));
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var job = new Job
        {
            prodThresh = math.cos(math.radians(Bootstrap.Param.neighborFov)),
            distThresh = Bootstrap.Param.neighborDistance,
            neighborsFromEntity = GetBufferFromEntity<NeighborsEntityBuffer>(false),
            positionFromEntity = GetComponentDataFromEntity<Position>(true),
            entities = group.GetEntityArray(),
        };
        return job.Schedule(this, inputDeps);
    }
}

該当するコンポーネントを持つエンティティのリストを取得するために、ComponentGroup を利用します。ComponentGroup の作成にはコストが伴うので、システム生成時に実行される OnCreateManager() で事前に作成しておく形にします。アーキタイプの時と同じように型を指定しておき GetEntityArray() をすると、該当するエンティティ一覧がメインスレッドで取得できます。

なお、この ComponentGroup[Inject] はどちらでも該当するコンポーネント群を保有するエンティティを探してくることができますが、[Inject] で行うフィールドへのインジェクションは将来的になくなる可能性があるようです。これは、静的解析ツールでインジェクションを検知できない点などの問題によるもののようです。

さて話を戻しますがご近所さん探しの実装はこれで完了で、後は同じように計算すれば OK です。[ReadOnly]BufferFromEntity<> につけてしまっているのですが、Add() は出来るみたいです(して良いのかな...?)。[ReadOnly] をつけるのは必須で、つけないと次のようなエラーが起きます。

InvalidOperationException: Job.Data.neighborsFromEntity is not declared [ReadOnly] in a IJobParallelFor job. The container does not support parallel writing. Please use a more suitable container type.

結果

ジョブ化した結果を確認してみましょう。従来は私のノート PC 環境では 150 匹くらいで 60 fps を下回ってしまったのですが、300 匹くらい動かしても問題なくなりました。次の図は 300 匹で動かしたときのプロファイラです。

f:id:hecomi:20181223165755p:plain

2 重ループになるのでやはり NeighborsDetectionSystem が一番重いですね。ちなみにジョブ化していない 300 匹の場合はこちらです。

f:id:hecomi:20181223170158p:plain

大分効率的になっているのが分かります。なお、上手だとピンク色のラインがたくさん見えています。拡大すると小さい領域しか専有していないのですが、これらは GC.Alloc() の結果で余分なメモリの確保が走っており望ましくはないものです。ただ、これらはエディタ上のみで走るものになっており、ビルドすると次のように多くの GC.Alloc() はなくなります。なのでパフォーマンス計測目的であればビルドして実行するようにしてください。

f:id:hecomi:20181231132337p:plain

ジョブをまとめる

今は 1 システム 1 ジョブでやっていましたが、システムの中に複数のジョブを含めることも出来ます。サンプルはこちらです。

ではコードを見てみましょう。

public class BoidsSimulationSystem : JobComponentSystem
{
    public struct NeighborsDetectionJob : 
        IJobProcessComponentDataWithEntity<Position, Velocity> { ... }

    public struct WallJob : 
        IJobProcessComponentData<Position, Acceleration> { ... }

    public struct SeparationJob : 
        IJobProcessComponentDataWithEntity<Position, Acceleration> { ... }

    public struct AlignmentJob : 
        IJobProcessComponentDataWithEntity<Velocity, Acceleration> { ... }

    public struct CohesionJob : 
        IJobProcessComponentDataWithEntity<Position, Acceleration> { ... }

    public struct MoveJob : 
        IJobProcessComponentData<Position, Rotation, Velocity, Acceleration> { ... }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var neighbors = new NeighborsDetectionJob { ... };
        var wall = new WallJob { ... };
        var separation = new SeparationJob { ... };
        var alignment = new AlignmentJob { ... };
        var cohesion = new CohesionJob { ... };
        var move = new MoveJob { ... };

        inputDeps = neighbors.Schedule(this, inputDeps);
        inputDeps = wall.Schedule(this, inputDeps);
        inputDeps = separation.Schedule(this, inputDeps);
        inputDeps = alignment.Schedule(this, inputDeps);
        inputDeps = cohesion.Schedule(this, inputDeps);
        inputDeps = move.Schedule(this, inputDeps);
        return inputDeps;
    }
}

最後に JobHandle をやり取りして順番を制御しています。これにより [UpdateAfter()] といった順序制御も要らなくなりました。

ジョブをまとめて管理するモチベーションとしては、より複雑な実行順制御をしたかったからです。まとめる前のプロファイラを見てみると次のように各ジョブの間で終了待ちが起こっています。

しかしながら、WallJobSeparationJobAlignmentJobMoveJob は実行順は適当で良いので、これをもっと密に詰めたくなります。そこで JobHandle には CombineDependencies() という並列に処理する仕組みが用意されていて、次のように書くことが出来ると期待します。

var neighborsHandle = neighbors.Schedule(this, inputDeps);

var wallHandle = wall.Schedule(this, neighborsHandle);
var separationHandle = separation.Schedule(this, neighborsHandle);
var alignmentHandle = alignment.Schedule(this, neighborsHandle);
var cohesionHandle = cohesion.Schedule(this, neighborsHandle);

var combinedDeps1 = JobHandle.CombineDependencies(wallHandle, separationHandle);
var combinedDeps2 = JobHandle.CombineDependencies(alignmentHandle, cohesionHandle);
var combinedDeps3 = JobHandle.CombineDependencies(combinedDeps1, combinedDeps2);

var moveHandle = move.Schedule(this, combinedDeps3);

return moveHandle;

しかしながら、この状態では Acceleration が同時に複数のジョブから書き込める状態になってしまうため、以下のようなエラーになってしまいます。なので、この対応は出来ませんでした。

InvalidOperationException: The previously scheduled job Component:Job writes to the NativeArray Job.field.

テラシュールブログさんにも詳細が載っています。

http://tsubakit1.hateblo.jp/entry/2018/04/26/01161tsubakit1.hateblo.jp

同時書き込み可能な float3 を自作すれば対応できるかもしれません。しかしながら本ケースの最適化という面では、上記 4 つのジョブを 1 つのジョブにまとめる、という対応をするのが一番楽且つ速いと思います。*FromEntity<> 系のコンテナの用意も 1 回ですむのでトータルでも軽くなるでしょう。

説明が面倒になるので、ここではこの最適化は据え置きにします。

Burst Compiler 対応

さて、いよいよ Burst Compiler 対応を行います。Burst Compiler でコードをコンパイルしてあげると C# で実行していたときよりも遥かに高速な SIMD の効いた高速なネイティブのコードが吐き出されます。加えてこれはものすごく簡単です。サンプルは以下になります。

次のように各ジョブの頭に [BurstCompile] アトリビュートを付与するだけです。

public class BoidsSimulationSystem : JobComponentSystem
{
    [BurstCompile]
    public struct NeighborsDetectionJob : 
        IJobProcessComponentDataWithEntity<Position, Velocity> { ... }

    [BurstCompile]
    public struct WallJob : 
        IJobProcessComponentData<Position, Acceleration> { ... }

    [BurstCompile]
    public struct SeparationJob : 
        IJobProcessComponentDataWithEntity<Position, Acceleration> { ... }

    [BurstCompile]
    public struct AlignmentJob : 
        IJobProcessComponentDataWithEntity<Velocity, Acceleration> { ... }

    [BurstCompile]
    public struct CohesionJob : 
        IJobProcessComponentDataWithEntity<Position, Acceleration> { ... }

    [BurstCompile]
    public struct MoveJob : 
        IJobProcessComponentData<Position, Rotation, Velocity, Acceleration> { ... }

これで実行すると、ジョブの中の Execute() のコードが Burst Compiler でコンパイルされ、C# での実行と比較すると爆速になります。先程と同様に 300 匹で動かしたときのプロファイラを見てみましょう。

f:id:hecomi:20181223170509p:plain

爆速になりました。1000 匹を超えるインスタンスラクラク動きます。Burst された後のアセンブリJobs > Burst Inspector で見ることが出来ます。

f:id:hecomi:20181223200229p:plain

Enhanced Disassembly にチェックを入れると、コード行と該当するコードが分かり見やすくなっておすすめです。例えば MoveSystem で加速度を速度に足す部分のコードは VFMADD213PS 命令になっており、単精度浮動小数点ベクトルに対し加算・乗算をまとめて行う命令に変換されています。すごい。

他にも詳細は @tnayuki さんのスライドをご参照ください。

learning.unity3d.jp

動的なエンティティの増減

最後になってしまいましたが、MonoBehaviour のときにあったエンティティの増減の処理を書いてみたいと思います。

メインスレッドから

まずは ComponentSystem 継承でメインスレッドで実行されるシステムを作ってみましょう。サンプルは以下になります。

Bootstrap から生成箇所を切り出すために Bootstrap.cs を少しいじります。

public class Bootstrap : MonoBehaviour 
{
    public static Bootstrap _Instance;

    public static Bootstrap Instance 
    { 
        get 
        { 
            return _Instance ?? (_Instance = FindObjectOfType<Bootstrap>());
        }
    }

    ...

    [System.Serializable]
    public struct BoidInfo
    {
        public int count;
        public Vector3 scale;
        public MeshInstanceRenderer renderer;
    }

    [SerializeField]
    BoidInfo boidInfo = new BoidInfo 
    {
        count = 100,
        scale = new Vector3(0.1f, 0.1f, 0.3f),
    };

    public static BoidInfo Boid
    {
        get { return Instance.boidInfo; }
    }

    ...
}

static 経由でセットされた変数にアクセスできるようにしておきます。また、OnCreateManager() はデフォルトのワールドでは Awake() のタイミングよりも前に呼ばれるので、Instance の取得方法を初回アクセス時に取得・キャッシュするように変更しておきます。

次にシステムを書きます。また色々出てきますが、まずは全体を貼ります。

[AlwaysUpdateSystem]
[UpdateBefore(typeof(BoidsSimulationSystem))]
public class BoidsEntityGenerationSystem : ComponentSystem
{
    EntityArchetype archetype;
    ComponentGroup group;
    MeshInstanceRenderer renderer;
    Unity.Mathematics.Random random;

    protected override void OnCreateManager()
    {
        archetype = EntityManager.CreateArchetype(
            typeof(Position),
            typeof(Rotation),
            typeof(Scale),
            typeof(Velocity),
            typeof(Acceleration),
            typeof(NeighborsEntityBuffer),
            typeof(MeshInstanceRenderer));

        group = GetComponentGroup(archetype.ComponentTypes);

        random = new Unity.Mathematics.Random(853);
    }

    protected override void OnUpdate()
    {
        var entities = group.GetEntityArray();
        for (int i = 0; i < entities.Length - Bootstrap.Boid.count; ++i)
        {
            PostUpdateCommands.DestroyEntity(entities[i]);
        }
        for (int i = 0; i < Bootstrap.Boid.count - entities.Length; ++i)
        {
            CreateEntity();
        }
    }

    void CreateEntity()
    {
        var scale = Bootstrap.Boid.scale;
        var renderer = Bootstrap.Boid.renderer;
        var initSpeed = Bootstrap.Param.initSpeed;
        PostUpdateCommands.CreateEntity(archetype);
        PostUpdateCommands.SetComponent(new Position { Value = random.NextFloat3(1f) });
        PostUpdateCommands.SetComponent(new Rotation { Value = quaternion.identity });
        PostUpdateCommands.SetComponent(new Scale { Value = new float3(scale.x, scale.y, scale.z) });
        PostUpdateCommands.SetComponent(new Velocity { Value = random.NextFloat3Direction() * initSpeed });
        PostUpdateCommands.SetComponent(new Acceleration { Value = float3.zero });
        PostUpdateCommands.SetSharedComponent(renderer);

    }
}

順に説明していきます。まず、アトリビュートですが、[UpdateBefore()] に加え、[AlwaysUpdateSystem] というものがついています。これをつけると [Inject] に対応するエンティティがいようがいまいがいつでも OnUpdate() が呼ばれるようになります。これをつけないと、初回は全くエンティティが無い状態なのでなんのシステムも呼ばれない状態になっているのと、そもそも [Inject] もないので対応するエンティティがどれかわからなくなっているためです。

次に OnCreateManager() ですが、中身は大体 Bootstrap からの移植です。1 点、ComponentGroup だけ、あとで該当のエンティティ取得用にアーキタイプから生成しています。

そして OnUpdate() の中で生成と破棄をしています。システムの中からその場でエンティティの増減を行ってしまうと不都合があります。そこで、PostUpdateCommands というものが用意されていて、これはその名の通り OnUpdate() の終了後に実行されるコマンドを格納しておくものになります。その場ではエンティティは生成されるわけではないので、PostUpdateCommands.CreateEntity() の返り値としてエンティティは受け取れないのですが、続けて SetComponent() をしてあげることで、直前のエンティティにコンポーネントをセットすることが出来ます。

こうしてエンティティの増減が ECS を使っていてもリアルタイムで行えるようになりました。しかしながら一気に大量に生成するとパフォーマンスの問題があります。1000 個の個体を 1 フレームで生成した際のプロファイラのスクリーンショットがこちらです。

f:id:hecomi:20181221004029p:plain

メインスレッドでドカッと処理されています。この処理をジョブ化するところまで説明したかったのですが、今回は時間の都合上ここまでにします。。

おわりに

具体的な例を使って説明していく流れはどうでしたでしょうか。まだ複雑なジョブや型は残っていますが、基本的な概念はかなり触れられたのではないかと思います。今回時間の都合上触れられなかった部分(Boid の種類を増やしたり、サンプルのようにサメのような敵を出したり、空間グリッドとハッシュテーブルによる高速化など)に関しては、この Boids のサンプルに加える形で今後も更新していこうと思います。なにかこうした新しい概念を勉強するときの近道は実際になにかを作ってみることだと思いますので、ぜひ皆さんも色々な題材に挑戦して(共有して)みてください。分かりにくいところがあればフィードバックいただければ追記いたします。また間違っている点や改良点などありましたらご連絡いただければ加筆修正します。

触ってみた所感としては、ECS をメインに使って色々なタイプの手触りの良い面白いゲームを作る、みたいな話になるとコード量も増えてしまいサクサクッとプロトタイピングもしづらいですし、まだ自分の中でノウハウもないので、難しいなという印象はあります。ただ今回のような賑やかしオブジェクトや自前パーティクルといったシンプルかつ大量なオブジェクトを高速に処理するユースケースであれば、決まった工程に従って作成していけば比較的簡単に安全な並列処理かつ高速なコードを実行できるので、現状でも場面によっては使うメリットはかなりあるなと感じました。

あと最後になりましたが今回の勉強はテラシュールブログに大感謝です、いつもありがとうございます。またコメントを頂いた皆様ありがとうございます、勉強になります。頂いたコメントを元に記事も適宜修正していきます。

その他参考文献

メモし忘れていたので思い出し次第追記します。