게임엔진/DOTS

DOTS 1.0 - 기본 (Component,System,Aspect,Job)

tsyang 2023. 1. 29. 23:39

2023.01.01 - [유니티/DOTS] - DOTS 1.0 나온 기념 세팅법

 

 

DOTS 1.0 나온 기념 세팅법

ECS 1.0 pre-release 버전이 나왔다. 설치해보자 우선 유니티 2022.2.0b 이상을 깔아야 한다. IDE도 최신으로 바꿔야 함. 아래 링크 참고 (https://docs.unity3d.com/Packages/com.unity.entities@1.0/manual/getting-started-install

tsyang.tistory.com

 

주의! webp 씀

 

개요


 

Entities 1.0 버전이 나오면서 이전의 방식과 크게 바뀌었다. 그래도 전반적인 컨셉은 똑같다.

 

이 글에서는 오브젝트를 하나 만들어서 ECS로 움직여 보는 것을 목표로 한다. 그 과정에서 Entity Component, Authoring, System 개념을 사용할 것이다.

 

 


 

두 개의 시스템


Entities에는 두 가지 방식의 시스템이 존재하는데, 하나는 SystemBase를 상속받는 것이고 다른 하나는 ISystem을 구현하는 것이다.

 

SystemBase 방식은 managed object, 즉 힙에 생성되는 오브젝트들을 대상으로 한다. 그 대신 메인 스레드에서만 돌아갈 수 있고, Burst Compiler를 사용할 수 없다.

 

ISystem 방식은 unmanaged object를 대상으로 한다. 멀티 쓰레드에서 돌아갈 수 있으며 Burst Compiler를 사용할 수 있다. 그래서 엄청 빠르다. 그러나 struct를 써야하기 때문에 복잡하다.

 

 

 

 


 

오브젝트 생성


 

우선 SubScene을 하나 만들고 거기에 캡슐 형태의 오브젝트를 하나 만들어 준다.

 

 

인스펙터 우상단을 보면 여러 모드가 있는데 이걸 Runtime으로 바꿔주면 

 

 

어떤 컴포넌트를 가지고 있는지 볼 수 있다.

 

 

 


 

새로운 컴포넌트 만들기


 

이제 위 캡슐에 새로운 컴포넌트를 추가해줄 것이다. 간단한게 캡슐에 속력 값을 가지고 있는 'Speed' 컴포넌트를 추가해보자.

 

방법은 매우 간단한다 IComponentData 인터페이스를 구현하는 Speed 구조체를 만들어주자.

public struct Speed : IComponentData
{
    public float Value;
}

 

Speed 구조체는 우리의 캡슐 오브젝트에 컴포넌트로 추가할 수 없으므로, MonoBehavior를 상속하는 별도의 저작 전용 컴포넌트가 필요하다. 이것이 Authoring 개념이며, 아래와 같이 표현할 수 있다.

public class SpeedAuthoring : MonoBehaviour
{
    public float Value;
}

 

이제 우리는 우리의 캡슐 오브젝트에 Speed Authoring 컴포넌트를 추가하고, 속도 값을 부여할 수 있게 됐다.

 

 

그런데 여기까지 보면 SpeedAuthoring과 Speed는 아무런 상관이 없다. 둘을 이어주기 위해서 Baker라는 개념을 사용해야 하며 아래와 같이 구현한다.

 //SpeedAuthoring을 컴포넌트로 바꿔준다.
public class SpeedBaker : Baker<SpeedAuthoring>
{
    public override void Bake(SpeedAuthoring authoring)
    {
        AddComponent(new Speed{Value = authoring.Value});
    }
}

 

SpeedBaker는 SpeedAuthoring 컴포넌트를 ECS에서 사용할 수 있는 Speed 컴포넌트로 바꿔준다. 이제 ECS에서 동작할 Speed 컴포넌트가 필요하다면, 오브젝트에다가 SpeedAuthoring이라는 Monobehaviour 컴포넌트를 추가하면 된다.

 

 

 

 


 

 

오브젝트 움직이기 (SystemBase)


 

오브젝트를 움직이기 위해서 캡슐의 Transform을 변경해줘야 하는데, ECS의 Transform 변경 방식은 조금 복잡하다. 

 

Transform 하나를 바꾸려고 위 3가지 컴포넌트를 손대야 한다면 아주 힘든 일일 것이다. 그래서 ECS에서는 Aspect라는 개념을 추가하였는데,

 

저 3가지를 하나의 컴포넌트처럼 쓸 수 있게 하는 것이다. 

 

 

자 그럼 우리의 캡슐 오브젝트 엔티티는 TransformAspect와 Speed 컴포넌트를 가지고 있다고 할 수 있다.

 

이를 이용하여 간단한 시스템을 만들어보자.

 

//단순히 MovingSystemBase를 정의한 것 만으로 작동
 public partial class MovingSystemBase : SystemBase
 {
     protected override void OnUpdate()
     {
         //메인 스레드에서만 돌아감
         foreach (var (transformAspect, speed) in SystemAPI.Query<TransformAspect, Speed>())
         {
             //SystemAPI.Time.DeltaTime을 쓰고 있음에 주목
             transformAspect.LocalPosition += new float3(SystemAPI.Time.DeltaTime * speed.Value , 0, 0);
         }
     }
 }

위와 같이 SystemBase를 상속한 클래스를 만들어준다. 이때 partial을 붙여준다. 유니티가 알아서 연관된 코드를 생성해주는 것 같다. 

 

UnityEngine.Time.DeltaTime 대신 SystemAPI.Time.DeltaTime을 써준다. SystemAPI는 말 그대로 SystemBase (혹은 ISystem)에서만 쓸 수 있다. 

 

SystemAPI.Query<TransformAspect, Speed>()를 호출하면, 월드에 존재하는 엔티티 중 TransformAspect와 Speed 컴포넌트를 가진 애들을 추려서 반환한다. 만약 Speed컴포넌트가 없다면 여기에 포함되지 않을 것이다. 

 

이를 확인해보기 위해 Speed컴포넌트가 없는 빨간 캡슐을 추가해줬다. 

 

게임을 실행하면 위와 같이 Speed가 붙어있는 오브젝트만 움직인다. 

 

SystemAPI.Query를 foreach로 순회하는 것 외에도 아래와 같이 Entities.Foreach를 쓸 수도 있다. 

 protected override void OnUpdate()
 {
     Entities.ForEach((TransformAspect transformAspect, Speed speed) =>
     {
         transformAspect.LocalPosition += new float3(SystemAPI.Time.DeltaTime * speed.Value, 0, 0);
     })
         .Run();                     //메인 스레드에서만 돌림
         //.Schedule();             //싱글 워커 스레드에서만 돌림
         // .ScheduleParallel();     //여러 개의 워커 스레드에서 돌림
 }

실행 방식이 3가지(Run, Schedule, ScheduleParallel)가 존재하는데 차이는 주석을 참고.

 

 

 


 

 

랜덤 이동 추가하기


 

이번에는 캡슐 오브젝트를 랜덤한 목표로 이동하도록 만들어보자. 목표에 도달하면 랜덤하게 다른 목표 지점을 만든다.

 

이를 위해 목표 지점을 표현하는 컴포넌트인 TargetPosition 컴포넌트를 만든다. 

 

public struct TargetPosition : IComponentData
{
    public float3 value;
}

public class TargetPositionAuthoring : MonoBehaviour
{
    public Vector3 value;
}

public class TargetPositionBaker : Baker<TargetPositionAuthoring>
{
    public override void Bake(TargetPositionAuthoring authoring)
    {
        AddComponent(new TargetPosition(){value = authoring.value});
    }
}

 

그 다음에는 랜덤 값을 생성해야 하는데, 아쉽게도 UnityEngine의 Random은 ECS에서는 쓰지 못 한다. 그대신 Unity.Mathematics.Random을 사용해야 하는데 여러 엔티티에서 하나의 Random을 공유해야 하므로 싱글턴으로 만들어 줄 것이다. 

 

이를 위해 RandomComponent를 만들어준다. 

 

public struct RandomComponent : IComponentData
{
    public Unity.Mathematics.Random Random;
}

public class RandomAuthoring : MonoBehaviour
{
}

public class RandomBaker : Baker<RandomAuthoring>
{
    public override void Bake(RandomAuthoring authoring)
    {
        AddComponent(new RandomComponent() { Random = new Unity.Mathematics.Random(1)});

    }
}

 

그리고 오브젝트를 하나 생성해 Authoring을 붙여준다.

 

 

아, 컴포넌트가 늘어나고 코드가 복잡해질 것 같으니 연관된 컴포넌트를 하나로 묶어 Aspect로 만들어주자.

 

public readonly partial struct MoveToPositionAspect : IAspect
{
    private readonly Entity _entity;    //Apsect에 Entity는 하나만 선언할 수 있다. (자기 자신을 나타냄)
    private readonly TransformAspect _transformAspect;
    private readonly RefRO<Speed> _speed;
    private readonly RefRW<TargetPosition> _targetPosition;

    //public void Move(?); //_targetPosition으로 엔티티를 이동시킨다.
    //public void CheckReachedTargetPosition(?) 목표 위치에 도달했는지 확인 후, 무작위로 새로운 목표를 만든다.
}

 

RefRO와 RefRW는 각각 Read-Only, Read/Write 레퍼런스를 나타낸다. 새로만든 Aspect에서는 엔티티를 이동시켜주고 새로운 목표를 생성해주는 두 메서드를 구현할 것이다. 

 

 

 


 

 

ISystem으로 이동 구현하기


이번에는 ISystem을 구현한 MovingISystem을 만들어 볼 것이다. 

 

우선, 랜덤한 목표를 생성하지 않고 일단 첫 목표 지점까지 이동만 시켜보자.

 

public partial struct MovingISystem : ISystem
{
    public void OnCreate(ref SystemState state) { }
    public void OnDestroy(ref SystemState state) { }

    public void OnUpdate(ref SystemState state)
    {
        foreach (MoveToPositionAspect moveToPositionAspect in SystemAPI.Query<MoveToPositionAspect>())
        {
            moveToPositionAspect.Move(SystemAPI.Time.DeltaTime);
        }
    }
}

 

실행 중에 Authroing의 값을 바꿔주면 실시간으로 목표가 바뀐다. 

 

 

 

로직을 Job으로 만들어보기

 

이제 이동하는 로직을 Job으로 만들어보자. OnUpdate내의 로직을 빼내 아래와 같이 만든다.

 

public partial struct MoveJob : IJobEntity
{
    public float DeltaTime;
    
    public void Execute(MoveToPositionAspect moveToPositionAspect)
    {
        moveToPositionAspect.Move(DeltaTime);
    }
}

이때, partial이 붙어야 하고, "Execute"라는 이름의 메서드가 있어야 한다.

 

이렇게 Job으로 만들면 여러 System에서 로직을 재사용 할 수 있다.

 

이제 OnUpdate를 다음과 같이 바꿔줄 수 있다.

public void OnUpdate(ref SystemState state)
{
    new MoveJob(){DeltaTime = SystemAPI.Time.DeltaTime}.ScheduleParallel();
}

Job도 Run,Schedule,ScheduleParallel 3가지 방법으로 실행할 수 있다.

 

 

 

 

 

랜덤으로 이동시키기

 

이제 오브젝트에게 랜덤한 목표 지점을 부여해주자. 

 

우선 RandomComponent를 싱글턴으로 가져오기 위해서 아래와 같은 메서드를 사용한다.

var randomComponent = SystemAPI.GetSingletonRW<RandomComponent>();

참고로 RandomComponent는 struct이다... Reference로 받아오지 않으면 매번 Copy가 일어나기 때문에 연속된 랜덤 값들을 불러올 수 없다.

 

아무튼 목표지점에 도달했다면 랜덤한 목표를 생성해내는 로직을 Job으로 만들어주자.

public partial struct CheckReachedTargetPositionJob : IJobEntity
{
    //Job에서 이런거 쓰면 오류 난다..(병렬로 돌아가면 에러 날 수 있으니까) 얘는 Run()으로만 돌릴 거니까 괜찮다고 명시해준다.
    [NativeDisableUnsafePtrRestriction] 
    public RefRW<RandomComponent> RandomComponent;
    
    public void Execute(MoveToPositionAspect moveToPositionAspect)
    {
        moveToPositionAspect.CheckReachedTargetPosition(RandomComponent);
    }
}

여기서 [NativeDisableUnsafePtrRestriction]  어트리뷰트가 뭐냐? Job은 병렬적으로 수행될 수 있기 때문에 하나의 레퍼런스를 공유한다면 문제가 생길 수 있다.

 

만약 병렬적으로 수행되지 않을 예정이라면 저 어트리뷰트를 달아서 '문제 없음 내가 의도한 것임 ㅇㅇ '이라고 알려주는 것이다.

 

그러면 이제 OnUpdate는 다음과 같이 쓸 수 있다.

public void OnUpdate(ref SystemState state)
{
    var randomComponent = SystemAPI.GetSingletonRW<RandomComponent>();
    var jobHandle = new MoveJob() { DeltaTime = SystemAPI.Time.DeltaTime }.ScheduleParallel(state.Dependency);
    
    jobHandle.Complete();   //모든 Job이 돌 때 까지 기다림
    
    new CheckReachedTargetPositionJob(){RandomComponent = randomComponent}.Run();
}

여기서 CheckReachedTargetPosition은 하나의 Random Component를 공유하므로 ScheduleParallel을 쓸 수 없다. 그래서 Run()으로 실행하며 메인 스레드에서만 돌려준다. 

 

그리고 새로운 목표 생성은 병렬적으로 수행되던 모든 이동 작업이 끝나고 실행되어야 하기 때문에 ScheduleParallel(state.Dependency)의 리턴값인 JobHandle을 받아 jobHandle.Complete()로 모든 스레드가 작업을 마칠 때 까지 기다려준다.

 

 

아 참고로 MoveToPositionAspect의 전체 코드는 다음과 같다

 

public readonly partial struct MoveToPositionAspect : IAspect
{
    private readonly Entity _entity;    //Apsect에 Entity는 하나만 선언할 수 있다. (자기 자신을 나타냄)
    private readonly TransformAspect _transformAspect;
    private readonly RefRO<Speed> _speed;
    private readonly RefRW<TargetPosition> _targetPosition;

    public void Move(float deltaTime)
    {
        float3 vector = _targetPosition.ValueRO.value - _transformAspect.LocalPosition;
        float3 direction = 0f;
        
        if (math.any(vector))   //vector 가 (0,0,0)이 아닌지 체크
            direction = math.normalize(vector);

        _transformAspect.LocalPosition += direction * deltaTime * _speed.ValueRO.Value;
    }

    public void CheckReachedTargetPosition(RefRW<RandomComponent> randomComponent)
    {
        float targetRadius = 0.5f;
        if (math.distance(_transformAspect.LocalPosition, _targetPosition.ValueRO.value) < targetRadius)
        {
            _targetPosition.ValueRW.value = GetRandomPosition(randomComponent);
        }
    }

    private float3 GetRandomPosition(RefRW<RandomComponent> randomComponent)
    {
        return new float3(randomComponent.ValueRW.Random.NextFloat(-4.5f, 4.5f), 0,
            randomComponent.ValueRW.Random.NextFloat(-4.5f, 4.5f));
    }
}

 

그리고 만든 System과 Job에 [BurstCompile] 어트리뷰트를 달아주면 버스트 컴파일링도 수행한다. 

[BurstCompile]
public partial struct MovingISystem : ISystem

[BurstCompile]
public partial struct MoveJob : IJobEntity

[BurstCompile]
public partial struct CheckReachedTargetPositionJob : IJobEntity

'게임엔진 > DOTS' 카테고리의 다른 글

Physics 1.0 써보기  (1) 2023.02.10
DOTS 1.0 - 2 (생성, MonoBehavour 연계)  (0) 2023.02.04
DOTS 1.0 나온 기념 세팅법  (2) 2023.01.01
일단 시작3 - Instantiate & Destory  (0) 2022.03.23
DOTS - 일단 시작2  (2) 2022.03.16