e.blog

主にUnity/UE周りのことについてまとめていきます

ECSでComponentSystemを自作する

カヤックと自分との関わり

この記事はex-KAYAC Advent Calendar 2018の22日目の記事です。
ということで、少しだけカヤックについての話を。

自分はカヤックへはWebのフロントエンドエンジニアとして入社。
その後は4年ほどWebのフロントエンドエンジニアとして働き、当時は「HTMLファイ部」という部署のリーダーをしていまいた。

そしてある日突然、「iOSやってくんない?」というオーダーを受け、Lobiというゲーム向けのSNSサービスのiOS版アプリの開発に携わることに。

それまでiOSを触ったことがなかったので1ヶ月間、ひたすらiOSのドキュメントや記事を読み漁り、それをひたすらQiitaにまとめる毎日でした。
なので自分の記事リストを見るとiOSObjective-C要素がだいぶ多めになっていますw

さらにその後、Oculus Rift DK1が登場しカヤック内でそれを持っている人がいたため体験することに。このときにVRに一気に惚れ込み、個人的にVR制作を始めました。

そしていよいよVRメインでやりたいと思った折に、コロプラにいた友人からVRエンジニアの募集を強化していることを聞いて今に至る、という感じです。

概要

ということで本題へ。

前回書いたECS入門の続編です。
前の記事ではECSを利用して画面にオブジェクトを描画するまでを書きました。

edom18.hateblo.jp

今回はもう少し踏み込んで、自分でカスタムのシステムを作って利用する流れを書きたいと思います。
このあたりがしっかりと身につけば、あとは応用で色々なシステムが作れるようになると思います。

今回の記事を書くにあたっては、のたぐすさんの以下の記事を参考にさせていただきました。

notargs.hateblo.jp

なお、今回の記事で書いているコードは前回の記事にも掲載したGithubに追記という形で公開しています。

github.com

大まかな考え方

まず必要な考え方は「データ指向設計」です。
データ指向については前回の記事で少しだけ書いたので詳細はそちらをご覧ください。

前回書いた部分を抜粋すると、

なぜデータに着目?

オブジェクト指向は人間がイメージしやすい形でプログラムを書いていくことができるので理解しやすい部類でしょう。(色々解釈などによる議論とかは見ますが)


しかし、コンピュータは「オブジェクト」という概念で物を見るのではなく、あくまでバイナリ表現されたデータを、それがなにかを考えずに黙々と処理していきます。


つまり、オブジェクトごとにまとめられたデータというのは、コンピュータからはまったく関係ない・・・どころか、作業効率の邪魔になり得ます。


実際の例で考えてみると、例えばこう考えてみてください。 あなたは自動販売機に飲み物を補充している店員だとします。


そして補充用の箱には「キリン」とか「サントリー」とか「明治」とかメーカーごとに商品が分けられて入れられています。


しかし、補充する側としては「今補充したい飲み物」だけを詰めてくれた箱があったほうが効率がいいですよね。 例えばコーヒーを補充しているのに、水やら別のものが箱に入っているとより分けて補充しなければなりません。


データ指向はまさにこの「補充したい飲み物だけ」を提供する形にデータを整形して処理するもの、と考えることができます。 こうすることによってCPUが効率よく処理することができるようになるというわけなんですね。

大雑把に言えば、CPUが効率よく処理するためにデータの構造を最適化する設計、という感じです。
そのためComponentSystemを理解する上で、この「データ指向」の考え方は重要となります。

「ComponentSystem」は2つある

ECSを利用するにあたって「ComponentSystem」がデータの処理を行います。
前回の記事では描画周りについて既存のシステムを利用してレンダリングしていました。

そして当然ですが、この「システム」は自作することができます。
必要な手順に沿ってクラスを実装することで自作したデータを利用した処理系を作ることができます。

ComponentSystemとJobComponentSystem

ふたつあるシステムとは「ComponentSystem」と「JobComponentSystem」のふたつです。

役割としてはどちらも同様ですが、JobComponentSystemはUnityが実装を進めている「JobSystem」を利用するところが異なります。

JobSystemについても後日記事を書きたいと思っていますが、ものすごくざっくりと言うと「Unityの持っているスレッドの空き時間に、ユーザのスクリプト実行を差し込める」というものです。

要は、Unityの処理の中で余らせている時間を有効に使おう、という趣旨の仕組みです。

JobSystemについてはテラシュールブログさんの以下の記事が詳しく書かれています。

tsubakit1.hateblo.jp

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です。

具体的にはPositionVelocityを要求する形になっています。
そして最後に、該当するデータ構造を持った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のデータ構造を認識するためのものです。

このあたりについては以下の記事がとても詳細にまとめてくれているので、興味がある方は見てみるといいでしょう。

qiita.com

そこから引用させてもらうと以下のように記載されています。

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が表示されます。

f:id:edo_m18:20181222132744g:plain

速度を上方向に設定しているのでそちらの方向に動いているわけですね。

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コンポーネントを用いた設計とはかなり違いがあるため最初は色々戸惑いそうですが、物量が必要なゲームの場合は必須となる機能なのは間違いないと思います。

まだプレビュー版なので色々とドラスティックに仕様が変わったりしていますが、今後も情報を追っていって、正式リリースした暁にはすぐに使い始められるようにしておきたい機能ですね。