初めまして。開発G所属のクライアントエンジニアです。
今回は「Unity ECS」を軽く触ってみた感想をまとめて行きたいと思います。
今回のプロジェクトはこちらにまとめて上げています。
説明外のことを確認したい場合はこちらをダウンロードしてご確認ください。
https://github.com/10antz-inc/UnityECSSample
目次
開発環境
Unity 2019.2.15f1
ECS(entities package) version 0.1.1
DOTS
Unityはマルチスレッドに対応したハイパフォーマンスなゲーム制作する機能としてDOTS(Data-Oriented Technology Stack) という新しい機能を提供し始めました。
DOTSには
・Entity Component System (ECS)
・C# Job System
・Burst compiler
という機能があります。
今回はECSに関しての記事になります。
「C# Job System」「Burst compiler」この2つに関しては扱いません。
Unity ECSとは
ECS自体はUnity公式で丁寧に纏められたりするので簡単に。
大きな特徴の1つとしてECSは「データ指向」という新しいアーキテクチャになります。
今までの「コンポーネント指向」とはアーキテクチャレベルで考え方が変わる点注意してください。
データ指向では、データのメモリ配置や入出力を中心に設計を行います。
また、メモリ配置を考慮するため基本的にデータの定義はすべて構造体になります。
クラスではないため、継承やポリモーフィズムといったオブジェクト指向的な組み方が必然的にできなくなります。
この面でも今までとは大きく変わった考え方が必要になるのがわかるかと思います。
Unity ECSのメリット
ではなぜ今までの考え方を捨ててまでECSをつかうのでしょうか?
ECSを使う上で一番のメリットとしては、効率的なメモリ配列による速度の向上になります。
上記のようにオブジェクト指向ではクラスが必須と言ってもよいでしょう。
クラスはC#では参照型になるのでインスタンスを生成した際のメモリ配置はバラバラに配置される事になります。
ですが、ECSではクラスではなく構造体で扱います。構造体だと参照型ではないため、メモリの配置は連続的な配置になります。
結果、メモリへのアクセスが高速化し速度が向上します。(厳密にはキャッシュラインの話になります。詳しく知りたい場合はこちらがとても参考になるかと思います)
他にも並列化するのが容易になるとかテストがしやするくなるとかメリットはありますが、自分は速度向上が一番大きいと考えます。
Unity ECSの仕組み
ECSには主に3つの登場人物が出てきます。
・Entity:ゲームに存在する実体を意味する。入れ物。
・ComponentData:Entityに関連付ける(入れる)データ。データのみで処理は持たない。
・System:Dataに対する処理。
ECSに関して調べるとよく出てくるのですが、既存のシステムに無理やり当て込むと以下のようになります。
・Entity = GameObject
・ComponentData = Componentのデータ部分。フィールド
・System=Componentのアップデート
Entityという入れ物にComponentDataを入れて、systemは入っているComponentDataに対して処理を行う。
というイメージになります。
注意点
ようやく実装に関する具体的な内容に入るのですが、その前に1つだけ注意点です。
ECS(entities package)がまだPreviewなので更新されるたび変更が入ったり、削除や追加されたりします。
Unity公式のサンプルもUnity 2019.3じゃないと動作しません。
こちらの内容はあくまでも上記の開発環境の場合に適用される点に注意してください。
サンプルゲーム
今回はサンプルとしてとても簡単なゲームを作成します。
一番下にある青いCubeがプレイヤーです。
上から赤いSphereが落ちてくるので、プレイヤーで赤いSphereにぶつかればScoreが加算。
取れなかったらゲーム終了
というゲームを作成します。
また、スタート画面と終了画面があり
スタート画面 -> Game部分 -> 終了画面
とループするように作成し、Stateの変化にも対応します。
Worldの作成
さてECSにおいて処理はsystemが行うという説明をしました。
ではそのSystemは誰が持ち動作させるのでしょう。
Unity ECSにおいてはWorldというものが担当しています。
EntityとsystemはWorld単位で管理されます。
Worldを用意して、それぞれにEntityとsystemを入れるということも可能です。
Unityでは何もしないとdefaultのWorldが作成され使用することができます。
defaultのWorldを作成させず自分のWorldを作成、使用することもできます。
今回は素直にdefaultのWorldを使用する事にします。
理由としては
・defaultのWorldであればsystemが自動で登録される(つまり独自のworldだと自前でsystemの登録が必要)
・defaultのWorldであれば座標の変換や描画がすでに登録済みになる(つまり独自のworldだと自前でry)
という点が挙げられます。
弾を表示させる
落ちてくる赤いSphereを画面に表示させてみます。
今回のプロジェクトでは今後は赤いSphereを「弾」「bullet」と表記します。
では最初にbulletを表示させるのに必要なComponentDataを考えてみます。
・位置
・スケール
・球のMesh
が必要になるかと思います。
そのためこれらのComponentDataを用意するのですが、
この辺りのよく使用するComponentDataはすでにUnityが用意しているのでそれらを使用します。
ComponentDataの用意ができたので、WorldにEntityを生成しComponentDataを入れます。
1 2 3 4 5 6 7 8 9 10 11 12 |
var world = World.Active; //現在のアクティブなworldを取得。今回の場合はdefaultのworld var manager = world.EntityManager; var entity = manager.CreateEntity(); manager.AddComponent(entity, typeof(Translation)); manager.AddComponent(entity, typeof(Scale)); manager.AddComponent(entity, typeof(LocalToWorld)); manager.AddComponent(entity, typeof(RenderMesh)); manager.SetComponentData(entity, new Scale { Value = 1f} ); manager.SetComponentData(entity, new Translation { Value = new float3(0f, 0f, 0f) } ); manager.SetSharedComponentData(entity, new RenderMesh { mesh = _mesh, material = _material} ); |
このようなソースコードになります。
大事な部分はentityの操作は全てEntityManager経由で行なっているという点です。
entityのソースを確認するとわかるのですが、Entity自身はindexとversionしかデータを持っていません。
そのため、ComponentDataの操作はEntityManager経由でどのentityに対して操作するかを指定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public struct Entity : IEquatable<Entity> { public int Index; public int Version; public static Entity Null { get; } public override bool Equals(object compare); public bool Equals(Entity entity); public override int GetHashCode(); public override string ToString(); public static bool operator ==(Entity lhs, Entity rhs); public static bool operator !=(Entity lhs, Entity rhs); } |
ここで出てきたComponentDataに関して説明すると
・Translation : 位置のデータを持つComponentData
・Scale : 拡大縮小のデータを持つComponentData
・LocalToWorld : ローカル座標からワールド座標へ変換するためのComponentData
・RenderMesh : 表示用のComponentData
となります。
ここでRenderMeshだけデータの設定方法が違う事に注目してください
1 |
manager.SetSharedComponentData(entity, new RenderMesh { mesh = _mesh, material = _material} ); |
他のものはSetComponentData
で設定しているのに対しRenderMeshだけSetSharedComponentData
で設定しています。
1 2 3 4 |
public struct Translation : IComponentData { .... } |
1 2 3 4 |
public struct RenderMesh : ISharedComponentData, IEquatable<RenderMesh> { .... } |
それぞれの定義を確認するとTranslationはIComponentDataを継承しているのに対しRenderMeshはISharedComponentDataを継承しています。
IComponentDataとISharedComponentDataの違いをざっくり説明すると
IComponentDataは各Entityに対しそれぞれComponentDataを持つものに対し、
ISharedComponentDataは同じComponentDataを持つEntity間で共通化され値を共有するようになります。その反面メモリのアクセス効率は悪くなります。
そのため、メッシュやマテリアル、スプライト等の各自で持つ必要のない、共有して使用されるべきAsset類を使用する際に主に使われます。
また、ISharedComponentDataのみ参照型を持つことができる点にも注意してください。
長くなりましたが、ComponentDataが用意できたのであとはSystemを用意するだけなのですが、
今回使用したComponentDataは全てUnityですでに用意されたものです。
そのため、すでにそれらを扱うSystemがworldに登録されています。(defaultの場合)
なので今回は特にsystemを作成せずに実行するだけで球が表示されるかと思います。
弾を移動させる
次に「弾」を移動させます。
先ほどまではUnityが用意したComponentDataを使用していましたが、今回からは独自のComponentDataを作成していきます。
移動させるためには移動量が必要になるので、そのためのComponentDataを用意します。
1 2 3 4 5 6 7 8 9 |
using Unity.Entities; using Unity.Mathematics; namespace ECS.Components { public struct Velocity : IComponentData { public float3 value; } } |
のようになるかと思います。
ではこのComponentDataを付与したEntityを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
var world = World.Active; var manager = world.EntityManager; var archetype = manager.CreateArchetype( typeof(ECS.Components.Velocity), typeof(Scale), typeof(Translation), typeof(RenderMesh), typeof(LocalToWorld) ); var entity = manager.CreateEntity(archetype); manager.SetComponentData(entity, new Scale { Value = 1f} ); manager.SetComponentData(entity, new Translation { Value = new float3(0f, 0f, 0f) } ); manager.SetComponentData(entity, new ECS.Components.Velocity { value = new float3(0f, -1f, 0f) } ); manager.SetSharedComponentData(entity, new RenderMesh { mesh = _mesh, material = _material} ); |
先ほどと少し作成方法が変わっています。
entityを生成する前に
1 |
var archetype = manager.CreateArchetype( |
archetypeというものが 作成されています。
archetypeとはentityに入れる型をまとめたものです。
1 |
manager.CreateEntity(archetype); |
Entity を生成するときにarchetypeを渡すことで同じComponentDataを持ったEntityを作成できます。
ComponentDataを作成できたので次は独自のSystemを作成していきます。
今回の弾を動かすsystemは以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
using UnityEngine; using Unity.Entities; using Unity.Transforms; namespace ECS.Systems { public class MoveObjectSystem : ComponentSystem { protected override void OnUpdate(){ Entities.ForEach(( ref Translation position, ref ECS.Components.Velocity vel )=>{ position.Value += vel.value * Time.deltaTime; }); } } } |
重要なのはこの部分で
1 |
Entities.ForEach(( ref Translation position, ref ECS.Components.Velocity vel )=>{ |
TranslationとVelocityのComponentDataを指定しています。
こうする事によって指定した2つのComponentDataをもつEntityのみがこの処理の対象になります。
あとはこれを実行すれば弾が移動するかと思います。
基本的にはこれの応用で今回のサンプルは作成されています。
まとめ
ECSとはメモリアクセスの効率化で速度向上が見込めるデータ指向のアーキテクチャである。
Entity,ComponentData,Systemという3つの登場人物がいる
EntityにComponentDataを入れていき、Systemは入っているComponentDataから処理をする対象を選択する
ComponentDataには複数種類があり、状況によって使い分ける必要がある
感想
今までのオブジェクト指向とは全く別の考えで組む必要があるので最初はとても戸惑いました。ただし慣れてくるとこれはこれで綺麗なんじゃないかと考えられるようになってきた部分もあります。。
ただ、ECSのサンプルがまだ少ないので自分の組み方が正しいECSの組み方なのか不安になる時が多々あります。
GameのState等のState管理はどのように組むか結構考えないといけない。また、State切り替え時にState独自のEntityが残らないように削除するような機能が必要になる。
いっそState別にWorldを用意した方がいいのかもしれません。
まだECSだけだとDOTSの効果を十全には発揮できないので、今後はこれをjob SystemやBurst compilerに対応していければと考えています。