Open5

Unity公式DOTSサンプル(EntityComponentSystemSamples)のJob Systemに関するサンプルシーンを見てみる

TakaseTakase

シーン

ゲームオブジェクト(コンポーネント)

  • Spawner(Spawner)
  • Seeker(Seeker, FindNearest)
  • Target(Target)

動作

  • SpawnerでSeekerとTargetが大量生成
  • ランダムなDirectionを指定される
  • UpdateでDirectionに向かって進む
  • FindNearestでSeekerから最も近いターゲットを検索して線を描画
TakaseTakase

Step 1

  • 普通の実装

Step 2

Jobの実行によるBurstコンパイル

  • NativeArrayによって連続したメモリにアクセスするだけなので呼び出しのオーバーヘッドが少ない
  • ここではメインスレッドはJobが完了するのを待つだけなので、メインスレッド自体がJobを実行してる

FindNearest

NativeArray<float3> TargetPositions;

// すべてのターゲット トランスフォームを NativeArray にコピーします。
for (int i = 0; i < TargetPositions.Length; i++)
{
    // Vector3 は暗黙的に float3 に変換されます
    TargetPositions[i] = Spawner.TargetTransforms[i].localPosition;
}
  • Jobに渡せる配列はUnmanagedなNativeArrayのみ
  • コピーする作業が必要
TargetPositions = new NativeArray<float3>(spawner.NumTargets, Allocator.Persistent);
...
TargetPositions.Dispose();
  • Allocator.Persistentでは明示的に破棄するまでアロケートされる
  • 必ずDisposeする
// ジョブをスケジュールするには、まずインスタンスを作成し、そのフィールドにデータを入力する必要があります。
FindNearestJob findJob = new FindNearestJob
{
    TargetPositions = TargetPositions,
    SeekerPositions = SeekerPositions,
    NearestTargetPositions = NearestTargetPositions,
};

// Schedule() はジョブ インスタンスをジョブ キューに置きます。
JobHandle findHandle = findJob.Schedule();

// Complete メソッドは、ハンドルによって表されるジョブの実行が終了するまで戻りません。
// 事実上、メインスレッドはジョブが完了するまでここで待機します。
findHandle.Complete();
  • JobをCompleteしてから配列にアクセスする(この場合はNearestTargetpositions)
  • Jobが終わるまでComplete呼び出しが実行されない
    • ここでは同期的なのでシンプルに処理がここで止まる

FindNerestJob : IJob

// ジョブをバースト コンパイルするために BurstCompile 属性を含めます。
[BurstCompile]
  • BurstではUnmanagedなものしか扱えない
  • 処理が高速になる
  • UnityではJobによってワーカースレッド内で実行されるときに使えるようになる
[ReadOnly] public NativeArray<float3> TargetPositions;
  • ReadOnlyにすると並列処理するとき安全にアクセスできる
    • 書き込みしないので同時にアクセスしても問題ない
TakaseTakase

Step 3

並列Jobを使う

public struct FindNearestJob : IJobParallelFor
  • IJobParallelForを実装すると並列処理される
public void Execute(int index)
{
	...
}
  • 配列の要素ごとに呼び出される
// Execute は、SeekerPositions 配列のすべての要素に対して 1 回呼び出されます。
// インデックスは 0 から配列の長さ (ただし、配列の長さは含まれません) までです。
// Execute 呼び出しは 100 個のバッチに分割されます。
JobHandle findHandle = findJob.Schedule(SeekerPositions.Length, 100);
  • 配列の長さが300でバッチサイズが100だったら3つのバッチに分割される
  • バッチに分割されたら異なるスレッドで同時に実行される

※スレッドの安全性について

  • 読み取り専用ではない配列を扱うときは引数で渡されたindex以外の要素にアクセスしてはいけない
    • 書き込みで競合してしまう
  • 読み取り専用だったら大丈夫
TakaseTakase

Step 4

並列Jobに加えてアルゴリズムを工夫したもの

FindNearest

SortJob<float3, AxisXComparer> sortJob = TargetPositions.SortJob(
    new AxisXComparer { });

FindNearestJob findJob = new FindNearestJob
{
    TargetPositions = TargetPositions,
    SeekerPositions = SeekerPositions,
    NearestTargetPositions = NearestTargetPositions,
};

JobHandle sortHandle = sortJob.Schedule();

// findJobをSortJobに依存させる
// findJobをスケジュールする際にSortJobのハンドルを渡す
JobHandle findHandle = findJob.Schedule(
        SeekerPositions.Length, 100, sortHandle);

// Jobを完了させると、その依存関係にあるすべてのJobも
// 完了するため、findJobを完了させるとSortJobも完了します。
findHandle.Complete();
  • NativeArrayの拡張メソッドであるSortJobをスケジュールすると、中でSegmentSortジョブとSegmentSortMergeジョブがスケジュールされる

FindNearestJob

  • TargetのpositionをX座標でソート
  • ソートされた配列を使ってSeekerとX座標が最も近いターゲットを検索