UnityのECSを使ってデータ指向型のゲーム作りをしてみる

記事内に広告が含まれています。

今回はUnityのEntity Component Systemを使ってみたいと思います。

色々使い方はあるみたいですが、とりあえずわかりやすいやり方のものでやっていこうと思います。

Entity Component System等のDOTS(Data-Oriented Technology Stack)に関するパッケージはまだほとんどがプレビュー版なので新しいプロジェクトを作成しそこで動作を確認した方がいいです。

Unity2020.1以降を使っている場合はEntity Component Systemのプレビュー版パッケージがPackage Managerに表示されないので、以下の記事のように自分でインストールするパッケージ名とバージョンを指定する必要があります。

UnityのDOTSってなんだろう?
UnityのDOTSとは何か?について見ていきます。DOTSはUnityでデータ指向型でゲームを作る時に必要な機能の集合体です。

今回は

com.unity.entities@0.16.0-preview.21

をインストールしました。

またDOTSでEntityの描画を行う時にHybrid Rendererパッケージも必要になりますので、こちらもインストールしてください。

com.unity.rendering.hybrid@0.10.0-preview.21

インストールするバージョンはUnityのバージョンやECSのバージョンによって変わってきます。

Hybrid RendererのV2は従来のビルトインのレンダリングパイプラインは未対応になっていますので、この場合はHybrid RendererのV1を使用してください。

Hybrid Rendererをインポートした時点ではV1がデフォルトで有効になっていますが、V2を使用するにはUnityメニューのEdit→Project Settingsを選択し、PlayerのScript Compliationの+を押しENABLE_HYBRID_RENDERER_V2を追加し、Applyボタンを押すとHybrid RendererのV2が使用出来ます。

Unity2020.2.0f1前のバージョンの場合は前の設定の後に;(セミコロン)を付けてその後に書きます。

Unity2020.1.0f1以降のHybrid RendererのV2の有効化

Unity2020.2.0f1以降の場合は+を押して個別に追加出来ます(Unity2020.2.0f1前のバージョンの前と同じように;の後に書いても自動で分割されます)。

Unity2020.2.0f1以降のHybrid RendererのV2の有効化

もしエンティティを生成した時にシーンビューやゲームビューにオブジェクトが描画されない場合はここら辺の事が関係している可能性が高いです。

レンダリングパイプラインとの互換性はUnityのHybrid Rendererのマニュアルを参照してみてください。

DOTS Hybrid Renderer | Hybrid Renderer | 0.8.0-preview.19

今回の記事ではURPのプロジェクトを作成しており、各パッケージのバージョンは以下のものを使用しています。

Unity-2020.1.17f1
Entities-0.16.0-preview.21
Hybrid Renderer-0.10.0-preview.21
Universal RP-8.3.1

Unity2020.2.0f1で試したんですがどうもうまく表示されないので少し前のバージョンで実行しました。

なのでHybrid RendererのV2は使わずデフォルトのV1を使用しました。

2020/12/09時点ではECSはプレビュー版なので今後使い方が変わってくる可能性があります。

スポンサーリンク

ECSとは

ECSはUnityのEntity Component Systemの略で、データ指向型でゲームを作成する時に便利な機能です。

Entityはデータを繋ぐもので含まれているデータ群を表すIDみたいなものです。
Componentはデータそのもので回転ならRotation型のデータ、移動スピードならfloat型のデータ等です。
Systemは一連のデータを操作します。

従来のやり方だとゲームオブジェクトにコンポーネント(スクリプト)を取り付けてゲームを作成してきました。

例えばPlayerというスクリプトでプレイヤーのステータスを保持していて、敵に攻撃を受けたのでPlayerスクリプトのTakeDamageメソッドでPlayerスクリプトが持つhpというフィールド(orプロパティ)の値を減らすという事をしていました。

なので該当するゲームオブジェクト毎に機能を持つというオブジェクト指向型のゲームの作り方になります。

この場合は、ゲームオブジェクトが各コンポーネントを持っていますが、それらのデータにアクセスする時にはそのゲームオブジェクトを介して該当する機能やデータは参照型を含むので同じデータの幅を持っておらず保存されているメモリも飛び飛びに作られているので、アクセスに時間がかかります。

データ指向型のゲームの作り方の場合はデータと処理を分けて考えるようにします。

データ(Component)としてHP、位置を表すTranslation、角度を表すRotation等を作成し、プレイヤーに必要なそれらのデータ群をEntityというIDで識別します。

ECSの場合はその同じデータを持つアーキタイプをメモリ上のチャンク(塊)に入れるという感じになります。

そしてSystemで同じチャンクを持つ同じデータ、例えばRotation全般を一括で操作します。

こうする事でメモリに効率的に領域を確保し、アクセス処理を速くすることが出来ます。

簡単な用語解説

ここまででEntity、Component、System、ArcheType、Chunkという用語が出てきましたが、それぞれがどういうものなのか分かり辛いので図にしてみます。

解説の順番は理解が進みやすい順番にしています。

Component

Componentはデータそのものなので以下のようにTranslation(位置)やRotation(回転)等の個々のデータです。

ECSのComponentの概念

Entity

Entityはどのデータが一緒に所属しているかを表すIDです。

ECSのEntityの概要

上のEntity0はTranslation、Rotation、Rigidbody、MoveDataのデータを持つものをEntity0というIDが表しています。

ArcheType

ArcheTypeはデータの組み合わせのタイプです。

以下のようにデータの組み合わせがアーキタイプになります。

ECSのアーキタイプの概要

上の場合はTranslation、Rotation、Rigidbody、MoveDataを持つ物をアーキタイプ0としたら、Translation、Rotation、Rigidbodyを持つ物をアーキタイプ1、Translation、Rotationを持つ物をアーキタイプ2というような感じの組み合わせの事です。

Chunk

Chunkは同一のアーキタイプを入れる為のメモリの領域です。

ECSのチャンクの概要

上のように同一のアーキタイプを持つものを同一のチャンク(薄い青色の領域)に格納するようになっています。

System

Systemは同一のアーキタイプを持つデータを一斉に処理をするものです。

ECSのSystemの概念

上の場合はTranslation、Rotation、Rigidbody、MoveDataを持つアーキタイプのデータの処理をするシステムの概念図です。

同じチャンクにTranslation等のデータが連続してあるので一斉に処理をして処理が速くなります。

とりあえずECSを使ってシンプルな動きを作ってみよう

ECSの概念はなんとなくわかったので、実際にECSを使ってみましょう。

ゲームオブジェクトのCubeを回転させながら重力で落下する動きを作ってみます。

まずはヒエラルキー上で右クリックをして3D Object→Cubeを選択し立方体のゲームオブジェクトを作ります。

TransformのPositionのYを50にします。

Componentの作成

次にデータ自体を作成します。

これは通常通りC#スクリプトを作成し、名前をMoveDataとします。

デフォルトでComponentの記述がされたものを改造したい場合は通常のC#スクリプトを選択するのではなく、Assetsフォルダ内で右クリックからCreate→ECS→Runtime Component Typeを選択します。

usingディレクティブでUnity.Entitiesを指定します。

MoveDataはclass(クラス)からstruct(構造体)へと変更し、MonoBehaviourをIComponentDataに変更し実装するようにします。

構造体には回転スピードと重力加速度のフィールドを用意し、アクセス修飾子はpublicにします。

重力加速度は特に設定する必要もないので[HideInInspector]アトリビュートを使ってインスペクタに表示しないようにします。

これでデータを表す構造体が出来ましたが、このままだとゲームオブジェクトに取り付けられないので、[GenerateAuthoringComponent]アトリビュートを構造体に付けます。

スクリプトが出来たらMoveDataを先ほど作ったCubeに取り付けます。

Entityの作成

データが出来たので次はEntityを作成します。

Entityはスクリプトからそのエンティティに所属するデータを設定して一から作ることも出来ますが、今回は元々あるコンポーネントをCubeに取り付けて実行時にゲームオブジェクトに取り付けられているコンポーネントからEntityを作成します。

CubeのインスペクタのAdd ComponentからDOTS→Convert To Entityコンポーネントを取り付けます。

Cubeのインスペクタは以下のような感じに設定します。。

CubeにデータコンポーネントとConvert To Entityコンポーネントを取り付ける

Convert To EntityのConversion ModeをConvert And Inject Game Objectにするとヒエラルキーにゲームオブジェクトが残ります(エンティティは別個で出来ます)。

この場合はゲームオブジェクトとエンティティを一緒に使う場合に使用するようです。

Convert And Destroyだとヒエラルキー上のゲームオブジェクトが消えエンティティを作成します。

Entityはヒエラルキー上で確認出来ず、Window→Analysis→Entity Debuggerで確認する必要があります。

試しにUnityエディターのプレイボタンを押してEntity Debuggerを確認すると以下のようにCubeエンティティを確認することが出来ます。

エンティティになったCube

元々ゲームオブジェクトに取り付けられていたコンポーネントが各データに変換されCubeエンティティというIDで繋がれているという感じになります。

CubeゲームオブジェクトはTransformやMesh Renderer等のコンポーネントを持っていたのでそれらがデータに変換されます。

右の各チャンクを選択するとそのチャンクにあるエンティティが左に表示されます。

Cubeには別途MoveDataスクリプトを取り付けていたのでこちらもチャンクで確認出来ますね。

Convert To Entityコンポーネントを使ったゲームオブジェクトからエンティティへの変換では変換出来ない機能は削除されます(自前のスクリプトや対応していないUnityのコンポーネント)。

ちなみにPositionはTranslationになります。

試しにヒエラルキー上で右クリックからCreate Emptyを作成し、Add ComponentからDOTS→Convert To Entityコンポーネントを取り付け実行してみると、空のゲームオブジェクトはTransformしか持っていないのでチャンクで確認するとLocalToWorld、Translation、RotationとTransformに関するデータだけを持ちます。

空のゲームオブジェクトをエンティティにした時のデータ

Systemを作ってCubeを動かす

最後に出来たEntityを操作してCubeが回転しながら重力で落下するシステムを作成します。

新しくMoveSystemという名前のC#スクリプトを作成します。

Assetsフォルダ内で右クリックからCreate→ECS→Systemで作成するとデフォルトでシステム関連の記述がされたものを作成出来ます。

usingディレクティブでUnity.Entities、Unity.Mathematics、Unity.Transformsを指定します。

MoveSystemはSystemBaseを継承して作成します。

SystemBaseを継承した場合はOnUpdateメソッドを実装する必要があります。

deltaTime変数にデルタタイムを保持しておきます。

その後Entities.ForEachとラムダ式を使って処理を行います。

ラムダ式は使い慣れないと分かり辛いですが、メソッドに実行する処理自体を引数として渡すことを簡略化して書いたものです。

なので、引数としてTranslation型、Rotation型、MoveData型を受け取り=>以降でその引数を使って処理を行うという感じになります。

ラムダ式に関しては以下の記事も参照してみてください。

C#でデリゲートとラムダ式を使ってみる
C#のデリゲートとラムダ式をUnity付属のMonoDevelopで使用してみます。

引数としてEntityのTranslation、Rotation、MoveDataをrefを使って指定します。

refはその引数に書き込みがある場合に指定します。

inは読み込みのみに使用する場合に指定します。

最適化の為に書き換えを行わない場合はrefの部分をinに変えます。

今回は全て書き込みをしているのでrefで指定しています。

OnUpdateメソッドが呼ばれる度にmoveData.gravitationalAccelerationにPhysics.gravity.y * deltaTimeを足していく事で重力加速度が増えていきます。

エンティティの位置は現在の位置に重力加速度を足した位置に移動させます。

エンティティの回転は現在の回転にY軸を軸にしてmoveDataのrotateSpeedの早さ分をかけた値を入れます。

これでCubeエンティティが徐々に回転します。

今まで位置や回転、速度等はVector3という共通の型を使用していましたが、位置はfloat3、回転は小文字のquaternion等のUnity.Mathematicsパッケージの機能を使います。

Unity.Mathematicsパッケージは通常のオブジェクト指向型のスクリプト内でも使えます。

最後に.ScheduleParallelでスケジューリングをして並列に処理を実行します。

MoveSystemはゲームオブジェクト等に取り付ける必要はありません。

シンプルなECSサンプルを実行してみる

これで機能が出来たのでUnityエディターのプレイボタンを押して実行してみましょう。

ヒエラルキー上のCubeが回転しながら重力で落下するのを確認出来ます。

ちょっと問題があるかも!?

Cubeゲームオブジェクトをエンティティに変換し、システムを使って落下させることが出来ました。

しかしここで少し問題があります。

MoveSystemスクリプトはゲームオブジェクトに取り付けていないので取り付けたゲームオブジェクト固有の動かすシステムとなっていません。

MoveSystemで行っていることはエンティティからTranslation型、Rotation型、MoveData型の引数を受け取りそのデータを処理してエンティティを移動させているだけです。

つまりCubeゲームオブジェクトから作ったエンティティだけを落下させるのではなく同じデータを持つエンティティのオブジェクトも同じように落下させてしまいます。

試しにヒエラルキー上で右クリックから3D Object→Capsuleを選択し、ゲームオブジェクトを作成してTransformのPositionのXを5、Yを50とし、MoveDataを取り付け、さらにAdd ComponentからDOTS→Convert To Entityコンポーネントを取り付けます。

CapsuleにMoveDataとConvert To Entityコンポーネントを取り付ける

これでCapsuleゲームオブジェクトはコライダコンポーネント以外は同じコンポーネントを保持しており同じアーキタイプになります(Physic系は対応するコンポーネントがないので消えます)。

Unityのエディターでプレイボタンを押してみるとCubeと同じようにCapsuleも回転しながら重力で落下しているのを確認出来ます。

今回の場合はCubeエンティティのオブジェクトだけを重力で落下させたいところです。

どうやってCubeとCapsuleを分けるか?

先ほどのCubeとCapsuleが両方とも同じデータの組み合わせを持つことでMoveSystemによって両方とも落下してしまいました。

これはMoveSystemではTranslation、Rotation、MoveDataを持つエンティティが全て実行されるからです。

試しに空のゲームオブジェクトを作成しMoveDataとConvert To Entityコンポーネントを取り付けるとこの空のゲームオブジェクトも落下します(描画コンポーネントはないので見えませんが)。

そこでCubeだけが落下するようにしたいと思います。

やり方はいくつかあるようですが、一番簡単なやり方をしてみます。

ComponentとしてCubeデータを作成し、これをゲームオブジェクトのタグとして使うやり方です。

システムでエンティティの処理をする時にエンティティクエリのオプションを指定することで、このデータに対して実行するという指定が出来るので、その時にCubeデータを指定してこのシステムでエンティティの識別が出来ます。

識別の種類

識別の種類をいくつか見ていきます。

WithAll

WithAllは指定したコンポーネントタイプ全て必要です。

WithAny

WithAnyは指定したコンポーネントタイプが一つ以上必要です。

WithNone

WithNoneは指定したコンポーネントタイプを持っていてはいけません。

他はUnityマニュアルを参照してください。

Class SystemBase | Entities | 0.16.0-preview.21
実際にCubeだけを処理してみる

エンティティを識別する方法がわかったので、実際にCubeエンティティだけを識別してみましょう。

Assetsフォルダ内で右クリックからCreate→ECS→Runtime Component Typeを選択し、名前をCubeTagとします。

CubeTagは識別の為だけに使うコンポーネントなので中身には何も書いていません。

CubeTagが出来たらCubeゲームオブジェクトに取り付けます。

次にMoveSystemでCubeTagを識別します。

WithAnyを使ってCubeTagを持っているエンティティにのみ処理を実行しています。

WithAny等は3つまでデータを指定出来ます。

例えば以下のような感じです。

上の場合はCubeTag、Translation、Rotationの少なくとも1つはデータを持っている必要があります。

.WithAllや.WithAny等複数を繋げて使用することも出来ますが競合してエラーが出る事もあるので、エラー内容を見て修正してください。

これでCubeの識別が出来るようになったので実行して確認してみましょう。

CubeゲームオブジェクトのみにCubeTagを取り付けているので、Capsuleゲームオブジェクトは落下しないようになりました。

サブシーンを使ってゲームオブジェクトをエンティティに変換する

サブシーンを使用するとサブシーン内のゲームオブジェクトが実行時にエンティティに変換されます。

新しいサブシーンを作成する

新しいサブシーンを作成するにはヒエラルキー上で右クリックからNew Sub Scene→Empty Scene…を選択し、出てきたダイアログでサブシーン名を入力して保存します。

新規サブシーンの作成方法

ヒエラルキー上にサブシーンが作成されます。

サブシーンがヒエラルキーに作成された

サブシーンに移動するゲームオブジェクトを選択してサブシーンを作成する

もうひとつのやり方としてサブシーンに入れたいゲームオブジェクトを選択した状態で右クリックからNew Sub Scene→From Selection…を選択します。

ゲームオブジェクトを選択しサブシーンを作成する

サブシーンが作成され、そこに選択していたゲームオブジェクトが登録されます。

選択したゲームオブジェクトがサブシーンに登録された

スクリプトからエンティティを作成してみる

落下するCubeのサンプルではConvert To Entityコンポーネントを使用してゲームオブジェクトからエンティティを生成しました。

今度はスクリプトからエンティティを生成してみることにします。

新しくCreateEntityScriptを作成しMain Camera等の何らかのゲームオブジェクトに取り付けます。

meshは設定するメッシュ、materialは設定するマテリアルをインスペクタで設定出来るようにします。

World.DefaultGameObjectInjectionWorld.EntityManagerでデフォルトの世界のEntityManagerを取得出来ます。

WorldはEntityManagerと一連のシステムを所有しています。

Worldはいくつもで作成できるようですが、よくわからないのでスルーです・・・・((+_+))

とりあえずデフォルトのワールドを使っていきます。

二つのアーキタイプをentityManagerのCreateArchetypeメソッドで作成し、ひとつのアーキタイプを元にEntityManagerのCreateEntityメソッドを使ってエンティティを作成します。

もう一つのアーキタイプを使って100個のエンティティを生成します。

entityManager.GetAllEntitiesメソッドで全てのエンティティを取得しNativeArray型のentitiesに入れます。

これは二つのアーキタイプのエンティティ全てを取得します。

for文で繰り返して全てのエンティティに「自作エンティティ + 番号」という名前を付けます。

その後entityManagerのHasComponentメソッドを使ってそのエンティティがTranslation型のコンポーネントを持っているか確認し、持っていればTranslation(位置)をSetComponentDataメソッドを使って設定します。

その後、同じようにRenderMeshコンポーネントを持っているかどうか確認し、持っていればAddSharedComponentDataを使ってRenderMeshにインスペクタで設定したメッシュとマテリアルを設定するようにします。

ここでは同じメッシュとマテリアルを持つRenderMeshを設定しているのでAddSharedComponentDataを使用して

共有しています。

このスクリプトでやっていることはアーキタイプを作成してそれを元にエンティティを作成し、そのエンティティのコンポーネントを操作しているという感じになります。

CreateEntityScriptを取り付けたゲームオブジェクトのインスペクタでmeshとmaterialに適当に何かを設定してプレイボタンを押して確認してみてください。

わたくしの場合はスタンダードアセットのEthanのメッシュとEthanのマテリアルを設定してみました。

CreateEntityScriptのインスペクタの設定

実行すると以下のようにEthanが大量に生産されました。(^^)/

Ethanエンティティを大量生産した画像

ECSの速さを確認してみる

前回C# Job Systemを使って並列でCubeを大量生産し動かす機能を作りました。

今回はECSを使って同様の機能を作成して速度がどうなるかを見てみたいと思います。

まずはヒエラルキー上にCubeを作成し、名前をECSCubeとし、Assetsフォルダ内にドラッグ&ドロップしてプレハブにします。

次に以下のようにMoveSpeed、RotationSpeed、CubeTagスクリプトを作成します。

MoveSpeedは移動スピード、RotationSpeedは回転スピード、CubeTagはタグに使用します。

namespaceを使っているのは個人的な理由で分ける為に使用しています。

この3つのスクリプトを先ほどプレハブにしたECSCubeに取り付けます。

次に新しくInstantiateCubeScriptスクリプトを作成し、MainCamera等のゲームオブジェクトに取り付けます。

prefabはインスペクタで先ほどのECSCubeプレハブを設定します。

numberToInstantiateは一度にインスタンス化する数です。

totalはインスタンス化した総数

totalTextはインスタンス化した総数を表示するテキストを設定します。

entityPrefabは元のゲームオブジェクトのプレハブをエンティティに変化させたものが入ります。

defaultWorldはデフォルトのワールドを入れます。

entityManagerはデフォルトのワールドのエンティティマネージャーを入れます。

Startメソッドでデフォルトのワールドの取得とそこからエンティティマネージャーの取得をしています。

その後GameObjectConversionSettings.FromWorldメソッドを使って設定を作成します。

さらにGameObjectConversionUtility.ConvertGameObjectHierarchyメソッドを使ってプレハブと設定を使ってエンティティに変換します。

Updateメソッドではスペースキーを押すたびにInstantiateEntityメソッドを呼んでエンティティにしたプレハブからエンティティを生成します。

作成したエンティティにentityManagerのSetComponentDataメソッドを使って位置や回転、移動スピード、回転スピードの設定をしています。

次にシステムを作成します。

やっていることはECSCube.CubeTagデータを持つエンティティ全てに対して移動スピードデータを取得し、下向きに徐々に移動させ、回転スピードデータを取得しエンティティをY軸で回転させるということをしています。

書き込みをするデータにはref、読み込みのみの場合はinを付けます。

Unity.Mathematicsパッケージのメソッドをいくつか使っていますが、

math.down()は下向きのfloat3、math.up()は上向きのfloat3の値が得られます。

math.down()はVector3.down、math.up()はVector3.upと同じ感じです。

quaternion.AxisAngleは第1引数で指定した軸で第2引数分回転させたquaternionの値が得られます。

math.mulで第1引数に第2引数をかけます。

結果として第1引数を第2引数分回転させたものが得られます。

とりあえず機能が出来たので実行して確認してみます。

以下のように50000のエンティティを生成してもFPSはだいぶ保っています。

前の記事のC# Job Systemで並列に処理をしBurst Compilerで最適化して実行したものと、今回のECSで作った物で比較してみると以下のようになりました。

C# Job System&BurstとECSの速度比較

C# Job SystemとBurstだと5000個のインスタンスを生成するとFPSが24、ECSの場合は50000とC# Job Systemの10倍Cubeを作成してもFPSが30を超えていました(動画とは再生環境が多少違います)。

ECSでの処理はメチャクチャ速いですね!

終わりに

今回はECSをシンプルに使って動かすということをしました。

今回のシステムでは全てのエンティティに対して処理を実行していますが、他のやり方としてチャンク単位で実行することも出来るようです。

その辺は出来たら別の記事でやりたいと思います。(._.)

DOTS関連のUnity.PhysicsやUnity.Animation等が正式版になれば従来作ってきたゲームの機能をDOTSで作成出来、しかも速いという事になってきますね。

でも使い方がやっぱり難しいなぁ・・・・(´Д`)

参考サイト

UnityマニュアルーEntity Component Systemー

Unity LearnーEntity Component Systemー

大量のオブジェクトを含む広いステージでも大丈夫、そうDOTSならね -Unite Tokyo 2019

たのしいDOTS〜初級から上級まで〜 – Unite Tokyo 2019

Unity GitHubのECSサンプル

EntityComponentSystemSamples

タイトルとURLをコピーしました