학습 참고 문서
공식 Sample Project 링크
https://github.com/Unity-Technologies/EntityComponentSystemSamples/tree/master
멀티코어 CPU를 활용하기 위해 Job을 사용하는 기본적인 방법
STEP
– 싱글 스레드 잡을 생성, 예약, 완료
– 병렬 잡을 생성, 예약, 완료
– 다른 잡에 종속되는 잡을 예약
– NativeArrays를 사용
파란 큐브와 빨간 큐브를 각각 Seeker와 Target으로 정하고 각Seeker은 가장 가까운 Target으로 이어지는 디버그 라인을 추가합니다.
각 Step은 서로 다른 방식으로 문제를 해결하여 Step마다 성능 비교를 할 수 있습니다.
SeekerPrefab의 인스턴스를 생성 -> 2D 평면을 따라 무작위 방향과 무작위 위치를 설정 ->
Target도 동일한 방식으로 생성 -> 모든 Seeker와 Target의 트랜스폼을 정적 배열에 할당하여
FindNearest 스크립트에서 액세스하여 Update()

Step 1. Scene – No Job
일반적인 브루트 포스 순회 방법, 각 Seeker 마다 모든 Target과의 거리를 확인
Spawner.cs
using UnityEngine;
namespace Tutorials.Jobs.Step1
{
public class Spawner : MonoBehaviour
{
// The set of targets is fixed, so rather than
// retrieve the targets every frame, we'll cache
// their transforms in this field.
public static Transform[] TargetTransforms;
public GameObject SeekerPrefab;
public GameObject TargetPrefab;
public int NumSeekers;
public int NumTargets;
public Vector2 Bounds;
public void Start()
{
Random.InitState(123);
for (int i = 0; i < NumSeekers; i++)
{
GameObject go = GameObject.Instantiate(SeekerPrefab);
Seeker seeker = go.GetComponent<Seeker>();
Vector2 dir = Random.insideUnitCircle;
seeker.Direction = new Vector3(dir.x, 0, dir.y);
go.transform.localPosition = new Vector3(
Random.Range(0, Bounds.x), 0, Random.Range(0, Bounds.y));
}
TargetTransforms = new Transform[NumTargets];
for (int i = 0; i < NumTargets; i++)
{
GameObject go = GameObject.Instantiate(TargetPrefab);
Target target = go.GetComponent<Target>();
Vector2 dir = Random.insideUnitCircle;
target.Direction = new Vector3(dir.x, 0, dir.y);
TargetTransforms[i] = go.transform;
go.transform.localPosition = new Vector3(
Random.Range(0, Bounds.x), 0, Random.Range(0, Bounds.y));
}
}
}
}
Seeker.cs
using UnityEngine;
namespace Tutorials.Jobs.Step1
{
public class Seeker : MonoBehaviour
{
public Vector3 Direction;
public void Update()
{
transform.localPosition += Direction * Time.deltaTime;
}
}
}
Target.cs
using UnityEngine;
namespace Tutorials.Jobs.Step1
{
public class Target : MonoBehaviour
{
public Vector3 Direction;
public void Update()
{
transform.localPosition += Direction * Time.deltaTime;
}
}
}
FindNearest.cs
using UnityEngine;
namespace Tutorials.Jobs.Step1
{
public class FindNearest : MonoBehaviour
{
public void Update()
{
// Find nearest Target.
// When comparing distances, it's cheaper to compare
// the squares of the distances because doing so
// avoids computing square roots.
Vector3 nearestTargetPosition = default;
float nearestDistSq = float.MaxValue;
foreach (var targetTransform in Spawner.TargetTransforms)
{
Vector3 offset = targetTransform.localPosition - transform.localPosition;
float distSq = offset.sqrMagnitude;
if (distSq < nearestDistSq)
{
nearestDistSq = distSq;
nearestTargetPosition = targetTransform.localPosition;
}
}
Debug.DrawLine(transform.localPosition, nearestTargetPosition);
}
}
}




Seeker의 1000개의 FindNearest가 Update()를 수행하면서 약 82ms를 프레임마다 실행 (개당 0.082ms)
Step 2. Scene – SingleThreadedJob
일반적인 브루트 포스 순회 방법, 하나의 FindNearest에서 Target과의 거리를 확인


Spawner.cs
using UnityEngine;
using Seeker = Tutorials.Jobs.Step1.Seeker;
using Target = Tutorials.Jobs.Step1.Target;
namespace Tutorials.Jobs.Step2
{
public class Spawner : MonoBehaviour
{
public static Transform[] TargetTransforms;
// Cache the seeker transforms.
public static Transform[] SeekerTransforms;
public GameObject SeekerPrefab;
public GameObject TargetPrefab;
public int NumSeekers;
public int NumTargets;
public Vector2 Bounds;
public void Start()
{
Random.InitState(123);
SeekerTransforms = new Transform[NumSeekers];
for (int i = 0; i < NumSeekers; i++)
{
GameObject go = GameObject.Instantiate(SeekerPrefab);
Seeker seeker = go.GetComponent<Seeker>();
Vector2 dir = Random.insideUnitCircle;
seeker.Direction = new Vector3(dir.x, 0, dir.y);
SeekerTransforms[i] = go.transform;
go.transform.localPosition = new Vector3(
Random.Range(0, Bounds.x), 0, Random.Range(0, Bounds.y));
}
TargetTransforms = new Transform[NumTargets];
for (int i = 0; i < NumTargets; i++)
{
GameObject go = GameObject.Instantiate(TargetPrefab);
Target target = go.GetComponent<Target>();
Vector2 dir = Random.insideUnitCircle;
target.Direction = new Vector3(dir.x, 0, dir.y);
TargetTransforms[i] = go.transform;
go.transform.localPosition = new Vector3(
Random.Range(0, Bounds.x), 0, Random.Range(0, Bounds.y));
}
}
}
}
FindNearest.cs
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
namespace Tutorials.Jobs.Step2
{
public class FindNearest : MonoBehaviour
{
// The size of our arrays does not need to vary, so rather than create
// new arrays every field, we'll create the arrays in Awake() and store them
// in these fields.
NativeArray<float3> TargetPositions;
NativeArray<float3> SeekerPositions;
NativeArray<float3> NearestTargetPositions;
public void Start()
{
Spawner spawner = Object.FindFirstObjectByType<Spawner>();
// We use the Persistent allocator because these arrays must
// exist for the run of the program.
TargetPositions = new NativeArray<float3>(spawner.NumTargets, Allocator.Persistent);
SeekerPositions = new NativeArray<float3>(spawner.NumSeekers, Allocator.Persistent);
NearestTargetPositions = new NativeArray<float3>(spawner.NumSeekers, Allocator.Persistent);
}
// We are responsible for disposing of our allocations
// when we no longer need them.
public void OnDestroy()
{
TargetPositions.Dispose();
SeekerPositions.Dispose();
NearestTargetPositions.Dispose();
}
public void Update()
{
// Copy every target transform to a NativeArray.
for (int i = 0; i < TargetPositions.Length; i++)
{
// Vector3 is implicitly converted to float3
TargetPositions[i] = Spawner.TargetTransforms[i].localPosition;
}
// Copy every seeker transform to a NativeArray.
for (int i = 0; i < SeekerPositions.Length; i++)
{
// Vector3 is implicitly converted to float3
SeekerPositions[i] = Spawner.SeekerTransforms[i].localPosition;
}
// To schedule a job, we first need to create an instance and populate its fields.
FindNearestJob findJob = new FindNearestJob
{
TargetPositions = TargetPositions,
SeekerPositions = SeekerPositions,
NearestTargetPositions = NearestTargetPositions,
};
// Schedule() puts the job instance on the job queue.
JobHandle findHandle = findJob.Schedule();
// The Complete method will not return until the job represented by
// the handle finishes execution. Effectively, the main thread waits
// here until the job is done.
findHandle.Complete();
// Draw a debug line from each seeker to its nearest target.
for (int i = 0; i < SeekerPositions.Length; i++)
{
// float3 is implicitly converted to Vector3
Debug.DrawLine(SeekerPositions[i], NearestTargetPositions[i]);
}
}
}
}
FindNearestJob.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
// We'll use Unity.Mathematics.float3 instead of Vector3,
// and we'll use Unity.Mathematics.math.distancesq instead of Vector3.sqrMagnitude.
using Unity.Mathematics;
namespace Tutorials.Jobs.Step2
{
// Include the BurstCompile attribute to Burst compile the job.
[BurstCompile]
public struct FindNearestJob : IJob
{
// All of the data which a job will access should
// be included in its fields. In this case, the job needs
// three arrays of float3.
// Array and collection fields that are only read in
// the job should be marked with the ReadOnly attribute.
// Although not strictly necessary in this case, marking data
// as ReadOnly may allow the job scheduler to safely run
// more jobs concurrently with each other.
// (See the "Intro to jobs" for more detail.)
[ReadOnly] public NativeArray<float3> TargetPositions;
[ReadOnly] public NativeArray<float3> SeekerPositions;
// For SeekerPositions[i], we will assign the nearest
// target position to NearestTargetPositions[i].
public NativeArray<float3> NearestTargetPositions;
// 'Execute' is the only method of the IJob interface.
// When a worker thread executes the job, it calls this method.
public void Execute()
{
// Compute the square distance from each seeker to every target.
for (int i = 0; i < SeekerPositions.Length; i++)
{
float3 seekerPos = SeekerPositions[i];
float nearestDistSq = float.MaxValue;
for (int j = 0; j < TargetPositions.Length; j++)
{
float3 targetPos = TargetPositions[j];
float distSq = math.distancesq(seekerPos, targetPos);
if (distSq < nearestDistSq)
{
nearestDistSq = distSq;
NearestTargetPositions[i] = targetPos;
}
}
}
}
}
}




FindNearest 업데이트 자체는 아무것도 하지 않고 잡이 실행 및 완료될 것을 기다리고 있음

Step 1 보다 성능이 향상된 원인은 Step 1의 Seeker의 Update() 1000개에 대한 오버헤드가 발생되지 않음 (오직 1개)
트랜스폼을 순회할 때 메모리를 건너 뛰면서 트랜스폼에 액세스했는데 이는 캐시에 부하주는 작업을 배열로 변경하여 성능을 향상시킴






Step 3. Scene – ParallelJob
일반적인 브루트 포스 순회 방법, 하나의 FindNearest에서 Target과의 거리를 확인


FindNearest.cs
using Spawner = Tutorials.Jobs.Step2.Spawner;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
namespace Tutorials.Jobs.Step3
{
// Exactly the same as in prior step except this schedules a parallel job instead of a single-threaded job.
public class FindNearest : MonoBehaviour
{
NativeArray<float3> TargetPositions;
NativeArray<float3> SeekerPositions;
NativeArray<float3> NearestTargetPositions;
public void Start()
{
Spawner spawner = GetComponent<Spawner>();
TargetPositions = new NativeArray<float3>(spawner.NumTargets, Allocator.Persistent);
SeekerPositions = new NativeArray<float3>(spawner.NumSeekers, Allocator.Persistent);
NearestTargetPositions = new NativeArray<float3>(spawner.NumSeekers, Allocator.Persistent);
}
public void OnDestroy()
{
TargetPositions.Dispose();
SeekerPositions.Dispose();
NearestTargetPositions.Dispose();
}
public void Update()
{
for (int i = 0; i < TargetPositions.Length; i++)
{
TargetPositions[i] = Spawner.TargetTransforms[i].localPosition;
}
for (int i = 0; i < SeekerPositions.Length; i++)
{
SeekerPositions[i] = Spawner.SeekerTransforms[i].localPosition;
}
FindNearestJob findJob = new FindNearestJob
{
TargetPositions = TargetPositions,
SeekerPositions = SeekerPositions,
NearestTargetPositions = NearestTargetPositions,
};
// Execute will be called once for every element of the SeekerPositions array,
// with every index from 0 up to (but not including) the length of the array.
// The Execute calls will be split into batches of 100.
JobHandle findHandle = findJob.Schedule(SeekerPositions.Length, 100);
findHandle.Complete();
for (int i = 0; i < SeekerPositions.Length; i++)
{
Debug.DrawLine(SeekerPositions[i], NearestTargetPositions[i]);
}
}
}
}
FindNearestJob.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
namespace Tutorials.Jobs.Step3
{
[BurstCompile]
public struct FindNearestJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float3> TargetPositions;
[ReadOnly] public NativeArray<float3> SeekerPositions;
public NativeArray<float3> NearestTargetPositions;
public void Execute(int index)
{
float3 seekerPos = SeekerPositions[index];
float nearestDistSq = float.MaxValue;
for (int i = 0; i < TargetPositions.Length; i++)
{
float3 targetPos = TargetPositions[i];
float distSq = math.distancesq(seekerPos, targetPos);
if (distSq < nearestDistSq)
{
nearestDistSq = distSq;
NearestTargetPositions[index] = targetPos;
}
}
}
}
}




Step 4. Scene – ParallelJob_Sorting
정렬을 이용한 병렬 작업


FindNearest.cs
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
using Spawner = Tutorials.Jobs.Step2.Spawner;
namespace Tutorials.Jobs.Step4
{
public class FindNearest : MonoBehaviour
{
NativeArray<float3> TargetPositions;
NativeArray<float3> SeekerPositions;
NativeArray<float3> NearestTargetPositions;
public void Start()
{
Spawner spawner = GetComponent<Spawner>();
TargetPositions = new NativeArray<float3>(spawner.NumTargets, Allocator.Persistent);
SeekerPositions = new NativeArray<float3>(spawner.NumSeekers, Allocator.Persistent);
NearestTargetPositions = new NativeArray<float3>(spawner.NumSeekers, Allocator.Persistent);
}
public void OnDestroy()
{
TargetPositions.Dispose();
SeekerPositions.Dispose();
NearestTargetPositions.Dispose();
}
public void Update()
{
for (int i = 0; i < TargetPositions.Length; i++)
{
TargetPositions[i] = Spawner.TargetTransforms[i].localPosition;
}
for (int i = 0; i < SeekerPositions.Length; i++)
{
SeekerPositions[i] = Spawner.SeekerTransforms[i].localPosition;
}
SortJob<float3, AxisXComparer> sortJob = TargetPositions.SortJob(new AxisXComparer { });
FindNearestJob findJob = new FindNearestJob
{
TargetPositions = TargetPositions,
SeekerPositions = SeekerPositions,
NearestTargetPositions = NearestTargetPositions,
};
JobHandle sortHandle = sortJob.Schedule();
JobHandle findHandle = findJob.Schedule(SeekerPositions.Length, 100, sortHandle);
findHandle.Complete();
for (int i = 0; i < SeekerPositions.Length; i++)
{
Debug.DrawLine(SeekerPositions[i], NearestTargetPositions[i]);
}
}
}
}
FindNearestJob.cs
using System.Collections.Generic;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
namespace Tutorials.Jobs.Step4
{
[BurstCompile]
public struct FindNearestJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float3> TargetPositions;
[ReadOnly] public NativeArray<float3> SeekerPositions;
public NativeArray<float3> NearestTargetPositions;
public void Execute(int index)
{
float3 seekerPos = SeekerPositions[index];
// Find the target with the closest X coord.
int startIdx = TargetPositions.BinarySearch(seekerPos, new AxisXComparer { });
// When no precise match is found, BinarySearch returns the bitwise negation of the last-searched offset.
// So when startIdx is negative, we flip the bits again, but we then must ensure the index is within bounds.
if (startIdx < 0) startIdx = ~startIdx;
if (startIdx >= TargetPositions.Length) startIdx = TargetPositions.Length - 1;
// The position of the target with the closest X coord.
float3 nearestTargetPos = TargetPositions[startIdx];
float nearestDistSq = math.distancesq(seekerPos, nearestTargetPos);
// Searching upwards through the array for a closer target.
Search(seekerPos, startIdx + 1, TargetPositions.Length, +1, ref nearestTargetPos, ref nearestDistSq);
// Search downwards through the array for a closer target.
Search(seekerPos, startIdx - 1, -1, -1, ref nearestTargetPos, ref nearestDistSq);
NearestTargetPositions[index] = nearestTargetPos;
}
void Search(float3 seekerPos, int startIdx, int endIdx, int step,
ref float3 nearestTargetPos, ref float nearestDistSq)
{
for (int i = startIdx; i != endIdx; i += step)
{
float3 targetPos = TargetPositions[i];
float xdiff = seekerPos.x - targetPos.x;
// If the square of the x distance is greater than the current nearest, we can stop searching.
if ((xdiff * xdiff) > nearestDistSq) break;
float distSq = math.distancesq(targetPos, seekerPos);
if (distSq < nearestDistSq)
{
nearestDistSq = distSq;
nearestTargetPos = targetPos;
}
}
}
}
public struct AxisXComparer : IComparer<float3>
{
public int Compare(float3 a, float3 b)
{
return a.x.CompareTo(b.x);
}
}
}

x축을 따라 TargetPositions를 정렬합니다
이어서 FindNearestJob에서 바이너리 검색을 통해 탐색기의 x 좌표와 가장 가까운 타겟의 위치를 찾고
그 인덱스 주위를 탐색해 가장 가까운 타겟을 찾습니다.
그러면 대부분의 경우 탐색기를 모든 타겟과 비교하는 대신 몇몇 타겟과만 비교하는 작업이 되어 더 간단합니다.

먼저 SortJob은 Collections 패키지의 네이티브 배열 확장 메서드로서 SortJob 구조체를 반환합니다.
그 구조체 자체는 잡이 아니지만 Schedule 메서드를 호출할 경우 하나가 아닌 두 개의 잡이 예약됩니다.
이 메서드는 먼저 segmentSortJob을 생성하고 예약하는데 이는 배열의 서로 다른 세그먼트를 독립적으로 정렬하는 병렬 잡입니다.
이후 두 번째 작업이자 싱글 스레드 작업이며 같은 배열 내 세그먼트들을 병합하는 SegmentSortMerge가 예약됩니다
이 두 잡은 기존 배열 내에서 MergeSort를 수행합니다.

결정적으로 두 번째의 병합 잡은 세그먼트 정렬이 완료될 때까지 기다려야 합니다.
그래서 두 번째 잡을 예약할 때 첫 번째 잡의 잡 핸들을 전달하면 첫 번째 잡은 두 번째 잡의 종속 관계가 됩니다.
잡이 종속 관계를 가질 경우 워커 스레드들은 그 종속 관계의 실행이 모두 완료될 때까지 해당 잡을 실행하지 않습니다.
둘 이상의 잡을 예약해야 하며 해당 잡들의 실행 순서를 지정해야 할 때는 종속 관계를 통해 그 순서를 정할 수 있습니다.
이 코드에서 FindNearestJob을 예약할 때 sortJob 핸들을 종속성으로 전달하는데 이는 MergeSort가 완료될 때까지
이 잡의 실행이 시작되어서는 안 되기 때문입니다.
이후 잡이 완료되면 잡의 모든 종속성도 묵시적으로 완료되므로 SegmentSort, SegmentMerge, FindNearestJob의 세 잡이 모두 완료됩니다.


FindNearestJob을 보면 Collections 패키지의 네이티브 배열 확장 메서드인 BinarySearch를 호출하고 있습니다.
이는 단지 X 좌표와 비교해서 SeekerPos와 일치하는 TargetPositions 내 인덱스를 반환합니다.
대부분은 정확히 일치하는 결과를 찾을 수 없을 것이므로 반환되는 인덱스 값은 대부분 음수가 될 겁니다.
하지만 이 메서드에 숨겨진 요령이 있다면 반환된 인덱스 값의 비트를 뒤집어서 배열에서 가장 가까운 일치 값의 인덱스를 얻을 수 있다는 겁니다.
해당 값을 고정해서 범위 내에 있게 하면 됩니다.
이제 x 좌표가 탐색기의 x 좌표와 가장 가까운 타겟의 인덱스를 알아냈지만 타겟을 위아래로 검색하여 탐색기와 더욱 가까운 타겟이 존재하지 않는지 확인합니다.
이 검색에서 중요한 요령은 x 좌표값의 차이가 지금까지 찾아낸 최단 거리보다 더 클 경우 검색을 중단할 수 있다는 겁니다.
TargetPositions가 x 좌표에 따라 정렬되니까요
이 임곗값을 넘고 나면 해당 임곗값 반대편에 존재하는 모든 후보들은 우리가 이미 찾아낸 후보보다 더 가까이에 있을 수 없습니다.









핑백: DOTS 정리 (3) – HelloCube로 엔티티 알아보기 - 어제와 내일의 나 그 사이의 이야기