はじめに
こんにちは、カヤックSG事業部の王といいます。
この記事はカヤックUnityアドベントカレンダー2018の9日目の記事です。
今日は最近話題のデータ指向プログラミングと、Unity 2018.1からプレビュー版として登場したUnity Entity Component System (略: Unity ECS)について紹介します。
データに集中する
名前の通り、データ指向プログラミング(data-oriented programming,略:DOP)はデータに基づくプログラミングです。このコンセプトがよく知られるようになったのは2009年以降です。現在広く利用されているオブジェクト指向プログラミング(object-oriented programming,略:OOP)に対してどこが違い、そしてどこにメリットがあるのかを紹介します。
現在、パフォーマンスに一番影響されるのはメモリの読み取りスピードです。この数十年CPUの性能が1万倍になるのに対して、メモリの性能は10倍未満しか達成していません。そしてCPUとメモリの差がだんだん増えています。 [参照]
その差を埋めるためCPUに小さいけどすごく速いキャッシュメモリを使っています。
CPUは処理するデータをメモリからCacheに読み取ります。一個ではなく一列としてCacheに読み取ります。処理したあとはCPUは予想される次のデータのアドレスに移動します。もしCacheにデータが見つからないとCache Missが発生してもう一度メモリからデータの読み取りを行います。
また、メモリとして、連続したアドレスに配置してるデータを一気に読むのはバラバラに読むのに比べて遥かに速いです。だから、CacheのHit率を上げて、メモリのデータを読み取るプロセスをできるだけ少なくすることがパーフォマンスの鍵です。その鍵はメモリにおけるデータの並び方になります。
次はOOPとDOPを比べるため、簡単なデモを作ってみます。このデモではスペースを押すと下に向いて移動する1000個スペースシップを作ります(Asset Store)。一番下に移動すると位置を上に更新します。再生すると下の図のように動きます。
オブジェクト指向デザイン
オブジェクト指向プログラミングのコードは以下です。PrefabにMovement.csをつけています。Updateで位置を更新します。
Prefabのスクショ:
public class GameManager : MonoBehaviour { [SerializeField] private GameObject _prefab; private void Start() { AddShip(Const.EnemyIncrement); } private void Update() { // GameObjectを作る if (Input.GetKeyDown("space")) { AddShip(Const.EnemyIncrement); } } private void AddShip(int amount) { for (int i = 0; i < amount; i++) { float xVal = Random.Range(-500f, 500f); float zVal = Random.Range(-300f, 300f); Vector3 pos = new Vector3(xVal, 0f, zVal); Quaternion rot = Quaternion.Euler(0f, 180f, 0f); var obj = Instantiate(_prefab, pos, rot) as GameObject; Movement movement = obj.GetComponent<Movement>(); movement.Speed = Random.Range(10f, 30f); } } }
public class Movement : MonoBehaviour { private bool _isInit = false; private float _speed; public float Speed { get { return _speed; } set { _speed = value; _isInit = true; } } private void Update() { if (!_isInit) { return; } //位置更新 var pos = transform.position; pos += transform.forward * Speed * Time.deltaTime; if (pos.z < Const.BottomBound) { pos.z = Const.TopBound; } transform.position = pos; } }
自分の環境:
- CPU: i7-4790k
- グラフィックボード: Geforce GTX 970 8G
- メモリ: 16GB
15000のGameObjectを生成するUpdateの処理時間とメモリ:
悪くないですが、改善するポイントがあります。
今のメモリレイアウトはこういう様子:
Transformにあるpositionとrotation、クラスメンバーであるSpeedは連続ではなく、そして他のデータも混ざっています。だから、Loopで毎回必ずCache Missが発生します。また、現在どんなGameObjectのデータを処理しているのか分かりづらくて、マルチスレッドの導入は難しいです。
だから、パフォーマンスを上げたいなら、positionとrotationとSpeedを一緒に並べなければなりません。
Entity Component System
Entity Component System(略:ECS)はUnityが現在開発しているDOPに基づいた新しいシステムです。特徴は今まで使っているPrefabではなく、インデックスのようなEntityを代わりに使っていることです。そして、C#のメモリ管理を使わず、NativeArrayを使っています。目的は唯一つ、バラバラのデータを整理して、無駄なデータを捨てることです。
Transformを捨てよう
ECSで使っている'Prefab'のスクショを見てください: 先のPrefabと比べてだいぶ違っているのがわかりますか?Transformが表示されていますが、実際は使っていません。なぜなら一番無駄なデータはTransformだからです。ほしいのはTransformにあるpositionとrotationだけなので相応のComponentを作ります。例としてPositionComponentのコードを見てください。
[Serializable] public struct Position : IComponentData { public float3 Value; }
参照型のclassではなく値型のstructを利用した上で、値だけ定義しています。float3はUnity.Mathmaticsで定義されるstructです。使い方はVector3と同じですが、メソッドは一つもありません。ここではVector3として理解すれば問題ないです。
Positionと同様にRotationとSpeedのComponentを作ればComponentはすべて揃います。そしてそれらを管理するSystemを作ります。
public class SpaceshipSystem : ComponentSystem { // 処理Chunk struct Group { // Unityはchunkの長さを管理するため必ず追加する public readonly int Length; // Componentの配列 public ComponentDataArray<Position> Positions; public ComponentDataArray<Rotation> Rotations; public ComponentDataArray<Speed> Speeds; } // 依存性の注入。指定した構造体でシステム処理するとき必要なComponentGroupを生成する。 [Inject] private Group _group; protected override void OnUpdate() { for (int i = 0; i < _group.Length; i++) { var position = _group.Positions[i]; // 位置計算 position.Value += _group.Speeds[i].Value * Time.deltaTime * math.forward(_group.Rotations[i].Value); if (position.Value.z < Const.BottomBound) { position.Value.z = Const.TopBound; } // 計算終了 _group.Positions[i] = position; } } }
ECSはComponentデータをChunkと呼ばれたバッチにまとめて処理します。ChunkにはComponentデータの配列が保存されます。そしてChunkを処理するモジュールはシステムと呼ばれます(他のDOPの解説ではエンジンとも呼ばれます)。OOPのMonoBehaviorのように、ECSのSystemはComponentSystemを継承しなければならないです。OnUpdateメソッドにはChunkをどうやって処理するのを指定します。こちらはOOPのコードように書けば問題ないですが、必ずリニア方式でChunkのエレメントを一つずつLoopする必要があります。理由を知りたい方はここのブログで確認してください。
ECSのメモリレイアウトは以下の図のようになっています。Positionテータは全部連続配置されています。Rotationデータはすぐ後ろに同じく連続配置されています。無駄なデータがないおかげて、CacheのHit率はすごく上がります。
最後はBootStrapperの実装:
public class BootStrapper : MonoBehaviour { private EntityManager _entityManager; [SerializeField] private GameObject _shipPrefab; void Start() { //Initiate the Entity manager _entityManager = World.Active.GetOrCreateManager<EntityManager>(); AddShip(Const.EnemyIncrement); } void Update() { if (Input.GetKeyDown("space")) { AddShip(Const.EnemyIncrement); } } private void AddShip(int amount) { //Entityのメモリを確保する NativeArray<Entity> entities = new NativeArray<Entity>(amount, Allocator.Temp); _entityManager.Instantiate(_shipPrefab, entities); //EntityのComponentDataの値を与える for (int i = 0; i < amount; i++) { float xVal = Random.Range(-500f, 500f); float zVal = Random.Range(-300f, 300f); _entityManager.SetComponentData(entities[i], new Position {Value = new float3(xVal, 0f, zVal)}); _entityManager.SetComponentData(entities[i], new Rotation {Value = new quaternion(0, 1, 0, 0)}); _entityManager.SetComponentData(entities[i], new Speed {Value = Random.Range(10f, 30f)}); } //メモリの解放を忘れないでください! entities.Dispose(); } }
結果
同じ15000個Entityを生成するとき、処理時間:
Entityは処理時間とメモリ使用両方がOOPより優れます。また、今CPUはどんなデータを処理するのかはっきりわかるのでマルチスレッドやUnity JobSystemを導入するのは簡単です。また、データとメソッドはきちんと分離されるからソフトウェアデザインとしても一つの利点です。
終わりに
この記事では非常に簡単なデモでデータ指向プログラミングとUnity ECSを紹介しました。ECSは新しいシステムでまだ解決していないことが多いですが、高速で進化しています。NativeArrayを導入したから、C++のようにメモリをきちんと管理しなければなりません。でもハイパフォーマンスを実現するために挑戦し甲斐があります。
最後に個人的なおすすめサイトとビデオを紹介します:
明日はNathanielさんの「演出作成時見つけた色々」についての記事です。お楽しみに!