Unity を検索

プロジェクトの開発中に DOTS への転換を決断した背景と得られたパフォーマンス

2019年11月27日 カテゴリ: テクノロジー | 12 分 で読めます
取り上げているトピック
シェア

私たちは現在、Data-Oriented Tech Stack(DOTS)を基盤に、Unity のコアの再構築に取り組んでいます。すでに多くのゲームスタジオで、Entity Component System(ECS)、C# Job System、Burst コンパイラーを活用すれば圧倒的なパフォーマンスが得られることが認知されつつあります。そんな中、Far North Entertainment 社が Unite Copenhagen で講演を行いました。その講演で、同社が DOTS 以前の技術で開発された Unity プロジェクトをどのように DOTS の各種機能を活用する形に移行したかが詳しく解説されました。

Far North Entertainment 社は元々同じエンジニアリング関連の学校で学んでいた 5 人が共同で設立したスウェーデンのゲームスタジオです。2018 年のはじめに Gear VR 向けに『Down to Dungeon』をリリースして以来、この会社は荒れ果てた世界でゾンビと戦いながら生き残るという、昔から PC ゲームではおなじみのジャンルのゲームの開発に取り組んできました。彼らのプロジェクトを特別なものにしているのは、ゲームでプレイヤーを追いかけてくるゾンビの数です。開発チームはこれまでのどんな作品でも見たことのないようなゾンビの大群が追いかけてくるゲームを作ろうとしたのです。

しかしこのアイデアを元にプロトタイピングを行った段階で、開発チームは早くも多数のパフォーマンス上の問題を抱えることになりました。ゾンビを出して、消して、アップデートして、アニメーションさせる。これらすべてが重大なボトルネックとなったのです。オブジェクトプールやアニメーションのインスタンシングなど、考えうる手立てをすべて打っても状況は改善しませんでした。

この状況を見たスタジオの CTO、Anders Eriksson 氏は DOTS に注目し、そしてオブジェクト指向からデータ指向へと発想を転換する方法について模索しはじめました。「オブジェクトとその階層構造について考えるのをやめ、データと、データがどのように変換されたりアクセスされたりするかを考えるようにしたのです。これは私たちが当時陥っていた状況を変化させる鍵となったひらめきでした」と Anders は語ります。このような考え方の転換によって、現実世界で実体を持つなにものかに紐づけてコードをモデル化する必要がなくなり、また、最も一般的なケースについて解決を図る必要もなくなったのです。彼は同じような考え方の転換を試みている人に広く役立つ助言をしてくれました。

「いま解決しようとしている問題は何かと自分自身に問いかけて、具体的な解決法に関係するデータは何かを深く考えるのです。同じデータのセットに同じ変換を何度もかけていませんか。1 本の CPU キャッシュラインにどのくらい関連するデータを詰め込めるでしょうか。既存のコードを置き換えることを検討しているなら、キャッシュラインにどのくらいゴミデータが混入しているか把握しましょう。計算処理を複数のスレッドに分けることはできるでしょうか。SIMD 命令を利用できないでしょうか ― こうした要素をすべて考慮するのです。」

このコンテンツはサードパーティのプロバイダーによってホストされており、Targeting Cookiesを使用することに同意しない限り動画の視聴が許可されません。これらのプロバイダーの動画の視聴を希望する場合は、Targeting Cookiesのクッキーの設定をオンにしてください。

チームは Unity のコンポーネントシステムがコンポーネントの列にある ID を調べているにすぎないことを理解しました。システムにはすべてのロジックが含まれ、「アーキタイプ」と呼ばれる固有のコンポーネントシグネチャに基づいてエンティティをフィルターしていますが、コンポーネント自体はただのデータです。「このような理解に達するには、ECS を SQL データベースのように考えるという視点が必要でした。アーキタイプはつまり、列にコンポーネント、行に個別のエンティティを対応させたテーブルなのです。そう考えると、エンティティに何か操作を加えたいときは、システムを使って、このアーキタイプのテーブルにクエリを発行するようにすればいいとわかります」と Anders は言います。

DOTS をはじめよう

Anders はいくつかの段階を経て、現在の理解にたどり着きました。Entity Component System のドキュメンテーションを熟読し、ECS Samples と、Unity が Nordeus 社と共同開発して Unite Austin で発表したサンプルを研究しました。Unity が提供しているリソース以外の、一般的なデータ指向設計に関する資料もチームにとって非常に役立ちました。「Mike Acton が CppCon 2014 で行ったデータ指向設計に関する講演を見たことで、チームははじめてデータ指向設計に基づくプログラミングを理解する第一歩を踏み出せたように思います。」

Far North Entertainment 社のチームは、彼らが学んだことを同社の開発者ブログに投稿し、この 9 月には Unite Copenhagen でデータ指向の考え方に切り替えたときの体験について講演を行いました。

このコンテンツはサードパーティのプロバイダーによってホストされており、Targeting Cookiesを使用することに同意しない限り動画の視聴が許可されません。これらのプロバイダーの動画の視聴を希望する場合は、Targeting Cookiesのクッキーの設定をオンにしてください。

本ブログ記事はこの講演の内容を元にしており、Far North Entertainment 社のチームが ECS、C# Job System、Burst コンパイラーを活用するために独自に行った実装について詳しく説明します。彼らはプロジェクトで使ったコード例も快く提供してくれました。

ゾンビのデータを準備する

「私たちが取り組んだ問題は、数千ものエンティティの移動と回転をクライアント側で補間するというものでした」と Anders は振り返ります。彼らはまずオブジェクト指向によるアプローチで、汎用的な EntityView を継承した ZombieView スクリプトの骨組みを作ろうとしました。EntityView はゲームオブジェクトにアタッチされる MonoBehaviour で、ゲームモデルの視覚的な表現としてふるまいます。すべての ZombieView は、その Update 関数で自分自身の移動と回転の補間を扱う責任を負っていました。

すべてのエンティティがメモリのランダムな位置に割り当てられるということを気にしなくていいのであれば、このアプローチは上手くいくでしょう。しかしこのような状態では、アクセスするエンティティの数が数千に達したとき、CPU はメモリ上でエンティティが割り当てられているバラバラの領域を 1 つずつ見ていく必要に迫られ、その処理速度は非常に遅くなります。きちんと整理された連続ブロックにデータを配置すれば、CPU は 1 サイクルですべてのデータをキャッシュすることができます。今日手に入るほとんどの CPU は、128 ビットから 256 ビットのデータをキャッシュから読み込むことができます。

これを見てチームは敵キャラの実装を DOTS に置き換えることにしました。そうすることでクライアント側で起きているパフォーマンス上のボトルネックを解消しようとしたのです。チームが何より優先したのは、ZombieView の Update 関数の置き換えでした。チームは別のシステムに切り出すべき部分を特定し、そこでどのようなデータが必要かを見定めました。真っ先に切り出すべき部分は位置と回転の補間を行う部分だということは明らかでした。ゲーム世界が 2D グリッドで構成されていたためです。2 つの浮動小数点数がゾンビの向かう先を表しており、最後に入るコンポーネントは敵キャラのサーバー側での位置情報を追跡するターゲットの位置のコンポーネントでした。


[Serializable]
public struct PositionData2D : IComponentData
{
    public float2 Position;
}


[Serializable]
public struct HeadingData2D : IComponentData
{
    public float2 Heading;
}

[Serializable]
public struct TargetPositionData : IComponentData
{
    public float2 TargetPosition;
}

次に敵キャラのアーキタイプを作成しました。アーキタイプは特定のエンティティに属するコンポーネントのセット、つまりコンポーネントのシグネチャのことです。

このプロジェクトではアーキタイプを定義するためにプレハブを使っています。これは個別の敵キャラについて(アーキタイプより)多くのコンポーネントが必要で、ゲームオブジェクトへの参照が必要な敵キャラも存在するためです。このやり方を上手く動作させる仕掛けは ComponentDataProxy オブジェクトにあります。このオブジェクトがコンポーネントデータをプレハブにアタッチできる MonoBehaviour に変換します。EntityManager でプレハブを渡して instantiate を呼ぶと、プレハブにアタッチされたすべてのコンポーネントデータが含まれるエンティティが生成されます。すべてのコンポーネントデータは 16KB のメモリチャンクに格納されます。このメモリチャンクを ArchetypeChunk と呼ばれます。

ここまでの説明を踏まえて、アーキタイプチャンク内のコンポーネントのストリームがどのように構成されるかを図解すると次の図のようになります。

「アーキタイプチャンクを使う大きな利点の 1 つに、新しくエンティティを作るときに新たなヒープ割り当てを行う必要がないケースがよく出ることが挙げられます。これはメモリが前もって割り当てられているからで、こうなるとエンティティの作成は単にアーキタイプチャンクの中にあるコンポーネントのストリームの終わりまでデータを書き込むだけのことになります。新たにヒープ割り当てが必要になるのは、チャンクの境界に収まらないサイズのエンティティを作ろうとしたときだけです。この場合はもう 1 つ 16KB のアーキタイプチャンクが割り当てられますが、もし同じアーキタイプのために作られた空のチャンクがあるならば、それが再利用されます。そして、新しいエンティティのデータが新しいチャンクのコンポーネントのストリームに書き込まれます。」とAnders は説明してくれました。

ゾンビをマルチスレッド化する

データをぎっちりとパッキングし、かつキャッシュしやすい形でメモリ内にレイアウトするようにしたおかげで、チームは C# Job System の長所を活かし、簡単にコードを複数の CPU コアで並列実行させることができました。

次のステップは、すべてのアーキタイプチャンクから PositionData2D、HeadingData2D、TargetPositionData の各コンポーネントを持つエンティティをすべて排除するシステムの構築でした。

これを実現するため、Anders と彼のチームは JobComponentSystem を作成し、OnCreate 関数でそのクエリを構築するようにしました。コードは次のようになります。


private EntityQuery m_Group;

protected override void OnCreate()
{
	base.OnCreate();

	var query = new EntityQueryDesc
	{
		All = new []
		{
			ComponentType.ReadWrite<PositionData2D>(),
			ComponentType.ReadWrite<HeadingData2D>(),
			ComponentType.ReadOnly<TargetPositionData>()
		},
	};

	m_Group = GetEntityQuery(query);
}

このコードはワールド内で位置、方向、および目標を持っているすべてのエンティティを排除するクエリがあることを宣言します。チームの次の狙いは、複数のワーカースレッドに計算処理を分散するために、C# Job System を介して毎フレームジョブをスケジュールすることでした。

「C# Job System のすごいところは、それが Unity のコードの内部で使われているものと同じシステムであるということです。このため、私たちは実行スレッド同士で互いに同じ CPU コアを占有しようとして停止してしまったり、パフォーマンス上の問題を引き起こしたりすることを気にする必要がありませんでした」と Anders は語ります。

チームは IJobChunk を使うことにしました。敵キャラが数千体もいて、実行時にクエリにマッチするアーキタイプチャンクが大量に発生するためです。IJobChunk は個別のワーカースレッドに合わせて必要なチャンクを配分してくれます。

毎フレーム、UpdatePositionAndHeadingJob という名前の新しいジョブがゲーム内の敵キャラの位置と回転の補間を制御します。

ジョブをスケジュールするコードは次のようになります。


protected override JobHandle OnUpdate(JobHandle inputDeps)
{
	var positionDataType       = GetArchetypeChunkComponentType<PositionData2D>();
	var headingDataType        = GetArchetypeChunkComponentType<HeadingData2D>();
	var targetPositionDataType = GetArchetypeChunkComponentType<TargetPositionData>(true);

	var updatePosAndHeadingJob = new UpdatePositionAndHeadingJob
	{
		PositionDataType = positionDataType,
		HeadingDataType = headingDataType,
		TargetPositionDataType = targetPositionDataType,
		DeltaTime = Time.deltaTime,
		RotationLerpSpeed = 2.0f,
		MovementLerpSpeed = 4.0f,
	};

	return updatePosAndHeadingJob.Schedule(m_Group, inputDeps);
}

ジョブの宣言は次のようになります。


public struct UpdatePositionAndHeadingJob : IJobChunk
{
    public ArchetypeChunkComponentType<PositionData2D> PositionDataType;
    public ArchetypeChunkComponentType<HeadingData2D> HeadingDataType;

    [ReadOnly]
    public ArchetypeChunkComponentType<TargetPositionData> TargetPositionDataType;

    [ReadOnly] public float DeltaTime;
    [ReadOnly] public float RotationLerpSpeed;
    [ReadOnly] public float MovementLerpSpeed;
}

こうすると、ワーカースレッドがキューからジョブをプルしたとき、ワーカースレッドからそのジョブの実行カーネルが呼び出されます。

実行カーネルはこのような形になります。


public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
{
	var chunkPositionData       = chunk.GetNativeArray(PositionDataType);
	var chunkHeadingData        = chunk.GetNativeArray(HeadingDataType);
	var chunkTargetPositionData = chunk.GetNativeArray(TargetPositionDataType);

	for (int i = 0; i < chunk.Count; i++)
	{
		var target       = chunkTargetPositionData[i];
		var positionData = chunkPositionData[i];
		var headingData  = chunkHeadingData[i];

		float2 toTarget = target.TargetPosition - positionData.Position;
		float distance  = math.length(toTarget);

		headingData.Heading = math.select(
			headingData.Heading,
			math.lerp(headingData.Heading,
					math.normalize(toTarget),
					math.mul(DeltaTime, RotationLerpSpeed)),
			distance > 0.008
		);

		positionData.Position = math.select(
			target.TargetPosition,
			math.lerp(
				positionData.Position,
				target.TargetPosition,
				math.mul(DeltaTime, MovementLerpSpeed)),
			distance <= 1
		);

		chunkPositionData[i] = positionData;
		chunkHeadingData[i]  = headingData;
	}
}

「分岐の代わりに select を使っているのに気づかれたかもしれません。こうしている理由は分岐予測の失敗を避けるためです。select 関数は両方の式を評価し、条件にマッチする方を選択します。使っている式の計算が重くないなら、switch の方を使うことをおすすめします。分岐予測の失敗から CPU が回復するのを待つのに比べればたいてい switch のほうがコストが安いからです」こう Anders は強調します。

パフォーマンスを爆発的に上げる

敵キャラの位置と方向の補間を DOTS に移行する作業の最終ステップは Burst コンパイラーを有効にすることでした。Anders にとってこれは非常に簡単だったそうです。「連続した配列にデータがレイアウトされていたのと、Unity が新たに公開した Mathematics ライブラリを使っていたので、あとは BurstCompile 属性をジョブに加えるだけでした。」


[BurstCompile]
public struct UpdatePositionAndHeadingJob : IJobChunk
{
    public ArchetypeChunkComponentType<PositionData2D> PositionDataType;
    public ArchetypeChunkComponentType<HeadingData2D> HeadingDataType;

    [ReadOnly]
    public ArchetypeChunkComponentType<TargetPositionData> TargetPositionDataType;

    [ReadOnly] public float DeltaTime;
    [ReadOnly] public float RotationLerpSpeed;
    [ReadOnly] public float MovementLerpSpeed;
}

Burst コンパイラーを使うと、Single Instruction Multiple Data(SIMD)マシン命令を使って、1 つの命令で複数の入力データセットに対して演算を行い、複数の出力データセットを得ることができます。この性質により、適切なデータで 128 ビットのキャッシュバスをより効率よく使うことができます。

Burst コンパイラーをキャッシュに適したデータレイアウトおよびジョブシステムと組み合わせて使うことで、チームはゲームの実行スピードの大幅な向上を実現できました。次に示すのは、移行ステップごとの実行時間の変化を測定してまとめたパフォーマンスの一覧表です。

この表に示された数値から、Far North Entertainment 社のチームはクライアント側で行うゾンビの位置と方向の補間に関連するボトルネックを完全に解消できたことがわかります。データはキャッシュしやすい形にレイアウトされ、キャッシュラインには関連するデータだけが詰まっています。CPU のすべてのコアがフル稼働し、Burst コンパイラーは SIMD 命令で高度に最適化されたマシンコードを出力します。

Far North Entertainment 社が贈る DOTS のヒントとコツ

  • ECS ではエンティティは並列なコンポーネントデータのストリームでインデックスを検索するものであるため、データのストリームを意識して実装を考えることを始めましょう。
  • ECS をアーキタイプがテーブルとして扱われるリレーショナルデータベースとして考えましょう。この考え方によれば、コンポーネントは列でエンティティはテーブルのインデックス(行)に相当します。
  • 連続した配列にデータを整理して、CPU キャッシュとハードウェアのプリフェッチャーを活用できるようにしましょう。
  • オブジェクトの階層を作りたいと思っても一旦それを忘れましょう。また、解決しようとしている問題の本質を理解する前に一般的なソリューションを作ろうとするのは止めましょう。
  • ガベージコレクションについて考えましょう。パフォーマンスが重要な領域で過剰なヒープ割り当てをすることは避け、Unity の新しいネイティブコンテナーを使うようにしましょう。ただし、メモリのクリーンアップについては手動で行わなければならないので注意してください。
  • 抽象化のコストを理解し、仮想関数呼び出しによるオーバーヘッドを考慮しましょう。
  • C# Job System を使って、CPU のすべてのコアをフル稼働させましょう。
  • ハードウェアのことを理解しましょう。Burst コンパイラーがきちんと SIMD 命令を生成しているか確認しましょう。解析には Burst インスペクターが便利です。
  • キャッシュラインを無駄遣いするのは止めましょう。UDP パケットにデータをパッキングするときのように、データをキャッシュラインにパッキングすることを考えましょう。

 

そして Anders Eriksson 氏がすでにプロジェクトの開発に入っているすべての人に一番届けたいというアドバイスは「開発中のゲームでパフォーマンスの問題が起きている領域を特定し、孤立している領域に DOTS を導入できるかを検討しましょう。なにもコードベース全体を作り変えなければないということはないのです」という、先に挙げたものに比べてより汎用性のあるものでした。

さらに先へ

「今後も私たちのゲームのさらに広い領域に DOTS を適用していこうと思います。また、DOTS によるアニメーション、Unity Physics、Live Link が Unite で発表されたことに非常に刺激を受けました。これらの技術を活用して、私たちのゲームのオブジェクトをどんどん ECS のエンティティに変換していければと思います。Unity の開発も順調に進み、それが実現できる世界が近づいていると思います」と Anders は締めくくりました。

Far North Entertainment 社のチームにもっと質問したい方は、 同社の Discord にぜひご参加ください。

また、Unite Copenhagen の DOTS 関連講演の録画リストにある動画で、他のゲームスタジオで DOTS がゲーム速度の向上にどのように役立てられているか、また、DOTS Physics、新しい変換ワークフロー、Burst コンパイラーなど、これから登場してくる DOTS ベースのコンポーネントをどのように組み合わせれば良いかについて、学ぶことができます。

2019年11月27日 カテゴリ: テクノロジー | 12 分 で読めます
取り上げているトピック