作って学ぶECS

はじめに

こちらの記事は先日行われた"第四十八回Unityもくもく会"にて発表したものの、詳細解説になります。

ECSって何だろう

  • Entity Component Systemの略
  • Unityに新しく追加される情報処理形態の一つ
  • ECS自体は昔からあるゲーム内オブジェクト処理記述方法の一つ[^1]

GameObject+MonoBehaviourの頃と何が違うのか

実際の仕組みを知りたいな

  • ソースを読む
    • パッケージマネージャからEntitiesを追加
    • ${ProjectFolder}/Library/PackageCache/com.unity.entities@(バージョン)/Unity.Entities 以下にそのままソースが入っています
  • 読んでみてだいたい分かったこと
    • ECSはJobSystem+PlayerLoopSystemの上に構築されている
    • ネイティブプラグインはなし
    • Unity本体の隠し機能などは利用されていない
    • unsafe祭り
    • メモリ確保はNative系

だいたい分かったことの詳細

  • JobSystem+PlayerLoopSystemの上に構築されている
    • 登録されたEntityやComponentに対する処理
      • PlayerLoopSystemでUpdate()の辺りに差し込まれ、毎フレーム自動で実行される
    • 複数のEntityに対する処理
      • JobSystemでUpdate
  • ネイティプラグインはなし
    • パッケージ内は
    • 依存パッケージまでは追わず
  • Unity本体の隠し機能などは利用されていない
    • 見た感じ
  • unsafe祭り
  • メモリ確保はNative系
    • 上記二点のお陰で、非常に読みにくいソースになっている

だいたい分かったことから思いついたこと

  • 自分で実装できるんじゃない?

してみました

https://github.com/fum1h1ro/SimpleECS

Unity2018.3.0b5で作業しましたが、特殊なものはNativeCollectionくらいしか使ってないので、2018.1辺りでも動くはずです。

Screen Shot 2018-10-19 at 12.07.59.png

サンプルとして、100x100=10,000個のCubeをサインカーブで動かしています。

また同等のものを、10,000個のGameObject+MonoBehaviourで実装しており、ボタンを押すことで切り替えることが出来るようになってます。

簡単な解説

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
	void Awake() {
_world = new World();
var archeType = _world.ArcheTypeManager.GetOrCreateArcheType(typeof(PositionData), typeof(RotationData));
var xOrigin = -(Width * 0.5f);
var yOrigin = -(Height * 0.5f);
for (int y = 0; y < Height; ++y) {
for (int x = 0; x < Width; ++x) {
var entity = _world.EntityManager.Create(archeType);
_world.EntityManager.SetComponentData<PositionData>(entity, new PositionData(){ pos = new Vector3(xOrigin+x, 0, yOrigin+y) });
_world.EntityManager.SetComponentData<RotationData>(entity, new RotationData(){ rot = Quaternion.identity });
}
}
_matrices = new Matrix4x4[ObjectCount];
_moveSystem = new MoveSystem(_world.ArcheTypeManager.GetOrCreateArcheType(typeof(PositionData)));
_makeMatrixSystem = new MakeMatrixSystem(archeType);
_makeMatrixSystem.Matrices = _matrices;
_makeMatrixSystem.MatricesSegments = _matricesSegments;

int count = ObjectCount;
while (count > 0) {
var len = Mathf.Min(count, 1023);
var segment = new Matrix4x4[len];
_matricesSegments.Add(segment);
count -= len;
}
}
void OnDestroy() {
_world.Dispose();
}
void Update() {
_moveSystem.Time = Time.realtimeSinceStartup;
_world.Dispatch(_moveSystem);
_world.Dispatch(_makeMatrixSystem);
#if true
foreach (var mtxs in _matricesSegments) {
Graphics.DrawMeshInstanced(_mesh, 0, _material, mtxs, mtxs.Length, null, ShadowCastingMode.Off, false);
}
#else
foreach (var mtxs in _matricesSegments) {
foreach (var mtx in mtxs) {
Graphics.DrawMesh(_mesh, mtx, _material, 0, null, 0, null, ShadowCastingMode.Off, false);
}
}
#endif
}

コメントが皆無で申し訳ないのですが、Awake()にて各種の準備を行って、Update()でEntityの更新を行うようになっています。

Worldクラスは本家と同じで、Entityを束ねる役目を果たしています。クラス名は本家に近いものになっていますが、自動呼び出しなどがないので、自分で更新を呼んでやる必要があります。

MoveSystemで位置情報を更新し、MakeMatrixSystemMatrix4x4を作っています。

描画部分が変な感じのループになっているのは、Graphics.DrawMeshInstanced()が、1023個までしか受け付けてくれないため、10,000個を1023ずつ分割して描画しているためです。[^2]

実装

ソースを見てもらうとわかると思いますが、800行程度の1ファイルに収まっています。

(詳しい説明を書く予定だった)

性能

Screen Shot 2018-10-19 at 18.19.26.png

Screen Shot 2018-10-19 at 18.19.46.png

ざっくりですが、エディタ上で見ると、10,000個のMonoBehaviourよりは早いようです。しかし、これは皆さんご存じの通り、Update()を10000回呼ぶ問題です。

ちなみに、エディタ上でNativeArrayにアクセスする際にはチェックが範囲チェックが入ります。ビルドするとその辺が消えるらしいので、もっと早くなります。

また、ECSの強みはSOAによるキャッシュミスの削減ですので、呼び出し時間も大切ですが、キャッシュミスの削減が実現されているかどうかをチェックする必要があります。

Screen Shot 2018-10-19 at 18.33.12.png

ビルドし、Instruments.appで測ってみます。上記の設定で測れているのかは確信がないのですが、L2キャッシュのリクエストのミスなのであってる?知ってる人がいたら教えてください。

SimpleECSの場合。
Screen Shot 2018-10-19 at 18.36.56.png

MonoBehaviourの場合。
Screen Shot 2018-10-19 at 18.39.31.png

type min max
SimpleECS 21 62
MonoBehaviour 57 89

び、微妙。

微妙ついでにL1D.REPLACEMENTもカウントしてみます。

SimpleECS
Screen Shot 2018-10-19 at 18.44.16.png

MonoBehaviour
Screen Shot 2018-10-19 at 18.44.51.png

type min max
SimpleECS 12 30
MonoBehaviour 37 57

数値は減ってるな。うん。

まとめ

  • ここまで一切触れてきてませんが、JobSystemは使っていません。なので、対応すればもっと早くなるはず。対応も難しくないはず
  • 性能面よりも、コンポーネントを付けたりする際にどういうメモリレイアウトになってるのかを知りたかった
  • そしてそれは果たされたし、自分で同じように実装してみて面倒くささがわかった
  • SharedComponentとか本家にある便利機能は未実装
  • unsafeなコードを書いていると、C++でも書いているんじゃないかという気分になってくる

[^1]: 最古は1998らしい https://en.wikipedia.org/wiki/Entity–component–system
[^3]: 全然関係ないですけど、公式的には"Entity Component System(通称ECS)"って書く決まりでもあるんですかね
[^2]: こういう場合は Graphics.DrawMeshInstancedIndirect()すると良いっぽいですが、そこまでは気力がなかった