Unity ECS & C# Job System その2
はじめに
こんにちはこんばんは。繰繰廻(くるくる・めぐる)です。
ドルフロしてたりCEDEC行ってたりしたら、3日坊主どころではない即落ち2コマブログにするところでした。
前回はスクリプトを全く書かずにECSの入り口を叩いたところでした。
前回の記事をご覧になってない方は、先にそちらをどうぞ。
kurukuru-meguru.hatenablog.com
今回は球体を大量にスポーンさせて、それを原点中心に単振動させます。そして、従来通りのMonoBehaviourと比較して高速化されたか確認してみます。
作ったサンプル
上が従来通り、球体のプレハブを100,000個スポーンさせ、MonoBehaviourのスクリプトに、中心点に向かって単振動する動きを書いたものです。
下は同じく100,000個の球体ですが、Entityで球体を作成し、Systemに単振動を書いたものです。
目に見えて動きが早くなっています。
プロファイラで経過時間を確認してみても明らかです。ただこれでも十分な速さとは言えませんが(113.92ms)。
作成
Entityの作成
単振動させるためには、速度と加速度(力)が必要です。
ですので、そのためのコンポーネントを定義して追加します。
手順は以下の通りです。
- 空のスクリプトを作成して、「VelocityComponent.cs」と名づける
- using Unity.Entities;する
- 最初から入っているMonoBehaviour全部消して、IComponentDataを継承した構造体を好きな名前で定義する
- ComponentDataWrapperを使ってファイル名と同じクラスを定義する
- 作成したスクリプトをこれまでの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した状態のインスペクターがこちらになります。
コンポーネントを自作する場合、IComponentDataを継承した構造体を定義します。
純粋なECSであればこれだけで十分コンポーネントとして使うこともできるのですが、現状のUnityには、ECSではMonoBehaiverにAddComponentする形で実装されていた機能たちを使う方法はありません。要するに描画や当たり判定やその他もろもろほとんどのUnityのありがたみを享受できません。
ですので、ComponentDataWrapperを継承したクラスを「ファイル名と同じ名前で」作成する必要があります。
これによって、インスペクターでこれまでのコンポーネントと同じように、ECSのコンポーネントを扱うことができるようになります。
蛇足ですが、純粋なECSはどういうシーンで使えばいいんでしょうね?AIみたいな描画との分離が比較的簡単なところとかでしょうか?
単振動させるSystemの定義
次に、単振動の処理部分を書いていきます。
手順は以下の通りです。
- 空のスクリプトを作成し、ComponentSystemを継承したクラスを定義
- そのクラス(システム)の中で、更新するコンポーネントをまとめた構造体を定義
- そのEntityを抽出する[Inject]なるものを書く
- 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.については何をいまさらなところなので、スルーします。
スポーンするための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を登録します。
そして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を自動で並列処理されるようにします。しかし次っていつだよ……