Unity 잡 시스템 시작하기

Tutorial

intermediate

+10XP

30 mins

14

Unity Technologies

Unity 잡 시스템 시작하기

이 튜토리얼에서는 멀티코어 CPU를 활용하기 위해 잡을 사용하는 기본적인 방법을 소개합니다.

이 튜토리얼에서 배울 내용은 다음과 같습니다.

  • 싱글 스레드 잡을 생성, 예약, 완료합니다.
  • 병렬 잡을 생성, 예약, 완료합니다.
  • 다른 잡에 종속되는 잡을 예약합니다.
  • NativeArrays를 사용합니다.

Materials

Languages available:

1. 개요

이 튜토리얼에서는 멀티코어 CPU를 활용하기 위해 잡을 사용하는 기본적인 방법을 소개합니다. GitHub의 엔티티 컴포넌트 샘플 저장소에서 잡 튜토리얼 샘플을 단계별로 살펴보겠습니다.

이 샘플에서는 2D 평면에서 각각 무작위 방향으로 천천히 이동하는 탐색기(파란색 큐브)와 타겟(빨간색 큐브)을 먼저 만나게 됩니다. 이때 과제는 각 탐색기에서 가장 가까운 타겟까지 실시간으로 흰색 디버그 라인을 그리는 것입니다.

우선 잡을 사용하지 않고 문제를 해결하는 방법을 살펴보겠습니다. 그런 다음 잡을 사용하여 이 문제를 해결하는 방법을 알아보고 프로파일을 살펴보면서 성능 향상을 확인하겠습니다.

이 튜토리얼의 약간 다른 버전을 보려면 아래 동영상을 시청하세요.

2. 시작하기 전에

이 튜토리얼에서는 여러분이 C#과 게임 오브젝트에 대한 기본 지식이 있고 이 교육 과정의 이전 튜토리얼을 완료하여 DOTS와 엔티티의 개념을 이해했다고 가정합니다.

Unity 프로젝트 설정

이 튜토리얼을 위한 Unity 프로젝트를 설정하는 방법은 다음과 같습니다.

1. 3D (URP) 템플릿을 사용하여 새 Unity 프로젝트를 생성합니다.

2. 패키지 관리자를 통해 Collections 패키지를 설치합니다.

Collections 패키지를 추가하면 종속 관계인 Burst 및 Mathematics 패키지도 자동으로 추가됩니다.

3. 2D 평면을 돌아다니는 탐색기 및 타겟 생성

탐색기와 타겟의 경우, 방향 벡터를 따라 일정한 속도로 Transform을 수정하는 MonoBehaviour를 정의해야 합니다. 이를 처리하는 방법은 다음과 같습니다.

1. ‘Target.cs’라는 새 스크립트 파일을 생성하고 다음 코드를 추가합니다.

using UnityEngine;

public class Target : MonoBehaviour
{
    public Vector3 Direction;

    public void Update()
    {
        transform.localPosition += Direction * Time.deltaTime;
    }
}

2. ‘Seeker.cs’라는 스크립트 파일을 새로 만들고 다음 내용을 추가합니다.

using UnityEngine;

public class Seeker : MonoBehaviour
{
    public Vector3 Direction;

    public void Update()
    {
        transform.localPosition += Direction * Time.deltaTime;
    }
}

3. 단일 루트 게임 오브젝트로 ‘Target’이라는 프리팹을 생성합니다. 이를 빨간색으로 렌더링된 큐브로 만들고 Target MonoBehaviour를 추가합니다.

4. 단일 루트 게임 오브젝트로 ‘Seeker’라는 프리팹을 생성합니다. 이를 파란색으로 렌더링된 큐브로 만들고 Seeker MonoBehaviour를 추가합니다.

이제 플레이 모드가 시작할 때 타겟과 탐색기를 생성하는 MonoBehaviour가 필요합니다.

5. 또 다른 새 스크립트 파일을 생성하고 이름을 ‘Spawner.cs’로 지정한 뒤 다음 코드를 추가합니다.

using UnityEngine;

public class Spawner : MonoBehaviour
{
    // 타겟과 탐색기 세트는 고정되어 있으므로 
    // 매 프레임마다 가져오는 대신 
    // 이 필드에서 해당 트랜스폼을 캐시합니다.
    public static Transform[] TargetTransforms;
    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));
        }
    }
}

6. 씬에서 이 Spawner 컴포넌트의 단일 인스턴스를 게임 오브젝트에 추가하고 해당 프로퍼티를 다음과 같이 설정합니다.

  • TargetPrefabTarget 프리팹 에셋으로 설정합니다.
  • SeekerPrefabSeeker 프리팹 에셋으로 설정합니다.
  • NumSeekers1000으로 설정합니다.
  • NumTargets1000으로 설정합니다.
  • Bounds500(x) 및 500(y)으로 설정합니다.

이제 플레이 모드로 들어가면 XZ 평면에서 빨간색과 파란색 큐브가 돌아다니는 것을 볼 수 있습니다. 하지만 각 탐색기(파란색 큐브)와 가장 가까운 타겟(빨간색 큐브)을 연결하는 흰색 디버그 라인은 아직 볼 수 없습니다.

4. 잡 없이 각 탐색기의 가장 가까운 타겟 찾기

각 탐색기에 가장 가까운 타겟을 찾고 그 사이에 흰색 디버그 라인을 그리려면 다른 MonoBehaviour가 필요합니다. 이 MonoBehaviour를 추가하는 방법은 다음과 같습니다.

1. 새 파일을 생성하고 이름을 ‘FindNearest.cs’로 지정한 뒤 다음 코드를 추가합니다.

using UnityEngine;

public class FindNearest : MonoBehaviour
{
    public void Update()
    {
        // 가장 가까운 타겟을 찾습니다.
        // 거리를 비교할 때는 거리의 제곱을
        // 비교하는 것이 더 편리합니다.
        // 제곱근 계산을 안 해도 되기 때문입니다.
        foreach (var seekerTransform in Spawner.SeekerTransforms)
        {
            Vector3 seekerPos = seekerTransform.localPosition;
            Vector3 nearestTargetPos = default;
            float nearestDistSq = float.MaxValue;
            foreach (var targetTransform in Spawner.TargetTransforms)
            {
                Vector3 offset = targetTransform.localPosition - seekerPos;
                float distSq = offset.sqrMagnitude;

                if (distSq < nearestDistSq)
                {
                    nearestDistSq = distSq;
                    nearestTargetPos = targetTransform.localPosition;
                }
            }
 
            Debug.DrawLine(seekerPos, nearestTargetPos);
        }        
    }
}

2. 씬에서 FindNearest 컴포넌트를 새 게임 오브젝트에 추가합니다.

업데이트 중 MonoBehaviour는 모든 탐색기에서 타겟까지의 거리를 확인하고 탐색기와 가장 가까운 타겟 사이에 흰색 디버그 라인을 그립니다.

참고: 플레이 모드에서 흰색 디버그 라인이 그려지는 것을 보려면 뷰나 게임 뷰에서 기즈모를 활성화해야 합니다.

탐색기 1,000개와 타겟 1,000개로 실행되는 일반적인 프레임의 프로파일은 다음과 같습니다.

각 탐색기는 업데이트하는 데 최대 0.33밀리초가 걸리며 프레임의 모든 탐색기를 업데이트하려면 총 340밀리초 이상이 소요됩니다.

매우 훌륭한 결과는 아니지만 이 메서드가 메인 스레드에서만 실행되는 무차별 대입 N^2 솔루션을 사용한다는 점을 고려하면 당연한 결과입니다.

5. 싱글 스레드 잡으로 각 탐색기의 가장 가까운 타겟 찾기

복잡한 작업을 잡으로 옮기면 해당 작업을 메인 스레드에서 워커 스레드로 옮길 수 있고 코드를 버스트 컴파일할 수 있습니다. 잡과 버스트 컴파일된 코드는 관리되는 오브젝트(게임 오브젝트와 게임 오브젝트 컴포넌트 포함)에 액세스할 수 없으므로 먼저 잡에서 처리할 모든 데이터를 NativeArrays 같은 관리되지 않는 컬렉션에 복사해야 합니다.

참고: 엄밀히 말하면 잡은 관리되는 오브젝트에 액세스할 수 있지만 그러기 위해서는 특별한 주의가 필요하기 때문에 보통 좋은 방법은 아닙니다. 게다가 속도 개선을 위해 이 잡을 버스트 컴파일하면 버스트 컴파일된 코드는 관리되는 오브젝트에 절대로 액세스할 수 없습니다.

참고: Vector3Mathf를 사용할 수도 있지만 그 대신 float3와 Mathematics 패키지의 수학 함수를 사용하겠습니다. 이 패키지에는 버스트를 위한 특수 최적화 훅이 포함되어 있습니다.

작업을 잡으로 옮기는 방법은 다음과 같습니다.

1. 새 스크립트 파일을 생성하고 이름을 ‘FindNearestJob.cs’로 지정한 뒤 다음 코드를 추가합니다.

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;

// Vector3 대신 Unity.Mathematics.float3를 사용하고
// Vector3.sqrMagnitude 대신 Unity.Mathematics.math.distancesq를 사용하겠습니다.
using Unity.Mathematics;

// 잡을 버스트 컴파일하려면 BurstCompile 속성을 포함합니다.
[BurstCompile]
public struct FindNearestJob : IJob
{
    // 잡이 액세스하는 모든 데이터는 
    // 해당 필드에 포함되어야 합니다. 이 경우 잡에는 float3 배열
    // 3개가 필요합니다.

    // 잡에서 읽기만 가능한 배열과 컬렉션 필드는
    // ReadOnly 속성으로 표시되어야 합니다.
    // 이 경우 꼭 필요한 것은 아니지만 데이터를  
    // ReadOnly로 표시하면 잡 스케줄러가 더 많은 잡을 
    // 동시에 안전하게 실행할 수 있습니다.

    [ReadOnly] public NativeArray<float3> TargetPositions;
    [ReadOnly] public NativeArray<float3> SeekerPositions;

    // SeekerPositions[i]의 경우 가장 가까운 타겟 위치를 
    // NearestTargetPositions[i]에 할당합니다.
    public NativeArray<float3> NearestTargetPositions;

    // 'Execute'는 IJob 인터페이스의 유일한 메서드입니다.
    // 워커 스레드가 잡을 실행할 때 이 메서드를 호출합니다.
    public void Execute()
    {
        // 각 탐색기에서 각 타겟까지 거리의 제곱을 계산합니다.
        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;
                }
            }
        }
    }
}

잡의 Execute() 메서드는 이전과 동일한 무차별 대입 N^2 로직을 사용할 예정이지만, 지금은 버스트 컴파일된 잡에서 실행 중이기 때문에 작업에 CPU 시간이 더 적게 소요되고 메인 스레드 대신 워커 스레드에서 작업을 실행할 수 있습니다.

잡을 실행하려면 해당 잡을 인스턴스화하고 예약합니다.

2. FindNearest.cs의 내용을 다음 코드로 변경합니다.

using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;

public class FindNearest : MonoBehaviour
{
    // 배열의 크기가 다양할 필요는 없으므로
    // 필드마다 새 배열을 생성하는 대신 Awake()에서
    // 배열을 생성하여 이 필드에 저장합니다.
    NativeArray<float3> TargetPositions;
    NativeArray<float3> SeekerPositions;
    NativeArray<float3> NearestTargetPositions;

    public void Start()
    {
        Spawner spawner = Object.FindObjectOfType<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()
    {
        // 모든 타겟 트랜스폼을 NativeArray에 복사합니다.
        for (int i = 0; i < TargetPositions.Length; i++)
        {
            // Vector3는 암시적으로 float3로 전환됩니다.
            TargetPositions[i] = Spawner.TargetTransforms[i].localPosition;
        }

        // 모든 탐색기 트랜스폼을 NativeArray에 복사합니다.
        for (int i = 0; i < SeekerPositions.Length; i++)
        {
            // Vector3는 암시적으로 float3로 전환됩니다.
            SeekerPositions[i] = Spawner.SeekerTransforms[i].localPosition;
        }

        // 잡을 예약하려면 먼저 인스턴스를 생성하고 해당 필드를 채워야 합니다.
        FindNearestJob findJob = new FindNearestJob
        {
            TargetPositions = TargetPositions,
            SeekerPositions = SeekerPositions,
            NearestTargetPositions = NearestTargetPositions,
        };

        // Schedule()은 잡 인스턴스를 잡 대기열에 넣습니다.
        JobHandle findHandle = findJob.Schedule();

        // Complete 메서드는 핸들이 나타내는 잡이
        // 실행을 완료할 때까지 반환되지 않습니다. 실제로 메인 스레드는 잡이 완료될 때까지
        // 여기서 대기합니다.
        findHandle.Complete();

        // 각 탐색기에서 가장 가까운 타겟까지 디버그 라인을 그립니다.
        for (int i = 0; i < SeekerPositions.Length; i++)
        {
            // float3는 암시적으로 Vector3로 전환됩니다.
            Debug.DrawLine(SeekerPositions[i], NearestTargetPositions[i]);
        }
    }
}

이제 플레이 모드로 들어가서 결과를 프로파일링하면 버스트 컴파일하지 않은 탐색기 1,000개와 타겟 1,000개가 있는 일반적인 프레임은 다음 프로파일과 같습니다.

앞서 봤던 최대 340밀리초보다는 최대 30밀리초가 훨씬 낫지만 이번에는 잡 구조체에 [BurstCompile] 속성을 추가하여 버스트 컴파일을 활성화해 보겠습니다. 또한 메인 메뉴(Jobs > Burst > Enable Compilation)에서 버스트 컴파일이 활성화되어 있어야 합니다.

아래 프로파일은 버스트가 활성화된 결과를 보여 줍니다.

최대 1.5밀리초의 결과가 나오고, 60fps의 예산 목표인 16.6밀리초를 넉넉히 달성합니다. 이제 여유 공간이 많기 때문에 탐색기 10,000개와 타겟 10,000개를 시도해 보겠습니다.

탐색기와 타겟이 10배 증가함에 따라 실행 시간이 70배 증가했습니다. 모든 탐색기가 여전히 모든 타겟까지의 거리를 무차별 대입으로 확인하고 있기 때문에 예상했던 결과입니다.

잡은 메인 스레드에서 실행된다는 점을 이 프로파일에서 알 수 있습니다. 잡 대기열에서 아직 제거되지 않은 잡에 Complete()를 호출하면 이런 현상이 발생할 수 있습니다. 해당 잡이 끝나기를 기다리는 동안 대기 상태(idle)였을 메인 스레드가 잡을 자체적으로 실행할 수 있기 때문입니다.

탐색기타겟 MonoBehaviour를 업데이트하는 데 걸리는 시간도 상당히 중요합니다. 특히 탐색기와 타겟의 수가 증가할수록 더욱 중요합니다. 이때 수천 개의 MonoBehaviour를 개별적으로 업데이트하는 오버헤드가 주로 발생합니다. 한 번의 업데이트 호출에서 모든 Transform을 업데이트하면 더 나은 결과를 얻을 수 있습니다. (게임 오브젝트 대신 엔티티를 사용하는 것이 더 좋은 방법이지만, 이 튜토리얼에서 다룰 범위를 벗어나는 내용입니다.)

6. 병렬 잡을 활용하여 각 탐색기의 가장 가까운 타겟 찾기

배열이나 목록을 처리하는 잡은 인덱스를 하위 범위로 분할하여 작업을 병렬화하는 것이 가능한 경우가 많습니다. 예를 들어 배열의 절반은 한 스레드에서 처리되고 동시에 나머지 절반은 다른 스레드에서 처리될 수 있습니다.

IJobParallelFor를 사용하여 이러한 방식으로 작업을 분할하는 잡을 편리하게 생성할 수 있으며 Schedule() 메서드는 다음 2개의 Int 인수를 사용합니다.

  • 인덱스 수: 처리 중인 배열이나 목록의 크기입니다.
  • 배치 크기: 하위 범위(배치)의 크기입니다.

예를 들어 잡의 인덱스 수가 100이고 배치 크기가 40이면 잡은 3개의 배치로 나뉩니다. 첫 번째 배치는 인덱스 0부터 39까지 처리하고, 두 번째 배치는 인덱스 40부터 79까지 처리하고, 세 번째 배치는 인덱스 80부터 99까지 처리합니다.

워커 스레드는 이러한 배치를 대기열에서 개별적으로 가져오므로 배치는 서로 다른 스레드에서 동시에 처리될 수 있습니다.

FindNearestJob 잡을 IJobParallelFor로 변경하려면 FindNearestJob.cs의 내용을 다음 코드로 바꿉니다.


using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

[BurstCompile]
public struct FindNearestJob : IJobParallelFor
{
    [ReadOnly] public NativeArray<float3> TargetPositions;
    [ReadOnly] public NativeArray<float3> SeekerPositions;

    public NativeArray<float3> NearestTargetPositions;

    // IJobParallelFor의 Execute() 메서드는 인덱스 파라미터를 사용하고 
    // 0부터 인덱스 수까지 각 인덱스에 한 번씩 호출됩니다.
    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;
            }
        }
    }
}

로직은 동일하나 이제 FindNearest.csSchedule() 호출에 파라미터를 추가하여 잡에서 수행할 Execute 호출 수와 배치 크기(100)를 지정해야 합니다.

// 이 잡은 모든 탐색기를 처리하므로
// 탐색기 배열 길이가 인덱스 수로 사용됩니다.
// 여기서 배치 크기 100은 너무 크지도 작지도 않기 때문에 
// 반쯤 임의로 선택되었습니다.
JobHandle findHandle = findJob.Schedule(SeekerPositions.Length, 100);

아래 이미지는 탐색기가 10,000개고 타겟이 10,000개인 일반적인 프레임에서 병렬 잡 솔루션을 활용한 경우의 프로파일입니다.

작업이 16개 코어로 분할되면 총 CPU 시간은 최대 260밀리초가 소요되지만 시작부터 끝까지는 17밀리초 미만으로 소요됩니다.

시작부터 끝까지 걸리는 시간을 단축하기 위해 총 CPU 시간을 더 많이 사용하는 것이 더 나은지는 상황에 따라 크게 달라집니다. 동시에 다른 작업을 수행해야 하는 경우 더 적은 수의 코어에서 잡이 실행되도록 제한하는 것이 더 나을 수 있습니다. 이 예제에서는 다른 작업을 수행하지 않으므로 시작부터 종료까지의 시간이 짧을수록 좋습니다.

7. 병렬 잡과 스마트 알고리즘을 활용하여 각 탐색기의 가장 가까운 타겟 찾기

지금까지는 가공되지 않은 무차별 대입 N^2 알고리즘을 사용했습니다. 대규모로 훨씬 더 나은 성능을 얻으려면 타겟을 쿼드트리 또는 k-d 트리로 정렬하는 등의 방식으로 데이터를 구성해야 합니다.

단순성을 유지하기 위해 단일 차원을 따라 타겟을 정렬할 수 있습니다. x 좌표 또는 z 좌표로 정렬할지는 임의로 정하면 되지만, 여기서는 x 좌표를 선택해 보겠습니다. 개별 탐색기에 가장 가까운 타겟을 찾는 작업은 다음 3단계로 수행할 수 있습니다.

  1. 바이너리 검색을 수행하여 탐색기의 x 좌표에 가장 가까운 x 좌표를 지닌 타겟을 찾습니다.
  2. 해당 타겟의 인덱스에서 배열의 위아래로 2차원 거리가 더 짧은 타겟을 검색합니다.
  3. 배열을 위아래로 검색할 때 x축 거리가 현재 후보까지의 2차원 거리를 초과하면 루프를 종료합니다.

이 방식의 장점은 현재 후보까지의 2차원 거리보다 x축 거리가 더 큰 타겟을 확인할 필요가 없다는 것입니다. 따라서 검색 시 각 탐색기의 모든 개별 타겟을 고려할 필요가 없습니다.

이렇게 보다 스마트한 알고리즘을 구현하는 방법은 다음과 같습니다.

1. FindNearestJob.cs의 내용을 다음 코드로 변경합니다.

using System.Collections.Generic;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

[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];

        // X 좌표가 가장 가까운 타겟을 찾습니다.
        int startIdx = TargetPositions.BinarySearch(seekerPos, new AxisXComparer { });

        // 정확하게 일치하는 항목이 없으면 BinarySearch는 마지막으로 검색한 오프셋의 비트 단위 부정을 반환합니다.
        // 따라서 startIdx가 음수면 비트를 다시 뒤집지만 인덱스가 범위 내에 있어야 합니다.
        if (startIdx < 0) startIdx = ~startIdx;
        if (startIdx >= TargetPositions.Length) startIdx = TargetPositions.Length - 1;

        // X 좌표가 가장 가까운 타겟의 위치입니다.
        float3 nearestTargetPos = TargetPositions[startIdx];
        float nearestDistSq = math.distancesq(seekerPos, nearestTargetPos);

        // 배열을 위쪽으로 검색하여 더 가까운 타겟을 찾습니다.
        Search(seekerPos, startIdx + 1, TargetPositions.Length, +1, ref nearestTargetPos, ref nearestDistSq);

        // 배열을 아래쪽으로 검색하여 더 가까운 타겟을 찾습니다.
        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;

            // x 거리의 제곱이 현재 가장 가까운 거리보다 크면 검색을 중지할 수 있습니다.
            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);
    }
}

FindNearestJob 잡이 실행되기 전에 탐색기의 Transform이 x 좌표를 기준으로 정렬되어 있어야 합니다. 정렬을 수행하기 위해 직접 잡을 작성할 수도 있지만 그 대신 SortJob()이라는 NativeArray 확장 메서드를 호출해 보겠습니다. 이 메서드는 SortJob 구조체를 반환합니다.

SortJob의 Schedule() 메서드는 2개의 잡을 예약합니다. SegmentSort는 배열의 개별 세그먼트를 병렬로 정렬하고 SegmentSortMerge는 정렬된 세그먼트를 병합합니다. (병합 알고리즘은 병렬화가 불가능하기 때문에 별도의 싱글 스레드 잡에서 병합을 수행해야 합니다.)

SegmentSort 잡이 실행을 완료한 후에 SegmentSortMerge 잡이 실행을 시작해야 하므로 SegmentSort 잡은 SegmentSortMerge 잡에 종속됩니다. 일반적으로 워커 스레드는 어떤 잡을 실행하기 전 여기에 종속된 모든 잡이 실행을 완료하기까지 기다립니다. 잡 종속성을 활용해 예약된 잡 중에서 실행 순서를 효과적으로 지정할 수 있습니다.

2. FindNearest.cs에서 잡 예약 코드를 다음과 같이 변경합니다.

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

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

// 정렬 잡 핸들을 Schedule()에 전달하면
// 찾기 잡이 정렬 잡에 종속됩니다. 이는 정렬 잡이 완료된 후에만 
// 찾기 잡 실행이 시작됨을 의미합니다.
// 가장 가까운 타겟 찾기 잡은 정렬이 완료되기를 
// 기다려야 하므로 정렬 잡에 종속됩니다. 
JobHandle findHandle = findJob.Schedule(SeekerPositions.Length, 100, sortHandle);
findHandle.Complete();

아래 이미지는 탐색기가 10,000개고 타겟이 10,000개인 일반적인 프레임에서 이 새로운 솔루션을 활용한 경우의 프로파일입니다.

FindNearestJob은 총 CPU 시간이 최대 7.5밀리초이며 시작부터 끝까지는 최대 0.5밀리초가 소요됩니다.

프로파일을 확대하면 정렬 잡을 확인할 수 있습니다.

SegmentSort는 시작부터 끝까지 0.1밀리초 미만이 소요되고 싱글 스레드 SegmentSortMerge는 최대 0.5밀리초가 소요됩니다. 따라서 타겟 위치를 정렬하기 위해 현재 추가 작업을 처리하고 있음에도 불구하고 전체 워크로드가 급격히 감소했습니다.

8. 추가로 시도해 볼 만한 작업

위에서 언급한 것처럼 타겟 및 탐색기 MonoBehaviour의 많은 업데이트가 낳은 비효율성으로 인해 프레임 시간이 많이 소모됩니다. 이 오버헤드를 해결하기 위해 게임 오브젝트를 엔티티로 대체해 볼 수 있습니다.

9. 다음 단계

이 교육 과정의 다음 튜토리얼에서는 잡에서 엔티티에 액세스하고 수정해 보겠습니다.

Complete this Tutorial