カヤックと自分との関わり
この記事はex-KAYAC Advent Calendar 2018の22日目の記事です。
ということで、少しだけカヤックについての話を。
自分はカヤックへはWebのフロントエンドエンジニアとして入社。
その後は4年ほどWebのフロントエンドエンジニアとして働き、当時は「HTMLファイ部」という部署のリーダーをしていまいた。
そしてある日突然、「iOSやってくんない?」というオーダーを受け、Lobiというゲーム向けのSNSサービスのiOS版アプリの開発に携わることに。
それまでiOSを触ったことがなかったので1ヶ月間、ひたすらiOSのドキュメントや記事を読み漁り、それをひたすらQiitaにまとめる毎日でした。
なので自分の記事リストを見るとiOS/Objective-C要素がだいぶ多めになっていますw
さらにその後、Oculus Rift DK1が登場しカヤック内でそれを持っている人がいたため体験することに。このときにVRに一気に惚れ込み、個人的にVR制作を始めました。
そしていよいよVRメインでやりたいと思った折に、コロプラにいた友人からVRエンジニアの募集を強化していることを聞いて今に至る、という感じです。
概要
ということで本題へ。
前回書いたECS入門の続編です。
前の記事ではECSを利用して画面にオブジェクトを描画するまでを書きました。
今回はもう少し踏み込んで、自分でカスタムのシステムを作って利用する流れを書きたいと思います。
このあたりがしっかりと身につけば、あとは応用で色々なシステムが作れるようになると思います。
今回の記事を書くにあたっては、のたぐすさんの以下の記事を参考にさせていただきました。
なお、今回の記事で書いているコードは前回の記事にも掲載したGithubに追記という形で公開しています。
大まかな考え方
まず必要な考え方は「データ指向設計」です。
データ指向については前回の記事で少しだけ書いたので詳細はそちらをご覧ください。
前回書いた部分を抜粋すると、
なぜデータに着目?
オブジェクト指向は人間がイメージしやすい形でプログラムを書いていくことができるので理解しやすい部類でしょう。(色々解釈などによる議論とかは見ますが)
しかし、コンピュータは「オブジェクト」という概念で物を見るのではなく、あくまでバイナリ表現されたデータを、それがなにかを考えずに黙々と処理していきます。
つまり、オブジェクトごとにまとめられたデータというのは、コンピュータからはまったく関係ない・・・どころか、作業効率の邪魔になり得ます。
実際の例で考えてみると、例えばこう考えてみてください。 あなたは自動販売機に飲み物を補充している店員だとします。
そして補充用の箱には「キリン」とか「サントリー」とか「明治」とかメーカーごとに商品が分けられて入れられています。
しかし、補充する側としては「今補充したい飲み物」だけを詰めてくれた箱があったほうが効率がいいですよね。 例えばコーヒーを補充しているのに、水やら別のものが箱に入っているとより分けて補充しなければなりません。
データ指向はまさにこの「補充したい飲み物だけ」を提供する形にデータを整形して処理するもの、と考えることができます。 こうすることによってCPUが効率よく処理することができるようになるというわけなんですね。
大雑把に言えば、CPUが効率よく処理するためにデータの構造を最適化する設計、という感じです。
そのためComponentSystem
を理解する上で、この「データ指向」の考え方は重要となります。
「ComponentSystem」は2つある
ECSを利用するにあたって「ComponentSystem」がデータの処理を行います。
前回の記事では描画周りについて既存のシステムを利用してレンダリングしていました。
そして当然ですが、この「システム」は自作することができます。
必要な手順に沿ってクラスを実装することで自作したデータを利用した処理系を作ることができます。
ComponentSystemとJobComponentSystem
ふたつあるシステムとは「ComponentSystem」と「JobComponentSystem」のふたつです。
役割としてはどちらも同様ですが、JobComponentSystemはUnityが実装を進めている「JobSystem」を利用するところが異なります。
JobSystemについても後日記事を書きたいと思っていますが、ものすごくざっくりと言うと「Unityの持っているスレッドの空き時間に、ユーザのスクリプト実行を差し込める」というものです。
要は、Unityの処理の中で余らせている時間を有効に使おう、という趣旨の仕組みです。
JobSystemについてはテラシュールブログさんの以下の記事が詳しく書かれています。
ComponentSystemを自作する勘所
さて、ふたつあるComponentSystemですが、作る際の勘所というか、どう理解していったらいいかは「データの構造の定義と利用」というイメージです。
以下から、実際のコードを例にしながら解説していきます。
データの構造を決める
冒頭で書いたように、ECSは「データ指向設計」になっているので、なにはなくとも「データの構造」を定義するところから始めます。
そして「定義した構造別にシステムを作っていく」イメージです。
データの構造のサンプルを見てみましょう。(データの構造自体はのたぐすさんの記事を参考にしたものになっています)
using System.Collections; using System.Collections.Generic; using UnityEngine; using Unity.Entities; using Unity.Mathematics; // 速度を表すデータ public struct Velocity : IComponentData { public float3 Value; public Velocity(float3 value) { Value = value; } }
なんのことはない、速度データを表す構造体です。
IComponentData
を実装した構造体を作るとComponentSystemで利用することができるようになります。
速度なので、位置の更新が必要となります。
ということで、システムが要求するデータの構造定義を以下のように行います。
Entityとデータ構造を結びつける
システムの要求する構造の前に、Entityと構造の定義を先に解説します。
Entityとシステムを結びつけるのは「データの構造」です。
そのためにはEntityがどんなデータ構造を持っているかの定義が必要です。
定義は以下のように行います。
// ワールドに所属する「EntityManager」を作成する EntityManager manager = World.Active.CreateManager<EntityManager>(); // ... 中略 ... // 「データの構造(Archetype = データアーキテクチャ)」を定義する EntityArchetype archetype = manager.CreateArchetype( typeof(Velocity), typeof(Position), typeof(MeshInstanceRenderer) );
CreateArchetype
メソッドによってデータ構造(データアーキテクチャ)を定義しています。
Entityの生成にはこのEntityArchetype
オブジェクトを引数に取るため、そこで構造とEntityが結び付けられます。
生成は以下のようになります。
Entity entity = manager.CreateEntity(archetype);
これで、定義したデータ構造を持ったEntityがひとつ、ワールドに生成されました。
システムが要求する「グループ」を定義する
次にEntityとシステムを結びつける定義を行います。
なお、この結びつけはComponentSystemとJobComponentSystemで若干異なります。
が、基本的には「データ構造を結びつける」という考え方はどちらも同じです。
まずはComponentSystemのほうから見てみましょう。
using System.Collections; using System.Collections.Generic; using UnityEngine; using Unity.Entities; using Unity.Transforms; using Unity.Rendering; using Unity.Collections; // ComponentSystemが要求するデータ構造を定義する public struct VelocityGroup { // ここでは「Position」と「Velocity」の2つのデータを要求することを定義している public ComponentDataArray<Position> Position; public ComponentDataArray<Velocity> Velocity; // 速度更新のシステムでは利用しないが、 // SharedComponentを要求する場合は`ReadOnly`属性をつける必要がある // 例) // [ReadOnly] // public SharedComponentDataArray<MeshInstanceRenderer> Renderer; // Entityの数を示すLengthフィールドを定義 public readonly int Length; }
まず、冒頭でVelocityGroup
という構造体を定義しています。
その定義の内容はシステムが利用するために必要なComponentDataArray
です。
具体的にはPosition
とVelocity
を要求する形になっています。
そして最後に、該当するデータ構造を持ったEntityの数を示すLength
フィールドを定義しています。
ただ、インターフェースもなにも実装していないしなにを持ってシステムと結びつけるのかと疑問に持たれる人もいるかもしれません。
紐づけに関しては、システムの定義側でInject
することで解決しています。
システムとグループは「Inject」によって紐付ける
システムのコードを見てみましょう。
// ... VelocityGroupの定義 public class VelocitySystem : ComponentSystem { [Inject] private VelocityGroup _velocityGroup; protected override void OnUpdate() { float deltaTime = Time.deltaTime; for (int i = 0; i < _velocityGroup.Length; i++) { Position pos = _velocityGroup.Position[i]; pos.Value += _velocityGroup.Velocity[i].Value * deltaTime; _velocityGroup.Position[i] = pos; } } }
意外にシンプルですね。
まずComponentSystem
を継承し、必要なメソッドをオーバーライドします。
上で定義したグループとの紐づけですが、[Inject]
アトリビュートを使って注入しています。
こうすることでシステムが要求するデータ構造が決まり、該当するEntityがワールドに存在している場合にシステムが起動され、処理が実行される、という仕組みになっています。
ComponentTypes
ここは余談になりますが、ComponentTypes
というものが存在します。
これはなにかというと、Entityのデータ構造を認識するためのものです。
このあたりについては以下の記事がとても詳細にまとめてくれているので、興味がある方は見てみるといいでしょう。
そこから引用させてもらうと以下のように記載されています。
ComponentGroupはComponentTypes(ComponentDataの型に応じたint型の識別用タグ)の配列を持ち、初期化後変更されることはないです。
つまり、ComponentGroup
というフィルターの役割をするものがあり、そこで利用されるのがComponentTypes
ということです。
そして初期化時にComponentData
を識別するタグの配列を持ち、以降変更されることがありません。
この情報を元に、該当するデータを持つEntityを決定している、というわけなんですね。
ワールドを生成し、システムを登録する
最後はWorld
を生成し、その中で必要なシステムを登録します。
こちらもコードを先に見てみましょう。
using System.Collections; using System.Collections.Generic; using UnityEngine; using Unity.Entities; using Unity.Rendering; using Unity.Transforms; using Unity.Mathematics; public class VelocityWorld : MonoBehaviour { [SerializeField] private Mesh _mesh; [SerializeField] private Material _material; [SerializeField] private bool _useJobSystem = false; private void Start() { World.DisposeAllWorlds(); World.Active = new World("VelocityWorld"); EntityManager manager = World.Active.CreateManager<EntityManager>(); World.Active.CreateManager<EndFrameTransformSystem>(); World.Active.CreateManager<RenderingSystemBootstrap>(); if (_useJobSystem) { World.Active.CreateManager<VelocityJobSystem>(); } else { World.Active.CreateManager<VelocitySystem>(); } EntityArchetype archetype = manager.CreateArchetype( typeof(Velocity), typeof(Position), typeof(MeshInstanceRenderer) ); Entity entity = manager.CreateEntity(archetype); manager.SetSharedComponentData(entity, new MeshInstanceRenderer { mesh = _mesh, material = _material, }); manager.SetComponentData(entity, new Velocity { Value = new float3(0, 1f, 0) }); manager.SetComponentData(entity, new Position { Value = new float3(0, 0, 0) }); manager.Instantiate(entity); ScriptBehaviourUpdateOrder.UpdatePlayerLoop(World.Active); } }
JobComponentSystem版も含まれているので分岐が入っていますが、そのシステムの登録部分以外はまったく同じなのが分かるかと思います。
冒頭でワールドの作成と、必要なシステムの登録を行っています。
そして最後の部分でEntityのデータ構造を定義し、その定義を元にEntityを生成しているわけです。
これを実行すると以下のように、少しずつ上に上昇するCubeが表示されます。
速度を上方向に設定しているのでそちらの方向に動いているわけですね。
JobComponentSystemの作成
さて、一歩戻ってJobComponentSystem
版を見てみましょう。
using System.Collections; using System.Collections.Generic; using UnityEngine; using Unity.Entities; using Unity.Jobs; using Unity.Burst; using Unity.Transforms; public class VelocityJobSystem : JobComponentSystem { [BurstCompile] struct Job : IJobProcessComponentData<Velocity, Position> { readonly float _deltaTime; public Job(float deltaTime) { _deltaTime = deltaTime; } public void Execute(ref Velocity velocity, ref Position position) { position.Value += velocity.Value * _deltaTime; } } protected override JobHandle OnUpdate(JobHandle inputDeps) { Job job = new Job(Time.deltaTime); return job.Schedule(this, inputDeps); } }
こちらもシンプルですね。
ComponentSystem
版との違いを見ていきましょう。
IJobProcessComponentDataを定義に用いる
ComponentSystem
ではGroup
の構造体を定義し、それを[Inject]
アトリビュートによって構造とシステムとの紐づけを行っていました。
しかし、JobComponentSystem
ではIJobProcessComponentData
を利用して紐づけを行います。
IJobProcessComponentData
はインターフェースになっていて、これを実装したJob
という単位を作成します。
JobSystem
を利用するわけなので、Job
で処理をする形になります。
また注意点として、Job
は別スレッドで実行されるためTime.deltaTime
が利用できません。
そのため、Job
のコンストラクタで値を設定し、それを利用して位置の更新を行っています。
あとは、MeshInstanceRendererSystem
などのシステムがPosition
データなどを用いてレンダリングを行ってくれるためそれ以外の処理は必要ありません。
ECSは比較的データを少なくし、処理単位もミニマムにしていくのが適しているのでひとつひとつのシステムはとてもシンプルになりますね。
IJobProcessComponentDataの拡張メソッド
さて、唐突にJob構造体のSchedule
というメソッドを実行していますが、これは拡張メソッドとして定義されいます。
そのため、IJobProcessComponentData
インターフェースを実装すると自動的に拡張されるようになっています。
定義を見てみると以下のように、いくつかの拡張メソッドが定義されているのが分かります。
namespace Unity.Entities { public static class JobProcessComponentDataExtensions { public static ComponentGroup GetComponentGroupForIJobProcessComponentData(this ComponentSystemBase system, Type jobType); public static void PrepareComponentGroup<T>(this T jobData, ComponentSystemBase system) where T : struct, IBaseJobProcessComponentData; public static void Run<T>(this T jobData, ComponentSystemBase system) where T : struct, IBaseJobProcessComponentData; public static JobHandle Schedule<T>(this T jobData, ComponentSystemBase system, JobHandle dependsOn = default(JobHandle)) where T : struct, IBaseJobProcessComponentData; public static JobHandle ScheduleSingle<T>(this T jobData, ComponentSystemBase system, JobHandle dependsOn = default(JobHandle)) where T : struct, IBaseJobProcessComponentData; } }
実は、インターフェースに拡張メソッドが定義できるの知らなかったので驚きでした。
意外と色々なところで使えそうなテクニックなので使っていこうと思います。
まとめ
ECSのシステム生成について見てきました。
データを定義し、小さく分割したシステムを生成し、それらを連携させて処理をしていく、というのが感じ取れたのではないでしょうか。
既存のGameObject
とコンポーネントを用いた設計とはかなり違いがあるため最初は色々戸惑いそうですが、物量が必要なゲームの場合は必須となる機能なのは間違いないと思います。
まだプレビュー版なので色々とドラスティックに仕様が変わったりしていますが、今後も情報を追っていって、正式リリースした暁にはすぐに使い始められるようにしておきたい機能ですね。