502 Views
June 27, 18
スライド概要
2018/6/27に開催されたGTMF2018OSAKAの講演資料です。
講師:山村 達彦(ユニティ・テクノロジーズ・ジャパン合同会社)
リアルタイム3Dコンテンツを制作・運用するための世界的にリードするプラットフォームである「Unity」の日本国内における販売、サポート、コミュニティ活動、研究開発、教育支援を行っています。ゲーム開発者からアーティスト、建築家、自動車デザイナー、映画製作者など、さまざまなクリエイターがUnityを使い想像力を発揮しています。
ハードウェアの性能を活かす為の、 Unityの新しい3つの機能 山村 達彦 ユニティ・テクノロジーズ・ジャパ ン
Unityの目指しているもの ゲーム開発の民主化 難しい問題の解決 今回得に解決するもの 成功を支援
環境にフィットしたエンジン 最近余り良くない グラフィック Vulkan / Metal / DirectX / OpenGL エンジンの機能 内部でプラットフォームのAPIを使う オブジェクト制御 Monobehaviour 環境毎にある程度最適化 …どうだろう
スクリプト、基礎から考え直す GameObject MonoBehaviour (簡単、様々な用途で使える。だが遅い) (パフォーマンスのために 難しい事をする必要がある) ECS C# Job System Burstコンパイラ (よりHWに寄り添う)
Performance By Default “ デフォルトで高性能なコードを ”
3つの機能 ・Entity Component System (ECS) ・C# Job System ・Burst Compiler 今回はコレの話
・Entity Component System (ECS) ・C# Job System ・Burst Compiler
オブジェクトを制御する “ E ntity C omponent S ystem ”
コンポーネントは状態と処理を持つ GameObjectはコンポーネントのコンテナ GameObject Transform Car GameObject • ゲーム世界内での物体 • 複数のComponentを持つ Component • GameObjectの機能 • フィールドを持つ • ロジックを持つ • 他のComponentを操作する (Transform等)
EntityはComponentDataのコンテナ ComponentDataは値のみを持つ Entity Position Wheel Engine Entity • ゲーム世界でのモノ • 複数のComponentDataを持つ ComponentData • Entityの持つ概念 • 値を持つ (持たない事もある) • ロジックは持たない
ComponentSystemがEntityを一括制御 操作対象はComponentDataでフィルタリング Entity Position Wheel Engine Group • フィルタリングした データをシステムに渡す ComponentSystem • 毎フレーム呼ばれる
“ なるほど わからん 大体の人の意見 ”
通常のクラスの場合 はんどる はんどるデータ はんどるロジック 乗り物 has-a たいや is a たいやデータ has-a くるま has-a 乗り物=クルマの動きを表現するために、様々な種類のパーツを持つ 各パーツは自身のステート、自身のロジックに沿って振る舞いを決める たいやロジック えんじん えんじんデータ えんじんロジック
通常のクラスの場合 ○○マネージャー 乗り物 is a 自身の振る舞いを決めるため、大抵は他のオブジェクトへ参照できるようにする 誰でも何処からでも参照できるシングルトンさんはお友達
通常クラスの場合 ○○マネージャー is a has a is a 乗り物 is a
ECSの場合 ハンドルの入力 ハンドル 曲がる動作 タイヤ エンジン 加速する動作 Entityの持つComponentDataの組み合わせで動作が決まる
Systemの要求するEntityだけ処理する タイヤ タイヤ タイヤ タイヤ 人力 人力 エンジン エンジン 荷物 荷物 人力で 移動 荷物を 運ぶ エンジンで 移動
ECSの場合 タイヤ タイヤ タイヤ タイヤ 人力 人力 エンジン エンジン 荷物 荷物 Entityに「タイヤ」と「エンジン」があれば「車」の振る舞いを実現する type:乗り物のような、特定のクラスは無くても良い
Entityの動作の切り替え タイヤ タイヤ タイヤ タイヤ タイヤ エンジン エンジン エンジン エンジン エンジン 荷物 操作 (AI) (Player) 荷物 ComponentDataを追加・削除して ShardComponentDataを「種類毎」に Systemからのフィルターに入る・外れる 集めて、種類ごとに一括処理する
Systemは誰がEntityを作ったかも知らなくていい タイヤ エンジン タイヤ エンジン タイヤ タイヤ 人力 条件が一致するEntityを自動取得 エンジン タイヤ タイヤ エンジン エンジン タイヤ
Systemは誰がEntityを作ったかも知らなくていい タイヤ タイヤ エンジン エンジン イヤ 勇者 タイヤ 人力 条件が一致するEntityを自動取得 エンジン タイヤ タイヤ エンジン エンジン タイヤ ユニークなEntityが欲しければ、 特別なComponentDataをもたせておく
複数のシステムでEntityの動きを作る コントロール 入力 座標 入力 システム 移動 システム 当たり判定 システム 何か呼ぶ 死亡判定 形状 耐久力 流れ作業のようにComponentDataを操作して、動きを作る
概念ではなく使い方が知りたい! “ ECSを使う ”
手順 1/3 ComponentDataの定義 IComponentDataを継承 public struct Engine : IComponentData { public float power; } フィールドのみ持つ 何もデータを持たない事もある IComponentData を定義
手順 2/3 ComponentDataの組み合わせ定義 var entity_manager = World.Active.GetOrCreateManager<EntityManager>(); arche_type = entity_manager.CreateArchetype( typeof(Position) , typeof(Rotation) , typeof(Engine) , typeof(Wheel) 先程定義したComponentData , typeof(Handle) , typeof(MeshInstanceRenderer)); Entityに含める”ComponentDataの組わせを”作成
手順 3/3Entity つくる
CreateEntityでEntityを作成
var entity = entity_manager.CreateEntity(arche_type);
SetComponentDataで初期化
var entity = entity_manager.CreateEntity(arche_type);
var pos0 = new Position { Value = new float3(0,0,0), };
entity_manager.SetComponentData<Position>(entity, pos0);
Entityが一つ出来た Entity Position Wheel Engine Handle Rotation Renderer
手順(1/4) Systemを作る public class CarSytem: ComponentSystem { ComponentSystemを継承 実行順がある場合は、 UpdateAfter(type)属性で 呼び出しタイミングを制御 }
手順(2/4) グループを作成 public class CarSytem: ComponentSystem { タイヤとエンジンを struct Group { 持つEntity public int Length; 条件を public ComponentDataArray<Wheel> wheels; [ReadOnly] public ComponentDataArray<Engine> engines; フィルタリング private SubtractiveComponent<Wing> wings; } } 羽を持つものは除く
手順(3/4) グループを注入 public class CarSytem: ComponentSystem { struct Group { public int Length; 条件を public ComponentDataArray<Wheel> wheels; [ReadOnly] public ComponentDataArray<Engine> engines; フィルタリング private SubtractiveComponent<Wing> wings; } [Inject] Group group; } グループの条件に一致した ComponentDataが自動的に登録される
手順(4/4) 処理を追加 public class CarSytem: ComponentSystem { 毎フレーム呼ばれる処理 … [Inject] Group group; protected override void OnUpdate() { for(int i=0; i<group.Length; i++){ var engine = group.engines[i]; 普通のC# 要素の分だけ回す //何かの処理 group.engines[i] = engine; } } } グループと一致した ComponentDataを 取り出して色々と処理
Entityを制御するシステムも出来た Entity Position Wheel Engine 車を表現するシステム Model Model Handle Rotation Renderer Model Model
ハードのお勉強 “ で、ECSは 何が良いの? ”
メモリキャッシュ・CPUコア 1アクセスの間に 200サイクルも 処理ができる CPU CPU メモリのアクセス速度と CPUの性能は大きな差がある メモリからデータをロード するのは時間がかかる メモリ
メモリキャッシュ・CPUコア CPU 2〜3 サイクル 20〜30 サイクル CPU キャッシュを活用して L1 キャッシュ L1 キャッシュ L2キャッシュ ギャップを緩和 キャッシュから使えば 100倍早くなる メモリ
CPU キャッシュライン 一回でロードするのは64バイト 1byteしかデータを使用しない 64 byte L1 キャッシュ 場合でも64byte単位でロード 64 byte L2キャッシュ いかに無駄なくデータを 配置できるかがポイント 64 byte メモリ
「クラシック」の問題(1) GameObject 1 Data Rigidbody GameObject 3 Data Transform Data Data Renderer Data GameObject 2 Data Data Data Data Monobehavour Data Data Transform Data Transform Data Data Data Data Monobehavour Rigidbody データが散在していてキャッシュが使いにくい 必要なデータを集めるのに何度もロードが必要 Data Data
「クラシック」の問題(2) MoveScript 太字:必要なデータ 細字:不要なデータ Transform float Speed; Vector3 up; Transform transform Vector3 right; … Vector3 forward; Vector3 position; Vector3 localPosition; Quatrainion rotation; Quatrainion localRotation; … 余計なデータが たくさん含まれる 余計なデータで キャッシュがすぐ埋まる
ECSのメモリレイアウト(見た目) Entity 0 Entity 1 Entity 2 Entity 3 Positoin Positoin Positoin Positoin Rotation Rotation Rotation Rotation Speed Speed Speed Speed EntityはComponentDataを持つコンテナのように見えるが…
ECSのメモリレイアウト(実際に近いイメージ) 0 1 2 3 pos Array Positoin Positoin Positoin Positoin … rot Array Rotation Rotation Rotation Rotation … spd Array Speed Speed Speed Speed … Entity2 Entityは唯のID、実態は構造体の配列 ※実際にはもっと複雑で様々な最適化を含む
ECSのシステムがアクセスするデータ Pos Array Positoin Po Rot Array Rotation Ro Spd Array Speed 使用するデータのみ アクセスする 余計なデータで キャッシュがすぐ埋まらない Sp
プリフェッチ 連続するデータは先読み予測が効く POS キャッシュライン POS POS 読んだ POS POS POS POS 読んだ POS POS POS POS POS 多分次はココやろ 先に読んどいたる
オブジェクトの増減が低コスト POS POS POS POS POS POS POS POS POS POS POS POS POS POS POS POS POS POS 要素を削除したときは POS POS POS POS 最後の要素で塗り直すだけ POS ガベージコレクションは発生しない
メモリはNative Containerで確保 •アンマネージドなメモリを提供 = GCは発生しない •利便性を損なわず効率アップ •IL2CPPではインデクサが1命令 (get/setのオーバーヘッドなし) NativeArray NativeArraySOA •AOSやSOAのなどに対応
NativeArray 生成 var a = new NativeArray<MyStruct>(32, Allocator.Persistent); 解放 a.Dispose(); Allocator.Persistent 永続的に使用可能 Allocator.Temp 同じフレームで解放しないとエラー Allocator.TempJob 4フレーム以内に解放しないとエラー ジョブ終了時に自動開放させるオプション有
ECSまとめ 新しいオブジェクトを制御する機構 コンポーネント志向に近い形のオブジェクト制御 メモリレイアウトがCPUに優しい データ志向のレイアウト キャッシュ、フェッチを活用してアクセスを効率化 ECSで組めばHWを知らなくてもよしなにしてくれる
安全・簡単にマルチコアを活用 “ C# Job System ”
最近のコアは増加傾向にある 1コア辺りのクロック数 コアの数 1989 2018 iOS/Android モバイルですら6〜8コア積んでる時代 ※イメージ図
一つのコアで複数の処理を実行 index[0] index[1] index[2] index[3] index[4] index[5] Work Work Work Work Work Work Core 0 Core 1 ZZZzzz 一つのコアが作業している間、他のコアは仕事が無い
複数のコアで複数の処理を実行 index[0] index[1] index[2] index[3] index[4] index[5] Work Work Work Work Work Work Core 0 Core 1 複数のコアで分担して作業すれば早くおわる だからもっとコアを活用しよう!と言いたいが、
とはいえ、マルチスレッドは難しい 粒度 競合 同期 大きな区分で仕事を任せた方が色々と早いが、分担が難しい 使用中のデータを、誰かが書き換えて結果がおかしくなる 同僚の仕事が終わらないと自分の仕事を始められない デッドロック 互いに完了待ちしてしまい、仕事が停止する バグるとデバッグしんどいです
C# Job Systemでしんどくないマルチスレッド対応 ルールに従えば簡単・安心にマルチコア活用 並列処理で処理が完了するまでの時間を短縮 ECSとも簡単に連携できる Burstを通すと、もっと高速化
メインスレッド以外もガンガン使う
C# Job Systemの2つのAPI IJob 処理 IJobParallelFor Main Main Worker 0 Worker 0 Worker 1 Worker 1 Worker 2 Worker 2 別のコアで処理 メインスレッドはジョブ発行するマン 複数のコアで一括処理 処理が追いつかなければメインスレッドも使って一括処理
C# Job Systemの基本的な流れ(1/3) NativeArray <float3> 計算に利用するデータ pos[0] pos[1] pos[2] pos[3] pos[4] pos[5] NativeArray <int> 結果を格納する用 res[0] NativeArray <int> 最終的な判断する用 ans[0] res[1] res[2] res[3] res[4] res[5] バッファーを用意する Jobの処理は全て確保したバッファーに対して行う
C# Job Systemの基本的な流れ(2/3) NativeArray <float3> 計算に利用するデータ pos[0] pos[1] pos[2] pos[3] pos[4] pos[5] Core 1 で処理 NativeArray <int> 結果を格納する res[0] res[1] Core 2 で処理 res[2] res[3] Core 3 で処理 res[4] 複数のジョブで担当する要素を処理し 結果をバッファに格納する res[5]
C# Job Systemの基本的な流れ(3/3) NativeArray <int> 結果を格納する res[0] res[1] res[2] res[3] res[4] Core 1 で処理 NativeArray <int> 最終的な結果 ans[0] 結果をまとめて最終的な判定を行う res[5]
IJobParallelForを定義(1/3) struct AJob : IJobParallelFor { 継承 public void Execute(int id) { } } IJobParallelForを継承した構造体を定義
IJobParallelForを定義(2/3) struct AJob : IJobParallelFor { public NativeArray<Vector3> positions; public void Execute(int id) { 入出力のバッファ } } Jobが使用するバッファを定義
IJobParallelForを定義(3/3)
struct AJob : IJobParallelFor {
public NativeArray<Vector3> positions;
public void Execute(int id) {
var pos = positions[id];
pos.y += 1f;
Jobが実行する処理
positions[id] = pos;
}
}
Jobの処理をExecuteに記述
idは供給される
IJobParallelForを使う(1/4)
生成
void Update() {
var ajob = new AJob() ;
}
Jobを生成
struct AJob : IJobParallelFor {
public NativeArray<Vector3> positions;
public void Execute(int id) {
var pos = positions[id];
pos.y += 1f;
positions[id] = pos;
}
}
IJobParallelForを使う(2/4)
void Update() {
データ参照
var ajob = new AJob() { positions = m_Positions, };
}
Jobにバッファを登録
struct AJob : IJobParallelFor {
public NativeArray<Vector3> positions;
public void Execute(int id) {
var pos = positions[id];
pos.y += 1f;
positions[id] = pos;
}
}
IJobParallelForを使う(3/4)
struct AJob : IJobParallelFor {
public NativeArray<Vector3> positions;
public void Execute(int id) {
var pos = positions[id];
pos.y += 1f;
positions[id] = pos;
}
}
void Update() {
var ajob = new AJob() { positions = m_Positions, };
var handle = ajob.Schedule(positions.Length, 8);
実行指令
1ジョブでまとめて
実行する回数
}
毎フレームScheduleを呼ぶ
IJobParallelForを使う(4/4)
struct AJob : IJobParallelFor {
public NativeArray<Vector3> positions;
public void Execute(int id) {
var pos = positions[id];
pos.y += 1f;
positions[id] = pos;
}
}
void Update() {
var ajob = new AJob() { positions = m_Positions, };
var handle = ajob.Schedule(positions.Length, 8);
以下は必要に応じて
JobHandle.ScheduleBatchedJobs();
handle.Complete();
}
完了まで待つ
即時ジョブ起動
IJob struct BJob : IJob { 継承 public void Execute() { } }
IJob struct BJob : IJob { public NativeArray<Vector3> positions; public NativeArray<int> result; public void Execute() { } } 入出力
IJob
struct BJob : IJob {
public NativeArray<Vector3> positions;
public NativeArray<int> result;
public void Execute() {
for (var i = 0; i < positions.Length; ++i) {
var pos = positions[i];
if( pos.x > -0.5f && pos.x < 0.5f){
ジョブの実行内容
}
}
}
}
IJob
struct BJob : IJob {
public NativeArray<Vector3> positions;
public NativeArray<int> result;
public void Execute() {
for (var i = 0; i < positions.Length; ++i) {
var pos = positions[i];
if( pos.x > -0.5f && pos.x < 0.5f){
result[0] = 1; return;
}
結果を格納
}
result[0] = 0;
}
}
IJob
struct BJob : IJob {
public NativeArray<Vector3> positions;
public NativeArray<int> result;
public void Execute() {
for (var i = 0; i < positions.Length; ++i) {
var pos = positions[i];
読込専用なら兎も角、読み書きするジョブが複数同時に同じ要素を操作するとヤバイ
if( pos.x > -0.5f && pos.x < 0.5f){
result[0] = 1; return;
}
}
データの読取専用
データの書込専用
}
[ReadOnly]や[WriteOnly]属性をつける
}
参照なので危険がつきまとう
問題が起こりそうならエラーを出してくれる InvalidOperationException: The previously scheduled job Job1:AJob writes to the NativeArray AJob.positions. You are trying to schedule a new job Job1:BJob, which writes to the same NativeArray (via BJob.positions). 要約:複数のジョブが同じNativeArrayに同時に書き込むかも
問題が起こりそうならエラーを出してくれる InvalidOperationException: The native container has been declared as [WriteOnly] in the job, but you are reading from it. 要約:書込専用のNativeArrayを読み込んじゃあかん ありがたや
ジョブの依存関係 void Update() { var ajob = new AJob() { positions = m_Positions, }; var bjob = new BJob() { positions = m_Positions, }; var handle = ajob.Schedule(positions.Length, 8); handle = bjob.Schedule(handle); bJobはaJobが JobHandle.ScheduleBatchedJobs(); 完了したら実行 handle.Complete(); } Scheduleの引数にJobHandleを入れる
ECSとのC# Job Systemの連携(1)
struct RigidbodyPositionJob : IJobParallelFor {
public ComponentDataArray<Unity.Transforms.Position> positions;
public ComponentDataArray<RigidbodyPosition> rigidbodies;
public void Execute(int i){
// 処理
}
}
Injectで取得したComponentDataArrayをそのまま渡せる
ECSとのC# Job Systemの連携(2) 必要なComponentDataはジェネリックで定義 public struct RigidbodyPositionJob : IJobProcessComponentData<Position, RigidbodyPosition> { public void Execute(ref Position pos, ref RigidbodyPosition rot) { // 処理 } } [ReadOnly][WriteOnly]は引数に付与 競合しないようジョブの依存をよしなにしてくれる IJobProcessComponentDataを使う [Inject]記述が不要になる他、若干効率的
High Performance C# Class Type無し Boxing無し GC Allocation無し Exception無し (C# Job Systemの制約) 大丈夫! まだ (一応) C# All Basic Types Struct Enum Generic Properties Safe Sandbox
.NETのThreadと比較して、どう違うの? .NET 4.xやC#7.2対応で 使いやすくなった UnityのWorker Thread上で動作 コンテキストスイッチの増加を防ぐ オーバーヘッドが少ない ※IL2CPPでビルドした場合 使うための制約がキツイ ハイパフォーマンスなC#記述を強制 複数フレームを跨ぐ処理に向かない ケースバイケースで使い分け推奨
.NETのThreadと比較して、どう違うの? .NET 4.xやC#7.2対応で 使いやすくなった UnityのWorker Thread上で動作 コンテキストスイッチの増加を防ぐ オーバーヘッドが少ない ※IL2CPPでビルドした場合 使うための制約がキツイ ハイパフォーマンスなC#記述を強制 複数フレームを跨ぐ処理に向かない ケースバイケースで使い分け推奨
結局、マルチスレッドの難しい点は解決出来たか? • ジョブのまとめて実行する数を指定できる →粒度をある程度コントロールする ・複数のジョブが同じ参照先に書き込み可能だとエラー →競合の発生をエディターで防ぐ
結局、マルチスレッドの難しい点は解決出来たか? ・ジョブはメインスレッドでのみCompleteできる →デッドロックを起こさない ・ジョブはメインスレッドでのみScheduleできる →Completeとの整合性を重視 危険なのは自由すぎるマルチスレッド
厳しい条件下で最適化するコンパイラ “ Burst ”
option 第三の選択肢 2 使い勝手を維持しつつ option IL2CPP 単純にC++へ変換 1 Mono IL option 3 厳しい条件下で最適化 Burstコンパイラ
厳しい条件の上で最適化 High Performance C#前提 Class Type無し 自由に組めると 最適化にも遊びが必要 Boxing無し GC Allocation無し Exception無し ECSのメモリレイアウト前提 データの入出力が確定してる前提 制約を設ける事で スレッドで処理が前提 限界まで最適化
実際、かなり速くなる
様々な最適化 CPU拡張命令を積極的に使う プロセッサ向けの最適化をUnityで行う avx2, sse4, neon, arrch 最適化しやすいMathライブラリも提供 計算の精度を下げる 厳密な結果が必要ない箇所に使うオプション 下手なC++コードより高速らしい
使い方
[Unity.Burst.BurstCompile]
struct AJob : IJobParallelFor
{
IJob系のインターフェースに
public NativeArray<Vector2> positions;
BurstCompile属性をつける
public void Execute(int index)
{
var pos = positions[index];
pos.x += 0.2f;
positions[index] = pos;
}
}
すべての場所で使える訳ではない
Burstでコンパイルした後のアセンブリを確認 アセンブラを書くまでは逆行しないが アセンブラを理解すると価値がある(かも)
PackageManagerで提供 ECSのPackageに同梱 新しいプロセッサが追加された際、 Unityエンジンをアップデートせずに対応
まとめ “ ECSでメモリレイアウト制御(面倒)をよしなにしてくれる ” C# Job Systemでマルチコア対応(面倒)をよしなにしてくれる BurstでCPU演算の最適化(面倒)をよしなにしてくれる
“ 高度なプログラミングを比較的手軽に実現 ” もっと多くの要素をゲームにつぎ込めるように Thank You