e.blog

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

ECSをそろそろ触ってみる ~ECS入門 シーンにオブジェクトを描画編~

概要

この記事は、Unity Advent Calendar 16日目の記事です。

TwitterのTLを見ているとECSを触ってなにかした、みたいのがよく流れてきていて流石にそろそろ触っておきたいなーと思ったので簡単にまとめてみました。
(Advent Calendarのネタがないから急遽手を出したわけじゃありません)

今回は「シーンにオブジェクトを描画編」ということで、シンプルに画面にオブジェクトを描画するところまでを書きたいと思います。
いきなり色々やってしまうと内容がブレてしまうかなと思ったので。

今回の内容についてはGithubにアップしているので、動作するものを見たい方はダウンロードして見てみてください。

github.com

ECSとは

まずは簡単に。

ECSとは、Unityが新しく導入を進めている新しいシステムです。
ちなみに「Entity Component System」の頭文字を取って「ECS」です。

ものすごく乱暴な言い方をすれば、処理負荷の高い既存のGameObjectの仕組みをイチから見直して高速化を達成するための仕組み、というような感じです。

詳細については以下のテラシュールブログさんの記事を見るといいと思います。

tsubakit1.hateblo.jp

少しだけ言葉を引用させていただくと、

コンポーネント志向に変わる新しいアーキテクチャパターンです。

と書かれています。
これは、今のコンポーネント指向、オブジェクト指向とは異なる「データ指向」のアーキテクチャを意味しています。

データ指向については以下のCygamesの記事が詳細に解説されています。

データ指向設計 | Cygames Engineers' Blog

データ指向設計とは

少しだけデータ指向設計について。
なぜこれを書くかというと、ECSを利用・理解する上でこの知識は必須となりそうだなと感じたからです。
まぁそもそも、この思想をベースにしているので当たり前と言えば当たり前ですが。

データ指向設計とは、今までのオブジェクト指向などと同じように「設計パターン」です。
そして着目するのが「データ」である、というのが大きな特徴です。

オブジェクト指向はデータではなく、あくまで表現したい「オブジェクト」を主体に考えます。
そしてそれに紐づくデータと振る舞いをセットにして表現します。

一方、データ指向では「コンピュータが扱いやすい形としてのデータ」に着目し、それを元に設計、実装していくパターンです。

なぜデータに着目?

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

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

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


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

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

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


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

キャッシュミスを減らして効率化

なぜ効率が良くなるかというと、CPUには以下に示すようにいくつかのキャッシュシステムを備えています。
そしてそのキャッシュには一定の塊(キャッシュライン)でデータが読み込まれそれを利用します。
その読み込まれた塊のデータの中に、次に計算するデータが含まれている場合は計算をすばやく行うことができます。
(これをキャッシュヒットと言います)

しかしもしその中に目的のデータがなかった場合は、改めてメモリからキャッシュにデータを読み込む必要があります。
(これをキャッシュミスと言います)

つまり、前述のように「必要なデータを揃えておく」ことによってこの「キャッシュヒット」を期待することができ、逆にそうでない場合は頻繁に「キャッシュミス」が発生することになってしまいます。

ちなみにキャッシュミスによってどれくらい差が出るかですが、CPUとメモリの関係について見てみましょう。

ここでのメモリは「大容量メモリ」、つまり一般的に言われているメモリです。(最近だと16GBとか、32GBとか積んでるあれです)
しかしCPUからは「とても遠い」存在なんですね。

そのため、CPUが扱いやすい距離にある位置にも少量のメモリが置かれます。
これをキャッシュと呼び、L1キャッシュL2キャッシュなどと呼びます。(場合によってはL3キャッシュもあるものも)

さて、どれくらい距離に差があるかと言うと。
Cygamesの記事から引用させていただくと以下の表のようになります。

種類 サイズ レイテンシ
L1キャッシュ 32KB - 128KB 3 - 4サイクル
L2キャッシュ 4MB - 20MB 20 - 40サイクル
メインメモリ 4GB - 32GB 200サイクル

サイクルとは、大雑把に言うとCPUがひとつの命令を実行する単位。

これを見てもらえば、どれくらい「距離に差がある」のかがイメージできるかと思います。
メインメモリへのアクセスに要する時間の間に、200命令くらいが実行可能、ということなんですね。

イメージ的にはL1キャッシュが机の上、L2キャッシュが部屋の本棚、メインメモリが近くの本屋、くらいの差です。
そりゃ処理が遅くなるわけですよね。

こうした、実際の内部構造的にどういう感じで最適化されるのか、なぜそれが必要なのかは前述のCygamesの記事を読んでみてください。

登場人物

ECSは新しい概念となるため、既存のUnityの仕組みをひとつひとつ置き換えながら、というのはむずかしそうです。
今回ははまずセットアップから始めて、シーンにメッシュを表示する流れまでを書きたいと思います。

その中で登場するいくつかの機能を紹介しておきましょう。

名称 意味
Entity エンティティ。GameObjectに変わる「単位」を表す
ComponentData Entityに格納するデータ
ComponentSystem 実際の処理
Group ComponentSystemが要求するComponentDataのグループ

ちなみにこれらの、既存の仕組みとの紐付けですが、上のテラシュールブログさんの記事から引用させていただくと以下のようになるみたいです。

この用語ですが、UnityのGameObject / Componentを差し替えるものだけあって、少し近いところがあります。物凄い大雑把に言ってしまえば、以下のモノと一致します。

ComponentSystemUpdateメソッドに該当するんですね。
ちなみに、ComponentSystemはいくつかのシステムを作ることができ、それぞれ目的のデータ構造を定義してそれを効率よく計算させることができます。

最後のGroupはまさにこの「システム」に渡すためのデータ構造をグループ化したものとなります。
これは前述の通り、新しいアーキテクチャである「データ指向」に依るものなので既存の仕組みにはない概念となっています。

導入方法

さて、まずは導入方法から。
ECSはまだまだ実験段階のため、正式には導入されていません。
そのため、導入するためには「Package Manager」から明示的にインストールする必要があります。

メインメニューから、「Window -> Package Manager」を開くと以下のようなウィンドウが表示されます。

f:id:edo_m18:20181213150808p:plain

そして「All」タブの中から「Entities」を選択してインストールします。

またECSは、.NET4.xを必要とするため「Player Settings」の「Scripting Runtime Version」を「.NET 4.x Equivalent」に変更します。

f:id:edo_m18:20181215011124p:plain

これで準備が整いました。
あとは通常通りコードを書いていくことができます。

ECS Hello World

さぁ、ここからが「Hello World」です。
そして事実、ECSでは「World」というクラスがあり、これがシステム全体を管理する役割を担っています。

ということで、ものすごくざっくりと、画面にメッシュがひとつだけ描画されるサンプルコードを見てみましょう。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
using Unity.Transforms;
using Unity.Rendering;

public class SimpleECSTest : MonoBehaviour
{
    [SerializeField] private Mesh _mesh;
    [SerializeField] private Material _material;

    private void Start()
    {
        World world = World.Active;
        EntityManager entityManager = world.GetOrCreateManager<EntityManager>();

        // Prefabを作成
        Entity prefab = entityManager.CreateEntity(
            ComponentType.Create<Position>(), // 位置
            ComponentType.Create<Prefab>() // Prefab(これがついているEntityはSystemから無視される)
        );

        // 描画用のComponentを追加
        entityManager.AddSharedComponentData(prefab, new MeshInstanceRenderer
        {
            castShadows = UnityEngine.Rendering.ShadowCastingMode.On,
            receiveShadows = true,
            material = _material,
            mesh = _mesh
        });

        // Prefabをインスタンス化
        entityManager.Instantiate(prefab);
    }
}

とてもシンプルなコードですね。これを実行すると以下のようにシーンにひとつだけオブジェクトが表示されます。

f:id:edo_m18:20181215011846p:plain

MeshMaterialはインスペクタから適当なものをセットしています)

コードはそこまで多くないので、ひとつひとつ順を追って見ていきましょう。

全体を管理する「World」クラス

冒頭ではWorldクラスを変数に格納しています。

World world = World.Active;

World.Activeにはデフォルトで生成されたワールドが保持されています。
しかしこれは、後述するように、デフォルトワールドの生成を抑止することもできます。
意図しないシステムが可動して無用な負荷を生まないためにも、実際にはオフにすることが多くなると思われます。

しかしまずは、必要最低限のもので描画まで実現するためにサンプルではデフォルトワールドはオンで説明します。

エンティティを生成する準備

デフォルトワールドはWorld.Activeに格納されています。
が、これは上書きすることができるので、現在メインで作業中の、くらいの感じで捉えておくといいかと思います。

ワールドが取得(あるいは生成)できたら、以下のフローによってエンティティ(ECSの単位)を生成します。

生成フロー

  1. EntityManagerを生成(or 取得)する
  2. アーキタイプEntityArchetype)を定義する
  3. アーキタイプを元にエンティティ(Entity)を生成する
  4. エンティティにコンポーネントデータを設定する
  5. セットアップが終わったエンティティをインスタンス化する

このフローを踏むことによって、無事にシーンにエンティティが表示されるようになります。

さて、上記フローが一体なにをしているのかというと。
アーキタイプの定義によって、エンティティに必要なデータ構造を定義します。

データ指向のための「設計」

「データ指向」がベースなので、まさにこのデータの設計をしているわけですね。(データのアーキテクチャ

そしてデータの設計が済んだら、それをベースにエンティティを生成します。
オブジェクト指向で言うとクラスを定義、に近いイメージでしょうか。
アーキタイプの設計は、オブジェクト指向だとコードを書く前の設計段階に近いかもしれません)

エンティティが生成できたら、次に行うのはそのエンティティの初期値の設定です。
設定はEntityManagerのメソッドを通して設定していきます。
セット用メソッドの第一引数に、生成したエンティティを渡してセットアップしていきます。
(このあたりはC言語っぽいイメージがありますね)

設計を元にしたインスタンス

そして初期値の設定が終わったらいよいよインスタンス化です。
これもまたEntityManagerInstantiateメソッドを利用してインスタンス化します。
こうすることで晴れて、エンティティがワールドに誕生することになります。

ECSを利用してエンティティを画面に表示するだけなら以上です。
意外と拍子抜けするほど簡単ですね。

ただ実は大事な概念がまだ説明されていません。
それが「ComponentSystem」です。

しかしこれは、デフォルトワールドではすでにセットアップ済みで、エンティティの設定とインスタンス化をするだけでワールドに誕生させることができます。

とはいえこれではECSを使いこなすことはむずかしいです。
ということで、次は「ComponentSystem」について解説します。

自前Worldの生成とComponentSystem

さて、最後に書くのは「自前World」の作成と「ComponentSystem」についてです。
前述のように、なにもしないとデフォルトワールドが生成された状態になります。

そしてそのワールドには、現在定義されているすべてのシステムがすでに登録済の状態となっています。
ただ、指摘したように基本はオフにするのが通常のフローになるかなーと思っています。

なので実践で使う場合はワールドを自作し、必要なシステムを自分でセットアップする必要があります。

ということで、ワールドの生成と(描画だけを行う最低限の)システムのセットアップについて、コード断片を載せます。(コード見たほうがイメージ湧きやすいかなと思うので)

必要なシステムの登録(生成)

では自作ワールドを作成して描画に必要なシステムの登録をするところから見てみましょう。

// ひとまず、デフォルトのワールドを削除する
// 
// ※ Player SettingsのDefine Symbolsに
// 「UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP」を指定しても
// デフォルトワールドの生成を抑えられる
World.DisposeAllWorlds();

_gameWorld = new World("GameWorld");

// あとからアクセスしやすいように、World.Activeにも設定しておく。
// ※ しなくても大丈夫
World.Active = _gameWorld;

// デフォルトで用意されているTransformSystem
_gameWorld.CreateManager<EndFrameTransformSystem>();

// デフォルトで用意されている描画を行うためのシステム「MeshInstanceRendererSystem」の補助クラス
_gameWorld.CreateManager<RenderingSystemBootstrap>();

// EndFrameTransformSystemなどを先に生成すると、自動的にEntityManagerが生成されるので、GetOrCreateで取得する
EntityManager entityManager = _gameWorld.GetOrCreateManager<EntityManager>();

描画に必要なものとして以下の2つのシステムを登録しています。

  • EndFrameTransformSystem
  • RenderingSystemBootstrap

なお、RenderingSystemBootstrapは描画のシステムであるMeshInstanceRendererSystemの補助クラスとなっているようです。

そして最後に、エンティティ周りを管理するEntityManagerを「取得」しています。

これだけGetOrCreateしているのには訳があります。
実はEndFrameTransformSystemを登録すると内部で自動的にEntityManagerが登録されるようです。

おそらくですが、内部でエンティティ周りの設定などを行うシステムの場合は自動的に生成されるのではないかと思います。
そのため、Createしてしまうと「すでに登録済み」というエラーが発生してしまいます。

エラー内容↓

ArgumentException: An item with the same key has already been added. Key: Unity.Entities.EntityManager

エンティティを作成する

システムの設定が完了したら、前段で取得したEntityManagerを利用してエンティティのセットアップ、生成を行います。
こちらもまずはコードを見てもらうのが早いでしょう。

EntityArchetype archetype = entityManager.CreateArchetype(
    // ComponentType.Create<LocalToWorld>(), // Positionなどがあるとデフォルトシステムなら自動で追加してくれる
    ComponentType.Create<Position>(),
    ComponentType.Create<Rotation>(),
    ComponentType.Create<MeshInstanceRenderer>()
);

// アーキタイプを元にエンティティを生成する
Entity entity = entityManager.CreateEntity(archetype);

// Rendererを設定
entityManager.SetSharedComponentData(entity, new MeshInstanceRenderer
{
    mesh = _mesh,
    material = _material
});

// Transform関連の情報を設定
entityManager.SetComponentData(entity, new Position
{
    Value = new float3(0, 0, 0)
});

entityManager.SetComponentData(entity, new Rotation
{
    Value = Quaternion.Euler(0f, 180f, 0f)
});

// インスタンス化
entityManager.Instantiate(entity);

コード的には、デフォルトワールドを利用してエンティティを表示したときとほとんど変わりがありませんね。
そして最後でエンティティをインスタンス化しています。

ワールドを有効化する

さあ、これですべてセットアップが終わりました。
が、実はこれだけだとワールドは動作しません。生成しただけでは動かないんですね。

ということで、動作するように有効化します。

// イベントループにワールドを登録する
ScriptBehaviourUpdateOrder.UpdatePlayerLoop(_gameWorld);

有効化自体はシンプルです。
ScriptBehaviourUpdateOrderUpdatePlayerLoopメソッドを利用してアップデートループに登録するだけです。

これで自作ワールドが動きはじめ、エディタを再生すると画面にメッシュが表示されるようになります。

まとめ

今回は以上です。
もう少し実践的に利用する作りについては別記事で書きたいと思います。
まずは自作ワールドを生成し、システムの登録を経てオブジェクトを表示するシンプルな例を示しました。

ECSの作法に慣れれば、あとは専用のシステムなどの制作を通してECSを自由に扱えるようになるはずです。

その他Tips

基本的なECSの使い方と概念の説明は以上です。
あとは、知っておいたほうがいいTipsなどを簡単にまとめておきます。

デフォルトのワールドを生成させないようにする

前述のように、デフォルトのワールドを最初から生成しない方法があります。
それは、「Player Settings」の「Scripting Define Symbols」に以下のシンボルを定義することで無効化することができます。

UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP

以下のように設定する。

f:id:edo_m18:20181213150959p:plain

生成しても、それを消すこともできます。
以下のようにするとすべてのワールドを破棄することができます。

World.DisposeAllWorlds();

ECSで構築された「世界」を見る

ECSで構築された世界は、通常のシーンのヒエラルキーには表示されません。
前述の青い球体がシーンビューには存在していても、ヒエラルキーにはなにもない状態となっています。

しかしそれでは色々と開発が大変になってしまうので、それを見る方法があります。
メインメニューの「Window > Analysis > Entity Debugger」を起動することで現在のシステムとエンティティを確認することができます。

f:id:edo_m18:20181213172056p:plain

これを開いて実行すると以下のように、システムとエンティティのリストを見ることができます。

f:id:edo_m18:20181216184231p:plain

その他ハマりどころ

まだまだ策定途中、実装途中な感じのECS。
そのため仕様変更なども比較的頻繁に行われているようで、調べて出てきた情報がすでに古い、なんてこともあります。

色々ぷちハマりしたところをメモとして残しておこうと思います。

TransfdormMatrixはなくなった

Twitterでつぶやいたところ、以下のようにコメントもらいました。

確かに、TransformMatrixよりもより「データ感」のある名称に変更されたということでしょう。

TransformSystemは抽象クラス

これ、もしかしたら途中でそうなったのかもしれませんが。
調べているときに出てきたコードをそのまま書いていたらエラーが。

どうやらTransformSystemは抽象クラスで、実際に利用する場合はその派生クラスであるEndFrameTransformSystemを利用する必要があるとのこと。
(あるいは自作する場合はこれを継承する)

EntityManagerは自動で生成される

システムの説明のときにも触れましたが改めて書いておきます。
前述のEndFrameTransformSystemを先に生成すると、内部的にどうやら自動的にEntityManagerが生成されるようです。

そのため、そのあとに以下のようにセットアップを行おうとするとすでに生成されてるよ! っていうエラーが出ます。

_world.CreateManager<EndFrameTransformSystem>();
EntityManager entityManager = _world.CreateManager<EntityManager>(); // => ここでエラー

エラー内容はこんな感じ↓

ArgumentException: An item with the same key has already been added. Key: Unity.Entities.EntityManager

なので最初に生成するか、あるいはGetOrCreateで取得してあげるとうまくいきます。

EntityManager entityManager = _world.GetOrCreateManager<EntityManager>();

参考にした記事

qiita.com

qiita.com

qiita.com

tsubakit1.hateblo.jp