本記事を書くにあたって
Unity:ECSとJobSystemsでFractal Brownian Motion(fBm)ノイズを生成
自身のはてなブログにて、ECS を使ってノイズ地形生成にチャレンジしようとしたのですが
非常に取っつき難かったので、当時自分が求めていた形で入門記事を書きました。
はじめて Unity ECS に触れる人の勉強時間が少しでも短くなると嬉しいです。
本記事の内容
Unity ECS + Job Systems + Burst Compile を使って、100 x 100 で平面に敷き詰めた計 10000 個の Cube の高さを Noise 関数で更新するデモの具体的な実装手順が示されています。
初投稿から一年経ち、実装内容が古くて動作しなくなったので Unity 2019.3.0.b に合わせて内容を更新しました (2019/09/21)
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)が切り開く新しいプログラミングより(画像クリックで動画へ飛びます)
Hajimeteno! ECS
新しい構文を学ぶので、最も簡素なサンプルから始めます。
お題として大量の Cube を平面に敷き詰めて、ノイズを使ってその場で高さを変更するデモを選びました。
実行環境(2019/09/21時点での最新)
Windows 10 バージョン 1903
Unity 2019.3.0b4
Entities preview - 0.1.1
ECS 利用準備
Window > Package Manager を選択して
All packages フィルタにし Show preview packages にチェックを入れて表示される Entities を選んで Install ボタンを押してプロジェクトで ECS を使えるようにします。
Package Manager のダイアログ
Entity Debugger から Default World の動作確認
Window > Analysis > Entity Debugger を選択して、空シーンを実行します。
Entity Debugger 空のシーン実行時の様子
ECS はすでに機能していることが確認できました。
これらは SystemGroup に所属している ComponentSystem です。
処理対象の Entity を待ち構えている様子なのですが、シーン内に Entity が無い旨のメッセージが確認できます。
ECS World
最初から存在する Default World を使うことにします。
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 での箱の表示方法は次の通りです。
Package Manager で Hybrid Renderer をインストールし
次の Class 実装を OneCube.cs に書いてシーンに配置します。
using Unity.Entities;
using Unity.Mathematics;
using Unity.Rendering;
using Unity.Transforms;
using UnityEngine;
public class OneCube : MonoBehaviour
{
void Start()
{
CreateCube();
}
void CreateCube()
{
var manager = World.Active.EntityManager;
// Entity が持つ Components を設計
var archetype = manager.CreateArchetype(
ComponentType.ReadWrite<LocalToWorld>(),
ComponentType.ReadWrite<Translation>(),
ComponentType.ReadOnly<RenderMesh>());
// 上記の Components を持つ Entity を作成
var entity = manager.CreateEntity(archetype);
// Entity の Component の値をセット(位置)
manager.SetComponentData(entity, new Translation() { Value = new float3(0, 1, 0) });
// キューブオブジェクトの作成
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
// Entity の Component の値をセット(描画メッシュ)
manager.SetSharedComponentData(entity, new RenderMesh()
{
mesh = cube.GetComponent<MeshFilter>().sharedMesh,
material = cube.GetComponent<MeshRenderer>().sharedMaterial,
subMesh = 0,
castShadows = UnityEngine.Rendering.ShadowCastingMode.Off,
receiveShadows = false
});
// キューブオブジェクトの削除
Destroy(cube);
}
}
Entity Debugger には確かに Entity が見えている模様
Cube を敷き詰める
先程作成した Entity の Archetype の Component に Prefab を追加して PrefabEntity 化します。
あとは Instantiate 関数を使って、10000個作り、それぞれの位置を初期化します。
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Rendering;
using Unity.Transforms;
using UnityEngine;
public class Cubes : MonoBehaviour
{
void Start()
{
CreateCubes();
}
void CreateCubes()
{
var manager = World.Active.EntityManager;
// Entity が持つ Components を設計(Prefabとして)
var archetype = manager.CreateArchetype(
ComponentType.ReadOnly<Prefab>(),
ComponentType.ReadWrite<LocalToWorld>(),
ComponentType.ReadWrite<Translation>(),
ComponentType.ReadOnly<RenderMesh>());
// 上記の Components を持つ Entity を作成
var prefab = manager.CreateEntity(archetype);
// Entity の Component の値をセット(位置)
manager.SetComponentData(prefab, new Translation() { Value = new float3(0, 1, 0) });
// キューブオブジェクトの作成
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
// Entity の Component の値をセット(描画メッシュ)
manager.SetSharedComponentData(prefab, new RenderMesh()
{
mesh = cube.GetComponent<MeshFilter>().sharedMesh,
material = cube.GetComponent<MeshRenderer>().sharedMaterial,
subMesh = 0,
castShadows = UnityEngine.Rendering.ShadowCastingMode.Off,
receiveShadows = false
});
// キューブオブジェクトの削除
Destroy(cube);
const int SIDE = 100;
using (NativeArray<Entity> entities = new NativeArray<Entity>(SIDE * SIDE, Allocator.Temp, NativeArrayOptions.UninitializedMemory))
{
// Prefab Entity をベースに 10000 個の Entity を作成
manager.Instantiate(prefab, entities);
// 平面に敷き詰めるように Translation を初期化
for (int x = 0; x < SIDE; x++)
{
for (int z = 0; z < SIDE; z++)
{
int index = x + z * SIDE;
manager.SetComponentData(entities[index], new Translation
{
Value = new float3(x, 0, z)
});
}
}
}
}
}
最初に作った entity を prefab に残りの entity は Instantiate するという書式を使っています。
必要な個数分繰り返し CreateEntity をした結果と同じになります。しかし、この書式の方がメモリアロケーションを細切れに行わずに済み、かつSetSharedComponentDataによる無駄なChunk移動を繰り返さないのでパフォーマンスが良いです。
Entity のパラメータ変更は SetComponentData を使って行う決まりです。
ComponentSystem を作成する
Cube の配置や描画などの基本的な機能はビルトインの ComponentSystem に頼って実現してきました。
Cube の高さを変更する機能を追加したいのでカスタム NoiseHeightSystem を作ります。
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;
public class NoiseHeightSystem : ComponentSystem
{
protected override void OnCreate()
{
base.OnCreate();
this.query = GetEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadWrite<Translation>() },
});
}
protected override void OnUpdate()
{
var time = Time.realtimeSinceStartup;
Entities.ForEach((ref Translation translation) => {
translation.Value.y = 3 * noise.snoise(new float2(time + 0.02f * translation.Value.x, time + 0.02f * translation.Value.z));
});
}
EntityQuery query;
}
World には様々な archeType の Entity が存在することになるのですが、その中から特定の archeType のものだけフィルタリングして取得するために EntityQuery を使いました。
プロジェクト内に上記の実装があるだけで、敷き詰めた Cube が波打つシーンを確認できます。
プロファイラーを見るとメインスレッドのみ頑張っていて、およそ 49ms を要してます。
Job Component System による高速化
JobComponentSystem に書き換えると、結果はこの通り、劇的な高速化が確認できました。
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;
public class NoiseHeightSystem : JobComponentSystem
{
struct TranslationNoise : IJobForEach<Translation>
{
public float time;
public void Execute(ref Translation translation)
{
translation.Value.y = 3 * noise.snoise(new float2(time + 0.02f * translation.Value.x, time + 0.02f * translation.Value.z));
}
}
protected override void OnCreate()
{
base.OnCreate();
this.query = GetEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadWrite<Translation>()},
});
}
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var job = new TranslationNoise() { time = Time.realtimeSinceStartup };
return job.Schedule(this, inputDeps);
}
EntityQuery query;
}
次の通り Profiler で確認した感じ、マルチスレッドにより並列化していることも確認できました。13 ms ほど要しています。
Burst Compile
Burst Compile はコードの変更は行わずに、Job の Execute 関数内の処理を SIMD 命令という複数の演算を一つの命令で処理するテクニックによる高速化をもたらします。
具体的な変更は次の通り、Job 構造体に BurstCompile 属性を追加するのみです。
[BurstCompile]
struct TranslationNoise : IJobForEach<Translation>
{
public float time;
public void Execute(ref Translation translation)
{
translation.Value.y = 3 * noise.snoise(new float2(time + 0.02f * translation.Value.x, time + 0.02f * translation.Value.z));
}
}
結果、さらに高速化しました。0.3 ms ほど要しています。
まとめ
はじめての ECS と題して
ECS がどんなゲーム要素に向いているのか考察し
簡素なサンプルとして Cube を平面に敷き詰めて、波で Cube の高さを更新するデモを実装しました。
初めに ECS を使って 100 x 100 の計 10000 個の Cube の高さを更新するのに 49 ms 要することを確かめ
ECS + JobComponentSystem を使って、同じ計算結果がマルチスレッド化により所要時間がスレッド数分の1(13ms)となることを確認し
ECS + JobComponentSystem + BurstCompile を使って、同じ計算結果がデタラメに高速で得られること(0.3ms)を確認しました。
筆者が ECS を触る上で最初に確認したかったことは以上です。
ここまで読んでくださりありがとうございました。
以下、ECS + Job Systems + Burst Compile の動作確認時の全コード内容です。
ファイルは2つです。
Cubes.cs
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Rendering;
using Unity.Transforms;
using UnityEngine;
public class Cubes : MonoBehaviour
{
void Start()
{
CreateCubes();
}
void CreateCubes()
{
var manager = World.Active.EntityManager;
// Entity が持つ Components を設計(Prefabとして)
var archetype = manager.CreateArchetype(
ComponentType.ReadOnly<Prefab>(),
ComponentType.ReadWrite<LocalToWorld>(),
ComponentType.ReadWrite<Translation>(),
ComponentType.ReadOnly<RenderMesh>());
// 上記の Components を持つ Entity を作成
var prefab = manager.CreateEntity(archetype);
// Entity の Component の値をセット(位置)
manager.SetComponentData(prefab, new Translation() { Value = new float3(0, 1, 0) });
// キューブオブジェクトの作成
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
// Entity の Component の値をセット(描画メッシュ)
manager.SetSharedComponentData(prefab, new RenderMesh()
{
mesh = cube.GetComponent<MeshFilter>().sharedMesh,
material = cube.GetComponent<MeshRenderer>().sharedMaterial,
subMesh = 0,
castShadows = UnityEngine.Rendering.ShadowCastingMode.Off,
receiveShadows = false
});
// キューブオブジェクトの削除
Destroy(cube);
const int SIDE = 100;
using (NativeArray<Entity> entities = new NativeArray<Entity>(SIDE * SIDE, Allocator.Temp, NativeArrayOptions.UninitializedMemory))
{
// Prefab Entity をベースに 10000 個の Entity を作成
manager.Instantiate(prefab, entities);
// 平面に敷き詰めるように Translation を初期化
for (int x = 0; x < SIDE; x++)
{
for (int z = 0; z < SIDE; z++)
{
int index = x + z * SIDE;
manager.SetComponentData(entities[index], new Translation
{
Value = new float3(x, 0, z)
});
}
}
}
}
}
NoiseHeightSystem.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;
public class NoiseHeightSystem : JobComponentSystem
{
[BurstCompile]
struct TranslationNoise : IJobForEach<Translation>
{
public float time;
public void Execute(ref Translation translation)
{
translation.Value.y = 3 * noise.snoise(new float2(time + 0.02f * translation.Value.x, time + 0.02f * translation.Value.z));
}
}
protected override void OnCreate()
{
base.OnCreate();
this.query = GetEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadWrite<Translation>()},
});
}
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var job = new TranslationNoise() { time = Time.realtimeSinceStartup };
return job.Schedule(this, inputDeps);
}
EntityQuery query;
}