Project KURUKURU

ゲームプログラミングについて、興味を持ったものを雑多に取り上げるブログです

Unity ECS & C# Job System その2

はじめに

こんにちはこんばんは。繰繰廻(くるくる・めぐる)です。

ドルフロしてたりCEDEC行ってたりしたら、3日坊主どころではない即落ち2コマブログにするところでした。

前回はスクリプトを全く書かずにECSの入り口を叩いたところでした。

前回の記事をご覧になってない方は、先にそちらをどうぞ。

kurukuru-meguru.hatenablog.com

 

今回は球体を大量にスポーンさせて、それを原点中心に単振動させます。そして、従来通りのMonoBehaviourと比較して高速化されたか確認してみます。

 

 

作ったサンプル

 

vimeo.com

vimeo.com

上が従来通り、球体のプレハブを100,000個スポーンさせ、MonoBehaviourのスクリプトに、中心点に向かって単振動する動きを書いたものです。

下は同じく100,000個の球体ですが、Entityで球体を作成し、Systemに単振動を書いたものです。

目に見えて動きが早くなっています。

f:id:kurukuru_meguru:20180809134345j:plain

f:id:kurukuru_meguru:20180809134412j:plain

プロファイラで経過時間を確認してみても明らかです。ただこれでも十分な速さとは言えませんが(113.92ms)。

 

作成

Entityの作成

単振動させるためには、速度と加速度(力)が必要です。

ですので、そのためのコンポーネントを定義して追加します。

手順は以下の通りです。

  1. 空のスクリプトを作成して、「VelocityComponent.cs」と名づける
  2. using Unity.Entities;する
  3. 最初から入っているMonoBehaviour全部消して、IComponentDataを継承した構造体を好きな名前で定義する
  4. ComponentDataWrapperを使ってファイル名と同じクラスを定義する
  5. 作成したスクリプトをこれまでのMonoBehaviourにAddComponentするのと同様に、シーン上で追加する

 

[Serializable]
public struct  SphereVelocity: IComponentData
{
    public Vector3 velocity;

    public SphereVelocity(float3 init) { velocity = init; }
}
public class SphereVelocityComponent : ComponentDataWrapper<SphereVelocity> { }

加速度についても同様に「SphereAccelerationComponent.cs」というファイル名で作成します。
[Serializable]
public struct SphereAcceleration : IComponentData
{
    public float kStrongness;     //ばね係数
    public Vector3 center;        //単振動の中心
}
public class SphereAccelerationComponent : ComponentDataWrapper<SphereAcceleration> { }
そしてどちらもAddComponentした状態のインスペクターがこちらになります。

f:id:kurukuru_meguru:20180809152053j:plain

 

コンポーネントを自作する場合、IComponentDataを継承した構造体を定義します。

純粋なECSであればこれだけで十分コンポーネントとして使うこともできるのですが、現状のUnityには、ECSではMonoBehaiverにAddComponentする形で実装されていた機能たちを使う方法はありません。要するに描画や当たり判定やその他もろもろほとんどのUnityのありがたみを享受できません。

ですので、ComponentDataWrapperを継承したクラスを「ファイル名と同じ名前で」作成する必要があります。

これによって、インスペクターでこれまでのコンポーネントと同じように、ECSのコンポーネントを扱うことができるようになります。

 蛇足ですが、純粋なECSはどういうシーンで使えばいいんでしょうね?AIみたいな描画との分離が比較的簡単なところとかでしょうか?

 

 

 単振動させるSystemの定義

次に、単振動の処理部分を書いていきます。

手順は以下の通りです。

  1. 空のスクリプトを作成し、ComponentSystemを継承したクラスを定義
  2. そのクラス(システム)の中で、更新するコンポーネントをまとめた構造体を定義
  3. そのEntityを抽出する[Inject]なるものを書く
  4. overrideしたOnUpdate関数に処理を書く

 

public class SphereAcceralationSystem : ComponentSystem
{
    struct Group
    {
        public ComponentDataArray<SphereAcceleration> acceleration;
        public ComponentDataArray<SphereVelocity> velocity;
        public ComponentDataArray<Position> position;
        public readonly int Length;
    }

    [Inject] Group m_Group;

    protected override void OnUpdate()
    {
        //配置場所決定
        for (int i = 0; i < m_Group.Length; i++)
        {
            //単振動の中心との距離測定
            Vector3 dir = new Vector3(
                m_Group.acceleration[i].center.x - m_Group.position[i].Value.x,
                m_Group.acceleration[i].center.y - m_Group.position[i].Value.y,
                m_Group.acceleration[i].center.z - m_Group.position[i].Value.z);
            float distance = dir.magnitude;
            dir.Normalize();

            //速度更新
            m_Group.velocity[i] = new SphereVelocity
            (
                m_Group.velocity[i].velocity +
                dir * distance * m_Group.acceleration[i].kStrongness * Time.deltaTime
            );

            //位置設定
            var position = new Position
            (
                m_Group.position[i].Value + (float3)m_Group.velocity[i].velocity * Time.deltaTime
            );

            m_Group.position[i] = position;
        }

    }
}

以上です。正直Systemの書き方は「決まり文句」のような特別なお作法が多くて、なぜそれで動くのかイマイチ理解し難い部分があります。

加速度のコンポーネントの値から速度のコンポーネントを更新し、速度のコンポーネントの値からPosition Componentの値を更新するため、それら3つのコンポーネントを抽出します。

 その抽出する構造体をGroupと名付けていますが、この中の

public readonly int Length;

については、このスペル通りに定義することで(length等小文字もNG)、抽出されたコンポーネントの配列の数が自動で入ります。そんな決まり文句があるなら基底を用意しておいてほしい。このLengthの数だけfor文を回すことで、OnUpdate内でコンポーネントすべての更新が行えます。

[Inject] Group m_Group;

と書くことで配列がこの中に入る仕組みもよくわからないですね……。[Inject]自体初めてお目にかかるのですが、C#の機能でしょうか?

これにより、このComponentの組み合わせを持つEntityが見つかれば、Systemは自動で動き始めます。Systemが書かれたスクリプトは、シーンのオブジェクトが持ったりする必要はなく、assetに置いたままで動きます。

 

球体をspawnさせるSystemの定義

先ほど作った球体のエンティティをスポーンさせるには、これまたSystemとComponentを作成する必要があります。

手順は以下の通りです。

  1. 上記設定をした球体をアセットブラウザにドロッグ&ドロップしてアセット化
  2. そのアセットをスポーンさせるシステムと、そのシステムのためのコンポーネントを作成

手順にすると二つだけですが、スポーンさせるところでちょっと引っ掛かりポイントがあります。というよりは1.については何をいまさらなところなので、スルーします。

スポーンするためのComponentについては、SharedComponentというものを使います。

普通のComponentはEntityごとに実体が確保されるのですが、SharedCompoentはEntity間で同じデータを共有(Share)します。スポーンさせるアセットは原型が一つだけですし、実体も1つで問題ないのでSharedComponentを使用します。

//using略

public struct SphereSpawner : ISharedComponentData
{
    public GameObject prefab;

    public static int kSpawnNum=100000;  //スポーンさせる個数
}
public class SphereSpawnerComponent : SharedComponentDataWrapper<SphereSpawner> { }

スポーンさせる個数の設定にはstaticを用いていますが、Burstコンパイラを用いるときにはNGかもしれません。まだ調べられてないですが……。

そして、このファイルをSphereSpawnerComponent.csとして、空のGameObjectに持たせて、prefabに先ほどasset化した球体

のEntityを登録します。

f:id:kurukuru_meguru:20180825151622j:plain

 

そしてSystemは以下のようになります。


//using略 public class SphereSpawnerSystem : ComponentSystem { struct Group { [ReadOnly] //SharedComponentDataArrayはReadOnlyフラグつけないとダメ) public SharedComponentDataArray<SphereSpawner> spawner; public EntityArray Entity; public readonly int Length; } //Sceneに置かれてるSphereSpawnerの抽出 [Inject] Group m_Group; protected override void OnUpdate() { while (m_Group.Length != 0) { var spawner = m_Group.spawner[0]; var sourceEntity = m_Group.Entity[0]; //GCされない配列を確保して一気にスポーン var entities = new NativeArray<Entity>(SphereSpawner.kSpawnNum, Allocator.Temp); EntityManager.Instantiate(spawner.prefab, entities); //配置場所決定 for (int i = 0; i < SphereSpawner.kSpawnNum; i++) { //原点からある程度の距離のところに球体配置 Vector3 dir = new Vector3(Random.Range(-1.0f, 1.0f), Random.Range(-1.0f, 1.0f), Random.Range(-1.0f, 1.0f)); dir.Normalize(); float distance = Random.Range(0.0f, 50.0f); //位置設定 var position = new Position { Value = dir * distance }; EntityManager.SetComponentData(entities[i], position); } //GCされない配列だから、解放は自分で書く entities.Dispose(); //処理完了後にコンポーネント削除することで、OnUpdateをまた通らないようにする(疑似Initialize) EntityManager.RemoveComponent<SphereSpawner>(sourceEntity); // Instantiate & AddComponent & RemoveComponent calls invalidate the injected groups, // so before we get to the next spawner we have to reinject them //STLのイテレータで配列の要素消したときに、詰める必要があるみたいな感じかな? UpdateInjectedComponentGroups(); } } }

 SharedComponentは並列して動く可能性のあるほかのSystemでも共有するため、[ReadOnly] をつける必要があります。

また、SystemにはStart()のような、初期化時に一度だけ通る仕組みがないため、スポーン処理を完了すればコンポーネントを削除して、次フレームのOnUpdate()時には通らないようにします。このあたりはUnity側が改善してほしいところですね。

 

まとめ

以上で

・Component作成

・Componentに対応するSystem作成

・asset化したEntityのSpawn

などができました。

次の記事ではこれらの処理をC#JobSystemを用いて、単一スレッドで動いているこのSystemを自動で並列処理されるようにします。しかし次っていつだよ……