Edited at

Unity:はじめてのECS


本記事を書くにあたって

img

Unity:ECSとJobSystemsでFractal Brownian Motion(fBm)ノイズを生成

先日自身のはてなブログにて、ECS を使ってノイズ地形生成にチャレンジしようとしたのですが

非常に取っつき難かったので、当時自分が求めていた形で入門記事を書きました。

はじめて Unity ECS に触れる人の勉強時間が少しでも短くなると嬉しいです。


本記事の内容

まったくの初心者が Unity ECS + Job Systems + Burst Compile を使って、100 x 100 で平面に敷き詰めた計 10000 個の箱の高さを 正弦波で更新するデモを実装する具体的な手順が示されています。


Unity の ECS で出来ること

Unity 2018.2 から使えるようになった ECS (Entity Component System) ですが

どんなゲームも高速化するというのは間違った期待感です。

ECS はシーン内に大量に出現する Entity(メッシュオブジェクトとか) の計算処理をコンピュータの仕組みを利用して高速化する道具です。

Entity に当たるものがシーンに大量に登場しない限り、ECSの恩恵を受ける場面は少ないでしょう。

ECS で高速化が期待できるゲームジャンルは


  • 弾幕シューティング


    • 大量の弾が飛び交う要素

    • 大量の敵キャラクターが攻撃してくる要素



  • 戦場で軍勢を指揮する戦略シミュレーション


    • 一人一人の兵が目的を持って行動する要素



  • オープンワールドアクションRPG


    • 大勢の敵を相手に切った張ったする要素

    • 大軍同士が激突する戦場を駆ける要素

    • 大勢のキャラクターが街中をそれぞれの目的を持って行動する要素



  • サンドボックスゲーム


    • 大量のブロックで構成される世界でそれぞれのブロックがルールに合った働きをする要素



  • ダンジョンRPG


    • 大量のモンスターがダンジョン内で目的をもって行動している要素



こうした大量に同じような計算式が登場するシーンがある場合に、ECS が有効に機能します。

流行りのAIとも密接に関わったジャンルが多いため、期待通りに軍勢が動く共通のロジックを研究することにもなりそうですね。


どうして ECS を使うと高速化するの?

理解にはコンピュータについての知識が必要です。

コンピュータは基本的に記憶装置(メモリ)から数字を読みだして中央演算装置(CPU)が演算をして結果の数字をメモリに格納することを繰り返します。

具体的には大容量のメモリの情報を CPU に近い小容量のメモリに配置して、そこからCPUが情報を取り出して計算します。

以上がコンピュータの説明です。

ECS は大量に同じような計算をするケースで、大容量のメモリの情報を CPU に近い小容量のメモリに配置する頻度を減らします。

具体的には同じ計算式で利用する情報を大容量のメモリに順番に整列して配置します。

ECS の目的は計算式が扱う情報をメモリに整列配置することだけです。

ECS が行おうとしているメモリ整列処理のために、プログラマーがECSにどんな情報を渡すべきか考えながら書式を追うと理解が早まると思います。

そのため、参考にした資料でも、最初にコンピュータの仕組みと ECS が具体的にメモリの整理をしていることを解説しています。

私の説明は文字だけで大変わかりづらいので、Unity 公式の資料などを読むとより理解が深まると思います。

【CEDEC2018】CPUを使い切れ!Entity Component System(通称ECS)が切り開く新しいプログラミング

【CEDEC2018】CPUを使い切れ!Entity Component System(通称ECS)が切り開く新しいプログラミングより(画像クリックで動画へ飛びます)


Hello ECS

新しい書式を学ぶので、最も簡素な例から始めます。

お題として大量の箱を平面に敷き詰めて、正弦関数(sin)を使ってその場で振動させるデモを選びました。

HelloECS.gif


実行環境

Windows 10 バージョン 1803

Unity 2018.3.0b5

新規プロジェクト作成時にテンプレート Lightweight RP (Preview) を選択

Entities Version 0.0.12

Jobs Version 0.0.7

Burst Version 0.2.4


ECS 利用準備

Window メニュー > Package Manager を選択して

All packages Advanced フィルターに変更して表示される Entities を選んで Install ボタンを押してプロジェクトで ECS を使えるようにします。

pm.jpg

Package Manager のダイアログ

続いて、ECSに関連のある Jobs, Burst についても同様にインストールしましょう。


Entity Debugger から Default World の動作確認

Winodw メニュー > Analysis > Entity Debugger を選択して、空のシーンを作って実行します。

Default World というものが予め作られており、ちょっとだけですが計算リソースを消費していることが確認できます。

defaultWorld.jpg

Entity Debugger 空のシーン実行時の様子

ECS はすでに機能していることが確認できました。

まずはこの Entity Debugger に表示されている System Name について、もう少し明らかにしますと


  • EndFrameTransformSystem : 次の EndFrameBarrier の前に実行される TransformSystem

  • EndFrameBarrier : Initialization の前に実行される BarrierSystem

  • EntityManager : Entity を World に作成したり、削除したりするときに利用する ScriptBehaviourManager

  • RenderingSystemBootstrap : EditMode でも動く ComponentSystem

  • MeshInstanceRendererSystem : 描画用のカメラを要求する EditMode でも動く ComponentSystem

TransformSystem とは何か? BarrierSystem とは何か?疑問は尽きませんが、追及はこの辺で留めて

以上が、Default World に最初から作られている ComponentSystem です。


ECS World を作成

本節では Default World を自動作成しないようにして、Default World と同じ機能を持つ自作の World を作成して動作確認します。

File メニュー > Build Settings > Player Settings の Scripting Define Symbols に

UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP

を追記して(複数設定する時のデリミタは ; でした) Enter を押し、ビルド完了を待ちます。

この状態で空のシーンを実行して Entity Debugger を確認すると No Worlds となっていることが確認できます。

noworld.jpg

公式ドキュメント

https://github.com/Unity-Technologies/EntityComponentSystemSamples/blob/master/Documentation/content/ecs_in_detail.md#world

の Default World creation code を参考に、自分で World を作るコードを書いてみます。

HelloECS.cs

using Unity.Entities;

using Unity.Rendering;
using Unity.Transforms;
using UnityEngine;

[RequireComponent(typeof(Camera))]
public class HelloECS : MonoBehaviour
{
void Start()
{
InitializeWorld();
}

void OnDisable()
{
ScriptBehaviourUpdateOrder.UpdatePlayerLoop(null);
_world?.Dispose();
}

void InitializeWorld()
{
_world = World.Active = new World("MyWorld");
_world.CreateManager(typeof(EntityManager));
_world.CreateManager(typeof(EndFrameTransformSystem));
_world.CreateManager(typeof(EndFrameBarrier));
_world.CreateManager<MeshInstanceRendererSystem>().ActiveCamera = GetComponent<Camera>();
_world.CreateManager(typeof(RenderingSystemBootstrap));
ScriptBehaviourUpdateOrder.UpdatePlayerLoop(_world);
}

private World _world;
}

以下はシーンに配置した Camera オブジェクトに上記 HelloECS.cs スクリプトコンポーネントをアタッチして実行したときの様子

myworld.jpg

MyWorld が機能していることが Entity Debugger から確認できました。


Entity の箱を一個だけ表示する

Unity 標準の箱の表示方法は次の通りです。

    void CreateCube()

{
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.transform.position = Vector3.zero;
cube.transform.rotation = Quaternion.identity;
cube.transform.localScale = Vector3.one;
}

ECS での箱の表示方法は次の通りです。

    void CreateCubeForECS()

{
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.transform.position = Vector3.zero;
cube.transform.rotation = Quaternion.identity;
cube.transform.localScale = Vector3.one;

var manager = _world?.GetExistingManager<EntityManager>();
if (null != manager)
{
var archetype = manager.CreateArchetype(ComponentType.Create<Position>(),
ComponentType.Create<MeshInstanceRenderer>());

var entity = manager.CreateEntity(archetype);

manager.SetComponentData(entity, new Position() { Value = float3.zero });

manager.SetSharedComponentData(entity, new MeshInstanceRenderer()
{
mesh = cube.GetComponent<MeshFilter>().sharedMesh,
material = cube.GetComponent<MeshRenderer>().sharedMaterial,
subMesh = 0,
castShadows = UnityEngine.Rendering.ShadowCastingMode.Off,
receiveShadows = false
});
}

Destroy(cube);
}

InitializeWorld の後で CreateCubeForECS を呼ぶようにして、Entity の箱が一個表示されることを確認できました。

cubeInstance.jpg


大量の Entity の箱を平面に敷き詰める

ポインタを C# で扱うことになるので、事前に File メニュー > Build Settings > Player Settings を開いて Allow 'unsafe' Code にチェックを入れます。

あとは先ほどの CreateCubeForECS 関数の entity 作成後のコードにて

            const int SIDE = 5;

NativeArray<Entity> entities = new NativeArray<Entity>(SIDE * SIDE, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
try
{
unsafe
{
Entity* ptr = (Entity*)entities.GetUnsafePtr();
NativeArray<Entity> outputEntities = NativeArrayUnsafeUtility.ConvertExistingDataToNativeArray<Entity>(ptr + 1, entities.Length - 1, Allocator.None);
#if ENABLE_UNITY_COLLECTIONS_CHECKS
NativeArrayUnsafeUtility.SetAtomicSafetyHandle(ref outputEntities, NativeArrayUnsafeUtility.GetAtomicSafetyHandle(entities));
#endif
entities[0] = entity;
manager.Instantiate(entity, outputEntities);

for (int x = 0; x < SIDE; x++)
{
for (int z = 0; z < SIDE; z++)
{
int index = x + z * SIDE;
manager.SetComponentData(entities[index], new Position
{
Value = new float3(x, 0, z)
});
}
}
}
}
finally { entities.Dispose(); }

最初に作った entity を prefab に残りの entity は Instantiate するという書式を使っています。

必要な個数分繰り返し CreateEntity をした結果と同じになります。しかし、この書式の方がメモリアロケーションを細切れに行わずに済み、かつSetSharedComponentDataによる無駄なChunk移動を繰り返さないのでパフォーマンスが良いです。

Entity のパラメータ変更は SetComponentData を使って行う決まりです。

上記コードを足した実行結果は次の期待通りのものとなりました。

instances.jpg

SIDE を 5 から 100 にして 10000 個の箱を平面に敷き詰めます。

まだ60FPSは保てていました。

ichimancube.jpg


ComponentSystem を作成する

ここまで箱の配置や描画などの基本的な機能はビルトインの ComponentSystem を World に追加することで実現してきました。

独自に箱の高さを変更する機能を追加したいので ComponentSystem を作ります。

public sealed class MyCubeSystem : ComponentSystem

{
protected override void OnUpdate()
{
var chunks = EntityManager.CreateArchetypeChunkArray(query, Allocator.TempJob);
var positionType = GetArchetypeChunkComponentType<Position>(false);

var time = Time.realtimeSinceStartup;
for (int chunkIndex = 0, length = chunks.Length; chunkIndex < length; chunkIndex++)
{
var chunk = chunks[chunkIndex];
var positions = chunk.GetNativeArray(positionType);
for (int i = 0, chunkCount = chunk.Count; i < chunkCount; i++)
{
var position = positions[i];
position.Value.y = math.sin(time + 0.2f * (position.Value.x + position.Value.z));
positions[i] = position;
}
}
chunks.Dispose();
}
private readonly EntityArchetypeQuery query = new EntityArchetypeQuery
{
Any = System.Array.Empty<ComponentType>(),
None = System.Array.Empty<ComponentType>(),
All = new ComponentType[] { ComponentType.Create<Position>(), ComponentType.Create<MeshInstanceRenderer>() }
};
}

World には様々な archeType の Entity が存在することになるのですが、その中から特定の archeType のものだけフィルタリングして取得するために ChunkIteration という書式を使いました。

参考にした公式ドキュメントはこちら

Chunk Iteration

World にこの MyCubeSystem を追加すると、平面に敷き詰めた箱が正弦波(sin波)で波打つシーンを確認できます。

HelloECS.gif

冒頭で ECS の目的について書きましたが、上記コードにおける次の行が、メモリに順番に整列して配置されたデータを指すのだと思います。

var positions = chunk.GetNativeArray(positionType);

ChunkIteration のサンプルに Jobs と Burst についても例が示されていたので、おまけとして Job Systems も合わせて使ってみようとお思います。


C# Job Systems によるマルチスレッド化

参考資料は先ほどと同じChunk Iterationを使います。

    protected override JobHandle OnUpdate(JobHandle inputDeps)

{
var myCubeJob = new MyCubeJob
{
chunks = EntityManager.CreateArchetypeChunkArray(query, Allocator.TempJob),
positionType = EntityManager.GetArchetypeChunkComponentType<Position>(false),
time = Time.realtimeSinceStartup
};
return myCubeJob.Schedule(myCubeJob.chunks.Length, 16, inputDeps);
}
struct MyCubeJob : IJobParallelFor
{
[ReadOnly][DeallocateOnJobCompletion] public NativeArray<ArchetypeChunk> chunks;
public ArchetypeChunkComponentType<Position> positionType;
public float time;

public unsafe void Execute(int chunkIndex)
{
var chunk = chunks[chunkIndex];
var positions = chunk.GetNativeArray(positionType);
var positionPtr = (Position*)positions.GetUnsafePtr();
for (int i = 0, chunkCount = chunk.Count; i < chunkCount; i++, positionPtr++)
positionPtr->Value.y = math.sin(time + 0.2f * (positionPtr->Value.x + positionPtr->Value.z));
}
}

処理の結果をそのままに、マルチスレッド化により MyCubeSystem の所要時間が 0.55 ms と 1/3 になりました。

C# Job Systems による高速化を確認できました。

jobs.jpg


Burst Compile

Burst Compile はコードの変更は行わずに、Job の Execute 関数内の処理を SIMD 命令という複数の演算を一つの命令で処理するテクニックによる高速化をもたらします。書式については次のガイドドキュメントを参照しました。

Burst User Guide

具体的な変更は次の通り、Job 構造体に属性を追加するのみです。

    [BurstCompile(Accuracy.Med, Support.Relaxed)]

struct MyCubeJob : IJobParallelFor

結果、C# Job Systems からさらに 2.5 倍の高速化(MyCubeSystem の所要時間が 0.21 ms )となりました。

burst.jpg


まとめ

はじめての ECS と題して

ECS がどんなゲーム要素に向いているのか考察し

簡素なサンプルとして大量の箱を平面に敷き詰めて、正弦波で箱の高さを更新するデモを実装しました。

初めに ECS を使って 100 x 100 の計 10000 個の箱の高さを更新するのに 1.55 ms 要することを確かめ

ECS + Job System を使って、同じ計算結果がマルチスレッド化により 0.55 ms と所要時間が 1/3 となることを確認し

ECS + Job Systems + Burst Compile を使って、同じ計算結果が 0.21 ms と所要時間がさらに短くなることを確認しました。

筆者が ECS を触る上で最初に確認したかったことは以上です。

ここまで読んでくださりありがとうございました。

以下、ECS + Job Systems + Burst Compile の動作確認時の全コード内容です。

ファイルは一つだけです。

使い方:

File メニュー > Build Settings > Player Settings の Scripting Define Symbols に

UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP

を設定してから、下記スクリプトをカメラオブジェクトにアタッチしてシーンを再生します。

HelloECS.cs

using Unity.Burst;

using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Rendering;
using Unity.Transforms;
using UnityEngine;

[RequireComponent(typeof(Camera))]
public class HelloECS : MonoBehaviour
{
void Start()
{
InitializeWorld();

CreateCubeForECS();
}

void OnDisable()
{
ScriptBehaviourUpdateOrder.UpdatePlayerLoop(null);
_world?.Dispose();
}

void InitializeWorld()
{
_world = World.Active = new World("MyWorld");
_world.CreateManager(typeof(EntityManager));
_world.CreateManager(typeof(EndFrameTransformSystem));
_world.CreateManager(typeof(EndFrameBarrier));
_world.CreateManager<MeshInstanceRendererSystem>().ActiveCamera = GetComponent<Camera>();
_world.CreateManager(typeof(RenderingSystemBootstrap));

_world.CreateManager(typeof(MyCubeSystem));
ScriptBehaviourUpdateOrder.UpdatePlayerLoop(_world);
}

void CreateCubeForECS()
{
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.transform.position = Vector3.zero;
cube.transform.rotation = Quaternion.identity;
cube.transform.localScale = Vector3.one;

var manager = _world?.GetExistingManager<EntityManager>();
if (null != manager)
{
var archetype = manager.CreateArchetype(ComponentType.Create<Prefab>(), ComponentType.Create<Position>(),
ComponentType.Create<MeshInstanceRenderer>());

var prefabEntity = manager.CreateEntity(archetype);

manager.SetComponentData(prefabEntity, new Position() { Value = float3.zero });

manager.SetSharedComponentData(prefabEntity, new MeshInstanceRenderer()
{
mesh = cube.GetComponent<MeshFilter>().sharedMesh,
material = cube.GetComponent<MeshRenderer>().sharedMaterial,
subMesh = 0,
castShadows = UnityEngine.Rendering.ShadowCastingMode.Off,
receiveShadows = false
});

const int SIDE = 100;
NativeArray<Entity> entities = new NativeArray<Entity>(SIDE * SIDE, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
try
{
manager.Instantiate(prefabEntity, entities);

for (int x = 0; x < SIDE; x++)
{
for (int z = 0; z < SIDE; z++)
{
int index = x + z * SIDE;
manager.SetComponentData(entities[index], new Position
{
Value = new float3(x, 0, z)
});
}
}
}
finally { entities.Dispose(); }
}

Destroy(cube);
}

private World _world;
}

public sealed class MyCubeSystem : JobComponentSystem
{
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var myCubeJob = new MyCubeJob
{
chunks = EntityManager.CreateArchetypeChunkArray(_query, Allocator.TempJob),
positionType = GetArchetypeChunkComponentType<Position>(false),
time = Time.realtimeSinceStartup
};
return myCubeJob.Schedule(myCubeJob.chunks.Length, 16, inputDeps);
}

[BurstCompile(Accuracy.Med, Support.Relaxed)]
struct MyCubeJob : IJobParallelFor
{
[ReadOnly] [DeallocateOnJobCompletion] public NativeArray<ArchetypeChunk> chunks;
public ArchetypeChunkComponentType<Position> positionType;
public float time;

public unsafe void Execute(int chunkIndex)
{
var chunk = chunks[chunkIndex];
var positions = chunk.GetNativeArray(positionType);
var positionPtr = (Position*)positions.GetUnsafePtr();
for (int i = 0, chunkCount = chunk.Count; i < chunkCount; i++, positionPtr++)
positionPtr->Value.y = math.sin(time + 0.2f * (positionPtr->Value.x + positionPtr->Value.z));
}
}
private readonly EntityArchetypeQuery _query = new EntityArchetypeQuery
{
Any = System.Array.Empty<ComponentType>(),
None = System.Array.Empty<ComponentType>(),
All = new ComponentType[] { ComponentType.Create<Position>(), ComponentType.Create<MeshInstanceRenderer>() }
};
}